verschieben des sofortigen verzichts auf das Widerrrufsrecht zum Anlegen des Events

This commit is contained in:
Codex Agent
2025-12-22 13:11:16 +01:00
parent 84234bfb8e
commit c947e638eb
29 changed files with 877 additions and 374 deletions

View File

@@ -8,10 +8,12 @@ use App\Http\Requests\Tenant\EventStoreRequest;
use App\Http\Resources\Tenant\EventJoinTokenResource; use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Http\Resources\Tenant\EventResource; use App\Http\Resources\Tenant\EventResource;
use App\Http\Resources\Tenant\PhotoResource; use App\Http\Resources\Tenant\PhotoResource;
use App\Models\CheckoutSession;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Models\GuestNotification; use App\Models\GuestNotification;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Photo; use App\Models\Photo;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
@@ -116,6 +118,17 @@ class EventController extends Controller
]); ]);
} }
$requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
$needsWaiver = $requiresWaiver && ! $existingWaiver;
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
throw ValidationException::withMessages([
'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.',
]);
}
$eventData = array_merge($validated, [ $eventData = array_merge($validated, [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'status' => $validated['status'] ?? 'draft', 'status' => $validated['status'] ?? 'draft',
@@ -180,15 +193,21 @@ class EventController extends Controller
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
]); ]);
$note = sprintf('Event #%d created (%s)', $event->id, $event->name); if ($package->isReseller()) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
throw new HttpException(402, 'Insufficient package allowance.'); throw new HttpException(402, 'Insufficient package allowance.');
}
} }
return $event; return $event;
}); });
if ($needsWaiver) {
$this->recordEventStartWaiver($tenant, $package, $latestPurchase);
}
$tenant->refresh(); $tenant->refresh();
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']); $event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
@@ -200,6 +219,45 @@ class EventController extends Controller
], 201); ], 201);
} }
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
{
return PackagePurchase::query()
->where('tenant_id', $tenant->id)
->where('package_id', $package->id)
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
}
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
{
$timestamp = now();
$legalVersion = config('app.legal_version', $timestamp->toDateString());
if ($purchase) {
$metadata = $purchase->metadata ?? [];
$consents = is_array($metadata['consents'] ?? null) ? $metadata['consents'] : [];
$consents['digital_content_waiver_at'] = $timestamp->toIso8601String();
$consents['legal_version'] = $consents['legal_version'] ?? $legalVersion;
$metadata['consents'] = $consents;
$purchase->metadata = $metadata;
$purchase->save();
}
$session = CheckoutSession::query()
->where('tenant_id', $tenant->id)
->where('package_id', $package->id)
->where('status', CheckoutSession::STATUS_COMPLETED)
->orderByDesc('completed_at')
->first();
if ($session && ! $session->digital_content_waiver_at) {
$session->digital_content_waiver_at = $timestamp;
$session->legal_version = $session->legal_version ?? $legalVersion;
$session->save();
}
}
public function show(Request $request, Event $event): JsonResponse public function show(Request $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');

View File

@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EventPackageAddon; use App\Models\EventPackageAddon;
use App\Services\Paddle\PaddleCustomerPortalService;
use App\Services\Paddle\PaddleCustomerService;
use App\Services\Paddle\PaddleTransactionService; use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -12,7 +14,11 @@ use Illuminate\Support\Facades\Log;
class TenantBillingController extends Controller class TenantBillingController extends Controller
{ {
public function __construct(private readonly PaddleTransactionService $paddleTransactions) {} public function __construct(
private readonly PaddleTransactionService $paddleTransactions,
private readonly PaddleCustomerService $paddleCustomers,
private readonly PaddleCustomerPortalService $portalSessions,
) {}
public function transactions(Request $request): JsonResponse public function transactions(Request $request): JsonResponse
{ {
@@ -116,4 +122,44 @@ class TenantBillingController extends Controller
], ],
]); ]);
} }
public function portal(Request $request): JsonResponse
{
$tenant = $request->attributes->get('tenant');
if (! $tenant) {
return response()->json([
'message' => 'Tenant not found.',
], 404);
}
try {
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
$session = $this->portalSessions->createSession($customerId);
} catch (\Throwable $exception) {
Log::warning('Failed to create Paddle customer portal session', [
'tenant_id' => $tenant->id,
'error' => $exception->getMessage(),
]);
return response()->json([
'message' => 'Failed to create Paddle customer portal session.',
], 502);
}
$url = Arr::get($session, 'data.urls.general.overview')
?? Arr::get($session, 'data.urls.general')
?? Arr::get($session, 'urls.general.overview')
?? Arr::get($session, 'urls.general');
if (! $url) {
return response()->json([
'message' => 'Paddle customer portal session missing URL.',
], 502);
}
return response()->json([
'url' => $url,
]);
}
} }

View File

@@ -219,16 +219,6 @@ class CheckoutController extends Controller
], 422); ], 422);
} }
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
if ($requiresWaiver && ! $request->boolean('accepted_waiver')) {
return response()->json([
'errors' => [
'accepted_waiver' => ['Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.'],
],
], 422);
}
$session = $sessions->createOrResume($user, $package, [ $session = $sessions->createOrResume($user, $package, [
'tenant' => $user->tenant, 'tenant' => $user->tenant,
'locale' => $validated['locale'] ?? null, 'locale' => $validated['locale'] ?? null,
@@ -241,7 +231,7 @@ class CheckoutController extends Controller
'accepted_terms_at' => $now, 'accepted_terms_at' => $now,
'accepted_privacy_at' => $now, 'accepted_privacy_at' => $now,
'accepted_withdrawal_notice_at' => $now, 'accepted_withdrawal_notice_at' => $now,
'digital_content_waiver_at' => $requiresWaiver && $request->boolean('accepted_waiver') ? $now : null, 'digital_content_waiver_at' => null,
'legal_version' => config('app.legal_version', $now->toDateString()), 'legal_version' => config('app.legal_version', $now->toDateString()),
])->save(); ])->save();

View File

@@ -38,14 +38,6 @@ class PaddleCheckoutController extends Controller
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
} }
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
if ($requiresWaiver && ! $request->boolean('accepted_waiver')) {
throw ValidationException::withMessages([
'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.',
]);
}
$session = $this->sessions->createOrResume($user, $package, [ $session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant, 'tenant' => $tenant,
]); ]);
@@ -58,7 +50,7 @@ class PaddleCheckoutController extends Controller
'accepted_terms_at' => $now, 'accepted_terms_at' => $now,
'accepted_privacy_at' => $now, 'accepted_privacy_at' => $now,
'accepted_withdrawal_notice_at' => $now, 'accepted_withdrawal_notice_at' => $now,
'digital_content_waiver_at' => $requiresWaiver ? $now : null, 'digital_content_waiver_at' => null,
'legal_version' => $this->resolveLegalVersion(), 'legal_version' => $this->resolveLegalVersion(),
])->save(); ])->save();
@@ -95,7 +87,6 @@ class PaddleCheckoutController extends Controller
'checkout_session_id' => (string) $session->id, 'checkout_session_id' => (string) $session->id,
'legal_version' => $session->legal_version, 'legal_version' => $session->legal_version,
'accepted_terms' => '1', 'accepted_terms' => '1',
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver') ? '1' : '0',
], ],
'customer' => array_filter([ 'customer' => array_filter([
'email' => $user->email, 'email' => $user->email,
@@ -112,7 +103,6 @@ class PaddleCheckoutController extends Controller
'coupon_code' => $couponCode ?: null, 'coupon_code' => $couponCode ?: null,
'legal_version' => $session->legal_version, 'legal_version' => $session->legal_version,
'accepted_terms' => true, 'accepted_terms' => true,
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver'),
], ],
'discount_id' => $discountId, 'discount_id' => $discountId,
]); ]);

