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
+40
View File
@@ -6,8 +6,48 @@ import (
"net/http"
"crussell/internal/square"
"crussell/mw"
)
// verificationRequiredCodes are Square CreatePayment error codes that mean the
// buyer must complete Strong Customer Authentication (3DS/SCA) before the
// charge can succeed: Square is demanding a fresh verification_token from the
// cardholder's buyer-verification flow. These are NOT plain declines — the
// frontend must surface the SCA challenge (the banking app / banking-app
// approval) and retry the charge with the resulting verification token. This
// is the SINGLE authoritative list of SCA-challenge codes; keep it in lock-step
// with the dev mock's simulated SCA toggle (square_dev.go).
var verificationRequiredCodes = map[string]bool{
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
"VERIFICATION_TOKEN_EXPIRED": true,
"VERIFICATION_TOKEN_INVALID": true,
"MISSING_VERIFICATION_TOKEN": true,
}
// isVerificationRequiredError reports whether a SquareClient.CreatePayment
// error is an SCA/verification-required rejection (the charge must be retried
// through the buyer-verification flow with a fresh verification_token) rather
// than a plain decline. Matches square.ErrorCode against the four SCA codes;
// CVV_VERIFICATION_REQUIRED / ADDRESS_VERIFICATION_REQUIRED are deliberately
// excluded — those mean re-entering card data, not a 3DS challenge.
func isVerificationRequiredError(err error) bool {
return verificationRequiredCodes[square.ErrorCode(err)]
}
// writeVerificationRequiredResponse responds 402 with the structured
// verification_required body the frontend keys on to trigger the SCA challenge
// flow (mirrors the overflow_tip_confirmation_required / campaign_fully_redeemed
// structured-error pattern — mw.RespondJSON, code + human message). The message
// tells the buyer to approve the payment in their banking app. Used both by the
// charge-failure paths (Square returned an SCA-required code) and by the 2FA
// gate when the SCA-only posture has no fallback for a token-less charge.
func writeVerificationRequiredResponse(w http.ResponseWriter) {
mw.RespondJSON(w, http.StatusPaymentRequired, map[string]string{
"error": "Your card issuer requires verification. Approve this payment in your banking app.",
"code": "verification_required",
})
}
// chargeFailureStatus classifies a SquareClient.CreatePayment error into the
// HTTP status a payment handler should return:
//
+61 -1
View File
@@ -504,7 +504,67 @@ func TestCreatePaymentMethod_AmbiguousCardSaveFailure_500(t *testing.T) {
// H4 — 2FA gate on CreatePaymentMethod and BuyGiftCard(SaveCard)
// =============================================================================
// TestTwoFactorEnforced_CreatePaymentMethod_Blocked_403 verifies the H4 gate on
// TestIsVerificationRequiredError pins the SCA-challenge classification: the
// four buyer-verification codes must classify as verification-required (so the
// handlers surface the structured 402 body the frontend keys on to trigger the
// 3DS challenge), while a plain decline and CVV re-entry requests must not.
func TestIsVerificationRequiredError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"CARD_DECLINED_VERIFICATION_REQUIRED → true", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), true},
{"VERIFICATION_TOKEN_EXPIRED → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true},
{"VERIFICATION_TOKEN_INVALID → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_INVALID", "PAYMENT_METHOD_ERROR"), true},
{"MISSING_VERIFICATION_TOKEN → true", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_VERIFICATION_TOKEN", "PAYMENT_METHOD_ERROR"), true},
{"CARD_DECLINED plain decline → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), false},
{"CVV_VERIFICATION_REQUIRED (re-entry, not a 3DS challenge) → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CVV_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), false},
{"INSUFFICIENT_FUNDS → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "INSUFFICIENT_FUNDS", "PAYMENT_METHOD_ERROR"), false},
{"plain transport error → false", errors.New("network error: connection reset by peer"), false},
{"nil → false", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isVerificationRequiredError(tt.err); got != tt.want {
t.Errorf("isVerificationRequiredError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// TestCreateTillSale_SCARequired_ReturnsStructured402 verifies the SCA-required
// surfacing end to end on a till charge: a Square
// CARD_DECLINED_VERIFICATION_REQUIRED failure (the buyer must complete 3DS)
// returns 402 with the structured verification_required body — the frontend
// triggers the challenge — instead of the plain-text "Payment failed".
func TestCreateTillSale_SCARequired_ReturnsStructured402(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:till-sca-required",
IdempotencyKey: "till-sca-required-key",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code")
require.Contains(t, body["error"], "card issuer requires verification")
}
// the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT
// having completed 2FA setup, persisting a card is blocked with 403 and no card
// row is created — the save-card endpoint is not an un-gated side door.
+27 -8
View File
@@ -1477,9 +1477,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// (reusePendingID != "") verifies WITHOUT consuming: the code was re-issued
// for exactly this retry and the completed-charge transaction below
// (ConsumePendingCode) burns it on terminal success, so a retry that fails
// again keeps its code for one more attempt.
// again keeps its code for one more attempt. A charge carrying a Square
// verification_token (SCA performed) skips the gate; a token-less charge
// falls back to 2FA and twoFAFallbackUsed is set for the charge-success
// audit.
giftCardVerificationToken := ""
if req.VerificationToken != nil {
giftCardVerificationToken = *req.VerificationToken
}
twoFAFallbackUsed := false
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode, reusePendingID == "") {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode, giftCardVerificationToken, reusePendingID == "")
if !gateOK {
return
}
}
@@ -1645,11 +1655,6 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
}
var verificationToken string
if req.VerificationToken != nil {
verificationToken = *req.VerificationToken
}
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Currency: "GBP",
@@ -1661,7 +1666,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card Purchase",
BuyerEmail: buyerEmail,
VerificationToken: verificationToken,
VerificationToken: giftCardVerificationToken,
}
// C3: a card-on-file (ccof) charge — paying with an existing saved card or
// saving a new card during this purchase — is customer-initiated: Square
@@ -1704,6 +1709,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
reissueTwoFACodeAfterFailedCharge(ctx, userID)
}
// Payment record intentionally left as 'pending' for manual retry.
// SCA-required failures must surface the structured verification_required
// body so the frontend triggers the 3DS challenge, not a plain decline.
if isVerificationRequiredError(err) {
writeVerificationRequiredResponse(w)
return
}
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
}
@@ -1867,6 +1878,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
// The 2FA BACKUP authorized this token-less saved-card gift-card purchase
// (SCA was unavailable) — record the strict fallback audit row AFTER the
// money transaction commits (a failed audit write must never roll back a
// completed charge). The actor is the customer's own userID.
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(ctx, userID, userID, paymentResult.CardLast4, buyPaymentID, "gift-card purchase authorized via 2FA fallback (SCA unavailable)")
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "success",
+186 -38
View File
@@ -67,6 +67,30 @@ func insertAdminAuditCharge(ctx context.Context, adminID, targetUserID, action s
}
}
// insertTwoFAFallbackAudit records that a saved-card charge was authorized by
// the homegrown 2FA BACKUP because SCA was unavailable (the charge carried no
// Square verification_token). It is the strict-audit half of the SCA-primary /
// 2FA-backup decision model: every 2FA-fallback authorization of a saved-card
// charge must land an admin_audit_log row (action_type '2fa_fallback_charge',
// details {sca_performed:false, fallback_reason:"verification_unavailable"}) so
// the operator can distinguish SCA-authorized charges from fallback-authorized
// ones. Mirrors insertAdminAuditCharge's best-effort, own-transaction,
// non-fatal failure handling (a failed audit write can never abort a completed
// charge). For customer-initiated online charges the actor (adminID) is the
// customer's own userID; for admin surfaces it is the admin from request
// context — the caller passes accordingly. cardLast4 and referenceID are filled
// by the caller at charge success (paymentResult.CardLast4, booking/till/payment
// id), where they are actually known.
func insertTwoFAFallbackAudit(ctx context.Context, adminID, userID, cardLast4, referenceID, notes string) {
insertAdminAuditCharge(ctx, adminID, userID, "2fa_fallback_charge", map[string]any{
"sca_performed": false,
"fallback_reason": "verification_unavailable",
"card_last4": cardLast4,
"reference_id": referenceID,
"notes": notes,
})
}
type CreateTerminalPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
PaymentType string `json:"payment_type" validate:"required"`
@@ -82,6 +106,12 @@ type CreateTerminalPaymentRequest struct {
// enforced environment charges a saved card only when this matches the
// customer's pending code; the operator relays it from the [2FA] log/email.
VerificationCode string `json:"verification_code,omitempty"`
// verification_token: Square 3DS/SCA verification token returned by the
// frontend's buyer-verification flow (tokenizeWithVerification). When a
// saved-card charge carries one, SCA has been performed by the issuer and
// the homegrown 2FA gate is SKIPPED (SCA is primary). Forwarded verbatim
// to Square on the CreatePaymentReq.
VerificationToken *string `json:"verification_token,omitempty"`
// idempotency_key: optional client-generated per-attempt UUID for saved-card
// charges. The frontend generates one per distinct charge and reuses it
// across retries of the SAME charge, so two DISTINCT identical charges on
@@ -407,6 +437,12 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return
}
if err := ValidateVerificationToken(req.VerificationToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// A3 (mirror of CreateBookingPayment at handlers.go:1596): tips have a
// dedicated endpoint (POST /api/bookings/{id}/tip, CreateTipPayment) which
// enforces the M4 "tips only after the service starts" gate, and the
@@ -801,6 +837,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return
}
// SCA verification token (if any) — extracted once, used both by the
// 2FA gate below (a present token skips the gate: SCA-primary) and
// forwarded to Square on the CreatePaymentReq.
terminalVerificationToken := ""
if req.VerificationToken != nil {
terminalVerificationToken = *req.VerificationToken
}
// Resolve the saved-card Square source for the booking's user (the
// card's owner, not the admin) — shared new-card-vs-saved-card
// resolution, see resolveChargeSource for the R6 rationale.
@@ -957,8 +1001,30 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// its code for one more attempt (the completed-charge transaction
// consumes it on terminal success).
reusePendingRecord := paymentID != ""
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, !reusePendingRecord) {
return
// SCA-primary / 2FA-backup gate (C5): charging the customer's SAVED card
// requires authorization when the feature is enforced. Gate on the
// card's owner — the booking's user, not the admin. A charge carrying a
// Square verification_token (SCA performed) passes without 2FA; a
// token-less charge falls back to the customer's 2FA code (single-use
// via consume) and the caller records a strict fallback audit row on
// the charge's success. New-card/terminal paths are not gated. Runs
// AFTER the idempotency dedup/reuse switch above: a same-key retry of
// an already-completed payment short-circuits there and returns the
// existing result WITHOUT demanding a fresh code — no new money moves,
// so no new authorization is needed. consume=!reusePendingRecord
// (finding 4): a FRESH charge verifies WITH consumption — the code is
// single-use at the gate, closing the TOCTOU where a verified-but-
// unconsumed code could authorize a second charge — and a pending-reuse
// retry verifies WITHOUT consuming, so a retry that fails again keeps
// its code for one more attempt (the completed-charge transaction
// consumes it on terminal success).
twoFAFallbackUsed := false
if bookingUserID.Valid {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, terminalVerificationToken, !reusePendingRecord)
if !gateOK {
return
}
}
// B13: the pre-charge discount SET for the post-charge apply-time
@@ -1036,18 +1102,23 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
}
paymentReq := square.CreatePaymentReq{
Amount: amount,
Currency: "GBP",
SourceID: sourceID,
CustomerID: savedCardCustomerID,
IdempotencyKey: scKey,
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: buyerEmail,
// C3: a saved-card (ccof) charge is customer-initiated — Square
// requires customer_details on stored-credential payments, and
// omitting it can fail or silently misclassify the charge.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
Amount: amount,
Currency: "GBP",
SourceID: sourceID,
CustomerID: savedCardCustomerID,
IdempotencyKey: scKey,
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: buyerEmail,
VerificationToken: terminalVerificationToken,
// MIT (merchant-initiated): the admin charging the customer's SAVED
// card (admin "Charge Saved Card") is a merchant-initiated stored-
// credential charge — NOT the cardholder. customer_initiated=false
// classifies it MIT for Square: no SCA is demanded and no liability
// shift applies, which is the correct treatment for an operator-
// initiated charge (any issuer challenge is handled via
// verification_token when the frontend performs one).
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: false},
}
// M1: store the verbatim request JSON so the sweep can replay the charge
// with an IDENTICAL body under the same key — Square compares the whole
@@ -1077,6 +1148,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
if bookingUserID.Valid {
reissueTwoFACodeAfterFailedCharge(r.Context(), bookingUserID.String)
}
// SCA-required failures (Square demands buyer verification) must
// surface the structured verification_required body so the frontend
// triggers the 3DS challenge instead of treating the payment as a
// plain decline.
if isVerificationRequiredError(err) {
writeVerificationRequiredResponse(w)
return
}
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
}
@@ -1205,6 +1284,11 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
"card_last4": paymentResult.CardLast4,
"square_payment_id": paymentResult.SquarePayID,
})
// The 2FA BACKUP authorized this token-less saved-card charge
// (SCA was unavailable) — record the strict fallback audit row.
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(r.Context(), adminID, bookingUserID.String, paymentResult.CardLast4, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)")
}
}
// F6: a fully-paid saved-card charge completes the booking exactly like
@@ -2074,8 +2158,21 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
// new charge may move at Square. The gate also runs before
// resolveChargeSource below, so an un-2FA'd request never persists a card.
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
return
// SCA-primary: a request carrying a Square verification_token (SCA
// performed) skips the gate; a token-less card-save/charge falls back to
// the customer's 2FA code, and twoFAFallbackUsed records that the 2FA
// BACKUP authorized the operation (the caller audits the charge on success).
twoFAFallbackUsed := false
bookingVerificationToken := ""
if req.VerificationToken != nil {
bookingVerificationToken = *req.VerificationToken
}
if req.SaveCard {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, true)
if !gateOK {
return
}
}
// After the idempotency check (which handles same-key retries), verify
@@ -2258,9 +2355,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// re-issues a fresh code (reissueTwoFACodeAfterFailedCharge). A pending-reuse
// retry verifies WITHOUT consuming: the code was re-issued for exactly this
// retry and the completed-charge transaction consumes it on terminal success,
// so a retry that fails again keeps its code for one more attempt.
// so a retry that fails again keeps its code for one more attempt. A charge
// carrying a Square verification_token (SCA performed) skips the gate; a
// token-less charge falls back to 2FA and twoFAFallbackUsed is set for the
// charge-success audit.
if req.CardID != nil && *req.CardID != "" {
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, !reusePendingRecord) {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, !reusePendingRecord)
if !gateOK {
return
}
}
@@ -2348,11 +2450,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// Step 2: DB transaction committed — safe to call Square now. If Square
// fails, the record stays 'pending' and a same-key retry reuses it.
var verificationToken string
if req.VerificationToken != nil {
verificationToken = *req.VerificationToken
}
paymentReq := square.CreatePaymentReq{
Amount: chargeAmount,
Currency: "GBP",
@@ -2362,7 +2459,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: bookingBuyerEmail,
VerificationToken: verificationToken,
VerificationToken: bookingVerificationToken,
// C3: every online charge here is cardholder-initiated — a saved-card
// (ccof) source MUST carry customer_details for Square's stored-
// credential rules, and a new-card (cnon) nonce is entered by the
@@ -2399,6 +2496,12 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
if req.CardID != nil && *req.CardID != "" {
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
}
// SCA-required failures must surface the structured verification_required
// body so the frontend triggers the 3DS challenge, not a plain decline.
if isVerificationRequiredError(err) {
writeVerificationRequiredResponse(w)
return
}
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
}
@@ -2630,6 +2733,15 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
// The 2FA BACKUP authorized this token-less saved-card charge (SCA was
// unavailable) — record the strict fallback audit row AFTER the money
// transaction commits (a failed audit write must never roll back a
// completed charge). The actor is the customer's own userID
// (customer-initiated online charge).
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)")
}
// B13: a campaign was exhausted between the preview and the apply-time
// re-check. The charge already succeeded at Square and the payment record
// is committed, so the customer's promised discount must not silently
@@ -3136,9 +3248,16 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
// endpoint must not be the un-gated side door. consume=true: saving a card
// is a terminal operation with no downstream charge to attach consumption
// to (MEDIUM-2).
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
return
// to (MEDIUM-2). This request type carries no verification_token field, so
// the 2FA fallback is the only authorization path; a fallback success is
// recorded by the strict audit below.
twoFAFallbackUsed := false
{
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, "", true)
if !gateOK {
return
}
}
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
@@ -3153,6 +3272,12 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
return
}
// The 2FA BACKUP authorized persisting this card (no SCA was performed on
// the add-card endpoint); the actor is the customer's own userID.
if twoFAFallbackUsed && card != nil {
insertTwoFAFallbackAudit(r.Context(), userID, userID, card.Last4, "", "card persisted via 2FA fallback (SCA unavailable)")
}
if err := json.NewEncoder(w).Encode(card); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
@@ -4239,9 +4364,20 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
// consume=true: saving a card is a terminal operation (the card row is
// created right here), so the verified code is single-use immediately —
// unlike the saved-card CHARGE gate below, which defers consumption to the
// charge's terminal success (MEDIUM-2).
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
return
// charge's terminal success (MEDIUM-2). A request carrying a Square
// verification_token (SCA performed) skips the gate; a token-less save falls
// back to 2FA and twoFAFallbackUsed is set for the charge-success audit.
twoFAFallbackUsed := false
tipVerificationToken := ""
if req.VerificationToken != nil {
tipVerificationToken = *req.VerificationToken
}
if req.SaveCard {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, tipVerificationToken, true)
if !gateOK {
return
}
}
if err := ValidateAmount(req.Amount); err != nil {
@@ -4478,9 +4614,13 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
// unconsumed code could authorize a second charge — and a pending-reuse
// retry verifies WITHOUT consuming, so a retry that fails again keeps its
// code for one more attempt (the completed-charge transaction consumes it
// on terminal success).
// on terminal success). A charge carrying a Square verification_token (SCA
// performed) skips the gate; a token-less charge falls back to 2FA and
// twoFAFallbackUsed is set for the charge-success audit.
if req.CardID != nil && *req.CardID != "" {
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, !reusePendingRecord) {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, tipVerificationToken, !reusePendingRecord)
if !gateOK {
return
}
}
@@ -4552,11 +4692,6 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
}
var verificationToken string
if req.VerificationToken != nil {
verificationToken = *req.VerificationToken
}
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Currency: "GBP",
@@ -4566,7 +4701,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
ReferenceID: bookingID,
Note: "tip",
BuyerEmail: buyerEmail,
VerificationToken: verificationToken,
VerificationToken: tipVerificationToken,
// C3: the tip charge is cardholder-initiated whether it uses a saved
// card (ccof — customer_details required) or a freshly entered card
// (cnon — buyer present), so the flag is true either way.
@@ -4600,6 +4735,12 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
if req.CardID != nil && *req.CardID != "" {
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
}
// SCA-required failures must surface the structured verification_required
// body so the frontend triggers the 3DS challenge, not a plain decline.
if isVerificationRequiredError(err) {
writeVerificationRequiredResponse(w)
return
}
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
}
@@ -4682,6 +4823,13 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
return
}
// The 2FA BACKUP authorized this token-less saved-card tip charge (SCA was
// unavailable) — record the strict fallback audit row. The actor is the
// customer's own userID (customer-initiated online charge).
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)")
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: paymentID,
BookingID: bookingID,
+42 -14
View File
@@ -894,6 +894,19 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// the post-charge 2FA consumption + audit after the Square call need it.
var cardUserID sql.NullString
// twoFAFallbackUsed records whether the 2FA BACKUP authorized a token-less
// saved-card charge (SCA unavailable). Hoisted: set in the saved_card case
// below, consumed by the strict fallback audit after the Square call.
twoFAFallbackUsed := false
// SCA verification token (if any) — hoisted so the 2FA gate in the
// saved_card case (a present token skips the gate: SCA-primary) and the
// CreatePaymentReq after the switch share one extraction.
tillVerificationToken := ""
if req.VerificationToken != nil {
tillVerificationToken = *req.VerificationToken
}
switch req.PaymentMethod {
case "cash":
saleStatus = "completed"
@@ -963,9 +976,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// WITHOUT consuming: the code was re-issued for exactly this retry and
// the post-charge success path below (ConsumePendingCode) burns it on
// terminal success, so a retry that fails again keeps its code for one
// more attempt.
if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, existingPendingID == "") {
return
// more attempt. A charge carrying a Square verification_token (SCA
// performed) skips the gate; a token-less charge falls back to 2FA and
// twoFAFallbackUsed is set for the charge-success audit.
if cardUserID.Valid {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, tillVerificationToken, existingPendingID == "")
if !gateOK {
return
}
}
tillSquareSourceID = savedCardSqCardID
@@ -1143,11 +1162,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
var paymentResult *square.PaymentResult
var squareErr error
var verificationToken string
if req.VerificationToken != nil {
verificationToken = *req.VerificationToken
}
if req.PaymentMethod == "saved_card" {
paymentReq := square.CreatePaymentReq{
Amount: penceAmount,
@@ -1157,14 +1171,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
// C3: a saved-card (ccof) charge is customer-initiated — Square
// requires customer_details on stored-credential payments, and
// omitting it can fail/quietly strip the charge.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
// MIT (merchant-initiated): the admin till charging a
// customer's saved card is merchant-initiated, not
// cardholder-initiated. customer_initiated=false classifies it
// MIT for Square: no SCA is demanded and no liability shift
// applies — correct for an operator-initiated gift-card top-up.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: false},
// Forward the 3DS/SCA verification token when the request
// carries one (the online_square branch already did; the
// saved-card branch did not).
VerificationToken: verificationToken,
VerificationToken: tillVerificationToken,
}
// M1: store the verbatim request JSON so the sweep can replay the
// charge with an IDENTICAL body under the same key — Square
@@ -1212,7 +1228,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
VerificationToken: verificationToken,
VerificationToken: tillVerificationToken,
}
// M1: store the verbatim request JSON so the sweep can replay the
// charge with an IDENTICAL body under the same key — Square
@@ -1268,6 +1284,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// 503 so the pending sale stays resumable on a same-key retry
// (M3). The clawback decision above stays keyed on
// isDefinitiveChargeFailure, unchanged.
// SCA-required failures must surface the structured
// verification_required body so the frontend triggers the 3DS
// challenge, not a plain decline.
if isVerificationRequiredError(squareErr) {
writeVerificationRequiredResponse(w)
return
}
http.Error(w, "Payment failed", chargeFailureStatus(squareErr))
return
}
@@ -1317,6 +1340,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
"card_last4": paymentResult.CardLast4,
"square_payment_id": paymentResult.SquarePayID,
})
// The 2FA BACKUP authorized this token-less saved-card till charge
// (SCA was unavailable); the actor is the admin from context.
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(ctx, adminID, cardUserID.String, paymentResult.CardLast4, tillSaleID, "admin till saved-card charge authorized via 2FA fallback (SCA unavailable)")
}
}
saleStatus = "completed"
+86 -17
View File
@@ -89,12 +89,61 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consum
return twofa.VerifyForUser(ctx, userID, code, consume)
}
// requireTwoFactorForCardAccess gates the saved-card online payment paths
// (PSD2 SCA stand-in until real SCA infra lands). It returns true when the
// request may proceed:
// twoFactorFallbackEnabled reports whether the homegrown 2FA may act as a
// BACKUP authorization for a saved-card charge when SCA is unavailable (the
// charge carries no Square verification_token). The parse is case-insensitive
// and alias-tolerant (false/0/off/no) — a value like "False" or "OFF" never
// silently leaves the fallback ON. Any other value — including empty and
// unknown — keeps the fallback enabled (the shipped default). It is the
// TWO_FACTOR_FALLBACK policy switch read at startup by main.go and exposed via
// PaymentService.TwoFactorFallbackEnabled.
func twoFactorFallbackEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("TWO_FACTOR_FALLBACK"))) {
case "false", "0", "off", "no":
return false
default:
return true
}
}
// TwoFactorFallbackEnabled is the exported form of twoFactorFallbackEnabled, so
// main.go can log the SCA-primary/2FA-backup posture at startup without
// re-implementing the env logic.
func (s *PaymentService) TwoFactorFallbackEnabled() bool {
return twoFactorFallbackEnabled()
}
// requireTwoFactorForCardAccess gates the saved-card online payment paths under
// the SCA-primary / 2FA-backup decision model. It returns (allowed, fallbackUsed):
// allowed is true when the request may proceed; fallbackUsed is true when the
// authorization was granted by the homegrown 2FA BACKUP (SCA was unavailable and
// the customer's 2FA code verified) — the caller must then write a strict
// insertTwoFAFallbackAudit row for the charge.
//
// - 2FA is not enforced (dev/mock), OR
// - the user has completed 2FA setup (two_factor_enabled) AND the request
// The decision model, in order:
//
// - 2FA is not enforced (dev/mock) → allowed, no fallback.
//
// - The request carries a Square verification_token (SCA performed — the
// issuer has already authenticated the buyer): SKIP the 2FA gate entirely.
// SCA is PRIMARY; the issuer did the job, so the homegrown gate is never
// consulted (fallbackUsed=false). A charge that carries a token passes even
// for a user who has not enabled 2FA.
//
// - Otherwise the gate is the FALLBACK authorization for a ccof charge with
// no verification token. It only runs when the fallback is permitted:
//
// (a) TWO_FACTOR_FALLBACK is enabled (see twoFactorFallbackEnabled) — when
// the deployment opts out, a token-less charge is denied 402
// verification_required: the frontend shows the SCA challenge, and if the
// bank cannot do SCA the payment cannot proceed (security-first); and
//
// (b) a 2FA code delivery channel exists (twoFADeliveryAvailable, build-
// dependent like the user package's) — a code the customer can never
// receive would silently lock the gate, so it is denied 503
// ("2FA requires an email or SMS delivery channel").
//
// - The user has completed 2FA setup (two_factor_enabled) AND the request
// carries a verification_code matching the user's stored pending code.
//
// B10: the setup flag alone must NOT unlock saved-card charges — an enforced
@@ -119,49 +168,69 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consum
// expired → 400, DB failure → 500.
//
// On any denial an error JSON is written (parseable by the frontend via
// extractErrorMessage) and false is returned — the caller must abort the charge.
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string, consume bool) bool {
// extractErrorMessage) and allowed=false is returned — the caller must abort
// the charge.
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
if !twoFactorEnforced() {
return true
return true, false
}
if service == nil {
service = &PaymentService{}
}
// SCA-primary: a Square verification_token means the issuer already
// completed Strong Customer Authentication — the 2FA gate is skipped and no
// fallback audit applies.
if verificationToken != "" {
return true, false
}
// 2FA is now the BACKUP authorization for a token-less ccof charge. Fail
// closed when the deployment disabled the fallback (TWO_FACTOR_FALLBACK) or
// has no delivery channel for its codes (twoFADeliveryAvailable).
if !twoFactorFallbackEnabled() {
writeVerificationRequiredResponse(w)
return false, false
}
if !twoFADeliveryAvailable() {
mw.RespondError(w, http.StatusServiceUnavailable, "2FA requires an email or SMS delivery channel; contact the salon")
return false, false
}
enabled, err := service.UserTwoFactorEnabled(r.Context(), userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
return false
return false, false
}
log.Printf("failed to check two-factor status for user %s: %v", userID, err)
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor status")
return false
return false, false
}
if !enabled {
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
return false
return false, false
}
// B10: an enforced charge of a saved card needs a live one-time code, not
// just the enabled setup flag.
if verificationCode == "" {
mw.RespondError(w, http.StatusForbidden, "A two-factor verification code is required to use this saved card. Ask the customer for their current code.")
return false
return false, false
}
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
case err == nil:
return true
// The 2FA BACKUP authorized this token-less saved-card charge. The
// caller writes the strict fallback audit row on the charge's success.
return true, true
case errors.Is(err, twofa.ErrIncorrect):
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
return false
return false, false
case errors.Is(err, twofa.ErrLockedOut):
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
return false
return false, false
case errors.Is(err, twofa.ErrMissingOrExpired):
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
return false
return false, false
default:
log.Printf("failed to check two-factor verification code for user %s: %v", userID, err)
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor verification code")
return false
return false, false
}
}
@@ -0,0 +1,11 @@
//go:build dev || test
package payments
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
// this build. Dev/test builds always have one — the [2FA] log line is the
// documented loose-fake delivery channel — so the 2FA BACKUP authorization
// (the saved-card gate when SCA is unavailable) is always usable here. Mirrors
// handlers/user/twofa_dev.go; production builds decide in
// twofa_delivery_prod.go.
func twoFADeliveryAvailable() bool { return true }
@@ -0,0 +1,17 @@
//go:build !dev && !test
package payments
import "os"
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
// this build. Production has no wired email/SMS transport (P6), so the ONLY
// channel is the operator's explicit opt-in to insecure log delivery
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Without a channel, codes can never
// reach the customer, so the 2FA BACKUP authorization (the saved-card gate
// when SCA is unavailable) cannot operate and a token-less saved-card charge is
// denied 503 (see requireTwoFactorForCardAccess). Mirrors
// handlers/user/twofa_prod.go; dev/test builds always deliver (twofa_delivery_dev.go).
func twoFADeliveryAvailable() bool {
return os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true"
}
+122 -10
View File
@@ -99,6 +99,109 @@ func TestTwoFactorEnforced(t *testing.T) {
}
}
// TestRequireTwoFactorForCardAccess_VerificationTokenSkips pins the SCA-primary
// leg of the decision model: when the request carries a Square verification_token
// (the issuer already completed SCA), the 2FA gate is skipped entirely — even a
// user with NO 2FA setup passes, no code is demanded, and no fallback is used.
func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "vrf_sca_token_123", false)
require.True(t, allowed, "SCA performed — the 2FA gate must be skipped")
require.False(t, fallbackUsed, "SCA is primary — the 2FA fallback was not used")
require.Equal(t, http.StatusOK, w.Code, "no denial response may be written when the token skips the gate")
}
// TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured pins the
// SCA-only posture (TWO_FACTOR_FALLBACK=false): a token-less saved-card charge
// has no 2FA fallback, so the gate denies 402 with the structured
// verification_required body the frontend keys on to trigger the SCA challenge.
func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing.T) {
helperEnvEnforce2FA(t)
t.Setenv("TWO_FACTOR_FALLBACK", "false")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
require.False(t, allowed, "SCA-only posture: no 2FA fallback for a token-less charge")
require.False(t, fallbackUsed)
require.Equal(t, http.StatusPaymentRequired, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits pins the strict audit
// trail: a 2FA-fallback saved-card charge (no verification token, code verified)
// must write an admin_audit_log row with action_type '2fa_fallback_charge' for
// the customer actor — the operator can distinguish SCA-authorized charges from
// fallback-authorized ones.
func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "445566")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_audit", "VISA", "4242")
require.NoError(t, err)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-fallback-audit",
VerificationCode: "445566",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var auditCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_log
WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $1
`, userID).Scan(&auditCount))
require.Equal(t, 1, auditCount, "a 2FA-fallback saved-card charge must write a strict audit row for the customer actor")
}
// TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA-
// authorized charge (verification token present) writes NO 2fa_fallback_charge
// audit row: SCA is primary and the homegrown fallback was not used.
func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_sca_audit", "VISA", "4242")
require.NoError(t, err)
vrf := "vrf_sca_booking_audit"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-sca-audit",
VerificationToken: &vrf,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var auditCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'
`).Scan(&auditCount))
require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row")
}
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
// allows every request without touching the DB (no user rows are consulted).
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
@@ -108,8 +211,10 @@ func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "mock")
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", false))
require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced")
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", "", false)
require.True(t, allowed, "no response must be written when not enforced")
require.False(t, fallbackUsed, "not enforced — the 2FA fallback did not authorize anything")
require.Equal(t, http.StatusOK, w.Code)
}
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
@@ -121,7 +226,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", false)
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
require.False(t, ok)
require.Equal(t, http.StatusForbidden, w.Code)
var body map[string]string
@@ -137,7 +242,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
// B10: the enabled setup flag alone must NOT unlock the gate.
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", false)
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "", false)
require.False(t, ok)
require.Equal(t, http.StatusForbidden, w.Code)
})
@@ -148,7 +253,9 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
seedTwoFAPendingCode(t, tx, userID, "424242")
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false))
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
require.True(t, allowed)
require.True(t, fallbackUsed, "a code-verified token-less charge uses the 2FA fallback")
require.Equal(t, http.StatusOK, w.Code)
})
@@ -158,7 +265,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
seedTwoFAPendingCode(t, tx, userID, "424242")
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", false)
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", "", false)
require.False(t, ok)
require.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]string
@@ -169,7 +276,8 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", false))
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", "", false)
require.False(t, ok)
require.Equal(t, http.StatusForbidden, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
@@ -193,7 +301,9 @@ func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "first gate pass must succeed")
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
require.True(t, allowed, "first gate pass must succeed")
require.True(t, fallbackUsed, "the code verification is the 2FA fallback")
require.Equal(t, http.StatusOK, w.Code)
// The code must still be present — the gate verified WITHOUT consuming.
@@ -203,7 +313,8 @@ func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
w = httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a not-yet-consumed code must pass the gate again on retry")
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
require.True(t, allowed, "a not-yet-consumed code must pass the gate again on retry")
require.Equal(t, http.StatusOK, w.Code)
// Consumption happens at the charge's terminal SUCCESS state.
@@ -213,7 +324,8 @@ func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
// A further attempt with the consumed code is denied as expired.
w = httptest.NewRecorder()
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a consumed code must not pass the gate")
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
require.False(t, allowed, "a consumed code must not pass the gate")
require.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+303 -13
View File
@@ -23,7 +23,8 @@ package square
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
// SimulateVerificationRequired) that let dev/tests drive Square failure modes
// SimulateVerificationRequired, SimulateSavedCardVerificationRequired,
// ChallengeResult) that let dev/tests drive Square failure modes
// that are otherwise only reachable against the real API. FailAfterCommit
// simulates the exact "charged but response lost → same-key retry" prod
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
@@ -58,6 +59,7 @@ import (
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -156,10 +158,61 @@ type MockClient struct {
// the charge succeeds. Off by default — existing dev/test flows charge
// plain "cnon:test-card"-style tokens without verification tokens.
SimulateVerificationRequired bool
// SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on
// saved-card (ccof:) charges — the SCA-primary saved-card posture the
// platform is moving toward (buyer verification on card-on-file charges,
// not just new-card nonces). When true, CreatePayment with a ccof: source
// and NO VerificationToken is rejected with the same structured 400
// CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and a pending
// buyer-verification challenge is recorded for the card. A subsequent
// charge WITH a verification token resolves that challenge (see
// resolveVerificationToken): an explicitly approved challenge, or a
// stateless verify_mock_<prefix>_<amount>_ok token, lets the charge
// succeed; a denied challenge / _deny token is rejected with 400
// VERIFICATION_TOKEN_INVALID. Cards marked via GrandfatherSavedCard bypass
// the gate entirely. Off by default — existing dev/test flows charge
// saved cards without verification tokens, so flipping it on in a
// prod-like test setup intentionally surfaces every ccof charge that would
// be rejected by Square's SCA.
SimulateSavedCardVerificationRequired bool
// ChallengeResult configures the mock's SCA challenge outcome when a
// verification token is supplied on a gated charge. "" or "approve"
// (default) accepts a valid token / approved challenge; "deny" simulates
// the buyer denying EVERY banking-app challenge (any token →
// VERIFICATION_TOKEN_INVALID); "auto" auto-resolves a gate rejection's
// pending challenge as approved, so the next tokenized retry succeeds
// without an explicit ApprovePendingVerification call.
ChallengeResult string
// grandfatheredCards marks ccof: tokens that are exempt from the saved-card
// verification gate (GrandfatherSavedCard). An exempt card charges without
// a verification token even while SimulateSavedCardVerificationRequired is
// on — mirroring cards Square has already verified / stored with a standing
// SCA exemption.
grandfatheredCards map[string]bool
// pendingChallenges records the per-card buyer-verification challenge state
// that the saved-card gate creates when it rejects a ccof charge without a
// token. An opaque (real-Square-shaped) verification token is resolved
// against this ledger; the deterministic verify_mock_... tokens carry their
// own outcome and only consult the ledger to honour an explicit denial.
pendingChallenges map[string]*pendingChallenge
// verifyTokens is a one-time-use ledger of verify_mock_* verification
// tokens consumed by a successful charge or a definitive
// VERIFICATION_TOKEN_INVALID rejection — mirroring real Square, which
// consumes a verification token on use so a replayed token is rejected
// (VERIFICATION_TOKEN_INVALID). Mirrors the usedSources ledger pattern.
verifyTokens map[string]bool
}
type devProdClient struct{}
// pendingChallenge is the recorded buyer-verification challenge state for one
// saved-card (ccof:) token. outcome "" = pending (recorded by the gate's
// rejection, not yet resolved); "approved" / "denied" = resolved via
// ApprovePendingVerification / DenyPendingVerification.
type pendingChallenge struct {
outcome string
}
func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
return createPaymentHTTP(ctx, req)
}
@@ -251,6 +304,9 @@ func NewDevClient() SquareClient {
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
usedSources: make(map[string]bool),
grandfatheredCards: make(map[string]bool),
pendingChallenges: make(map[string]*pendingChallenge),
verifyTokens: make(map[string]bool),
}
}
}
@@ -287,6 +343,160 @@ func keyReuseError(key string) error {
}
}
// verificationTokenInvalidError is Square's VERIFICATION_TOKEN_INVALID
// rejection (definitivePaymentCodes): the supplied 3DS/SCA verification token
// is invalid, expired, already used, denied by the buyer, or not bound to this
// card + amount. A definitive payment error — retrying the same request can
// never succeed.
func verificationTokenInvalidError(token, sourceID string) error {
return &squareAPIError{
Code: "VERIFICATION_TOKEN_INVALID",
Category: "PAYMENT_METHOD_ERROR",
Detail: "The verification token is invalid, expired, already used, or not valid for this charge",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: verification token %s is not valid for card-on-file charge on %s", tokenPrefix(token), tokenPrefix(sourceID)),
}
}
// parsedVerifyToken is the deterministic verify_mock_<prefix>_<amount>[_ok|_deny]
// verification-token encoding shared by the dev frontend and the mock, so the
// two sides can exercise approve/deny outcomes WITHOUT shared state.
type parsedVerifyToken struct {
prefix string
amount int64
denied bool
}
// parseVerifyToken parses a deterministic mock verification token of the form
// verify_mock_<prefix>_<amount>[_ok|_deny] (outcome suffix defaults to ok).
// Returns ok=false for anything that is not parseable (an opaque token — the
// same shape as real Square's verification tokens — which resolves against the
// recorded pending challenge instead).
func parseVerifyToken(token string) (parsedVerifyToken, bool) {
const marker = "verify_mock_"
if !strings.HasPrefix(token, marker) {
return parsedVerifyToken{}, false
}
rest := strings.TrimPrefix(token, marker)
if rest == "" {
return parsedVerifyToken{}, false
}
parts := strings.Split(rest, "_")
denied := false
if n := len(parts); n > 1 {
switch parts[n-1] {
case "ok", "deny":
denied = parts[n-1] == "deny"
parts = parts[:n-1]
}
}
if len(parts) == 0 {
return parsedVerifyToken{}, false
}
amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return parsedVerifyToken{}, false
}
return parsedVerifyToken{prefix: strings.Join(parts[:len(parts)-1], "_"), amount: amount, denied: denied}, true
}
// verificationTokenPrefixForSource returns the card prefix the mock binds an
// SCA verification token to for a source_id — the SAME derivation the dev
// frontend uses when minting verify_mock_... tokens, so the binding check
// cannot drift between the two sides. New-card (cnon:) nonces bind to the
// first four digits of the PAN the user typed (MockCardForm MOCK_TOKENS);
// saved-card (ccof:) tokens bind to the first four chars after the ccof:
// prefix (MockCardForm.verifySavedCard).
func verificationTokenPrefixForSource(sourceID string) string {
switch sourceID {
case "cnon:test-card":
return "4242"
case "cnon:visa":
return "4111"
case "cnon:mastercard":
return "5555"
case "cnon:amex":
return "3782"
}
if strings.HasPrefix(sourceID, "ccof:") {
rest := strings.TrimPrefix(sourceID, "ccof:")
if len(rest) > 4 {
return rest[:4]
}
return rest
}
return ""
}
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
// a charge. savedCard=true resolves against the saved-card challenge ledger;
// savedCard=false (new-card nonce) treats any present token as satisfying the
// gate. Returns nil when the token is accepted (consuming it in the one-time-use
// ledger), or a definitive 400 VERIFICATION_TOKEN_INVALID. Must be called under
// m.mu (CreatePayment holds the write lock).
func (m *MockClient) resolveVerificationToken(token, sourceID string, amount int64, savedCard bool) error {
isVerifyToken := strings.HasPrefix(token, "verify_mock_")
// ChallengeResult="deny" simulates the buyer denying every banking-app
// challenge: ANY token is definitively rejected.
if m.ChallengeResult == "deny" {
if isVerifyToken {
m.verifyTokens[token] = true
}
return verificationTokenInvalidError(token, sourceID)
}
// One-time-use ledger: a verify_mock_* token is consumed on its first use;
// a second use of the same token is definitively invalid.
if isVerifyToken && m.verifyTokens[token] {
return verificationTokenInvalidError(token, sourceID)
}
// Deterministic tokens carry their own binding and outcome.
if parsed, ok := parseVerifyToken(token); ok {
if parsed.amount != amount || parsed.prefix != verificationTokenPrefixForSource(sourceID) {
m.verifyTokens[token] = true
return verificationTokenInvalidError(token, sourceID)
}
if parsed.denied {
m.verifyTokens[token] = true
return verificationTokenInvalidError(token, sourceID)
}
// Encoded approval. An explicitly DENIED pending challenge is still the
// authority (shared-state denial overrides the stateless encoding).
if savedCard {
if ch := m.pendingChallenges[sourceID]; ch != nil && ch.outcome == "denied" {
m.verifyTokens[token] = true
return verificationTokenInvalidError(token, sourceID)
}
}
m.verifyTokens[token] = true
return nil
}
// Opaque token (real-Square-shaped): a new-card charge accepts it (the cnon
// gate requires only a present token); a saved-card charge resolves it
// against the recorded pending challenge — a token for a challenge that was
// never recorded, or that is still pending, is "never seen" → invalid.
if !savedCard {
if isVerifyToken {
m.verifyTokens[token] = true
}
return nil
}
ch := m.pendingChallenges[sourceID]
if ch == nil || ch.outcome != "approved" {
if isVerifyToken {
m.verifyTokens[token] = true
}
return verificationTokenInvalidError(token, sourceID)
}
if isVerifyToken {
m.verifyTokens[token] = true
}
return nil
}
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
if m.ShouldFail {
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
@@ -381,20 +591,64 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
}
// Mirror Square's SCA enforcement (opt-in toggle, off by default): a
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
// must re-verify and re-tokenize, NOT retry the same request (the code is
// in definitivePaymentCodes). A present verification token (e.g.
// Mirror Square's SCA enforcement on NEW-CARD charges (opt-in toggle, off
// by default): a cnon: charge without a 3DS/SCA verification token is
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the
// buyer must re-verify and re-tokenize, NOT retry the same request (the
// code is in definitivePaymentCodes). A present verification token (e.g.
// verify_mock_...) satisfies the gate exactly as production accepts a
// Square-issued verification_token on the CreatePayment body.
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") {
if req.VerificationToken == "" {
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
}
}
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, false); err != nil {
return nil, err
}
}
// Mirror Square's SCA enforcement on SAVED-CARD (ccof:) charges — the
// SCA-primary saved-card posture (opt-in toggle, off by default; placement
// AFTER the customer_id gate above so a ccof charge without a customer is
// still MISSING_REQUIRED_PARAMETER, never verification-required). A ccof:
// charge without a 3DS/SCA verification token is rejected with the same
// structured 400 CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and
// a pending buyer-verification challenge is recorded for the card. A
// subsequent charge WITH a verification token resolves the challenge (see
// resolveVerificationToken). Grandfathered cards (GrandfatherSavedCard)
// bypass the gate.
if m.SimulateSavedCardVerificationRequired && strings.HasPrefix(req.SourceID, "ccof:") {
if req.VerificationToken == "" {
if m.grandfatheredCards[req.SourceID] {
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate bypassed: source %s is grandfathered", tokenPrefix(req.SourceID))
} else {
if m.ChallengeResult == "auto" {
// "auto" config: the banking-app challenge resolves itself
// as approved, so the next tokenized retry succeeds without
// an explicit ApprovePendingVerification call.
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: "approved"}
} else {
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: ""}
}
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate: source %s rejected without a verification token", tokenPrefix(req.SourceID))
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "saved card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
}
}
} else {
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, true); err != nil {
return nil, err
}
}
}
@@ -925,6 +1179,42 @@ func (m *MockClient) UsedSources() []string {
return out
}
// GrandfatherSavedCard marks a ccof: token exempt from the saved-card
// verification gate (SimulateSavedCardVerificationRequired), so that card
// charges without a verification token — mirroring a card Square has already
// verified or holds a standing SCA exemption for.
func (m *MockClient) GrandfatherSavedCard(ccofToken string) {
m.mu.Lock()
defer m.mu.Unlock()
m.grandfatheredCards[ccofToken] = true
}
// ApprovePendingVerification marks the recorded buyer-verification challenge
// for a saved card as approved (creating it if the gate never recorded one), so
// a subsequent tokenized charge of that card succeeds.
func (m *MockClient) ApprovePendingVerification(ccofToken string) {
m.mu.Lock()
defer m.mu.Unlock()
if ch, ok := m.pendingChallenges[ccofToken]; ok {
ch.outcome = "approved"
return
}
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "approved"}
}
// DenyPendingVerification marks the recorded buyer-verification challenge for
// a saved card as denied, so a subsequent tokenized charge of that card is
// rejected with VERIFICATION_TOKEN_INVALID (the token is consumed).
func (m *MockClient) DenyPendingVerification(ccofToken string) {
m.mu.Lock()
defer m.mu.Unlock()
if ch, ok := m.pendingChallenges[ccofToken]; ok {
ch.outcome = "denied"
return
}
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "denied"}
}
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
+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")
}
+10
View File
@@ -214,6 +214,16 @@ func initSquare() {
if !enforced && !payments.IsExplicitDevOrMockEnv() {
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env)
}
// SCA-primary / 2FA-backup posture (TWO_FACTOR_FALLBACK, default "true"):
// the homegrown 2FA gate is the BACKUP authorization for saved-card charges
// that carry no Square verification_token. TWO_FACTOR_FALLBACK=false makes
// the deployment SCA-only: a token-less saved-card charge is then rejected
// 402 verification_required (the frontend shows the 3DS challenge; if the
// bank cannot do SCA the payment cannot proceed). Warn when the operator
// opted out so the SCA-only posture is a deliberate, visible choice.
if !payments.NewPaymentService().TwoFactorFallbackEnabled() && !payments.IsExplicitDevOrMockEnv() {
log.Printf("WARNING: TWO_FACTOR_FALLBACK=false with SQUARE_ENVIRONMENT=%q — the homegrown 2FA is DISABLED as a saved-card charge fallback. Only charges carrying a Square verification_token (SCA performed) can proceed; a token-less charge is rejected 402 verification_required.", env)
}
// The mirror-image confusion: enforcement is ON but the Square client fell
// back to the in-memory mock (internal/square.NewDevClient only picks the
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or