Änderungen (relevant):

- Add‑on Checkout auf Transactions + Transaction‑ID speichern: app/Services/Addons/EventAddonCheckoutService.php
  - Paket/Marketing Checkout auf Transactions: app/Services/Paddle/PaddleCheckoutService.php
  - Gift‑Voucher Checkout: Customer anlegen/finden + Transactions: app/Services/GiftVouchers/
    GiftVoucherCheckoutService.php
  - Tests aktualisiert: tests/Feature/Tenant/EventAddonCheckoutTest.php, tests/Unit/PaddleCheckoutServiceTest.php,
tests/Unit/GiftVoucherCheckoutServiceTest.php
This commit is contained in:
Codex Agent
2025-12-29 18:04:28 +01:00
parent 795e37ee12
commit 5f521d055f
26 changed files with 783 additions and 102 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Exceptions; namespace App\Exceptions;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\SentryReporter;
use Illuminate\Database\Connectors\ConnectionException as DatabaseConnectionException; use Illuminate\Database\Connectors\ConnectionException as DatabaseConnectionException;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
@@ -43,13 +44,9 @@ class Handler extends ExceptionHandler
return; return;
} }
if (! app()->bound('sentry') || empty(config('sentry.dsn'))) {
return;
}
$this->configureSentryScope(); $this->configureSentryScope();
app('sentry')->captureException($e); SentryReporter::captureException($e);
}); });
} }

View File

@@ -34,6 +34,8 @@ class OnboardingController extends Controller
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'), 'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'), 'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'), 'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
'branding_completed' => (bool) ($status['palette'] ?? false), 'branding_completed' => (bool) ($status['palette'] ?? false),
'tasks_configured' => (bool) ($status['packages'] ?? false), 'tasks_configured' => (bool) ($status['packages'] ?? false),
'event_created' => (bool) ($status['event'] ?? false), 'event_created' => (bool) ($status['event'] ?? false),
@@ -84,6 +86,10 @@ class OnboardingController extends Controller
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String()); Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
break; break;
case 'dismissed':
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
break;
case 'completed': case 'completed':
TenantOnboardingState::markCompleted($tenant, $meta); TenantOnboardingState::markCompleted($tenant, $meta);
break; break;

View File

@@ -32,8 +32,10 @@ use App\Services\Checkout\CheckoutSessionService;
use App\Services\Security\PhotoSecurityScanner; use App\Services\Security\PhotoSecurityScanner;
use App\Services\Storage\EventStorageManager; use App\Services\Storage\EventStorageManager;
use App\Services\Storage\StorageHealthService; use App\Services\Storage\StorageHealthService;
use App\Support\SentryReporter;
use App\Testing\Mailbox; use App\Testing\Mailbox;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Console\Events\ScheduledTaskFailed;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Mail\Events\MessageSent; use Illuminate\Mail\Events\MessageSent;
use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\Events\JobFailed;
@@ -241,36 +243,63 @@ class AppServiceProvider extends ServiceProvider
]; ];
}); });
if (config('storage-monitor.queue_failure_alerts')) { Queue::failing(function (JobFailed $event) {
Queue::failing(function (JobFailed $event) { $context = [
$context = [ 'queue' => $event->job->getQueue(),
'queue' => $event->job->getQueue(), 'job' => $event->job->resolveName(),
'job' => $event->job->resolveName(), 'exception' => $event->exception->getMessage(),
'exception' => $event->exception->getMessage(), ];
];
$command = data_get($event->job->payload(), 'data.command'); $command = data_get($event->job->payload(), 'data.command');
if (is_string($command)) { if (is_string($command)) {
try { try {
$instance = @unserialize($command, ['allowed_classes' => true]); $instance = @unserialize($command, ['allowed_classes' => true]);
if (is_object($instance)) { if (is_object($instance)) {
foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) { foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) {
if (isset($instance->{$property})) { if (isset($instance->{$property})) {
$context[$label] = $instance->{$property}; $context[$label] = $instance->{$property};
}
} }
} }
} catch (\Throwable $e) {
$context['unserialize_error'] = $e->getMessage();
} }
} catch (\Throwable $e) {
$context['unserialize_error'] = $e->getMessage();
} }
}
SentryReporter::captureException($event->exception, [
'tags' => [
'queue' => $context['queue'],
'job' => $context['job'],
'connection' => $event->connectionName,
],
'extra' => [
'event_id' => $context['event_id'] ?? null,
'photo_id' => $context['photo_id'] ?? null,
],
]);
if (config('storage-monitor.queue_failure_alerts')) {
if ($mail = config('storage-monitor.alert_recipients.mail')) { if ($mail = config('storage-monitor.alert_recipients.mail')) {
Notification::route('mail', $mail)->notify(new UploadPipelineFailed($context)); Notification::route('mail', $mail)->notify(new UploadPipelineFailed($context));
} }
}); }
} });
EventFacade::listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $event): void {
$task = $event->task;
SentryReporter::captureException($event->exception, [
'tags' => [
'schedule_command' => $task->command ?? 'unknown',
'schedule_expression' => $task->expression ?? null,
],
'extra' => [
'schedule_description' => $task->description ?? null,
'schedule_timezone' => $task->timezone ?? null,
],
]);
});
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->app->register(\App\Providers\Filament\AdminPanelProvider::class); $this->app->register(\App\Providers\Filament\AdminPanelProvider::class);

View File

