• Added the two‑step Widerruf flow with an auth‑only CTA on the Widerrufsbelehrung page and a dedicated confirmation

screen where users pick an eligible end‑customer purchase and confirm. Eligibility is enforced server‑side
  (endcustomer_event, within 14 days, no event package created after purchase), refund is issued via Paddle, the
  purchase is marked refunded, the tenant package is deactivated, and a new confirmation email is sent using resources/
  views/emails/partials/layout.blade.php.

  Details

  - New controller + form request for the confirm flow: app/Http/Controllers/WithdrawalController.php, app/Http/
    Requests/Marketing/WithdrawalConfirmRequest.php
  - New confirmation page + CTA: resources/js/pages/marketing/WithdrawalConfirm.tsx, resources/js/pages/legal/Show.tsx
  - Routes + locale rewrites: routes/web.php, resources/js/lib/localizedPath.ts
  - New email notification + template: app/Notifications/Customer/WithdrawalConfirmed.php, resources/views/emails/
    withdrawal-confirmation.blade.php
  - Translations added for marketing UI + backend flash + email copy: public/lang/de/marketing.json, public/lang/en/
    marketing.json, resources/lang/de/marketing.php, resources/lang/en/marketing.php, resources/lang/de/emails.php,
    resources/lang/en/emails.php
  - Tests: tests/Feature/Marketing/WithdrawalConfirmationTest.php
This commit is contained in:
Codex Agent
2025-12-24 11:54:15 +01:00
parent f6e7c72d14
commit 42b4b647d7
15 changed files with 808 additions and 1 deletions

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Marketing\WithdrawalConfirmRequest;
use App\Models\EventPackage;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Notifications\Customer\WithdrawalConfirmed;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Inertia\Inertia;
use Inertia\Response;
class WithdrawalController extends Controller
{
private const WITHDRAWAL_DAYS = 14;
public function show(Request $request, string $locale): Response
{
$user = $request->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<int, array<string, mixed>>
*/
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<int, string>}
*/
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<string, mixed>
*/
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();
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Marketing;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawalConfirmRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return (bool) $this->user();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'purchase_id' => ['required', 'integer', 'exists:package_purchases,id'],
];
}
}

View File

@@ -0,0 +1,61 @@
<?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;
use Illuminate\Support\Carbon;
class WithdrawalConfirmed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly PackagePurchase $purchase,
private readonly Carbon $confirmedAt,
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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,
]);
}
}

View File

