feat: poll checkout status and show failures

This commit is contained in:
Codex Agent
2026-01-12 13:31:30 +01:00
parent f452c486d4
commit 749cb04cec
11 changed files with 345 additions and 48 deletions

View File

@@ -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<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false);
const [pendingCheckout, setPendingCheckout] = React.useState<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 isExpired = Date.now() - parsed.startedAt > PENDING_CHECKOUT_TTL_MS;
return isExpired ? null : { packageId, startedAt: parsed.startedAt };
} catch {
return null;
}
});
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(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<typeof setInterval> | 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 (
<MobileShell
activeTab="profile"
@@ -216,7 +252,45 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : null}
{pendingCheckout ? (
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={danger}>
{t('billing.checkoutFailedTitle', 'Checkout failed')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutFailedBody',
'The payment did not complete. You can try again or contact support.'
)}
</Text>
{checkoutStatusReason ? (
<Text fontSize="$xs" color={muted}>
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
</Text>
) : null}
</YStack>
<PillBadge tone="danger">
{t('billing.checkoutFailedBadge', 'Failed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>

View File

@@ -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();
});
});

View File

@@ -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')));

View File

@@ -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,