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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user