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
+12
View File
@@ -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', () => {