From 965da86b6494ee14604d4e3feac777e60087588d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 3 Aug 2026 19:26:50 +0100 Subject: [PATCH] Re-tokenize fresh after a definitive card charge failure in all nonce flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cnon: nonce and its SCA verification token are consumed by a definitive charge failure (e.g. declined card) and can never succeed again, but TipPayment, UserBookingModal, UserPaymentModal and the account-page Buy-a-Gift-Card cached them and resubmitted the dead nonce on every retry — a non-retryable failure loop. The nonce/verification-token/amount/timestamp cache is now cleared in each error branch so retries re-tokenize fresh, while the idempotency key is kept for network-timeout dedup. --- .../lib/components/account/UserBookingModal.svelte | 7 +++++++ .../src/lib/components/payments/TipPayment.svelte | 8 ++++++++ .../components/payments/UserPaymentModal.svelte | 9 +++++++++ frontend/src/routes/account/+page.svelte | 14 ++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 9c36ebd..2e02f61 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -304,6 +304,13 @@ fetchBookingDetails(); } catch (err) { toast.error(err instanceof Error ? err.message : 'Tip payment failed'); + // A definitive charge failure consumes the nonce + SCA verification + // token — clear the cached pair so retries re-tokenize fresh. The + // idempotency key stays for network-timeout dedup. + tipNonce = ''; + tipVerificationToken = ''; + tipTokenAmount = 0; + tipTokenizedAt = 0; } finally { tipProcessing = false; } diff --git a/frontend/src/lib/components/payments/TipPayment.svelte b/frontend/src/lib/components/payments/TipPayment.svelte index 6dfef94..b11d82d 100644 --- a/frontend/src/lib/components/payments/TipPayment.svelte +++ b/frontend/src/lib/components/payments/TipPayment.svelte @@ -275,6 +275,14 @@ paymentState = 'error'; const errorMessage = err instanceof Error ? err.message : 'Payment failed'; toast.error(errorMessage); + // A definitive charge failure (e.g. declined card) consumes the nonce + // and SCA verification token — they can never succeed again. Clear the + // cached pair so the next retry re-tokenizes fresh. The idempotency + // key is kept: it's still correct for network-timeout dedup. + tipNonce = ''; + tipVerificationToken = ''; + tipTokenAmount = 0; + tipTokenizedAt = 0; } } diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index de16545..382c072 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -464,6 +464,15 @@ const msg = _err instanceof Error ? _err.message : 'Payment declined'; error = msg; toast.error(`${msg}. Please try again or use another card.`); + // A definitive charge failure consumes the nonce + SCA verification + // token — clear the cached pair so retries re-tokenize fresh. The + // idempotency key stays for network-timeout dedup. CardSelection stays + // mounted on error, so this also stops a user who corrects their card + // digits from resubmitting the old nonce for the new card. + newCardNonce = ''; + newCardVerificationToken = ''; + newCardTokenAmount = 0; + newCardTokenizedAt = 0; releaseLock(); } } diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index f8f782e..b310ca9 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -353,10 +353,24 @@ } else { const errText = await res.text(); toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card'); + // A definitive charge failure (e.g. declined card) consumes the + // nonce + SCA verification token — clear the cached pair so the + // next retry re-tokenizes fresh. The idempotency key stays for + // network-timeout dedup. + buyNonce = ''; + buyVerificationToken = ''; + buyTokenAmount = 0; + buyTokenizedAt = 0; } } catch (err) { console.error('buyGiftCard error:', err); toast.error('Network error'); + // Same for thrown errors (network / malformed response): a retry must + // re-tokenize fresh rather than resubmit a consumed nonce. + buyNonce = ''; + buyVerificationToken = ''; + buyTokenAmount = 0; + buyTokenizedAt = 0; } finally { buyingGiftCard = false; }