diff --git a/app/Http/Controllers/WithdrawalController.php b/app/Http/Controllers/WithdrawalController.php new file mode 100644 index 0000000..44c9b04 --- /dev/null +++ b/app/Http/Controllers/WithdrawalController.php @@ -0,0 +1,258 @@ +user(); + $tenant = $user?->tenant; + + if (! $tenant) { + abort(404); + } + + return Inertia::render('marketing/WithdrawalConfirm', [ + 'eligiblePurchases' => $this->eligiblePurchases($tenant, $locale), + 'windowDays' => self::WITHDRAWAL_DAYS, + ]); + } + + public function confirm( + WithdrawalConfirmRequest $request, + PaddleTransactionService $transactions, + string $locale + ): RedirectResponse { + $user = $request->user(); + $tenant = $user?->tenant; + + if (! $tenant) { + abort(404); + } + + $purchaseId = $request->integer('purchase_id'); + $purchase = PackagePurchase::query() + ->with('package') + ->where('tenant_id', $tenant->id) + ->findOrFail($purchaseId); + + $eligibility = $this->evaluateEligibility($purchase, $tenant); + + if (! $eligibility['eligible']) { + return redirect() + ->back() + ->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale)); + } + + $transactionId = $this->resolveTransactionId($purchase); + + if (! $transactionId) { + Log::warning('Withdrawal missing Paddle transaction reference.', [ + 'purchase_id' => $purchase->id, + 'provider' => $purchase->provider, + ]); + + return redirect() + ->back() + ->with('error', __('marketing.withdrawal.errors.missing_transaction', [], $locale)); + } + + try { + $transactions->refund($transactionId, ['reason' => 'withdrawal']); + } catch (\Throwable $exception) { + Log::warning('Withdrawal refund failed', [ + 'purchase_id' => $purchase->id, + 'transaction_id' => $transactionId, + 'error' => $exception->getMessage(), + ]); + + return redirect() + ->back() + ->with('error', __('marketing.withdrawal.errors.refund_failed', [], $locale)); + } + + $confirmedAt = now(); + $metadata = $purchase->metadata ?? []; + $withdrawalMeta = is_array($metadata['withdrawal'] ?? null) ? $metadata['withdrawal'] : []; + + $withdrawalMeta = array_merge($withdrawalMeta, [ + 'confirmed_at' => $confirmedAt->toIso8601String(), + 'confirmed_by' => $user?->id, + 'transaction_id' => $transactionId, + ]); + + $metadata['withdrawal'] = $withdrawalMeta; + + $purchase->forceFill([ + 'provider_id' => $transactionId, + 'refunded' => true, + 'metadata' => $metadata, + ])->save(); + + $this->deactivateTenantPackage($tenant, $purchase); + + $recipient = $tenant->contact_email ?? $user?->email; + if ($recipient) { + Notification::route('mail', $recipient) + ->notify(new WithdrawalConfirmed($purchase, $confirmedAt)); + } + + return redirect() + ->back() + ->with('success', __('marketing.withdrawal.success', [], $locale)); + } + + /** + * @return array> + */ + private function eligiblePurchases(Tenant $tenant, string $locale): array + { + $purchases = PackagePurchase::query() + ->with('package') + ->where('tenant_id', $tenant->id) + ->where('type', 'endcustomer_event') + ->where('provider', 'paddle') + ->where('refunded', false) + ->orderByDesc('purchased_at') + ->orderByDesc('id') + ->get(); + + return $purchases + ->filter(fn (PackagePurchase $purchase) => $this->evaluateEligibility($purchase, $tenant)['eligible']) + ->map(fn (PackagePurchase $purchase) => $this->mapPurchaseForView($purchase, $locale)) + ->values() + ->all(); + } + + /** + * @return array{eligible: bool, reasons: array} + */ + private function evaluateEligibility(PackagePurchase $purchase, Tenant $tenant): array + { + $reasons = []; + + if ($purchase->type !== 'endcustomer_event') { + $reasons[] = 'type'; + } + + if ($purchase->provider !== 'paddle') { + $reasons[] = 'provider'; + } + + if ($purchase->refunded) { + $reasons[] = 'refunded'; + } + + if (! $this->resolveTransactionId($purchase)) { + $reasons[] = 'missing_reference'; + } + + if (! $this->isWithinWindow($purchase)) { + $reasons[] = 'expired'; + } + + if ($this->hasAttachedEvent($purchase, $tenant)) { + $reasons[] = 'event_used'; + } + + return [ + 'eligible' => $reasons === [], + 'reasons' => $reasons, + ]; + } + + private function isWithinWindow(PackagePurchase $purchase): bool + { + $purchasedAt = $purchase->purchased_at; + + if (! $purchasedAt) { + return false; + } + + return $purchasedAt->greaterThanOrEqualTo(now()->subDays(self::WITHDRAWAL_DAYS)); + } + + private function hasAttachedEvent(PackagePurchase $purchase, Tenant $tenant): bool + { + if (! $purchase->purchased_at) { + return true; + } + + return EventPackage::query() + ->where('package_id', $purchase->package_id) + ->where('purchased_at', '>=', $purchase->purchased_at) + ->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->id)) + ->exists(); + } + + /** + * @return array + */ + private function mapPurchaseForView(PackagePurchase $purchase, string $locale): array + { + $package = $purchase->package; + $currency = data_get($purchase->metadata, 'currency', 'EUR'); + $purchasedAt = $purchase->purchased_at; + $expiresAt = $purchasedAt?->copy()->addDays(self::WITHDRAWAL_DAYS); + $packageName = $package?->getNameForLocale($locale) + ?? $package?->name + ?? __('emails.package_limits.package_fallback', [], $locale); + + return [ + 'id' => $purchase->id, + 'package_name' => $packageName, + 'purchased_at' => $purchasedAt?->toIso8601String(), + 'expires_at' => $expiresAt?->toIso8601String(), + 'price' => $purchase->price, + 'currency' => $currency, + ]; + } + + private function resolveTransactionId(PackagePurchase $purchase): ?string + { + if ($purchase->provider === 'paddle' && $purchase->provider_id) { + return (string) $purchase->provider_id; + } + + return data_get($purchase->metadata, 'paddle_transaction_id'); + } + + private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void + { + $tenant->tenantPackages() + ->where('package_id', $purchase->package_id) + ->where('active', true) + ->update([ + 'active' => false, + 'expires_at' => now(), + ]); + + $hasActive = $tenant->tenantPackages() + ->where('active', true) + ->whereHas('package', fn ($query) => $query->where('type', 'endcustomer')) + ->exists(); + + if (! $hasActive) { + $tenant->forceFill([ + 'subscription_status' => 'free', + 'subscription_expires_at' => null, + ])->save(); + } + } +} diff --git a/app/Http/Requests/Marketing/WithdrawalConfirmRequest.php b/app/Http/Requests/Marketing/WithdrawalConfirmRequest.php new file mode 100644 index 0000000..73fa72c --- /dev/null +++ b/app/Http/Requests/Marketing/WithdrawalConfirmRequest.php @@ -0,0 +1,28 @@ +user(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'purchase_id' => ['required', 'integer', 'exists:package_purchases,id'], + ]; + } +} diff --git a/app/Notifications/Customer/WithdrawalConfirmed.php b/app/Notifications/Customer/WithdrawalConfirmed.php new file mode 100644 index 0000000..89eda76 --- /dev/null +++ b/app/Notifications/Customer/WithdrawalConfirmed.php @@ -0,0 +1,61 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $tenant = $this->purchase->tenant; + $package = $this->purchase->package; + $amount = number_format((float) $this->purchase->price, 2); + $currency = data_get($this->purchase->metadata, 'currency', 'EUR'); + $locale = app()->getLocale(); + $packageName = $package?->getNameForLocale($locale) + ?? $package?->name + ?? __('emails.package_limits.package_fallback', [], $locale); + $subject = __('emails.withdrawal_confirmation.subject', ['package' => $packageName]); + $greeting = __('emails.withdrawal_confirmation.greeting', [ + 'name' => $tenant?->name ?? __('emails.package_limits.team_fallback'), + ]); + + return (new MailMessage) + ->subject($subject) + ->view('emails.withdrawal-confirmation', [ + 'subject' => $subject, + 'greeting' => $greeting, + 'packageName' => $packageName, + 'amount' => $amount, + 'currency' => $currency, + 'providerId' => $this->purchase->provider_id ?? '—', + 'confirmedAt' => $this->confirmedAt, + ]); + } +} diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index fd84384..577fbc3 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -1037,6 +1037,24 @@ }, "too_many_attempts": "Zu viele Versuche. Bitte kurz warten und erneut probieren." }, + "withdrawal": { + "cta_title": "Widerruf starten", + "cta_body": "Du kannst dein Paket widerrufen, solange noch kein Event damit erstellt wurde und der Kauf weniger als 14 Tage zurückliegt.", + "cta_button": "Widerruf erklären", + "title": "Widerruf bestätigen", + "subtitle": "Widerruf innerhalb von {{days}} Tagen nach Kauf, solange kein Event mit dem Paket gestartet wurde.", + "empty_title": "Kein widerrufbares Paket gefunden", + "empty_body": "Für deine aktuellen Käufe liegt entweder kein 14-Tage-Widerrufsrecht mehr vor oder das Paket wurde bereits für ein Event verwendet.", + "empty_cta": "Pakete ansehen", + "selection_title": "Paket auswählen", + "selection_body": "Wähle das Paket aus, das du widerrufen möchtest. Der Widerruf ist erst mit der Bestätigung rechts wirksam.", + "purchase_date": "Gekauft am {{date}}", + "expires_at": "Widerruf möglich bis {{date}}", + "confirm_title": "Widerruf offiziell erklären", + "confirm_body": "Mit dem Klick wird der Widerruf dokumentiert und die Rückerstattung gestartet. Du erhältst eine E-Mail mit Datum und Uhrzeit.", + "confirm_button": "Widerruf jetzt bestätigen", + "confirm_processing": "Widerruf wird übermittelt …" + }, "not_found": { "title": "Seite nicht gefunden", "subtitle": "Ups! Diese Seite existiert nicht mehr.", diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 5b4183c..ba5aa36 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -1030,6 +1030,24 @@ }, "too_many_attempts": "Too many attempts. Please wait a moment and try again." }, + "withdrawal": { + "cta_title": "Start withdrawal", + "cta_body": "You can withdraw your package if no event has been created with it and the purchase is less than 14 days old.", + "cta_button": "Declare withdrawal", + "title": "Confirm withdrawal", + "subtitle": "Withdraw within {{days}} days of purchase as long as no event has been started with the package.", + "empty_title": "No eligible package found", + "empty_body": "Your current purchases are either outside the 14-day window or the package has already been used for an event.", + "empty_cta": "View packages", + "selection_title": "Select a package", + "selection_body": "Choose the package you want to withdraw. The withdrawal only takes effect after confirmation.", + "purchase_date": "Purchased on {{date}}", + "expires_at": "Withdrawal possible until {{date}}", + "confirm_title": "Officially declare withdrawal", + "confirm_body": "By clicking, the withdrawal is documented and the refund is started. You will receive an email with date and time.", + "confirm_button": "Confirm withdrawal now", + "confirm_processing": "Submitting withdrawal …" + }, "not_found": { "title": "Page not found", "subtitle": "Oops! This page is nowhere to be found.", diff --git a/resources/js/lib/localizedPath.ts b/resources/js/lib/localizedPath.ts index 914b0d5..8aff9fd 100644 --- a/resources/js/lib/localizedPath.ts +++ b/resources/js/lib/localizedPath.ts @@ -17,6 +17,8 @@ export const defaultLocaleRewrites: LocaleRewriteMap = { '/occasions/birthday': { de: '/anlaesse/geburtstag' }, '/occasions/corporate-event': { de: '/anlaesse/firmenevent' }, '/occasions/confirmation': { de: '/anlaesse/konfirmation' }, + '/widerruf': { en: '/withdrawal/confirm' }, + '/withdrawal/confirm': { de: '/widerruf' }, }; const sanitizePath = (input: string): string => { diff --git a/resources/js/pages/legal/Show.tsx b/resources/js/pages/legal/Show.tsx index 4f86de8..665f39f 100644 --- a/resources/js/pages/legal/Show.tsx +++ b/resources/js/pages/legal/Show.tsx @@ -1,5 +1,8 @@ import React from 'react'; +import { Link, usePage } from '@inertiajs/react'; +import { useTranslation } from 'react-i18next'; import MarketingLayout from '@/layouts/mainWebsite'; +import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; type LegalShowProps = { seoTitle: string; @@ -12,7 +15,11 @@ type LegalShowProps = { }; const LegalShow: React.FC = (props) => { - const { seoTitle, title, content, effectiveFromLabel, versionLabel } = props; + const { seoTitle, title, content, effectiveFromLabel, versionLabel, slug } = props; + const { t } = useTranslation('marketing'); + const { localizedPath } = useLocalizedRoutes(); + const { auth } = usePage<{ auth?: { user?: { id?: number } | null } }>().props; + const showWithdrawalAction = slug === 'widerrufsbelehrung' && Boolean(auth?.user); return ( @@ -33,6 +40,23 @@ const LegalShow: React.FC = (props) => { )} + {showWithdrawalAction && ( +
+

+ {t('withdrawal.cta_title')} +

+

+ {t('withdrawal.cta_body')} +

+ + {t('withdrawal.cta_button')} + +
+ )} +
= ({ eligiblePurchases, windowDays }) => { + const { t, i18n } = useTranslation('marketing'); + const { localizedPath } = useLocalizedRoutes(); + const { flash } = usePage<{ flash?: { success?: string; error?: string } }>().props; + const initialPurchaseId = eligiblePurchases[0]?.id ?? null; + const { data, setData, post, processing, errors } = useForm<{ purchase_id: number | null }>({ + purchase_id: initialPurchaseId, + }); + + const formatDate = (value: string) => new Date(value).toLocaleDateString(i18n.language); + const formatDateTime = (value: string) => new Date(value).toLocaleString(i18n.language); + const formattedPurchases = useMemo( + () => eligiblePurchases.map((purchase) => ({ + ...purchase, + purchasedLabel: formatDateTime(purchase.purchased_at), + expiresLabel: formatDate(purchase.expires_at), + })), + [eligiblePurchases, i18n.language], + ); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + post(localizedPath('/widerruf'), { preserveScroll: true }); + }; + + const hasEligible = formattedPurchases.length > 0; + + return ( + + +
+
+
+

+ Fotospiel App +

+

+ {t('withdrawal.title')} +

+

+ {t('withdrawal.subtitle', { days: windowDays })} +

+
+ + {flash?.success && ( +
+ {flash.success} +
+ )} + + {flash?.error && ( +
+ {flash.error} +
+ )} + + {!hasEligible ? ( +
+

+ {t('withdrawal.empty_title')} +

+

+ {t('withdrawal.empty_body')} +

+ + {t('withdrawal.empty_cta')} + +
+ ) : ( +
+
+

+ {t('withdrawal.selection_title')} +

+

+ {t('withdrawal.selection_body')} +

+ +
+ {formattedPurchases.map((purchase) => ( + + ))} +
+ + {errors.purchase_id && ( +

+ {errors.purchase_id} +

+ )} +
+ +
+
+

+ {t('withdrawal.confirm_title')} +

+

+ {t('withdrawal.confirm_body')} +

+
+ +
+
+ )} +
+
+
+ ); +}; + +WithdrawalConfirm.layout = (page: React.ReactNode) => page; + +export default WithdrawalConfirm; diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index f24af6c..6a24b5b 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -279,6 +279,19 @@ return [ 'reason' => 'Grund: :reason', 'footer' => 'Die Rückerstattung wird vom Zahlungsanbieter verarbeitet und kann je nach Bank einige Tage dauern.', ], + 'withdrawal_confirmation' => [ + 'subject' => 'Widerruf bestätigt: :package', + 'preheader' => 'Dein Widerruf für :package wurde bestätigt.', + 'subtitle' => 'Widerruf für :package', + 'greeting' => 'Hallo :name,', + 'body' => 'Wir haben deinen Widerruf für :package dokumentiert und die Rückerstattung angestoßen.', + 'package_label' => 'Paket', + 'amount_label' => 'Betrag', + 'transaction_label' => 'Zahlungs-ID', + 'confirmed_label' => 'Bestätigt am', + 'processing_hint' => 'Die Rückerstattung wird vom Zahlungsanbieter verarbeitet und kann je nach Bank einige Tage dauern.', + 'footer' => 'Wenn du Fragen hast, antworte einfach auf diese E-Mail.', + ], 'ops' => [ 'purchase' => [ diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index b6fd4d4..d6c27f7 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -301,4 +301,12 @@ return [ ], 'too_many_attempts' => 'Zu viele Versuche. Bitte kurz warten und erneut probieren.', ], + 'withdrawal' => [ + 'success' => 'Dein Widerruf wurde bestätigt. Eine Bestätigung ist per E-Mail unterwegs.', + 'errors' => [ + 'not_eligible' => 'Dieses Paket kann aktuell nicht widerrufen werden.', + 'missing_transaction' => 'Für diesen Kauf fehlt eine gültige Zahlungsreferenz.', + 'refund_failed' => 'Die Rückerstattung konnte nicht gestartet werden. Bitte kontaktiere den Support.', + ], + ], ]; diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index 03c99a7..0c68261 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -253,6 +253,19 @@ return [ 'reason' => 'Reason: :reason', 'footer' => 'The refund is processed by the payment provider and may take a few days depending on your bank.', ], + 'withdrawal_confirmation' => [ + 'subject' => 'Withdrawal confirmed: :package', + 'preheader' => 'Your withdrawal for :package has been confirmed.', + 'subtitle' => 'Withdrawal for :package', + 'greeting' => 'Hi :name,', + 'body' => 'We have documented your withdrawal for :package and started the refund.', + 'package_label' => 'Package', + 'amount_label' => 'Amount', + 'transaction_label' => 'Payment ID', + 'confirmed_label' => 'Confirmed on', + 'processing_hint' => 'The refund is processed by the payment provider and may take a few days depending on your bank.', + 'footer' => 'If you have any questions, just reply to this email.', + ], 'ops' => [ 'purchase' => [ diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index 409a7d2..24a3ff5 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -301,4 +301,12 @@ return [ ], 'too_many_attempts' => 'Too many attempts. Please wait a moment and try again.', ], + 'withdrawal' => [ + 'success' => 'Your withdrawal was confirmed. A confirmation email is on the way.', + 'errors' => [ + 'not_eligible' => 'This package is currently not eligible for withdrawal.', + 'missing_transaction' => 'This purchase is missing a valid payment reference.', + 'refund_failed' => 'We could not start the refund. Please contact support.', + ], + ], ]; diff --git a/resources/views/emails/withdrawal-confirmation.blade.php b/resources/views/emails/withdrawal-confirmation.blade.php new file mode 100644 index 0000000..b1b54e9 --- /dev/null +++ b/resources/views/emails/withdrawal-confirmation.blade.php @@ -0,0 +1,39 @@ +@extends('emails.partials.layout') + +@section('title', $subject) +@section('preheader', __('emails.withdrawal_confirmation.preheader', ['package' => $packageName])) +@section('hero_title', $greeting) +@section('hero_subtitle', __('emails.withdrawal_confirmation.subtitle', ['package' => $packageName])) + +@section('content') +

+ {{ __('emails.withdrawal_confirmation.body', ['package' => $packageName]) }} +

+ + + + + + + + + + + + + + + + + + +
{{ __('emails.withdrawal_confirmation.package_label') }}{{ $packageName }}
{{ __('emails.withdrawal_confirmation.amount_label') }}{{ $amount }} {{ $currency }}
{{ __('emails.withdrawal_confirmation.transaction_label') }}{{ $providerId }}
{{ __('emails.withdrawal_confirmation.confirmed_label') }}{{ $confirmedAt->locale(app()->getLocale())->isoFormat('LLL') }}
+ +

+ {{ __('emails.withdrawal_confirmation.processing_hint') }} +

+@endsection + +@section('footer') + {!! __('emails.withdrawal_confirmation.footer') !!} +@endsection diff --git a/routes/web.php b/routes/web.php index fc973fd..5d063fa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,6 +17,7 @@ use App\Http\Controllers\ProfileDataExportController; use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Http\Controllers\TenantAdminAuthController; use App\Http\Controllers\TenantAdminGoogleController; +use App\Http\Controllers\WithdrawalController; use App\Models\Package; use App\Support\CheckoutRoutes; use App\Support\LocaleConfig; @@ -181,6 +182,19 @@ Route::prefix('{locale}') Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'index']) ->name('marketing.profile.index'); + + Route::get('/widerruf', [WithdrawalController::class, 'show']) + ->where('locale', 'de') + ->name('marketing.withdrawal.confirm.de'); + Route::post('/widerruf', [WithdrawalController::class, 'confirm']) + ->where('locale', 'de') + ->name('marketing.withdrawal.confirm.submit.de'); + Route::get('/withdrawal/confirm', [WithdrawalController::class, 'show']) + ->where('locale', 'en') + ->name('marketing.withdrawal.confirm.en'); + Route::post('/withdrawal/confirm', [WithdrawalController::class, 'confirm']) + ->where('locale', 'en') + ->name('marketing.withdrawal.confirm.submit.en'); }); Route::fallback(function () { diff --git a/tests/Feature/Marketing/WithdrawalConfirmationTest.php b/tests/Feature/Marketing/WithdrawalConfirmationTest.php new file mode 100644 index 0000000..54da5d5 --- /dev/null +++ b/tests/Feature/Marketing/WithdrawalConfirmationTest.php @@ -0,0 +1,134 @@ +create(); + $tenant = Tenant::factory()->create(['user_id' => $user->id]); + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + $package = Package::factory()->create(['type' => 'endcustomer']); + $purchase = PackagePurchase::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider' => 'paddle', + 'provider_id' => 'txn_123', + 'refunded' => false, + 'type' => 'endcustomer_event', + 'purchased_at' => now()->subDays(2), + ]); + + $response = $this->actingAs($user)->get('/de/widerruf'); + + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('marketing/WithdrawalConfirm') + ->where('windowDays', 14) + ->has('eligiblePurchases', 1) + ->where('eligiblePurchases.0.id', $purchase->id) + ); + } + + public function test_withdrawal_confirmation_refunds_and_sends_email(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(['user_id' => $user->id]); + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + $package = Package::factory()->create(['type' => 'endcustomer']); + $purchase = PackagePurchase::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider' => 'paddle', + 'provider_id' => 'txn_456', + 'refunded' => false, + 'type' => 'endcustomer_event', + 'purchased_at' => now()->subDays(5), + ]); + + $tenantPackage = TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'active' => true, + ]); + + $this->mock(PaddleTransactionService::class, function ($mock) { + $mock->shouldReceive('refund') + ->once() + ->andReturn([]); + }); + + $response = $this->actingAs($user)->post('/de/widerruf', [ + 'purchase_id' => $purchase->id, + ]); + + $response->assertSessionHas('success'); + $this->assertTrue($purchase->fresh()->refunded); + $this->assertFalse($tenantPackage->fresh()->active); + + Notification::assertSentOnDemand( + WithdrawalConfirmed::class, + function (WithdrawalConfirmed $notification, array $channels) { + return in_array('mail', $channels, true); + } + ); + } + + public function test_withdrawal_rejected_when_event_exists(): void + { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(['user_id' => $user->id]); + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + $package = Package::factory()->create(['type' => 'endcustomer']); + $purchase = PackagePurchase::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider' => 'paddle', + 'provider_id' => 'txn_789', + 'refunded' => false, + 'type' => 'endcustomer_event', + 'purchased_at' => now()->subDays(3), + ]); + + $event = Event::factory()->create(['tenant_id' => $tenant->id]); + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + ]); + + $this->mock(PaddleTransactionService::class, function ($mock) { + $mock->shouldReceive('refund')->never(); + }); + + $response = $this->actingAs($user)->post('/de/widerruf', [ + 'purchase_id' => $purchase->id, + ]); + + $response->assertSessionHas('error'); + $this->assertFalse($purchase->fresh()->refunded); + } +}