@@ -5,8 +5,8 @@ namespace App\Services\Addons;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackageAddon; use App\Models\EventPackageAddon;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Paddle\PaddleCustomerService;
use App\Services\Paddle\PaddleClient; use App\Services\Paddle\PaddleClient;
use App\Services\Paddle\PaddleCustomerService;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -86,15 +86,20 @@ class EventAddonCheckoutService
'quantity' => $quantity, 'quantity' => $quantity,
], ],
], ],
'metadata' => $metadata, 'custom_data' => $metadata,
'success_url' => $payload['success_url'] ?? null,
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== ''); ], static fn ($value) => $value !== null && $value !== '');
$response = $this->paddle->post('/checkout/links', $requestPayload); $response = $this->paddle->post('/transactions', $requestPayload);
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url'); $checkoutUrl = Arr::get($response, 'data.checkout.url')
$checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id'); ?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url');
$checkoutId = Arr::get($response, 'data.checkout_id')
?? Arr::get($response, 'data.checkout.id')
?? Arr::get($response, 'checkout_id')
?? Arr::get($response, 'checkout.id');
$transactionId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $checkoutUrl) { if (! $checkoutUrl) {
Log::warning('Paddle addon checkout response missing url', ['response' => $response]); Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
@@ -108,7 +113,7 @@ class EventAddonCheckoutService
'quantity' => $quantity, 'quantity' => $quantity,
'price_id' => $priceId, 'price_id' => $priceId,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => null, 'transaction_id' => $transactionId,
'status' => 'pending', 'status' => 'pending',
'metadata' => array_merge($metadata, [ 'metadata' => array_merge($metadata, [
'increments' => $increments, 'increments' => $increments,
@@ -126,8 +131,10 @@ class EventAddonCheckoutService
return [ return [
'checkout_url' => $checkoutUrl, 'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'), 'expires_at' => Arr::get($response, 'data.checkout.expires_at')
'id' => $checkoutId, ?? Arr::get($response, 'data.expires_at')
?? Arr::get($response, 'expires_at'),
'id' => $transactionId ?? $checkoutId,
]; ];
} }

View File

@@ -2,6 +2,7 @@
namespace App\Services\GiftVouchers; namespace App\Services\GiftVouchers;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleClient; use App\Services\Paddle\PaddleClient;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
@@ -49,6 +50,8 @@ class GiftVoucherCheckoutService
]); ]);
} }
$customerId = $this->ensureCustomerId($data['purchaser_email']);
$payload = [ $payload = [
'items' => [ 'items' => [
[ [
@@ -56,7 +59,7 @@ class GiftVoucherCheckoutService
'quantity' => 1, 'quantity' => 1,
], ],
], ],
'customer_email' => $data['purchaser_email'], 'customer_id' => $customerId,
'custom_data' => array_filter([ 'custom_data' => array_filter([
'type' => 'gift_voucher', 'type' => 'gift_voucher',
'tier_key' => $tier['key'], 'tier_key' => $tier['key'],
@@ -66,15 +69,18 @@ class GiftVoucherCheckoutService
'message' => $data['message'] ?? null, 'message' => $data['message'] ?? null,
'app_locale' => App::getLocale(), 'app_locale' => App::getLocale(),
]), ]),
'success_url' => $data['success_url'] ?? route('marketing.success', ['locale' => App::getLocale(), 'type' => 'gift']),
'cancel_url' => $data['return_url'] ?? route('packages', ['locale' => App::getLocale()]),
]; ];
$response = $this->client->post('/checkout/links', $payload); $response = $this->client->post('/transactions', $payload);
return [ return [
'checkout_url' => Arr::get($response, 'data.url') ?? Arr::get($response, 'url'), 'checkout_url' => Arr::get($response, 'data.checkout.url')
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'), ?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url'),
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
?? Arr::get($response, 'data.expires_at')
?? Arr::get($response, 'expires_at'),
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'), 'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
]; ];
} }
@@ -97,4 +103,43 @@ class GiftVoucherCheckoutService
return $tier; return $tier;
} }
protected function ensureCustomerId(string $email): string
{
$payload = ['email' => $email];
try {
$response = $this->client->post('/customers', $payload);
} catch (PaddleException $exception) {
$customerId = $this->resolveExistingCustomerId($email, $exception);
if ($customerId) {
return $customerId;
}
throw $exception;
}
$customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $customerId) {
throw new PaddleException('Failed to create Paddle customer.');
}
return $customerId;
}
protected function resolveExistingCustomerId(string $email, PaddleException $exception): ?string
{
if ($exception->status() !== 409 || Arr::get($exception->context(), 'error.code') !== 'customer_already_exists') {
return null;
}
$response = $this->client->get('/customers', [
'email' => $email,
'per_page' => 1,
]);
return Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id');
}
} }

View File

