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.

{:else} diff --git a/frontend/src/lib/components/payments/MockCardForm.svelte b/frontend/src/lib/components/payments/MockCardForm.svelte index dfc6cfd..b8d6382 100644 --- a/frontend/src/lib/components/payments/MockCardForm.svelte +++ b/frontend/src/lib/components/payments/MockCardForm.svelte @@ -1,6 +1,33 @@ + +
@@ -282,4 +368,21 @@ class={inputClasses} />
+ + {#if awaitingChallenge} +
+

Waiting for approval in the banking app…

+

+ Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome + ({challengeResult === 'deny' ? 'deny' : 'approve'}). +

+ +
+ {/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

+ {:else} +

Processing saved card payment...

+ {/if}
{:else if status === 'error' && error}
diff --git a/frontend/src/lib/components/payments/SquareCardInput.svelte b/frontend/src/lib/components/payments/SquareCardInput.svelte index a2e21ff..0404eb2 100644 --- a/frontend/src/lib/components/payments/SquareCardInput.svelte +++ b/frontend/src/lib/components/payments/SquareCardInput.svelte @@ -1,4 +1,164 @@