View File

@@ -24,7 +24,6 @@ class CheckoutFreeActivationRequest extends FormRequest
return [ return [
'package_id' => ['required', 'exists:packages,id'], 'package_id' => ['required', 'exists:packages,id'],
'accepted_terms' => ['required', 'boolean', 'accepted'], 'accepted_terms' => ['required', 'boolean', 'accepted'],
'accepted_waiver' => ['nullable', 'boolean'],
'locale' => ['nullable', 'string', 'max:10'], 'locale' => ['nullable', 'string', 'max:10'],
]; ];
} }

View File

@@ -28,7 +28,6 @@ class PaddleCheckoutRequest extends FormRequest
'inline' => ['sometimes', 'boolean'], 'inline' => ['sometimes', 'boolean'],
'coupon_code' => ['nullable', 'string', 'max:64'], 'coupon_code' => ['nullable', 'string', 'max:64'],
'accepted_terms' => ['required', 'boolean', 'accepted'], 'accepted_terms' => ['required', 'boolean', 'accepted'],
'accepted_waiver' => ['sometimes', 'boolean'],
]; ];
} }

View File

@@ -67,6 +67,7 @@ class EventStoreRequest extends FormRequest
'settings.watermark.offset_x' => ['nullable', 'integer', 'min:-500', 'max:500'], 'settings.watermark.offset_x' => ['nullable', 'integer', 'min:-500', 'max:500'],
'settings.watermark.offset_y' => ['nullable', 'integer', 'min:-500', 'max:500'], 'settings.watermark.offset_y' => ['nullable', 'integer', 'min:-500', 'max:500'],
'settings.watermark_serve_originals' => ['nullable', 'boolean'], 'settings.watermark_serve_originals' => ['nullable', 'boolean'],
'accepted_waiver' => ['nullable', 'boolean'],
]; ];
} }

View File

@@ -10,6 +10,15 @@ class PackageLimitEvaluator
{ {
public function assessEventCreation(Tenant $tenant): ?array public function assessEventCreation(Tenant $tenant): ?array
{ {
$hasEndcustomerPackage = $tenant->tenantPackages()
->where('active', true)
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer'))
->exists();
if ($hasEndcustomerPackage) {
return null;
}
if ($tenant->hasEventAllowance()) { if ($tenant->hasEventAllowance()) {
return null; return null;
} }

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\Paddle;
class PaddleCustomerPortalService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* @param array{subscription_ids?: array<int, string>} $options
* @return array<string, mixed>
*/
public function createSession(string $customerId, array $options = []): array
{
$payload = [
'customer_id' => $customerId,
];
if (! empty($options['subscription_ids'])) {
$payload['subscription_ids'] = array_values(
array_filter($options['subscription_ids'], 'is_string')
);
}
return $this->client->post('/customer-portal-sessions', $payload);
}
}

View File

@@ -682,6 +682,7 @@ type EventSavePayload = {
status?: 'draft' | 'published' | 'archived'; status?: 'draft' | 'published' | 'archived';
is_active?: boolean; is_active?: boolean;
package_id?: number; package_id?: number;
accepted_waiver?: boolean;
settings?: Record<string, unknown> & { settings?: Record<string, unknown> & {
watermark?: WatermarkSettings; watermark?: WatermarkSettings;
watermark_serve_originals?: boolean | null; watermark_serve_originals?: boolean | null;
@@ -2158,6 +2159,27 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
}; };
} }
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
method: 'POST',
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to create Paddle portal session', response.status, payload);
throw new Error('Failed to create Paddle portal session');
}
const payload = await safeJson(response);
const url = payload?.url;
if (typeof url !== 'string' || url.length === 0) {
throw new Error('Paddle portal session missing URL');
}
return { url };
}
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
data: TenantAddonHistoryEntry[]; data: TenantAddonHistoryEntry[];
meta: PaginationMeta; meta: PaginationMeta;

View File

