fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round
Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:
MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance
SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue
DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)
Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
submitPaymentWithRetry,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode,
|
||||
PAYMENT_METHOD_SAVED_CARD,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
@@ -35,13 +36,13 @@
|
||||
qty: number;
|
||||
};
|
||||
|
||||
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | 'saved_card';
|
||||
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | (typeof PAYMENT_METHOD_SAVED_CARD);
|
||||
|
||||
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
|
||||
{ key: 'cash', label: 'Cash' },
|
||||
{ key: 'card_machine', label: 'Card Machine' },
|
||||
{ key: 'online_square', label: 'Online Card' },
|
||||
{ key: 'saved_card', label: 'Saved Card' }
|
||||
{ key: PAYMENT_METHOD_SAVED_CARD, label: 'Saved Card' }
|
||||
];
|
||||
|
||||
let cart = $state<CartItem[]>([]);
|
||||
@@ -73,7 +74,7 @@
|
||||
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
|
||||
// saved_card charges also key on the selected card id so switching to a
|
||||
// different card (or back to another method) yields fresh keys.
|
||||
const composite = `${item.id}:${qtyIndex}:${item.price}:${paymentMethod}:${paymentMethod === 'saved_card' ? (selectedSavedCardId ?? '') : ''}`;
|
||||
const composite = `${item.id}:${qtyIndex}:${item.price}:${paymentMethod}:${paymentMethod === PAYMENT_METHOD_SAVED_CARD ? (selectedSavedCardId ?? '') : ''}`;
|
||||
let key = idempotencyKeys.get(composite);
|
||||
if (!key) {
|
||||
key = generateUUID();
|
||||
@@ -152,7 +153,7 @@
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () =>
|
||||
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
|
||||
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
|
||||
mint: () =>
|
||||
selectedCustomer?.id
|
||||
@@ -165,14 +166,14 @@
|
||||
const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0);
|
||||
|
||||
const availablePaymentMethods = $derived(
|
||||
PAYMENT_METHODS.filter((m) => m.key !== 'saved_card' || showSavedCardOption)
|
||||
PAYMENT_METHODS.filter((m) => m.key !== PAYMENT_METHOD_SAVED_CARD || showSavedCardOption)
|
||||
);
|
||||
|
||||
// If the saved-card option disappears (customer cleared, no valid cards, or
|
||||
// a card expires mid-session) fall back to cash instead of leaving the till
|
||||
// on an unrenderable method.
|
||||
$effect(() => {
|
||||
if (paymentMethod === 'saved_card' && !showSavedCardOption) {
|
||||
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && !showSavedCardOption) {
|
||||
paymentMethod = 'cash';
|
||||
selectedSavedCardId = null;
|
||||
}
|
||||
@@ -353,7 +354,7 @@
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (paymentMethod === 'saved_card' && (!selectedCustomer || !selectedSavedCardId)) {
|
||||
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && (!selectedCustomer || !selectedSavedCardId)) {
|
||||
toast.error('Select a customer and a saved card before charging');
|
||||
return;
|
||||
}
|
||||
@@ -374,7 +375,7 @@
|
||||
payment_method: paymentMethod,
|
||||
idempotency_key: idempotencyKeyFor(item, i)
|
||||
};
|
||||
if (paymentMethod === 'saved_card') {
|
||||
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
|
||||
body.user_id = selectedCustomer?.id;
|
||||
body.user_saved_card_id = selectedSavedCardId;
|
||||
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
||||
@@ -398,12 +399,19 @@
|
||||
}
|
||||
|
||||
for (const body of saleBodies) {
|
||||
const res = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const res = await submitPaymentWithRetry(
|
||||
() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
// Finding 4: a saved-card till line gated on 2FA consumed its
|
||||
// code at the backend gate — a 503 auto-retry would re-send a
|
||||
// dead code and self-defeat.
|
||||
{
|
||||
verificationCodeGated: paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
responseStatus = res.status;
|
||||
@@ -415,7 +423,7 @@
|
||||
// its SAME cached idempotency key. runTillSavedCardSCA throws to
|
||||
// stop the whole sale on any non-verified outcome.
|
||||
if (
|
||||
paymentMethod === 'saved_card' &&
|
||||
paymentMethod === PAYMENT_METHOD_SAVED_CARD &&
|
||||
isVerificationRequiredSignal(responseStatus, errText)
|
||||
) {
|
||||
await runTillSavedCardSCA(body);
|
||||
@@ -830,7 +838,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if paymentMethod === 'saved_card'}
|
||||
{#if paymentMethod === PAYMENT_METHOD_SAVED_CARD}
|
||||
<div class="mt-3 space-y-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
|
||||
{#if loadingSavedCards}
|
||||
<div class="flex justify-center py-6">
|
||||
@@ -955,7 +963,7 @@
|
||||
processing ||
|
||||
twoFactor.missing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
|
||||
@@ -527,18 +527,23 @@
|
||||
if (!confirmedBooking) return;
|
||||
const bookingId = confirmedBooking.id;
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
|
||||
})
|
||||
})
|
||||
const response = await submitPaymentWithRetry(
|
||||
() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
|
||||
})
|
||||
}),
|
||||
// Finding 4: a 2FA-gated charge consumed its code at the backend gate
|
||||
// — a 503 auto-retry would re-send a dead code and self-defeat. The
|
||||
// code is the gate only when no SCA verification token is present.
|
||||
{ verificationCodeGated: twoFactor.showInput && !('verification_token' in body) }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
campaignDiscountPence,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
PAYMENT_METHOD_SAVED_CARD,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
|
||||
@@ -63,7 +64,7 @@
|
||||
amount: number;
|
||||
};
|
||||
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | 'savedcard' | null;
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | (typeof PAYMENT_METHOD_SAVED_CARD) | null;
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let selectedMethod = $state<PaymentMethod>(null);
|
||||
@@ -103,7 +104,7 @@
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () =>
|
||||
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
|
||||
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === PAYMENT_METHOD_SAVED_CARD,
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
|
||||
mint: () => {
|
||||
const customerID = booking.user_id ?? booking.user?.id;
|
||||
@@ -839,19 +840,23 @@
|
||||
try {
|
||||
await applyLoyaltyRedemption();
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: chargeAmount,
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
})
|
||||
})
|
||||
const response = await submitPaymentWithRetry(
|
||||
() =>
|
||||
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: chargeAmount,
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
})
|
||||
}),
|
||||
// Finding 4: a 2FA-gated charge consumed its code at the backend
|
||||
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
|
||||
{ verificationCodeGated: twoFactor.showInput }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -908,6 +913,18 @@
|
||||
// code, brute-force lockout) is recoverable — keep the code populated
|
||||
// and reveal the input so the charge can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||
// A DEFINITIVE 402 (declined card / stale token) means the charge did
|
||||
// NOT land — Square's idempotency key would otherwise reject a retry
|
||||
// that re-runs SCA and mints a fresh token. Regenerate the key on 402
|
||||
// so the next Pay click gets a fresh key + fresh pending row. Keep it
|
||||
// on 503/network (ambiguous) and on the verification-required signal
|
||||
// (that path runs the SCA challenge and returns before this catch).
|
||||
if (responseStatus === 402) {
|
||||
savedCardIdempotencyKey = '';
|
||||
savedCardKeyedBookingId = '';
|
||||
savedCardKeyedCardId = '';
|
||||
savedCardKeyedAmount = 0;
|
||||
}
|
||||
error = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
@@ -923,7 +940,7 @@
|
||||
if (selectedMethod === 'giftcard') {
|
||||
giftCardId = '';
|
||||
}
|
||||
if (selectedMethod === 'savedcard') {
|
||||
if (selectedMethod === PAYMENT_METHOD_SAVED_CARD) {
|
||||
fetchSavedCards();
|
||||
}
|
||||
});
|
||||
@@ -1271,14 +1288,14 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
|
||||
'savedcard'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => {
|
||||
selectedMethod = 'savedcard';
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
|
||||
PAYMENT_METHOD_SAVED_CARD
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => {
|
||||
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8"
|
||||
@@ -1330,7 +1347,7 @@
|
||||
disabled={nothingToCharge}
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => {
|
||||
selectedMethod = 'savedcard';
|
||||
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -345,12 +345,16 @@
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||
};
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const response = await submitPaymentWithRetry(
|
||||
() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
// Finding 4: a 2FA-gated charge consumed its code at the
|
||||
// backend gate — a 503 auto-retry would re-send a dead code.
|
||||
{ verificationCodeGated: twoFactor.showInput && !verificationToken }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -539,21 +539,25 @@
|
||||
): Promise<void> {
|
||||
let responseStatus = 0;
|
||||
try {
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountPence,
|
||||
payment_type: paymentType,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
})
|
||||
const response = await submitPaymentWithRetry(
|
||||
() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountPence,
|
||||
payment_type: paymentType,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
}),
|
||||
// Finding 4: a 2FA-gated charge consumed its code at the backend
|
||||
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
|
||||
{ verificationCodeGated: twoFactor.showInput && !verificationToken }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -743,6 +747,12 @@
|
||||
}
|
||||
|
||||
function handlePayDeposit() {
|
||||
// Deposit is sent RAW (no client-side campaign subtraction) — the
|
||||
// backend applies the eligible campaign credit to the deposit charge
|
||||
// server-side. A retry (same key via makePayment's amount cache)
|
||||
// resends this SAME raw amount, and the backend's pending-reuse check
|
||||
// compares against the discounted charge it stored — sending a
|
||||
// pre-discounted deposit here would double-discount (HIGH-2).
|
||||
const depositPence = booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100);
|
||||
|
||||
@@ -501,6 +501,33 @@ describe('submitPaymentWithRetry', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234);
|
||||
});
|
||||
|
||||
it('does not auto-retry a 503 on a verificationCodeGated submission (finding 4)', async () => {
|
||||
// A fresh saved-card charge already CONSUMED the 2FA code at the backend
|
||||
// gate and the backend re-issued a fresh one — a same-body retry would
|
||||
// re-send a dead code and come back 400. The 503 must be surfaced to the
|
||||
// caller (which re-runs the gate) instead of retrying.
|
||||
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
|
||||
const response = await submitPaymentWithRetry(submit, {
|
||||
verificationCodeGated: true,
|
||||
retryDelayMs: 1
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still auto-retries a 503 when verificationCodeGated is false', async () => {
|
||||
const submit = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(503))
|
||||
.mockResolvedValueOnce(jsonResponse(200));
|
||||
const response = await submitPaymentWithRetry(submit, {
|
||||
verificationCodeGated: false,
|
||||
retryDelayMs: 1
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestNewTwoFactorCode', () => {
|
||||
|
||||
@@ -23,6 +23,14 @@ export interface SquareConfig {
|
||||
// forces a fresh tokenization before the charge attempt.
|
||||
export const NONCE_STALENESS_MS = 240_000;
|
||||
|
||||
// Canonical payment_method value for charging a customer's saved card. The
|
||||
// backend keys on this exact string ('saved_card' — see till.go's payment-method
|
||||
// switch and the terminal saved-card branch), so every surface that charges a
|
||||
// saved card must send it verbatim. Single source of truth so the gate
|
||||
// expressions and request bodies can't drift between 'savedcard' and
|
||||
// 'saved_card' across the booking, tip, account, till and admin surfaces.
|
||||
export const PAYMENT_METHOD_SAVED_CARD = 'saved_card';
|
||||
|
||||
/**
|
||||
* True when a user's role is allowed to save cards for reuse. Only VERIFIED
|
||||
* accounts (verified_email, admin) may save cards — guests, unverified accounts
|
||||
@@ -410,16 +418,33 @@ const PAYMENT_MAX_RETRIES = 3;
|
||||
* other status return immediately so the caller surfaces the error; the caller
|
||||
* must NOT retry those on its own. Defaults to up to 3 retries with a 1.5s
|
||||
* backoff (Square/backend transient failures typically clear within seconds).
|
||||
*
|
||||
* `verificationCodeGated` disables the auto-retry for charges whose body
|
||||
* carries the homegrown 2FA `verification_code` (finding 4): a fresh saved-card
|
||||
* charge already CONSUMED that code at the backend gate (single-use), and the
|
||||
* backend re-issued a fresh one on the ambiguous failure — so a same-body retry
|
||||
* would re-send a dead code and come back 400 "Invalid verification code". The
|
||||
* 503 is surfaced to the caller instead, which re-runs the gate / asks the
|
||||
* customer for the re-issued code.
|
||||
*/
|
||||
export async function submitPaymentWithRetry(
|
||||
submit: () => Promise<Response>,
|
||||
options: { maxRetries?: number; retryDelayMs?: number } = {}
|
||||
options: {
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
verificationCodeGated?: boolean;
|
||||
} = {}
|
||||
): Promise<Response> {
|
||||
const maxRetries = options.maxRetries ?? PAYMENT_MAX_RETRIES;
|
||||
const retryDelayMs = options.retryDelayMs ?? PAYMENT_RETRY_DELAY_MS;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await submit();
|
||||
if (response.ok || !isAmbiguousPaymentFailure(response.status) || attempt >= maxRetries) {
|
||||
if (
|
||||
response.ok ||
|
||||
!isAmbiguousPaymentFailure(response.status) ||
|
||||
options.verificationCodeGated ||
|
||||
attempt >= maxRetries
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
|
||||
@@ -510,21 +510,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
const res = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/user/giftcards/buy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: buyAmount * 100, // pence
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
})
|
||||
const res = await submitPaymentWithRetry(
|
||||
() =>
|
||||
apiFetch('/api/user/giftcards/buy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: buyAmount * 100, // pence
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
}),
|
||||
// Finding 4: a 2FA-gated charge consumed its code at the backend
|
||||
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
|
||||
{ verificationCodeGated: buyTwoFactor.showInput && !verificationToken }
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user