• 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:
258
app/Http/Controllers/WithdrawalController.php
Normal file
258
app/Http/Controllers/WithdrawalController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/Marketing/WithdrawalConfirmRequest.php
Normal file
28
app/Http/Requests/Marketing/WithdrawalConfirmRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Notifications/Customer/WithdrawalConfirmed.php
Normal file
61
app/Notifications/Customer/WithdrawalConfirmed.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
169
resources/js/pages/marketing/WithdrawalConfirm.tsx
Normal file
169
resources/js/pages/marketing/WithdrawalConfirm.tsx
Normal 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;
|
||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
39
resources/views/emails/withdrawal-confirmation.blade.php
Normal file
39
resources/views/emails/withdrawal-confirmation.blade.php
Normal 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
|
||||||
@@ -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 () {
|
||||||
|
|||||||
134
tests/Feature/Marketing/WithdrawalConfirmationTest.php
Normal file
134
tests/Feature/Marketing/WithdrawalConfirmationTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user