@@ -4,7 +4,11 @@
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.", "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
"actions": { "actions": {
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"exportCsv": "Export als CSV" "exportCsv": "Export als CSV",
"portal": "Im Paddle-Portal verwalten",
"portalBusy": "Portal wird geöffnet...",
"openPackages": "Pakete öffnen",
"contactSupport": "Support kontaktieren"
}, },
"stats": { "stats": {
"package": { "package": {
@@ -17,7 +21,7 @@
"helper": "Verfügbar: {{count}}" "helper": "Verfügbar: {{count}}"
}, },
"addons": { "addons": {
"label": "Add-ons", "label": "Zusatzpakete",
"helper": "Historie insgesamt" "helper": "Historie insgesamt"
}, },
"transactions": { "transactions": {
@@ -27,16 +31,19 @@
}, },
"errors": { "errors": {
"load": "Paketdaten konnten nicht geladen werden.", "load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge konnten nicht geladen werden." "more": "Weitere Einträge konnten nicht geladen werden.",
"portal": "Paddle-Portal konnte nicht geöffnet werden."
}, },
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Rechnungen & Zahlungen", "title": "Rechnungen & Zahlungen",
"hint": "Zahlungen prüfen und Belege herunterladen.",
"empty": "Keine Zahlungen gefunden." "empty": "Keine Zahlungen gefunden."
}, },
"addOns": { "addOns": {
"title": "Add-ons", "title": "Zusatzpakete",
"empty": "Keine Add-ons gebucht." "hint": "Zusatzkontingente je Event im Blick behalten.",
"empty": "Keine Zusatzpakete gebucht."
}, },
"overview": { "overview": {
"title": "Paketübersicht", "title": "Paketübersicht",
@@ -68,7 +75,8 @@
} }
}, },
"packages": { "packages": {
"title": "Paket-Historie", "title": "Pakete",
"hint": "Aktives Paket, Limits und Historie auf einen Blick.",
"description": "Übersicht über aktive und vergangene Pakete.", "description": "Übersicht über aktive und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.", "empty": "Noch keine Pakete gebucht.",
"card": { "card": {
@@ -335,6 +343,14 @@
"confirm": "Weiter zum Checkout", "confirm": "Weiter zum Checkout",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"eventStartConsent": {
"title": "Vor dem ersten Event",
"description": "Bitte bestätige den sofortigen Beginn der digitalen Leistung, bevor du dein erstes Event erstellst.",
"checkboxWaiver": "Ich verlange ausdrücklich, dass mit der Bereitstellung der digitalen Leistung jetzt begonnen wird. Mir ist bekannt, dass ich mein Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
"errorWaiver": "Bitte bestätige den sofortigen Leistungsbeginn und das vorzeitige Erlöschen des Widerrufsrechts.",
"confirm": "Event erstellen",
"cancel": "Abbrechen"
},
"placeholders": { "placeholders": {
"untitled": "Unbenanntes Event" "untitled": "Unbenanntes Event"
}, },
@@ -1369,55 +1385,6 @@
"confirm": { "confirm": {
"disable": "Photobooth-Zugang deaktivieren?" "disable": "Photobooth-Zugang deaktivieren?"
} }
},
"billing": {
"title": "Pakete & Abrechnung",
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV"
},
"errors": {
"load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge konnten nicht geladen werden."
},
"sections": {
"overview": {
"title": "Paketübersicht",
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
"empty": "Noch kein Paket aktiv.",
"emptyBadge": "Kein aktives Paket",
"cards": {
"package": {
"label": "Aktives Paket",
"helper": "Aktuell zugewiesen"
},
"used": {
"label": "Genutzte Events",
"helper": "Verfügbar: {{count}}"
},
"price": {
"label": "Preis (netto)"
},
"expires": {
"label": "Läuft ab",
"helper": "Automatische Verlängerung, falls aktiv"
}
}
}
},
"packages": {
"title": "Paket-Historie",
"description": "Übersicht über aktuelle und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"used": "Genutzte Events",
"available": "Verfügbar",
"expires": "Läuft ab"
}
}
} }
}, },
"settings": { "settings": {
@@ -2185,6 +2152,15 @@
"mobileBilling": { "mobileBilling": {
"packageFallback": "Paket", "packageFallback": "Paket",
"remainingEvents": "{{count}} Events", "remainingEvents": "{{count}} Events",
"openEvent": "Event öffnen",
"usage": {
"events": "Events",
"guests": "Gäste",
"photos": "Fotos",
"value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}",
"remaining": "Verbleibend {{count}}"
},
"status": { "status": {
"completed": "Abgeschlossen", "completed": "Abgeschlossen",
"pending": "Ausstehend", "pending": "Ausstehend",

View File

@@ -4,7 +4,11 @@
"subtitle": "Manage your purchased packages and track their durations.", "subtitle": "Manage your purchased packages and track their durations.",
"actions": { "actions": {
"refresh": "Refresh", "refresh": "Refresh",
"exportCsv": "Export CSV" "exportCsv": "Export CSV",
"portal": "Manage in Paddle",
"portalBusy": "Opening portal...",
"openPackages": "Open packages",
"contactSupport": "Contact support"
}, },
"stats": { "stats": {
"package": { "package": {
@@ -27,15 +31,18 @@
}, },
"errors": { "errors": {
"load": "Unable to load package data.", "load": "Unable to load package data.",
"more": "Unable to load more entries." "more": "Unable to load more entries.",
"portal": "Unable to open the Paddle portal."
}, },
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Invoices & payments", "title": "Invoices & payments",
"hint": "Review transactions and download receipts.",
"empty": "No payments found." "empty": "No payments found."
}, },
"addOns": { "addOns": {
"title": "Add-ons", "title": "Add-ons",
"hint": "Track extra photo, guest, or time bundles per event.",
"empty": "No add-ons booked." "empty": "No add-ons booked."
}, },
"overview": { "overview": {
@@ -68,7 +75,8 @@
} }
}, },
"packages": { "packages": {
"title": "Package history", "title": "Packages",
"hint": "Active package, limits, and history at a glance.",
"description": "Overview of active and past packages.", "description": "Overview of active and past packages.",
"empty": "No packages purchased yet.", "empty": "No packages purchased yet.",
"card": { "card": {
@@ -938,6 +946,14 @@
"confirm": "Continue to checkout", "confirm": "Continue to checkout",
"cancel": "Cancel" "cancel": "Cancel"
}, },
"eventStartConsent": {
"title": "Before your first event",
"description": "Please confirm the immediate start of the digital service before creating your first event.",
"checkboxWaiver": "I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.",
"errorWaiver": "Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.",
"confirm": "Create event",
"cancel": "Cancel"
},
"placeholders": { "placeholders": {
"untitled": "Untitled event" "untitled": "Untitled event"
}, },
@@ -1382,55 +1398,6 @@
"confirm": { "confirm": {
"disable": "Disable photobooth access?" "disable": "Disable photobooth access?"
} }
},
"billing": {
"title": "Packages & billing",
"subtitle": "Manage your purchased packages and track their durations.",
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV"
},
"errors": {
"load": "Unable to load package data.",
"more": "Unable to load more entries."
},
"sections": {
"overview": {
"title": "Package overview",
"description": "Your active package and the most important metrics.",
"empty": "No active package yet.",
"emptyBadge": "No active package",
"cards": {
"package": {
"label": "Active package",
"helper": "Currently assigned"
},
"used": {
"label": "Events used",
"helper": "Remaining: {{count}}"
},
"price": {
"label": "Price (net)"
},
"expires": {
"label": "Expires",
"helper": "Auto-renews if enabled"
}
}
}
},
"packages": {
"title": "Package history",
"description": "Overview of current and past packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
"statusInactive": "Inactive",
"used": "Used events",
"available": "Available",
"expires": "Expires"
}
}
} }
} }
, ,
@@ -2205,6 +2172,15 @@
"mobileBilling": { "mobileBilling": {
"packageFallback": "Package", "packageFallback": "Package",
"remainingEvents": "{{count}} events", "remainingEvents": "{{count}} events",
"openEvent": "Open event",
"usage": {
"events": "Events",
"guests": "Guests",
"photos": "Photos",
"value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}",
"remaining": "Remaining {{count}}"
},
"status": { "status": {
"completed": "Completed", "completed": "Completed",
"pending": "Pending", "pending": "Pending",

View File

@@ -1,13 +1,15 @@
import React from 'react'; import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CreditCard, Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react'; import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { import {
createTenantBillingPortalSession,
getTenantPackagesOverview, getTenantPackagesOverview,
getTenantPaddleTransactions, getTenantPaddleTransactions,
TenantPackageSummary, TenantPackageSummary,
@@ -15,7 +17,8 @@ import {
} from '../api'; } from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants'; import { ADMIN_EVENT_VIEW_PATH } from '../constants';
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage';
export default function MobileBillingPage() { export default function MobileBillingPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -27,8 +30,10 @@ export default function MobileBillingPage() {
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]); const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false);
const packagesRef = React.useRef<HTMLDivElement | null>(null); const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null); const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
setLoading(true); setLoading(true);
@@ -51,6 +56,35 @@ export default function MobileBillingPage() {
} }
}, [t]); }, [t]);
const scrollToPackages = React.useCallback(() => {
packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
const openSupport = React.useCallback(() => {
if (typeof window !== 'undefined') {
window.location.href = `mailto:${supportEmail}`;
}
}, [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 Paddle-Portal nicht öffnen.'));
toast.error(message);
} finally {
setPortalBusy(false);
}
}, [portalBusy, t]);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -80,6 +114,7 @@ export default function MobileBillingPage() {
<Text fontWeight="700" color="#b91c1c"> <Text fontWeight="700" color="#b91c1c">
{error} {error}
</Text> </Text>
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard> </MobileCard>
) : null} ) : null}
@@ -90,6 +125,14 @@ export default function MobileBillingPage() {
{t('billing.sections.packages.title', 'Packages')} {t('billing.sections.packages.title', 'Packages')}
</Text> </Text>
</XStack> </XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
</Text>
<CTAButton
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Paddle')}
onPress={openPortal}
disabled={portalBusy}
/>
{loading ? ( {loading ? (
<Text fontSize="$sm" color="#6b7280"> <Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')} {t('common.loading', 'Lädt...')}
@@ -97,7 +140,11 @@ export default function MobileBillingPage() {
) : ( ) : (
<YStack space="$2"> <YStack space="$2">
{activePackage ? ( {activePackage ? (
<PackageCard pkg={activePackage} label={t('billing.sections.packages.card.statusActive', 'Aktiv')} /> <PackageCard
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive
/>
) : null} ) : null}
{packages {packages
.filter((pkg) => !activePackage || pkg.id !== activePackage.id) .filter((pkg) => !activePackage || pkg.id !== activePackage.id)
@@ -115,14 +162,21 @@ export default function MobileBillingPage() {
{t('billing.sections.invoices.title', 'Invoices & Payments')} {t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text> </Text>
</XStack> </XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.invoices.hint', 'Review transactions and download receipts.')}
</Text>
{loading ? ( {loading ? (
<Text fontSize="$sm" color="#6b7280"> <Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')} {t('common.loading', 'Lädt...')}
</Text> </Text>
) : transactions.length === 0 ? ( ) : transactions.length === 0 ? (
<Text fontSize="$sm" color="#4b5563"> <YStack space="$2">
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')} <Text fontSize="$sm" color="#4b5563">
</Text> {t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
</Text>
<CTAButton label={t('billing.actions.openPackages', 'Open packages')} onPress={scrollToPackages} />
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
</YStack>
) : ( ) : (
<YStack space="$1.5"> <YStack space="$1.5">
{transactions.slice(0, 8).map((trx) => ( {transactions.slice(0, 8).map((trx) => (
@@ -169,6 +223,9 @@ export default function MobileBillingPage() {
{t('billing.sections.addOns.title', 'Add-ons')} {t('billing.sections.addOns.title', 'Add-ons')}
</Text> </Text>
</XStack> </XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
</Text>
{loading ? ( {loading ? (
<Text fontSize="$sm" color="#6b7280"> <Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')} {t('common.loading', 'Lädt...')}
@@ -190,12 +247,13 @@ export default function MobileBillingPage() {
); );
} }
function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string }) { function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0; const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0;
const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
const usageMetrics = buildPackageUsageMetrics(pkg);
return ( return (
<MobileCard borderColor="#e5e7eb" space="$2"> <MobileCard borderColor={isActive ? '#2563eb' : '#e5e7eb'} borderWidth={isActive ? 2 : 1} backgroundColor={isActive ? '#eff6ff' : undefined} space="$2">
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a"> <Text fontSize="$md" fontWeight="800" color="#0f172a">
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')} {pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
@@ -217,9 +275,13 @@ function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))} {renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))} {renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
</XStack> </XStack>
<YStack space="$1.5" marginTop="$2"> {usageMetrics.length ? (
<FeatureList pkg={pkg} /> <YStack space="$2" marginTop="$2">
</YStack> {usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
</YStack>
) : null}
</MobileCard> </MobileCard>
); );
} }
@@ -231,43 +293,45 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>; return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
} }
function FeatureList({ pkg }: { pkg: TenantPackageSummary }) { function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const limits = pkg.package_limits ?? {}; const labelMap: Record<PackageUsageMetric['key'], string> = {
const features = (pkg as any).features as string[] | undefined; events: t('mobileBilling.usage.events', 'Events'),
guests: t('mobileBilling.usage.guests', 'Guests'),
photos: t('mobileBilling.usage.photos', 'Photos'),
};
const rows: Array<{ label: string; value: string }> = []; if (!metric.limit) {
return null;
if (limits.max_photos !== undefined && limits.max_photos !== null) {
rows.push({ label: t('billing.features.maxPhotos', 'Max photos'), value: String(limits.max_photos) });
}
if (limits.max_guests !== undefined && limits.max_guests !== null) {
rows.push({ label: t('billing.features.maxGuests', 'Max guests'), value: String(limits.max_guests) });
}
if (limits.gallery_days !== undefined && limits.gallery_days !== null) {
rows.push({ label: t('billing.features.galleryDays', 'Gallery days'), value: String(limits.gallery_days) });
}
if (limits.max_tasks !== undefined && limits.max_tasks !== null) {
rows.push({ label: t('billing.features.maxTasks', 'Max tasks'), value: String(limits.max_tasks) });
}
if (Array.isArray(features) && features.length) {
rows.push({ label: t('billing.features.featureList', 'Included features'), value: features.join(', ') });
} }
if (!rows.length) return null; const hasUsage = metric.used !== null;
const valueText = hasUsage
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
: t('mobileBilling.usage.limit', { limit: metric.limit });
const remainingText = metric.remaining !== null
? t('mobileBilling.usage.remaining', { count: metric.remaining })
: null;
const fill = usagePercent(metric);
return ( return (
<YStack space="$1"> <YStack space="$1.5">
{rows.map((row) => ( <XStack alignItems="center" justifyContent="space-between">
<XStack key={row.label} alignItems="center" justifyContent="space-between"> <Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color="#6b7280"> {labelMap[metric.key]}
{row.label} </Text>
</Text> <Text fontSize="$xs" color="#0f172a" fontWeight="700">
<Text fontSize="$xs" color="#0f172a" fontWeight="700"> {valueText}
{row.value} </Text>
</Text> </XStack>
</XStack> <YStack height={6} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
))} <YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? '#2563eb' : '#94a3b8'} />
</YStack>
{remainingText ? (
<Text fontSize="$xs" color="#6b7280">
{remainingText}
</Text>
) : null}
</YStack> </YStack>
); );
} }
@@ -286,6 +350,7 @@ function formatAmount(value: number | null | undefined, currency: string | null
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) { function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const navigate = useNavigate();
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = { const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') }, completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') }, pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
@@ -296,6 +361,21 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) || (addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) || (addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
null; null;
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
const impactBadges = hasImpact ? (
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
) : null;
return ( return (
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5"> <MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
@@ -305,28 +385,31 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
</Text> </Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge> <PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack> </XStack>
{eventName ? (
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#0f172a" fontWeight="600">
{eventName}
</Text>
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
{t('mobileBilling.openEvent', 'Open event')}
</Text>
</XStack>
</Pressable>
) : (
<Text fontSize="$xs" color="#9ca3af">
{eventName}
</Text>
)
) : null}
{impactBadges}
<Text fontSize="$sm" color="#0f172a" marginTop="$1.5">
{formatAmount(addon.amount, addon.currency)}
</Text>
<Text fontSize="$xs" color="#6b7280"> <Text fontSize="$xs" color="#6b7280">
{formatDate(addon.purchased_at)} {formatDate(addon.purchased_at)}
</Text> </Text>
{eventName ? (
<Text fontSize="$xs" color="#9ca3af">
{eventName}
</Text>
) : null}
<XStack space="$2" marginTop="$1">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
<Text fontSize="$sm" color="#0f172a" marginTop="$1">
{formatAmount(addon.amount, addon.currency)}
</Text>
</MobileCard> </MobileCard>
); );
} }

View File

@@ -7,11 +7,12 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api'; import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
import { resolveEventSlugAfterUpdate } from './eventFormNavigation'; import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiValidationMessage } from '../lib/apiError'; import { getApiValidationMessage, isApiError } from '../lib/apiError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
type FormState = { type FormState = {
@@ -46,6 +47,9 @@ export default function MobileEventFormPage() {
const [typesLoading, setTypesLoading] = React.useState(false); const [typesLoading, setTypesLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit); const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentBusy, setConsentBusy] = React.useState(false);
const [pendingPayload, setPendingPayload] = React.useState<Parameters<typeof createEvent>[0] | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
@@ -99,24 +103,24 @@ export default function MobileEventFormPage() {
async function handleSubmit() { async function handleSubmit() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
if (isEdit && slug) { if (isEdit && slug) {
const updated = await updateEvent(slug, { const updated = await updateEvent(slug, {
name: form.name, name: form.name,
event_date: form.date || undefined, event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft', status: form.published ? 'published' : 'draft',
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
}, },
}); });
const nextSlug = resolveEventSlugAfterUpdate(slug, updated); const nextSlug = resolveEventSlugAfterUpdate(slug, updated);
navigate(adminPath(`/mobile/events/${nextSlug}`)); navigate(adminPath(`/mobile/events/${nextSlug}`));
} else { } else {
const payload = { const payload = {
name: form.name || t('eventForm.fields.name.fallback', 'Event'), name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`, slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined, event_date: form.date || undefined,
@@ -126,10 +130,56 @@ export default function MobileEventFormPage() {
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
}, },
}; } as Parameters<typeof createEvent>[0];
const { event } = await createEvent(payload as any); const { event } = await createEvent(payload);
navigate(adminPath(`/mobile/events/${event.slug}`)); navigate(adminPath(`/mobile/events/${event.slug}`));
} }
} catch (err) {
if (isAuthError(err)) {
return;
}
if (!isEdit && isWaiverRequiredError(err)) {
const payload = {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
status: (form.published ? 'published' : 'draft') as const,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
},
} as Parameters<typeof createEvent>[0];
setPendingPayload(payload);
setConsentOpen(true);
return;
}
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
}
async function handleConsentConfirm(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!pendingPayload) {
setConsentOpen(false);
return;
}
setConsentBusy(true);
try {
const { event } = await createEvent({
...pendingPayload,
accepted_waiver: consents.acceptedWaiver,
});
navigate(adminPath(`/mobile/events/${event.slug}`));
setConsentOpen(false);
setPendingPayload(null);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.')); const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
@@ -137,7 +187,7 @@ export default function MobileEventFormPage() {
toast.error(message); toast.error(message);
} }
} finally { } finally {
setSaving(false); setConsentBusy(false);
} }
} }
@@ -331,6 +381,37 @@ export default function MobileEventFormPage() {
onPress={() => handleSubmit()} onPress={() => handleSubmit()}
/> />
</YStack> </YStack>
<LegalConsentSheet
open={consentOpen}
onClose={() => {
if (consentBusy) return;
setConsentOpen(false);
setPendingPayload(null);
}}
onConfirm={handleConsentConfirm}
busy={consentBusy}
requireTerms={false}
requireWaiver
copy={{
title: t('events.eventStartConsent.title', 'Before your first event'),
description: t(
'events.eventStartConsent.description',
'Please confirm the immediate start of the digital service before creating your first event.',
),
checkboxWaiver: t(
'events.eventStartConsent.checkboxWaiver',
'I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.',
),
errorWaiver: t(
'events.eventStartConsent.errorWaiver',
'Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.',
),
confirm: t('events.eventStartConsent.confirm', 'Create event'),
cancel: t('events.eventStartConsent.cancel', 'Cancel'),
}}
t={t}
/>
</MobileShell> </MobileShell>
); );
} }
@@ -364,6 +445,19 @@ function renderName(name: TenantEvent['name']): string {
return ''; return '';
} }
function isWaiverRequiredError(error: unknown): boolean {
if (!isApiError(error)) {
return false;
}
const metaErrors = error.meta?.errors;
if (!metaErrors || typeof metaErrors !== 'object') {
return false;
}
return 'accepted_waiver' in metaErrors;
}
function toDateTimeLocal(value?: string | null): string { function toDateTimeLocal(value?: string | null): string {
if (!value) return ''; if (!value) return '';

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import type { TenantPackageSummary } from '../../api';
import { buildPackageUsageMetrics, usagePercent } from '../billingUsage';
const basePackage: TenantPackageSummary = {
id: 1,
package_id: 1,
package_name: 'Pro',
active: true,
used_events: 2,
remaining_events: 3,
price: 120,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: {
max_events_per_year: 5,
max_guests: 150,
max_photos: 1000,
},
branding_allowed: true,
watermark_allowed: true,
features: null,
};
describe('buildPackageUsageMetrics', () => {
it('builds usage metrics for event, guest, and photo limits', () => {
const metrics = buildPackageUsageMetrics(basePackage);
const keys = metrics.map((metric) => metric.key);
expect(keys).toEqual(['events', 'guests', 'photos']);
const eventMetric = metrics.find((metric) => metric.key === 'events');
expect(eventMetric?.used).toBe(2);
expect(eventMetric?.limit).toBe(5);
expect(eventMetric?.remaining).toBe(3);
});
it('filters metrics without limits', () => {
const metrics = buildPackageUsageMetrics({
...basePackage,
package_limits: { max_events_per_year: 0 },
});
expect(metrics).toHaveLength(0);
});
});
describe('usagePercent', () => {
it('calculates usage percent when usage is known', () => {
const metrics = buildPackageUsageMetrics(basePackage);
const eventMetric = metrics.find((metric) => metric.key === 'events');
expect(eventMetric).toBeTruthy();
expect(usagePercent(eventMetric!)).toBe(40);
});
it('defaults to full bar when usage is unknown', () => {
const metrics = buildPackageUsageMetrics({
...basePackage,
used_events: NaN,
remaining_events: null,
package_limits: { max_events_per_year: 10 },
});
expect(usagePercent(metrics[0])).toBe(100);
});
});

View File

@@ -0,0 +1,83 @@
import type { TenantPackageSummary } from '../api';
export type PackageUsageMetricKey = 'events' | 'guests' | 'photos';
export type PackageUsageMetric = {
key: PackageUsageMetricKey;
limit: number | null;
used: number | null;
remaining: number | null;
};
const toNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
};
const resolveLimitValue = (limits: Record<string, unknown> | null, key: string): number | null => {
const value = limits ? toNumber(limits[key]) : null;
if (value === null || value <= 0) {
return null;
}
return value;
};
const resolveUsageValue = (value: unknown): number | null => {
const normalized = toNumber(value);
if (normalized === null || normalized < 0) {
return null;
}
return normalized;
};
const deriveUsedFromRemaining = (limit: number | null, remaining: number | null): number | null => {
if (limit === null || remaining === null) {
return null;
}
return Math.max(limit - remaining, 0);
};
export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsageMetric[] => {
const limits = pkg.package_limits ?? {};
const eventLimit = resolveLimitValue(limits, 'max_events_per_year');
const eventRemaining = resolveUsageValue(pkg.remaining_events);
const eventUsed = resolveUsageValue(pkg.used_events) ?? deriveUsedFromRemaining(eventLimit, eventRemaining);
const guestLimit = resolveLimitValue(limits, 'max_guests');
const guestRemaining = resolveUsageValue(limits['remaining_guests']);
const guestUsed = resolveUsageValue(limits['used_guests']) ?? deriveUsedFromRemaining(guestLimit, guestRemaining);
const photoLimit = resolveLimitValue(limits, 'max_photos');
const photoRemaining = resolveUsageValue(limits['remaining_photos']);
const photoUsed = resolveUsageValue(limits['used_photos']) ?? deriveUsedFromRemaining(photoLimit, photoRemaining);
return [
{ key: 'events', limit: eventLimit, used: eventUsed, remaining: eventRemaining },
{ key: 'guests', limit: guestLimit, used: guestUsed, remaining: guestRemaining },
{ key: 'photos', limit: photoLimit, used: photoUsed, remaining: photoRemaining },
].filter((metric) => metric.limit !== null);
};
export const usagePercent = (metric: PackageUsageMetric): number => {
if (!metric.limit || metric.limit <= 0) {
return 0;
}
if (metric.used === null || metric.used < 0) {
return 100;
}
return Math.min(100, Math.max(0, Math.round((metric.used / metric.limit) * 100)));
};

View File

@@ -11,11 +11,31 @@ type LegalConsentSheetProps = {
onClose: () => void; onClose: () => void;
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => Promise<void> | void; onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => Promise<void> | void;
busy?: boolean; busy?: boolean;
requireTerms?: boolean;
requireWaiver?: boolean; requireWaiver?: boolean;
copy?: {
title?: string;
description?: string;
checkboxTerms?: string;
checkboxWaiver?: string;
errorTerms?: string;
errorWaiver?: string;
confirm?: string;
cancel?: string;
};
t: Translator; t: Translator;
}; };
export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requireWaiver = true, t }: LegalConsentSheetProps) { export function LegalConsentSheet({
open,
onClose,
onConfirm,
busy = false,
requireTerms = true,
requireWaiver = true,
copy,
t,
}: LegalConsentSheetProps) {
const [acceptedTerms, setAcceptedTerms] = React.useState(false); const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false); const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -29,25 +49,28 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
}, [open]); }, [open]);
async function handleConfirm() { async function handleConfirm() {
if (!acceptedTerms) { if (requireTerms && !acceptedTerms) {
setError(t('events.legalConsent.errorTerms', 'Please confirm the terms.')); setError(copy?.errorTerms ?? t('events.legalConsent.errorTerms', 'Please confirm the terms.'));
return; return;
} }
if (requireWaiver && !acceptedWaiver) { if (requireWaiver && !acceptedWaiver) {
setError(t('events.legalConsent.errorWaiver', 'Please confirm the waiver.')); setError(copy?.errorWaiver ?? t('events.legalConsent.errorWaiver', 'Please confirm the waiver.'));
return; return;
} }
setError(null); setError(null);
await onConfirm({ acceptedTerms, acceptedWaiver: requireWaiver ? acceptedWaiver : true }); await onConfirm({
acceptedTerms: requireTerms ? acceptedTerms : true,
acceptedWaiver: requireWaiver ? acceptedWaiver : true,
});
} }
return ( return (
<MobileSheet <MobileSheet
open={open} open={open}
onClose={onClose} onClose={onClose}
title={t('events.legalConsent.title', 'Before purchase')} title={copy?.title ?? t('events.legalConsent.title', 'Before purchase')}
footer={ footer={
<YStack space="$2"> <YStack space="$2">
{error ? ( {error ? (
@@ -55,29 +78,41 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
{error} {error}
</Text> </Text>
) : null} ) : null}
<CTAButton label={t('events.legalConsent.confirm', 'Continue to checkout')} onPress={handleConfirm} loading={busy} disabled={busy} /> <CTAButton
<CTAButton label={t('events.legalConsent.cancel', 'Cancel')} tone="ghost" onPress={onClose} disabled={busy} /> label={copy?.confirm ?? t('events.legalConsent.confirm', 'Continue to checkout')}
onPress={handleConfirm}
loading={busy}
disabled={busy}
/>
<CTAButton
label={copy?.cancel ?? t('events.legalConsent.cancel', 'Cancel')}
tone="ghost"
onPress={onClose}
disabled={busy}
/>
</YStack> </YStack>
} }
> >
<YStack space="$2"> <YStack space="$2">
<Text fontSize="$sm" color="#111827"> <Text fontSize="$sm" color="#111827">
{t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')} {copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
</Text> </Text>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}> {requireTerms ? (
<input <label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
type="checkbox" <input
checked={acceptedTerms} type="checkbox"
onChange={(event) => setAcceptedTerms(event.target.checked)} checked={acceptedTerms}
style={{ marginTop: 4, width: 16, height: 16 }} onChange={(event) => setAcceptedTerms(event.target.checked)}
/> style={{ marginTop: 4, width: 16, height: 16 }}
<Text fontSize="$sm" color="#111827"> />
{t( <Text fontSize="$sm" color="#111827">
'events.legalConsent.checkboxTerms', {copy?.checkboxTerms ?? t(
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.', 'events.legalConsent.checkboxTerms',
)} 'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
</Text> )}
</label> </Text>
</label>
) : null}
{requireWaiver ? ( {requireWaiver ? (
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}> <label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<input <input
@@ -87,7 +122,7 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
style={{ marginTop: 4, width: 16, height: 16 }} style={{ marginTop: 4, width: 16, height: 16 }}
/> />
<Text fontSize="$sm" color="#111827"> <Text fontSize="$sm" color="#111827">
{t( {copy?.checkboxWaiver ?? t(
'events.legalConsent.checkboxWaiver', 'events.legalConsent.checkboxWaiver',
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.', 'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
)} )}

View File

@@ -73,16 +73,29 @@ export function CTAButton({
onPress, onPress,
tone = 'primary', tone = 'primary',
fullWidth = true, fullWidth = true,
disabled = false,
loading = false,
}: { }: {
label: string; label: string;
onPress: () => void; onPress: () => void;
tone?: 'primary' | 'ghost'; tone?: 'primary' | 'ghost';
fullWidth?: boolean; fullWidth?: boolean;
disabled?: boolean;
loading?: boolean;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const isPrimary = tone === 'primary'; const isPrimary = tone === 'primary';
const isDisabled = disabled || loading;
return ( return (
<Pressable onPress={onPress} style={{ width: fullWidth ? '100%' : undefined, flex: fullWidth ? undefined : 1 }}> <Pressable
onPress={isDisabled ? undefined : onPress}
disabled={isDisabled}
style={{
width: fullWidth ? '100%' : undefined,
flex: fullWidth ? undefined : 1,
opacity: isDisabled ? 0.6 : 1,
}}
>
<XStack <XStack
height={56} height={56}
borderRadius={14} borderRadius={14}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { CheckoutWizardProvider } from '../WizardContext';
import { PaymentStep } from '../steps/PaymentStep';
const basePackage = {
id: 1,
price: 49,
paddle_price_id: 'pri_test_123',
};
describe('PaymentStep', () => {
beforeEach(() => {
localStorage.clear();
window.Paddle = {
Environment: { set: vi.fn() },
Checkout: { open: vi.fn() },
};
});
afterEach(() => {
cleanup();
delete window.Paddle;
});
it('renders the payment experience without crashing', async () => {
render(
<CheckoutWizardProvider initialPackage={basePackage} packageOptions={[basePackage]}>
<PaymentStep />
</CheckoutWizardProvider>,
);
expect(await screen.findByText('checkout.payment_step.guided_title')).toBeInTheDocument();
expect(screen.queryByText('checkout.legal.checkbox_digital_content_label')).not.toBeInTheDocument();
});
});

View File

@@ -120,13 +120,12 @@ const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean;
export const PaymentStep: React.FC = () => { export const PaymentStep: React.FC = () => {
const { t, i18n } = useTranslation('marketing'); const { t, i18n } = useTranslation('marketing');
const { selectedPackage, nextStep, paddleConfig, authUser, setPaymentCompleted } = useCheckoutWizard(); const { selectedPackage, nextStep, paddleConfig, authUser, paymentCompleted, setPaymentCompleted } = useCheckoutWizard();
const [status, setStatus] = useState<PaymentStatus>('idle'); const [status, setStatus] = useState<PaymentStatus>('idle');
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>('');
const [initialised, setInitialised] = useState(false); const [initialised, setInitialised] = useState(false);
const [inlineActive, setInlineActive] = useState(false); const [inlineActive, setInlineActive] = useState(false);
const [acceptedTerms, setAcceptedTerms] = useState(false); const [acceptedTerms, setAcceptedTerms] = useState(false);
const [acceptedWaiver, setAcceptedWaiver] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null); const [consentError, setConsentError] = useState<string | null>(null);
const [couponCode, setCouponCode] = useState<string>(() => { const [couponCode, setCouponCode] = useState<string>(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -171,7 +170,6 @@ export const PaymentStep: React.FC = () => {
}, [i18n.language]); }, [i18n.language]);
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
const requiresImmediateWaiver = useMemo(() => Boolean(selectedPackage?.activates_immediately), [selectedPackage]);
const applyCoupon = useCallback(async (code: string) => { const applyCoupon = useCallback(async (code: string) => {
if (!selectedPackage) { if (!selectedPackage) {
@@ -263,7 +261,7 @@ export const PaymentStep: React.FC = () => {
return; return;
} }
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) { if (!acceptedTerms) {
setConsentError(t('checkout.legal.checkbox_terms_error')); setConsentError(t('checkout.legal.checkbox_terms_error'));
return; return;
} }
@@ -285,7 +283,6 @@ export const PaymentStep: React.FC = () => {
body: JSON.stringify({ body: JSON.stringify({
package_id: selectedPackage.id, package_id: selectedPackage.id,
accepted_terms: acceptedTerms, accepted_terms: acceptedTerms,
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
locale: paddleLocale, locale: paddleLocale,
}), }),
}); });
@@ -317,7 +314,7 @@ export const PaymentStep: React.FC = () => {
return; return;
} }
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) { if (!acceptedTerms) {
setConsentError(t('checkout.legal.checkbox_terms_error')); setConsentError(t('checkout.legal.checkbox_terms_error'));
return; return;
} }
@@ -362,7 +359,6 @@ export const PaymentStep: React.FC = () => {
locale: paddleLocale, locale: paddleLocale,
coupon_code: couponPreview?.coupon.code ?? undefined, coupon_code: couponPreview?.coupon.code ?? undefined,
accepted_terms: acceptedTerms, accepted_terms: acceptedTerms,
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
inline: inlineSupported, inline: inlineSupported,
}), }),
}); });
@@ -792,29 +788,6 @@ export const PaymentStep: React.FC = () => {
</div> </div>
</div> </div>
{requiresImmediateWaiver && (
<div className="flex items-start gap-3">
<Checkbox
id="checkout-waiver-free"
checked={acceptedWaiver}
onCheckedChange={(checked) => {
setAcceptedWaiver(Boolean(checked));
if (consentError) {
setConsentError(null);
}
}}
/>
<div className="space-y-1 text-sm">
<Label htmlFor="checkout-waiver-free" className="cursor-pointer">
{t('checkout.legal.checkbox_digital_content_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('checkout.legal.hint_subscription_withdrawal')}
</p>
</div>
</div>
)}
{consentError && ( {consentError && (
<div className="flex items-center gap-2 text-sm text-destructive"> <div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
@@ -827,7 +800,7 @@ export const PaymentStep: React.FC = () => {
<Button <Button
size="lg" size="lg"
onClick={handleFreeActivation} onClick={handleFreeActivation}
disabled={freeActivationBusy || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)} disabled={freeActivationBusy || !acceptedTerms}
> >
{freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} {freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t('checkout.payment_step.activate_package')} {t('checkout.payment_step.activate_package')}
@@ -940,30 +913,6 @@ export const PaymentStep: React.FC = () => {
</div> </div>
</div> </div>
{requiresImmediateWaiver && (
<div className="flex items-start gap-3">
<Checkbox
id="checkout-waiver-hero"
checked={acceptedWaiver}
onCheckedChange={(checked) => {
setAcceptedWaiver(Boolean(checked));
if (consentError) {
setConsentError(null);
}
}}
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
/>
<div className="space-y-1 text-sm">
<Label htmlFor="checkout-waiver-hero" className="cursor-pointer text-white">
{t('checkout.legal.checkbox_digital_content_label')}
</Label>
<p className="text-xs text-white/80">
{t('checkout.legal.hint_subscription_withdrawal')}
</p>
</div>
</div>
)}
{consentError && ( {consentError && (
<div className="flex items-center gap-2 text-sm text-red-200"> <div className="flex items-center gap-2 text-sm text-red-200">
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
@@ -975,7 +924,7 @@ export const PaymentStep: React.FC = () => {
<div className="space-y-2"> <div className="space-y-2">
<PaddleCta <PaddleCta
onCheckout={startPaddleCheckout} onCheckout={startPaddleCheckout}
disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)} disabled={status === 'processing' || !acceptedTerms}
isProcessing={status === 'processing'} isProcessing={status === 'processing'}
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)} className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
/> />
@@ -1070,7 +1019,7 @@ export const PaymentStep: React.FC = () => {
</p> </p>
<PaddleCta <PaddleCta
onCheckout={startPaddleCheckout} onCheckout={startPaddleCheckout}
disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)} disabled={status === 'processing' || !acceptedTerms}
isProcessing={status === 'processing'} isProcessing={status === 'processing'}
className={PRIMARY_CTA_STYLES} className={PRIMARY_CTA_STYLES}
/> />

View File

@@ -316,11 +316,14 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name('tenant.billing.transactions'); ->name('tenant.billing.transactions');
Route::get('addons', [TenantBillingController::class, 'addons']) Route::get('addons', [TenantBillingController::class, 'addons'])
->name('tenant.billing.addons'); ->name('tenant.billing.addons');
Route::post('portal', [TenantBillingController::class, 'portal'])
->name('tenant.billing.portal');
}); });
Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () { Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () {
Route::get('transactions', [TenantBillingController::class, 'transactions']); Route::get('transactions', [TenantBillingController::class, 'transactions']);
Route::get('addons', [TenantBillingController::class, 'addons']); Route::get('addons', [TenantBillingController::class, 'addons']);
Route::post('portal', [TenantBillingController::class, 'portal']);
}); });
Route::post('feedback', [TenantFeedbackController::class, 'store']) Route::post('feedback', [TenantFeedbackController::class, 'store'])

View File

@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Api\Tenant;
use Illuminate\Support\Facades\Http;
use Tests\Feature\Tenant\TenantTestCase;
class BillingPortalTest extends TenantTestCase
{
public function test_tenant_can_create_paddle_portal_session(): void
{
Http::fake([
'*paddle.com/customers' => Http::response([
'data' => ['id' => 'cus_123'],
], 200),
'*paddle.com/customer-portal-sessions' => Http::response([
'data' => [
'urls' => [
'general' => [
'overview' => 'https://portal.example/overview',
],
],
],
], 200),
]);
$this->tenant->forceFill(['paddle_customer_id' => null])->save();
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/billing/portal');
$response->assertOk();
$response->assertJsonPath('url', 'https://portal.example/overview');
$this->assertDatabaseHas('tenants', [
'id' => $this->tenant->id,
'paddle_customer_id' => 'cus_123',
]);
}
}

View File

@@ -36,7 +36,6 @@ class CheckoutFreeActivationTest extends TestCase
$response = $this->postJson(route('checkout.free-activate'), [ $response = $this->postJson(route('checkout.free-activate'), [
'package_id' => $package->id, 'package_id' => $package->id,
'accepted_terms' => true, 'accepted_terms' => true,
'accepted_waiver' => true,
'locale' => 'de', 'locale' => 'de',
]); ]);
@@ -63,7 +62,7 @@ class CheckoutFreeActivationTest extends TestCase
]); ]);
} }
public function test_free_checkout_requires_waiver_when_package_activates_immediately(): void public function test_free_checkout_does_not_require_waiver_before_first_use(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create(); $user = User::factory()->for($tenant)->create();
@@ -76,14 +75,13 @@ class CheckoutFreeActivationTest extends TestCase
$response = $this->postJson(route('checkout.free-activate'), [ $response = $this->postJson(route('checkout.free-activate'), [
'package_id' => $package->id, 'package_id' => $package->id,
'accepted_terms' => true, 'accepted_terms' => true,
'accepted_waiver' => false,
'locale' => 'de', 'locale' => 'de',
]); ]);
$response->assertStatus(422) $response->assertOk()
->assertJsonValidationErrors(['accepted_waiver']); ->assertJsonPath('status', 'completed');
$this->assertDatabaseMissing('package_purchases', [ $this->assertDatabaseHas('package_purchases', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
]); ]);

View File

@@ -4,40 +4,51 @@ namespace Tests\Feature;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Models\EventType;
use App\Models\Package; use App\Models\Package;
use App\Models\Tenant; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Models\User;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Tests\TestCase; use Tests\Feature\Tenant\TenantTestCase;
class EventControllerTest extends TestCase class EventControllerTest extends TenantTestCase
{ {
use RefreshDatabase;
public function test_create_event_with_valid_package_succeeds(): void public function test_create_event_with_valid_package_succeeds(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = $this->tenant;
$user = User::factory()->create(['tenant_id' => $tenant->id]); $eventType = EventType::factory()->create();
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]); $package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
TenantPackage::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'active' => true,
]);
$purchase = PackagePurchase::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'endcustomer_event',
'metadata' => [],
]);
$response = $this->actingAs($user) $response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
->postJson('/api/v1/tenant/events', [ 'name' => 'Test Event',
'name' => 'Test Event', 'slug' => 'test-event',
'slug' => 'test-event', 'event_date' => Carbon::now()->addDays(10)->toDateString(),
'date' => '2025-10-01', 'event_type_id' => $eventType->id,
'package_id' => $package->id, 'package_id' => $package->id,
]); 'accepted_waiver' => true,
]);
$response->assertStatus(201); $response->assertStatus(201);
$this->assertDatabaseHas('events', [ $this->assertDatabaseHas('events', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Test Event', 'name' => json_encode('Test Event'),
'slug' => 'test-event', 'slug' => 'test-event',
'event_type_id' => $eventType->id,
]); ]);
$event = Event::latest()->first(); $event = Event::latest()->first();
@@ -50,35 +61,50 @@ class EventControllerTest extends TestCase
'event_id' => $event->id, 'event_id' => $event->id,
]); ]);
$this->assertDatabaseHas('package_purchases', [ $purchase->refresh();
'event_id' => $event->id, $this->assertNotNull(data_get($purchase->metadata, 'consents.digital_content_waiver_at'));
'package_id' => $package->id,
'type' => 'endcustomer_event',
'provider' => 'manual',
'provider_id' => 'manual',
]);
} }
public function test_create_event_without_package_fails(): void public function test_create_event_without_package_fails(): void
{ {
$tenant = Tenant::factory()->create(); $response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
$user = User::factory()->create(['tenant_id' => $tenant->id]); 'name' => 'Test Event',
'slug' => 'test-event',
'event_date' => Carbon::now()->addDays(10)->toDateString(),
]);
$response = $this->actingAs($user) $response->assertStatus(402)
->postJson('/api/v1/tenant/events', [ ->assertJsonPath('error.code', 'event_limit_missing');
'name' => 'Test Event', }
'slug' => 'test-event',
'date' => '2025-10-01', public function test_create_event_requires_waiver_for_endcustomer_package(): void
]); {
$tenant = $this->tenant;
$eventType = EventType::factory()->create();
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
TenantPackage::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'active' => true,
]);
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
'name' => 'Test Event',
'slug' => 'test-event',
'event_date' => Carbon::now()->addDays(10)->toDateString(),
'event_type_id' => $eventType->id,
'package_id' => $package->id,
'accepted_waiver' => false,
]);
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors(['package_id']); ->assertJsonValidationErrors(['accepted_waiver']);
} }
public function test_create_event_with_reseller_package_limits_events(): void public function test_create_event_with_reseller_package_limits_events(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = $this->tenant;
$user = User::factory()->create(['tenant_id' => $tenant->id]); $eventType = EventType::factory()->create();
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]); $package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
TenantPackage::factory()->create([ TenantPackage::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
@@ -89,37 +115,38 @@ class EventControllerTest extends TestCase
]); ]);
// First event succeeds // First event succeeds
$response1 = $this->actingAs($user) $response1 = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
->postJson('/api/v1/tenant/events', [ 'name' => 'First Event',
'name' => 'First Event', 'slug' => 'first-event',
'slug' => 'first-event', 'event_date' => Carbon::now()->addDays(10)->toDateString(),
'date' => '2025-10-01', 'event_type_id' => $eventType->id,
'package_id' => $package->id, // Use reseller package for event? Adjust if needed 'package_id' => $package->id, // Use reseller package for event? Adjust if needed
]); ]);
$response1->assertStatus(201); $response1->assertStatus(201);
// Second event fails due to limit // Second event fails due to limit
$response2 = $this->actingAs($user) $response2 = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
->postJson('/api/v1/tenant/events', [ 'name' => 'Second Event',
'name' => 'Second Event', 'slug' => 'second-event',
'slug' => 'second-event', 'event_date' => Carbon::now()->addDays(11)->toDateString(),
'date' => '2025-10-02', 'event_type_id' => $eventType->id,
'package_id' => $package->id, 'package_id' => $package->id,
]); ]);
$response2->assertStatus(402) $response2->assertStatus(402)
->assertJson(['error' => 'No available package for event creation']); ->assertJsonPath('error.code', 'event_limit_exceeded');
} }
public function test_upload_exceeds_package_limit_fails(): void public function test_upload_exceeds_package_limit_fails(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = $this->tenant;
$event = Event::factory()->create(['tenant_id' => $tenant->id, 'status' => 'published']); $event = Event::factory()->create(['tenant_id' => $tenant->id, 'status' => 'published']);
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0 $package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0
EventPackage::factory()->create([ EventPackage::create([
'event_id' => $event->id, 'event_id' => $event->id,
'package_id' => $package->id, 'package_id' => $package->id,
'purchased_price' => $package->price,
'used_photos' => 0, 'used_photos' => 0,
]); ]);

View File

@@ -81,7 +81,6 @@ class PaddleCheckoutControllerTest extends TestCase
'package_id' => $package->id, 'package_id' => $package->id,
'coupon_code' => 'SAVE15', 'coupon_code' => 'SAVE15',
'accepted_terms' => true, 'accepted_terms' => true,
'accepted_waiver' => true,
]); ]);
$response->assertOk() $response->assertOk()

View File

@@ -64,6 +64,7 @@ class EventManagementTest extends TenantTestCase
'event_type_id' => $eventType->id, 'event_type_id' => $eventType->id,
'event_date' => Carbon::now()->addDays(10)->toDateString(), 'event_date' => Carbon::now()->addDays(10)->toDateString(),
'status' => 'draft', 'status' => 'draft',
'accepted_waiver' => true,
]; ];
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload); $response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);

View File

@@ -164,11 +164,6 @@ async function acceptCheckoutTerms(page: import('@playwright/test').Page) {
const termsCheckbox = page.locator('#checkout-terms-hero'); const termsCheckbox = page.locator('#checkout-terms-hero');
await expect(termsCheckbox).toBeVisible(); await expect(termsCheckbox).toBeVisible();
await termsCheckbox.click(); await termsCheckbox.click();
const waiverCheckbox = page.locator('#checkout-waiver-hero');
if (await waiverCheckbox.isVisible()) {
await waiverCheckbox.click();
}
} }
declare global { declare global {

View File

@@ -44,11 +44,6 @@ test.describe('Paddle sandbox full flow (staging)', () => {
await expect(termsCheckbox).toBeVisible(); await expect(termsCheckbox).toBeVisible();
await termsCheckbox.click(); await termsCheckbox.click();
const waiverCheckbox = page.locator('#checkout-waiver-hero');
if (await waiverCheckbox.isVisible()) {
await waiverCheckbox.click();
}
const checkoutCta = page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first(); const checkoutCta = page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first();
await expect(checkoutCta).toBeVisible({ timeout: 20000 }); await expect(checkoutCta).toBeVisible({ timeout: 20000 });

View File

@@ -103,11 +103,6 @@ test.describe('Standard package checkout with Paddle completion', () => {
await expect(termsCheckbox).toBeVisible(); await expect(termsCheckbox).toBeVisible();
await termsCheckbox.click(); await termsCheckbox.click();
const waiverCheckbox = page.locator('#checkout-waiver-hero');
if (await waiverCheckbox.isVisible()) {
await waiverCheckbox.click();
}
await page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first().click(); await page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first().click();
let checkoutMode: 'inline' | 'hosted' | null = null; let checkoutMode: 'inline' | 'hosted' | null = null;