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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user