From 02363792c873c8717728a1b63a815efe224bc458 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Jan 2026 13:31:30 +0100 Subject: [PATCH] feat: poll checkout status and show failures --- .../Controllers/Api/PackageController.php | 66 +++++++- resources/js/admin/api.ts | 14 +- .../js/admin/i18n/locales/de/management.json | 10 ++ .../js/admin/i18n/locales/en/management.json | 10 ++ resources/js/admin/mobile/BillingPage.tsx | 152 +++++++++++++----- .../mobile/__tests__/billingCheckout.test.ts | 26 ++- .../admin/mobile/hooks/usePackageCheckout.ts | 12 +- .../js/admin/mobile/lib/billingCheckout.ts | 51 ++++++ routes/api.php | 2 + .../TenantCheckoutSessionStatusTest.php | 42 +++++ .../Tenant/TenantPaddleCheckoutTest.php | 8 +- 11 files changed, 345 insertions(+), 48 deletions(-) create mode 100644 tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index bf803ec..60b3acb 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -3,9 +3,12 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Checkout\CheckoutSessionStatusRequest; +use App\Models\CheckoutSession; use App\Models\Package; use App\Models\PackagePurchase; use App\Models\TenantPackage; +use App\Services\Checkout\CheckoutSessionService; use App\Services\Paddle\PaddleCheckoutService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException; class PackageController extends Controller { - public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {} + public function __construct( + private readonly PaddleCheckoutService $paddleCheckout, + private readonly CheckoutSessionService $sessions, + ) {} public function index(Request $request): JsonResponse { @@ -165,23 +171,79 @@ class PackageController extends Controller $package = Package::findOrFail($request->integer('package_id')); $tenant = $request->attributes->get('tenant'); + $user = $request->user(); if (! $tenant) { throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']); } + if (! $user) { + throw ValidationException::withMessages(['user' => 'User context missing.']); + } + if (! $package->paddle_price_id) { throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); } + $session = $this->sessions->createOrResume($user, $package, [ + 'tenant' => $tenant, + ]); + + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + + $now = now(); + + $session->forceFill([ + 'accepted_terms_at' => $now, + 'accepted_privacy_at' => $now, + 'accepted_withdrawal_notice_at' => $now, + 'digital_content_waiver_at' => null, + 'legal_version' => config('app.legal_version', $now->toDateString()), + ])->save(); + $payload = [ 'success_url' => $request->input('success_url'), 'return_url' => $request->input('return_url'), + 'metadata' => [ + 'checkout_session_id' => $session->id, + 'legal_version' => $session->legal_version, + 'accepted_terms' => true, + ], ]; $checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); - return response()->json($checkout); + $session->forceFill([ + 'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, + 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ + 'paddle_checkout_id' => $checkout['id'] ?? null, + 'paddle_checkout_url' => $checkout['checkout_url'] ?? null, + 'paddle_expires_at' => $checkout['expires_at'] ?? null, + ])), + ])->save(); + + return response()->json(array_merge($checkout, [ + 'checkout_session_id' => $session->id, + ])); + } + + public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse + { + $history = $session->status_history ?? []; + $reason = null; + + foreach (array_reverse($history) as $entry) { + if (($entry['status'] ?? null) === $session->status) { + $reason = $entry['reason'] ?? null; + break; + } + } + + return response()->json([ + 'status' => $session->status, + 'completed_at' => optional($session->completed_at)->toIso8601String(), + 'reason' => $reason, + ]); } private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 219a41f..3cc5138 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -2458,7 +2458,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{ export async function createTenantPaddleCheckout( packageId: number, urls?: { success_url?: string; return_url?: string } -): Promise<{ checkout_url: string; id: string; expires_at?: string }> { +): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> { const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -2468,12 +2468,22 @@ export async function createTenantPaddleCheckout( return_url: urls?.return_url, }), }); - return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>( + return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>( response, 'Failed to create checkout' ); } +export async function getTenantPackageCheckoutStatus( + checkoutSessionId: string, +): Promise<{ status: string; completed_at?: string | null; reason?: string | null }> { + const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`); + return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null }>( + response, + 'Failed to load checkout status' + ); +} + export async function createTenantBillingPortalSession(): Promise<{ url: string }> { const response = await authorizedFetch('/api/v1/tenant/billing/portal', { method: 'POST', diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 47a9553..72607fb 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -36,11 +36,21 @@ }, "checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.", "checkoutCancelled": "Checkout wurde abgebrochen.", + "checkoutActivated": "Dein Paket ist jetzt aktiv.", "checkoutPendingTitle": "Paket wird aktiviert", "checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.", "checkoutPendingBadge": "Ausstehend", "checkoutPendingRefresh": "Aktualisieren", "checkoutPendingDismiss": "Ausblenden", + "checkoutFailedTitle": "Checkout fehlgeschlagen", + "checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.", + "checkoutFailedBadge": "Fehlgeschlagen", + "checkoutFailedRetry": "Erneut versuchen", + "checkoutFailedDismiss": "Ausblenden", + "checkoutFailureReasons": { + "paddle_failed": "Die Zahlung wurde abgelehnt.", + "paddle_cancelled": "Der Checkout wurde abgebrochen." + }, "sections": { "invoices": { "title": "Rechnungen & Zahlungen", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 91d846a..d2a3648 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -36,11 +36,21 @@ }, "checkoutSuccess": "Checkout completed. Your package will activate shortly.", "checkoutCancelled": "Checkout was cancelled.", + "checkoutActivated": "Your package is now active.", "checkoutPendingTitle": "Activating your package", "checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.", "checkoutPendingBadge": "Pending", "checkoutPendingRefresh": "Refresh", "checkoutPendingDismiss": "Dismiss", + "checkoutFailedTitle": "Checkout failed", + "checkoutFailedBody": "The payment did not complete. You can try again or contact support.", + "checkoutFailedBadge": "Failed", + "checkoutFailedRetry": "Try again", + "checkoutFailedDismiss": "Dismiss", + "checkoutFailureReasons": { + "paddle_failed": "The payment was declined.", + "paddle_cancelled": "The checkout was cancelled." + }, "sections": { "invoices": { "title": "Invoices & payments", diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index 9115261..420fff3 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -12,6 +12,7 @@ import { createTenantBillingPortalSession, getTenantPackagesOverview, getTenantPaddleTransactions, + getTenantPackageCheckoutStatus, TenantPackageSummary, PaddleTransactionSummary, } from '../api'; @@ -27,9 +28,14 @@ import { getPackageFeatureLabel, getPackageLimitEntries, } from './lib/packageSummary'; -import { PendingCheckout, PENDING_CHECKOUT_TTL_MS, shouldClearPendingCheckout } from './lib/billingCheckout'; +import { + PendingCheckout, + loadPendingCheckout, + shouldClearPendingCheckout, + storePendingCheckout, +} from './lib/billingCheckout'; -const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1'; +const CHECKOUT_POLL_INTERVAL_MS = 10000; export default function MobileBillingPage() { const { t } = useTranslation('management'); @@ -43,29 +49,10 @@ export default function MobileBillingPage() { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [portalBusy, setPortalBusy] = React.useState(false); - const [pendingCheckout, setPendingCheckout] = React.useState(() => { - if (typeof window === 'undefined') { - return null; - } - try { - const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as PendingCheckout; - if (typeof parsed?.startedAt !== 'number') { - return null; - } - const packageId = - typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId) - ? parsed.packageId - : null; - const isExpired = Date.now() - parsed.startedAt > PENDING_CHECKOUT_TTL_MS; - return isExpired ? null : { packageId, startedAt: parsed.startedAt }; - } catch { - return null; - } - }); + const [pendingCheckout, setPendingCheckout] = React.useState(() => loadPendingCheckout()); + const [checkoutStatus, setCheckoutStatus] = React.useState(null); + const [checkoutStatusReason, setCheckoutStatusReason] = React.useState(null); + const lastCheckoutStatusRef = React.useRef(null); const packagesRef = React.useRef(null); const invoicesRef = React.useRef(null); const supportEmail = 'support@fotospiel.de'; @@ -123,18 +110,7 @@ export default function MobileBillingPage() { const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => { setPendingCheckout(next); - if (typeof window === 'undefined') { - return; - } - try { - if (!next) { - window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY); - } else { - window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next)); - } - } catch { - // Ignore storage errors. - } + storePendingCheckout(next); }, []); React.useEffect(() => { @@ -164,8 +140,10 @@ export default function MobileBillingPage() { if (checkout === 'success') { const packageIdNumber = packageId ? Number(packageId) : null; + const existingSessionId = pendingCheckout?.checkoutSessionId ?? null; const pendingEntry = { packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null, + checkoutSessionId: existingSessionId, startedAt: Date.now(), }; persistPendingCheckout(pendingEntry); @@ -185,7 +163,7 @@ export default function MobileBillingPage() { }, { replace: true }, ); - }, [location.hash, location.pathname, location.search, navigate, persistPendingCheckout, t]); + }, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]); React.useEffect(() => { if (!pendingCheckout) { @@ -197,6 +175,64 @@ export default function MobileBillingPage() { } }, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]); + React.useEffect(() => { + if (!pendingCheckout?.checkoutSessionId) { + setCheckoutStatus(null); + setCheckoutStatusReason(null); + lastCheckoutStatusRef.current = null; + return; + } + + let active = true; + let intervalId: ReturnType | null = null; + + const poll = async () => { + try { + const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string); + if (!active) { + return; + } + setCheckoutStatus(result.status); + setCheckoutStatusReason(result.reason ?? null); + + const lastStatus = lastCheckoutStatusRef.current; + lastCheckoutStatusRef.current = result.status; + + if (result.status === 'completed') { + persistPendingCheckout(null); + if (lastStatus !== 'completed') { + toast.success(t('billing.checkoutActivated', 'Your package is now active.')); + } + await load(); + if (intervalId) { + clearInterval(intervalId); + } + return; + } + + if (result.status === 'failed' || result.status === 'cancelled') { + if (intervalId) { + clearInterval(intervalId); + } + } + } catch { + if (!active) { + return; + } + } + }; + + void poll(); + intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS); + + return () => { + active = false; + if (intervalId) { + clearInterval(intervalId); + } + }; + }, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]); + return ( ) : null} - {pendingCheckout ? ( + {pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? ( + + + + + {t('billing.checkoutFailedTitle', 'Checkout failed')} + + + {t( + 'billing.checkoutFailedBody', + 'The payment did not complete. You can try again or contact support.' + )} + + {checkoutStatusReason ? ( + + {t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)} + + ) : null} + + + {t('billing.checkoutFailedBadge', 'Failed')} + + + + navigate(adminPath('/mobile/billing/shop'))} + fullWidth={false} + /> + persistPendingCheckout(null)} + fullWidth={false} + /> + + + ) : null} + {pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' ? ( diff --git a/resources/js/admin/mobile/__tests__/billingCheckout.test.ts b/resources/js/admin/mobile/__tests__/billingCheckout.test.ts index 68b7a47..0274628 100644 --- a/resources/js/admin/mobile/__tests__/billingCheckout.test.ts +++ b/resources/js/admin/mobile/__tests__/billingCheckout.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from 'vitest'; -import { PENDING_CHECKOUT_TTL_MS, isCheckoutExpired, shouldClearPendingCheckout } from '../lib/billingCheckout'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + CHECKOUT_STORAGE_KEY, + PENDING_CHECKOUT_TTL_MS, + isCheckoutExpired, + loadPendingCheckout, + shouldClearPendingCheckout, + storePendingCheckout, +} from '../lib/billingCheckout'; describe('billingCheckout helpers', () => { + beforeEach(() => { + sessionStorage.clear(); + }); it('detects expired pending checkout', () => { const pending = { packageId: 12, startedAt: 0 }; expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true); @@ -17,4 +27,16 @@ describe('billingCheckout helpers', () => { const pending = { packageId: 12, startedAt: now }; expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true); }); + + it('stores and loads pending checkout from session storage', () => { + const pending = { packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() }; + storePendingCheckout(pending); + expect(loadPendingCheckout(pending.startedAt)).toEqual(pending); + }); + + it('clears pending checkout storage', () => { + storePendingCheckout({ packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() }); + storePendingCheckout(null); + expect(sessionStorage.getItem(CHECKOUT_STORAGE_KEY)).toBeNull(); + }); }); diff --git a/resources/js/admin/mobile/hooks/usePackageCheckout.ts b/resources/js/admin/mobile/hooks/usePackageCheckout.ts index 75e0aa2..0bcbf2f 100644 --- a/resources/js/admin/mobile/hooks/usePackageCheckout.ts +++ b/resources/js/admin/mobile/hooks/usePackageCheckout.ts @@ -5,6 +5,7 @@ import toast from 'react-hot-toast'; import { createTenantPaddleCheckout } from '../../api'; import { adminPath } from '../../constants'; import { getApiErrorMessage } from '../../lib/apiError'; +import { storePendingCheckout } from '../lib/billingCheckout'; export function usePackageCheckout(): { busy: boolean; @@ -32,10 +33,19 @@ export function usePackageCheckout(): { cancelUrl.searchParams.set('checkout', 'cancel'); cancelUrl.searchParams.set('package_id', String(packageId)); - const { checkout_url } = await createTenantPaddleCheckout(packageId, { + const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, { success_url: successUrl.toString(), return_url: cancelUrl.toString(), }); + + if (checkout_session_id) { + storePendingCheckout({ + packageId, + checkoutSessionId: checkout_session_id, + startedAt: Date.now(), + }); + } + window.location.href = checkout_url; } catch (err) { toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed'))); diff --git a/resources/js/admin/mobile/lib/billingCheckout.ts b/resources/js/admin/mobile/lib/billingCheckout.ts index cb54d7c..e8d483d 100644 --- a/resources/js/admin/mobile/lib/billingCheckout.ts +++ b/resources/js/admin/mobile/lib/billingCheckout.ts @@ -1,9 +1,11 @@ export type PendingCheckout = { packageId: number | null; + checkoutSessionId?: string | null; startedAt: number; }; export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30; +export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1'; export function isCheckoutExpired( pending: PendingCheckout, @@ -13,6 +15,55 @@ export function isCheckoutExpired( return now - pending.startedAt > ttl; } +export function loadPendingCheckout( + now = Date.now(), + ttl = PENDING_CHECKOUT_TTL_MS, +): PendingCheckout | null { + if (typeof window === 'undefined') { + return null; + } + try { + const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY); + if (! raw) { + return null; + } + const parsed = JSON.parse(raw) as PendingCheckout; + if (typeof parsed?.startedAt !== 'number') { + return null; + } + const packageId = + typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId) + ? parsed.packageId + : null; + const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null; + if (now - parsed.startedAt > ttl) { + return null; + } + return { + packageId, + checkoutSessionId, + startedAt: parsed.startedAt, + }; + } catch { + return null; + } +} + +export function storePendingCheckout(next: PendingCheckout | null): void { + if (typeof window === 'undefined') { + return; + } + try { + if (! next) { + window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY); + } else { + window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next)); + } + } catch { + // Ignore storage errors. + } +} + export function shouldClearPendingCheckout( pending: PendingCheckout, activePackageId: number | null, diff --git a/routes/api.php b/routes/api.php index e766285..3b03942 100644 --- a/routes/api.php +++ b/routes/api.php @@ -353,6 +353,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete'); Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free'); Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout'); + Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus']) + ->name('packages.checkout-session.status'); }); Route::get('addons/catalog', [EventAddonCatalogController::class, 'index']) diff --git a/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php b/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php new file mode 100644 index 0000000..f8bc9bd --- /dev/null +++ b/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php @@ -0,0 +1,42 @@ +create([ + 'price' => 129, + ]); + + $session = CheckoutSession::create([ + 'id' => (string) Str::uuid(), + 'user_id' => $this->tenantUser->id, + 'tenant_id' => $this->tenant->id, + 'package_id' => $package->id, + 'status' => CheckoutSession::STATUS_FAILED, + 'provider' => CheckoutSession::PROVIDER_PADDLE, + 'status_history' => [ + [ + 'status' => CheckoutSession::STATUS_FAILED, + 'reason' => 'paddle_failed', + 'at' => now()->toIso8601String(), + ], + ], + ]); + + $response = $this->authenticatedRequest( + 'GET', + "/api/v1/tenant/packages/checkout-session/{$session->id}/status" + ); + + $response->assertOk() + ->assertJsonPath('status', CheckoutSession::STATUS_FAILED) + ->assertJsonPath('reason', 'paddle_failed'); + } +} diff --git a/tests/Feature/Tenant/TenantPaddleCheckoutTest.php b/tests/Feature/Tenant/TenantPaddleCheckoutTest.php index 4156b20..76fc651 100644 --- a/tests/Feature/Tenant/TenantPaddleCheckoutTest.php +++ b/tests/Feature/Tenant/TenantPaddleCheckoutTest.php @@ -29,7 +29,10 @@ class TenantPaddleCheckoutTest extends TenantTestCase return $tenant->is($this->tenant) && $payloadPackage->is($package) && array_key_exists('success_url', $payload) - && array_key_exists('return_url', $payload); + && array_key_exists('return_url', $payload) + && array_key_exists('metadata', $payload) + && is_array($payload['metadata']) + && ! empty($payload['metadata']['checkout_session_id']); }) ->andReturn([ 'checkout_url' => 'https://checkout.paddle.test/checkout/123', @@ -42,7 +45,8 @@ class TenantPaddleCheckoutTest extends TenantTestCase ]); $response->assertOk() - ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123'); + ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123') + ->assertJsonStructure(['checkout_session_id']); } public function test_paddle_checkout_requires_paddle_price_id(): void