@@ -5,7 +5,6 @@ namespace App\Services\Paddle;
use App\Models\Package; use App\Models\Package;
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class PaddleCheckoutService class PaddleCheckoutService
@@ -22,15 +21,6 @@ class PaddleCheckoutService
{ {
$customerId = $this->customers->ensureCustomerId($tenant); $customerId = $this->customers->ensureCustomerId($tenant);
$successUrl = $options['success_url'] ?? route('marketing.success', [
'locale' => App::getLocale(),
'packageId' => $package->id,
]);
$returnUrl = $options['return_url'] ?? route('packages', [
'locale' => App::getLocale(),
'highlight' => $package->slug,
]);
$customData = $this->buildMetadata( $customData = $this->buildMetadata(
$tenant, $tenant,
$package, $package,
@@ -46,21 +36,18 @@ class PaddleCheckoutService
], ],
], ],
'custom_data' => $customData, 'custom_data' => $customData,
'success_url' => $successUrl,
'cancel_url' => $returnUrl,
]; ];
if (! empty($options['discount_id'])) { if (! empty($options['discount_id'])) {
$payload['discount_id'] = $options['discount_id']; $payload['discount_id'] = $options['discount_id'];
} }
if ($tenant->contact_email) { $response = $this->client->post('/transactions', $payload);
$payload['customer_email'] = $tenant->contact_email;
}
$response = $this->client->post('/checkout/links', $payload); $checkoutUrl = Arr::get($response, 'data.checkout.url')
?? Arr::get($response, 'checkout.url')
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url'); ?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url');
if (! $checkoutUrl) { if (! $checkoutUrl) {
Log::warning('Paddle checkout response missing url', ['response' => $response]); Log::warning('Paddle checkout response missing url', ['response' => $response]);
@@ -68,7 +55,9 @@ class PaddleCheckoutService
return [ return [
'checkout_url' => $checkoutUrl, 'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'), 'expires_at' => Arr::get($response, 'data.checkout.expires_at')
?? Arr::get($response, 'data.expires_at')
?? Arr::get($response, 'expires_at'),
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'), 'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
]; ];
} }

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Support;
use Sentry\State\Scope;
use Throwable;
class SentryReporter
{
private static ?\WeakMap $reported = null;
/**
* @param array{tags?: array<string, string|int|null>, extra?: array<string, mixed>} $context
*/
public static function captureException(Throwable $exception, array $context = []): void
{
if (! self::shouldReport()) {
return;
}
if (self::wasReported($exception)) {
return;
}
self::markReported($exception);
if (! function_exists('\Sentry\withScope')) {
app('sentry')->captureException($exception);
return;
}
\Sentry\withScope(function (Scope $scope) use ($exception, $context): void {
self::applyContext($scope, $context);
app('sentry')->captureException($exception);
});
}
private static function shouldReport(): bool
{
return app()->bound('sentry') && ! empty(config('sentry.dsn'));
}
private static function wasReported(Throwable $exception): bool
{
$reported = self::reportedMap();
return $reported->offsetExists($exception);
}
private static function markReported(Throwable $exception): void
{
$reported = self::reportedMap();
$reported[$exception] = true;
}
private static function reportedMap(): \WeakMap
{
if (! self::$reported instanceof \WeakMap) {
self::$reported = new \WeakMap;
}
return self::$reported;
}
/**
* @param array{tags?: array<string, string|int|null>, extra?: array<string, mixed>} $context
*/
private static function applyContext(Scope $scope, array $context): void
{
foreach (($context['tags'] ?? []) as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$scope->setTag((string) $key, (string) $value);
}
foreach (($context['extra'] ?? []) as $key => $value) {
if ($value === null) {
continue;
}
$scope->setExtra((string) $key, $value);
}
}
}

View File

