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
+199
View File
@@ -2257,3 +2257,202 @@ func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T
assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable")
})
}
// TestDevClient_CreatePayment_SavedCardVerificationRequired locks the mock's SCA
// enforcement on SAVED-CARD (ccof:) charges: with
// SimulateSavedCardVerificationRequired=true, a ccof charge without a 3DS/SCA
// verification token is rejected with a structured 400
// CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error), a
// deterministic verify_mock_<prefix>_<amount>_ok token satisfies the gate, a
// grandfathered card charges without a token, and with the toggle off (default)
// no verification is required. Placement check: a ccof charge WITHOUT a customer
// id must still be MISSING_REQUIRED_PARAMETER, never verification-required.
func TestDevClient_CreatePayment_SavedCardVerificationRequired(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_saved"
t.Run("ccof_without_verification_token_is_rejected", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-no-token",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
})
t.Run("verification_token_satisfies_gate", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-with-token",
VerificationToken: "verify_mock_mock_5000_ok",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status)
assert.Equal(t, "ON_FILE", result.EntryMethod)
})
t.Run("customer_id_gate_fires_before_verification_gate", func(t *testing.T) {
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "ccof:other_card", IdempotencyKey: "sca-ccof-no-customer",
})
require.Error(t, err)
assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err), "a ccof charge without a customer must stay MISSING_REQUIRED_PARAMETER, never verification-required")
})
t.Run("grandfathered_card_charges_without_token", func(t *testing.T) {
client.GrandfatherSavedCard(ccof)
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-grandfathered",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status)
})
t.Run("toggle_off_requires_no_verification", func(t *testing.T) {
client.SimulateSavedCardVerificationRequired = false
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-default-off",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status, "with SimulateSavedCardVerificationRequired off (default), a ccof charge needs no verification token")
})
}
// TestDevClient_CreatePayment_VerificationToken_OneTimeUse locks the mock's
// one-time-use verification-token ledger: a verify_mock_* token is consumed on
// its first successful charge, so a second charge with the SAME token (under a
// DIFFERENT idempotency key) is rejected with a definitive 400
// VERIFICATION_TOKEN_INVALID — mirroring real Square consuming a verification
// token on use.
func TestDevClient_CreatePayment_VerificationToken_OneTimeUse(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_saved"
const token = "verify_mock_mock_5000_ok"
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-1",
VerificationToken: token,
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
client.mu.RLock()
consumed := client.verifyTokens[token]
client.mu.RUnlock()
assert.True(t, consumed, "a successfully used verification token must be consumed")
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-2",
VerificationToken: token,
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.True(t, IsDefinitivePaymentError(err), "VERIFICATION_TOKEN_INVALID must classify as a definitive payment error")
}
// TestDevClient_CreatePayment_VerificationToken_Denied locks the shared-state
// challenge resolution: DenyPendingVerification marks the recorded challenge for
// a saved card as denied, so a subsequent tokenized charge is rejected with
// VERIFICATION_TOKEN_INVALID and the token is consumed (a second use of the same
// token is also invalid).
func TestDevClient_CreatePayment_VerificationToken_Denied(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_saved"
const token = "verify_mock_mock_5000_ok"
// The gate rejects the no-token charge and records a pending challenge.
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-gate",
})
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
// The buyer denies the challenge in the banking app.
client.DenyPendingVerification(ccof)
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry",
VerificationToken: token,
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
// The denied token is consumed: a second use of the SAME token is also invalid.
result2, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry-2",
VerificationToken: token,
})
require.Error(t, err)
assert.Nil(t, result2)
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
}
// TestDevClient_CreatePayment_VerificationToken_AmountMismatch locks the
// deterministic token's amount/source binding: a verify_mock_<prefix>_<amount>
// token is bound to the card + amount it was issued for, so charging a DIFFERENT
// amount (or a different card) with it is rejected with VERIFICATION_TOKEN_INVALID.
func TestDevClient_CreatePayment_VerificationToken_AmountMismatch(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_saved"
t.Run("token_for_wrong_amount_is_rejected", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "amount-mismatch",
VerificationToken: "verify_mock_mock_9999_ok", // bound to £99.99, the charge is £50.00
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
})
t.Run("token_for_wrong_card_is_rejected", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "source-mismatch",
VerificationToken: "verify_mock_other_5000_ok", // bound to a different card
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
})
}
// TestDevClient_CreatePayment_SavedCardVerification_Grandfathered locks the
// GrandfatherSavedCard exemption: marking a ccof token exempt lets it charge
// without a verification token even while the saved-card SCA gate is on, while
// a DIFFERENT (non-grandfathered) card stays gated.
func TestDevClient_CreatePayment_SavedCardVerification_Grandfathered(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_saved"
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-before",
})
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "before grandfathering the card must be gated")
client.GrandfatherSavedCard(ccof)
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-after",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status, "a grandfathered card must charge without a verification token")
_, err = client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "grand-other",
})
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "a non-grandfathered card must stay gated")
}