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
@@ -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