@@ -36,12 +36,55 @@ return Application::configure(basePath: dirname(__DIR__))
\App\Console\Commands\SeedDemoSwitcherTenants::class, \App\Console\Commands\SeedDemoSwitcherTenants::class,
]) ])
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
$schedule->command('package:check-status')->dailyAt('06:00'); $onFailure = static function (string $command): \Closure {
$schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping(); return static function () use ($command): void {
$schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping(); \App\Support\SentryReporter::captureException(
$schedule->command('exports:purge')->dailyAt('02:00'); new \RuntimeException('Scheduled command failed'),
$schedule->command('tenants:retention-scan')->dailyAt('03:00'); [
$schedule->command('guest:feedback-reminders')->dailyAt('22:00'); 'tags' => [
'schedule_command' => $command,
],
],
);
};
};
$schedule->command('package:check-status')
->dailyAt('06:00')
->onFailure($onFailure('package:check-status'));
$schedule->command('storage:monitor')
->everyFiveMinutes()
->withoutOverlapping()
->onFailure($onFailure('storage:monitor'));
$schedule->command('storage:check-upload-queues')
->everyFiveMinutes()
->withoutOverlapping()
->onFailure($onFailure('storage:check-upload-queues'));
$schedule->command('storage:archive-pending')
->dailyAt('01:00')
->withoutOverlapping()
->onFailure($onFailure('storage:archive-pending'));
$schedule->command('photobooth:cleanup-expired')
->hourly()
->withoutOverlapping()
->onFailure($onFailure('photobooth:cleanup-expired'));
$schedule->command('photobooth:ingest')
->everyFiveMinutes()
->withoutOverlapping()
->onFailure($onFailure('photobooth:ingest'));
$schedule->command('checkout:send-reminders')
->hourly()
->withoutOverlapping()
->onFailure($onFailure('checkout:send-reminders'));
$schedule->command('exports:purge')
->dailyAt('02:00')
->onFailure($onFailure('exports:purge'));
$schedule->command('tenants:retention-scan')
->dailyAt('03:00')
->onFailure($onFailure('tenants:retention-scan'));
$schedule->command('guest:feedback-reminders')
->dailyAt('22:00')
->onFailure($onFailure('guest:feedback-reminders'));
}) })
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies( $middleware->trustProxies(

View File

@@ -98,6 +98,10 @@ return [
'waits' => [ 'waits' => [
'redis:default' => 60, 'redis:default' => 60,
'redis:media-storage' => 60,
'redis:media-security' => 60,
'redis:notifications' => 60,
'redis:webhooks' => 60,
], ],
/* /*
@@ -197,9 +201,22 @@ return [
*/ */
'defaults' => [ 'defaults' => [
'supervisor-1' => [ 'supervisor-default' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['default'], 'queue' => ['default', 'notifications', 'webhooks'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
'supervisor-media' => [
'connection' => 'redis',
'queue' => ['media-storage', 'media-security'],
'balance' => 'auto', 'balance' => 'auto',
'autoScalingStrategy' => 'time', 'autoScalingStrategy' => 'time',
'maxProcesses' => 1, 'maxProcesses' => 1,
@@ -214,17 +231,25 @@ return [
'environments' => [ 'environments' => [
'production' => [ 'production' => [
'supervisor-1' => [ 'supervisor-default' => [
'maxProcesses' => 10, 'maxProcesses' => 10,
'balanceMaxShift' => 1, 'balanceMaxShift' => 1,
'balanceCooldown' => 3, 'balanceCooldown' => 3,
], ],
'supervisor-media' => [
'maxProcesses' => 3,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
], ],
'local' => [ 'local' => [
'supervisor-1' => [ 'supervisor-default' => [
'maxProcesses' => 3, 'maxProcesses' => 3,
], ],
'supervisor-media' => [
'maxProcesses' => 1,
],
], ],
], ],
]; ];

View File

@@ -195,7 +195,7 @@ services:
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest} image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
env_file: env_file:
- path: .env - path: .env
command: /var/www/html/scripts/queue-worker.sh default command: /var/www/html/scripts/queue-worker.sh default,notifications,webhooks
environment: environment:
<<: *app-env <<: *app-env
SKIP_CODE_SYNC: "1" SKIP_CODE_SYNC: "1"
@@ -237,6 +237,30 @@ services:
condition: service_started condition: service_started
restart: unless-stopped restart: unless-stopped
media-security-worker:
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
env_file:
- path: .env
command: /var/www/html/scripts/queue-worker.sh media-security
environment:
<<: *app-env
QUEUE_TRIES: 3
QUEUE_SLEEP: 5
SKIP_CODE_SYNC: "1"
volumes:
- app-code:/var/www/html
- app-storage:/var/www/html/storage
- app-bootstrap-cache:/var/www/html/bootstrap/cache
networks:
- default
- photobooth-network
depends_on:
app:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
scheduler: scheduler:
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest} image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
env_file: env_file:

View File

@@ -305,6 +305,8 @@ export type TenantOnboardingStatus = {
admin_app_opened_at?: string | null; admin_app_opened_at?: string | null;
primary_event_id?: number | string | null; primary_event_id?: number | string | null;
selected_packages?: unknown; selected_packages?: unknown;
dismissed_at?: string | null;
completed_at?: string | null;
branding_completed?: boolean; branding_completed?: boolean;
tasks_configured?: boolean; tasks_configured?: boolean;
event_created?: boolean; event_created?: boolean;

View File

@@ -2217,6 +2217,24 @@
"checkoutMissing": "Checkout konnte nicht gestartet werden.", "checkoutMissing": "Checkout konnte nicht gestartet werden.",
"checkoutFailed": "Add-on Checkout fehlgeschlagen." "checkoutFailed": "Add-on Checkout fehlgeschlagen."
}, },
"legalConsent": {
"title": "Vor dem Kauf",
"description": "Bitte bestätige die rechtlichen Hinweise, bevor du ein Add-on kaufst.",
"checkboxTerms": "Ich habe die AGB, die Datenschutzerklärung und die Widerrufsbelehrung gelesen und akzeptiere sie.",
"checkboxWaiver": "Ich verlange ausdrücklich, dass mit der Bereitstellung der digitalen Leistung (Aktivierung meines Event-Add-ons) vor Ablauf der Widerrufsfrist begonnen wird. Mir ist bekannt, dass ich mein Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
"errorTerms": "Bitte bestätige AGB, Datenschutzerklärung und Widerrufsbelehrung.",
"errorWaiver": "Bitte bestätige den sofortigen Leistungsbeginn und das vorzeitige Erlöschen des Widerrufsrechts.",
"confirm": "Weiter zum Checkout",
"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"
}, },

View File

@@ -2221,6 +2221,24 @@
"checkoutMissing": "Checkout could not be started.", "checkoutMissing": "Checkout could not be started.",
"checkoutFailed": "Add-on checkout failed." "checkoutFailed": "Add-on checkout failed."
}, },
"legalConsent": {
"title": "Before purchase",
"description": "Please confirm the legal notes before buying an add-on.",
"checkboxTerms": "I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.",
"checkboxWaiver": "I expressly request that you begin providing the digital services (activation of my event add-on) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.",
"errorTerms": "Please confirm you accept the terms, privacy policy, and right of withdrawal.",
"errorWaiver": "Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.",
"confirm": "Continue to checkout",
"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"
}, },

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { YStack } from '@tamagui/stacks'; import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { useTheme } from '@tamagui/core';
import { MobileSheet } from './Sheet'; import { MobileSheet } from './Sheet';
import { CTAButton } from './Primitives'; import { CTAButton } from './Primitives';
@@ -36,9 +37,24 @@ export function LegalConsentSheet({
copy, copy,
t, t,
}: LegalConsentSheetProps) { }: LegalConsentSheetProps) {
const theme = useTheme();
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);
const checkboxAccent = String(theme.primary?.val ?? '#2563eb');
const checkboxBorder = String(theme.borderColor?.val ?? '#e5e7eb');
const checkboxSurface = String(theme.surface?.val ?? '#ffffff');
const checkboxStyle = {
marginTop: 4,
width: 18,
height: 18,
accentColor: checkboxAccent,
backgroundColor: checkboxSurface,
border: `1px solid ${checkboxBorder}`,
borderRadius: 4,
appearance: 'auto',
WebkitAppearance: 'auto',
} as const;
React.useEffect(() => { React.useEffect(() => {
if (open) { if (open) {
@@ -103,7 +119,7 @@ export function LegalConsentSheet({
type="checkbox" type="checkbox"
checked={acceptedTerms} checked={acceptedTerms}
onChange={(event) => setAcceptedTerms(event.target.checked)} onChange={(event) => setAcceptedTerms(event.target.checked)}
style={{ marginTop: 4, width: 16, height: 16 }} style={checkboxStyle}
/> />
<Text fontSize="$sm" color="#111827"> <Text fontSize="$sm" color="#111827">
{copy?.checkboxTerms ?? t( {copy?.checkboxTerms ?? t(
@@ -119,7 +135,7 @@ export function LegalConsentSheet({
type="checkbox" type="checkbox"
checked={acceptedWaiver} checked={acceptedWaiver}
onChange={(event) => setAcceptedWaiver(event.target.checked)} onChange={(event) => setAcceptedWaiver(event.target.checked)}
style={{ marginTop: 4, width: 16, height: 16 }} style={checkboxStyle}
/> />
<Text fontSize="$sm" color="#111827"> <Text fontSize="$sm" color="#111827">
{copy?.checkboxWaiver ?? t( {copy?.checkboxWaiver ?? t(

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
primary: { val: '#2563eb' },
borderColor: { val: '#e5e7eb' },
surface: { val: '#ffffff' },
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
}));
import { LegalConsentSheet } from '../LegalConsentSheet';
describe('LegalConsentSheet', () => {
it('renders the required consent checkboxes when open', () => {
const { getAllByRole } = render(
<LegalConsentSheet
open
onClose={vi.fn()}
onConfirm={vi.fn()}
t={(key, fallback) => fallback ?? key}
/>
);
expect(getAllByRole('checkbox')).toHaveLength(2);
});
});

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveOnboardingRedirect } from './onboardingGuard'; import { resolveOnboardingRedirect } from './onboardingGuard';
import { import {
ADMIN_WELCOME_BASE_PATH,
ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_PACKAGES_PATH,
ADMIN_WELCOME_SUMMARY_PATH, ADMIN_WELCOME_SUMMARY_PATH,
} from '../../constants'; } from '../../constants';
@@ -15,6 +15,8 @@ describe('resolveOnboardingRedirect', () => {
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false, isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
}); });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -24,9 +26,11 @@ describe('resolveOnboardingRedirect', () => {
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, selectedPackageId: null,
pathname: ADMIN_WELCOME_PACKAGES_PATH, pathname: ADMIN_WELCOME_BASE_PATH,
isWelcomePath: true, isWelcomePath: true,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
}); });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -39,6 +43,8 @@ describe('resolveOnboardingRedirect', () => {
pathname: '/event-admin/mobile/billing', pathname: '/event-admin/mobile/billing',
isWelcomePath: false, isWelcomePath: false,
isBillingPath: true, isBillingPath: true,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
}); });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -51,6 +57,8 @@ describe('resolveOnboardingRedirect', () => {
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false, isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
}); });
expect(result).toBe(ADMIN_WELCOME_EVENT_PATH); expect(result).toBe(ADMIN_WELCOME_EVENT_PATH);
}); });
@@ -63,11 +71,13 @@ describe('resolveOnboardingRedirect', () => {
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false, isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
}); });
expect(result).toBe(ADMIN_WELCOME_SUMMARY_PATH); expect(result).toBe(ADMIN_WELCOME_SUMMARY_PATH);
}); });
it('redirects to packages when no selection exists', () => { it('redirects to landing when no selection exists', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
@@ -75,8 +85,10 @@ describe('resolveOnboardingRedirect', () => {
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false, isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
}); });
expect(result).toBe(ADMIN_WELCOME_PACKAGES_PATH); expect(result).toBe(ADMIN_WELCOME_BASE_PATH);
}); });
it('does not redirect when already on target', () => { it('does not redirect when already on target', () => {
@@ -84,9 +96,39 @@ describe('resolveOnboardingRedirect', () => {
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, selectedPackageId: null,
pathname: ADMIN_WELCOME_PACKAGES_PATH, pathname: ADMIN_WELCOME_BASE_PATH,
isWelcomePath: false, isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBeNull();
});
it('does not redirect when onboarding is dismissed', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
selectedPackageId: null,
pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false,
isOnboardingDismissed: true,
isOnboardingCompleted: false,
});
expect(result).toBeNull();
});
it('does not redirect when onboarding is completed', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
selectedPackageId: null,
pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: true,
}); });
expect(result).toBeNull(); expect(result).toBeNull();
}); });

