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
+15 -7
View File
@@ -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;