feat: poll checkout status and show failures
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user