View File

@@ -1,6 +1,6 @@
import { import {
ADMIN_WELCOME_BASE_PATH,
ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_PACKAGES_PATH,
ADMIN_WELCOME_SUMMARY_PATH, ADMIN_WELCOME_SUMMARY_PATH,
} from '../../constants'; } from '../../constants';
@@ -11,6 +11,8 @@ type OnboardingRedirectInput = {
pathname: string; pathname: string;
isWelcomePath: boolean; isWelcomePath: boolean;
isBillingPath: boolean; isBillingPath: boolean;
isOnboardingDismissed?: boolean;
isOnboardingCompleted?: boolean;
}; };
export function resolveOnboardingRedirect({ export function resolveOnboardingRedirect({
@@ -20,11 +22,17 @@ export function resolveOnboardingRedirect({
pathname, pathname,
isWelcomePath, isWelcomePath,
isBillingPath, isBillingPath,
isOnboardingDismissed,
isOnboardingCompleted,
}: OnboardingRedirectInput): string | null { }: OnboardingRedirectInput): string | null {
if (hasEvents) { if (hasEvents) {
return null; return null;
} }
if (isOnboardingDismissed || isOnboardingCompleted) {
return null;
}
if (isWelcomePath || isBillingPath) { if (isWelcomePath || isBillingPath) {
return null; return null;
} }
@@ -34,7 +42,7 @@ export function resolveOnboardingRedirect({
? ADMIN_WELCOME_EVENT_PATH ? ADMIN_WELCOME_EVENT_PATH
: shouldContinueSummary : shouldContinueSummary
? ADMIN_WELCOME_SUMMARY_PATH ? ADMIN_WELCOME_SUMMARY_PATH
: ADMIN_WELCOME_PACKAGES_PATH; : ADMIN_WELCOME_BASE_PATH;
if (pathname === target) { if (pathname === target) {
return null; return null;

View File

@@ -8,7 +8,7 @@ import { SizableText as Text } from '@tamagui/text';
import { OnboardingShell } from '../components/OnboardingShell'; import { OnboardingShell } from '../components/OnboardingShell';
import { MobileCard, CTAButton } from '../components/Primitives'; import { MobileCard, CTAButton } from '../components/Primitives';
import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants'; import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
import { getTenantPackagesOverview } from '../../api'; import { getTenantPackagesOverview, trackOnboarding } from '../../api';
import { getSelectedPackageId } from '../lib/onboardingSelection'; import { getSelectedPackageId } from '../lib/onboardingSelection';
export default function WelcomeEventPage() { export default function WelcomeEventPage() {
@@ -24,6 +24,10 @@ export default function WelcomeEventPage() {
const hasActivePackage = const hasActivePackage =
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active)); Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
const handleSkip = React.useCallback(() => {
void trackOnboarding('dismissed');
navigate(ADMIN_HOME_PATH);
}, [navigate]);
const backTarget = selectedId const backTarget = selectedId
? ADMIN_WELCOME_SUMMARY_PATH ? ADMIN_WELCOME_SUMMARY_PATH
@@ -40,7 +44,7 @@ export default function WelcomeEventPage() {
'Fill in a few details, invite co-hosts, and open your guest gallery for the big day.', 'Fill in a few details, invite co-hosts, and open your guest gallery for the big day.',
)} )}
onBack={() => navigate(backTarget)} onBack={() => navigate(backTarget)}
onSkip={() => navigate(ADMIN_HOME_PATH)} onSkip={handleSkip}
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')} skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
> >
<MobileCard space="$3"> <MobileCard space="$3">

View File

@@ -7,7 +7,7 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { MobileCard, CTAButton, PillBadge } from '../components/Primitives'; import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
import { OnboardingShell } from '../components/OnboardingShell'; import { OnboardingShell } from '../components/OnboardingShell';
import { getTenantPackagesOverview } from '../../api'; import { getTenantPackagesOverview, trackOnboarding } from '../../api';
import { useEventContext } from '../../context/EventContext'; import { useEventContext } from '../../context/EventContext';
import { import {
ADMIN_HOME_PATH, ADMIN_HOME_PATH,
@@ -29,6 +29,10 @@ export default function WelcomeLandingPage() {
const hasActivePackage = const hasActivePackage =
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active)); Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
const handleSkip = React.useCallback(() => {
void trackOnboarding('dismissed');
navigate(ADMIN_HOME_PATH);
}, [navigate]);
return ( return (
<OnboardingShell <OnboardingShell
@@ -38,7 +42,7 @@ export default function WelcomeLandingPage() {
'layout.subtitle', 'layout.subtitle',
'Begin with an inspired introduction, secure your package, and craft the perfect guest gallery all optimised for mobile hosts.', 'Begin with an inspired introduction, secure your package, and craft the perfect guest gallery all optimised for mobile hosts.',
)} )}
onSkip={() => navigate(ADMIN_HOME_PATH)} onSkip={handleSkip}
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')} skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
> >
<MobileCard space="$3"> <MobileCard space="$3">

View File

@@ -14,7 +14,7 @@ import {
ADMIN_PUBLIC_LANDING_PATH, ADMIN_PUBLIC_LANDING_PATH,
ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_BASE_PATH,
} from './constants'; } from './constants';
import { getTenantPackagesOverview } from './api'; import { fetchOnboardingStatus, getTenantPackagesOverview } from './api';
import { getSelectedPackageId } from './mobile/lib/onboardingSelection'; import { getSelectedPackageId } from './mobile/lib/onboardingSelection';
import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard'; import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard';
const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage')); const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
@@ -47,14 +47,15 @@ const MobileWelcomeSummaryPage = React.lazy(() => import('./mobile/welcome/Welco
const MobileWelcomeEventPage = React.lazy(() => import('./mobile/welcome/WelcomeEventPage')); const MobileWelcomeEventPage = React.lazy(() => import('./mobile/welcome/WelcomeEventPage'));
function RequireAuth() { function RequireAuth() {
const { status } = useAuth(); const { status, user } = useAuth();
const location = useLocation(); const location = useLocation();
const { hasEvents, isLoading: eventsLoading } = useEventContext(); const { hasEvents, isLoading: eventsLoading } = useEventContext();
const selectedPackageId = getSelectedPackageId(); const selectedPackageId = getSelectedPackageId();
const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH); const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH);
const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH); const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH);
const isTenantAdmin = Boolean(user && user.role !== 'member');
const shouldCheckPackages = const shouldCheckPackages =
status === 'authenticated' && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath; status === 'authenticated' && isTenantAdmin && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath;
const { data: packagesData, isLoading: packagesLoading } = useQuery({ const { data: packagesData, isLoading: packagesLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'], queryKey: ['mobile', 'onboarding', 'packages-overview'],
@@ -63,8 +64,18 @@ function RequireAuth() {
staleTime: 60_000, staleTime: 60_000,
}); });
const { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'status'],
queryFn: fetchOnboardingStatus,
enabled: shouldCheckPackages,
staleTime: 60_000,
});
const hasActivePackage = const hasActivePackage =
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active)); Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
const isOnboardingDismissed = Boolean(onboardingStatus?.steps?.dismissed_at);
const isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at);
const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading;
const redirectTarget = resolveOnboardingRedirect({ const redirectTarget = resolveOnboardingRedirect({
hasEvents, hasEvents,
@@ -73,6 +84,8 @@ function RequireAuth() {
pathname: location.pathname, pathname: location.pathname,
isWelcomePath, isWelcomePath,
isBillingPath, isBillingPath,
isOnboardingDismissed,
isOnboardingCompleted,
}); });
if (status === 'loading') { if (status === 'loading') {
@@ -87,7 +100,7 @@ function RequireAuth() {
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />; return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
} }
if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading)) { if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading || shouldBlockOnboarding)) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Bitte warten ... Bitte warten ...

