widerrufsbelehrung hinzugefügt und in den checkout mit eingebunden. refund ins backend eingebaut.
This commit is contained in:
@@ -23,7 +23,12 @@ use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use App\Notifications\Ops\RefundProcessed;
|
||||
use App\Notifications\Customer\RefundReceipt;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
|
||||
class PurchaseResource extends Resource
|
||||
{
|
||||
@@ -125,6 +130,21 @@ class PurchaseResource extends Resource
|
||||
TextColumn::make('provider_id')
|
||||
->copyable()
|
||||
->toggleable(),
|
||||
TextColumn::make('metadata.consents.legal_version')
|
||||
->label('Legal Version')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('metadata.consents.accepted_terms_at')
|
||||
->label('Terms accepted')
|
||||
->dateTime()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('metadata.consents.accepted_withdrawal_notice_at')
|
||||
->label('Withdrawal notice')
|
||||
->dateTime()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('metadata.consents.digital_content_waiver_at')
|
||||
->label('Waiver (digital)')
|
||||
->dateTime()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('type')
|
||||
@@ -162,10 +182,55 @@ class PurchaseResource extends Resource
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (PackagePurchase $record): bool => ! $record->refunded)
|
||||
->action(function (PackagePurchase $record) {
|
||||
$record->update(['refunded' => true]);
|
||||
// TODO: Call Stripe/Paddle API for actual refund
|
||||
Log::info('Refund processed for purchase ID: '.$record->id);
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Refund reason (optional)')
|
||||
->rows(2),
|
||||
])
|
||||
->action(function (PackagePurchase $record, array $data) {
|
||||
$reason = $data['reason'] ?? null;
|
||||
$refundSuccess = true;
|
||||
$errorMessage = null;
|
||||
|
||||
if ($record->provider === 'paddle' && $record->provider_id) {
|
||||
try {
|
||||
/** @var PaddleTransactionService $paddle */
|
||||
$paddle = App::make(PaddleTransactionService::class);
|
||||
$paddle->refund($record->provider_id, ['reason' => $reason]);
|
||||
} catch (\Throwable $exception) {
|
||||
$refundSuccess = false;
|
||||
$errorMessage = $exception->getMessage();
|
||||
Log::warning('Paddle refund failed', [
|
||||
'purchase_id' => $record->id,
|
||||
'provider_id' => $record->provider_id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = $record->metadata ?? [];
|
||||
$metadata['refund_reason'] = $reason ?: ($metadata['refund_reason'] ?? null);
|
||||
|
||||
$record->update([
|
||||
'refunded' => true,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
Log::info('Refund processed for purchase ID: '.$record->id, [
|
||||
'provider' => $record->provider,
|
||||
'provider_id' => $record->provider_id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
$customerEmail = $record->tenant->contact_email ?? $record->tenant?->user?->email;
|
||||
if ($customerEmail) {
|
||||
Notification::route('mail', $customerEmail)->notify(new RefundReceipt($record, $reason));
|
||||
}
|
||||
|
||||
$opsEmail = config('mail.ops_address');
|
||||
if ($opsEmail) {
|
||||
Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage));
|
||||
}
|
||||
}),
|
||||
])
|
||||
->bulkActions([
|
||||
|
||||
@@ -38,18 +38,10 @@ class LegalController extends BaseController
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
$locale = $request->query('lang', 'de');
|
||||
|
||||
// Support common English aliases as fallbacks
|
||||
$s = strtolower($slug);
|
||||
$aliasMap = [
|
||||
'imprint' => 'impressum',
|
||||
'privacy' => 'datenschutz',
|
||||
'terms' => 'agb',
|
||||
];
|
||||
$resolved = $aliasMap[$s] ?? $s;
|
||||
$slugs = $this->resolveSlugs($slug);
|
||||
|
||||
$page = LegalPage::query()
|
||||
->where('slug', $resolved)
|
||||
->whereIn('slug', $slugs)
|
||||
->where('is_published', true)
|
||||
->orderByDesc('version')
|
||||
->first();
|
||||
@@ -59,7 +51,7 @@ class LegalController extends BaseController
|
||||
'Legal Page Not Found',
|
||||
'The requested legal document does not exist.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['slug' => $resolved]
|
||||
['slug' => $slugs[0]]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,6 +69,28 @@ class LegalController extends BaseController
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
protected function resolveSlugs(string $slug): array
|
||||
{
|
||||
$s = strtolower($slug);
|
||||
$aliasMap = [
|
||||
'imprint' => 'impressum',
|
||||
'privacy' => 'datenschutz',
|
||||
'terms' => 'agb',
|
||||
'withdrawal' => 'widerrufsbelehrung',
|
||||
'cancellation' => 'widerrufsbelehrung',
|
||||
'cancellation-policy' => 'widerrufsbelehrung',
|
||||
'widerruf' => 'widerrufsbelehrung',
|
||||
];
|
||||
|
||||
$canonical = $aliasMap[$s] ?? $s;
|
||||
|
||||
return array_values(array_unique(
|
||||
$canonical === 'widerrufsbelehrung'
|
||||
? ['widerrufsbelehrung', 'widerruf']
|
||||
: [$canonical]
|
||||
));
|
||||
}
|
||||
|
||||
protected function convertMarkdownToHtml(string $markdown): string
|
||||
{
|
||||
return trim((string) $this->markdown->convert($markdown));
|
||||
|
||||
@@ -20,12 +20,12 @@ class LegalPageController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $locale, ?string $slug = null): Response
|
||||
{
|
||||
$resolvedSlug = $this->resolveSlug($slug);
|
||||
$slugCandidates = $this->resolveSlugs($slug);
|
||||
$page = null;
|
||||
|
||||
try {
|
||||
$page = LegalPage::query()
|
||||
->where('slug', $resolvedSlug)
|
||||
->whereIn('slug', $slugCandidates)
|
||||
->where('is_published', true)
|
||||
->orderByDesc('version')
|
||||
->first();
|
||||
@@ -37,7 +37,7 @@ class LegalPageController extends Controller
|
||||
$locale = $request->route('locale', app()->getLocale());
|
||||
|
||||
if (! $page) {
|
||||
$fallback = $this->loadFallbackDocument($resolvedSlug, $locale);
|
||||
$fallback = $this->loadFallbackDocument($slugCandidates, $locale);
|
||||
|
||||
if (! $fallback) {
|
||||
abort(404);
|
||||
@@ -50,7 +50,7 @@ class LegalPageController extends Controller
|
||||
'effectiveFrom' => null,
|
||||
'effectiveFromLabel' => null,
|
||||
'versionLabel' => null,
|
||||
'slug' => $resolvedSlug,
|
||||
'slug' => $slugCandidates[0],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ class LegalPageController extends Controller
|
||||
?? $page->title[$page->locale_fallback]
|
||||
?? $page->title['de']
|
||||
?? $page->title['en']
|
||||
?? Str::title($resolvedSlug);
|
||||
?? Str::title($slugCandidates[0]);
|
||||
$title = Str::ucfirst($title);
|
||||
|
||||
$bodyMarkdown = $page->body_markdown[$locale]
|
||||
?? $page->body_markdown[$page->locale_fallback]
|
||||
@@ -76,11 +77,11 @@ class LegalPageController extends Controller
|
||||
? __('legal.effective_from', ['date' => $effectiveFrom->translatedFormat('d. F Y')])
|
||||
: null,
|
||||
'versionLabel' => __('legal.version', ['version' => $page->version]),
|
||||
'slug' => $resolvedSlug,
|
||||
'slug' => $slugCandidates[0],
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveSlug(?string $slug): string
|
||||
private function resolveSlugs(?string $slug): array
|
||||
{
|
||||
$slug = strtolower($slug ?? '');
|
||||
|
||||
@@ -88,9 +89,20 @@ class LegalPageController extends Controller
|
||||
'imprint' => 'impressum',
|
||||
'privacy' => 'datenschutz',
|
||||
'terms' => 'agb',
|
||||
'withdrawal' => 'widerrufsbelehrung',
|
||||
'cancellation' => 'widerrufsbelehrung',
|
||||
'cancellation-policy' => 'widerrufsbelehrung',
|
||||
'widerruf' => 'widerrufsbelehrung',
|
||||
];
|
||||
|
||||
return $aliases[$slug] ?? $slug ?: 'impressum';
|
||||
$canonical = $aliases[$slug] ?? $slug ?: 'impressum';
|
||||
|
||||
// Support both slugs for withdrawal in case DB uses a shorter slug
|
||||
$fallbacks = $canonical === 'widerrufsbelehrung'
|
||||
? ['widerrufsbelehrung', 'widerruf']
|
||||
: [$canonical];
|
||||
|
||||
return array_values(array_unique($fallbacks));
|
||||
}
|
||||
|
||||
private function convertMarkdownToHtml(string $markdown): string
|
||||
@@ -111,7 +123,7 @@ class LegalPageController extends Controller
|
||||
return trim((string) $converter->convert($markdown));
|
||||
}
|
||||
|
||||
private function loadFallbackDocument(string $slug, string $locale): ?array
|
||||
private function loadFallbackDocument(array $slugCandidates, string $locale): ?array
|
||||
{
|
||||
$candidates = array_unique([
|
||||
strtolower($locale),
|
||||
@@ -120,20 +132,22 @@ class LegalPageController extends Controller
|
||||
'en',
|
||||
]);
|
||||
|
||||
foreach ($candidates as $candidateLocale) {
|
||||
$path = base_path("docs/legal/{$slug}-{$candidateLocale}.md");
|
||||
foreach ($slugCandidates as $slug) {
|
||||
foreach ($candidates as $candidateLocale) {
|
||||
$path = base_path("docs/legal/{$slug}-{$candidateLocale}.md");
|
||||
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$markdown = (string) file_get_contents($path);
|
||||
$title = $this->extractTitleFromMarkdown($markdown) ?? Str::title($slug);
|
||||
|
||||
return [
|
||||
'markdown' => $markdown,
|
||||
'title' => $title,
|
||||
];
|
||||
}
|
||||
|
||||
$markdown = (string) file_get_contents($path);
|
||||
$title = $this->extractTitleFromMarkdown($markdown) ?? Str::title($slug);
|
||||
|
||||
return [
|
||||
'markdown' => $markdown,
|
||||
'title' => $title,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -132,6 +132,13 @@ class MarketingController extends Controller
|
||||
Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]);
|
||||
$package = Package::findOrFail($packageId);
|
||||
|
||||
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
|
||||
|
||||
$request->validate([
|
||||
'accepted_terms' => ['sometimes', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$couponCode = $this->rememberCouponFromRequest($request, $package);
|
||||
|
||||
if (! Auth::check()) {
|
||||
@@ -194,6 +201,16 @@ class MarketingController extends Controller
|
||||
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $request->boolean('accepted_terms') ? $now : null,
|
||||
'accepted_privacy_at' => $request->boolean('accepted_terms') ? $now : null,
|
||||
'accepted_withdrawal_notice_at' => $request->boolean('accepted_terms') ? $now : null,
|
||||
'digital_content_waiver_at' => $requiresWaiver && $request->boolean('accepted_waiver') ? $now : null,
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
])->save();
|
||||
|
||||
$appliedDiscountId = null;
|
||||
|
||||
if ($couponCode) {
|
||||
@@ -219,6 +236,9 @@ class MarketingController extends Controller
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'coupon_code' => $couponCode,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => (bool) $session->accepted_terms_at,
|
||||
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
|
||||
],
|
||||
'discount_id' => $appliedDiscountId,
|
||||
]);
|
||||
@@ -628,4 +648,9 @@ class MarketingController extends Controller
|
||||
'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
{
|
||||
return config('app.legal_version', now()->toDateString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ class PaddleCheckoutController extends Controller
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
@@ -44,12 +46,30 @@ class PaddleCheckoutController extends Controller
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
|
||||
|
||||
if ($requiresWaiver && ! $request->boolean('accepted_waiver')) {
|
||||
throw ValidationException::withMessages([
|
||||
'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.',
|
||||
]);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => $requiresWaiver ? $now : null,
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
])->save();
|
||||
|
||||
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
||||
$discountId = null;
|
||||
|
||||
@@ -80,6 +100,9 @@ class PaddleCheckoutController extends Controller
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
'checkout_session_id' => (string) $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => '1',
|
||||
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver') ? '1' : '0',
|
||||
],
|
||||
'customer' => array_filter([
|
||||
'email' => $user->email,
|
||||
@@ -94,6 +117,9 @@ class PaddleCheckoutController extends Controller
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'coupon_code' => $couponCode ?: null,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver'),
|
||||
],
|
||||
'discount_id' => $discountId,
|
||||
]);
|
||||
@@ -109,4 +135,9 @@ class PaddleCheckoutController extends Controller
|
||||
|
||||
return response()->json($checkout);
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
{
|
||||
return config('app.legal_version', now()->toDateString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ class EventAddonCheckoutRequest extends FormRequest
|
||||
'quantity' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'cancel_url' => ['nullable', 'url'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
// Add-ons werden sofort erbracht -> Widerruf erlischt nur mit ausdrücklicher Zustimmung
|
||||
'accepted_waiver' => ['required', 'boolean', 'accepted'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ class CheckoutSession extends Model
|
||||
'discount_breakdown' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'accepted_terms_at' => 'datetime',
|
||||
'accepted_privacy_at' => 'datetime',
|
||||
'accepted_withdrawal_notice_at' => 'datetime',
|
||||
'digital_content_waiver_at' => 'datetime',
|
||||
'amount_subtotal' => 'decimal:2',
|
||||
'amount_total' => 'decimal:2',
|
||||
'amount_discount' => 'decimal:2',
|
||||
|
||||
@@ -57,6 +57,10 @@ class Package extends Model
|
||||
'paddle_snapshot' => 'array',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'activates_immediately',
|
||||
];
|
||||
|
||||
protected function features(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -140,4 +144,10 @@ class Package extends Model
|
||||
'max_events_per_year' => $this->max_events_per_year,
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivatesImmediatelyAttribute(): bool
|
||||
{
|
||||
// Default: Pakete werden nach Kauf sofort freigeschaltet (digitale Dienstleistung).
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Notifications/Customer/RefundReceipt.php
Normal file
46
app/Notifications/Customer/RefundReceipt.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Customer;
|
||||
|
||||
use App\Models\PackagePurchase;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class RefundReceipt extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly PackagePurchase $purchase,
|
||||
private readonly ?string $reason = null,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenant = $this->purchase->tenant;
|
||||
$package = $this->purchase->package;
|
||||
$amount = number_format((float) $this->purchase->price, 2);
|
||||
|
||||
$mail = (new MailMessage)
|
||||
->subject(__('emails.refund.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
|
||||
->greeting(__('emails.refund.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')]))
|
||||
->line(__('emails.refund.body', [
|
||||
'amount' => $amount,
|
||||
'currency' => '€',
|
||||
'provider_id' => $this->purchase->provider_id ?? '—',
|
||||
]));
|
||||
|
||||
if ($this->reason) {
|
||||
$mail->line(__('emails.refund.reason', ['reason' => $this->reason]));
|
||||
}
|
||||
|
||||
return $mail->line(__('emails.refund.footer'));
|
||||
}
|
||||
}
|
||||
43
app/Notifications/Ops/AddonPurchased.php
Normal file
43
app/Notifications/Ops/AddonPurchased.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Ops;
|
||||
|
||||
use App\Models\EventPackageAddon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class AddonPurchased extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly EventPackageAddon $addon) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->addon->event;
|
||||
$tenant = $event?->tenant;
|
||||
$label = $this->addon->metadata['label'] ?? $this->addon->addon_key;
|
||||
$amount = $this->addon->amount ? number_format((float) $this->addon->amount, 2) : null;
|
||||
$currency = $this->addon->currency ?? 'EUR';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.ops.addon.subject', ['addon' => $label]))
|
||||
->greeting(__('emails.ops.addon.greeting'))
|
||||
->line(__('emails.ops.addon.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')]))
|
||||
->line(__('emails.ops.addon.event', ['event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback')]))
|
||||
->line(__('emails.ops.addon.addon', ['addon' => $label, 'quantity' => $this->addon->quantity]))
|
||||
->when($amount, fn ($mail) => $mail->line(__('emails.ops.addon.amount', ['amount' => $amount, 'currency' => $currency])))
|
||||
->line(__('emails.ops.addon.provider', [
|
||||
'checkout' => $this->addon->checkout_id ?? '—',
|
||||
'transaction' => $this->addon->transaction_id ?? '—',
|
||||
]))
|
||||
->line(__('emails.ops.addon.footer'));
|
||||
}
|
||||
}
|
||||
43
app/Notifications/Ops/PurchaseCreated.php
Normal file
43
app/Notifications/Ops/PurchaseCreated.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Ops;
|
||||
|
||||
use App\Models\PackagePurchase;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class PurchaseCreated extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly PackagePurchase $purchase) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenant = $this->purchase->tenant;
|
||||
$package = $this->purchase->package;
|
||||
$amount = number_format((float) $this->purchase->price, 2);
|
||||
$consents = $this->purchase->metadata['consents'] ?? [];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.ops.purchase.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
|
||||
->greeting(__('emails.ops.purchase.greeting'))
|
||||
->line(__('emails.ops.purchase.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')]))
|
||||
->line(__('emails.ops.purchase.package', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
|
||||
->line(__('emails.ops.purchase.amount', ['amount' => $amount, 'currency' => '€']))
|
||||
->line(__('emails.ops.purchase.provider', ['provider' => $this->purchase->provider, 'id' => $this->purchase->provider_id ?? '—']))
|
||||
->line(__('emails.ops.purchase.consents', [
|
||||
'legal' => $consents['legal_version'] ?? 'n/a',
|
||||
'terms' => $consents['accepted_terms_at'] ?? 'n/a',
|
||||
'waiver' => $consents['digital_content_waiver_at'] ?? 'n/a',
|
||||
]))
|
||||
->line(__('emails.ops.purchase.footer'));
|
||||
}
|
||||
}
|
||||
52
app/Notifications/Ops/RefundProcessed.php
Normal file
52
app/Notifications/Ops/RefundProcessed.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Ops;
|
||||
|
||||
use App\Models\PackagePurchase;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class RefundProcessed extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly PackagePurchase $purchase,
|
||||
private readonly bool $success,
|
||||
private readonly ?string $reason = null,
|
||||
private readonly ?string $error = null,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$tenant = $this->purchase->tenant;
|
||||
$package = $this->purchase->package;
|
||||
$amount = number_format((float) $this->purchase->price, 2);
|
||||
|
||||
$mail = (new MailMessage)
|
||||
->subject(__('emails.ops.refund.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
|
||||
->greeting(__('emails.ops.refund.greeting'))
|
||||
->line(__('emails.ops.refund.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')]))
|
||||
->line(__('emails.ops.refund.package', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')]))
|
||||
->line(__('emails.ops.refund.amount', ['amount' => $amount, 'currency' => '€']))
|
||||
->line(__('emails.ops.refund.provider', ['provider' => $this->purchase->provider, 'id' => $this->purchase->provider_id ?? '—']))
|
||||
->line($this->success ? __('emails.ops.refund.status_success') : __('emails.ops.refund.status_failed'));
|
||||
|
||||
if ($this->reason) {
|
||||
$mail->line(__('emails.ops.refund.reason', ['reason' => $this->reason]));
|
||||
}
|
||||
|
||||
if ($this->error) {
|
||||
$mail->line(__('emails.ops.refund.error', ['error' => $this->error]));
|
||||
}
|
||||
|
||||
return $mail->line(__('emails.ops.refund.footer'));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ class EventAddonCheckoutService
|
||||
{
|
||||
$addonKey = $payload['addon_key'] ?? null;
|
||||
$quantity = max(1, (int) ($payload['quantity'] ?? 1));
|
||||
$acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false);
|
||||
$acceptedTerms = (bool) ($payload['accepted_terms'] ?? false);
|
||||
|
||||
if (! $addonKey || ! $this->catalog->find($addonKey)) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -60,6 +62,9 @@ class EventAddonCheckoutService
|
||||
'addon_key' => $addonKey,
|
||||
'addon_intent' => $addonIntent,
|
||||
'quantity' => $quantity,
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
'accepted_terms' => $acceptedTerms ? '1' : '0',
|
||||
'accepted_waiver' => $acceptedWaiver ? '1' : '0',
|
||||
];
|
||||
|
||||
$requestPayload = array_filter([
|
||||
@@ -97,6 +102,11 @@ class EventAddonCheckoutService
|
||||
'metadata' => array_merge($metadata, [
|
||||
'increments' => $increments,
|
||||
'provider_payload' => $response,
|
||||
'consents' => [
|
||||
'legal_version' => $metadata['legal_version'],
|
||||
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
|
||||
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
|
||||
],
|
||||
]),
|
||||
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
|
||||
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
|
||||
@@ -109,4 +119,9 @@ class EventAddonCheckoutService
|
||||
'id' => $checkoutId,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
{
|
||||
return config('app.legal_version', now()->toDateString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services\Addons;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Notifications\Addons\AddonPurchaseReceipt;
|
||||
use App\Notifications\Ops\AddonPurchased;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -98,6 +99,11 @@ class EventAddonWebhookService
|
||||
if ($tenant) {
|
||||
Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email])
|
||||
->notify(new AddonPurchaseReceipt($addon));
|
||||
|
||||
$opsEmail = config('mail.ops_address');
|
||||
if ($opsEmail) {
|
||||
Notification::route('mail', $opsEmail)->notify(new AddonPurchased($addon));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Notifications\Ops\PurchaseCreated;
|
||||
|
||||
class CheckoutAssignmentService
|
||||
{
|
||||
@@ -48,6 +50,14 @@ class CheckoutAssignmentService
|
||||
}
|
||||
|
||||
$metadata = $session->provider_metadata ?? [];
|
||||
$consents = [
|
||||
'accepted_terms_at' => optional($session->accepted_terms_at)->toIso8601String(),
|
||||
'accepted_privacy_at' => optional($session->accepted_privacy_at)->toIso8601String(),
|
||||
'accepted_withdrawal_notice_at' => optional($session->accepted_withdrawal_notice_at)->toIso8601String(),
|
||||
'digital_content_waiver_at' => optional($session->digital_content_waiver_at)->toIso8601String(),
|
||||
'legal_version' => $session->legal_version,
|
||||
];
|
||||
$consents = array_filter($consents);
|
||||
|
||||
$providerReference = $options['provider_reference']
|
||||
?? $metadata['paddle_transaction_id'] ?? null
|
||||
@@ -72,7 +82,11 @@ class CheckoutAssignmentService
|
||||
'price' => $session->amount_total,
|
||||
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
'metadata' => $options['payload'] ?? null,
|
||||
'metadata' => array_filter([
|
||||
'payload' => $options['payload'] ?? null,
|
||||
'checkout_session_id' => $session->id,
|
||||
'consents' => $consents ?: null,
|
||||
]),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -104,6 +118,11 @@ class CheckoutAssignmentService
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
->queue(new PurchaseConfirmation($purchase));
|
||||
|
||||
$opsEmail = config('mail.ops_address');
|
||||
if ($opsEmail) {
|
||||
Notification::route('mail', $opsEmail)->notify(new PurchaseCreated($purchase));
|
||||
}
|
||||
}
|
||||
|
||||
AbandonedCheckout::query()
|
||||
|
||||
@@ -33,6 +33,21 @@ class PaddleTransactionService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a refund for a Paddle transaction.
|
||||
*
|
||||
* @param array{reason?: string|null} $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function refund(string $transactionId, array $options = []): array
|
||||
{
|
||||
$payload = array_filter([
|
||||
'reason' => $options['reason'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
return $this->client->post("/transactions/{$transactionId}/refunds", $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $transaction
|
||||
* @return array<string, mixed>
|
||||
|
||||
@@ -60,6 +60,7 @@ trait PresentsPackages
|
||||
'max_events_per_year' => $package->max_events_per_year,
|
||||
'watermark_allowed' => (bool) $package->watermark_allowed,
|
||||
'branding_allowed' => (bool) $package->branding_allowed,
|
||||
'activates_immediately' => (bool) ($package->activates_immediately ?? true),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user