feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup
SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.
BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
(SCA primary); no token => 2FA fallback requires delivery channel + consume +
insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
{sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
customer_initiated=false (merchant-initiated, no SCA, no liability shift);
customer-initiated online flows keep true
FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
(booking/tip/till/gift-card/admin); 'waiting for approval in your banking
app' state on admin surfaces; 2FA backup-only UX in the shared composable
MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
grandfathered; frontend helper tests
DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).
26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -89,12 +89,61 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consum
|
||||
return twofa.VerifyForUser(ctx, userID, code, consume)
|
||||
}
|
||||
|
||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths
|
||||
// (PSD2 SCA stand-in until real SCA infra lands). It returns true when the
|
||||
// request may proceed:
|
||||
// twoFactorFallbackEnabled reports whether the homegrown 2FA may act as a
|
||||
// BACKUP authorization for a saved-card charge when SCA is unavailable (the
|
||||
// charge carries no Square verification_token). The parse is case-insensitive
|
||||
// and alias-tolerant (false/0/off/no) — a value like "False" or "OFF" never
|
||||
// silently leaves the fallback ON. Any other value — including empty and
|
||||
// unknown — keeps the fallback enabled (the shipped default). It is the
|
||||
// TWO_FACTOR_FALLBACK policy switch read at startup by main.go and exposed via
|
||||
// PaymentService.TwoFactorFallbackEnabled.
|
||||
func twoFactorFallbackEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("TWO_FACTOR_FALLBACK"))) {
|
||||
case "false", "0", "off", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// TwoFactorFallbackEnabled is the exported form of twoFactorFallbackEnabled, so
|
||||
// main.go can log the SCA-primary/2FA-backup posture at startup without
|
||||
// re-implementing the env logic.
|
||||
func (s *PaymentService) TwoFactorFallbackEnabled() bool {
|
||||
return twoFactorFallbackEnabled()
|
||||
}
|
||||
|
||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths under
|
||||
// the SCA-primary / 2FA-backup decision model. It returns (allowed, fallbackUsed):
|
||||
// allowed is true when the request may proceed; fallbackUsed is true when the
|
||||
// authorization was granted by the homegrown 2FA BACKUP (SCA was unavailable and
|
||||
// the customer's 2FA code verified) — the caller must then write a strict
|
||||
// insertTwoFAFallbackAudit row for the charge.
|
||||
//
|
||||
// - 2FA is not enforced (dev/mock), OR
|
||||
// - the user has completed 2FA setup (two_factor_enabled) AND the request
|
||||
// The decision model, in order:
|
||||
//
|
||||
// - 2FA is not enforced (dev/mock) → allowed, no fallback.
|
||||
//
|
||||
// - The request carries a Square verification_token (SCA performed — the
|
||||
// issuer has already authenticated the buyer): SKIP the 2FA gate entirely.
|
||||
// SCA is PRIMARY; the issuer did the job, so the homegrown gate is never
|
||||
// consulted (fallbackUsed=false). A charge that carries a token passes even
|
||||
// for a user who has not enabled 2FA.
|
||||
//
|
||||
// - Otherwise the gate is the FALLBACK authorization for a ccof charge with
|
||||
// no verification token. It only runs when the fallback is permitted:
|
||||
//
|
||||
// (a) TWO_FACTOR_FALLBACK is enabled (see twoFactorFallbackEnabled) — when
|
||||
// the deployment opts out, a token-less charge is denied 402
|
||||
// verification_required: the frontend shows the SCA challenge, and if the
|
||||
// bank cannot do SCA the payment cannot proceed (security-first); and
|
||||
//
|
||||
// (b) a 2FA code delivery channel exists (twoFADeliveryAvailable, build-
|
||||
// dependent like the user package's) — a code the customer can never
|
||||
// receive would silently lock the gate, so it is denied 503
|
||||
// ("2FA requires an email or SMS delivery channel").
|
||||
//
|
||||
// - The user has completed 2FA setup (two_factor_enabled) AND the request
|
||||
// carries a verification_code matching the user's stored pending code.
|
||||
//
|
||||
// B10: the setup flag alone must NOT unlock saved-card charges — an enforced
|
||||
@@ -119,49 +168,69 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consum
|
||||
// expired → 400, DB failure → 500.
|
||||
//
|
||||
// On any denial an error JSON is written (parseable by the frontend via
|
||||
// extractErrorMessage) and false is returned — the caller must abort the charge.
|
||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string, consume bool) bool {
|
||||
// extractErrorMessage) and allowed=false is returned — the caller must abort
|
||||
// the charge.
|
||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
|
||||
if !twoFactorEnforced() {
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
if service == nil {
|
||||
service = &PaymentService{}
|
||||
}
|
||||
// SCA-primary: a Square verification_token means the issuer already
|
||||
// completed Strong Customer Authentication — the 2FA gate is skipped and no
|
||||
// fallback audit applies.
|
||||
if verificationToken != "" {
|
||||
return true, false
|
||||
}
|
||||
// 2FA is now the BACKUP authorization for a token-less ccof charge. Fail
|
||||
// closed when the deployment disabled the fallback (TWO_FACTOR_FALLBACK) or
|
||||
// has no delivery channel for its codes (twoFADeliveryAvailable).
|
||||
if !twoFactorFallbackEnabled() {
|
||||
writeVerificationRequiredResponse(w)
|
||||
return false, false
|
||||
}
|
||||
if !twoFADeliveryAvailable() {
|
||||
mw.RespondError(w, http.StatusServiceUnavailable, "2FA requires an email or SMS delivery channel; contact the salon")
|
||||
return false, false
|
||||
}
|
||||
enabled, err := service.UserTwoFactorEnabled(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
log.Printf("failed to check two-factor status for user %s: %v", userID, err)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor status")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
if !enabled {
|
||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
// B10: an enforced charge of a saved card needs a live one-time code, not
|
||||
// just the enabled setup flag.
|
||||
if verificationCode == "" {
|
||||
mw.RespondError(w, http.StatusForbidden, "A two-factor verification code is required to use this saved card. Ask the customer for their current code.")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
|
||||
case err == nil:
|
||||
return true
|
||||
// The 2FA BACKUP authorized this token-less saved-card charge. The
|
||||
// caller writes the strict fallback audit row on the charge's success.
|
||||
return true, true
|
||||
case errors.Is(err, twofa.ErrIncorrect):
|
||||
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
|
||||
return false
|
||||
return false, false
|
||||
case errors.Is(err, twofa.ErrLockedOut):
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
|
||||
return false
|
||||
return false, false
|
||||
case errors.Is(err, twofa.ErrMissingOrExpired):
|
||||
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
|
||||
return false
|
||||
return false, false
|
||||
default:
|
||||
log.Printf("failed to check two-factor verification code for user %s: %v", userID, err)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor verification code")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build dev || test
|
||||
|
||||
package payments
|
||||
|
||||
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
|
||||
// this build. Dev/test builds always have one — the [2FA] log line is the
|
||||
// documented loose-fake delivery channel — so the 2FA BACKUP authorization
|
||||
// (the saved-card gate when SCA is unavailable) is always usable here. Mirrors
|
||||
// handlers/user/twofa_dev.go; production builds decide in
|
||||
// twofa_delivery_prod.go.
|
||||
func twoFADeliveryAvailable() bool { return true }
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !dev && !test
|
||||
|
||||
package payments
|
||||
|
||||
import "os"
|
||||
|
||||
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
|
||||
// this build. Production has no wired email/SMS transport (P6), so the ONLY
|
||||
// channel is the operator's explicit opt-in to insecure log delivery
|
||||
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Without a channel, codes can never
|
||||
// reach the customer, so the 2FA BACKUP authorization (the saved-card gate
|
||||
// when SCA is unavailable) cannot operate and a token-less saved-card charge is
|
||||
// denied 503 (see requireTwoFactorForCardAccess). Mirrors
|
||||
// handlers/user/twofa_prod.go; dev/test builds always deliver (twofa_delivery_dev.go).
|
||||
func twoFADeliveryAvailable() bool {
|
||||
return os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true"
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -23,7 +23,8 @@ package square
|
||||
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
|
||||
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
|
||||
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
|
||||
// SimulateVerificationRequired) that let dev/tests drive Square failure modes
|
||||
// SimulateVerificationRequired, SimulateSavedCardVerificationRequired,
|
||||
// ChallengeResult) that let dev/tests drive Square failure modes
|
||||
// that are otherwise only reachable against the real API. FailAfterCommit
|
||||
// simulates the exact "charged but response lost → same-key retry" prod
|
||||
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
|
||||
@@ -58,6 +59,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -156,10 +158,61 @@ type MockClient struct {
|
||||
// the charge succeeds. Off by default — existing dev/test flows charge
|
||||
// plain "cnon:test-card"-style tokens without verification tokens.
|
||||
SimulateVerificationRequired bool
|
||||
// SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on
|
||||
// saved-card (ccof:) charges — the SCA-primary saved-card posture the
|
||||
// platform is moving toward (buyer verification on card-on-file charges,
|
||||
// not just new-card nonces). When true, CreatePayment with a ccof: source
|
||||
// and NO VerificationToken is rejected with the same structured 400
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and a pending
|
||||
// buyer-verification challenge is recorded for the card. A subsequent
|
||||
// charge WITH a verification token resolves that challenge (see
|
||||
// resolveVerificationToken): an explicitly approved challenge, or a
|
||||
// stateless verify_mock_<prefix>_<amount>_ok token, lets the charge
|
||||
// succeed; a denied challenge / _deny token is rejected with 400
|
||||
// VERIFICATION_TOKEN_INVALID. Cards marked via GrandfatherSavedCard bypass
|
||||
// the gate entirely. Off by default — existing dev/test flows charge
|
||||
// saved cards without verification tokens, so flipping it on in a
|
||||
// prod-like test setup intentionally surfaces every ccof charge that would
|
||||
// be rejected by Square's SCA.
|
||||
SimulateSavedCardVerificationRequired bool
|
||||
// ChallengeResult configures the mock's SCA challenge outcome when a
|
||||
// verification token is supplied on a gated charge. "" or "approve"
|
||||
// (default) accepts a valid token / approved challenge; "deny" simulates
|
||||
// the buyer denying EVERY banking-app challenge (any token →
|
||||
// VERIFICATION_TOKEN_INVALID); "auto" auto-resolves a gate rejection's
|
||||
// pending challenge as approved, so the next tokenized retry succeeds
|
||||
// without an explicit ApprovePendingVerification call.
|
||||
ChallengeResult string
|
||||
// grandfatheredCards marks ccof: tokens that are exempt from the saved-card
|
||||
// verification gate (GrandfatherSavedCard). An exempt card charges without
|
||||
// a verification token even while SimulateSavedCardVerificationRequired is
|
||||
// on — mirroring cards Square has already verified / stored with a standing
|
||||
// SCA exemption.
|
||||
grandfatheredCards map[string]bool
|
||||
// pendingChallenges records the per-card buyer-verification challenge state
|
||||
// that the saved-card gate creates when it rejects a ccof charge without a
|
||||
// token. An opaque (real-Square-shaped) verification token is resolved
|
||||
// against this ledger; the deterministic verify_mock_... tokens carry their
|
||||
// own outcome and only consult the ledger to honour an explicit denial.
|
||||
pendingChallenges map[string]*pendingChallenge
|
||||
// verifyTokens is a one-time-use ledger of verify_mock_* verification
|
||||
// tokens consumed by a successful charge or a definitive
|
||||
// VERIFICATION_TOKEN_INVALID rejection — mirroring real Square, which
|
||||
// consumes a verification token on use so a replayed token is rejected
|
||||
// (VERIFICATION_TOKEN_INVALID). Mirrors the usedSources ledger pattern.
|
||||
verifyTokens map[string]bool
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
|
||||
// pendingChallenge is the recorded buyer-verification challenge state for one
|
||||
// saved-card (ccof:) token. outcome "" = pending (recorded by the gate's
|
||||
// rejection, not yet resolved); "approved" / "denied" = resolved via
|
||||
// ApprovePendingVerification / DenyPendingVerification.
|
||||
type pendingChallenge struct {
|
||||
outcome string
|
||||
}
|
||||
|
||||
func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return createPaymentHTTP(ctx, req)
|
||||
}
|
||||
@@ -251,6 +304,9 @@ func NewDevClient() SquareClient {
|
||||
customers: make(map[string]*CustomerResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
usedSources: make(map[string]bool),
|
||||
grandfatheredCards: make(map[string]bool),
|
||||
pendingChallenges: make(map[string]*pendingChallenge),
|
||||
verifyTokens: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +343,160 @@ func keyReuseError(key string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// verificationTokenInvalidError is Square's VERIFICATION_TOKEN_INVALID
|
||||
// rejection (definitivePaymentCodes): the supplied 3DS/SCA verification token
|
||||
// is invalid, expired, already used, denied by the buyer, or not bound to this
|
||||
// card + amount. A definitive payment error — retrying the same request can
|
||||
// never succeed.
|
||||
func verificationTokenInvalidError(token, sourceID string) error {
|
||||
return &squareAPIError{
|
||||
Code: "VERIFICATION_TOKEN_INVALID",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "The verification token is invalid, expired, already used, or not valid for this charge",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: fmt.Errorf("square: verification token %s is not valid for card-on-file charge on %s", tokenPrefix(token), tokenPrefix(sourceID)),
|
||||
}
|
||||
}
|
||||
|
||||
// parsedVerifyToken is the deterministic verify_mock_<prefix>_<amount>[_ok|_deny]
|
||||
// verification-token encoding shared by the dev frontend and the mock, so the
|
||||
// two sides can exercise approve/deny outcomes WITHOUT shared state.
|
||||
type parsedVerifyToken struct {
|
||||
prefix string
|
||||
amount int64
|
||||
denied bool
|
||||
}
|
||||
|
||||
// parseVerifyToken parses a deterministic mock verification token of the form
|
||||
// verify_mock_<prefix>_<amount>[_ok|_deny] (outcome suffix defaults to ok).
|
||||
// Returns ok=false for anything that is not parseable (an opaque token — the
|
||||
// same shape as real Square's verification tokens — which resolves against the
|
||||
// recorded pending challenge instead).
|
||||
func parseVerifyToken(token string) (parsedVerifyToken, bool) {
|
||||
const marker = "verify_mock_"
|
||||
if !strings.HasPrefix(token, marker) {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
rest := strings.TrimPrefix(token, marker)
|
||||
if rest == "" {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
parts := strings.Split(rest, "_")
|
||||
denied := false
|
||||
if n := len(parts); n > 1 {
|
||||
switch parts[n-1] {
|
||||
case "ok", "deny":
|
||||
denied = parts[n-1] == "deny"
|
||||
parts = parts[:n-1]
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
||||
if err != nil {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
return parsedVerifyToken{prefix: strings.Join(parts[:len(parts)-1], "_"), amount: amount, denied: denied}, true
|
||||
}
|
||||
|
||||
// verificationTokenPrefixForSource returns the card prefix the mock binds an
|
||||
// SCA verification token to for a source_id — the SAME derivation the dev
|
||||
// frontend uses when minting verify_mock_... tokens, so the binding check
|
||||
// cannot drift between the two sides. New-card (cnon:) nonces bind to the
|
||||
// first four digits of the PAN the user typed (MockCardForm MOCK_TOKENS);
|
||||
// saved-card (ccof:) tokens bind to the first four chars after the ccof:
|
||||
// prefix (MockCardForm.verifySavedCard).
|
||||
func verificationTokenPrefixForSource(sourceID string) string {
|
||||
switch sourceID {
|
||||
case "cnon:test-card":
|
||||
return "4242"
|
||||
case "cnon:visa":
|
||||
return "4111"
|
||||
case "cnon:mastercard":
|
||||
return "5555"
|
||||
case "cnon:amex":
|
||||
return "3782"
|
||||
}
|
||||
if strings.HasPrefix(sourceID, "ccof:") {
|
||||
rest := strings.TrimPrefix(sourceID, "ccof:")
|
||||
if len(rest) > 4 {
|
||||
return rest[:4]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
|
||||
// a charge. savedCard=true resolves against the saved-card challenge ledger;
|
||||
// savedCard=false (new-card nonce) treats any present token as satisfying the
|
||||
// gate. Returns nil when the token is accepted (consuming it in the one-time-use
|
||||
// ledger), or a definitive 400 VERIFICATION_TOKEN_INVALID. Must be called under
|
||||
// m.mu (CreatePayment holds the write lock).
|
||||
func (m *MockClient) resolveVerificationToken(token, sourceID string, amount int64, savedCard bool) error {
|
||||
isVerifyToken := strings.HasPrefix(token, "verify_mock_")
|
||||
|
||||
// ChallengeResult="deny" simulates the buyer denying every banking-app
|
||||
// challenge: ANY token is definitively rejected.
|
||||
if m.ChallengeResult == "deny" {
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
|
||||
// One-time-use ledger: a verify_mock_* token is consumed on its first use;
|
||||
// a second use of the same token is definitively invalid.
|
||||
if isVerifyToken && m.verifyTokens[token] {
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
|
||||
// Deterministic tokens carry their own binding and outcome.
|
||||
if parsed, ok := parseVerifyToken(token); ok {
|
||||
if parsed.amount != amount || parsed.prefix != verificationTokenPrefixForSource(sourceID) {
|
||||
m.verifyTokens[token] = true
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
if parsed.denied {
|
||||
m.verifyTokens[token] = true
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
// Encoded approval. An explicitly DENIED pending challenge is still the
|
||||
// authority (shared-state denial overrides the stateless encoding).
|
||||
if savedCard {
|
||||
if ch := m.pendingChallenges[sourceID]; ch != nil && ch.outcome == "denied" {
|
||||
m.verifyTokens[token] = true
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
}
|
||||
m.verifyTokens[token] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Opaque token (real-Square-shaped): a new-card charge accepts it (the cnon
|
||||
// gate requires only a present token); a saved-card charge resolves it
|
||||
// against the recorded pending challenge — a token for a challenge that was
|
||||
// never recorded, or that is still pending, is "never seen" → invalid.
|
||||
if !savedCard {
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ch := m.pendingChallenges[sourceID]
|
||||
if ch == nil || ch.outcome != "approved" {
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
if m.ShouldFail {
|
||||
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
||||
@@ -381,20 +591,64 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror Square's SCA enforcement (opt-in toggle, off by default): a
|
||||
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
|
||||
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
|
||||
// must re-verify and re-tokenize, NOT retry the same request (the code is
|
||||
// in definitivePaymentCodes). A present verification token (e.g.
|
||||
// Mirror Square's SCA enforcement on NEW-CARD charges (opt-in toggle, off
|
||||
// by default): a cnon: charge without a 3DS/SCA verification token is
|
||||
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the
|
||||
// buyer must re-verify and re-tokenize, NOT retry the same request (the
|
||||
// code is in definitivePaymentCodes). A present verification token (e.g.
|
||||
// verify_mock_...) satisfies the gate exactly as production accepts a
|
||||
// Square-issued verification_token on the CreatePayment body.
|
||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") {
|
||||
if req.VerificationToken == "" {
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||
}
|
||||
}
|
||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror Square's SCA enforcement on SAVED-CARD (ccof:) charges — the
|
||||
// SCA-primary saved-card posture (opt-in toggle, off by default; placement
|
||||
// AFTER the customer_id gate above so a ccof charge without a customer is
|
||||
// still MISSING_REQUIRED_PARAMETER, never verification-required). A ccof:
|
||||
// charge without a 3DS/SCA verification token is rejected with the same
|
||||
// structured 400 CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and
|
||||
// a pending buyer-verification challenge is recorded for the card. A
|
||||
// subsequent charge WITH a verification token resolves the challenge (see
|
||||
// resolveVerificationToken). Grandfathered cards (GrandfatherSavedCard)
|
||||
// bypass the gate.
|
||||
if m.SimulateSavedCardVerificationRequired && strings.HasPrefix(req.SourceID, "ccof:") {
|
||||
if req.VerificationToken == "" {
|
||||
if m.grandfatheredCards[req.SourceID] {
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate bypassed: source %s is grandfathered", tokenPrefix(req.SourceID))
|
||||
} else {
|
||||
if m.ChallengeResult == "auto" {
|
||||
// "auto" config: the banking-app challenge resolves itself
|
||||
// as approved, so the next tokenized retry succeeds without
|
||||
// an explicit ApprovePendingVerification call.
|
||||
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: "approved"}
|
||||
} else {
|
||||
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: ""}
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate: source %s rejected without a verification token", tokenPrefix(req.SourceID))
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "saved card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,6 +1179,42 @@ func (m *MockClient) UsedSources() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// GrandfatherSavedCard marks a ccof: token exempt from the saved-card
|
||||
// verification gate (SimulateSavedCardVerificationRequired), so that card
|
||||
// charges without a verification token — mirroring a card Square has already
|
||||
// verified or holds a standing SCA exemption for.
|
||||
func (m *MockClient) GrandfatherSavedCard(ccofToken string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.grandfatheredCards[ccofToken] = true
|
||||
}
|
||||
|
||||
// ApprovePendingVerification marks the recorded buyer-verification challenge
|
||||
// for a saved card as approved (creating it if the gate never recorded one), so
|
||||
// a subsequent tokenized charge of that card succeeds.
|
||||
func (m *MockClient) ApprovePendingVerification(ccofToken string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if ch, ok := m.pendingChallenges[ccofToken]; ok {
|
||||
ch.outcome = "approved"
|
||||
return
|
||||
}
|
||||
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "approved"}
|
||||
}
|
||||
|
||||
// DenyPendingVerification marks the recorded buyer-verification challenge for
|
||||
// a saved card as denied, so a subsequent tokenized charge of that card is
|
||||
// rejected with VERIFICATION_TOKEN_INVALID (the token is consumed).
|
||||
func (m *MockClient) DenyPendingVerification(ccofToken string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if ch, ok := m.pendingChallenges[ccofToken]; ok {
|
||||
ch.outcome = "denied"
|
||||
return
|
||||
}
|
||||
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "denied"}
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||
|
||||
|
||||
@@ -2257,3 +2257,202 @@ func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T
|
||||
assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_SavedCardVerificationRequired locks the mock's SCA
|
||||
// enforcement on SAVED-CARD (ccof:) charges: with
|
||||
// SimulateSavedCardVerificationRequired=true, a ccof charge without a 3DS/SCA
|
||||
// verification token is rejected with a structured 400
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error), a
|
||||
// deterministic verify_mock_<prefix>_<amount>_ok token satisfies the gate, a
|
||||
// grandfathered card charges without a token, and with the toggle off (default)
|
||||
// no verification is required. Placement check: a ccof charge WITHOUT a customer
|
||||
// id must still be MISSING_REQUIRED_PARAMETER, never verification-required.
|
||||
func TestDevClient_CreatePayment_SavedCardVerificationRequired(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
|
||||
t.Run("ccof_without_verification_token_is_rejected", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-no-token",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
|
||||
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
|
||||
})
|
||||
|
||||
t.Run("verification_token_satisfies_gate", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-with-token",
|
||||
VerificationToken: "verify_mock_mock_5000_ok",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
assert.Equal(t, "ON_FILE", result.EntryMethod)
|
||||
})
|
||||
|
||||
t.Run("customer_id_gate_fires_before_verification_gate", func(t *testing.T) {
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "ccof:other_card", IdempotencyKey: "sca-ccof-no-customer",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err), "a ccof charge without a customer must stay MISSING_REQUIRED_PARAMETER, never verification-required")
|
||||
})
|
||||
|
||||
t.Run("grandfathered_card_charges_without_token", func(t *testing.T) {
|
||||
client.GrandfatherSavedCard(ccof)
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-grandfathered",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
})
|
||||
|
||||
t.Run("toggle_off_requires_no_verification", func(t *testing.T) {
|
||||
client.SimulateSavedCardVerificationRequired = false
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-default-off",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status, "with SimulateSavedCardVerificationRequired off (default), a ccof charge needs no verification token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_VerificationToken_OneTimeUse locks the mock's
|
||||
// one-time-use verification-token ledger: a verify_mock_* token is consumed on
|
||||
// its first successful charge, so a second charge with the SAME token (under a
|
||||
// DIFFERENT idempotency key) is rejected with a definitive 400
|
||||
// VERIFICATION_TOKEN_INVALID — mirroring real Square consuming a verification
|
||||
// token on use.
|
||||
func TestDevClient_CreatePayment_VerificationToken_OneTimeUse(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
const token = "verify_mock_mock_5000_ok"
|
||||
|
||||
first, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-1",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", first.Status)
|
||||
|
||||
client.mu.RLock()
|
||||
consumed := client.verifyTokens[token]
|
||||
client.mu.RUnlock()
|
||||
assert.True(t, consumed, "a successfully used verification token must be consumed")
|
||||
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-2",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.True(t, IsDefinitivePaymentError(err), "VERIFICATION_TOKEN_INVALID must classify as a definitive payment error")
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_VerificationToken_Denied locks the shared-state
|
||||
// challenge resolution: DenyPendingVerification marks the recorded challenge for
|
||||
// a saved card as denied, so a subsequent tokenized charge is rejected with
|
||||
// VERIFICATION_TOKEN_INVALID and the token is consumed (a second use of the same
|
||||
// token is also invalid).
|
||||
func TestDevClient_CreatePayment_VerificationToken_Denied(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
const token = "verify_mock_mock_5000_ok"
|
||||
|
||||
// The gate rejects the no-token charge and records a pending challenge.
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-gate",
|
||||
})
|
||||
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
|
||||
|
||||
// The buyer denies the challenge in the banking app.
|
||||
client.DenyPendingVerification(ccof)
|
||||
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
|
||||
// The denied token is consumed: a second use of the SAME token is also invalid.
|
||||
result2, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry-2",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result2)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_VerificationToken_AmountMismatch locks the
|
||||
// deterministic token's amount/source binding: a verify_mock_<prefix>_<amount>
|
||||
// token is bound to the card + amount it was issued for, so charging a DIFFERENT
|
||||
// amount (or a different card) with it is rejected with VERIFICATION_TOKEN_INVALID.
|
||||
func TestDevClient_CreatePayment_VerificationToken_AmountMismatch(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
|
||||
t.Run("token_for_wrong_amount_is_rejected", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "amount-mismatch",
|
||||
VerificationToken: "verify_mock_mock_9999_ok", // bound to £99.99, the charge is £50.00
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
})
|
||||
|
||||
t.Run("token_for_wrong_card_is_rejected", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "source-mismatch",
|
||||
VerificationToken: "verify_mock_other_5000_ok", // bound to a different card
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_SavedCardVerification_Grandfathered locks the
|
||||
// GrandfatherSavedCard exemption: marking a ccof token exempt lets it charge
|
||||
// without a verification token even while the saved-card SCA gate is on, while
|
||||
// a DIFFERENT (non-grandfathered) card stays gated.
|
||||
func TestDevClient_CreatePayment_SavedCardVerification_Grandfathered(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-before",
|
||||
})
|
||||
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "before grandfathering the card must be gated")
|
||||
|
||||
client.GrandfatherSavedCard(ccof)
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-after",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status, "a grandfathered card must charge without a verification token")
|
||||
|
||||
_, err = client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "grand-other",
|
||||
})
|
||||
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "a non-grandfathered card must stay gated")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border bg-card">
|
||||
@@ -793,10 +885,9 @@
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">
|
||||
This card may require bank app confirmation to complete. Ensure the customer has
|
||||
their phone ready.
|
||||
</p>
|
||||
<p class="text-xs text-amber-800">
|
||||
Your card issuer will ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -836,18 +927,32 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
twoFactor.missing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
{#if awaitingSCA}
|
||||
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
<p class="mt-3 text-sm font-medium text-gray-700">
|
||||
Waiting for customer to approve in their banking app…
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
The customer may need to approve this payment in their banking app
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
twoFactor.missing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
{/if}
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Gift card sales are processed through the till; retail items require manual recording for
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<p class="text-sm text-blue-800">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
<!-- DEV-ONLY mock card form — enabled only by VITE_SQUARE_ENVIRONMENT === 'mock'
|
||||
(never in production). The PAN lives only in local component state; only a
|
||||
cnon: token is ever returned. -->
|
||||
<script module lang="ts">
|
||||
/**
|
||||
* DEV-ONLY saved-card SCA challenge. SquareCardInput.tokenizeSavedCardWithVerification
|
||||
* dynamically imports this module and calls this export when the mock form is
|
||||
* active, so the saved-card (ccof) 3DS/SCA path runs end to end. The token is
|
||||
* deterministic and stateless — it encodes the card prefix, the bound amount and
|
||||
* the encoded outcome (`_ok`) — and the backend dev mock
|
||||
* (square_dev.go parseVerifyToken) parses the same shape back, so local-dev
|
||||
* mirrors production without shared state.
|
||||
*/
|
||||
export function tokenizeSavedCard(
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
_contact?: { givenName?: string; familyName?: string; email?: string }
|
||||
): Promise<{
|
||||
verificationToken: string | null;
|
||||
outcome: 'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
|
||||
}> {
|
||||
const stripped = squareCardId.startsWith('ccof:') ? squareCardId.slice(5) : squareCardId;
|
||||
const prefix = stripped.slice(0, 4) || 'test';
|
||||
return Promise.resolve({
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_ok`,
|
||||
outcome: 'verified'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import CardBrandIcon from './CardBrandIcon.svelte';
|
||||
|
||||
@@ -15,14 +42,46 @@
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
onReady = () => {}
|
||||
}: { disabled?: boolean; onReady?: (ready: boolean) => void } = $props();
|
||||
onReady = () => {},
|
||||
// Dev toggles for the mock 3DS/SCA challenge simulation:
|
||||
// simulateChallenge waits for the "Approve in banking app" button before
|
||||
// resolving; challengeResult is the deterministic outcome encoded into the
|
||||
// verification token (verify_mock_<prefix>_<amount>_ok|_deny).
|
||||
simulateChallenge = false,
|
||||
challengeResult = 'approve'
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onReady?: (ready: boolean) => void;
|
||||
simulateChallenge?: boolean;
|
||||
challengeResult?: 'approve' | 'deny';
|
||||
} = $props();
|
||||
|
||||
let cardNumber = $state('');
|
||||
let expiry = $state('');
|
||||
let cvc = $state('');
|
||||
let cardholderName = $state('');
|
||||
|
||||
// Mock challenge state: while awaitingChallenge is true, the form shows the
|
||||
// "Approve in banking app" panel and the in-flight tokenize call waits.
|
||||
let awaitingChallenge = $state(false);
|
||||
let challengeResolver: ((outcome: 'approve' | 'deny') => void) | null = null;
|
||||
|
||||
async function runChallenge(): Promise<'approve' | 'deny'> {
|
||||
if (!simulateChallenge) return challengeResult ?? 'approve';
|
||||
awaitingChallenge = true;
|
||||
return await new Promise<'approve' | 'deny'>((resolve) => {
|
||||
challengeResolver = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/** Dev toggle: resolves a simulated banking-app challenge with the configured outcome. */
|
||||
export function resolveMockChallenge(): void {
|
||||
const resolver = challengeResolver;
|
||||
challengeResolver = null;
|
||||
awaitingChallenge = false;
|
||||
resolver?.(challengeResult ?? 'approve');
|
||||
}
|
||||
|
||||
let cardNumberTouched = $state(false);
|
||||
let expiryTouched = $state(false);
|
||||
let cvcTouched = $state(false);
|
||||
@@ -169,8 +228,12 @@
|
||||
/**
|
||||
* Mirrors SquareCardInput.tokenizeWithVerification() so the dev mock
|
||||
* exercises the full SCA path (card nonce + verification token) end to end.
|
||||
* The fake verification token is deterministic and the backend mock accepts
|
||||
* it alongside the cnon: nonce.
|
||||
* The fake verification token is deterministic — it encodes the card prefix,
|
||||
* the bound amount and the challenge outcome (verify_mock_<prefix>_<amount>_ok|_deny)
|
||||
* — and the backend dev mock parses it back (square_dev.go
|
||||
* parseVerifyToken), so the frontend and backend agree on the outcome without
|
||||
* shared state. The outcome is challengeResult (default approve), optionally
|
||||
* gated behind the mock "Approve in banking app" panel via simulateChallenge.
|
||||
*
|
||||
* @param amount The amount that WILL be charged, in pence — same pence input
|
||||
* contract as the real form. The real form serializes this to
|
||||
@@ -190,13 +253,36 @@
|
||||
if (!complete) {
|
||||
throw new Error('Card details are incomplete');
|
||||
}
|
||||
const outcome = await runChallenge();
|
||||
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
|
||||
const prefix = digits.slice(0, 4) || 'test';
|
||||
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
|
||||
return Promise.resolve({
|
||||
nonce: token,
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-ONLY saved-card SCA verification token for a ccof charge — the
|
||||
* deterministic counterpart of Square's buyer-verification flow for a saved
|
||||
* card. SquareCardInput.tokenizeSavedCardWithVerification delegates to the
|
||||
* module-level tokenizeSavedCard for the same flow; this instance method is
|
||||
* available for direct use and honours the challenge simulation toggles. The
|
||||
* token encodes the card prefix (first 4 chars after `ccof:`), the bound
|
||||
* amount (pence) and the challenge outcome:
|
||||
* verify_mock_<prefix>_<amount>_ok|_deny.
|
||||
*
|
||||
* @param amount The amount the saved-card charge will be for, in pence.
|
||||
* @param ccofToken The saved card's ccof: token (the charge source).
|
||||
*/
|
||||
export async function verifySavedCard(amount: number, ccofToken: string): Promise<string> {
|
||||
const outcome = await runChallenge();
|
||||
const stripped = ccofToken.startsWith('ccof:') ? ccofToken.slice(5) : ccofToken;
|
||||
const prefix = stripped.slice(0, 4) || 'test';
|
||||
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
|
||||
return `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -282,4 +368,21 @@
|
||||
class={inputClasses}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if awaitingChallenge}
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
|
||||
<p class="text-sm font-medium text-amber-800">Waiting for approval in the banking app…</p>
|
||||
<p class="mt-1 text-xs text-amber-700">
|
||||
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome
|
||||
({challengeResult === 'deny' ? 'deny' : 'approve'}).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={resolveMockChallenge}
|
||||
class="mt-2 rounded-md bg-amber-600 px-3 py-1 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
Approve in banking app
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
@@ -1526,8 +1650,7 @@
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1563,12 +1686,19 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'saved-card-processing'}
|
||||
{:else if status === 'saved-card-processing' || status === 'saved-card-waiting-sca'}
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<div
|
||||
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
|
||||
{#if status === 'saved-card-waiting-sca'}
|
||||
<p class="text-lg font-medium text-gray-700">
|
||||
Waiting for customer to approve in their banking app…
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500">The customer may need to approve this payment in their banking app</p>
|
||||
{:else}
|
||||
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if status === 'error' && error}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,164 @@
|
||||
<script module lang="ts">
|
||||
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
|
||||
|
||||
/**
|
||||
* Billing contact passed to Square's tokenize() verificationDetails for
|
||||
* Strong Customer Authentication (SCA). Only fields we already hold are
|
||||
* included; omit the object entirely when nothing is available.
|
||||
*/
|
||||
export interface SquareVerificationContact {
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** Result of a tokenize-with-verification call. */
|
||||
export interface TokenizeWithVerificationResult {
|
||||
nonce: string;
|
||||
verificationToken: string | null;
|
||||
}
|
||||
|
||||
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to
|
||||
* decide whether to retry with the fresh verification token, surface a
|
||||
* retryable failure, or fall back to the 2FA gate. */
|
||||
export type SavedCardVerificationOutcome =
|
||||
| 'verified'
|
||||
| 'challenge-cancelled'
|
||||
| 'sca-unavailable'
|
||||
| 'sca-failed';
|
||||
|
||||
/** Result of tokenizeSavedCardWithVerification. */
|
||||
export interface SavedCardVerificationResult {
|
||||
verificationToken: string | null;
|
||||
outcome: SavedCardVerificationOutcome;
|
||||
}
|
||||
|
||||
/** Square Web Payments `card.tokenize()` verification details shape. */
|
||||
interface SquareVerificationDetails {
|
||||
amount: string;
|
||||
billingContact?: SquareVerificationContact;
|
||||
intent: string;
|
||||
currencyCode: string;
|
||||
customerInitiated: boolean;
|
||||
sellerKeyedIn: boolean;
|
||||
}
|
||||
|
||||
/** Square Web Payments `card.tokenize()` result shape (verification path). */
|
||||
interface SquareTokenizeResult {
|
||||
status: string;
|
||||
token?: string;
|
||||
verificationResult?: { token?: string };
|
||||
errors?: Array<{ message?: string; code?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the SCA challenge for a SAVED card (ccof) whose charge Square refused
|
||||
* with a "verification required" signal. Square's card-on-file flow binds
|
||||
* buyer verification to the exact charge amount, so the challenge must use
|
||||
* the same major-units amount as the pending charge.
|
||||
*
|
||||
* Returns a verification token (retry the SAME charge with it) plus an
|
||||
* outcome the surfaces map to UX: 'verified' → retry with the token;
|
||||
* 'challenge-cancelled' / 'sca-failed' → retryable, keep the pending row;
|
||||
* 'sca-unavailable' → no challenge could run, fall back to the 2FA gate.
|
||||
*/
|
||||
export async function tokenizeSavedCardWithVerification(
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
contact?: SquareVerificationContact
|
||||
): Promise<SavedCardVerificationResult> {
|
||||
if (isSquareMock()) {
|
||||
// DEV-ONLY mock: the mock agent extends MockCardForm with a saved-card
|
||||
// SCA method. Use it when present (so the mock exercises the same
|
||||
// challenge path), otherwise fall back to a deterministic fake token
|
||||
// the backend dev mock accepts.
|
||||
try {
|
||||
const mockModule = (await import('./MockCardForm.svelte')) as {
|
||||
tokenizeSavedCard?: (
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
contact?: SquareVerificationContact
|
||||
) => Promise<SavedCardVerificationResult>;
|
||||
default?: {
|
||||
tokenizeSavedCard?: (
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
contact?: SquareVerificationContact
|
||||
) => Promise<SavedCardVerificationResult>;
|
||||
};
|
||||
};
|
||||
const mockTokenize = mockModule.tokenizeSavedCard ?? mockModule.default?.tokenizeSavedCard;
|
||||
if (mockTokenize) {
|
||||
return await mockTokenize(amount, squareCardId, contact);
|
||||
}
|
||||
} catch {
|
||||
// Dynamic import failure → fall through to the deterministic token.
|
||||
}
|
||||
const prefix = squareCardId.replace(/^ccof:/, '').slice(0, 4) || 'test';
|
||||
return {
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`,
|
||||
outcome: 'verified'
|
||||
};
|
||||
}
|
||||
|
||||
const payments = (await getSquarePayments()) as {
|
||||
card: () => Promise<{
|
||||
tokenize: (
|
||||
verificationDetails: SquareVerificationDetails,
|
||||
cardId: string
|
||||
) => Promise<SquareTokenizeResult>;
|
||||
}>;
|
||||
};
|
||||
const card = await payments.card();
|
||||
|
||||
// Same verification-details shape as tokenizeWithVerification: a
|
||||
// MAJOR-units decimal amount string (W3C valid-decimal-monetary-value)
|
||||
// bound to the exact pending charge, intent CHARGE (the card is already
|
||||
// stored — nothing new to save), GBP, customer-initiated, not seller-keyed.
|
||||
const verificationDetails: SquareVerificationDetails = {
|
||||
amount: (amount / 100).toFixed(2),
|
||||
intent: 'CHARGE',
|
||||
currencyCode: 'GBP',
|
||||
customerInitiated: true,
|
||||
sellerKeyedIn: false
|
||||
};
|
||||
if (contact && (contact.givenName || contact.familyName || contact.email)) {
|
||||
verificationDetails.billingContact = contact;
|
||||
}
|
||||
|
||||
let result: SquareTokenizeResult;
|
||||
try {
|
||||
result = await card.tokenize(verificationDetails, squareCardId);
|
||||
} catch (err) {
|
||||
// A thrown error (SDK load failure, network) means no challenge could
|
||||
// run — SCA is unavailable for this charge, fall back to the 2FA gate.
|
||||
console.error('Saved-card SCA tokenization failed:', err);
|
||||
return { verificationToken: null, outcome: 'sca-unavailable' };
|
||||
}
|
||||
|
||||
if (result.status === 'OK' && result.verificationResult?.token) {
|
||||
return { verificationToken: result.verificationResult.token, outcome: 'verified' };
|
||||
}
|
||||
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
|
||||
const errorText =
|
||||
codes.join(' ') +
|
||||
' ' +
|
||||
(result.errors ?? [])
|
||||
.map((e) => e.message ?? '')
|
||||
.join(' ');
|
||||
// VERIFICATION_CHALLENGE / cancel-coded errors mean the challenge was
|
||||
// shown but not completed — the buyer can retry, so this is retryable.
|
||||
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
|
||||
return { verificationToken: null, outcome: 'challenge-cancelled' };
|
||||
}
|
||||
// The card/issuer cannot complete buyer verification at all — SCA is not
|
||||
// available for this charge, so the surface falls back to the 2FA gate.
|
||||
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
|
||||
return { verificationToken: null, outcome: 'sca-unavailable' };
|
||||
}
|
||||
return { verificationToken: null, outcome: 'sca-failed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The real card form is a CROSS-ORIGIN iframe (web.squarecdn.com) that does
|
||||
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
|
||||
@@ -43,34 +203,6 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||
import type MockCardForm from './MockCardForm.svelte';
|
||||
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
|
||||
|
||||
/**
|
||||
* Billing contact passed to Square's tokenize() verificationDetails for
|
||||
* Strong Customer Authentication (SCA). Only fields we already hold are
|
||||
* included; omit the object entirely when nothing is available.
|
||||
*/
|
||||
export interface SquareVerificationContact {
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** Result of a tokenize-with-verification call. */
|
||||
export interface TokenizeWithVerificationResult {
|
||||
nonce: string;
|
||||
verificationToken: string | null;
|
||||
}
|
||||
|
||||
/** Square Web Payments `card.tokenize()` verification details shape. */
|
||||
interface SquareVerificationDetails {
|
||||
amount: string;
|
||||
billingContact?: SquareVerificationContact;
|
||||
intent: string;
|
||||
currencyCode: string;
|
||||
customerInitiated: boolean;
|
||||
sellerKeyedIn: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Disable the form while a payment is processing. */
|
||||
|
||||
@@ -18,11 +18,18 @@
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
|
||||
// Shared tip-payment UI used by /tip, /pay-tip/[id] and the account
|
||||
// booking-modal tip dialog. The routes resolve the booking (most-recent past
|
||||
@@ -113,9 +120,14 @@
|
||||
// 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 && (selectedCardId !== '' || saveCard)
|
||||
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
|
||||
});
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
@@ -313,7 +325,17 @@
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
// 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 (same idempotency key).
|
||||
if (selectedCardId && isVerificationRequiredSignal(responseStatus, errorText)) {
|
||||
await runTipSCA(amountInPence, selectedCardId);
|
||||
return;
|
||||
}
|
||||
const err = new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
(err as { bodyText?: string }).bodyText = errorText;
|
||||
throw err;
|
||||
}
|
||||
|
||||
paymentState = 'success';
|
||||
@@ -332,12 +354,19 @@
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
// 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.
|
||||
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
|
||||
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
// 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.
|
||||
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
|
||||
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
|
||||
if (
|
||||
scaVerificationRequired ||
|
||||
isSavedCardVerificationRequired(responseStatus, usedSavedCard)
|
||||
) {
|
||||
errorMessage = 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
|
||||
@@ -361,6 +390,91 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when the tip charge came back 402
|
||||
* with the verification-required signal. 'verified' retries the SAME tip
|
||||
* 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 runTipSCA(amountInPence: number, cardId: string) {
|
||||
const squareCardId = savedCards.find((c) => c.id === cardId)?.square_card_id;
|
||||
paymentState = 'processing';
|
||||
if (!squareCardId) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
paymentState = 'error';
|
||||
toast.error(VERIFICATION_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(amountInPence, squareCardId, {
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
paymentState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
||||
return;
|
||||
}
|
||||
lastSCAOutcome = result.outcome;
|
||||
if (result.outcome === 'verified') {
|
||||
try {
|
||||
const retryBody: Record<string, unknown> = {
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
card_id: cardId,
|
||||
verification_token: result.verificationToken,
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||
};
|
||||
const retry = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(retryBody)
|
||||
})
|
||||
);
|
||||
if (!retry.ok) {
|
||||
const retryText = await retry.text();
|
||||
const retryMsg = extractErrorMessage(retryText) || 'Payment failed';
|
||||
if (isTwoFactorVerificationGateFailure(retry.status, retryMsg)) twoFactor.reveal = true;
|
||||
paymentState = 'error';
|
||||
toast.error(retryMsg);
|
||||
return;
|
||||
}
|
||||
paymentState = 'success';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
toast.success('Thank you for your tip!');
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : 'Payment failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
twoFactor.reveal = true;
|
||||
paymentState = 'error';
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
function retryPayment() {
|
||||
paymentState = 'idle';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<!--
|
||||
TwoFactorCodeInput.svelte — B6/B10 2FA verification-code input for saved-card
|
||||
charges. The backend's requireTwoFactorForCardAccess gate requires the CARD
|
||||
OWNER's current one-time code on every saved-card charge in an enforced
|
||||
environment; this input collects it so the charge body carries
|
||||
charges. With Square 3DS SCA now the PRIMARY authorisation for saved-card
|
||||
(ccof) charges, this input is the BACKUP path: it shows only when the charge
|
||||
hits the backend's requireTwoFactorForCardAccess gate and SCA couldn't
|
||||
authorise (sca-unavailable), or a charge 403/SCA-failure has revealed it. The
|
||||
backend requires the CARD OWNER's current one-time code on every 2FA-gated
|
||||
saved-card charge; this input collects it so the charge body carries
|
||||
`verification_code`. Shared by the customer booking, tip, admin booking and
|
||||
till saved-card surfaces so the field name, hint copy and the
|
||||
enabled/not-enabled presentation can't drift between them.
|
||||
@@ -47,8 +50,7 @@
|
||||
class="mt-1 font-mono tracking-widest"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
A current 2FA verification code is required for this saved-card charge. Ask the customer
|
||||
for their code, or retrieve it from the server log.
|
||||
Your bank doesn't support in-app approval — enter the code sent to you / your phone.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -23,10 +23,17 @@
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -58,9 +65,14 @@
|
||||
// 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 && (selectedCardId !== '' || saveCard)
|
||||
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
|
||||
});
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||
@@ -540,7 +552,20 @@
|
||||
status = 'idle';
|
||||
return;
|
||||
}
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to initiate 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 and retry with the fresh token (same idempotency key,
|
||||
// which stays cached) instead of surfacing a dead-end decline. A
|
||||
// token-carrying retry is never re-intercepted — the backend skips
|
||||
// the 2FA gate when a verification_token is present.
|
||||
if (cardId && !verificationToken && isVerificationRequiredSignal(responseStatus, errData)) {
|
||||
await runSavedCardSCA(paymentType, amountPence, cardId, confirmOverflowTip);
|
||||
return;
|
||||
}
|
||||
const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
|
||||
(err as { bodyText?: string }).bodyText = errData;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -581,12 +606,16 @@
|
||||
status = 'error';
|
||||
overflowConfirm = null;
|
||||
let msg = _err instanceof Error ? _err.message : 'Payment declined';
|
||||
// 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.
|
||||
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
|
||||
if (verificationFailure) msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
// A 402 with the structured verification-required signal (or the
|
||||
// dev/mock text parity) surfaces the SCA-first guidance; the legacy
|
||||
// saved-card verification check is the fallback for generic 402s.
|
||||
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
|
||||
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
|
||||
const verificationFailure =
|
||||
scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, !!cardId);
|
||||
if (verificationFailure) {
|
||||
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.
|
||||
@@ -607,6 +636,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when the charge came back 402 with
|
||||
* the verification-required signal. Shows the 3DS challenge (customer
|
||||
* approves in their banking app), then:
|
||||
* - '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 and
|
||||
* reveals the code input so the charge can be retried with a code.
|
||||
*/
|
||||
async function runSavedCardSCA(
|
||||
paymentType: string,
|
||||
amountPence: number,
|
||||
cardId: string,
|
||||
confirmOverflowTip: boolean
|
||||
) {
|
||||
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
|
||||
status = 'processing';
|
||||
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(amountPence, squareCardId, {
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.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') {
|
||||
await submitBookingPayment(
|
||||
paymentType,
|
||||
amountPence,
|
||||
cardId,
|
||||
undefined,
|
||||
result.verificationToken ?? undefined,
|
||||
confirmOverflowTip
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
// Confirm the overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
|
||||
// both pre-start and post-start overflows (B12).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
campaignDiscountPence,
|
||||
canSaveCardsForRole,
|
||||
@@ -12,8 +13,10 @@ import {
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry
|
||||
} from './square';
|
||||
import type * as SquareModule from './square';
|
||||
@@ -219,6 +222,81 @@ describe('payment failure classification', () => {
|
||||
expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0);
|
||||
expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification');
|
||||
});
|
||||
|
||||
it('VERIFICATION_REQUIRED_MESSAGE is non-empty and mentions verification', () => {
|
||||
expect(VERIFICATION_REQUIRED_MESSAGE.length).toBeGreaterThan(0);
|
||||
expect(VERIFICATION_REQUIRED_MESSAGE.toLowerCase()).toContain('verification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVerificationRequiredSignal', () => {
|
||||
it('matches a 402 JSON body carrying the verification_required code', () => {
|
||||
const body = JSON.stringify({
|
||||
error: 'Saved card charge requires buyer verification',
|
||||
code: 'verification_required'
|
||||
});
|
||||
expect(isVerificationRequiredSignal(402, body)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
|
||||
expect(
|
||||
isVerificationRequiredSignal(402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the plain "verification required" phrasing', () => {
|
||||
expect(isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a 402 body with a different code', () => {
|
||||
expect(isVerificationRequiredSignal(402, JSON.stringify({ error: 'Declined', code: 'card_declined' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a 402 body with only error text and no code', () => {
|
||||
expect(isVerificationRequiredSignal(402, 'Payment failed')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a non-JSON body that does not mention verification', () => {
|
||||
expect(isVerificationRequiredSignal(402, 'Payment declined')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for an empty body', () => {
|
||||
expect(isVerificationRequiredSignal(402, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for any non-402 status even with the code present', () => {
|
||||
expect(isVerificationRequiredSignal(503, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isVerificationRequiredSignal(400, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isVerificationRequiredSignal(200, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is false when the code is not an exact match (guards against prefix drift)', () => {
|
||||
expect(
|
||||
isVerificationRequiredSignal(402, JSON.stringify({ error: 'x', code: 'verification_required_extra' }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldFallbackTo2FA', () => {
|
||||
it.each([
|
||||
['sca-unavailable', true],
|
||||
['verified', false],
|
||||
['challenge-cancelled', false],
|
||||
['sca-failed', false],
|
||||
['', false]
|
||||
])('outcome %s → %s', (outcome, expected) => {
|
||||
expect(shouldFallbackTo2FA(outcome)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTwoFactorVerificationGateFailure', () => {
|
||||
|
||||
@@ -125,6 +125,59 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b
|
||||
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine-readable code the backend returns on a 402 when a saved-card (ccof)
|
||||
* charge requires Strong Customer Authentication and no `verification_token`
|
||||
* was supplied. The saved-card charge path returns a JSON body of the form
|
||||
* `{"error": "...", "code": "verification_required"}` — the shared error-text
|
||||
* extractor only surfaces the human-readable message, so this checks the raw
|
||||
* body for the code field exactly like isOverflowTipConfirmationRequired does.
|
||||
*/
|
||||
const VERIFICATION_REQUIRED_CODE = 'verification_required';
|
||||
|
||||
/**
|
||||
* True when a charge response is Square's SCA "verification required" signal:
|
||||
* HTTP 402 with a JSON body `{"error": "...", "code": "verification_required"}`
|
||||
* (the shape the backend now returns on a saved-card charge Square refuses for
|
||||
* want of a verification token), OR the raw body text matching the known
|
||||
* verification-required strings (`CARD_DECLINED_VERIFICATION_REQUIRED` and the
|
||||
* plain-text "verification required" phrasing, for dev/mock parity where the
|
||||
* backend mock may not emit the structured code). The caller runs the
|
||||
* client-side 3DS challenge (tokenizeSavedCardWithVerification) and retries
|
||||
* with the fresh token instead of surfacing a dead-end decline. Returns false
|
||||
* for any non-402 status, non-matching text, or non-JSON body.
|
||||
*/
|
||||
export function isVerificationRequiredSignal(status: number, bodyText: string): boolean {
|
||||
if (status !== PAYMENT_DEFINITIVE_STATUS) return false;
|
||||
const trimmed = bodyText.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/verification required|CARD_DECLINED_VERIFICATION_REQUIRED/i.test(trimmed)) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
return parsed?.code === VERIFICATION_REQUIRED_CODE;
|
||||
} catch {
|
||||
// Not JSON — cannot be the structured verification-required body
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when an SCA attempt reported that buyer verification is genuinely
|
||||
* unavailable (no 3DS challenge could be run), so the surface falls back to the
|
||||
* homegrown 2FA gate. Every other outcome — verified, a cancelled challenge, or
|
||||
* a hard SCA failure — keeps SCA as the primary path (a cancelled/failed
|
||||
* challenge is retryable, and SCA should be attempted again).
|
||||
*/
|
||||
export function shouldFallbackTo2FA(scaOutcome: string): boolean {
|
||||
return scaOutcome === 'sca-unavailable';
|
||||
}
|
||||
|
||||
/** User-facing guidance for a saved-card charge whose issuer requires Strong
|
||||
* Customer Authentication: the buyer must approve the payment in their banking
|
||||
* app (the client-side tokenizeSavedCardWithVerification challenge does this). */
|
||||
export const VERIFICATION_REQUIRED_MESSAGE =
|
||||
'Your card issuer requires verification. Approve this payment in your banking app.';
|
||||
|
||||
/** User-facing guidance for a saved-card charge the issuer requires
|
||||
* verification to complete. Retrying the same saved card is pointless — the
|
||||
* buyer must pay with a new card or re-add their card. */
|
||||
|
||||
@@ -8,6 +8,10 @@ export type SavedCard = {
|
||||
exp_year: number;
|
||||
cardholder_name?: string;
|
||||
is_default: boolean;
|
||||
// Square's card-on-file id (`ccof:...`), returned by the payment-methods
|
||||
// endpoints. Required to run the saved-card SCA challenge
|
||||
// (tokenizeSavedCardWithVerification).
|
||||
square_card_id: string;
|
||||
};
|
||||
|
||||
function createSavedCardsStore() {
|
||||
|
||||
@@ -24,6 +24,13 @@ import { requestNewTwoFactorCode } from '$lib/square/square';
|
||||
* card is selected, or a new card is being saved for reuse.
|
||||
* The surface passes its exact gate expression so each
|
||||
* surface's gate semantics are preserved verbatim.
|
||||
* - `scaAvailable()` — whether Square Strong Customer Authentication is the
|
||||
* active authorisation for this charge. Defaults to true
|
||||
* (SCA primary). When the last SCA attempt reported the
|
||||
* challenge is genuinely unavailable ('sca-unavailable'),
|
||||
* the surface passes `() => !shouldFallbackTo2FA(...)` so
|
||||
* the code input surfaces as the 2FA-BACKUP path — it shows
|
||||
* without a 403 self-heal because SCA can't authorise.
|
||||
* - `mint()` — optional; the code-request call. Customer surfaces omit
|
||||
* it (defaults to the session-scoped /api/user/2fa/code:
|
||||
* session user == card owner). Admin surfaces MUST pass
|
||||
@@ -36,6 +43,7 @@ export function useTwoFactorCodeForSavedCard(options: {
|
||||
enabled: () => boolean;
|
||||
gateActive: () => boolean;
|
||||
mint?: () => ReturnType<typeof requestNewTwoFactorCode>;
|
||||
scaAvailable?: () => boolean;
|
||||
}) {
|
||||
// Kept populated across retries so an invalid/expired code can be corrected
|
||||
// without re-typing it.
|
||||
@@ -48,8 +56,10 @@ export function useTwoFactorCodeForSavedCard(options: {
|
||||
let requesting = $state(false);
|
||||
|
||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||
// gate: charging a saved card OR saving the new card for reuse.
|
||||
const showInput = $derived(reveal || options.gateActive());
|
||||
// gate AND SCA isn't available to authorise instead (2FA is the BACKUP, not
|
||||
// the default), OR a failure has revealed it explicitly.
|
||||
const scaAvailable = options.scaAvailable ?? (() => true);
|
||||
const showInput = $derived(reveal || (options.gateActive() && !scaAvailable()));
|
||||
const missing = $derived(showInput && options.enabled() && code.trim() === '');
|
||||
|
||||
async function requestNewCode() {
|
||||
|
||||
@@ -15,9 +15,16 @@
|
||||
isSavedCardVerificationRequired,
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
shouldFallbackTo2FA,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
@@ -257,9 +264,14 @@
|
||||
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
// 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 buyLastSCAOutcome = $state('');
|
||||
const buyTwoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => buyTwoFactorEnabled,
|
||||
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard)
|
||||
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard),
|
||||
scaAvailable: () => !shouldFallbackTo2FA(buyLastSCAOutcome)
|
||||
});
|
||||
|
||||
// Client-side mirror of the £500/day online purchase cap. The backend is
|
||||
@@ -507,16 +519,28 @@
|
||||
// SCA check needs it, and text() can only be read once.
|
||||
const status = res.status;
|
||||
const errText = await res.text();
|
||||
// A saved-card (ccof) charge skips the client-side SCA step, so a
|
||||
// definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification — surface the fix instead of the generic
|
||||
// backend text.
|
||||
const verificationRequired = isSavedCardVerificationRequired(status, !!buySelectedCard);
|
||||
// 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 (same idempotency key).
|
||||
if (buySelectedCard && isVerificationRequiredSignal(status, errText)) {
|
||||
await runBuySavedCardSCA();
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
const scaVerificationRequired = isVerificationRequiredSignal(status, errText);
|
||||
const verificationRequired =
|
||||
scaVerificationRequired || isSavedCardVerificationRequired(status, !!buySelectedCard);
|
||||
// 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.
|
||||
const buyErrMsg = verificationRequired
|
||||
? SAVED_CARD_VERIFICATION_MESSAGE
|
||||
? scaVerificationRequired
|
||||
? VERIFICATION_REQUIRED_MESSAGE
|
||||
: SAVED_CARD_VERIFICATION_MESSAGE
|
||||
: extractErrorMessage(errText) || 'Failed to purchase gift card';
|
||||
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
|
||||
buyTwoFactor.reveal = true;
|
||||
@@ -549,6 +573,87 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when the gift-card buy came back 402
|
||||
* with the verification-required signal. 'verified' retries the SAME buy
|
||||
* 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 runBuySavedCardSCA() {
|
||||
const squareCardId = savedCardsStore.cards.find((c) => c.id === buySelectedCard)?.square_card_id;
|
||||
if (!squareCardId) {
|
||||
buyLastSCAOutcome = 'sca-unavailable';
|
||||
buyTwoFactor.reveal = true;
|
||||
toast.error(VERIFICATION_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(buyAmount * 100, squareCardId, {
|
||||
givenName: userData?.firstName,
|
||||
familyName: userData?.lastName,
|
||||
email: userData?.email
|
||||
});
|
||||
} catch (err) {
|
||||
buyLastSCAOutcome = 'sca-unavailable';
|
||||
buyTwoFactor.reveal = true;
|
||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
||||
return;
|
||||
}
|
||||
buyLastSCAOutcome = result.outcome;
|
||||
if (result.outcome === 'verified') {
|
||||
try {
|
||||
const retry = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/user/giftcards/buy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: buyAmount * 100,
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
...(buySelectedCard ? { card_id: buySelectedCard } : {}),
|
||||
verification_token: result.verificationToken,
|
||||
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
})
|
||||
);
|
||||
if (retry.ok) {
|
||||
const data = await retry.json();
|
||||
toast.success('Gift card purchased successfully!');
|
||||
purchaseResultCode = data.code;
|
||||
buyDailyTotal += buyAmount;
|
||||
buyIdempotencyKey = '';
|
||||
buyKeyedAmount = 0;
|
||||
buyKeyedCard = '';
|
||||
buyNonce = '';
|
||||
buyVerificationToken = '';
|
||||
buyTokenAmount = 0;
|
||||
buyTokenizedAt = 0;
|
||||
buyTokenizedForSaveCard = false;
|
||||
buyTwoFactor.setCode('');
|
||||
buyTwoFactor.reveal = false;
|
||||
await fetchGiftCardBalance();
|
||||
return;
|
||||
}
|
||||
const retryText = await retry.text();
|
||||
toast.error(extractErrorMessage(retryText) || 'Failed to purchase gift card');
|
||||
} catch (err) {
|
||||
console.error('gift card SCA retry error:', err);
|
||||
toast.error('Network error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
buyTwoFactor.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."
|
||||
);
|
||||
}
|
||||
|
||||
function formatAndPreserveCursor(
|
||||
input: HTMLInputElement,
|
||||
formatter: (val: string) => string,
|
||||
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"collapse-filter": true,
|
||||
"search": "",
|
||||
"showTags": false,
|
||||
"showAttachments": false,
|
||||
"hideUnresolved": false,
|
||||
"showOrphans": true,
|
||||
"collapse-color-groups": true,
|
||||
"colorGroups": [],
|
||||
"collapse-display": true,
|
||||
"showArrow": false,
|
||||
"textFadeMultiplier": 0,
|
||||
"nodeSizeMultiplier": 1,
|
||||
"lineSizeMultiplier": 1,
|
||||
"collapse-forces": true,
|
||||
"centerStrength": 0.518713248970312,
|
||||
"repelStrength": 10,
|
||||
"linkStrength": 1,
|
||||
"linkDistance": 250,
|
||||
"scale": 1,
|
||||
"close": false
|
||||
}
|
||||
Vendored
+22
-17
@@ -4,21 +4,21 @@
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "4dec2140f7c65f3e",
|
||||
"id": "d45436e02729bff7",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "6776d739ee18449c",
|
||||
"id": "7b4ed20d72674471",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Crussell/User Manual.md",
|
||||
"file": "Crussell/payments and money processes.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "User Manual"
|
||||
"title": "payments and money processes"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -41,7 +41,9 @@
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false
|
||||
"autoReveal": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Files"
|
||||
@@ -78,7 +80,8 @@
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"right": {
|
||||
"id": "2750d7726f904ef3",
|
||||
@@ -94,7 +97,7 @@
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "Crussell/Future Work - Gap Backlog.md",
|
||||
"file": "Crussell/payments and money processes.md",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
@@ -104,7 +107,7 @@
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Backlinks for Future Work - Gap Backlog"
|
||||
"title": "Backlinks"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -113,12 +116,12 @@
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "Crussell/Crussell Nails.md",
|
||||
"file": "Crussell/payments and money processes.md",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Outgoing links from Crussell Nails"
|
||||
"title": "Outgoing links"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -142,16 +145,17 @@
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "Crussell/Crussell Nails.md",
|
||||
"file": "Crussell/payments and money processes.md",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline of Crussell Nails"
|
||||
"title": "Outline"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"currentTab": 3
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
@@ -169,19 +173,20 @@
|
||||
"bases:Create new base": false
|
||||
}
|
||||
},
|
||||
"active": "6776d739ee18449c",
|
||||
"active": "7b4ed20d72674471",
|
||||
"lastOpenFiles": [
|
||||
"Untitled.canvas",
|
||||
"Crussell/payments and money processes.md",
|
||||
"Crussell/Overview.md",
|
||||
"Crussell/User Manual.md",
|
||||
"Crussell/Future Work - Gap Backlog.md",
|
||||
"Crussell/Technical Manual.md",
|
||||
"Crussell/Loyalty & Discount System Reference.md",
|
||||
"Crussell/Overview.md",
|
||||
"Crussell/User Manual.md",
|
||||
"Crussell/Admin Manual.md",
|
||||
"Crussell/Test Implementation Plan.md",
|
||||
"Crussell/Crussell Nails.md",
|
||||
"Crussell/Backend/bookings.md",
|
||||
"Untitled.base",
|
||||
"Untitled.canvas",
|
||||
"Express.js Cheat Sheet.md"
|
||||
]
|
||||
}
|
||||
@@ -158,16 +158,16 @@ Anonymous rate cap: max 50 reservations per IP in 10 minutes.
|
||||
|
||||
## 2. Payments
|
||||
|
||||
Multi-method payment system accepting Square (card terminal & online), cash, gift cards, and saved cards. Supports deposits, full/partial payments, tips on completed bookings, and refunds with notice-period tiers.
|
||||
Multi-method payment system accepting Square (card terminal & online), cash, gift cards, and saved cards. Supports deposits, full/partial payments, tips on completed bookings, and refunds with notice-period tiers. Online card payments — new **or saved** — are authenticated by Square PSD2 SCA (buyer verification / approve-in-app) as the primary authorisation, with the homegrown 2FA code gate as the backup when a customer's bank cannot run SCA ([[#2.11 SCA & the 2FA Backup]]).
|
||||
|
||||
**Related:** [[Booking System|1. Booking System]] (deposits), [[Gift Cards|4. Gift Cards]] (pay by gift card), [[Admin Dashboard|5. Admin Dashboard]] (till purchases)
|
||||
|
||||
### 2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK)
|
||||
**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. Saving a card also provisions a Square customer profile (P14), reused for subsequent saves. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`), which renders a plain HTML card form and mints the same `cnon:` tokens the backend dev mock accepts — a full as-if-live walkthrough with zero credentials; without credentials or mock mode, new-card entry is gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips.
|
||||
**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. Every online charge is authenticated by Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`): the Web Payments SDK returns a **verification token**, the backend validates and passes it through to Square, and the customer approves in their banking app. This applies to saved-card (customer-initiated, CIT) charges as well as new cards — the charge is marked `customer_details.customer_initiated=true` so Square classifies it for SCA and liability shift. If the buyer cannot be verified, Square declines with `CARD_DECLINED_VERIFICATION_REQUIRED` (a definitive error — the buyer must re-verify; the dev mock mirrors it via its verification toggle). When a customer's bank cannot run SCA, the homegrown **2FA code gate is the backup authorisation** ([[#2.11 SCA & the 2FA Backup]]). Saving a card also provisions a Square customer profile (P14), reused for subsequent saves. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`), which renders a plain HTML card form and mints the same `cnon:` tokens the backend dev mock accepts — a full as-if-live walkthrough with zero credentials; without credentials or mock mode, new-card entry is gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips.
|
||||
|
||||
**Layman summary:** "Pay online with your card — just like any online shop."
|
||||
**Layman summary:** "Pay online with your card — just like any online shop. Your bank may ask you to approve the payment in your banking app."
|
||||
|
||||
**Related:** [[Saved Cards|2.5 Saved Cards]], [[Double-Payment Prevention|2.9 Double-Payment Prevention]]
|
||||
**Related:** [[Saved Cards|2.5 Saved Cards]], [[Double-Payment Prevention|2.9 Double-Payment Prevention]], [[SCA & the 2FA Backup|2.11 SCA & the 2FA Backup]]
|
||||
|
||||
### 2.2 Square Terminal (In-Person Card)
|
||||
**What it does:** Admin initiates a card payment on the Square Terminal. Customer taps or inserts their card at the terminal. Admin polls for completion.
|
||||
@@ -191,11 +191,11 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif
|
||||
**Related:** [[Gift Cards|4. Gift Cards]], [[VAT Calculation|2.10 VAT Calculation]]
|
||||
|
||||
### 2.5 Saved Cards
|
||||
**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). Saving a card also provisions a Square customer profile (P14) — `square_customer_id` is stored on the row and reused for subsequent saves. The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (a Web Payments SDK `cnon:` nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile`. When frontend Square credentials are unset and mock mode is off (local dev), add-card shows the `CardEntryUnavailable` notice; with `VITE_SQUARE_ENVIRONMENT=mock` it uses the frontend mock form instead (saved mock cards appear as `ccof:mock_*` rows in the dev DB).
|
||||
**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). Saving a card also provisions a Square customer profile (P14) — `square_customer_id` is stored on the row and reused for subsequent saves. The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (a Web Payments SDK `cnon:` nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile`. Charging a saved card is authenticated by Square PSD2 SCA as the primary authorisation (a customer-initiated stored-credential charge carries a verification token; see [[#2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK)|2.1]]); when a customer's bank cannot run SCA, the 2FA code gate (customer-keyed, single-use, fail-closed) is the backup — see [[#2.11 SCA & the 2FA Backup]]. Adding a card is itself 2FA-gated wherever the backup gate is enforced. When frontend Square credentials are unset and mock mode is off (local dev), add-card shows the `CardEntryUnavailable` notice; with `VITE_SQUARE_ENVIRONMENT=mock` it uses the frontend mock form instead (saved mock cards appear as `ccof:mock_*` rows in the dev DB).
|
||||
|
||||
**Layman summary:** "Save your card for next time — one-click payment."
|
||||
|
||||
**Related:** [[GDPR & Compliance|9. GDPR & Compliance]] (financial data retention), [[Frontend Architecture|15. Frontend Architecture]] (Cards tab)
|
||||
**Related:** [[GDPR & Compliance|9. GDPR & Compliance]] (financial data retention), [[Frontend Architecture|15. Frontend Architecture]] (Cards tab), [[SCA & the 2FA Backup|2.11 SCA & the 2FA Backup]]
|
||||
|
||||
### 2.6 Tips
|
||||
**What it does:** Customers can add a tip to a completed booking. Available as percentage presets (10%/15%/20%) or custom amount. Cash tip via "keep change as tip" checkbox.
|
||||
@@ -232,6 +232,13 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif
|
||||
|
||||
**Related:** [[VAT Treatment (SPV vs MPV)|4.7 VAT Treatment]], [[Business Settings|5.6 Business Settings]]
|
||||
|
||||
### 2.11 SCA & the 2FA Backup
|
||||
**What it does:** Online card payments are authenticated by Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`). For new cards, the Web Payments SDK issues a verification token at card entry. For **saved cards**, the charge is a customer-initiated transaction (CIT — the charge carries `customer_details.customer_initiated=true`), so PSR 2017 applies and Square's buyer verification is the **primary authorisation**: the customer approves in their banking app, the verification token is passed through to Square, and chargeback liability shifts to the card scheme. If the buyer cannot be verified, Square declines with `CARD_DECLINED_VERIFICATION_REQUIRED`, a definitive error: the customer must re-verify or the card be re-tokenized (a same-request retry never succeeds). The homegrown **2FA gate is the backup**, used only when SCA is unavailable (e.g. the customer's bank does not support in-app approval). It is customer-keyed (the code verifies against the card owner, never the admin session), single-use (one code authorises one charge; a failed charge re-mints), fail-closed (`REQUIRE_2FA` defaults ON outside dev/mock environments), and fully audited: every admin mint-or-reuse writes an `admin_audit_log` row (`2fa_code_mint`), and every admin saved-card charge writes its own row (`saved_card_charge` / `till_saved_card_charge`). Delivery: email/SMS is the intended channel (method chosen at setup), not yet wired (P6); until then, production codes are delivered via the opt-in `[2FA]` server-log relay (`TWO_FACTOR_ALLOW_LOG_DELIVERY=true`), and production issuance fails closed (503) without it.
|
||||
|
||||
**Layman summary:** "Paying online is approved by your bank through your banking app. If your bank can't do that, the salon uses a one-time code instead — and every use is logged."
|
||||
|
||||
**Related:** [[Online Card Payment|2.1 Online Card Payment]], [[Saved Cards|2.5 Saved Cards]], [[Authentication & Security|8. Authentication & Security]], [[GDPR & Compliance|9. GDPR & Compliance]] (admin audit log)
|
||||
|
||||
---
|
||||
|
||||
## 3. Availability & Scheduling
|
||||
@@ -367,7 +374,7 @@ Physical and digital gift cards with multi-method purchase, 24-month rolling exp
|
||||
**Related:** [[Admin Dashboard|5. Admin Dashboard]] (Gift Card Management), [[Admin Audit Trail|4.9 Admin Audit Trail]]
|
||||
|
||||
### 4.7 VAT Treatment (SPV vs MPV)
|
||||
**What it does:** Gift cards can be configured as Single-Purpose Vouchers (VAT charged at purchase) or Multi-Purpose Vouchers (VAT charged at redemption). Default is SPV.
|
||||
**What it does:** Gift cards are Single-Purpose Vouchers (VAT charged at purchase) — a salon-only gift card is an SPV under HMRC law, so a stored Multi-Purpose Voucher (MPV) setting is overridden to SPV at read time. Default and only effective type is SPV.
|
||||
|
||||
**Layman summary:** "VAT is handled differently depending on the gift card type — charged at purchase or at use."
|
||||
|
||||
@@ -452,7 +459,7 @@ The central management hub for salon operations — managing users, bookings, se
|
||||
**Related:** [[Gift Cards|4. Gift Cards]], [[Till Purchases (POS)|5.9 Till Purchases]], [[Expired Balance Recovery|4.6 Expired Balance Recovery]]
|
||||
|
||||
### 5.6 Business Settings
|
||||
**What it does:** Configure business name, address, VAT rate, gift card expiry months, and voucher type (SPV/MPV).
|
||||
**What it does:** Configure business name, address, VAT rate, gift card expiry months, and voucher type (SPV; a stored MPV setting is overridden to SPV at read time).
|
||||
|
||||
**Layman summary:** "Salon settings — VAT, gift cards, and contact info."
|
||||
|
||||
@@ -573,7 +580,7 @@ The staff's main daily dashboard for managing appointments in real time.
|
||||
|
||||
## 8. Authentication & Security
|
||||
|
||||
JWT-based authentication with refresh token rotation, role-based access control, progressive rate limiting, and account lockout.
|
||||
JWT-based authentication with refresh token rotation, role-based access control, progressive rate limiting, account lockout, and the 2FA code backup for saved-card payments ([[#2.11 SCA & the 2FA Backup]]).
|
||||
|
||||
**Related:** [[Frontend Architecture|15. Frontend Architecture]] (auth store), [[GDPR & Compliance|9. GDPR & Compliance]] (data export includes session data)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Square integration has two build-tagged implementations:
|
||||
|
||||
Saved cards stored in `user_saved_cards` with soft delete (`retained_until` for 7-year UK compliance). Refunds tracked in `refunds` table — partial or full. Square webhooks at `/webhooks/square` (registered on the router root, proxied exact-match by nginx — not under `/api`) are HMAC-verified **fail-closed** (503 without the signing key, 403 on bad signature) and deduplicated by `event_id`: a fast-path in-memory cache plus a `square_webhook_events` DB row committed **after** dispatch, so delivery is at-least-once and Square retries on any failure. Events dispatch to state-mutating handlers that reconcile `payments`, `till_sales`, `refunds`, and `disputes` — a lost dispute marks the payment failed and a `critical_payment_log` admin notification is always raised, even when the disputed payment is not tracked locally (no sweep fallback exists for disputes). The background sweeps remain as the eventual backstop.
|
||||
|
||||
**2FA on online card payments:** a two-factor authorization feature that acts as a **merchant-level authorization gate on saved-card payments — 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. Charging a **saved card** requires the user to have 2FA enabled when it is enforced (enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced — and disabled only by `REQUIRE_2FA=false` (case-insensitive, also `0`/`off`/`no`); new-card/nonce charges are not gated). The 6-digit code is delivered via the server log (`[2FA]` prefix) in ALL modes — the operator reads it and relays it to the customer — standing in for real email/SMS delivery until that infrastructure lands (P6). In unenforced/dev mode the setup response also returns the code, so the flow is testable without grepping backend logs; there is no email/SMS transport yet. UI: Account → Two-Factor Authentication. Details in the [[Technical Manual]].
|
||||
**SCA & 2FA on online card payments:** Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`) is the **primary authorisation** for online card payments — **both new-card and saved-card** customer-initiated charges. For a saved card (a stored `ccof:` credential) the charge is a PSR 2017-regulated CIT; Square's verification token satisfies SCA and shifts chargeback liability to the card scheme, and the customer approves in their banking app ("approve-in-app"). The homegrown **2FA gate is now the backup**, firing only when SCA is unavailable (e.g. the customer's bank does not support in-app approval). Charging a **saved card** on the fallback path requires the user to have 2FA enabled when it is enforced (enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced — and disabled only by `REQUIRE_2FA=false` (case-insensitive, also `0`/`off`/`no`); new-card/nonce charges are not gated). The fallback carries a strict audit trail: every admin mint-or-reuse is logged (`2fa_code_mint`, fresh-or-reused + remaining lifetime) and every admin saved-card charge writes its own audit row (`saved_card_charge` / `till_saved_card_charge`). The intended 2FA delivery channel is **email/SMS** (the method chosen at setup), **not yet wired** (P6). Until it lands, the 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) **only** when the operator explicitly opts in with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` — production issuance otherwise fails closed (503) so no user can complete 2FA setup, and every enforced fallback saved-card payment 403s. In unenforced/dev mode the setup response also returns the code, so the flow is testable without grepping backend logs; there is no email/SMS transport yet. UI: Account → Two-Factor Authentication. Details in the [[Technical Manual]].
|
||||
|
||||
Fees column on `payments` stores actual Square deductions. **`square_deposits` (and the `generate_square_deposit_id()` function) were dead schema with zero Go references, a placeholder for Square bank reconciliation against Mettle; they were dropped from `init-scripts/init-script.sql` in the fresh-DB recreate (backlog T1 closed). Mettle/FreeAgent integration is a planned upcoming body of work.**
|
||||
|
||||
@@ -55,7 +55,7 @@ Expiry is 24 months from last use (not from purchase). Each use resets the timer
|
||||
|
||||
Accounts idle 2+ years (no balance) or 5+ years (with balance) are anonymized. Balances before deletion move to `gift_card_expired_balances`. `CleanupIdleAccounts()` runs on availability fetch.
|
||||
|
||||
VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) by default — VAT charged at purchase, not redemption. Configurable to Multi-Purpose Voucher (MPV) in business settings. Gift card purchases now insert a pending payment record with VAT applied before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record rather than losing the payment. Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds, `sweep-stale-pending-payments` fails stale pending payments and till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour.
|
||||
VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) under HMRC law — VAT charged at purchase, not redemption. A stored Multi-Purpose Voucher (MPV) setting is overridden to SPV at read time, because a salon-only gift card is an SPV by definition (HMRC VAT Notice 700/7). Gift card purchases now insert a pending payment record with VAT applied before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record rather than losing the payment. Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds, `sweep-stale-pending-payments` fails stale pending payments and till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour.
|
||||
|
||||
### Scheduling
|
||||
|
||||
@@ -133,7 +133,7 @@ Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `a
|
||||
- **No error tracking** — Sentry DSN not configured, `log.Printf()` only
|
||||
- **No automated DB backups** — no `pg_dump` cron or point-in-time recovery
|
||||
- **No API documentation** — no OpenAPI/Swagger spec
|
||||
- **L3 progressive rate limiting** — per-IP dual-window (30 req/5s burst + 120 req/60s sustained) on login/register. Account lockout after 5 failures (progressive 15min→2h).
|
||||
- **L3 progressive rate limiting** — per-IP dual-window (30 req/5s burst + 120 req/60s sustained) on login/register. Account lockout after 5 failures (15min, escalating to 30min at 7+ failures).
|
||||
- **A11y checks via Svelte 5 compiler** — ESLint a11y plugin rules removed; compiler built-in checks used instead
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -284,7 +284,7 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
|
||||
| GET | `/api/services/popular` | Optional | 120/min | List active services sorted by booking popularity (last 6mo), then price desc |
|
||||
| GET | `/api/services/eligible-for/{user_id}` | Admin | 120/min | Services filtered by user's age/patch test |
|
||||
| POST | `/api/register` | None | 10/min | Create user account (optional `referralCode` field) |
|
||||
| POST | `/api/login` | None | ProgressiveRateLimit + RateLimit(10, 1min) | Authenticate, receive JWT + refreshToken. Account lockout after 5 failures (15min→30min→1h→2h). |
|
||||
| POST | `/api/login` | None | ProgressiveRateLimit + RateLimit(10, 1min) | Authenticate, receive JWT + refreshToken. Account lockout after 5 failures (15min, escalating to 30min at 7+ failures). |
|
||||
| POST | `/api/verify/generate` | None | — | Generate email verification or password reset code |
|
||||
| POST | `/api/verify/check` | None | — | Verify code |
|
||||
| GET | `/api/health` | None | — | Health check (DB, S3, Square, frontend status) |
|
||||
@@ -638,8 +638,8 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
|
||||
- **Card-machine checkout lifecycle:** a checkout created but never committed to the DB is cancelled on request failure, so a tracking failure cannot orphan a live terminal charge. The `sweep-stale-terminal-checkouts` background sweep cancels any card-machine checkout still pending at Square after an hour.
|
||||
|
||||
**VAT Treatment:**
|
||||
- SPV: VAT charged at purchase, not at redemption (default)
|
||||
- MPV: VAT charged at redemption (configurable via `business_settings.voucher_type`)
|
||||
- SPV: VAT charged at purchase, not at redemption (default and only effective type — a stored MPV is overridden to SPV at read time)
|
||||
- MPV: not available for this business — a salon-only gift card is an SPV under HMRC VAT Notice 700/7
|
||||
- `apply_vat_to_till_sale()` function handles VAT calculation for till sales
|
||||
|
||||
**Decision:** 24-month rolling expiry (not fixed) matches the CMA's 24-month industry standard and avoids an unfair-contract-term challenge under the Consumer Rights Act 2015. The `gift_card_expired_balances` table stores only account ID + amount (no PII) — indefinite retention by design, with no claim deadline.
|
||||
@@ -823,20 +823,25 @@ validTransitions := map[string]map[string]bool{
|
||||
|
||||
---
|
||||
|
||||
### Two-Factor Authentication (2FA) — merchant-level authorization gate (not PSD2 SCA)
|
||||
### Two-Factor Authentication (2FA) — backup authorisation for saved-card charges (SCA-primary)
|
||||
|
||||
**What it is:** a two-factor authorization feature that acts as a **merchant-level authorization gate on saved-card payments**. It is **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. Enabling it is optional per-user; when enforcement is active, a user who has **not** enabled 2FA is blocked (403 JSON, parseable via `extractErrorMessage`) from saved-card online payment paths.
|
||||
**What it is:** a two-factor authorization feature that acts as the **backup authorisation on saved-card payments**. The **primary** authorisation is Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`), which now covers **both new-card and saved-card** charges: a customer-initiated charge against a stored credential carries Square's verification token, satisfying PSR 2017 and shifting fraud liability to the card scheme. The 2FA gate fires **only when SCA is unavailable** — the concrete case being a customer whose bank does not support the in-app approval flow — and is then the last line standing on that charge. Enabling it is optional per-user; when enforcement is active, a user who has **not** enabled 2FA is blocked (403 JSON, parseable via `extractErrorMessage`) from the saved-card paths that fall back to it.
|
||||
|
||||
**Enforcement** (`twoFactorEnforced`, `handlers/payments/twofa.go`):
|
||||
- Enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT`, including empty and unknown values, which are treated as production-enforced. It is disabled only when `REQUIRE_2FA` is an explicit disable value (`false`/`0`/`off`/`no`, case-insensitive) **or** `SQUARE_ENVIRONMENT` is an explicit dev/mock value (`mock`, `dev`, `development`, `test`).
|
||||
- A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
|
||||
- **Residual brute-force exposure (accepted):** a fresh-code delivery (setup, or a disable that mints because no pending code exists) resets the shared 5-attempt counter. An authenticated attacker who already holds the victim's password can therefore loop `disable` with wrong codes to obtain an unlimited series of fresh codes, each granting 5 guesses — the 2FA gate then reduces to a 6-digit guessing game bounded only by the per-IP rate limit (120 req/min on `/api/user`) and the 10-minute code TTL. This is the same reset-on-delivery tradeoff that makes codes deliverable to locked-out users; it is documented rather than fixed because a hard per-user lockout would strand a legitimate user who lost their code, with no email/SMS transport to recover (P6). Revisit when real delivery lands.
|
||||
- **Residual brute-force exposure (accepted):** a fresh-code delivery (setup, or a disable that mints because no pending code exists) resets the shared 5-attempt counter. An authenticated attacker who already holds the victim's password can therefore loop `disable` with wrong codes to obtain an unlimited series of fresh codes, each granting 5 guesses — the 2FA gate then reduces to a 6-digit guessing game bounded only by the per-IP rate limit (120 req/min on `/api/user`) and the 10-minute code TTL. This is the same reset-on-delivery tradeoff that makes codes deliverable to locked-out users; it is documented rather than fixed because a hard per-user lockout would strand a legitimate user who lost their code, with no email/SMS transport to recover (P6). Revisit when real delivery lands. (Because 2FA is now backup-only, the exposure is confined to the no-SCA fallback path — it no longer fronts every saved-card charge.)
|
||||
|
||||
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash`, `two_factor_pending_code_expires` (10-minute TTL). Only a digest of the code is stored in the DB — never the plaintext. The digest is **HMAC-SHA256 keyed by `TWO_FACTOR_PEPPER`** when that env var is set (`hashTwoFACode`, `handlers/user/twofa.go`); an unset pepper falls back to the legacy unsalted SHA-256 digest **only** in dev/test builds and for the legacy-row migration window — production builds can never persist an unsalted digest because code issuance **fails closed** without the pepper (see `handlers/user/twofa_prod.go`). **Code delivery is build-dependent and production fails closed:** dev/test builds always write the plaintext code to the server log with a `[2FA]` prefix (and, when enforcement is off, the setup endpoint also returns the code and verify accepts any code, so the flow is testable without grepping logs). Production builds **NEVER** log the code unless the operator explicitly opts in with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true`; without it, code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced saved-card payment 403s with no way forward. This is the fake delivery channel until real email/SMS infrastructure replaces that log line (P6); there is no email/SMS transport yet. Each fresh code is checked under a shared **5-attempt lockout** (`twoFAMaxAttempts = 5` consecutive failed verifies invalidate the pending code); a fresh-code delivery resets that counter (see the residual brute-force note above).
|
||||
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash`, `two_factor_pending_code_expires` (10-minute TTL). Only a digest of the code is stored in the DB — never the plaintext. The digest is **HMAC-SHA256 keyed by `TWO_FACTOR_PEPPER`** when that env var is set (`hashTwoFACode`, `handlers/user/twofa.go`); an unset pepper falls back to the legacy unsalted SHA-256 digest **only** in dev/test builds and for the legacy-row migration window — production builds can never persist an unsalted digest because code issuance **fails closed** without the pepper (see `handlers/user/twofa_prod.go`). **Code delivery is build-dependent and production fails closed:** the **intended** channel is email/SMS (the method chosen at setup) — **not yet wired (P6)**. Until that transport lands, the **only** production channel is the operator's explicit opt-in to the insecure stdout-log relay: with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` the plaintext code is written to the server log with a `[2FA]` prefix (user id and code on **separate** lines, so a single record cannot trivially pair them), and the operator relays it. Without the opt-in, production code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced fallback saved-card payment 403s with no way forward — a loud failure rather than a silent lockout. Dev/test builds always write the `[2FA]` log line (and, when enforcement is off, the setup endpoint also returns the code and verify accepts any code, so the flow is testable without grepping logs). Each fresh code is checked under a shared **5-attempt lockout** (`twoFAMaxAttempts = 5` consecutive failed verifies invalidate the pending code); a fresh-code delivery resets that counter (see the residual brute-force note above).
|
||||
|
||||
**Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. Disabling 2FA requires a verification code when enforcement is ON (a password-only attacker must not be able to lift the protection) — the disable flow reuses a still-valid pending code when one exists, otherwise it generates and delivers a fresh one via the same `[2FA]` log channel; the submitted code is checked under the shared 5-attempt lockout (the same per-user counter as verify). The "always generate a fresh code on disable" alternative was deliberately **not** adopted: with an out-of-band log-delivery channel, a code generated by a request could never be submitted within that same request. In dev (unenforced) environments no code is required to disable.
|
||||
**Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, saved-card till sales, gift-card saved-card charges — and on the save-card endpoints (`CreatePaymentMethod`, the `save_card=true` booking/tip branches). New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry, and under the SCA-primary model the same buyer verification is the primary authorisation for saved-card charges, with this gate as the fallback when SCA is unavailable. Disabling 2FA requires a verification code when enforcement is ON (a password-only attacker must not be able to lift the protection) — the disable flow reuses a still-valid pending code when one exists, otherwise it generates and delivers a fresh one via the same `[2FA]` log channel; the submitted code is checked under the shared 5-attempt lockout (the same per-user counter as verify). The "always generate a fresh code on disable" alternative was deliberately **not** adopted: with an out-of-band log-delivery channel, a code generated by a request could never be submitted within that same request. In dev (unenforced) environments no code is required to disable.
|
||||
|
||||
**Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`. UI: Account → Two-Factor Authentication.
|
||||
**Audit requirement (the fallback is fully traceable):**
|
||||
- `POST /api/admin/users/{id}/2fa/code` (`AdminSendVerificationCodeHandler`) mints (or reuses) a code keyed to the **target customer**, never the admin session — the gate verifies against the card owner. Every successful mint-or-reuse writes an `admin_audit_log` row, `action_type='2fa_code_mint'`, with details carrying `reused` (fresh vs reused) and `remaining_seconds` (the effective code lifetime).
|
||||
- Every admin saved-card charge writes its own audit row via `insertAdminAuditCharge` (`handlers/payments/handlers.go` ~38): `saved_card_charge` for the online path, `till_saved_card_charge` for the till, each with the target customer, amount, card, and Square payment id.
|
||||
- A customer's own requests (`POST /api/user/2fa/code`, `SendVerificationCodeHandler`) are per-user rate-limited and logged like every other 2FA delivery; the code is never included in the response when 2FA is enforced.
|
||||
|
||||
**Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`, `POST /api/user/2fa/code` (enabled user mints a charge code; 409 if not enabled, 429 on mint cooldown, 503 when no delivery channel), `POST /api/admin/users/{id}/2fa/code` (admin relay, audited), `POST /api/admin/users/{id}/2fa/remove` (admin recovery). UI: Account → Two-Factor Authentication.
|
||||
|
||||
---
|
||||
|
||||
@@ -1236,10 +1241,10 @@ Lockout state is stored in `users.failed_attempts` and `users.locked_until` colu
|
||||
- `vat_registration_number` — VAT number
|
||||
- `is_vat_registered` — boolean
|
||||
- `gift_card_expiry_months` — default 12 (configurable, but actual expiry logic uses 24 months)
|
||||
- `voucher_type` — `SPV` (default) or `MPV`
|
||||
- `voucher_type` — `SPV` (default); a stored `MPV` is accepted for backward compatibility but overridden to `SPV` at read time
|
||||
|
||||
**Validation:**
|
||||
- `voucher_type` must be `SPV` or `MPV`
|
||||
- `voucher_type` must be `SPV` or `MPV` (an `MPV` value is accepted but treated as `SPV` — a salon-only gift card is an SPV under HMRC VAT Notice 700/7)
|
||||
- `gift_card_expiry_months` must be a positive integer
|
||||
- `vat_registration_number` must be a valid UK VAT number: `GB` followed by 9 digits (standard) or 12 digits (branch). Previously allowed up to 20 arbitrary characters.
|
||||
- `business_email` must be 254 characters or fewer
|
||||
@@ -1348,7 +1353,7 @@ Items that must be closed before a production go-live. This is a living list; ad
|
||||
|
||||
- **Set `SUPPORT_EMAIL`.** Every consumer-facing legal doc ([[Terms & Conditions - Overall App]], [[Privacy Policy]], [[Gift Card Terms & Conditions]], and the `/terms`, `/privacy-policy`, `/cancellation-policy` routes) currently uses the `{{SUPPORT_EMAIL}}` placeholder for the support address. The real address must be substituted in **all** of those places before launch — a placeholder in a live policy is a consumer-law exposure.
|
||||
- **Legal review of the DRAFT-bannered legal docs.** The T&Cs, Privacy Policy, Gift Card Terms, and the policy routes are still drafts for go-live review; have the wording checked by a solicitor before launch.
|
||||
- **Wire real email/SMS or keep the `[2FA]` log relay.** 2FA codes are delivered via the server log until email/SMS lands (see the Two-Factor Authentication section in this manual); confirm the delivery channel before launch. In a production build the relay is **explicitly opt-in**: set `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` to deliver codes via the `[2FA]` log line, otherwise code issuance fails closed (503) and no user can complete 2FA setup or disable — every enforced saved-card online payment will 403. This is the **only** production 2FA delivery channel until email/SMS (P6) is wired, so it must be a deliberate decision at launch (with restricted log access), not a silent default.
|
||||
- **Wire email/SMS or keep the `[2FA]` log relay for the 2FA backup.** SCA (Square buyer verification) is the primary authorisation for saved-card charges; the 2FA gate fires only when a customer's bank cannot run SCA. Its codes are delivered via the server log until email/SMS lands (P6) — see the Two-Factor Authentication section in this manual. In a production build the relay is **explicitly opt-in**: set `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` to deliver codes via the `[2FA]` log line, otherwise code issuance fails closed (503) and no user can complete 2FA setup or disable — every enforced fallback saved-card payment will 403. This is the **only** production 2FA delivery channel until email/SMS (P6) is wired, so it must be a deliberate decision at launch (with restricted log access), not a silent default.
|
||||
- **Set `SNAPSHOT_ENC_KEY`.** `square_request_snapshot` rows contain buyer PII (email + `ccof:` card tokens). Without `SNAPSHOT_ENC_KEY` (base64-encoded 32-byte AES-256 key, `openssl rand -base64 32`), non-mock deployments store those rows **PLAINTEXT at rest** with only a one-time CRITICAL startup log (see `checkSnapshotEncKey`, `backend/main.go`). Money-safety first: the process does **not** fail at startup, so the misconfiguration is otherwise silent — set the key before go-live.
|
||||
- **Set `TRUST_PROXY_HEADERS=true`.** The backend is deployed behind nginx and/or Cloudflare, which overwrite `X-Real-IP`/`CF-Connecting-IP` with the real client IP. `TRUST_PROXY_HEADERS` defaults to false; without it every per-IP rate-limit key collapses onto the proxy's IP and any one client can exhaust the shared per-IP budget for everyone (and per-IP limiter protection is effectively bypassed). Keep it false only when the backend is origin-exposed. The var ships via `.env` (`env_file` in `compose.yml`) — `compose.yml` deliberately never sets it, the operator decides per deployment.
|
||||
- **Treatment/safety notes retention — operator assertion (documented residual risk).** The privacy-policy route promises notes are "retained in a form that cannot be traced back to you" (de-identified at account erasure). This is a **business decision, not a technical guarantee**: notes are free-text `TEXT` (no backend PII validation, UI-capped at 1,000,000 chars) and are kept on the anonymised booking row after `anonymize_user` wipes the surrounding record. The operator asserts notes never contain direct identifiers. The residual risk is that a note entered with a name/phone/address could still re-identify the customer after erasure — see the Admin Manual procedure ("never enter direct identifiers in notes") and the `anonymize_user` RETENTION POLICY comment in `init-scripts/init-script.sql`.
|
||||
@@ -1648,9 +1653,9 @@ Saved cards use `retained_until` instead of `deleted_at`. This is because UK fin
|
||||
|
||||
The API uses pence (int64) for all monetary values to avoid floating-point precision issues. The database stores pounds as `NUMERIC(10,2)` for SQL-level precision. The conversion happens at the API boundary: pence → pounds on read, pounds → pence on write.
|
||||
|
||||
### Why gift cards are SPVs by default?
|
||||
### Why gift cards are SPVs?
|
||||
|
||||
Under UK VAT law, most salon gift cards are Single-Purpose Vouchers (SPVs) because they can only be redeemed for the salon's own services. VAT is charged at the point of purchase. This is the default behavior. Multi-Purpose Vouchers (MPVs) — where VAT is charged at redemption — are configurable for future flexibility.
|
||||
Under UK VAT law, salon gift cards are Single-Purpose Vouchers (SPVs) because they can only be redeemed for the salon's own services. VAT is charged at the point of purchase. This is the default and the only effective type: a stored Multi-Purpose Voucher (MPV) setting is overridden to SPV at read time, because a salon-only gift card is an SPV by definition (HMRC VAT Notice 700/7). Charging VAT at redemption for such a card would defer the output tax past the point of supply.
|
||||
|
||||
### Why idle account cleanup uses two thresholds?
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{}
|
||||
Reference in New Issue
Block a user