diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index c9cbeab..ec1146a 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -3,6 +3,7 @@ namespace App\Exceptions; use App\Support\ApiError; +use App\Support\SentryReporter; use Illuminate\Database\Connectors\ConnectionException as DatabaseConnectionException; use Illuminate\Database\QueryException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; @@ -43,13 +44,9 @@ class Handler extends ExceptionHandler return; } - if (! app()->bound('sentry') || empty(config('sentry.dsn'))) { - return; - } - $this->configureSentryScope(); - app('sentry')->captureException($e); + SentryReporter::captureException($e); }); } diff --git a/app/Http/Controllers/Api/Tenant/OnboardingController.php b/app/Http/Controllers/Api/Tenant/OnboardingController.php index 54b36ba..41e0703 100644 --- a/app/Http/Controllers/Api/Tenant/OnboardingController.php +++ b/app/Http/Controllers/Api/Tenant/OnboardingController.php @@ -34,6 +34,8 @@ class OnboardingController extends Controller 'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'), 'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'), '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), 'tasks_configured' => (bool) ($status['packages'] ?? 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()); break; + case 'dismissed': + Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String()); + break; + case 'completed': TenantOnboardingState::markCompleted($tenant, $meta); break; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index da8bb05..473845d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -32,8 +32,10 @@ use App\Services\Checkout\CheckoutSessionService; use App\Services\Security\PhotoSecurityScanner; use App\Services\Storage\EventStorageManager; use App\Services\Storage\StorageHealthService; +use App\Support\SentryReporter; use App\Testing\Mailbox; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Console\Events\ScheduledTaskFailed; use Illuminate\Http\Request; use Illuminate\Mail\Events\MessageSent; 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) { - $context = [ - 'queue' => $event->job->getQueue(), - 'job' => $event->job->resolveName(), - 'exception' => $event->exception->getMessage(), - ]; + Queue::failing(function (JobFailed $event) { + $context = [ + 'queue' => $event->job->getQueue(), + 'job' => $event->job->resolveName(), + 'exception' => $event->exception->getMessage(), + ]; - $command = data_get($event->job->payload(), 'data.command'); + $command = data_get($event->job->payload(), 'data.command'); - if (is_string($command)) { - try { - $instance = @unserialize($command, ['allowed_classes' => true]); - if (is_object($instance)) { - foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) { - if (isset($instance->{$property})) { - $context[$label] = $instance->{$property}; - } + if (is_string($command)) { + try { + $instance = @unserialize($command, ['allowed_classes' => true]); + if (is_object($instance)) { + foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) { + if (isset($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')) { 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()) { $this->app->register(\App\Providers\Filament\AdminPanelProvider::class); diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index 0d6dc80..ce4084d 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -5,8 +5,8 @@ namespace App\Services\Addons; use App\Models\Event; use App\Models\EventPackageAddon; use App\Models\Tenant; -use App\Services\Paddle\PaddleCustomerService; use App\Services\Paddle\PaddleClient; +use App\Services\Paddle\PaddleCustomerService; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -86,15 +86,20 @@ class EventAddonCheckoutService 'quantity' => $quantity, ], ], - 'metadata' => $metadata, - 'success_url' => $payload['success_url'] ?? null, - 'cancel_url' => $payload['cancel_url'] ?? null, + 'custom_data' => $metadata, ], 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'); - $checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id'); + $checkoutUrl = Arr::get($response, 'data.checkout.url') + ?? 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) { Log::warning('Paddle addon checkout response missing url', ['response' => $response]); @@ -108,7 +113,7 @@ class EventAddonCheckoutService 'quantity' => $quantity, 'price_id' => $priceId, 'checkout_id' => $checkoutId, - 'transaction_id' => null, + 'transaction_id' => $transactionId, 'status' => 'pending', 'metadata' => array_merge($metadata, [ 'increments' => $increments, @@ -126,8 +131,10 @@ class EventAddonCheckoutService return [ 'checkout_url' => $checkoutUrl, - 'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'), - 'id' => $checkoutId, + 'expires_at' => Arr::get($response, 'data.checkout.expires_at') + ?? Arr::get($response, 'data.expires_at') + ?? Arr::get($response, 'expires_at'), + 'id' => $transactionId ?? $checkoutId, ]; } diff --git a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php index cc62701..5774bf9 100644 --- a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php +++ b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php @@ -2,6 +2,7 @@ namespace App\Services\GiftVouchers; +use App\Services\Paddle\Exceptions\PaddleException; use App\Services\Paddle\PaddleClient; use Illuminate\Support\Arr; use Illuminate\Support\Facades\App; @@ -49,6 +50,8 @@ class GiftVoucherCheckoutService ]); } + $customerId = $this->ensureCustomerId($data['purchaser_email']); + $payload = [ 'items' => [ [ @@ -56,7 +59,7 @@ class GiftVoucherCheckoutService 'quantity' => 1, ], ], - 'customer_email' => $data['purchaser_email'], + 'customer_id' => $customerId, 'custom_data' => array_filter([ 'type' => 'gift_voucher', 'tier_key' => $tier['key'], @@ -66,15 +69,18 @@ class GiftVoucherCheckoutService 'message' => $data['message'] ?? null, '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 [ - 'checkout_url' => Arr::get($response, 'data.url') ?? Arr::get($response, 'url'), - 'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'), + 'checkout_url' => Arr::get($response, 'data.checkout.url') + ?? 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'), ]; } @@ -97,4 +103,43 @@ class GiftVoucherCheckoutService 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'); + } } diff --git a/app/Services/Paddle/PaddleCheckoutService.php b/app/Services/Paddle/PaddleCheckoutService.php index 9574232..028217c 100644 --- a/app/Services/Paddle/PaddleCheckoutService.php +++ b/app/Services/Paddle/PaddleCheckoutService.php @@ -5,7 +5,6 @@ namespace App\Services\Paddle; use App\Models\Package; use App\Models\Tenant; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; class PaddleCheckoutService @@ -22,15 +21,6 @@ class PaddleCheckoutService { $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( $tenant, $package, @@ -46,21 +36,18 @@ class PaddleCheckoutService ], ], 'custom_data' => $customData, - 'success_url' => $successUrl, - 'cancel_url' => $returnUrl, ]; if (! empty($options['discount_id'])) { $payload['discount_id'] = $options['discount_id']; } - if ($tenant->contact_email) { - $payload['customer_email'] = $tenant->contact_email; - } + $response = $this->client->post('/transactions', $payload); - $response = $this->client->post('/checkout/links', $payload); - - $checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url'); + $checkoutUrl = Arr::get($response, 'data.checkout.url') + ?? Arr::get($response, 'checkout.url') + ?? Arr::get($response, 'data.url') + ?? Arr::get($response, 'url'); if (! $checkoutUrl) { Log::warning('Paddle checkout response missing url', ['response' => $response]); @@ -68,7 +55,9 @@ class PaddleCheckoutService return [ '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'), ]; } diff --git a/app/Support/SentryReporter.php b/app/Support/SentryReporter.php new file mode 100644 index 0000000..b5ed045 --- /dev/null +++ b/app/Support/SentryReporter.php @@ -0,0 +1,87 @@ +, extra?: array} $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, extra?: array} $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); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c5a7a23..441a578 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -36,12 +36,55 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\SeedDemoSwitcherTenants::class, ]) ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { - $schedule->command('package:check-status')->dailyAt('06:00'); - $schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping(); - $schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping(); - $schedule->command('exports:purge')->dailyAt('02:00'); - $schedule->command('tenants:retention-scan')->dailyAt('03:00'); - $schedule->command('guest:feedback-reminders')->dailyAt('22:00'); + $onFailure = static function (string $command): \Closure { + return static function () use ($command): void { + \App\Support\SentryReporter::captureException( + new \RuntimeException('Scheduled command failed'), + [ + '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) { $middleware->trustProxies( diff --git a/config/horizon.php b/config/horizon.php index 1153f9d..f2fd0a0 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -98,6 +98,10 @@ return [ 'waits' => [ 'redis:default' => 60, + 'redis:media-storage' => 60, + 'redis:media-security' => 60, + 'redis:notifications' => 60, + 'redis:webhooks' => 60, ], /* @@ -197,9 +201,22 @@ return [ */ 'defaults' => [ - 'supervisor-1' => [ + 'supervisor-default' => [ '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', 'autoScalingStrategy' => 'time', 'maxProcesses' => 1, @@ -214,17 +231,25 @@ return [ 'environments' => [ 'production' => [ - 'supervisor-1' => [ + 'supervisor-default' => [ 'maxProcesses' => 10, 'balanceMaxShift' => 1, 'balanceCooldown' => 3, ], + 'supervisor-media' => [ + 'maxProcesses' => 3, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], ], 'local' => [ - 'supervisor-1' => [ + 'supervisor-default' => [ 'maxProcesses' => 3, ], + 'supervisor-media' => [ + 'maxProcesses' => 1, + ], ], ], ]; diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index ef602ca..f4e8c13 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -195,7 +195,7 @@ services: image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest} env_file: - path: .env - command: /var/www/html/scripts/queue-worker.sh default + command: /var/www/html/scripts/queue-worker.sh default,notifications,webhooks environment: <<: *app-env SKIP_CODE_SYNC: "1" @@ -237,6 +237,30 @@ services: condition: service_started 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: image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest} env_file: diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 5d75450..b042fec 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -305,6 +305,8 @@ export type TenantOnboardingStatus = { admin_app_opened_at?: string | null; primary_event_id?: number | string | null; selected_packages?: unknown; + dismissed_at?: string | null; + completed_at?: string | null; branding_completed?: boolean; tasks_configured?: boolean; event_created?: boolean; diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index c98aff3..9845812 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2217,6 +2217,24 @@ "checkoutMissing": "Checkout konnte nicht gestartet werden.", "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": { "untitled": "Unbenanntes Event" }, diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index f261c3c..f0ec75e 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2221,6 +2221,24 @@ "checkoutMissing": "Checkout could not be started.", "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": { "untitled": "Untitled event" }, diff --git a/resources/js/admin/mobile/components/LegalConsentSheet.tsx b/resources/js/admin/mobile/components/LegalConsentSheet.tsx index 5b9a6a4..bde1eb7 100644 --- a/resources/js/admin/mobile/components/LegalConsentSheet.tsx +++ b/resources/js/admin/mobile/components/LegalConsentSheet.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { YStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; +import { useTheme } from '@tamagui/core'; import { MobileSheet } from './Sheet'; import { CTAButton } from './Primitives'; @@ -36,9 +37,24 @@ export function LegalConsentSheet({ copy, t, }: LegalConsentSheetProps) { + const theme = useTheme(); const [acceptedTerms, setAcceptedTerms] = React.useState(false); const [acceptedWaiver, setAcceptedWaiver] = React.useState(false); const [error, setError] = React.useState(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(() => { if (open) { @@ -103,7 +119,7 @@ export function LegalConsentSheet({ type="checkbox" checked={acceptedTerms} onChange={(event) => setAcceptedTerms(event.target.checked)} - style={{ marginTop: 4, width: 16, height: 16 }} + style={checkboxStyle} /> {copy?.checkboxTerms ?? t( @@ -119,7 +135,7 @@ export function LegalConsentSheet({ type="checkbox" checked={acceptedWaiver} onChange={(event) => setAcceptedWaiver(event.target.checked)} - style={{ marginTop: 4, width: 16, height: 16 }} + style={checkboxStyle} /> {copy?.checkboxWaiver ?? t( diff --git a/resources/js/admin/mobile/components/__tests__/LegalConsentSheet.test.tsx b/resources/js/admin/mobile/components/__tests__/LegalConsentSheet.test.tsx new file mode 100644 index 0000000..3ace955 --- /dev/null +++ b/resources/js/admin/mobile/components/__tests__/LegalConsentSheet.test.tsx @@ -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 }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ children }: { children: React.ReactNode }) => , +})); + +import { LegalConsentSheet } from '../LegalConsentSheet'; + +describe('LegalConsentSheet', () => { + it('renders the required consent checkboxes when open', () => { + const { getAllByRole } = render( + fallback ?? key} + /> + ); + + expect(getAllByRole('checkbox')).toHaveLength(2); + }); +}); diff --git a/resources/js/admin/mobile/lib/onboardingGuard.test.ts b/resources/js/admin/mobile/lib/onboardingGuard.test.ts index ea07121..dfe3c50 100644 --- a/resources/js/admin/mobile/lib/onboardingGuard.test.ts +++ b/resources/js/admin/mobile/lib/onboardingGuard.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { resolveOnboardingRedirect } from './onboardingGuard'; import { + ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, - ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, } from '../../constants'; @@ -15,6 +15,8 @@ describe('resolveOnboardingRedirect', () => { pathname: '/event-admin/mobile/dashboard', isWelcomePath: false, isBillingPath: false, + isOnboardingDismissed: false, + isOnboardingCompleted: false, }); expect(result).toBeNull(); }); @@ -24,9 +26,11 @@ describe('resolveOnboardingRedirect', () => { hasEvents: false, hasActivePackage: false, selectedPackageId: null, - pathname: ADMIN_WELCOME_PACKAGES_PATH, + pathname: ADMIN_WELCOME_BASE_PATH, isWelcomePath: true, isBillingPath: false, + isOnboardingDismissed: false, + isOnboardingCompleted: false, }); expect(result).toBeNull(); }); @@ -39,6 +43,8 @@ describe('resolveOnboardingRedirect', () => { pathname: '/event-admin/mobile/billing', isWelcomePath: false, isBillingPath: true, + isOnboardingDismissed: false, + isOnboardingCompleted: false, }); expect(result).toBeNull(); }); @@ -51,6 +57,8 @@ describe('resolveOnboardingRedirect', () => { pathname: '/event-admin/mobile/dashboard', isWelcomePath: false, isBillingPath: false, + isOnboardingDismissed: false, + isOnboardingCompleted: false, }); expect(result).toBe(ADMIN_WELCOME_EVENT_PATH); }); @@ -63,11 +71,13 @@ describe('resolveOnboardingRedirect', () => { pathname: '/event-admin/mobile/dashboard', isWelcomePath: false, isBillingPath: false, + isOnboardingDismissed: false, + isOnboardingCompleted: false, }); 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({ hasEvents: false, hasActivePackage: false, @@ -75,8 +85,10 @@ describe('resolveOnboardingRedirect', () => { pathname: '/event-admin/mobile/dashboard', isWelcomePath: 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', () => { @@ -84,9 +96,39 @@ describe('resolveOnboardingRedirect', () => { hasEvents: false, hasActivePackage: false, selectedPackageId: null, - pathname: ADMIN_WELCOME_PACKAGES_PATH, + pathname: ADMIN_WELCOME_BASE_PATH, isWelcomePath: 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(); }); diff --git a/resources/js/admin/mobile/lib/onboardingGuard.ts b/resources/js/admin/mobile/lib/onboardingGuard.ts index 3f4c440..d89fac5 100644 --- a/resources/js/admin/mobile/lib/onboardingGuard.ts +++ b/resources/js/admin/mobile/lib/onboardingGuard.ts @@ -1,6 +1,6 @@ import { + ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, - ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, } from '../../constants'; @@ -11,6 +11,8 @@ type OnboardingRedirectInput = { pathname: string; isWelcomePath: boolean; isBillingPath: boolean; + isOnboardingDismissed?: boolean; + isOnboardingCompleted?: boolean; }; export function resolveOnboardingRedirect({ @@ -20,11 +22,17 @@ export function resolveOnboardingRedirect({ pathname, isWelcomePath, isBillingPath, + isOnboardingDismissed, + isOnboardingCompleted, }: OnboardingRedirectInput): string | null { if (hasEvents) { return null; } + if (isOnboardingDismissed || isOnboardingCompleted) { + return null; + } + if (isWelcomePath || isBillingPath) { return null; } @@ -34,7 +42,7 @@ export function resolveOnboardingRedirect({ ? ADMIN_WELCOME_EVENT_PATH : shouldContinueSummary ? ADMIN_WELCOME_SUMMARY_PATH - : ADMIN_WELCOME_PACKAGES_PATH; + : ADMIN_WELCOME_BASE_PATH; if (pathname === target) { return null; diff --git a/resources/js/admin/mobile/welcome/WelcomeEventPage.tsx b/resources/js/admin/mobile/welcome/WelcomeEventPage.tsx index 95b51f5..b903b28 100644 --- a/resources/js/admin/mobile/welcome/WelcomeEventPage.tsx +++ b/resources/js/admin/mobile/welcome/WelcomeEventPage.tsx @@ -8,7 +8,7 @@ import { SizableText as Text } from '@tamagui/text'; import { OnboardingShell } from '../components/OnboardingShell'; 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 { getTenantPackagesOverview } from '../../api'; +import { getTenantPackagesOverview, trackOnboarding } from '../../api'; import { getSelectedPackageId } from '../lib/onboardingSelection'; export default function WelcomeEventPage() { @@ -24,6 +24,10 @@ export default function WelcomeEventPage() { const hasActivePackage = 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 ? 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.', )} onBack={() => navigate(backTarget)} - onSkip={() => navigate(ADMIN_HOME_PATH)} + onSkip={handleSkip} skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')} > diff --git a/resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx b/resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx index f0ec81a..f36c7d7 100644 --- a/resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx +++ b/resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx @@ -7,7 +7,7 @@ import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { MobileCard, CTAButton, PillBadge } from '../components/Primitives'; import { OnboardingShell } from '../components/OnboardingShell'; -import { getTenantPackagesOverview } from '../../api'; +import { getTenantPackagesOverview, trackOnboarding } from '../../api'; import { useEventContext } from '../../context/EventContext'; import { ADMIN_HOME_PATH, @@ -29,6 +29,10 @@ export default function WelcomeLandingPage() { const hasActivePackage = Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active)); + const handleSkip = React.useCallback(() => { + void trackOnboarding('dismissed'); + navigate(ADMIN_HOME_PATH); + }, [navigate]); return ( navigate(ADMIN_HOME_PATH)} + onSkip={handleSkip} skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')} > diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index a95cb6a..e412879 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -14,7 +14,7 @@ import { ADMIN_PUBLIC_LANDING_PATH, ADMIN_WELCOME_BASE_PATH, } from './constants'; -import { getTenantPackagesOverview } from './api'; +import { fetchOnboardingStatus, getTenantPackagesOverview } from './api'; import { getSelectedPackageId } from './mobile/lib/onboardingSelection'; import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard'; 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')); function RequireAuth() { - const { status } = useAuth(); + const { status, user } = useAuth(); const location = useLocation(); const { hasEvents, isLoading: eventsLoading } = useEventContext(); const selectedPackageId = getSelectedPackageId(); const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH); const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH); + const isTenantAdmin = Boolean(user && user.role !== 'member'); const shouldCheckPackages = - status === 'authenticated' && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath; + status === 'authenticated' && isTenantAdmin && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath; const { data: packagesData, isLoading: packagesLoading } = useQuery({ queryKey: ['mobile', 'onboarding', 'packages-overview'], @@ -63,8 +64,18 @@ function RequireAuth() { staleTime: 60_000, }); + const { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({ + queryKey: ['mobile', 'onboarding', 'status'], + queryFn: fetchOnboardingStatus, + enabled: shouldCheckPackages, + staleTime: 60_000, + }); + const hasActivePackage = 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({ hasEvents, @@ -73,6 +84,8 @@ function RequireAuth() { pathname: location.pathname, isWelcomePath, isBillingPath, + isOnboardingDismissed, + isOnboardingCompleted, }); if (status === 'loading') { @@ -87,7 +100,7 @@ function RequireAuth() { return ; } - if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading)) { + if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading || shouldBlockOnboarding)) { return (
Bitte warten ... diff --git a/tests/Feature/Api/Tenant/OnboardingStatusTest.php b/tests/Feature/Api/Tenant/OnboardingStatusTest.php new file mode 100644 index 0000000..7956b9e --- /dev/null +++ b/tests/Feature/Api/Tenant/OnboardingStatusTest.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/tests/Feature/Console/ScheduleConfigurationTest.php b/tests/Feature/Console/ScheduleConfigurationTest.php new file mode 100644 index 0000000..261f9fe --- /dev/null +++ b/tests/Feature/Console/ScheduleConfigurationTest.php @@ -0,0 +1,42 @@ + 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> $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)); + } +} diff --git a/tests/Feature/SentryReportingTest.php b/tests/Feature/SentryReportingTest.php index 4aa1fcb..1bebc2b 100644 --- a/tests/Feature/SentryReportingTest.php +++ b/tests/Feature/SentryReportingTest.php @@ -2,6 +2,10 @@ 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 Tests\TestCase; @@ -55,4 +59,156 @@ class SentryReportingTest extends TestCase $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'); + } } diff --git a/tests/Feature/Tenant/EventAddonCheckoutTest.php b/tests/Feature/Tenant/EventAddonCheckoutTest.php index 1feffbe..95a2911 100644 --- a/tests/Feature/Tenant/EventAddonCheckoutTest.php +++ b/tests/Feature/Tenant/EventAddonCheckoutTest.php @@ -27,11 +27,17 @@ class EventAddonCheckoutTest extends TenantTestCase // Fake Paddle response Http::fake([ - '*/checkout/links' => Http::response([ + '*/customers' => Http::response([ 'data' => [ - 'url' => 'https://checkout.paddle.test/abcd', - 'id' => 'chk_addon_123', - 'expires_at' => now()->addHour()->toIso8601String(), + 'id' => 'ctm_addon_123', + ], + ], 200), + '*/transactions' => Http::response([ + 'data' => [ + 'id' => 'txn_addon_123', + 'checkout' => [ + 'url' => 'https://checkout.paddle.test/abcd', + ], ], ], 200), ]); @@ -67,14 +73,14 @@ class EventAddonCheckoutTest extends TenantTestCase ]); $response->assertOk(); - $response->assertJsonPath('checkout_id', 'chk_addon_123'); + $response->assertJsonPath('checkout_id', 'txn_addon_123'); $this->assertDatabaseHas('event_package_addons', [ 'event_package_id' => $eventPackage->id, 'addon_key' => 'extra_photos_small', 'status' => 'pending', 'quantity' => 2, - 'checkout_id' => 'chk_addon_123', + 'transaction_id' => 'txn_addon_123', ]); $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); diff --git a/tests/Unit/GiftVoucherCheckoutServiceTest.php b/tests/Unit/GiftVoucherCheckoutServiceTest.php index e395ae7..39d8f67 100644 --- a/tests/Unit/GiftVoucherCheckoutServiceTest.php +++ b/tests/Unit/GiftVoucherCheckoutServiceTest.php @@ -37,12 +37,18 @@ class GiftVoucherCheckoutServiceTest extends TestCase $client = Mockery::mock(PaddleClient::class); $client->shouldReceive('post') ->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' - && $payload['customer_email'] === 'buyer@example.com' + && $payload['customer_id'] === 'ctm_123' && $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); @@ -57,6 +63,6 @@ class GiftVoucherCheckoutServiceTest extends TestCase ]); $this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']); - $this->assertSame('chk_123', $checkout['id']); + $this->assertSame('txn_123', $checkout['id']); } } diff --git a/tests/Unit/PaddleCheckoutServiceTest.php b/tests/Unit/PaddleCheckoutServiceTest.php index 769303a..f0528f3 100644 --- a/tests/Unit/PaddleCheckoutServiceTest.php +++ b/tests/Unit/PaddleCheckoutServiceTest.php @@ -35,15 +35,18 @@ class PaddleCheckoutServiceTest extends TestCase $client->shouldReceive('post') ->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' && $payload['customer_id'] === 'ctm_123' && ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id && ($payload['custom_data']['package_id'] ?? null) === (string) $package->id && ($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(PaddleCustomerService::class, $customers); @@ -57,6 +60,6 @@ class PaddleCheckoutServiceTest extends TestCase ]); $this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']); - $this->assertSame('chk_123', $checkout['id']); + $this->assertSame('txn_123', $checkout['id']); } }