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:
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
campaignDiscountPence,
|
||||
canSaveCardsForRole,
|
||||
@@ -12,8 +13,10 @@ import {
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry
|
||||
} from './square';
|
||||
import type * as SquareModule from './square';
|
||||
@@ -219,6 +222,81 @@ describe('payment failure classification', () => {
|
||||
expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0);
|
||||
expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification');
|
||||
});
|
||||
|
||||
it('VERIFICATION_REQUIRED_MESSAGE is non-empty and mentions verification', () => {
|
||||
expect(VERIFICATION_REQUIRED_MESSAGE.length).toBeGreaterThan(0);
|
||||
expect(VERIFICATION_REQUIRED_MESSAGE.toLowerCase()).toContain('verification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVerificationRequiredSignal', () => {
|
||||
it('matches a 402 JSON body carrying the verification_required code', () => {
|
||||
const body = JSON.stringify({
|
||||
error: 'Saved card charge requires buyer verification',
|
||||
code: 'verification_required'
|
||||
});
|
||||
expect(isVerificationRequiredSignal(402, body)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
|
||||
expect(
|
||||
isVerificationRequiredSignal(402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the plain "verification required" phrasing', () => {
|
||||
expect(isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a 402 body with a different code', () => {
|
||||
expect(isVerificationRequiredSignal(402, JSON.stringify({ error: 'Declined', code: 'card_declined' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a 402 body with only error text and no code', () => {
|
||||
expect(isVerificationRequiredSignal(402, 'Payment failed')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a non-JSON body that does not mention verification', () => {
|
||||
expect(isVerificationRequiredSignal(402, 'Payment declined')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for an empty body', () => {
|
||||
expect(isVerificationRequiredSignal(402, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for any non-402 status even with the code present', () => {
|
||||
expect(isVerificationRequiredSignal(503, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isVerificationRequiredSignal(400, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isVerificationRequiredSignal(200, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is false when the code is not an exact match (guards against prefix drift)', () => {
|
||||
expect(
|
||||
isVerificationRequiredSignal(402, JSON.stringify({ error: 'x', code: 'verification_required_extra' }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldFallbackTo2FA', () => {
|
||||
it.each([
|
||||
['sca-unavailable', true],
|
||||
['verified', false],
|
||||
['challenge-cancelled', false],
|
||||
['sca-failed', false],
|
||||
['', false]
|
||||
])('outcome %s → %s', (outcome, expected) => {
|
||||
expect(shouldFallbackTo2FA(outcome)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTwoFactorVerificationGateFailure', () => {
|
||||
|
||||
Reference in New Issue
Block a user