View File

@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Api\Tenant;
use Illuminate\Support\Arr;
use Tests\Feature\Tenant\TenantTestCase;
class OnboardingStatusTest extends TenantTestCase
{
public function test_tenant_can_dismiss_onboarding(): void
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
'step' => 'dismissed',
]);
$response->assertOk();
$this->tenant->refresh();
$dismissedAt = Arr::get($this->tenant->settings ?? [], 'onboarding.dismissed_at');
$this->assertNotNull($dismissedAt);
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
$show->assertOk();
$show->assertJsonPath('steps.dismissed_at', $dismissedAt);
}
public function test_tenant_can_mark_onboarding_completed(): void
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
'step' => 'completed',
'meta' => [],
]);
$response->assertOk();
$this->tenant->refresh();
$completedAt = Arr::get($this->tenant->settings ?? [], 'onboarding.completed_at');
$this->assertNotNull($completedAt);
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
$show->assertOk();
$show->assertJsonPath('steps.completed_at', $completedAt);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
use Tests\TestCase;
class ScheduleConfigurationTest extends TestCase
{
public function test_storage_and_checkout_commands_are_scheduled(): void
{
Artisan::call('schedule:list', [
'--json' => true,
'--no-interaction' => true,
]);
$tasks = json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR);
$this->assertScheduledCommand($tasks, 'storage:monitor', '*/5 * * * *');
$this->assertScheduledCommand($tasks, 'storage:check-upload-queues', '*/5 * * * *');
$this->assertScheduledCommand($tasks, 'storage:archive-pending', '0 1 * * *');
$this->assertScheduledCommand($tasks, 'checkout:send-reminders', '0 * * * *');
}
/**
* @param array<int, array<string, mixed>> $tasks
*/
private function assertScheduledCommand(array $tasks, string $command, string $expression): void
{
foreach ($tasks as $task) {
$taskCommand = (string) ($task['command'] ?? '');
if ($taskCommand !== '' && Str::contains($taskCommand, $command)) {
$this->assertSame($expression, $task['expression'] ?? null);
return;
}
}
$this->fail(sprintf('Scheduled command not found: %s', $command));
}
}