@@ -1037,6 +1037,24 @@
}, },
"too_many_attempts": "Zu viele Versuche. Bitte kurz warten und erneut probieren." "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": { "not_found": {
"title": "Seite nicht gefunden", "title": "Seite nicht gefunden",
"subtitle": "Ups! Diese Seite existiert nicht mehr.", "subtitle": "Ups! Diese Seite existiert nicht mehr.",

View File

@@ -1030,6 +1030,24 @@
}, },
"too_many_attempts": "Too many attempts. Please wait a moment and try again." "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": { "not_found": {
"title": "Page not found", "title": "Page not found",
"subtitle": "Oops! This page is nowhere to be found.", "subtitle": "Oops! This page is nowhere to be found.",

View File

@@ -17,6 +17,8 @@ export const defaultLocaleRewrites: LocaleRewriteMap = {
'/occasions/birthday': { de: '/anlaesse/geburtstag' }, '/occasions/birthday': { de: '/anlaesse/geburtstag' },
'/occasions/corporate-event': { de: '/anlaesse/firmenevent' }, '/occasions/corporate-event': { de: '/anlaesse/firmenevent' },
'/occasions/confirmation': { de: '/anlaesse/konfirmation' }, '/occasions/confirmation': { de: '/anlaesse/konfirmation' },
'/widerruf': { en: '/withdrawal/confirm' },
'/withdrawal/confirm': { de: '/widerruf' },
}; };
const sanitizePath = (input: string): string => { const sanitizePath = (input: string): string => {

View File

@@ -1,5 +1,8 @@
import React from 'react'; import React from 'react';
import { Link, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite'; import MarketingLayout from '@/layouts/mainWebsite';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
type LegalShowProps = { type LegalShowProps = {
seoTitle: string; seoTitle: string;
@@ -12,7 +15,11 @@ type LegalShowProps = {
}; };
const LegalShow: React.FC<LegalShowProps> = (props) => { const LegalShow: React.FC<LegalShowProps> = (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 ( return (
<MarketingLayout title={seoTitle}> <MarketingLayout title={seoTitle}>
@@ -33,6 +40,23 @@ const LegalShow: React.FC<LegalShowProps> = (props) => {
)} )}
</header> </header>
{showWithdrawalAction && (
<div className="mb-10 rounded-2xl border border-pink-200/60 bg-pink-50 p-6">
<h2 className="text-lg font-semibold text-gray-900">
{t('withdrawal.cta_title')}
</h2>
<p className="mt-2 text-sm text-gray-600">
{t('withdrawal.cta_body')}
</p>
<Link
href={localizedPath('/widerruf')}
className="mt-4 inline-flex items-center justify-center rounded-full bg-gray-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900"
>
{t('withdrawal.cta_button')}
</Link>
</div>
)}
<article <article
className="prose prose-slate max-w-none prose-headings:font-display" className="prose prose-slate max-w-none prose-headings:font-display"
dangerouslySetInnerHTML={{ __html: content }} dangerouslySetInnerHTML={{ __html: content }}

View File

@@ -0,0 +1,169 @@
import React, { useMemo } from 'react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { Button } from '@/components/ui/button';
type EligiblePurchase = {
id: number;
package_name: string;
purchased_at: string;
expires_at: string;
price: number | string;
currency: string;
};
type WithdrawalConfirmProps = {
eligiblePurchases: EligiblePurchase[];
windowDays: number;
};
const WithdrawalConfirm: React.FC<WithdrawalConfirmProps> = ({ 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 (
<MarketingLayout title={t('withdrawal.title')}>
<Head title={t('withdrawal.title')} />
<section className="bg-white py-16">
<div className="mx-auto max-w-4xl px-6">
<header className="mb-10">
<p className="text-sm uppercase tracking-[0.2em] text-gray-400">
Fotospiel App
</p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900 md:text-4xl">
{t('withdrawal.title')}
</h1>
<p className="mt-3 text-sm text-gray-500">
{t('withdrawal.subtitle', { days: windowDays })}
</p>
</header>
{flash?.success && (
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700">
{flash.success}
</div>
)}
{flash?.error && (
<div className="mb-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{flash.error}
</div>
)}
{!hasEligible ? (
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-8">
<h2 className="text-lg font-semibold text-gray-900">
{t('withdrawal.empty_title')}
</h2>
<p className="mt-2 text-sm text-gray-600">
{t('withdrawal.empty_body')}
</p>
<Link
href={localizedPath('/packages')}
className="mt-6 inline-flex items-center text-sm font-semibold text-gray-900 underline underline-offset-4"
>
{t('withdrawal.empty_cta')}
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900">
{t('withdrawal.selection_title')}
</h2>
<p className="mt-2 text-sm text-gray-600">
{t('withdrawal.selection_body')}
</p>
<div className="mt-6 space-y-3">
{formattedPurchases.map((purchase) => (
<label
key={purchase.id}
className="flex cursor-pointer items-start gap-4 rounded-xl border border-gray-200 p-4 transition hover:border-gray-300"
>
<input
type="radio"
name="purchase_id"
value={purchase.id}
checked={data.purchase_id === purchase.id}
onChange={() => setData('purchase_id', purchase.id)}
className="mt-1 h-4 w-4 text-gray-900"
/>
<div className="flex-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-gray-900">
{purchase.package_name}
</p>
<p className="mt-1 text-xs text-gray-500">
{t('withdrawal.purchase_date', { date: purchase.purchasedLabel })}
</p>
</div>
<div className="text-sm font-semibold text-gray-900">
{purchase.price} {purchase.currency}
</div>
</div>
<p className="mt-2 text-xs text-gray-500">
{t('withdrawal.expires_at', { date: purchase.expiresLabel })}
</p>
</div>
</label>
))}
</div>
{errors.purchase_id && (
<p className="mt-3 text-sm text-red-600">
{errors.purchase_id}
</p>
)}
</div>
<div className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-gray-50 p-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-gray-900">
{t('withdrawal.confirm_title')}
</p>
<p className="mt-1 text-sm text-gray-600">
{t('withdrawal.confirm_body')}
</p>
</div>
<Button type="submit" size="lg" disabled={processing || !data.purchase_id}>
{processing ? t('withdrawal.confirm_processing') : t('withdrawal.confirm_button')}
</Button>
</div>
</form>
)}
</div>
</section>
</MarketingLayout>
);
};
WithdrawalConfirm.layout = (page: React.ReactNode) => page;
export default WithdrawalConfirm;

View File

@@ -279,6 +279,19 @@ return [
'reason' => 'Grund: :reason', 'reason' => 'Grund: :reason',
'footer' => 'Die Rückerstattung wird vom Zahlungsanbieter verarbeitet und kann je nach Bank einige Tage dauern.', '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' => [ 'ops' => [
'purchase' => [ 'purchase' => [

View File

@@ -301,4 +301,12 @@ return [
], ],
'too_many_attempts' => 'Zu viele Versuche. Bitte kurz warten und erneut probieren.', '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.',
],
],
]; ];

View File

@@ -253,6 +253,19 @@ return [
'reason' => 'Reason: :reason', 'reason' => 'Reason: :reason',
'footer' => 'The refund is processed by the payment provider and may take a few days depending on your bank.', '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' => [ 'ops' => [
'purchase' => [ 'purchase' => [

View File

@@ -301,4 +301,12 @@ return [
], ],
'too_many_attempts' => 'Too many attempts. Please wait a moment and try again.', '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.',
],
],
]; ];

View File

@@ -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')
<p style="margin:0 0 16px; font-size:15px; color:#0f172a;">
{{ __('emails.withdrawal_confirmation.body', ['package' => $packageName]) }}
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse; margin:0 0 16px;">
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.package_label') }}</td>
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $packageName }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.amount_label') }}</td>
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $amount }} {{ $currency }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.transaction_label') }}</td>
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $providerId }}</td>
</tr>
<tr>
<td style="padding:10px 0; font-size:14px; color:#6b7280;">{{ __('emails.withdrawal_confirmation.confirmed_label') }}</td>
<td style="padding:10px 0; font-size:14px; color:#0f172a; text-align:right;">{{ $confirmedAt->locale(app()->getLocale())->isoFormat('LLL') }}</td>
</tr>
</table>
<p style="margin:0; font-size:14px; color:#6b7280;">
{{ __('emails.withdrawal_confirmation.processing_hint') }}
</p>
@endsection
@section('footer')
{!! __('emails.withdrawal_confirmation.footer') !!}
@endsection

View File

@@ -17,6 +17,7 @@ use App\Http\Controllers\ProfileDataExportController;
use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use App\Http\Controllers\TenantAdminAuthController; use App\Http\Controllers\TenantAdminAuthController;
use App\Http\Controllers\TenantAdminGoogleController; use App\Http\Controllers\TenantAdminGoogleController;
use App\Http\Controllers\WithdrawalController;
use App\Models\Package; use App\Models\Package;
use App\Support\CheckoutRoutes; use App\Support\CheckoutRoutes;
use App\Support\LocaleConfig; use App\Support\LocaleConfig;
@@ -181,6 +182,19 @@ Route::prefix('{locale}')
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'index']) Route::get('/profile', [ProfileController::class, 'index'])
->name('marketing.profile.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 () { Route::fallback(function () {

View File

@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature\Marketing;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use App\Notifications\Customer\WithdrawalConfirmed;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class WithdrawalConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_withdrawal_page_lists_eligible_purchases(): 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_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);
}
}