diff --git a/AGENTS.md b/AGENTS.md index 9b7cb0bf..44d0a60b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -623,6 +623,134 @@ export default () => ( | overflow-ellipsis | text-ellipsis | | decoration-slice | box-decoration-slice | | decoration-clone | box-decoration-clone | + +=== filament/filament rules === + +## Filament + +- Filament is used by this application. Follow existing conventions for how and where it's implemented. +- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS. +- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices. + +### Artisan + +- Use Filament-specific Artisan commands to create files. Find them with `list-artisan-commands` or `php artisan --help`. +- Inspect required options and always pass `--no-interaction`. + +### Patterns + +Use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values. + +Use `Get $get` to read other form field values for conditional logic: + + +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Schemas\Components\Utilities\Get; + +Select::make('type') + ->options(CompanyType::class) + ->required() + ->live(), + +TextInput::make('company_name') + ->required() + ->visible(fn (Get $get): bool => $get('type') === 'business'), + + +Use `state()` with a `Closure` to compute derived column values: + + +use Filament\Tables\Columns\TextColumn; + +TextColumn::make('full_name') + ->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"), + + +Actions encapsulate a button with optional modal form and logic: + + +use Filament\Actions\Action; +use Filament\Forms\Components\TextInput; + +Action::make('updateEmail') + ->form([ + TextInput::make('email')->email()->required(), + ]) + ->action(fn (array $data, User $record): void => $record->update($data)), + + +### Testing + +Authenticate before testing panel functionality. Filament uses Livewire, so use `livewire()` or `Livewire::test()`: + + + livewire(ListUsers::class) + ->assertCanSeeTableRecords($users) + ->searchTable($users->first()->name) + ->assertCanSeeTableRecords($users->take(1)) + ->assertCanNotSeeTableRecords($users->skip(1)); + + + + livewire(CreateUser::class) + ->fillForm([ + 'name' => 'Test', + 'email' => 'test@example.com', + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + assertDatabaseHas(User::class, [ + 'name' => 'Test', + 'email' => 'test@example.com', + ]); + + + + livewire(CreateUser::class) + ->fillForm([ + 'name' => null, + 'email' => 'invalid-email', + ]) + ->call('create') + ->assertHasFormErrors([ + 'name' => 'required', + 'email' => 'email', + ]) + ->assertNotNotified(); + + + + use Filament\Actions\DeleteAction; + use Filament\Actions\Testing\TestAction; + + livewire(EditUser::class, ['record' => $user->id]) + ->callAction(DeleteAction::class) + ->assertNotified() + ->assertRedirect(); + + livewire(ListUsers::class) + ->callAction(TestAction::make('promote')->table($user), [ + 'role' => 'admin', + ]) + ->assertNotified(); + + +### Common Mistakes + +**Commonly Incorrect Namespaces:** +- Form fields (TextInput, Select, etc.): `Filament\Forms\Components\` +- Infolist entries (for read-only views) (TextEntry, IconEntry, etc.): `Filament\Forms\Components\` +- Layout components (Grid, Section, Fieldset, Tabs, Wizard, etc.): `Filament\Schemas\Components\` +- Schema utilities (Get, Set, etc.): `Filament\Schemas\Components\Utilities\` +- Actions: `Filament\Actions\` (no `Filament\Tables\Actions\` etc.) +- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`) + +**Recent breaking changes to Filament:** +- File visibility is `private` by default. Use `->visibility('public')` for public access. +- `Grid`, `Section`, and `Fieldset` no longer span all columns by default. ## Issue Tracking diff --git a/app/Http/Controllers/Api/Marketing/CouponPreviewController.php b/app/Http/Controllers/Api/Marketing/CouponPreviewController.php index 5bf1881e..2bdc7c2e 100644 --- a/app/Http/Controllers/Api/Marketing/CouponPreviewController.php +++ b/app/Http/Controllers/Api/Marketing/CouponPreviewController.php @@ -24,12 +24,6 @@ class CouponPreviewController extends Controller $package = Package::findOrFail($data['package_id']); - if (! $package->lemonsqueezy_variant_id) { - throw ValidationException::withMessages([ - 'code' => __('marketing.coupon.errors.package_not_configured'), - ]); - } - $tenant = Auth::user()?->tenant; try { diff --git a/app/Http/Controllers/Api/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php index 9ebe6f81..e2745c16 100644 --- a/app/Http/Controllers/Api/TenantBillingController.php +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -4,18 +4,21 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\EventPackageAddon; +use App\Models\PackagePurchase; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; -use App\Services\LemonSqueezy\LemonSqueezyOrderService; use App\Services\LemonSqueezy\LemonSqueezySubscriptionService; +use Dompdf\Dompdf; +use Dompdf\Options; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\Response; class TenantBillingController extends Controller { public function __construct( - private readonly LemonSqueezyOrderService $orders, private readonly LemonSqueezySubscriptionService $subscriptions, ) {} @@ -30,45 +33,45 @@ class TenantBillingController extends Controller ], 404); } - if (! $tenant->lemonsqueezy_customer_id) { - return response()->json([ - 'data' => [], - 'meta' => [ - 'next' => null, - 'previous' => null, - 'has_more' => false, - ], - ]); - } + $perPage = max(1, min((int) $request->query('per_page', 25), 100)); + $page = max(1, (int) $request->query('page', 1)); + $locale = $request->user()?->preferred_locale ?? app()->getLocale(); - $cursor = $request->query('cursor'); - $perPage = (int) $request->query('per_page', 25); + $paginator = PackagePurchase::query() + ->where('tenant_id', $tenant->id) + ->with(['package']) + ->orderByDesc('purchased_at') + ->orderByDesc('id') + ->paginate($perPage, ['*'], 'page', $page); - $query = [ - 'per_page' => max(1, min($perPage, 100)), - ]; + $data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) { + $totals = $this->resolvePurchaseTotals($purchase); + $transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey(); - if ($cursor) { - $query['after'] = $cursor; - } - - try { - $result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query); - } catch (\Throwable $exception) { - Log::warning('Failed to load Lemon Squeezy transactions', [ - 'tenant_id' => $tenant->id, - 'error' => $exception->getMessage(), - ]); - - return response()->json([ - 'data' => [], - 'message' => 'Failed to load Lemon Squeezy transactions.', - ], 502); - } + return [ + 'id' => $purchase->getKey(), + 'status' => $purchase->refunded ? 'refunded' : 'completed', + 'amount' => $totals['total'], + 'currency' => $totals['currency'], + 'tax' => $totals['tax'], + 'provider' => $purchase->provider ?? 'paypal', + 'provider_id' => $transactionId, + 'package_name' => $this->resolvePackageName($purchase, $locale), + 'purchased_at' => $purchase->purchased_at?->toIso8601String(), + 'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [ + 'purchase' => $purchase->getKey(), + ], absolute: false), + ]; + })->values(); return response()->json([ - 'data' => $result['data'], - 'meta' => $result['meta'], + 'data' => $data, + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], ]); } @@ -201,4 +204,184 @@ class TenantBillingController extends Controller 'url' => $url, ]); } + + public function receipt(Request $request, PackagePurchase $purchase): Response + { + $tenant = $request->attributes->get('tenant'); + + if (! $tenant || (int) $purchase->tenant_id !== (int) $tenant->id) { + abort(404); + } + + $purchase->loadMissing(['tenant.user', 'package']); + + $locale = $request->user()?->preferred_locale ?? app()->getLocale(); + app()->setLocale($locale); + + $totals = $this->resolvePurchaseTotals($purchase); + $currency = $totals['currency']; + $total = $totals['total']; + $tax = $totals['tax']; + + $buyer = $purchase->tenant?->user; + $buyerName = $buyer?->full_name ?? $buyer?->name ?? $buyer?->email ?? ''; + $buyerEmail = $buyer?->email ?? ''; + $buyerAddress = $buyer?->address ?? ''; + + $packageName = $this->resolvePackageName($purchase, $locale); + $packageTypeLabel = $this->resolvePackageTypeLabel($purchase->package?->type); + $providerLabel = $this->resolveProviderLabel($purchase->provider); + + $purchaseDate = $this->formatDate($purchase->purchased_at, $locale); + $amountFormatted = $this->formatCurrency($total, $currency, $locale); + $taxFormatted = $tax !== null ? $this->formatCurrency($tax, $currency, $locale) : null; + $totalFormatted = $amountFormatted; + + $html = view('billing.receipt', [ + 'receiptNumber' => (string) $purchase->getKey(), + 'purchaseDate' => $purchaseDate, + 'packageName' => $packageName, + 'packageTypeLabel' => $packageTypeLabel, + 'providerLabel' => $providerLabel, + 'orderId' => $purchase->provider_id ?? $purchase->getKey(), + 'buyerName' => $buyerName, + 'buyerEmail' => $buyerEmail, + 'buyerAddress' => $buyerAddress, + 'amountFormatted' => $amountFormatted, + 'taxFormatted' => $taxFormatted, + 'totalFormatted' => $totalFormatted, + 'currency' => $currency, + 'companyName' => config('app.name', 'Fotospiel'), + 'companyEmail' => config('mail.from.address', 'info@fotospiel.app'), + ])->render(); + + $options = new Options; + $options->set('isHtml5ParserEnabled', true); + $options->set('isRemoteEnabled', true); + $options->set('defaultFont', 'Helvetica'); + + $dompdf = new Dompdf($options); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->render(); + + $pdfBinary = $dompdf->output(); + $filenameStem = Str::slug($packageName ?: 'receipt'); + + return response($pdfBinary) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'inline; filename="receipt-'.$filenameStem.'.pdf"'); + } + + /** + * @return array{currency: string, total: float, tax: float|null} + */ + private function resolvePurchaseTotals(PackagePurchase $purchase): array + { + $metadata = $purchase->metadata ?? []; + $totals = $metadata['paypal_totals'] ?? $metadata['lemonsqueezy_totals'] ?? []; + + $currency = $totals['currency'] + ?? $metadata['currency'] + ?? $purchase->package?->currency + ?? 'EUR'; + + $total = array_key_exists('total', $totals) + ? (float) $totals['total'] + : (float) $purchase->price; + + $tax = array_key_exists('tax', $totals) ? (float) $totals['tax'] : null; + + return [ + 'currency' => strtoupper((string) $currency), + 'total' => round($total, 2), + 'tax' => $tax !== null ? round($tax, 2) : null, + ]; + } + + private function resolvePackageName(PackagePurchase $purchase, string $locale): string + { + $package = $purchase->package; + if (! $package) { + return ''; + } + + $localized = $package->getNameForLocale($locale); + + return $localized ?: (string) $package->name; + } + + private function resolveProviderLabel(?string $provider): string + { + $provider = $provider ?: 'paypal'; + $labelKey = 'emails.purchase.provider.'.$provider; + $label = __($labelKey); + + if ($label === $labelKey) { + return ucfirst($provider); + } + + return $label; + } + + private function resolvePackageTypeLabel(?string $type): string + { + $type = $type ?: 'endcustomer'; + $labelKey = 'emails.purchase.package_type.'.$type; + $label = __($labelKey); + + if ($label === $labelKey) { + return ucfirst($type); + } + + return $label; + } + + private function formatCurrency(float $amount, string $currency, string $locale): string + { + $formatter = class_exists(\NumberFormatter::class) + ? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY) + : null; + + if ($formatter) { + $formatted = $formatter->formatCurrency($amount, $currency); + if ($formatted !== false) { + return $formatted; + } + } + + $symbol = match (strtoupper($currency)) { + 'EUR' => '€', + 'USD' => '$', + default => strtoupper($currency).' ', + }; + + return $symbol.number_format($amount, 2, ',', '.'); + } + + private function formatDate(?\Carbon\CarbonInterface $date, string $locale): string + { + if (! $date) { + return ''; + } + + $localized = $date->locale($locale); + + if (str_starts_with($locale, 'en')) { + return $localized->translatedFormat('F j, Y'); + } + + return $localized->translatedFormat('d. F Y'); + } + + private function mapLocale(string $locale): string + { + $normalized = strtolower(str_replace('_', '-', $locale)); + + return match (true) { + str_starts_with($normalized, 'de') => 'de_DE', + str_starts_with($normalized, 'en') => 'en_US', + default => 'de_DE', + }; + } } diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php index bb02b112..3c1b0db5 100644 --- a/app/Http/Middleware/ContentSecurityPolicy.php +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -81,6 +81,18 @@ class ContentSecurityPolicy 'https:', ]; + $paypalSources = [ + 'https://www.paypal.com', + 'https://www.paypalobjects.com', + 'https://*.paypal.com', + 'https://*.paypalobjects.com', + ]; + + $scriptSources = array_merge($scriptSources, $paypalSources); + $connectSources = array_merge($connectSources, $paypalSources); + $frameSources = array_merge($frameSources, $paypalSources); + $imgSources = array_merge($imgSources, $paypalSources); + if ($matomoOrigin) { $scriptSources[] = $matomoOrigin; $connectSources[] = $matomoOrigin; @@ -90,6 +102,18 @@ class ContentSecurityPolicy $isDev = app()->environment(['local', 'development']) || config('app.debug'); if ($isDev) { + $paypalSandboxSources = [ + 'https://www.sandbox.paypal.com', + 'https://www.sandbox.paypalobjects.com', + 'https://*.sandbox.paypal.com', + 'https://*.sandbox.paypalobjects.com', + ]; + + $scriptSources = array_merge($scriptSources, $paypalSandboxSources); + $connectSources = array_merge($connectSources, $paypalSandboxSources); + $frameSources = array_merge($frameSources, $paypalSandboxSources); + $imgSources = array_merge($imgSources, $paypalSandboxSources); + $devHosts = [ 'http://fotospiel-app.test:5173', 'http://127.0.0.1:5173', diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index 7771c545..543c750d 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -80,7 +80,7 @@ class TenantPackage extends Model public function getRemainingEventsAttribute(): ?int { if (! $this->package->isReseller()) { - return 0; + return max(0, 1 - (int) $this->used_events); } $max = $this->package->max_events_per_year; diff --git a/app/Services/Coupons/CouponService.php b/app/Services/Coupons/CouponService.php index 285cfa1b..c1051294 100644 --- a/app/Services/Coupons/CouponService.php +++ b/app/Services/Coupons/CouponService.php @@ -4,7 +4,6 @@ namespace App\Services\Coupons; use App\Enums\CouponStatus; use App\Enums\CouponType; -use App\Models\CheckoutSession; use App\Models\Coupon; use App\Models\CouponRedemption; use App\Models\Package; @@ -36,12 +35,6 @@ class CouponService public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null, ?string $provider = null): void { - if ($provider !== CheckoutSession::PROVIDER_PAYPAL && ! $coupon->lemonsqueezy_discount_id) { - throw ValidationException::withMessages([ - 'code' => __('marketing.coupon.errors.not_synced'), - ]); - } - if (! $coupon->enabled_for_checkout) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.disabled'), diff --git a/app/Services/PayPal/PayPalOrderService.php b/app/Services/PayPal/PayPalOrderService.php index 8cdec694..e50ecea1 100644 --- a/app/Services/PayPal/PayPalOrderService.php +++ b/app/Services/PayPal/PayPalOrderService.php @@ -139,7 +139,7 @@ class PayPalOrderService $headers['PayPal-Request-Id'] = $requestId; } - return $this->client->post(sprintf('/v2/checkout/orders/%s/capture', $orderId), [], $headers); + return $this->client->post(sprintf('/v2/checkout/orders/%s/capture', $orderId), (object) [], $headers); } public function resolveApproveUrl(array $payload): ?string diff --git a/boost.json b/boost.json index 76ae75b6..fad3511f 100644 --- a/boost.json +++ b/boost.json @@ -3,7 +3,10 @@ "codex" ], "editors": [ + "codex", "vscode" ], - "guidelines": [] + "guidelines": [ + "filament/filament" + ] } diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 89a1c041..d085614c 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -687,12 +687,12 @@ "secure_payment_desc": "Sichere Zahlung ueber PayPal.", "lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.", "guided_title": "Sichere Zahlung mit PayPal", - "guided_body": "Bezahle schnell und sicher mit PayPal. Dein Paket wird nach der Bestaetigung sofort freigeschaltet.", + "guided_body": "Bezahle schnell und sicher mit PayPal. Es oeffnet sich ein PayPal-Fenster – kehre danach hierher zurueck.", "lemonsqueezy_partner": "Powered by PayPal", "trust_secure": "Verschlüsselte Zahlung", "trust_tax": "Automatische Steuerberechnung", "trust_support": "Support in Minuten", - "guided_cta_hint": "Sicher abgewickelt ueber PayPal", + "guided_cta_hint": "Zahlung in PayPal abschliessen und hierher zurueckkehren.", "toast_success": "Zahlung erfolgreich – wir bereiten alles vor.", "lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...", "lemonsqueezy_overlay_ready": "Der PayPal-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.", @@ -738,11 +738,13 @@ "processing_confirmation": "Zahlung eingegangen. Wir schliessen deine Bestellung ab...", "paypal_partner": "Powered by PayPal", "paypal_preparing": "PayPal-Checkout wird vorbereitet...", - "paypal_ready": "PayPal-Checkout ist bereit. Schließe die Zahlung ab, um fortzufahren.", + "paypal_ready": "PayPal-Checkout ist bereit. Schliesse die Zahlung in PayPal ab und kehre hierher zurueck.", "paypal_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", "paypal_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.", - "paypal_cancelled": "PayPal-Checkout wurde abgebrochen.", - "paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung." + "paypal_cancelled": "PayPal-Checkout wurde abgebrochen. Du kannst es unten erneut versuchen.", + "paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung.", + "resume_paypal": "Weiter in PayPal", + "resume_hint": "Falls PayPal nicht geoeffnet wurde oder geschlossen wurde, hier im neuen Tab fortsetzen." }, "confirmation_step": { "title": "Bestätigung", @@ -753,21 +755,28 @@ "email_followup": "Wir haben dir gerade alle Details per E-Mail geschickt – inklusive Rechnung und den nächsten Schritten.", "hero_badge": "Checkout abgeschlossen", "hero_title": "Weiter geht's im Marketing-Dashboard", - "hero_body": "Wir haben deinen Zugang aktiviert und PayPal synchronisiert. Mit diesen Aufgaben startest du direkt durch.", + "hero_body": "Wir haben deinen Zugang aktiviert und synchronisieren PayPal. Mit diesen Aufgaben startest du direkt durch.", "hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln – diese Übersicht kannst du jederzeit erneut öffnen.", "status_title": "Bestellstatus", "status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.", "status_state": { "processing": "Wird bestätigt", "completed": "Bestätigt", - "failed": "Aktion nötig" + "failed": "Aktion nötig", + "action_required": "Aktion erforderlich" }, "status_body_processing": "Wir synchronisieren dein Konto mit PayPal. Das kann einen Moment dauern.", "status_body_completed": "Alles ist bereit. Dein Konto ist vollständig freigeschaltet.", "status_body_failed": "Wir konnten den Kauf noch nicht bestätigen. Bitte prüfe den Status erneut oder kontaktiere den Support.", + "status_body_action_required": "PayPal benoetigt noch eine kurze Bestaetigung. Schliesse den Checkout ab, um dein Paket zu aktivieren.", "status_manual_hint": "Dauert es zu lange? Du kannst den Status erneut prüfen oder die Seite aktualisieren.", "status_retry": "Status prüfen", "status_refresh": "Seite aktualisieren", + "status_action_hint": "Wir benoetigen noch deine PayPal-Bestaetigung, bevor wir dein Paket aktivieren koennen.", + "status_action_button": "PayPal-Checkout fortsetzen", + "status_action_back": "Zurueck zur Zahlung", + "status_failed_hint": "Die Zahlung wurde nicht abgeschlossen. Du kannst den Checkout erneut starten.", + "status_failed_back": "Zurueck zur Zahlung", "status_items": { "payment": { "title": "Zahlung bestätigt", diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 874b1935..8d7ec4ae 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -685,12 +685,12 @@ "secure_payment_desc": "Secure payment with PayPal.", "lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.", "guided_title": "Secure checkout with PayPal", - "guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.", + "guided_body": "Pay quickly and securely with PayPal. A PayPal window opens; return here after approval to finish.", "lemonsqueezy_partner": "Powered by PayPal", "trust_secure": "Encrypted payment", "trust_tax": "Automatic tax handling", "trust_support": "Live support within minutes", - "guided_cta_hint": "Securely processed via PayPal", + "guided_cta_hint": "Complete the payment in PayPal and return here to finish.", "toast_success": "Payment received – setting everything up for you.", "lemonsqueezy_preparing": "Preparing PayPal checkout...", "lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.", @@ -736,11 +736,13 @@ "processing_confirmation": "Payment received. Finalising your order...", "paypal_partner": "Powered by PayPal", "paypal_preparing": "Preparing PayPal checkout...", - "paypal_ready": "PayPal checkout is ready. Complete the payment to continue.", + "paypal_ready": "PayPal checkout is ready. Complete the payment in PayPal and return here.", "paypal_error": "We could not start the PayPal checkout. Please try again.", "paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.", - "paypal_cancelled": "PayPal checkout was cancelled.", - "paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase." + "paypal_cancelled": "PayPal checkout was cancelled. You can try again below.", + "paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.", + "resume_paypal": "Continue in PayPal", + "resume_hint": "If PayPal did not open or you closed it, continue in a new tab." }, "confirmation_step": { "title": "Confirmation", @@ -751,21 +753,28 @@ "email_followup": "We've just sent a confirmation email with your receipt and the next steps.", "hero_badge": "Checkout complete", "hero_title": "You're ready for the Marketing Dashboard", - "hero_body": "We activated your access and synced PayPal. Follow the checklist below to launch your first event.", + "hero_body": "We activated your access and are syncing PayPal. Follow the checklist below to launch your first event.", "hero_next": "Use the button below whenever you're ready to jump into your customer area—this summary is always available.", "status_title": "Purchase status", "status_subtitle": "We are finishing the handoff and syncing your account.", "status_state": { "processing": "Finalising", "completed": "Confirmed", - "failed": "Needs attention" + "failed": "Needs attention", + "action_required": "Action required" }, "status_body_processing": "We are syncing your account with PayPal. This can take a minute.", "status_body_completed": "Everything is ready. Your account is fully unlocked.", "status_body_failed": "We could not confirm the purchase yet. Please try again or contact support.", + "status_body_action_required": "PayPal still needs a quick confirmation. Complete the checkout to activate your package.", "status_manual_hint": "Still waiting? You can re-check the status or refresh the page.", "status_retry": "Check status", "status_refresh": "Refresh page", + "status_action_hint": "We still need PayPal approval before we can activate your package.", + "status_action_button": "Continue PayPal checkout", + "status_action_back": "Back to payment", + "status_failed_hint": "The payment did not complete. You can start the checkout again.", + "status_failed_back": "Back to payment", "status_items": { "payment": { "title": "Payment confirmed", diff --git a/resources/css/app.css b/resources/css/app.css index 438a6ffa..d7a7216d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -870,6 +870,11 @@ html.guest-theme.dark { background-color: #000; } +.guest-layout { + padding-bottom: var(--guest-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 88px)); + scroll-padding-bottom: var(--guest-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 88px)); +} + @theme inline { --animate-spin: spin 2s linear infinite; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 9884a213..83d4830e 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -571,6 +571,19 @@ export type LemonSqueezyOrderSummary = { tax?: number | null; }; +export type TenantBillingTransactionSummary = { + id: string | number | null; + status: string | null; + amount: number | null; + currency: string | null; + provider: string | null; + provider_id: string | null; + package_name: string | null; + purchased_at: string | null; + receipt_url?: string | null; + tax?: number | null; +}; + export type TenantAddonEventSummary = { id: number; slug: string; @@ -1125,21 +1138,21 @@ export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { }; } -function normalizeLemonSqueezyOrder(entry: JsonValue): LemonSqueezyOrderSummary { - const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total); - const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total); +function normalizeTenantBillingTransaction(entry: JsonValue): TenantBillingTransactionSummary { + const idValue = (entry as { id?: unknown }).id; + const amountValue = (entry as { amount?: unknown }).amount; + const taxValue = (entry as { tax?: unknown }).tax; return { - id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null, - status: entry.status ?? null, + id: typeof idValue === 'string' || typeof idValue === 'number' ? idValue : idValue ? String(idValue) : null, + status: typeof (entry as { status?: unknown }).status === 'string' ? (entry as { status?: unknown }).status : null, amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null, - currency: entry.currency ?? entry.currency_code ?? 'EUR', - origin: entry.origin ?? null, - checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null), - created_at: entry.created_at ?? null, - updated_at: entry.updated_at ?? null, - receipt_url: entry.receipt_url ?? entry.invoice_url ?? null, - grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null, + currency: typeof (entry as { currency?: unknown }).currency === 'string' ? (entry as { currency?: unknown }).currency : 'EUR', + provider: typeof (entry as { provider?: unknown }).provider === 'string' ? (entry as { provider?: unknown }).provider : null, + provider_id: typeof (entry as { provider_id?: unknown }).provider_id === 'string' ? (entry as { provider_id?: unknown }).provider_id : null, + package_name: typeof (entry as { package_name?: unknown }).package_name === 'string' ? (entry as { package_name?: unknown }).package_name : null, + purchased_at: typeof (entry as { purchased_at?: unknown }).purchased_at === 'string' ? (entry as { purchased_at?: unknown }).purchased_at : null, + receipt_url: typeof (entry as { receipt_url?: unknown }).receipt_url === 'string' ? (entry as { receipt_url?: unknown }).receipt_url : null, tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null, }; } @@ -2731,32 +2744,43 @@ export async function downloadTenantDataExport(downloadUrl: string): Promise { + const response = await authorizedFetch(receiptUrl, { + headers: { 'Accept': 'application/pdf' }, + }); + + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to download billing receipt', response.status, payload); + throw new Error('Failed to download billing receipt'); + } + + return response.blob(); +} + +export async function getTenantBillingTransactions(page = 1): Promise<{ + data: TenantBillingTransactionSummary[]; }> { - const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''; - const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`); + const params = new URLSearchParams({ + page: String(Math.max(1, page)), + }); + const response = await authorizedFetch(`/api/v1/tenant/billing/transactions?${params.toString()}`); if (response.status === 404) { - return { data: [], nextCursor: null, hasMore: false }; + return { data: [] }; } if (!response.ok) { const payload = await safeJson(response); - console.error('[API] Failed to load Lemon Squeezy transactions', response.status, payload); - throw new Error('Failed to load Lemon Squeezy transactions'); + console.error('[API] Failed to load billing transactions', response.status, payload); + throw new Error('Failed to load billing transactions'); } const payload = await safeJson(response) ?? {}; const entries = Array.isArray(payload.data) ? payload.data : []; - const meta = payload.meta ?? {}; return { - data: entries.map(normalizeLemonSqueezyOrder), - nextCursor: typeof meta.next === 'string' ? meta.next : null, - hasMore: Boolean(meta.has_more), + data: entries.map(normalizeTenantBillingTransaction), }; } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 8de2410a..74441c73 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -6,10 +6,12 @@ "language": "Sprache", "languageDe": "Deutsch", "languageEn": "Englisch", - "theme": "Theme", + "theme": "Erscheinungsbild", "themeLight": "Hell", "themeDark": "Dunkel", "themeSystem": "System", + "themeSystemLabel": "System ({{mode}})", + "themeSystemHint": "Folgt der Geräteeinstellung: {{mode}}", "logout": "Abmelden", "logoutTitle": "Ausloggen", "logoutHint": "Aus der App ausloggen" @@ -27,8 +29,9 @@ "actions": { "refresh": "Aktualisieren", "exportCsv": "Export als CSV", - "portal": "Im PayPal-Portal verwalten", - "portalBusy": "Portal wird geöffnet...", + "portal": "Abrechnungsdetails", + "portalBusy": "Details werden geöffnet...", + "receiptDownloaded": "Beleg heruntergeladen.", "openPackages": "Pakete öffnen", "contactSupport": "Support kontaktieren" }, @@ -54,7 +57,8 @@ "errors": { "load": "Paketdaten konnten nicht geladen werden.", "more": "Weitere Einträge konnten nicht geladen werden.", - "portal": "PayPal-Portal konnte nicht geöffnet werden." + "portal": "Abrechnungsdetails konnten nicht geöffnet werden.", + "receipt": "Beleg konnte nicht heruntergeladen werden." }, "checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.", "checkoutCancelled": "Checkout wurde abgebrochen.", @@ -140,14 +144,14 @@ } }, "transactions": { - "title": "PayPal-Transaktionen", - "description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.", - "empty": "Noch keine PayPal-Transaktionen.", + "title": "Transaktionen", + "description": "Neueste Paketkäufe für dieses Kundenkonto.", + "empty": "Noch keine Transaktionen.", "labels": { "transactionId": "Transaktion {{id}}", - "checkoutId": "Checkout-ID: {{id}}", - "origin": "Herkunft: {{origin}}", - "receipt": "Beleg ansehen", + "provider": "Anbieter: {{provider}}", + "receipt": "Beleg herunterladen", + "packageFallback": "Paket", "tax": "Steuer: {{value}}" }, "table": { @@ -162,6 +166,7 @@ "processing": "Verarbeitung", "failed": "Fehlgeschlagen", "cancelled": "Storniert", + "refunded": "Erstattet", "unknown": "Unbekannt" }, "loadMore": "Weitere Transaktionen laden", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index f5ed58bc..4b4207ba 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -6,10 +6,12 @@ "language": "Language", "languageDe": "Deutsch", "languageEn": "English", - "theme": "Theme", + "theme": "Appearance", "themeLight": "Light", "themeDark": "Dark", "themeSystem": "System", + "themeSystemLabel": "System ({{mode}})", + "themeSystemHint": "Following device setting: {{mode}}", "logout": "Log out", "logoutTitle": "Sign out", "logoutHint": "Sign out from this app." @@ -27,8 +29,9 @@ "actions": { "refresh": "Refresh", "exportCsv": "Export CSV", - "portal": "Manage in PayPal", - "portalBusy": "Opening portal...", + "portal": "Billing details", + "portalBusy": "Opening billing details...", + "receiptDownloaded": "Receipt downloaded.", "openPackages": "Open packages", "contactSupport": "Contact support" }, @@ -54,7 +57,8 @@ "errors": { "load": "Unable to load package data.", "more": "Unable to load more entries.", - "portal": "Unable to open the PayPal portal." + "portal": "Unable to open billing details.", + "receipt": "Receipt download failed." }, "checkoutSuccess": "Checkout completed. Your package will activate shortly.", "checkoutCancelled": "Checkout was cancelled.", @@ -140,14 +144,14 @@ } }, "transactions": { - "title": "PayPal transactions", - "description": "Recent PayPal transactions for this customer account.", - "empty": "No PayPal transactions yet.", + "title": "Transactions", + "description": "Recent package purchases for this account.", + "empty": "No transactions yet.", "labels": { "transactionId": "Transaction {{id}}", - "checkoutId": "Checkout ID: {{id}}", - "origin": "Origin: {{origin}}", - "receipt": "View receipt", + "provider": "Provider: {{provider}}", + "receipt": "Download receipt", + "packageFallback": "Package", "tax": "Tax: {{value}}" }, "table": { @@ -162,6 +166,7 @@ "processing": "Processing", "failed": "Failed", "cancelled": "Cancelled", + "refunded": "Refunded", "unknown": "Unknown" }, "loadMore": "Load more transactions", diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index 7fb62e3d..d4e949ac 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -10,12 +10,12 @@ import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { ContextHelpLink } from './components/ContextHelpLink'; import { - createTenantBillingPortalSession, + getTenantBillingTransactions, getTenantPackagesOverview, - getTenantLemonSqueezyTransactions, getTenantPackageCheckoutStatus, TenantPackageSummary, - LemonSqueezyOrderSummary, + TenantBillingTransactionSummary, + downloadTenantBillingReceipt, } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; @@ -42,6 +42,7 @@ import { shouldClearPendingCheckout, storePendingCheckout, } from './lib/billingCheckout'; +import { triggerDownloadFromBlob } from './invite-layout/export-utils'; const CHECKOUT_POLL_INTERVAL_MS = 10000; @@ -52,11 +53,10 @@ export default function MobileBillingPage() { const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme(); const [packages, setPackages] = React.useState([]); const [activePackage, setActivePackage] = React.useState(null); - const [transactions, setTransactions] = React.useState([]); + const [transactions, setTransactions] = React.useState([]); const [addons, setAddons] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const [portalBusy, setPortalBusy] = React.useState(false); const [pendingCheckout, setPendingCheckout] = React.useState(() => loadPendingCheckout()); const [checkoutStatus, setCheckoutStatus] = React.useState(null); const [checkoutStatusReason, setCheckoutStatusReason] = React.useState(null); @@ -78,7 +78,7 @@ export default function MobileBillingPage() { try { const [pkg, trx, addonHistory] = await Promise.all([ getTenantPackagesOverview({ force: true }), - getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })), + getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })), getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })), ]); setPackages(pkg.packages ?? []); @@ -104,30 +104,32 @@ export default function MobileBillingPage() { } }, [supportEmail]); - const openPortal = React.useCallback(async () => { - if (portalBusy) { - return; - } - - setPortalBusy(true); - try { - const { url } = await createTenantBillingPortalSession(); - if (typeof window !== 'undefined') { - window.open(url, '_blank', 'noopener'); - } - } catch (err) { - const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Lemon Squeezy-Portal nicht öffnen.')); - toast.error(message); - } finally { - setPortalBusy(false); - } - }, [portalBusy, t]); - const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => { setPendingCheckout(next); storePendingCheckout(next); }, []); + const handleReceiptDownload = React.useCallback( + async (transaction: TenantBillingTransactionSummary) => { + if (!transaction.receipt_url) { + return; + } + + const transactionId = transaction.provider_id + ?? (transaction.id !== null && transaction.id !== undefined ? String(transaction.id) : 'receipt'); + + try { + const blob = await downloadTenantBillingReceipt(transaction.receipt_url); + const filename = `fotospiel-receipt-${transactionId}.pdf`; + triggerDownloadFromBlob(blob, filename); + toast.success(t('billing.actions.receiptDownloaded', 'Receipt downloaded.')); + } catch (err) { + toast.error(getApiErrorMessage(err, t('billing.errors.receipt', 'Receipt download failed.'))); + } + }, + [t], + ); + React.useEffect(() => { void load(); }, [load]); @@ -387,11 +389,6 @@ export default function MobileBillingPage() { {t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')} - {loading ? ( {t('common.loading', 'Lädt...')} @@ -439,38 +436,55 @@ export default function MobileBillingPage() { ) : ( - {transactions.slice(0, 8).map((trx) => ( - - - - {trx.status ?? '—'} - - - {formatDate(trx.created_at)} - - {trx.origin ? ( - - {trx.origin} + {transactions.slice(0, 8).map((trx) => { + const statusLabel = trx.status + ? t(`billing.sections.transactions.status.${trx.status}`, trx.status) + : '—'; + const providerLabel = trx.provider + ? t(`billing.providers.${trx.provider}`, trx.provider) + : t('billing.providers.unknown', 'Unknown'); + const transactionId = trx.provider_id ?? (trx.id !== null && trx.id !== undefined ? String(trx.id) : '—'); + const packageName = trx.package_name ?? t('billing.sections.transactions.labels.packageFallback', 'Package'); + + return ( + + + + {packageName} - ) : null} - - - - {formatAmount(trx.amount, trx.currency)} - - {trx.tax ? ( - {t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })} + {statusLabel} - ) : null} - {trx.receipt_url ? ( - - {t('billing.sections.transactions.labels.receipt', 'Beleg')} - - ) : null} - - - ))} + + {t('billing.sections.transactions.labels.provider', { provider: providerLabel })} + + + {t('billing.sections.transactions.labels.transactionId', { id: transactionId })} + + + + + {formatAmount(trx.amount, trx.currency)} + + {trx.tax ? ( + + {t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })} + + ) : null} + + {formatDate(trx.purchased_at)} + + {trx.receipt_url ? ( + void handleReceiptDownload(trx)}> + + {t('billing.sections.transactions.labels.receipt', 'Beleg')} + + + ) : null} + + + ); + })} )} diff --git a/resources/js/admin/mobile/__tests__/BillingPage.test.tsx b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx new file mode 100644 index 00000000..ef0348b4 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const navigateMock = vi.fn(); + +const tMock = ( + _key: string, + fallback?: string | Record, + options?: Record, +) => { + let value = typeof fallback === 'string' ? fallback : _key; + if (options) { + Object.entries(options).forEach(([key, val]) => { + value = value.replaceAll(`{{${key}}}`, String(val)); + }); + } + return value; +}; + +const downloadReceiptMock = vi.fn().mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); +const triggerDownloadMock = vi.fn(); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, + useLocation: () => ({ pathname: '/mobile/billing', search: '', hash: '' }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: tMock }), + initReactI18next: { + type: '3rdParty', + init: () => undefined, + }, +})); + +vi.mock('lucide-react', () => ({ + Package: () => , + Receipt: () => , + RefreshCcw: () => , + Sparkles: () => , +})); + +vi.mock('react-hot-toast', () => { + const toast = Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + }); + return { default: toast }; +}); + +vi.mock('../hooks/useBackNavigation', () => ({ + useBackNavigation: () => vi.fn(), +})); + +vi.mock('../theme', () => ({ + useAdminTheme: () => ({ + textStrong: '#111827', + text: '#111827', + muted: '#6b7280', + subtle: '#9ca3af', + danger: '#b91c1c', + border: '#e5e7eb', + primary: '#2563eb', + accentSoft: '#eef2ff', + }), +})); + +vi.mock('../components/MobileShell', () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) =>
{children}
, + HeaderActionButton: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/Primitives', () => ({ + MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => ( + + ), + PillBadge: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('../components/ContextHelpLink', () => ({ + ContextHelpLink: () =>
, +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ + children, + onPress, + }: { + children: React.ReactNode; + onPress?: () => void; + }) => ( + + ), +})); + +vi.mock('../../lib/apiError', () => ({ + getApiErrorMessage: (_err: unknown, fallback: string) => fallback, +})); + +vi.mock('../../constants', () => ({ + ADMIN_EVENT_VIEW_PATH: '/mobile/events', + adminPath: (path: string) => path, +})); + +vi.mock('../billingUsage', () => ({ + buildPackageUsageMetrics: () => [], + formatPackageEventAllowance: () => '—', + getUsageState: () => 'ok', + usagePercent: () => 0, +})); + +vi.mock('../lib/packageSummary', () => ({ + collectPackageFeatures: () => [], + formatEventUsage: () => '', + getPackageFeatureLabel: () => '', + getPackageLimitEntries: () => [], + resolveTenantWatermarkFeatureKey: () => '', +})); + +vi.mock('../lib/billingCheckout', () => ({ + loadPendingCheckout: () => null, + shouldClearPendingCheckout: () => false, + storePendingCheckout: vi.fn(), +})); + +vi.mock('../invite-layout/export-utils', () => ({ + triggerDownloadFromBlob: (...args: unknown[]) => triggerDownloadMock(...args), +})); + +vi.mock('../../api', () => ({ + getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }), + getTenantBillingTransactions: vi.fn().mockResolvedValue({ + data: [ + { + id: 1, + status: 'completed', + amount: 49, + currency: 'EUR', + provider: 'paypal', + provider_id: 'ORDER-1', + package_name: 'Starter', + purchased_at: '2024-01-01T00:00:00Z', + receipt_url: '/api/v1/billing/transactions/1/receipt', + }, + ], + }), + getTenantAddonHistory: vi.fn().mockResolvedValue({ data: [] }), + getTenantPackageCheckoutStatus: vi.fn(), + downloadTenantBillingReceipt: (...args: unknown[]) => downloadReceiptMock(...args), +})); + +import MobileBillingPage from '../BillingPage'; + +describe('MobileBillingPage', () => { + it('downloads receipts via the API helper', async () => { + render(); + + const receiptLink = await screen.findByText('Beleg'); + fireEvent.click(receiptLink); + + await waitFor(() => { + expect(downloadReceiptMock).toHaveBeenCalledWith('/api/v1/billing/transactions/1/receipt'); + expect(triggerDownloadMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/resources/js/admin/mobile/__tests__/packageSummary.test.ts b/resources/js/admin/mobile/__tests__/packageSummary.test.ts new file mode 100644 index 00000000..2b4b99b1 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/packageSummary.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { getPackageLimitEntries } from '../lib/packageSummary'; + +const t = (_key: string, fallback?: string) => fallback ?? _key; + +describe('getPackageLimitEntries', () => { + it('defaults endcustomer event limit to 1 when missing', () => { + const entries = getPackageLimitEntries({}, t, {}, { packageType: 'endcustomer' }); + const eventEntry = entries.find((entry) => entry.key === 'max_events_per_year'); + + expect(eventEntry?.value).toBe('1'); + }); +}); diff --git a/resources/js/admin/mobile/components/BottomNav.tsx b/resources/js/admin/mobile/components/BottomNav.tsx index f944013b..cb6fefd6 100644 --- a/resources/js/admin/mobile/components/BottomNav.tsx +++ b/resources/js/admin/mobile/components/BottomNav.tsx @@ -18,6 +18,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: const { t } = useTranslation('mobile'); const location = useLocation(); const theme = useAdminTheme(); + const navRef = React.useRef(null); // Modern Glass Background const navSurface = theme.glassSurfaceStrong ?? theme.surfaceMuted ?? theme.surface; @@ -35,8 +36,46 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: { key: 'profile', icon: User, label: t('nav.profile', 'Profile') }, ]; + const setBottomOffset = React.useCallback(() => { + if (typeof document === 'undefined' || !navRef.current) { + return; + } + + const height = Math.ceil(navRef.current.getBoundingClientRect().height); + document.documentElement.style.setProperty('--admin-bottom-nav-offset', `${height}px`); + }, []); + + React.useLayoutEffect(() => { + if (typeof window === 'undefined') { + return; + } + + setBottomOffset(); + + const handleResize = () => setBottomOffset(); + if (typeof ResizeObserver !== 'undefined' && navRef.current) { + const observer = new ResizeObserver(() => setBottomOffset()); + observer.observe(navRef.current); + window.addEventListener('resize', handleResize); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', handleResize); + document.documentElement.style.removeProperty('--admin-bottom-nav-offset'); + }; + } + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + document.documentElement.style.removeProperty('--admin-bottom-nav-offset'); + }; + }, [setBottomOffset]); + return ( {!online ? ( diff --git a/resources/js/admin/mobile/components/UserMenuSheet.tsx b/resources/js/admin/mobile/components/UserMenuSheet.tsx index 8bff92ff..2b38b813 100644 --- a/resources/js/admin/mobile/components/UserMenuSheet.tsx +++ b/resources/js/admin/mobile/components/UserMenuSheet.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronRight, CreditCard, FileText, HelpCircle, Moon, Sun, User, X } from 'lucide-react'; +import { ChevronRight, CreditCard, FileText, HelpCircle, Monitor, Moon, Sun, User, X } from 'lucide-react'; import { XStack, YStack, SizableText as Text, ListItem, YGroup, Separator } from 'tamagui'; import { Pressable } from '@tamagui/react-native-web-lite'; import { ToggleGroup } from '@tamagui/toggle-group'; @@ -32,6 +32,12 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM }, [i18n.language]); const themeValue: 'light' | 'dark' = (appearance === 'system' ? resolved : appearance) ?? 'light'; + const selectedAppearance: 'light' | 'dark' | 'system' = appearance ?? 'system'; + const resolvedLabel = + themeValue === 'dark' + ? t('mobileProfile.themeDark', 'Dark') + : t('mobileProfile.themeLight', 'Light'); + const systemLabel = t('mobileProfile.themeSystemLabel', 'System ({{mode}})', { mode: resolvedLabel }); const activeToggleBg = theme.accentSoft ?? withAlpha(theme.primary, 0.18); const activeToggleBorder = withAlpha(theme.primary, 0.45); @@ -244,9 +250,9 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM iconAfter={ { - if (next === 'light' || next === 'dark') { + if (next === 'light' || next === 'dark' || next === 'system') { updateAppearance(next); } }} @@ -258,25 +264,24 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM {([ { key: 'light', label: t('mobileProfile.themeLight', 'Light'), icon: Sun }, { key: 'dark', label: t('mobileProfile.themeDark', 'Dark'), icon: Moon }, + { key: 'system', label: systemLabel, icon: Monitor }, ] as const).map((option) => { - const active = option.key === themeValue; + const active = option.key === selectedAppearance; const Icon = option.icon; return ( - - - - {option.label} - + + ); @@ -288,7 +293,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM {appearance === 'system' ? ( - {t('mobileProfile.themeSystem', 'System')} + {t('mobileProfile.themeSystemHint', 'Following device setting: {{mode}}', { mode: resolvedLabel })} ) : null} diff --git a/resources/js/admin/mobile/components/__tests__/BottomNav.test.tsx b/resources/js/admin/mobile/components/__tests__/BottomNav.test.tsx new file mode 100644 index 00000000..4bd6ef2a --- /dev/null +++ b/resources/js/admin/mobile/components/__tests__/BottomNav.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; +const originalResizeObserver = globalThis.ResizeObserver; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children, ...props }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children, ...props }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => ( + + ), +})); + +vi.mock('../../theme', () => ({ + useAdminTheme: () => ({ + primary: '#FF5A5F', + muted: '#6b7280', + glassSurfaceStrong: 'rgba(255,255,255,0.9)', + surfaceMuted: '#f8fafc', + surface: '#ffffff', + glassBorder: 'rgba(229,231,235,0.7)', + border: '#e5e7eb', + glassShadow: 'rgba(15,23,42,0.14)', + shadow: 'rgba(0,0,0,0.12)', + }), +})); + +vi.mock('../../../constants', () => ({ + adminPath: (path: string) => path, +})); + +import { BottomNav } from '../BottomNav'; + +describe('BottomNav', () => { + beforeEach(() => { + HTMLElement.prototype.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 0, + height: 84, + top: 0, + left: 0, + right: 0, + bottom: 84, + toJSON: () => ({}), + }) as DOMRect; + + (globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class { + private callback: () => void; + + constructor(callback: () => void) { + this.callback = callback; + } + + observe() { + this.callback(); + } + + disconnect() {} + }; + + document.documentElement.style.removeProperty('--admin-bottom-nav-offset'); + }); + + afterEach(() => { + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + document.documentElement.style.removeProperty('--admin-bottom-nav-offset'); + if (originalResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver; + } else { + delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + } + }); + + it('sets the admin bottom nav offset CSS variable', async () => { + render( + + undefined} /> + + ); + + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--admin-bottom-nav-offset')).toBe('84px'); + }); + }); +}); diff --git a/resources/js/admin/mobile/lib/packageSummary.ts b/resources/js/admin/mobile/lib/packageSummary.ts index 1c40f83e..fcd530ca 100644 --- a/resources/js/admin/mobile/lib/packageSummary.ts +++ b/resources/js/admin/mobile/lib/packageSummary.ts @@ -202,7 +202,13 @@ export function getPackageLimitEntries( key, label: t(labelKey, fallback), value: formatLimitWithRemaining( - toNumber((limits as Record)[key]), + (() => { + const limitValue = toNumber((limits as Record)[key]); + if (key === 'max_events_per_year' && options.packageType !== 'reseller' && limitValue === null) { + return 1; + } + return limitValue; + })(), resolveRemainingForKey(limits, key, usageOverrides), t ), diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index 302f232d..5d781f49 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -54,6 +54,7 @@ export default function BottomNav() { const { event, status } = useEventData(); const { t } = useTranslation(); const { branding } = useEventBranding(); + const navRef = React.useRef(null); const radius = branding.buttons?.radius ?? 12; const buttonStyle = branding.buttons?.style ?? 'filled'; const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; @@ -83,9 +84,46 @@ export default function BottomNav() { const compact = isUploadActive; const navPaddingBottom = `calc(env(safe-area-inset-bottom, 0px) + ${compact ? 12 : 18}px)`; + const setBottomOffset = React.useCallback(() => { + if (typeof document === 'undefined' || !navRef.current) { + return; + } + + const height = Math.ceil(navRef.current.getBoundingClientRect().height); + document.documentElement.style.setProperty('--guest-bottom-nav-offset', `${height}px`); + }, []); + + React.useLayoutEffect(() => { + if (typeof window === 'undefined') { + return; + } + + setBottomOffset(); + + const handleResize = () => setBottomOffset(); + if (typeof ResizeObserver !== 'undefined' && navRef.current) { + const observer = new ResizeObserver(() => setBottomOffset()); + observer.observe(navRef.current); + window.addEventListener('resize', handleResize); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', handleResize); + document.documentElement.style.removeProperty('--guest-bottom-nav-offset'); + }; + } + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + document.documentElement.style.removeProperty('--guest-bottom-nav-offset'); + }; + }, [setBottomOffset, compact]); return (
({ + useEventData: () => ({ + status: 'ready', + event: { + id: 1, + default_locale: 'de', + }, + }), +})); + +vi.mock('../../i18n/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../../context/EventBrandingContext', () => ({ + useEventBranding: () => ({ + branding: { + primaryColor: '#0f172a', + secondaryColor: '#38bdf8', + backgroundColor: '#ffffff', + palette: { + surface: '#ffffff', + }, + buttons: { + radius: 12, + style: 'filled', + linkColor: '#0f172a', + }, + }, + }), +})); + +vi.mock('../../lib/engagement', () => ({ + isTaskModeEnabled: () => false, +})); + +describe('BottomNav', () => { + beforeEach(() => { + HTMLElement.prototype.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 0, + height: 80, + top: 0, + left: 0, + right: 0, + bottom: 80, + toJSON: () => ({}), + }) as DOMRect; + + (globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class { + private callback: () => void; + + constructor(callback: () => void) { + this.callback = callback; + } + + observe() { + this.callback(); + } + + disconnect() {} + }; + + document.documentElement.style.removeProperty('--guest-bottom-nav-offset'); + }); + + afterEach(() => { + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + document.documentElement.style.removeProperty('--guest-bottom-nav-offset'); + if (originalResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver; + } else { + delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + } + }); + + it('sets the bottom nav offset CSS variable', async () => { + render( + + + } /> + + + ); + + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--guest-bottom-nav-offset')).toBe('80px'); + }); + }); +}); diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index 74726096..75f53474 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -46,7 +46,7 @@ function HomeLayout() { if (!token) { return ( -
+
@@ -132,7 +132,7 @@ function EventBoundary({ token }: { token: string }) { -
+
@@ -341,7 +341,7 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled); return ( -
+
{children} diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index 90f28809..c021a680 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -168,7 +168,10 @@ const WizardBody: React.FC<{ return true; }, [atLastStep, authUser, currentStep, isAuthenticated, paymentCompleted, selectedPackage]); - const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]); + const shouldShowNextButton = useMemo( + () => currentStep !== 'confirmation' && currentStep !== 'payment', + [currentStep] + ); const highlightNextCta = currentStep === 'payment' && paymentCompleted; const handleNext = useCallback(() => { diff --git a/resources/js/pages/marketing/checkout/WizardContext.tsx b/resources/js/pages/marketing/checkout/WizardContext.tsx index dbfeb99f..936d0efd 100644 --- a/resources/js/pages/marketing/checkout/WizardContext.tsx +++ b/resources/js/pages/marketing/checkout/WizardContext.tsx @@ -12,6 +12,7 @@ interface CheckoutState { error: string | null; paymentCompleted: boolean; checkoutSessionId: string | null; + checkoutActionUrl: string | null; } interface CheckoutWizardContextType { @@ -29,6 +30,7 @@ interface CheckoutWizardContextType { } | null; paymentCompleted: boolean; checkoutSessionId: string | null; + checkoutActionUrl: string | null; selectPackage: (pkg: CheckoutPackage) => void; setSelectedPackage: (pkg: CheckoutPackage) => void; setAuthUser: (user: unknown) => void; @@ -44,6 +46,8 @@ interface CheckoutWizardContextType { setPaymentCompleted: (completed: boolean) => void; setCheckoutSessionId: (sessionId: string | null) => void; clearCheckoutSessionId: () => void; + setCheckoutActionUrl: (url: string | null) => void; + clearCheckoutActionUrl: () => void; } const CheckoutWizardContext = createContext(null); @@ -59,6 +63,7 @@ const initialState: CheckoutState = { error: null, paymentCompleted: false, checkoutSessionId: null, + checkoutActionUrl: null, }; type CheckoutAction = @@ -71,7 +76,8 @@ type CheckoutAction = | { type: 'SET_LOADING'; payload: boolean } | { type: 'SET_ERROR'; payload: string | null } | { type: 'SET_PAYMENT_COMPLETED'; payload: boolean } - | { type: 'SET_CHECKOUT_SESSION_ID'; payload: string | null }; + | { type: 'SET_CHECKOUT_SESSION_ID'; payload: string | null } + | { type: 'SET_CHECKOUT_ACTION_URL'; payload: string | null }; function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState { switch (action.type) { @@ -109,6 +115,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout return { ...state, paymentCompleted: action.payload }; case 'SET_CHECKOUT_SESSION_ID': return { ...state, checkoutSessionId: action.payload }; + case 'SET_CHECKOUT_ACTION_URL': + return { ...state, checkoutActionUrl: action.payload }; default: return state; } @@ -148,6 +156,7 @@ export function CheckoutWizardProvider({ }; const checkoutSessionStorageKey = 'checkout-session-id'; + const checkoutActionStorageKey = 'checkout-action-url'; const [state, dispatch] = useReducer(checkoutReducer, customInitialState); @@ -174,6 +183,11 @@ export function CheckoutWizardProvider({ if (storedSession) { dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: storedSession }); } + + const storedActionUrl = localStorage.getItem(checkoutActionStorageKey); + if (storedActionUrl) { + dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: storedActionUrl }); + } }, [initialPackage]); // Save state to localStorage whenever it changes @@ -199,6 +213,14 @@ export function CheckoutWizardProvider({ } }, [state.checkoutSessionId]); + useEffect(() => { + if (state.checkoutActionUrl) { + localStorage.setItem(checkoutActionStorageKey, state.checkoutActionUrl); + } else { + localStorage.removeItem(checkoutActionStorageKey); + } + }, [state.checkoutActionUrl]); + const selectPackage = useCallback((pkg: CheckoutPackage) => { dispatch({ type: 'SELECT_PACKAGE', payload: pkg }); }, []); @@ -241,6 +263,7 @@ export function CheckoutWizardProvider({ dispatch({ type: 'SET_ERROR', payload: null }); dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false }); dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null }); + dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null }); }, []); const setPaymentCompleted = useCallback((completed: boolean) => { @@ -253,6 +276,15 @@ export function CheckoutWizardProvider({ const clearCheckoutSessionId = useCallback(() => { dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null }); + dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null }); + }, []); + + const setCheckoutActionUrl = useCallback((url: string | null) => { + dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: url }); + }, []); + + const clearCheckoutActionUrl = useCallback(() => { + dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null }); }, []); const cancelCheckout = useCallback(() => { @@ -277,9 +309,10 @@ export function CheckoutWizardProvider({ // State aus localStorage entfernen localStorage.removeItem('checkout-wizard-state'); localStorage.removeItem(checkoutSessionStorageKey); + localStorage.removeItem(checkoutActionStorageKey); // Zur Package-Übersicht zurückleiten window.location.href = '/packages'; - }, [state, checkoutSessionStorageKey]); + }, [state, checkoutActionStorageKey, checkoutSessionStorageKey]); const value: CheckoutWizardContextType = { state, @@ -291,6 +324,7 @@ export function CheckoutWizardProvider({ paypalConfig: paypal ?? null, paymentCompleted: state.paymentCompleted, checkoutSessionId: state.checkoutSessionId, + checkoutActionUrl: state.checkoutActionUrl, selectPackage, setSelectedPackage, setAuthUser, @@ -306,6 +340,8 @@ export function CheckoutWizardProvider({ setPaymentCompleted, setCheckoutSessionId, clearCheckoutSessionId, + setCheckoutActionUrl, + clearCheckoutActionUrl, }; return ( diff --git a/resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx b/resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx index e38c8783..1b1181a5 100644 --- a/resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx +++ b/resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx @@ -98,7 +98,7 @@ describe('CheckoutWizard auth step navigation guard', () => { expect(screen.queryByTestId('payment-step')).not.toBeInTheDocument(); }); - it('only renders the next button on the payment step after the payment is completed', async () => { + it('does not render the next button on the payment step', async () => { const paidPackage = { ...basePackage, id: 2, price: 99 }; render( @@ -113,12 +113,10 @@ describe('CheckoutWizard auth step navigation guard', () => { await screen.findByTestId('payment-step'); - const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' }); - nextButtons.forEach((button) => expect(button).toBeDisabled()); + expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'mark-complete' })); - const activatedButtons = await screen.findAllByRole('button', { name: 'checkout.next' }); - activatedButtons.forEach((button) => expect(button).toBeEnabled()); + expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument(); }); }); diff --git a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx index 773cdb79..cef2724d 100644 --- a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx @@ -4,7 +4,7 @@ import { useCheckoutWizard } from "../WizardContext"; import { Trans, useTranslation } from 'react-i18next'; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { CalendarDays, CheckCircle2, ClipboardList, LoaderCircle, MailCheck, QrCode, ShieldCheck, Smartphone, Sparkles, XCircle } from "lucide-react"; +import { AlertTriangle, CalendarDays, CheckCircle2, ClipboardList, LoaderCircle, MailCheck, QrCode, ShieldCheck, Smartphone, Sparkles, XCircle } from "lucide-react"; import { cn } from "@/lib/utils"; interface ConfirmationStepProps { @@ -25,8 +25,10 @@ export const ConfirmationStep: React.FC = ({ onViewProfil checkoutSessionId, setPaymentCompleted, clearCheckoutSessionId, + checkoutActionUrl, + goToStep, } = useCheckoutWizard(); - const [status, setStatus] = useState<'processing' | 'completed' | 'failed'>( + const [status, setStatus] = useState<'processing' | 'completed' | 'failed' | 'action_required'>( checkoutSessionId ? 'processing' : 'completed', ); const [elapsedMs, setElapsedMs] = useState(0); @@ -79,6 +81,15 @@ export const ConfirmationStep: React.FC = ({ onViewProfil badge: 'bg-rose-50 text-rose-700 border-rose-200', }; } + if (status === 'action_required') { + return { + label: t('checkout.confirmation_step.status_state.action_required'), + body: t('checkout.confirmation_step.status_body_action_required'), + tone: 'text-amber-600', + icon: AlertTriangle, + badge: 'bg-amber-50 text-amber-700 border-amber-200', + }; + } return { label: t('checkout.confirmation_step.status_state.processing'), body: t('checkout.confirmation_step.status_body_processing'), @@ -88,7 +99,7 @@ export const ConfirmationStep: React.FC = ({ onViewProfil }; }, [status, t]); - const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed'> => { + const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed' | 'action_required'> => { if (!checkoutSessionId) { return 'completed'; } @@ -114,6 +125,10 @@ export const ConfirmationStep: React.FC = ({ onViewProfil return 'completed'; } + if (remoteStatus === 'requires_customer_action') { + return 'action_required'; + } + if (remoteStatus === 'failed' || remoteStatus === 'cancelled') { clearCheckoutSessionId(); return 'failed'; @@ -204,10 +219,15 @@ export const ConfirmationStep: React.FC = ({ onViewProfil if (status === 'failed') { return { payment: false, email: false, access: false }; } + if (status === 'action_required') { + return { payment: false, email: false, access: false }; + } return { payment: true, email: false, access: false }; }, [status]); const showManualActions = status === 'processing' && elapsedMs >= 30000; + const showActionRequired = status === 'action_required'; + const showFailedActions = status === 'failed'; const StatusIcon = statusCopy.icon; const handleStatusRetry = useCallback(async () => { @@ -227,6 +247,18 @@ export const ConfirmationStep: React.FC = ({ onViewProfil } }, []); + const handleContinueCheckout = useCallback(() => { + if (checkoutActionUrl && typeof window !== 'undefined') { + window.open(checkoutActionUrl, '_blank', 'noopener'); + return; + } + goToStep('payment'); + }, [checkoutActionUrl, goToStep]); + + const handleBackToPayment = useCallback(() => { + goToStep('payment'); + }, [goToStep]); + return (
@@ -314,6 +346,29 @@ export const ConfirmationStep: React.FC = ({ onViewProfil
)} + {showActionRequired && ( +
+

{t('checkout.confirmation_step.status_action_hint')}

+
+ + +
+
+ )} + {showFailedActions && ( +
+

{t('checkout.confirmation_step.status_failed_hint')}

+
+ +
+
+ )} diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index 8967c9f8..16a819d1 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -108,6 +108,34 @@ export function resolveCheckoutLocale(rawLocale?: string | null): string { return short || 'en'; } +const PAYPAL_LOCALE_FALLBACKS: Record = { + de: 'DE', + en: 'US', +}; + +function resolvePayPalLocale(rawLocale?: string | null): string | null { + if (!rawLocale) { + return null; + } + + const trimmed = rawLocale.trim(); + if (trimmed.length === 0) { + return null; + } + + const normalized = trimmed.replace('_', '-'); + const parts = normalized.split('-').filter(Boolean); + + if (parts.length >= 2) { + return `${parts[0].toLowerCase()}_${parts[1].toUpperCase()}`; + } + + const language = parts[0].toLowerCase(); + const region = PAYPAL_LOCALE_FALLBACKS[language]; + + return region ? `${language}_${region}` : null; +} + type PayPalSdkOptions = { clientId: string; currency: string; @@ -134,8 +162,9 @@ async function loadPayPalSdk(options: PayPalSdkOptions): Promise { setPaymentCompleted, checkoutSessionId, setCheckoutSessionId, + checkoutActionUrl, + setCheckoutActionUrl, + clearCheckoutActionUrl, paypalConfig, } = useCheckoutWizard(); const [status, setStatus] = useState('idle'); @@ -292,18 +324,18 @@ export const PaymentStep: React.FC = () => { } }, [RateLimitHelper, selectedPackage, t, trackEvent]); - useEffect(() => { - if (couponCode && selectedPackage) { - applyCoupon(couponCode); - } - }, [applyCoupon, couponCode, selectedPackage]); - useEffect(() => { setCouponPreview(null); setCouponNotice(null); setCouponError(null); }, [selectedPackage?.id]); + useEffect(() => { + if (selectedPackage) { + clearCheckoutActionUrl(); + } + }, [clearCheckoutActionUrl, selectedPackage?.id]); + useEffect(() => { if (typeof window === 'undefined') { return; @@ -343,6 +375,7 @@ export const PaymentStep: React.FC = () => { setConsentError(null); setFreeActivationBusy(true); + clearCheckoutActionUrl(); try { await refreshCheckoutCsrfToken(); @@ -469,6 +502,7 @@ export const PaymentStep: React.FC = () => { setMessage(t('checkout.payment_step.paypal_preparing')); setPaymentCompleted(false); setCheckoutSessionId(null); + setCheckoutActionUrl(null); await refreshCheckoutCsrfToken(); @@ -498,6 +532,11 @@ export const PaymentStep: React.FC = () => { checkoutSessionRef.current = payload.checkout_session_id; } + const approveUrl = typeof payload?.approve_url === 'string' ? payload.approve_url : null; + if (approveUrl) { + setCheckoutActionUrl(approveUrl); + } + const orderId = payload?.order_id; if (!orderId) { throw new Error('PayPal order ID missing.'); @@ -524,6 +563,11 @@ export const PaymentStep: React.FC = () => { return; } + if (payload?.status === 'requires_customer_action') { + nextStep(); + return; + } + setStatus('error'); setMessage(t('checkout.payment_step.paypal_error')); } catch (error) { @@ -554,7 +598,7 @@ export const PaymentStep: React.FC = () => { paypalButtonsRef.current.close(); } }; - }, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]); + }, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutActionUrl, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]); useEffect(() => { if (paypalActionsRef.current) { @@ -593,6 +637,12 @@ export const PaymentStep: React.FC = () => { } }, [couponPreview, trackEvent]); + const handleOpenPayPal = useCallback(() => { + if (checkoutActionUrl && typeof window !== 'undefined') { + window.open(checkoutActionUrl, '_blank', 'noopener'); + } + }, [checkoutActionUrl]); + const openWithdrawalModal = useCallback(async () => { setShowWithdrawalModal(true); @@ -771,6 +821,21 @@ export const PaymentStep: React.FC = () => {

{t('checkout.payment_step.guided_cta_hint')}

+ {checkoutActionUrl && ( +
+ +

+ {t('checkout.payment_step.resume_hint')} +

+
+ )}
diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index e15a505c..a6f01bfb 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -88,6 +88,13 @@ return [ 'limits_title' => 'Ihre Paketdetails', 'invoice_title' => 'Rechnung', 'invoice_link' => 'Rechnung öffnen', + 'invoice_seller_label' => 'Anbieter', + 'invoice_customer_label' => 'Kunde', + 'invoice_item_label' => 'Leistung', + 'invoice_type_label' => 'Typ', + 'invoice_tax_label' => 'Steuer', + 'invoice_total_label' => 'Gesamt', + 'invoice_footer' => 'Dieser Beleg wurde automatisch von Fotospiel erstellt.', 'cta' => 'Zum Event-Admin', 'provider' => [ 'lemonsqueezy' => 'PayPal', diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index a19f2032..69e6fd27 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -469,9 +469,9 @@ "secure_payment_desc": "Sichere Zahlung ueber PayPal.", "lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.", "guided_title": "Sichere Zahlung mit PayPal", - "guided_body": "Bezahle schnell und sicher mit PayPal. Dein Paket wird nach der Bestaetigung sofort freigeschaltet.", + "guided_body": "Bezahle schnell und sicher mit PayPal. Es oeffnet sich ein PayPal-Fenster – kehre danach hierher zurueck.", "lemonsqueezy_partner": "Powered by PayPal", - "guided_cta_hint": "Sicher abgewickelt ueber PayPal", + "guided_cta_hint": "Zahlung in PayPal abschliessen und hierher zurueckkehren.", "lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...", "lemonsqueezy_overlay_ready": "Der PayPal-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.", "lemonsqueezy_ready": "PayPal-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.", @@ -482,11 +482,88 @@ "pay_with_lemonsqueezy": "Weiter mit PayPal", "paypal_partner": "Powered by PayPal", "paypal_preparing": "PayPal-Checkout wird vorbereitet...", - "paypal_ready": "PayPal-Checkout ist bereit. Schließe die Zahlung ab, um fortzufahren.", + "paypal_ready": "PayPal-Checkout ist bereit. Schliesse die Zahlung in PayPal ab und kehre hierher zurueck.", "paypal_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", "paypal_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.", - "paypal_cancelled": "PayPal-Checkout wurde abgebrochen.", - "paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung." + "paypal_cancelled": "PayPal-Checkout wurde abgebrochen. Du kannst es unten erneut versuchen.", + "paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung.", + "resume_paypal": "Weiter in PayPal", + "resume_hint": "Falls PayPal nicht geoeffnet wurde oder geschlossen wurde, hier im neuen Tab fortsetzen." + }, + "confirmation_step": { + "title": "Bestätigung", + "subtitle": "Alles erledigt!", + "description": "Dein Paket ist aktiviert. Prüfe deine E-Mails für Details.", + "welcome": "Danke, dass du die Fotospiel App gewählt hast!", + "package_summary": "Dein Paket {name} ist jetzt freigeschaltet. Du kannst sofort mit der Einrichtung loslegen.", + "email_followup": "Wir haben dir gerade alle Details per E-Mail geschickt – inklusive Rechnung und den nächsten Schritten.", + "hero_badge": "Checkout abgeschlossen", + "hero_title": "Weiter geht's im Marketing-Dashboard", + "hero_body": "Wir haben deinen Zugang aktiviert und synchronisieren PayPal. Mit diesen Aufgaben startest du direkt durch.", + "hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln – diese Übersicht kannst du jederzeit erneut öffnen.", + "status_title": "Bestellstatus", + "status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.", + "status_state": { + "processing": "Wird bestätigt", + "completed": "Bestätigt", + "failed": "Aktion nötig", + "action_required": "Aktion erforderlich" + }, + "status_body_processing": "Wir synchronisieren dein Konto mit PayPal. Das kann einen Moment dauern.", + "status_body_completed": "Alles ist bereit. Dein Konto ist vollständig freigeschaltet.", + "status_body_failed": "Wir konnten den Kauf noch nicht bestätigen. Bitte prüfe den Status erneut oder kontaktiere den Support.", + "status_body_action_required": "PayPal benoetigt noch eine kurze Bestaetigung. Schliesse den Checkout ab, um dein Paket zu aktivieren.", + "status_manual_hint": "Dauert es zu lange? Du kannst den Status erneut prüfen oder die Seite aktualisieren.", + "status_retry": "Status prüfen", + "status_refresh": "Seite aktualisieren", + "status_action_hint": "Wir benoetigen noch deine PayPal-Bestaetigung, bevor wir dein Paket aktivieren koennen.", + "status_action_button": "PayPal-Checkout fortsetzen", + "status_action_back": "Zurueck zur Zahlung", + "status_failed_hint": "Die Zahlung wurde nicht abgeschlossen. Du kannst den Checkout erneut starten.", + "status_failed_back": "Zurueck zur Zahlung", + "status_items": { + "payment": { + "title": "Zahlung bestätigt", + "body": "Deine PayPal-Zahlung war erfolgreich." + }, + "email": { + "title": "Beleg versendet", + "body": "Die Bestätigungsmail ist unterwegs." + }, + "access": { + "title": "Zugang freigeschaltet", + "body": "Dashboard und PWA stehen bereit." + } + }, + "onboarding_title": "Vorschau auf deine Onboarding-Schritte", + "onboarding_subtitle": "Diese Aufgaben erwarten dich direkt nach dem Login.", + "onboarding_badge": "Nächste Schritte", + "onboarding_items": { + "event": { + "title": "Erstes Event anlegen", + "body": "Titel, Datum und Highlights festlegen – alles bleibt anpassbar." + }, + "invites": { + "title": "QR-Einladungen aktivieren", + "body": "Teile deinen Event-QR-Code oder den Shortcut-Link mit Gästen." + }, + "tasks": { + "title": "Fotoaufgaben planen", + "body": "Nutze Vorlagen oder füge eigene kreative Aufgaben hinzu." + } + }, + "control_center_title": "Event Control Center (PWA)", + "control_center_body": "Alle Live-Aufgaben erledigst du später im Control Center – optimiert für Mobilgeräte und offlinefähig.", + "control_center_hint": "Installiere die PWA direkt aus dem Dashboard.", + "package_title": "Dein Paket", + "package_body": "Dein Paket ist aktiviert und sofort einsatzbereit.", + "package_label": "Aktiviertes Paket", + "actions_title": "Nächste Schritte", + "actions_body": "Zum Adminbereich wechseln oder Profildaten prüfen.", + "package_activated": "Ihr Paket '{name}' ist aktiviert.", + "email_sent": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet.", + "open_profile": "Profil öffnen", + "to_admin": "Zum Admin-Bereich" } } } diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index 261d4b69..80033381 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -87,6 +87,13 @@ return [ 'limits_title' => 'Your package details', 'invoice_title' => 'Invoice', 'invoice_link' => 'Open invoice', + 'invoice_seller_label' => 'Seller', + 'invoice_customer_label' => 'Customer', + 'invoice_item_label' => 'Item', + 'invoice_type_label' => 'Type', + 'invoice_tax_label' => 'Tax', + 'invoice_total_label' => 'Total', + 'invoice_footer' => 'This receipt was generated automatically by Fotospiel.', 'cta' => 'Open Event Admin', 'provider' => [ 'lemonsqueezy' => 'PayPal', diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index 748b4f45..f19d8cca 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -465,9 +465,9 @@ "secure_payment_desc": "Secure payment with PayPal.", "lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.", "guided_title": "Secure checkout with PayPal", - "guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.", + "guided_body": "Pay quickly and securely with PayPal. A PayPal window opens; return here after approval to finish.", "lemonsqueezy_partner": "Powered by PayPal", - "guided_cta_hint": "Securely processed via PayPal", + "guided_cta_hint": "Complete the payment in PayPal and return here to finish.", "lemonsqueezy_preparing": "Preparing PayPal checkout...", "lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.", "lemonsqueezy_ready": "PayPal checkout opened in a new tab. Complete the payment and then continue here.", @@ -478,11 +478,88 @@ "pay_with_lemonsqueezy": "Continue with PayPal", "paypal_partner": "Powered by PayPal", "paypal_preparing": "Preparing PayPal checkout...", - "paypal_ready": "PayPal checkout is ready. Complete the payment to continue.", + "paypal_ready": "PayPal checkout is ready. Complete the payment in PayPal and return here.", "paypal_error": "We could not start the PayPal checkout. Please try again.", "paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.", - "paypal_cancelled": "PayPal checkout was cancelled.", - "paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase." + "paypal_cancelled": "PayPal checkout was cancelled. You can try again below.", + "paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.", + "resume_paypal": "Continue in PayPal", + "resume_hint": "If PayPal did not open or you closed it, continue in a new tab." + }, + "confirmation_step": { + "title": "Confirmation", + "subtitle": "All Done!", + "description": "Your package is activated. Check your email for details.", + "welcome": "Thank you for choosing the Fotospiel App!", + "package_summary": "Your {name} package is now active. You're ready to get everything set up.", + "email_followup": "We've just sent a confirmation email with your receipt and the next steps.", + "hero_badge": "Checkout complete", + "hero_title": "You're ready for the Marketing Dashboard", + "hero_body": "We activated your access and are syncing PayPal. Follow the checklist below to launch your first event.", + "hero_next": "Use the button below whenever you're ready to jump into your customer area—this summary is always available.", + "status_title": "Purchase status", + "status_subtitle": "We are finishing the handoff and syncing your account.", + "status_state": { + "processing": "Finalising", + "completed": "Confirmed", + "failed": "Needs attention", + "action_required": "Action required" + }, + "status_body_processing": "We are syncing your account with PayPal. This can take a minute.", + "status_body_completed": "Everything is ready. Your account is fully unlocked.", + "status_body_failed": "We could not confirm the purchase yet. Please try again or contact support.", + "status_body_action_required": "PayPal still needs a quick confirmation. Complete the checkout to activate your package.", + "status_manual_hint": "Still waiting? You can re-check the status or refresh the page.", + "status_retry": "Check status", + "status_refresh": "Refresh page", + "status_action_hint": "We still need PayPal approval before we can activate your package.", + "status_action_button": "Continue PayPal checkout", + "status_action_back": "Back to payment", + "status_failed_hint": "The payment did not complete. You can start the checkout again.", + "status_failed_back": "Back to payment", + "status_items": { + "payment": { + "title": "Payment confirmed", + "body": "Your PayPal payment was successful." + }, + "email": { + "title": "Receipt sent", + "body": "A confirmation email is on its way." + }, + "access": { + "title": "Access unlocked", + "body": "Your dashboard and PWA access are active." + } + }, + "onboarding_title": "Preview your onboarding steps", + "onboarding_subtitle": "These are the first tasks you'll see after logging in.", + "onboarding_badge": "Next steps", + "onboarding_items": { + "event": { + "title": "Create your first event", + "body": "Set title, date, and highlights. You can adjust everything later." + }, + "invites": { + "title": "Activate QR invites", + "body": "Share your event QR code or shortcut link with guests." + }, + "tasks": { + "title": "Plan photo tasks", + "body": "Pick from the library or add your own creative prompts." + } + }, + "control_center_title": "Event Control Center (PWA)", + "control_center_body": "You handle live moderation and uploads in the Control Center — mobile-first and offline-ready.", + "control_center_hint": "Install the PWA directly from the dashboard.", + "package_title": "Your package", + "package_body": "Your plan is active and ready to use.", + "package_label": "Activated package", + "actions_title": "Next actions", + "actions_body": "Jump into your admin area or update profile details.", + "package_activated": "Your package '{name}' is activated.", + "email_sent": "We have sent you a confirmation email.", + "open_profile": "Open Profile", + "to_admin": "To Admin Area" } } } diff --git a/resources/views/billing/receipt.blade.php b/resources/views/billing/receipt.blade.php new file mode 100644 index 00000000..39e2f158 --- /dev/null +++ b/resources/views/billing/receipt.blade.php @@ -0,0 +1,151 @@ + + + + + {{ __('emails.purchase.invoice_title') }} + + + +
+
+

{{ __('emails.purchase.invoice_title') }}

+
{{ __('emails.purchase.order_label') }}: {{ $orderId }}
+
{{ __('emails.purchase.date_label') }}: {{ $purchaseDate }}
+
+
+
{{ __('emails.purchase.provider_label') }}
+
{{ $providerLabel }}
+
+
+ +
+
+
{{ __('emails.purchase.invoice_seller_label') }}
+
{{ $companyName }}
+
{{ $companyEmail }}
+
+
+
{{ __('emails.purchase.invoice_customer_label') }}
+
{{ $buyerName }}
+ @if ($buyerEmail) +
{{ $buyerEmail }}
+ @endif + @if ($buyerAddress) +
{{ $buyerAddress }}
+ @endif +
+
+ +
+ + + + + + + + + + + + + + + +
{{ __('emails.purchase.invoice_item_label') }}{{ __('emails.purchase.invoice_type_label') }}{{ __('emails.purchase.price_label') }}
{{ $packageName }}{{ $packageTypeLabel }}{{ $amountFormatted }}
+ + + + @if ($taxFormatted) + + + + + @endif + + + + + +
{{ __('emails.purchase.invoice_tax_label') }}{{ $taxFormatted }}
{{ __('emails.purchase.invoice_total_label') }}{{ $totalFormatted }}
+
+ + + + diff --git a/routes/api.php b/routes/api.php index aa8979d9..d28f5af4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -461,6 +461,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::prefix('billing')->middleware('tenant.admin')->group(function () { Route::get('transactions', [TenantBillingController::class, 'transactions']) ->name('tenant.billing.transactions'); + Route::get('transactions/{purchase}/receipt', [TenantBillingController::class, 'receipt']) + ->name('tenant.billing.transactions.receipt'); Route::get('addons', [TenantBillingController::class, 'addons']) ->name('tenant.billing.addons'); Route::post('portal', [TenantBillingController::class, 'portal']) @@ -469,6 +471,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () { Route::get('transactions', [TenantBillingController::class, 'transactions']); + Route::get('transactions/{purchase}/receipt', [TenantBillingController::class, 'receipt']); Route::get('addons', [TenantBillingController::class, 'addons']); Route::post('portal', [TenantBillingController::class, 'portal']); }); diff --git a/tests/Feature/Api/Tenant/BillingReceiptTest.php b/tests/Feature/Api/Tenant/BillingReceiptTest.php new file mode 100644 index 00000000..c6d21ddb --- /dev/null +++ b/tests/Feature/Api/Tenant/BillingReceiptTest.php @@ -0,0 +1,27 @@ +create(['name' => 'Starter']); + $purchase = PackagePurchase::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'package_id' => $package->id, + 'provider' => 'paypal', + 'provider_id' => 'ORDER-123', + 'price' => 49.0, + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/transactions/'.$purchase->id.'/receipt'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); + } +} diff --git a/tests/Feature/Api/Tenant/BillingTransactionsTest.php b/tests/Feature/Api/Tenant/BillingTransactionsTest.php index 7d9e9f06..68708f06 100644 --- a/tests/Feature/Api/Tenant/BillingTransactionsTest.php +++ b/tests/Feature/Api/Tenant/BillingTransactionsTest.php @@ -2,49 +2,30 @@ namespace Tests\Feature\Api\Tenant; -use Illuminate\Http\Client\Request; -use Illuminate\Support\Facades\Http; +use App\Models\Package; +use App\Models\PackagePurchase; use Tests\Feature\Tenant\TenantTestCase; class BillingTransactionsTest extends TenantTestCase { - public function test_transactions_endpoint_creates_missing_lemonsqueezy_customer_id(): void + public function test_transactions_endpoint_returns_package_purchases(): void { - Http::fake(function (Request $request) { - $path = parse_url($request->url(), PHP_URL_PATH); - - if ($path === '/customers' && $request->method() === 'POST') { - return Http::response([ - 'data' => ['id' => 'cus_456'], - ], 200); - } - - if ($path === '/transactions' && $request->method() === 'GET') { - return Http::response([ - 'data' => [], - 'meta' => [ - 'pagination' => [ - 'next' => null, - 'previous' => null, - 'has_more' => false, - ], - ], - ], 200); - } - - return Http::response([], 404); - }); - - $this->tenant->forceFill(['lemonsqueezy_customer_id' => null])->save(); + $package = Package::factory()->create(['name' => 'Starter']); + $purchase = PackagePurchase::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'package_id' => $package->id, + 'provider' => 'paypal', + 'provider_id' => 'ORDER-123', + 'price' => 49.0, + ]); $response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/transactions'); $response->assertOk(); - $response->assertJsonPath('data', []); - - $this->assertDatabaseHas('tenants', [ - 'id' => $this->tenant->id, - 'lemonsqueezy_customer_id' => 'cus_456', + $response->assertJsonFragment([ + 'id' => $purchase->id, + 'provider' => 'paypal', + 'provider_id' => 'ORDER-123', ]); } } diff --git a/tests/Feature/Tenant/TenantPackageOverviewTest.php b/tests/Feature/Tenant/TenantPackageOverviewTest.php index bb32a01d..9b0276bc 100644 --- a/tests/Feature/Tenant/TenantPackageOverviewTest.php +++ b/tests/Feature/Tenant/TenantPackageOverviewTest.php @@ -93,5 +93,6 @@ class TenantPackageOverviewTest extends TenantTestCase $this->assertSame($package->id, $payload['active_package']['package_id']); $this->assertSame(['custom_branding'], $payload['active_package']['package_limits']['features']); + $this->assertSame(1, $payload['active_package']['remaining_events']); } }