feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup

SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent ecef5da516
commit 5dae0bba08
35 changed files with 3683 additions and 249 deletions
+53
View File
@@ -125,6 +125,59 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
}
/**
* Machine-readable code the backend returns on a 402 when a saved-card (ccof)
* charge requires Strong Customer Authentication and no `verification_token`
* was supplied. The saved-card charge path returns a JSON body of the form
* `{"error": "...", "code": "verification_required"}` — the shared error-text
* extractor only surfaces the human-readable message, so this checks the raw
* body for the code field exactly like isOverflowTipConfirmationRequired does.
*/
const VERIFICATION_REQUIRED_CODE = 'verification_required';
/**
* True when a charge response is Square's SCA "verification required" signal:
* HTTP 402 with a JSON body `{"error": "...", "code": "verification_required"}`
* (the shape the backend now returns on a saved-card charge Square refuses for
* want of a verification token), OR the raw body text matching the known
* verification-required strings (`CARD_DECLINED_VERIFICATION_REQUIRED` and the
* plain-text "verification required" phrasing, for dev/mock parity where the
* backend mock may not emit the structured code). The caller runs the
* client-side 3DS challenge (tokenizeSavedCardWithVerification) and retries
* with the fresh token instead of surfacing a dead-end decline. Returns false
* for any non-402 status, non-matching text, or non-JSON body.
*/
export function isVerificationRequiredSignal(status: number, bodyText: string): boolean {
if (status !== PAYMENT_DEFINITIVE_STATUS) return false;
const trimmed = bodyText.trim();
if (!trimmed) return false;
if (/verification required|CARD_DECLINED_VERIFICATION_REQUIRED/i.test(trimmed)) return true;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
return parsed?.code === VERIFICATION_REQUIRED_CODE;
} catch {
// Not JSON — cannot be the structured verification-required body
return false;
}
}
/**
* True only when an SCA attempt reported that buyer verification is genuinely
* unavailable (no 3DS challenge could be run), so the surface falls back to the
* homegrown 2FA gate. Every other outcome — verified, a cancelled challenge, or
* a hard SCA failure — keeps SCA as the primary path (a cancelled/failed
* challenge is retryable, and SCA should be attempted again).
*/
export function shouldFallbackTo2FA(scaOutcome: string): boolean {
return scaOutcome === 'sca-unavailable';
}
/** User-facing guidance for a saved-card charge whose issuer requires Strong
* Customer Authentication: the buyer must approve the payment in their banking
* app (the client-side tokenizeSavedCardWithVerification challenge does this). */
export const VERIFICATION_REQUIRED_MESSAGE =
'Your card issuer requires verification. Approve this payment in your banking app.';
/** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless — the
* buyer must pay with a new card or re-add their card. */