From 5dae0bba083d7c5efa575ec2b475249fdd951d57 Mon Sep 17 00:00:00 2001
From: Stephen Adamson
Date: Sat, 15 Aug 2026 00:15:32 +0100
Subject: [PATCH] feat: Square 3DS2 SCA primary authorisation for saved-card
charges; 2FA demoted to audited backup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.env.example | 10 +
README.md | 4 +-
backend/handlers/payments/errors.go | 40 +
backend/handlers/payments/errors_test.go | 62 +-
backend/handlers/payments/giftcards.go | 35 +-
backend/handlers/payments/handlers.go | 224 ++-
backend/handlers/payments/till.go | 56 +-
backend/handlers/payments/twofa.go | 103 +-
.../handlers/payments/twofa_delivery_dev.go | 11 +
.../handlers/payments/twofa_delivery_prod.go | 17 +
backend/handlers/payments/twofa_test.go | 132 +-
backend/internal/square/square_dev.go | 316 +++-
backend/internal/square/square_dev_test.go | 199 +++
backend/main.go | 10 +
.../lib/components/admin/TillPurchases.svelte | 151 +-
.../lib/components/booking/BookingFlow.svelte | 83 +-
.../components/payments/CardSelection.svelte | 6 +-
.../components/payments/MockCardForm.svelte | 113 +-
.../components/payments/PaymentModal.svelte | 152 +-
.../payments/SquareCardInput.svelte | 188 ++-
.../lib/components/payments/TipPayment.svelte | 132 +-
.../payments/TwoFactorCodeInput.svelte | 12 +-
.../payments/UserPaymentModal.svelte | 110 +-
frontend/src/lib/square/square.test.ts | 78 +
frontend/src/lib/square/square.ts | 53 +
frontend/src/lib/stores/savedCards.svelte.ts | 4 +
.../src/lib/stores/twoFactorCode.svelte.ts | 14 +-
frontend/src/routes/account/+page.svelte | 121 +-
obsidian/.obsidian/graph.json | 22 +
obsidian/.obsidian/workspace.json | 39 +-
obsidian/Crussell/Feature Catalog.md | 25 +-
obsidian/Crussell/Overview.md | 6 +-
obsidian/Crussell/Technical Manual.md | 33 +-
.../Crussell/payments and money processes.md | 1370 +++++++++++++++++
obsidian/Untitled.canvas | 1 +
35 files changed, 3683 insertions(+), 249 deletions(-)
create mode 100644 backend/handlers/payments/twofa_delivery_dev.go
create mode 100644 backend/handlers/payments/twofa_delivery_prod.go
create mode 100644 obsidian/.obsidian/graph.json
create mode 100644 obsidian/Crussell/payments and money processes.md
create mode 100644 obsidian/Untitled.canvas
diff --git a/.env.example b/.env.example
index 02700b1..f97d51f 100644
--- a/.env.example
+++ b/.env.example
@@ -70,6 +70,16 @@ SQUARE_ALLOW_REAL_API=
# environments an operator must relay the logged code to the user out-of-band;
# the API never returns the code while enforcement is ON.
REQUIRE_2FA=true
+# TWO_FACTOR_FALLBACK: whether the homegrown 2FA code gate may be used as a
+# BACKUP authorisation for saved-card charges when Square SCA (3-D Secure /
+# buyer verification) is unavailable — e.g. a customer's bank does not support
+# in-app approval. Defaults true. Square 3DS2 SCA is the PRIMARY authorisation;
+# when a saved-card charge carries a Square verification_token the 2FA gate is
+# skipped entirely. Set TWO_FACTOR_FALLBACK=false for a security-first posture
+# in which a saved-card charge without SCA cannot proceed via 2FA (the frontend
+# surfaces the SCA challenge; if the bank can't complete it, the charge fails).
+# 2FA fallback success always writes an admin_audit_log 2fa_fallback_charge row.
+TWO_FACTOR_FALLBACK=true
# TWO_FACTOR_PEPPER — server-side pepper for HMAC-hashing 2FA codes. REQUIRED
# in production builds: code issuance FAILS CLOSED when it is unset (an
# unsalted SHA-256 digest in the 1M code space would be offline-brute-forceable
diff --git a/README.md b/README.md
index aa59439..a12933b 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL
## Limitations
- **Single employee** — no multi-staff scheduling, no team management
-- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, and notifications are UI-only (planned upcoming body of work)
+- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, notifications, and 2FA code delivery are UI-only/log-delivery (planned upcoming body of work; until email/SMS lands, fallback 2FA codes are delivered via the opt-in `[2FA]` log relay — see the 2FA section above)
- **No production S3/R2** — prod storage stubs return "not implemented" (planned upcoming body of work)
- **No social auth** — OAuth providers (Google, Microsoft, Facebook) not registered
- **No dark mode, no PWA, no recurring bookings, no CSV export**
@@ -76,7 +76,7 @@ docker compose up --build -d
### Two-factor authentication (2FA)
-`REQUIRE_2FA` gates saved-card online payments as a **merchant-level authorization gate — NOT PSD2 SCA** (Square buyer verification via `tokenizeWithVerification` is the SCA mechanism, wired for new-card charges; the gate is retained as an additional fraud control until Square buyer verification is wired for saved-card charges) and is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. The 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) until email/SMS lands — but only in production builds when `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` is set; without it, production code issuance fails closed (503) and no user can complete 2FA setup, which then 403s every enforced saved-card payment.
+`REQUIRE_2FA` gates the **2FA backup** for saved-card online payments. Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`) is now the **primary authorisation** for saved-card charges — a customer-initiated stored-credential charge is a PSR 2017-regulated transaction, and Square's verification token both satisfies SCA and shifts chargeback liability to the card scheme. The homegrown 2FA gate fires **only when SCA is unavailable** (e.g. a customer's bank does not support the in-app approval flow) and is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. The intended 2FA delivery channel is email/SMS (the method chosen at setup), **not yet wired**; until it lands, the 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) — but only in production builds when `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` is set (an explicit, insecure opt-in); without it, production code issuance fails closed (503) and no user can complete 2FA setup, which then 403s every enforced saved-card payment on the fallback path. Every admin mint-or-reuse of a fallback code is recorded in the admin audit log, and every admin saved-card charge writes its own audit row.
`TWO_FACTOR_PEPPER` (backend `.env`) is a server-side pepper for HMAC-hashing 2FA codes: with it set, codes are hashed as HMAC-SHA256 keyed by the pepper; if unset, dev/test builds fall back to the legacy unsalted SHA-256 digest with a one-time warning. It is **required in production builds** — code issuance fails closed when it is unset, because an unsalted digest in the 1M code space would be offline-brute-forceable from a log/DB leak (mirroring the fail-fast `JWT_SECRET_KEY` check). Generate a strong random value with `openssl rand -base64 32`.
diff --git a/backend/handlers/payments/errors.go b/backend/handlers/payments/errors.go
index 0f02a21..1ac46b3 100644
--- a/backend/handlers/payments/errors.go
+++ b/backend/handlers/payments/errors.go
@@ -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:
//
diff --git a/backend/handlers/payments/errors_test.go b/backend/handlers/payments/errors_test.go
index cfc1f2b..7adcd8c 100644
--- a/backend/handlers/payments/errors_test.go
+++ b/backend/handlers/payments/errors_test.go
@@ -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.
diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go
index 9b16716..de786ed 100644
--- a/backend/handlers/payments/giftcards.go
+++ b/backend/handlers/payments/giftcards.go
@@ -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",
diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go
index 1f19a06..64d7ca3 100644
--- a/backend/handlers/payments/handlers.go
+++ b/backend/handlers/payments/handlers.go
@@ -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,
diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go
index 8d7e067..19bd4dd 100644
--- a/backend/handlers/payments/till.go
+++ b/backend/handlers/payments/till.go
@@ -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"
diff --git a/backend/handlers/payments/twofa.go b/backend/handlers/payments/twofa.go
index 59aba22..84b572f 100644
--- a/backend/handlers/payments/twofa.go
+++ b/backend/handlers/payments/twofa.go
@@ -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
}
}
diff --git a/backend/handlers/payments/twofa_delivery_dev.go b/backend/handlers/payments/twofa_delivery_dev.go
new file mode 100644
index 0000000..2267b44
--- /dev/null
+++ b/backend/handlers/payments/twofa_delivery_dev.go
@@ -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 }
diff --git a/backend/handlers/payments/twofa_delivery_prod.go b/backend/handlers/payments/twofa_delivery_prod.go
new file mode 100644
index 0000000..e3b174b
--- /dev/null
+++ b/backend/handlers/payments/twofa_delivery_prod.go
@@ -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"
+}
diff --git a/backend/handlers/payments/twofa_test.go b/backend/handlers/payments/twofa_test.go
index d67252e..9bb5114 100644
--- a/backend/handlers/payments/twofa_test.go
+++ b/backend/handlers/payments/twofa_test.go
@@ -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))
diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go
index eecf7dd..3a075f7 100644
--- a/backend/internal/square/square_dev.go
+++ b/backend/internal/square/square_dev.go
@@ -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___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__[_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__[_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)
diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go
index 6d10896..f2224bb 100644
--- a/backend/internal/square/square_dev_test.go
+++ b/backend/internal/square/square_dev_test.go
@@ -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___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__
+// 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")
+}
diff --git a/backend/main.go b/backend/main.go
index 43ee2c0..78193cb 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -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
diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte
index 3657a7e..ff57ccf 100644
--- a/frontend/src/lib/components/admin/TillPurchases.svelte
+++ b/frontend/src/lib/components/admin/TillPurchases.svelte
@@ -11,12 +11,19 @@
import {
isSquareConfigured,
isTwoFactorVerificationGateFailure,
+ isVerificationRequiredSignal,
+ shouldFallbackTo2FA,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
- requestNewTwoFactorCode
+ requestNewTwoFactorCode,
+ VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
- import { authStore } from '$lib/stores/auth.svelte';
- import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
+ import {
+ tokenizeSavedCardWithVerification,
+ type SavedCardVerificationResult
+ } from '$lib/components/payments/SquareCardInput.svelte';
+ import { authStore } from '$lib/stores/auth.svelte';
+ import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
type CartItem = {
id: string;
@@ -87,6 +94,9 @@
exp_month: number;
exp_year: number;
cardholder_name?: string;
+ // Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
+ // challenge (tokenizeSavedCardWithVerification).
+ square_card_id?: string;
};
let customerQuery = $state('');
@@ -129,9 +139,17 @@
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
+ // Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
+ // from backup to the only available gate (scaAvailable → false); every other
+ // outcome keeps SCA primary for the next retry.
+ let lastSCAOutcome = $state('');
+ // True while the saved-card 3DS challenge is open and the CUSTOMER must
+ // approve it in their banking app — drives the "waiting for approval" panel.
+ let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
+ scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () =>
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
});
@@ -384,6 +402,16 @@
if (!res.ok) {
responseStatus = res.status;
const errText = await res.text();
+ // Saved-card (ccof) SCA: the backend returns 402 +
+ // `verification_required` when Square requires buyer verification
+ // and no verification_token was supplied. Run the client-side 3DS
+ // challenge and retry the SAME sale line with the fresh token and
+ // its SAME cached idempotency key. runTillSavedCardSCA throws to
+ // stop the whole sale on any non-verified outcome.
+ if (paymentMethod === 'saved_card' && isVerificationRequiredSignal(responseStatus, errText)) {
+ await runTillSavedCardSCA(body);
+ continue;
+ }
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
const data = await res.json();
@@ -405,11 +433,75 @@
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
paymentError = msg;
toast.error(msg);
- } finally {
- isProcessingPaymentSync = false;
- processing = false;
- }
+ } finally {
+ isProcessingPaymentSync = false;
+ processing = false;
}
+}
+
+/**
+ * Saved-card (ccof) SCA challenge, run when a till sale line came back 402
+ * with the verification-required signal. The CUSTOMER approves the 3DS
+ * challenge in their banking app; the operator's screen shows the waiting
+ * state. 'verified' retries the SAME sale line with the fresh verification_token
+ * and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
+ * demotes 2FA from backup to the available gate; 'challenge-cancelled' /
+ * 'sca-failed' keep the pending row retryable (the idempotency key stays
+ * cached). Throws to stop the whole sale on any non-verified outcome.
+ */
+async function runTillSavedCardSCA(body: Record): Promise {
+ const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
+ // The till body carries the amount in POUNDS (the backend multiplies by
+ // 100); the SCA challenge binds to pence, so convert for the challenge.
+ const amountPence = Math.round((Number(body.amount) || 0) * 100);
+ awaitingSCA = true;
+ try {
+ if (!squareCardId) {
+ lastSCAOutcome = 'sca-unavailable';
+ twoFactor.reveal = true;
+ throw new Error(VERIFICATION_REQUIRED_MESSAGE);
+ }
+ let result: SavedCardVerificationResult;
+ try {
+ result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
+ email: selectedCustomer?.email
+ });
+ } catch (err) {
+ lastSCAOutcome = 'sca-unavailable';
+ twoFactor.reveal = true;
+ throw err;
+ }
+ lastSCAOutcome = result.outcome;
+ if (result.outcome === 'verified') {
+ const retry = await submitPaymentWithRetry(() =>
+ apiFetch('/api/admin/till/sale', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...body,
+ verification_token: result.verificationToken
+ })
+ })
+ );
+ if (!retry.ok) {
+ const errText = await retry.text();
+ throw new Error(extractErrorMessage(errText) || 'Till sale failed');
+ }
+ return;
+ }
+ twoFactor.reveal = true;
+ if (result.outcome === 'sca-unavailable') {
+ throw new Error(
+ `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
+ );
+ }
+ throw new Error(
+ "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
+ );
+ } finally {
+ awaitingSCA = false;
+ }
+}
@@ -793,10 +885,9 @@
-
- This card may require bank app confirmation to complete. Ensure the customer has
- their phone ready.
-
+
+ Your card issuer will ask you to approve this payment in your banking app.
+
{/if}
@@ -836,18 +927,32 @@
{/if}
-
+ {#if awaitingSCA}
+
+
+
+ Waiting for customer to approve in their banking app…
+
+
+ The customer may need to approve this payment in their banking app
+
+
+ {:else}
+
+ {/if}
Secure payment powered by Square
Gift card sales are processed through the till; retail items require manual recording for
diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte
index 7cf1f4f..c090c7f 100644
--- a/frontend/src/lib/components/booking/BookingFlow.svelte
+++ b/frontend/src/lib/components/booking/BookingFlow.svelte
@@ -46,8 +46,15 @@
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
- submitPaymentWithRetry
+ isVerificationRequiredSignal,
+ shouldFallbackTo2FA,
+ submitPaymentWithRetry,
+ VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
+ import {
+ tokenizeSavedCardWithVerification,
+ type SavedCardVerificationResult
+ } from '$lib/components/payments/SquareCardInput.svelte';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
@@ -107,6 +114,9 @@
exp_month: number;
exp_year: number;
is_default?: boolean;
+ // Square's card-on-file id (`ccof:...`), needed to run the saved-card
+ // SCA challenge (tokenizeSavedCardWithVerification).
+ square_card_id?: string;
}>
>([]);
let paymentMethodsLoading = $state(false);
@@ -158,10 +168,15 @@
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
+ // Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
+ // from backup to the only available gate (scaAvailable → false); every other
+ // outcome keeps SCA primary for the next retry.
+ let lastSCAOutcome = $state('');
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () =>
- savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard)
+ savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard),
+ scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
});
const depositCardFormValid = $derived(paymentCardSelectionValid);
@@ -520,6 +535,20 @@
}
const text = await response.text();
+ // Saved-card (ccof) SCA: the backend returns 402 + `verification_required`
+ // when Square requires buyer verification and no verification_token was
+ // supplied. Run the client-side 3DS challenge and retry with the fresh
+ // token — the SAME body and idempotency key (never regenerated here). A
+ // token-carrying retry is never re-intercepted (the backend skips the 2FA
+ // gate when a verification_token is present).
+ if (
+ selectedPaymentMethod &&
+ !body.verification_token &&
+ isVerificationRequiredSignal(response.status, text)
+ ) {
+ await runDepositSCA({ body, amountPence, depositAmount, confirmOverflowTip });
+ return;
+ }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the deposit can be retried with a fresh code.
@@ -610,6 +639,56 @@
depositTokenizedForSaveCard = false;
}
+ /**
+ * Saved-card (ccof) SCA challenge, run when the deposit charge came back 402
+ * with the verification-required signal. 'verified' retries the SAME deposit
+ * body with the fresh verification_token and the SAME cached idempotency key
+ * (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the
+ * pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable'
+ * demotes 2FA from backup to the available gate.
+ */
+ async function runDepositSCA(options: {
+ body: Record;
+ amountPence: number;
+ depositAmount: number;
+ confirmOverflowTip: boolean;
+ }) {
+ const squareCardId = paymentMethods.find((c) => c.id === selectedPaymentMethod)?.square_card_id;
+ if (!squareCardId) {
+ lastSCAOutcome = 'sca-unavailable';
+ twoFactor.reveal = true;
+ toast.error(VERIFICATION_REQUIRED_MESSAGE);
+ return;
+ }
+ let result: SavedCardVerificationResult;
+ try {
+ result = await tokenizeSavedCardWithVerification(options.amountPence, squareCardId, {
+ givenName: customerInfo.firstName || authStore.currentUser?.firstName,
+ familyName: customerInfo.lastName || authStore.currentUser?.lastName,
+ email: customerInfo.email || authStore.currentUser?.email
+ });
+ } catch (err) {
+ lastSCAOutcome = 'sca-unavailable';
+ twoFactor.reveal = true;
+ toast.error(err instanceof Error ? err.message : 'Card verification failed');
+ return;
+ }
+ lastSCAOutcome = result.outcome;
+ if (result.outcome === 'verified') {
+ await submitDepositPayment({
+ ...options,
+ body: { ...options.body, verification_token: result.verificationToken }
+ });
+ return;
+ }
+ twoFactor.reveal = true;
+ toast.error(
+ result.outcome === 'sca-unavailable'
+ ? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
+ : "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
+ );
+ }
+
let paymentAttempted = $state(false);
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
diff --git a/frontend/src/lib/components/payments/CardSelection.svelte b/frontend/src/lib/components/payments/CardSelection.svelte
index b34721f..285238c 100644
--- a/frontend/src/lib/components/payments/CardSelection.svelte
+++ b/frontend/src/lib/components/payments/CardSelection.svelte
@@ -15,6 +15,10 @@
exp_month: number;
exp_year: number;
is_default?: boolean;
+ // Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
+ // challenge (tokenizeSavedCardWithVerification). Optional so surfaces that
+ // haven't populated it from the API can still render the list.
+ square_card_id?: string;
}
let {
@@ -104,7 +108,7 @@
{#if twoFactorEnabled}
- A verification code is required to use a saved card — you'll be asked for it at checkout.
+ Your card issuer will ask you to approve this payment in your banking app.
+ {/if}
diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte
index 28e88ed..aa0def5 100644
--- a/frontend/src/lib/components/payments/PaymentModal.svelte
+++ b/frontend/src/lib/components/payments/PaymentModal.svelte
@@ -12,12 +12,19 @@
campaignDiscountPence,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
+ isVerificationRequiredSignal,
sanitizeDecimalInput,
+ shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
+ VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
} from '$lib/square/square';
+ import {
+ tokenizeSavedCardWithVerification,
+ type SavedCardVerificationResult
+ } from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
@@ -44,6 +51,7 @@
| 'gift-confirming'
| 'saved-card-selecting'
| 'saved-card-processing'
+ | 'saved-card-waiting-sca'
| 'success'
| 'error';
@@ -78,6 +86,10 @@
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
+ // Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
+ // from backup to the only available gate (scaAvailable → false); every other
+ // outcome keeps SCA primary for the next retry.
+ let lastSCAOutcome = $state('');
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
@@ -92,6 +104,7 @@
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
+ scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () => {
const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
@@ -739,6 +752,7 @@
exp_month: number;
exp_year: number;
cardholder_name?: string;
+ square_card_id?: string;
}>
>([]);
let loadingSavedCards = $state(false);
@@ -829,7 +843,18 @@
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
- throw new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
+ // Saved-card (ccof) SCA: the backend returns 402 +
+ // `verification_required` when Square requires buyer verification
+ // and no verification_token was supplied. Run the client-side 3DS
+ // challenge (the CUSTOMER approves in their banking app) and retry
+ // with the fresh token + the SAME cached idempotency key.
+ if (isVerificationRequiredSignal(responseStatus, errData)) {
+ await runSavedCardSCA(chargeAmount);
+ return;
+ }
+ const err = new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
+ (err as { bodyText?: string }).bodyText = errData;
+ throw err;
}
const data = await response.json();
@@ -854,13 +879,17 @@
onComplete(paymentResult);
} catch (_err) {
status = 'error';
- // Saved-card (ccof) charges skip the client-side SCA step, so a
- // definitive 402 on the saved-card path means the issuer still
- // requires verification — retrying the same saved card can never
- // succeed. Surface the fix instead of the generic backend text.
+ // A definitive 402 on the saved-card path means the issuer still
+ // requires verification. A structured verification-required signal
+ // surfaces the SCA-first guidance; the legacy saved-card check is the
+ // fallback for generic 402s (the SCA flow above intercepts the
+ // structured ones, so this is the belt-and-braces path).
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
- if (isSavedCardVerificationRequired(responseStatus, true))
- msg = SAVED_CARD_VERIFICATION_MESSAGE;
+ const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
+ const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
+ if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) {
+ msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
+ }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
@@ -884,6 +913,101 @@
fetchSavedCards();
}
});
+
+ /**
+ * Saved-card (ccof) SCA challenge, run when the charge came back 402 with
+ * the verification-required signal. The CUSTOMER approves the 3DS challenge
+ * in their banking app; the operator's screen shows the waiting state.
+ * - 'verified' → retries the SAME charge with the fresh verification_token
+ * and the SAME cached idempotency key (never regenerated here);
+ * - 'challenge-cancelled' / 'sca-failed' → leaves the pending row retryable
+ * (the idempotency key stays cached) and reveals the 2FA-fallback input;
+ * - 'sca-unavailable' → demotes 2FA from backup to the available gate.
+ */
+ async function runSavedCardSCA(chargeAmount: number) {
+ if (!selectedSavedCardId) return;
+ const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
+ status = 'saved-card-waiting-sca';
+ if (!squareCardId) {
+ lastSCAOutcome = 'sca-unavailable';
+ twoFactor.reveal = true;
+ status = 'error';
+ error = VERIFICATION_REQUIRED_MESSAGE;
+ toast.error(error);
+ return;
+ }
+ let result: SavedCardVerificationResult;
+ try {
+ result = await tokenizeSavedCardWithVerification(chargeAmount, squareCardId, {
+ givenName: booking.user?.first_name,
+ familyName: booking.user?.last_name,
+ email: booking.user?.email
+ });
+ } catch (_err) {
+ lastSCAOutcome = 'sca-unavailable';
+ twoFactor.reveal = true;
+ status = 'error';
+ error = _err instanceof Error ? _err.message : 'Card verification failed';
+ toast.error(error);
+ return;
+ }
+ lastSCAOutcome = result.outcome;
+ if (result.outcome === 'verified') {
+ try {
+ const retry = await submitPaymentWithRetry(() =>
+ apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ amount: chargeAmount,
+ payment_type: 'full',
+ payment_method: 'saved_card',
+ saved_card_id: selectedSavedCardId,
+ verification_token: result.verificationToken,
+ ...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
+ idempotency_key: savedCardIdempotencyKey
+ })
+ })
+ );
+ if (!retry.ok) {
+ const retryText = await retry.text();
+ const err = new Error(
+ extractErrorMessage(retryText) || 'Failed to process saved card payment'
+ );
+ (err as { bodyText?: string }).bodyText = retryText;
+ throw err;
+ }
+ const data = await retry.json();
+ status = 'success';
+ paymentResult = {
+ checkout_id: data.payment_id || data.checkout_id || data.id || '',
+ status: 'COMPLETED',
+ card_brand: data.card_brand,
+ last4: data.card_last4,
+ amount: data.amount
+ };
+ savedCardIdempotencyKey = '';
+ savedCardKeyedAmount = 0;
+ twoFactor.setCode('');
+ twoFactor.reveal = false;
+ toast.success('Saved card payment successful');
+ onComplete(paymentResult);
+ return;
+ } catch (_err) {
+ status = 'error';
+ error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
+ toast.error(error);
+ return;
+ }
+ }
+ twoFactor.reveal = true;
+ status = 'error';
+ error =
+ result.outcome === 'sca-unavailable'
+ ? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
+ : "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
+ toast.error(error);
+ }
!open && handleClose()}>
@@ -1526,8 +1650,7 @@
- This card may require bank app confirmation to complete. Ensure the customer has their
- phone ready.
+ Your card issuer will ask you to approve this payment in your banking app.
{/if}
@@ -1563,12 +1686,19 @@
- {:else if status === 'saved-card-processing'}
+ {:else if status === 'saved-card-processing' || status === 'saved-card-waiting-sca'}
-
Processing saved card payment...
+ {#if status === 'saved-card-waiting-sca'}
+
+ Waiting for customer to approve in their banking app…
+
+
The customer may need to approve this payment in their banking app