View File

@@ -2,6 +2,10 @@
namespace Tests\Feature; namespace Tests\Feature;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase; use Tests\TestCase;
@@ -55,4 +59,156 @@ class SentryReportingTest extends TestCase
$this->assertSame(0, $fake->captured, 'Sentry should ignore 4xx errors'); $this->assertSame(0, $fake->captured, 'Sentry should ignore 4xx errors');
} }
public function test_reports_scheduled_task_failures_to_sentry(): void
{
$fake = new class
{
public int $captured = 0;
public function captureException(mixed $exception = null): void
{
$this->captured++;
}
};
$this->app->instance('sentry', $fake);
Artisan::call('schedule:list', [
'--json' => true,
'--no-interaction' => true,
]);
$schedule = $this->app->make(Schedule::class);
$task = collect($schedule->events())
->first(fn ($event) => str_contains((string) $event->command, 'storage:monitor'));
$this->assertNotNull($task, 'Expected scheduled storage:monitor task');
$task->finish($this->app, 1);
$this->assertSame(1, $fake->captured, 'Sentry should receive scheduled task failures');
}
public function test_reports_queue_failures_to_sentry(): void
{
$fake = new class
{
public int $captured = 0;
public function captureException(mixed $exception = null): void
{
$this->captured++;
}
};
$this->app->instance('sentry', $fake);
$job = new class implements \Illuminate\Contracts\Queue\Job
{
public function uuid(): ?string
{
return null;
}
public function getJobId(): string
{
return 'job-id';
}
public function payload(): array
{
return [];
}
public function fire(): void {}
public function release($delay = 0): void {}
public function isReleased(): bool
{
return false;
}
public function delete(): void {}
public function isDeleted(): bool
{
return false;
}
public function isDeletedOrReleased(): bool
{
return false;
}
public function attempts(): int
{
return 1;
}
public function hasFailed(): bool
{
return false;
}
public function markAsFailed(): void {}
public function fail($e = null): void {}
public function maxTries(): ?int
{
return null;
}
public function maxExceptions(): ?int
{
return null;
}
public function timeout(): ?int
{
return null;
}
public function retryUntil(): ?int
{
return null;
}
public function getName(): string
{
return 'FakeJob';
}
public function resolveName(): string
{
return 'FakeJob';
}
public function resolveQueuedJobClass(): string
{
return 'FakeJob';
}
public function getConnectionName(): string
{
return 'redis';
}
public function getQueue(): string
{
return 'default';
}
public function getRawBody(): string
{
return '{}';
}
};
Event::dispatch(new JobFailed('redis', $job, new \RuntimeException('Queue failure')));
$this->assertSame(1, $fake->captured, 'Sentry should receive queue failures');
}
} }

