widerrufsbelehrung hinzugefügt und in den checkout mit eingebunden. refund ins backend eingebaut.

This commit is contained in:
Codex Agent
2025-12-07 11:57:05 +01:00
parent e092f72475
commit 1d3d49e05a
44 changed files with 1143 additions and 71 deletions

View File

@@ -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([

View File

@@ -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));

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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',

View File

@@ -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;
}
}

View 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'));
}
}

View 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'));
}
}

View 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'));
}
}

View 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'));
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}
});

View File

@@ -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()

View File

@@ -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>

View File

@@ -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),
];
}