From c4c65d9dd860f6dee3f472b5253f9e3887f53b4d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 15 Aug 2026 00:37:33 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20proactive=20saved-card=20SCA=20?= =?UTF-8?q?=E2=80=94=20challenge=20runs=20BEFORE=20the=20first=20charge,?= =?UTF-8?q?=20never=20a=20naked=20ccof=20attempt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Square's card.tokenize(verificationDetails, squareCardId) determines the SCA requirement UP FRONT and returns a fresh verification_token (or an explicit outcome), so the customer-initiated saved-card flow now runs it before the first charge attempt instead of the reactive 'attempt naked ccof -> 402 verification_required -> challenge + retry' round-trip. - UserPaymentModal/TipPayment/BookingFlow/account gift-card buy: call runSavedCardSCAProactively before charging; 'verified' carries the token on attempt #1; 'sca-unavailable' demotes to the 2FA gate (the only tokenless path); 'challenge-cancelled'/'sca-failed' never charge and keep the pending row retryable with the same cached idempotency key - The reactive re-challenge hook is removed; a defensive verification-required 402 (stale/consumed token) surfaces VERIFICATION_REQUIRED_MESSAGE and lets the user retry - Admin PaymentModal + till saved-card charges remain MERCHANT-INITIATED (customer_initiated=false, SCA-exempt, no liability shift) — unchanged - square.ts comments updated (saved-card charges now carry a token proactively; SAVED_CARD_VERIFICATION_MESSAGE is the defensive path) - Tests: 98 frontend tests (proactive decision coverage); build clean --- .../lib/components/booking/BookingFlow.svelte | 92 ++++++------ .../lib/components/payments/TipPayment.svelte | 134 +++++++----------- .../payments/UserPaymentModal.svelte | 99 ++++++------- frontend/src/lib/square/square.test.ts | 12 ++ frontend/src/lib/square/square.ts | 22 ++- frontend/src/routes/account/+page.svelte | 123 +++++++--------- 6 files changed, 221 insertions(+), 261 deletions(-) diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index c090c7f..a2526f5 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -53,6 +53,7 @@ } from '$lib/square/square'; import { tokenizeSavedCardWithVerification, + type SavedCardVerificationOutcome, type SavedCardVerificationResult } from '$lib/components/payments/SquareCardInput.svelte'; import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; @@ -439,6 +440,25 @@ depositKeyedCard = cardKey; } + // Proactive saved-card (ccof) SCA: run the client-side challenge + // BEFORE the first deposit charge attempt so it carries a fresh + // verification_token — a naked ccof is never sent. Only + // 'sca-unavailable' proceeds token-less (the 2FA gate is the + // fallback); a cancelled/failed challenge does NOT charge — the + // deposit step stays retryable and the user taps Pay again, reusing + // the cached idempotency key above (never regenerated across the + // challenge-then-charge). + if (selectedPaymentMethod && !verificationToken) { + const proactive = await runDepositSCAProactively(amountPence); + if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') { + toast.error( + "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." + ); + return; + } + if (proactive.verificationToken) verificationToken = proactive.verificationToken; + } + const body: Record = { payment_type: 'deposit', amount: amountPence, @@ -535,18 +555,14 @@ } const text = await response.text(); - // Saved-card (ccof) SCA: the backend returns 402 + `verification_required` - // when Square requires buyer verification and no verification_token was - // supplied. Run the client-side 3DS challenge and retry with the fresh - // token — the SAME body and idempotency key (never regenerated here). A - // token-carrying retry is never re-intercepted (the backend skips the 2FA - // gate when a verification_token is present). - if ( - selectedPaymentMethod && - !body.verification_token && - isVerificationRequiredSignal(response.status, text) - ) { - await runDepositSCA({ body, amountPence, depositAmount, confirmOverflowTip }); + // Defensive fallback: a `verification_required` 402 on a saved-card + // deposit should no longer happen — the first attempt carried a proactive + // verification_token or demoted to 2FA (sca-unavailable). If it still + // occurs (e.g. a stale token was consumed/expired between tokenize and + // charge), surface the guidance and let the user retry — never re-run SCA + // silently mid-flow. + if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) { + toast.warning(VERIFICATION_REQUIRED_MESSAGE); return; } // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired @@ -640,53 +656,39 @@ } /** - * Saved-card (ccof) SCA challenge, run when the deposit charge came back 402 - * with the verification-required signal. 'verified' retries the SAME deposit - * body with the fresh verification_token and the SAME cached idempotency key - * (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the - * pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable' - * demotes 2FA from backup to the available gate. + * Saved-card (ccof) SCA challenge, run PROACTIVELY before the first deposit + * charge attempt (never after a 402). Square's tokenize(verificationDetails, + * squareCardId) determines UP FRONT whether buyer verification is required + * and returns a fresh verification_token bound to the exact amount: + * - 'verified' → the caller charges with the returned token (the first + * attempt carries it — a naked ccof is never sent); + * - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup + * to the only available gate and the caller proceeds WITHOUT a token; + * - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge: the + * deposit step stays retryable and the user taps Pay again to re-run the + * challenge. */ - async function runDepositSCA(options: { - body: Record; - amountPence: number; - depositAmount: number; - confirmOverflowTip: boolean; - }) { + async function runDepositSCAProactively( + amountPence: number + ): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> { const squareCardId = paymentMethods.find((c) => c.id === selectedPaymentMethod)?.square_card_id; if (!squareCardId) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; - toast.error(VERIFICATION_REQUIRED_MESSAGE); - return; + return { outcome: 'sca-unavailable' }; } let result: SavedCardVerificationResult; try { - result = await tokenizeSavedCardWithVerification(options.amountPence, squareCardId, { + result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, { givenName: customerInfo.firstName || authStore.currentUser?.firstName, familyName: customerInfo.lastName || authStore.currentUser?.lastName, email: customerInfo.email || authStore.currentUser?.email }); - } catch (err) { + } catch (_err) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; - toast.error(err instanceof Error ? err.message : 'Card verification failed'); - return; + return { outcome: 'sca-unavailable' }; } lastSCAOutcome = result.outcome; - if (result.outcome === 'verified') { - await submitDepositPayment({ - ...options, - body: { ...options.body, verification_token: result.verificationToken } - }); - return; - } - twoFactor.reveal = true; - toast.error( - result.outcome === 'sca-unavailable' - ? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.` - : "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." - ); + return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined }; } let paymentAttempted = $state(false); diff --git a/frontend/src/lib/components/payments/TipPayment.svelte b/frontend/src/lib/components/payments/TipPayment.svelte index 9b31a76..824610e 100644 --- a/frontend/src/lib/components/payments/TipPayment.svelte +++ b/frontend/src/lib/components/payments/TipPayment.svelte @@ -28,6 +28,7 @@ import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import { tokenizeSavedCardWithVerification, + type SavedCardVerificationOutcome, type SavedCardVerificationResult } from '$lib/components/payments/SquareCardInput.svelte'; @@ -298,14 +299,32 @@ // rationale as the booking/account flows). Include the card so a // same-amount tip on a DIFFERENT card gets a fresh key instead of // deduping against the previous card's charge. - const cardKey = selectedCardId || 'new-card'; - if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) { - tipIdempotencyKey = generateUUID(); - tipKeyedAmount = tipAmount; - tipKeyedCard = cardKey; + const cardKey = selectedCardId || 'new-card'; + if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) { + tipIdempotencyKey = generateUUID(); + tipKeyedAmount = tipAmount; + tipKeyedCard = cardKey; + } + const amountInPence = Math.round(tipAmount * 100); + // Proactive saved-card (ccof) SCA: run the client-side challenge + // BEFORE the first tip charge attempt so it carries a fresh + // verification_token — a naked ccof is never sent. Only + // 'sca-unavailable' proceeds token-less (the 2FA gate is the + // fallback); a cancelled/failed challenge does NOT charge — the user + // taps Pay Tip again to re-run it, and the cached idempotency key + // above is never regenerated across the challenge-then-charge. + if (selectedCardId && !verificationToken) { + const proactive = await runTipSCAProactively(amountInPence, selectedCardId); + if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') { + paymentState = 'error'; + toast.error( + "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." + ); + return; } - const amountInPence = Math.round(tipAmount * 100); - const body: Record = { + if (proactive.verificationToken) verificationToken = proactive.verificationToken; + } + const body: Record = { amount: amountInPence, idempotency_key: tipIdempotencyKey, ...(selectedCardId ? { card_id: selectedCardId } : {}), @@ -325,14 +344,13 @@ if (!response.ok) { responseStatus = response.status; const errorText = await response.text(); - // Saved-card (ccof) SCA: the backend returns 402 + - // `verification_required` when Square requires buyer verification - // and no verification_token was supplied. Run the client-side 3DS - // challenge and retry with the fresh token (same idempotency key). - if (selectedCardId && isVerificationRequiredSignal(responseStatus, errorText)) { - await runTipSCA(amountInPence, selectedCardId); - return; - } + // Defensive fallback: a `verification_required` 402 on a + // saved-card tip should no longer happen — the first attempt + // carried a proactive verification_token or demoted to 2FA. If + // it still occurs (e.g. a stale token was consumed between + // tokenize and charge), surface the guidance and let the user + // retry — never re-run SCA silently mid-flow (the catch below + // maps the signal to VERIFICATION_REQUIRED_MESSAGE). const err = new Error(extractErrorMessage(errorText) || 'Payment failed'); (err as { bodyText?: string }).bodyText = errorText; throw err; @@ -391,22 +409,26 @@ } /** - * Saved-card (ccof) SCA challenge, run when the tip charge came back 402 - * with the verification-required signal. 'verified' retries the SAME tip - * with the fresh verification_token and the SAME cached idempotency key - * (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the - * pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable' - * demotes 2FA from backup to the available gate. + * Saved-card (ccof) SCA challenge, run PROACTIVELY before the first tip + * charge attempt (never after a 402). Square's tokenize(verificationDetails, + * squareCardId) determines UP FRONT whether buyer verification is required + * and returns a fresh verification_token bound to the exact amount: + * - 'verified' → the caller charges with the returned token (the first + * attempt carries it — a naked ccof is never sent); + * - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup + * to the only available gate and the caller proceeds WITHOUT a token; + * - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge: the + * pending row stays retryable and the user taps Pay Tip again to re-run + * the challenge. */ - async function runTipSCA(amountInPence: number, cardId: string) { + async function runTipSCAProactively( + amountInPence: number, + cardId: string + ): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> { const squareCardId = savedCards.find((c) => c.id === cardId)?.square_card_id; - paymentState = 'processing'; if (!squareCardId) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; - paymentState = 'error'; - toast.error(VERIFICATION_REQUIRED_MESSAGE); - return; + return { outcome: 'sca-unavailable' }; } let result: SavedCardVerificationResult; try { @@ -415,64 +437,12 @@ familyName: authStore.currentUser?.lastName, email: authStore.currentUser?.email }); - } catch (err) { + } catch (_err) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; - paymentState = 'error'; - toast.error(err instanceof Error ? err.message : 'Card verification failed'); - return; + return { outcome: 'sca-unavailable' }; } lastSCAOutcome = result.outcome; - if (result.outcome === 'verified') { - try { - const retryBody: Record = { - amount: amountInPence, - idempotency_key: tipIdempotencyKey, - card_id: cardId, - verification_token: result.verificationToken, - ...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}) - }; - const retry = await submitPaymentWithRetry(() => - apiFetch(`/api/bookings/${booking.id}/tip`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(retryBody) - }) - ); - if (!retry.ok) { - const retryText = await retry.text(); - const retryMsg = extractErrorMessage(retryText) || 'Payment failed'; - if (isTwoFactorVerificationGateFailure(retry.status, retryMsg)) twoFactor.reveal = true; - paymentState = 'error'; - toast.error(retryMsg); - return; - } - paymentState = 'success'; - tipIdempotencyKey = ''; - tipKeyedAmount = 0; - tipKeyedCard = ''; - tipNonce = ''; - tipVerificationToken = ''; - tipTokenAmount = 0; - tipTokenizedAt = 0; - tipTokenizedForSaveCard = false; - twoFactor.setCode(''); - twoFactor.reveal = false; - toast.success('Thank you for your tip!'); - onSuccess?.(); - } catch (err) { - paymentState = 'error'; - toast.error(err instanceof Error ? err.message : 'Payment failed'); - } - return; - } - twoFactor.reveal = true; - paymentState = 'error'; - toast.error( - result.outcome === 'sca-unavailable' - ? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.` - : "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." - ); + return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined }; } function retryPayment() { diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index 6557a36..8ce8143 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -32,6 +32,7 @@ } from '$lib/square/square'; import { tokenizeSavedCardWithVerification, + type SavedCardVerificationOutcome, type SavedCardVerificationResult } from '$lib/components/payments/SquareCardInput.svelte'; @@ -479,6 +480,25 @@ payKeyedCard = cardKey; } + // Proactive saved-card (ccof) SCA: run the client-side challenge BEFORE + // the first charge attempt so it carries a fresh verification_token — + // a naked ccof is never sent to the backend. Only 'sca-unavailable' + // proceeds token-less (the 2FA gate is the fallback); a cancelled/failed + // challenge does NOT charge — the user taps Pay again to re-run it, and + // the cached idempotency key above is never regenerated across the + // challenge-then-charge. + if (cardId && !verificationToken) { + const proactive = await runSavedCardSCAProactively(amountPence, cardId); + if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') { + status = 'error'; + error = + "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."; + toast.error(error); + return; + } + if (proactive.verificationToken) verificationToken = proactive.verificationToken; + } + await submitBookingPayment( paymentType, amountPence, @@ -552,17 +572,14 @@ status = 'idle'; return; } - // Saved-card (ccof) SCA: the backend returns 402 + - // `verification_required` when Square requires buyer verification - // and no verification_token was supplied. Run the client-side 3DS - // challenge and retry with the fresh token (same idempotency key, - // which stays cached) instead of surfacing a dead-end decline. A - // token-carrying retry is never re-intercepted — the backend skips - // the 2FA gate when a verification_token is present. - if (cardId && !verificationToken && isVerificationRequiredSignal(responseStatus, errData)) { - await runSavedCardSCA(paymentType, amountPence, cardId, confirmOverflowTip); - return; - } + // Defensive fallback: a `verification_required` 402 on a + // saved-card charge should no longer happen — the first attempt + // always carried a proactive verification_token or demoted to + // 2FA (sca-unavailable). If it still occurs (e.g. a stale token + // got consumed/expired between tokenize and charge), surface the + // guidance and let the user retry — never re-run SCA silently + // mid-flow. The catch below maps this signal to + // VERIFICATION_REQUIRED_MESSAGE. const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment'); (err as { bodyText?: string }).bodyText = errData; throw err; @@ -637,31 +654,26 @@ } /** - * Saved-card (ccof) SCA challenge, run when the charge came back 402 with - * the verification-required signal. Shows the 3DS challenge (customer - * approves in their banking app), then: - * - 'verified' → retries the SAME charge with the fresh verification_token - * and the SAME cached idempotency key (never regenerated here); - * - 'challenge-cancelled' / 'sca-failed' → leaves the pending row retryable - * (the idempotency key stays cached) and reveals the 2FA-fallback input; - * - 'sca-unavailable' → demotes 2FA from backup to the available gate and - * reveals the code input so the charge can be retried with a code. + * Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge + * attempt (never after a 402). Square's card.tokenize(verificationDetails, + * squareCardId) determines UP FRONT whether buyer verification is required + * and returns a fresh verification_token bound to the exact amount: + * - 'verified' → the caller charges with the returned token (the first + * attempt carries it — a naked ccof is never sent); + * - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup + * to the only available gate and the caller proceeds WITHOUT a token; + * - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge: + * the pending row stays retryable and the user taps Pay again to re-run + * the challenge. */ - async function runSavedCardSCA( - paymentType: string, + async function runSavedCardSCAProactively( amountPence: number, - cardId: string, - confirmOverflowTip: boolean - ) { + cardId: string + ): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> { const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id; - status = 'processing'; if (!squareCardId) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; - status = 'error'; - error = VERIFICATION_REQUIRED_MESSAGE; - toast.error(error); - return; + return { outcome: 'sca-unavailable' }; } let result: SavedCardVerificationResult; try { @@ -672,31 +684,10 @@ }); } catch (_err) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; - status = 'error'; - error = _err instanceof Error ? _err.message : 'Card verification failed'; - toast.error(error); - return; + return { outcome: 'sca-unavailable' }; } lastSCAOutcome = result.outcome; - if (result.outcome === 'verified') { - await submitBookingPayment( - paymentType, - amountPence, - cardId, - undefined, - result.verificationToken ?? undefined, - confirmOverflowTip - ); - return; - } - twoFactor.reveal = true; - status = 'error'; - error = - result.outcome === 'sca-unavailable' - ? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.` - : "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."; - toast.error(error); + return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined }; } // Confirm the overpayment: resend the SAME rejected request with diff --git a/frontend/src/lib/square/square.test.ts b/frontend/src/lib/square/square.test.ts index 41c92da..815b137 100644 --- a/frontend/src/lib/square/square.test.ts +++ b/frontend/src/lib/square/square.test.ts @@ -297,6 +297,18 @@ describe('shouldFallbackTo2FA', () => { ])('outcome %s → %s', (outcome, expected) => { expect(shouldFallbackTo2FA(outcome)).toBe(expected); }); + + it('demotes to the 2FA gate only on sca-unavailable', () => { + expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true); + }); + + it('keeps SCA primary after a successful verification', () => { + expect(shouldFallbackTo2FA('verified')).toBe(false); + }); + + it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => { + expect(shouldFallbackTo2FA('challenge-cancelled')).toBe(false); + }); }); describe('isTwoFactorVerificationGateFailure', () => { diff --git a/frontend/src/lib/square/square.ts b/frontend/src/lib/square/square.ts index 3e566a1..fcf5a9c 100644 --- a/frontend/src/lib/square/square.ts +++ b/frontend/src/lib/square/square.ts @@ -111,15 +111,23 @@ const PAYMENT_DEFINITIVE_STATUS = 402; /** * True when a definitive (402) charge failure on a SAVED CARD should be * surfaced as a card-issuer verification problem rather than a plain decline. - * The backend sets customer_details.customer_initiated=true on saved-card - * (ccof) charges and classifies issuer-verification rejections — Square's + * + * This is the DEFENSIVE/unexpected path: customer-initiated saved-card (ccof) + * surfaces now run the client-side SCA challenge PROACTIVELY before the first + * charge attempt (tokenizeSavedCardWithVerification) and carry a fresh + * `verification_token` on the charge — or have demoted to the 2FA gate when + * SCA is unavailable — so a naked ccof charge should never reach Square. A + * 402 here therefore means the verification token was consumed/expired + * between tokenize and charge (or a config drift), and the buyer should be + * pointed at the retry affordance rather than silently re-challenged. The + * backend sets customer_details.customer_initiated=true on saved-card (ccof) + * charges and classifies issuer-verification rejections — Square's * CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive 402s, but the * response body is the generic "Payment failed" text with no distinguishing - * code. Saved cards skip the client-side tokenizeWithVerification SCA step, so - * a 402 on the saved-card path means the issuer still requires verification: - * retrying the same saved card can never succeed, and the buyer must pay with - * a freshly tokenized card or re-add theirs. New-card (cnon) charges carry - * their own SCA verification token, so they are never classified this way. + * code. Retrying the same saved card can never succeed, and the buyer must pay + * with a freshly tokenized card, re-add theirs, or re-run the SCA challenge. + * New-card (cnon) charges carry their own SCA verification token, so they are + * never classified this way. */ export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean { return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS; diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index adeab9c..4dd6719 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -23,6 +23,7 @@ } from '$lib/square/square'; import { tokenizeSavedCardWithVerification, + type SavedCardVerificationOutcome, type SavedCardVerificationResult } from '$lib/components/payments/SquareCardInput.svelte'; import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte'; @@ -479,6 +480,24 @@ buyKeyedCard = cardKey; } + // Proactive saved-card (ccof) SCA: run the client-side challenge + // BEFORE the first buy attempt so it carries a fresh + // verification_token — a naked ccof is never sent. Only + // 'sca-unavailable' proceeds token-less (the 2FA gate is the + // fallback); a cancelled/failed challenge does NOT charge — the + // user taps Buy again to re-run it, and the cached idempotency + // key above is never regenerated across the challenge-then-charge. + if (cardId && !verificationToken) { + const proactive = await runBuySCAProactively(); + if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') { + toast.error( + "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." + ); + return; + } + if (proactive.verificationToken) verificationToken = proactive.verificationToken; + } + const res = await submitPaymentWithRetry(() => apiFetch('/api/user/giftcards/buy', { method: 'POST', @@ -515,18 +534,19 @@ buyTwoFactor.reveal = false; await fetchGiftCardBalance(); } else { - // Capture the status BEFORE consuming the body — the saved-card - // SCA check needs it, and text() can only be read once. + // Capture the status BEFORE consuming the body — the + // saved-card SCA classification below needs it, and text() + // can only be read once. const status = res.status; const errText = await res.text(); - // Saved-card (ccof) SCA: the backend returns 402 + - // `verification_required` when Square requires buyer verification - // and no verification_token was supplied. Run the client-side 3DS - // challenge and retry with the fresh token (same idempotency key). - if (buySelectedCard && isVerificationRequiredSignal(status, errText)) { - await runBuySavedCardSCA(); - return; - } + // Defensive fallback: a `verification_required` 402 on a + // saved-card buy should no longer happen — the first attempt + // carried a proactive verification_token or demoted to 2FA. If + // it still occurs (e.g. a stale token was consumed between + // tokenize and charge), surface the guidance and let the user + // retry — never re-run SCA silently mid-flow (the + // classification below maps the signal to + // VERIFICATION_REQUIRED_MESSAGE). // A definitive 402 on the saved-card path means the issuer still // requires verification. A structured verification-required signal // surfaces the SCA-first guidance; the legacy saved-card check is @@ -574,20 +594,27 @@ } /** - * Saved-card (ccof) SCA challenge, run when the gift-card buy came back 402 - * with the verification-required signal. 'verified' retries the SAME buy - * with the fresh verification_token and the SAME cached idempotency key - * (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the - * pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable' - * demotes 2FA from backup to the available gate. + * Saved-card (ccof) SCA challenge, run PROACTIVELY before the first + * gift-card buy attempt (never after a 402). Square's + * tokenize(verificationDetails, squareCardId) determines UP FRONT whether + * buyer verification is required and returns a fresh verification_token + * bound to the exact amount: + * - 'verified' → the caller charges with the returned token (the first + * attempt carries it — a naked ccof is never sent); + * - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup + * to the only available gate and the caller proceeds WITHOUT a token; + * - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge: the + * pending row stays retryable and the user taps Buy again to re-run the + * challenge. */ - async function runBuySavedCardSCA() { + async function runBuySCAProactively(): Promise<{ + outcome: SavedCardVerificationOutcome; + verificationToken?: string; + }> { const squareCardId = savedCardsStore.cards.find((c) => c.id === buySelectedCard)?.square_card_id; if (!squareCardId) { buyLastSCAOutcome = 'sca-unavailable'; - buyTwoFactor.reveal = true; - toast.error(VERIFICATION_REQUIRED_MESSAGE); - return; + return { outcome: 'sca-unavailable' }; } let result: SavedCardVerificationResult; try { @@ -596,62 +623,12 @@ familyName: userData?.lastName, email: userData?.email }); - } catch (err) { + } catch (_err) { buyLastSCAOutcome = 'sca-unavailable'; - buyTwoFactor.reveal = true; - toast.error(err instanceof Error ? err.message : 'Card verification failed'); - return; + return { outcome: 'sca-unavailable' }; } buyLastSCAOutcome = result.outcome; - if (result.outcome === 'verified') { - try { - const retry = await submitPaymentWithRetry(() => - apiFetch('/api/user/giftcards/buy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - amount: buyAmount * 100, - recipient_type: buyRecipientType, - recipient_email: buyRecipientEmail, - ...(buySelectedCard ? { card_id: buySelectedCard } : {}), - verification_token: result.verificationToken, - ...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}), - idempotency_key: buyIdempotencyKey - }) - }) - ); - if (retry.ok) { - const data = await retry.json(); - toast.success('Gift card purchased successfully!'); - purchaseResultCode = data.code; - buyDailyTotal += buyAmount; - buyIdempotencyKey = ''; - buyKeyedAmount = 0; - buyKeyedCard = ''; - buyNonce = ''; - buyVerificationToken = ''; - buyTokenAmount = 0; - buyTokenizedAt = 0; - buyTokenizedForSaveCard = false; - buyTwoFactor.setCode(''); - buyTwoFactor.reveal = false; - await fetchGiftCardBalance(); - return; - } - const retryText = await retry.text(); - toast.error(extractErrorMessage(retryText) || 'Failed to purchase gift card'); - } catch (err) { - console.error('gift card SCA retry error:', err); - toast.error('Network error'); - } - return; - } - buyTwoFactor.reveal = true; - toast.error( - result.outcome === 'sca-unavailable' - ? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.` - : "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." - ); + return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined }; } function formatAndPreserveCursor(