View File

@@ -27,11 +27,17 @@ class EventAddonCheckoutTest extends TenantTestCase
// Fake Paddle response // Fake Paddle response
Http::fake([ Http::fake([
'*/checkout/links' => Http::response([ '*/customers' => Http::response([
'data' => [ 'data' => [
'url' => 'https://checkout.paddle.test/abcd', 'id' => 'ctm_addon_123',
'id' => 'chk_addon_123', ],
'expires_at' => now()->addHour()->toIso8601String(), ], 200),
'*/transactions' => Http::response([
'data' => [
'id' => 'txn_addon_123',
'checkout' => [
'url' => 'https://checkout.paddle.test/abcd',
],
], ],
], 200), ], 200),
]); ]);
@@ -67,14 +73,14 @@ class EventAddonCheckoutTest extends TenantTestCase
]); ]);
$response->assertOk(); $response->assertOk();
$response->assertJsonPath('checkout_id', 'chk_addon_123'); $response->assertJsonPath('checkout_id', 'txn_addon_123');
$this->assertDatabaseHas('event_package_addons', [ $this->assertDatabaseHas('event_package_addons', [
'event_package_id' => $eventPackage->id, 'event_package_id' => $eventPackage->id,
'addon_key' => 'extra_photos_small', 'addon_key' => 'extra_photos_small',
'status' => 'pending', 'status' => 'pending',
'quantity' => 2, 'quantity' => 2,
'checkout_id' => 'chk_addon_123', 'transaction_id' => 'txn_addon_123',
]); ]);
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();

View File

@@ -37,12 +37,18 @@ class GiftVoucherCheckoutServiceTest extends TestCase
$client = Mockery::mock(PaddleClient::class); $client = Mockery::mock(PaddleClient::class);
$client->shouldReceive('post') $client->shouldReceive('post')
->once() ->once()
->with('/checkout/links', Mockery::on(function ($payload) { ->with('/customers', Mockery::on(function ($payload) {
return $payload['email'] === 'buyer@example.com';
}))
->andReturn(['data' => ['id' => 'ctm_123']]);
$client->shouldReceive('post')
->once()
->with('/transactions', Mockery::on(function ($payload) {
return $payload['items'][0]['price_id'] === 'pri_a' return $payload['items'][0]['price_id'] === 'pri_a'
&& $payload['customer_email'] === 'buyer@example.com' && $payload['customer_id'] === 'ctm_123'
&& $payload['custom_data']['type'] === 'gift_voucher'; && $payload['custom_data']['type'] === 'gift_voucher';
})) }))
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'expires_at' => '2025-12-31T00:00:00Z', 'id' => 'chk_123']]); ->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]);
$this->app->instance(PaddleClient::class, $client); $this->app->instance(PaddleClient::class, $client);
@@ -57,6 +63,6 @@ class GiftVoucherCheckoutServiceTest extends TestCase
]); ]);
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']); $this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
$this->assertSame('chk_123', $checkout['id']); $this->assertSame('txn_123', $checkout['id']);
} }
} }

View File

@@ -35,15 +35,18 @@ class PaddleCheckoutServiceTest extends TestCase
$client->shouldReceive('post') $client->shouldReceive('post')
->once() ->once()
->with('/checkout/links', Mockery::on(function (array $payload) use ($tenant, $package) { ->with('/transactions', Mockery::on(function (array $payload) use ($tenant, $package) {
return $payload['items'][0]['price_id'] === 'pri_123' return $payload['items'][0]['price_id'] === 'pri_123'
&& $payload['customer_id'] === 'ctm_123' && $payload['customer_id'] === 'ctm_123'
&& ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id && ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id
&& ($payload['custom_data']['package_id'] ?? null) === (string) $package->id && ($payload['custom_data']['package_id'] ?? null) === (string) $package->id
&& ($payload['custom_data']['source'] ?? null) === 'test' && ($payload['custom_data']['source'] ?? null) === 'test'
&& ! isset($payload['metadata']); && ! isset($payload['metadata'])
&& ! isset($payload['success_url'])
&& ! isset($payload['cancel_url'])
&& ! isset($payload['customer_email']);
})) }))
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'id' => 'chk_123']]); ->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]);
$this->app->instance(PaddleClient::class, $client); $this->app->instance(PaddleClient::class, $client);
$this->app->instance(PaddleCustomerService::class, $customers); $this->app->instance(PaddleCustomerService::class, $customers);
@@ -57,6 +60,6 @@ class PaddleCheckoutServiceTest extends TestCase
]); ]);
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']); $this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
$this->assertSame('chk_123', $checkout['id']); $this->assertSame('txn_123', $checkout['id']);
} }
} }