feat: proactive saved-card SCA — challenge runs BEFORE the first charge, never a naked ccof attempt

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
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 5dae0bba08
commit c4c65d9dd8
6 changed files with 221 additions and 261 deletions
@@ -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<string, unknown> = {
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<string, unknown>;
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);
@@ -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<string, unknown> = {
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
const body: Record<string, unknown> = {
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<string, unknown> = {
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() {
@@ -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