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;
|
# environments an operator must relay the logged code to the user out-of-band;
|
||||||
# the API never returns the code while enforcement is ON.
|
# the API never returns the code while enforcement is ON.
|
||||||
REQUIRE_2FA=true
|
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
|
# TWO_FACTOR_PEPPER — server-side pepper for HMAC-hashing 2FA codes. REQUIRED
|
||||||
# in production builds: code issuance FAILS CLOSED when it is unset (an
|
# 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
|
# 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
|
## Limitations
|
||||||
|
|
||||||
- **Single employee** — no multi-staff scheduling, no team management
|
- **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 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 social auth** — OAuth providers (Google, Microsoft, Facebook) not registered
|
||||||
- **No dark mode, no PWA, no recurring bookings, no CSV export**
|
- **No dark mode, no PWA, no recurring bookings, no CSV export**
|
||||||
@@ -76,7 +76,7 @@ docker compose up --build -d
|
|||||||
|
|
||||||
### Two-factor authentication (2FA)
|
### 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`.
|
`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"
|
"net/http"
|
||||||
|
|
||||||
"crussell/internal/square"
|
"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
|
// chargeFailureStatus classifies a SquareClient.CreatePayment error into the
|
||||||
// HTTP status a payment handler should return:
|
// 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)
|
// 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
|
// 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
|
// 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.
|
// 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
|
// (reusePendingID != "") verifies WITHOUT consuming: the code was re-issued
|
||||||
// for exactly this retry and the completed-charge transaction below
|
// for exactly this retry and the completed-charge transaction below
|
||||||
// (ConsumePendingCode) burns it on terminal success, so a retry that fails
|
// (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 (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
|
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)
|
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{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
@@ -1661,7 +1666,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card Purchase",
|
Note: "Gift Card Purchase",
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
VerificationToken: verificationToken,
|
VerificationToken: giftCardVerificationToken,
|
||||||
}
|
}
|
||||||
// C3: a card-on-file (ccof) charge — paying with an existing saved card or
|
// 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
|
// 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)
|
reissueTwoFACodeAfterFailedCharge(ctx, userID)
|
||||||
}
|
}
|
||||||
// Payment record intentionally left as 'pending' for manual retry.
|
// 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))
|
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1867,6 +1878,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
"status": "success",
|
"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 {
|
type CreateTerminalPaymentRequest struct {
|
||||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||||
PaymentType string `json:"payment_type" validate:"required"`
|
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
|
// enforced environment charges a saved card only when this matches the
|
||||||
// customer's pending code; the operator relays it from the [2FA] log/email.
|
// customer's pending code; the operator relays it from the [2FA] log/email.
|
||||||
VerificationCode string `json:"verification_code,omitempty"`
|
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
|
// idempotency_key: optional client-generated per-attempt UUID for saved-card
|
||||||
// charges. The frontend generates one per distinct charge and reuses it
|
// charges. The frontend generates one per distinct charge and reuses it
|
||||||
// across retries of the SAME charge, so two DISTINCT identical charges on
|
// 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
|
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
|
// A3 (mirror of CreateBookingPayment at handlers.go:1596): tips have a
|
||||||
// dedicated endpoint (POST /api/bookings/{id}/tip, CreateTipPayment) which
|
// dedicated endpoint (POST /api/bookings/{id}/tip, CreateTipPayment) which
|
||||||
// enforces the M4 "tips only after the service starts" gate, and the
|
// 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
|
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
|
// Resolve the saved-card Square source for the booking's user (the
|
||||||
// card's owner, not the admin) — shared new-card-vs-saved-card
|
// card's owner, not the admin) — shared new-card-vs-saved-card
|
||||||
// resolution, see resolveChargeSource for the R6 rationale.
|
// 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
|
// its code for one more attempt (the completed-charge transaction
|
||||||
// consumes it on terminal success).
|
// consumes it on terminal success).
|
||||||
reusePendingRecord := paymentID != ""
|
reusePendingRecord := paymentID != ""
|
||||||
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, !reusePendingRecord) {
|
// SCA-primary / 2FA-backup gate (C5): charging the customer's SAVED card
|
||||||
return
|
// 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
|
// 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{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
CustomerID: savedCardCustomerID,
|
CustomerID: savedCardCustomerID,
|
||||||
IdempotencyKey: scKey,
|
IdempotencyKey: scKey,
|
||||||
ReferenceID: bookingID,
|
ReferenceID: bookingID,
|
||||||
Note: req.PaymentType,
|
Note: req.PaymentType,
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
// C3: a saved-card (ccof) charge is customer-initiated — Square
|
VerificationToken: terminalVerificationToken,
|
||||||
// requires customer_details on stored-credential payments, and
|
// MIT (merchant-initiated): the admin charging the customer's SAVED
|
||||||
// omitting it can fail or silently misclassify the charge.
|
// card (admin "Charge Saved Card") is a merchant-initiated stored-
|
||||||
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
|
// 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
|
// 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
|
// 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 {
|
if bookingUserID.Valid {
|
||||||
reissueTwoFACodeAfterFailedCharge(r.Context(), bookingUserID.String)
|
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))
|
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1205,6 +1284,11 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
"card_last4": paymentResult.CardLast4,
|
"card_last4": paymentResult.CardLast4,
|
||||||
"square_payment_id": paymentResult.SquarePayID,
|
"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
|
// 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
|
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
|
||||||
// new charge may move at Square. The gate also runs before
|
// new charge may move at Square. The gate also runs before
|
||||||
// resolveChargeSource below, so an un-2FA'd request never persists a card.
|
// resolveChargeSource below, so an un-2FA'd request never persists a card.
|
||||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
// SCA-primary: a request carrying a Square verification_token (SCA
|
||||||
return
|
// 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
|
// 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
|
// re-issues a fresh code (reissueTwoFACodeAfterFailedCharge). A pending-reuse
|
||||||
// retry verifies WITHOUT consuming: the code was re-issued for exactly this
|
// retry verifies WITHOUT consuming: the code was re-issued for exactly this
|
||||||
// retry and the completed-charge transaction consumes it on terminal success,
|
// 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 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
|
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
|
// Step 2: DB transaction committed — safe to call Square now. If Square
|
||||||
// fails, the record stays 'pending' and a same-key retry reuses it.
|
// 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{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: chargeAmount,
|
Amount: chargeAmount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
@@ -2362,7 +2459,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
ReferenceID: bookingID,
|
ReferenceID: bookingID,
|
||||||
Note: req.PaymentType,
|
Note: req.PaymentType,
|
||||||
BuyerEmail: bookingBuyerEmail,
|
BuyerEmail: bookingBuyerEmail,
|
||||||
VerificationToken: verificationToken,
|
VerificationToken: bookingVerificationToken,
|
||||||
// C3: every online charge here is cardholder-initiated — a saved-card
|
// C3: every online charge here is cardholder-initiated — a saved-card
|
||||||
// (ccof) source MUST carry customer_details for Square's stored-
|
// (ccof) source MUST carry customer_details for Square's stored-
|
||||||
// credential rules, and a new-card (cnon) nonce is entered by the
|
// 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 != "" {
|
if req.CardID != nil && *req.CardID != "" {
|
||||||
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
|
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))
|
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2630,6 +2733,15 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// B13: a campaign was exhausted between the preview and the apply-time
|
||||||
// re-check. The charge already succeeded at Square and the payment record
|
// re-check. The charge already succeeded at Square and the payment record
|
||||||
// is committed, so the customer's promised discount must not silently
|
// 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
|
// 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
|
// 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
|
// is a terminal operation with no downstream charge to attach consumption
|
||||||
// to (MEDIUM-2).
|
// to (MEDIUM-2). This request type carries no verification_token field, so
|
||||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
// the 2FA fallback is the only authorization path; a fallback success is
|
||||||
return
|
// 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)
|
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
|
||||||
@@ -3153,6 +3272,12 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err := json.NewEncoder(w).Encode(card); err != nil {
|
||||||
log.Printf("Failed to encode JSON response: %v", err)
|
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
|
// consume=true: saving a card is a terminal operation (the card row is
|
||||||
// created right here), so the verified code is single-use immediately —
|
// created right here), so the verified code is single-use immediately —
|
||||||
// unlike the saved-card CHARGE gate below, which defers consumption to the
|
// unlike the saved-card CHARGE gate below, which defers consumption to the
|
||||||
// charge's terminal success (MEDIUM-2).
|
// charge's terminal success (MEDIUM-2). A request carrying a Square
|
||||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
// verification_token (SCA performed) skips the gate; a token-less save falls
|
||||||
return
|
// 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 {
|
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
|
// unconsumed code could authorize a second charge — and a pending-reuse
|
||||||
// retry verifies WITHOUT consuming, so a retry that fails again keeps its
|
// retry verifies WITHOUT consuming, so a retry that fails again keeps its
|
||||||
// code for one more attempt (the completed-charge transaction consumes it
|
// 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 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
|
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)
|
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{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
@@ -4566,7 +4701,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
ReferenceID: bookingID,
|
ReferenceID: bookingID,
|
||||||
Note: "tip",
|
Note: "tip",
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
VerificationToken: verificationToken,
|
VerificationToken: tipVerificationToken,
|
||||||
// C3: the tip charge is cardholder-initiated whether it uses a saved
|
// C3: the tip charge is cardholder-initiated whether it uses a saved
|
||||||
// card (ccof — customer_details required) or a freshly entered card
|
// card (ccof — customer_details required) or a freshly entered card
|
||||||
// (cnon — buyer present), so the flag is true either way.
|
// (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 != "" {
|
if req.CardID != nil && *req.CardID != "" {
|
||||||
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
|
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))
|
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -4682,6 +4823,13 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
||||||
ID: paymentID,
|
ID: paymentID,
|
||||||
BookingID: bookingID,
|
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.
|
// the post-charge 2FA consumption + audit after the Square call need it.
|
||||||
var cardUserID sql.NullString
|
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 {
|
switch req.PaymentMethod {
|
||||||
case "cash":
|
case "cash":
|
||||||
saleStatus = "completed"
|
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
|
// WITHOUT consuming: the code was re-issued for exactly this retry and
|
||||||
// the post-charge success path below (ConsumePendingCode) burns it on
|
// the post-charge success path below (ConsumePendingCode) burns it on
|
||||||
// terminal success, so a retry that fails again keeps its code for one
|
// terminal success, so a retry that fails again keeps its code for one
|
||||||
// more attempt.
|
// more attempt. A charge carrying a Square verification_token (SCA
|
||||||
if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, existingPendingID == "") {
|
// performed) skips the gate; a token-less charge falls back to 2FA and
|
||||||
return
|
// 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
|
tillSquareSourceID = savedCardSqCardID
|
||||||
@@ -1143,11 +1162,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
var paymentResult *square.PaymentResult
|
var paymentResult *square.PaymentResult
|
||||||
var squareErr error
|
var squareErr error
|
||||||
|
|
||||||
var verificationToken string
|
|
||||||
if req.VerificationToken != nil {
|
|
||||||
verificationToken = *req.VerificationToken
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.PaymentMethod == "saved_card" {
|
if req.PaymentMethod == "saved_card" {
|
||||||
paymentReq := square.CreatePaymentReq{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: penceAmount,
|
Amount: penceAmount,
|
||||||
@@ -1157,14 +1171,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card " + req.Action,
|
Note: "Gift Card " + req.Action,
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
// C3: a saved-card (ccof) charge is customer-initiated — Square
|
// MIT (merchant-initiated): the admin till charging a
|
||||||
// requires customer_details on stored-credential payments, and
|
// customer's saved card is merchant-initiated, not
|
||||||
// omitting it can fail/quietly strip the charge.
|
// cardholder-initiated. customer_initiated=false classifies it
|
||||||
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
|
// 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
|
// Forward the 3DS/SCA verification token when the request
|
||||||
// carries one (the online_square branch already did; the
|
// carries one (the online_square branch already did; the
|
||||||
// saved-card branch did not).
|
// saved-card branch did not).
|
||||||
VerificationToken: verificationToken,
|
VerificationToken: tillVerificationToken,
|
||||||
}
|
}
|
||||||
// M1: store the verbatim request JSON so the sweep can replay the
|
// M1: store the verbatim request JSON so the sweep can replay the
|
||||||
// charge with an IDENTICAL body under the same key — Square
|
// 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,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card " + req.Action,
|
Note: "Gift Card " + req.Action,
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
VerificationToken: verificationToken,
|
VerificationToken: tillVerificationToken,
|
||||||
}
|
}
|
||||||
// M1: store the verbatim request JSON so the sweep can replay the
|
// M1: store the verbatim request JSON so the sweep can replay the
|
||||||
// charge with an IDENTICAL body under the same key — Square
|
// 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
|
// 503 so the pending sale stays resumable on a same-key retry
|
||||||
// (M3). The clawback decision above stays keyed on
|
// (M3). The clawback decision above stays keyed on
|
||||||
// isDefinitiveChargeFailure, unchanged.
|
// 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))
|
http.Error(w, "Payment failed", chargeFailureStatus(squareErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1317,6 +1340,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
"card_last4": paymentResult.CardLast4,
|
"card_last4": paymentResult.CardLast4,
|
||||||
"square_payment_id": paymentResult.SquarePayID,
|
"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"
|
saleStatus = "completed"
|
||||||
|
|||||||
@@ -89,12 +89,61 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consum
|
|||||||
return twofa.VerifyForUser(ctx, userID, code, consume)
|
return twofa.VerifyForUser(ctx, userID, code, consume)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths
|
// twoFactorFallbackEnabled reports whether the homegrown 2FA may act as a
|
||||||
// (PSD2 SCA stand-in until real SCA infra lands). It returns true when the
|
// BACKUP authorization for a saved-card charge when SCA is unavailable (the
|
||||||
// request may proceed:
|
// 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 decision model, in order:
|
||||||
// - the user has completed 2FA setup (two_factor_enabled) AND the request
|
//
|
||||||
|
// - 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.
|
// carries a verification_code matching the user's stored pending code.
|
||||||
//
|
//
|
||||||
// B10: the setup flag alone must NOT unlock saved-card charges — an enforced
|
// 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.
|
// expired → 400, DB failure → 500.
|
||||||
//
|
//
|
||||||
// On any denial an error JSON is written (parseable by the frontend via
|
// On any denial an error JSON is written (parseable by the frontend via
|
||||||
// extractErrorMessage) and false is returned — the caller must abort the charge.
|
// extractErrorMessage) and allowed=false is returned — the caller must abort
|
||||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string, consume bool) bool {
|
// the charge.
|
||||||
|
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
|
||||||
if !twoFactorEnforced() {
|
if !twoFactorEnforced() {
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
if service == nil {
|
if service == nil {
|
||||||
service = &PaymentService{}
|
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)
|
enabled, err := service.UserTwoFactorEnabled(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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.")
|
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)
|
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")
|
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor status")
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
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
|
// B10: an enforced charge of a saved card needs a live one-time code, not
|
||||||
// just the enabled setup flag.
|
// just the enabled setup flag.
|
||||||
if verificationCode == "" {
|
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.")
|
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); {
|
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
|
||||||
case err == nil:
|
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):
|
case errors.Is(err, twofa.ErrIncorrect):
|
||||||
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
|
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
|
||||||
return false
|
return false, false
|
||||||
case errors.Is(err, twofa.ErrLockedOut):
|
case errors.Is(err, twofa.ErrLockedOut):
|
||||||
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
|
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
|
||||||
return false
|
return false, false
|
||||||
case errors.Is(err, twofa.ErrMissingOrExpired):
|
case errors.Is(err, twofa.ErrMissingOrExpired):
|
||||||
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
|
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
|
||||||
return false
|
return false, false
|
||||||
default:
|
default:
|
||||||
log.Printf("failed to check two-factor verification code for user %s: %v", userID, err)
|
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")
|
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
|
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
|
||||||
// allows every request without touching the DB (no user rows are consulted).
|
// allows every request without touching the DB (no user rows are consulted).
|
||||||
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
|
// 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")
|
t.Setenv("SQUARE_ENVIRONMENT", "mock")
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", false))
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", "", false)
|
||||||
require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced")
|
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) {
|
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||||
@@ -121,7 +226,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", false)
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
require.Equal(t, http.StatusForbidden, w.Code)
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
@@ -137,7 +242,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
// B10: the enabled setup flag alone must NOT unlock the gate.
|
// 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.False(t, ok)
|
||||||
require.Equal(t, http.StatusForbidden, w.Code)
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
})
|
})
|
||||||
@@ -148,7 +253,9 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
|||||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
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)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,7 +265,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
|||||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", false)
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", "", false)
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
var body map[string]string
|
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) {
|
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
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)
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
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)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
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)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
// The code must still be present — the gate verified WITHOUT consuming.
|
// 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.
|
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
|
||||||
w = httptest.NewRecorder()
|
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)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
// Consumption happens at the charge's terminal SUCCESS state.
|
// 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.
|
// A further attempt with the consumed code is denied as expired.
|
||||||
w = httptest.NewRecorder()
|
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)
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
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,
|
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
|
||||||
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
|
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
|
||||||
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
|
// 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
|
// that are otherwise only reachable against the real API. FailAfterCommit
|
||||||
// simulates the exact "charged but response lost → same-key retry" prod
|
// simulates the exact "charged but response lost → same-key retry" prod
|
||||||
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
|
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
|
||||||
@@ -58,6 +59,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -156,10 +158,61 @@ type MockClient struct {
|
|||||||
// the charge succeeds. Off by default — existing dev/test flows charge
|
// the charge succeeds. Off by default — existing dev/test flows charge
|
||||||
// plain "cnon:test-card"-style tokens without verification tokens.
|
// plain "cnon:test-card"-style tokens without verification tokens.
|
||||||
SimulateVerificationRequired bool
|
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{}
|
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) {
|
func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||||
return createPaymentHTTP(ctx, req)
|
return createPaymentHTTP(ctx, req)
|
||||||
}
|
}
|
||||||
@@ -251,6 +304,9 @@ func NewDevClient() SquareClient {
|
|||||||
customers: make(map[string]*CustomerResult),
|
customers: make(map[string]*CustomerResult),
|
||||||
completed: make(map[string]*PaymentResult),
|
completed: make(map[string]*PaymentResult),
|
||||||
usedSources: make(map[string]bool),
|
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) {
|
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||||
if m.ShouldFail {
|
if m.ShouldFail {
|
||||||
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
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
|
// Mirror Square's SCA enforcement on NEW-CARD charges (opt-in toggle, off
|
||||||
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
|
// by default): a cnon: charge without a 3DS/SCA verification token is
|
||||||
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
|
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the
|
||||||
// must re-verify and re-tokenize, NOT retry the same request (the code is
|
// buyer must re-verify and re-tokenize, NOT retry the same request (the
|
||||||
// in definitivePaymentCodes). A present verification token (e.g.
|
// code is in definitivePaymentCodes). A present verification token (e.g.
|
||||||
// verify_mock_...) satisfies the gate exactly as production accepts a
|
// verify_mock_...) satisfies the gate exactly as production accepts a
|
||||||
// Square-issued verification_token on the CreatePayment body.
|
// Square-issued verification_token on the CreatePayment body.
|
||||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
|
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") {
|
||||||
return nil, &squareAPIError{
|
if req.VerificationToken == "" {
|
||||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
return nil, &squareAPIError{
|
||||||
Category: "PAYMENT_METHOD_ERROR",
|
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||||
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
Category: "PAYMENT_METHOD_ERROR",
|
||||||
StatusCode: http.StatusBadRequest,
|
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
||||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
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
|
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) {
|
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
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")
|
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() {
|
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)
|
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
|
// The mirror-image confusion: enforcement is ON but the Square client fell
|
||||||
// back to the in-memory mock (internal/square.NewDevClient only picks the
|
// back to the in-memory mock (internal/square.NewDevClient only picks the
|
||||||
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or
|
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or
|
||||||
|
|||||||
@@ -11,12 +11,19 @@
|
|||||||
import {
|
import {
|
||||||
isSquareConfigured,
|
isSquareConfigured,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
|
isVerificationRequiredSignal,
|
||||||
|
shouldFallbackTo2FA,
|
||||||
submitPaymentWithRetry,
|
submitPaymentWithRetry,
|
||||||
adminRequestNewTwoFactorCode,
|
adminRequestNewTwoFactorCode,
|
||||||
requestNewTwoFactorCode
|
requestNewTwoFactorCode,
|
||||||
|
VERIFICATION_REQUIRED_MESSAGE
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import {
|
||||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
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 = {
|
type CartItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,6 +94,9 @@
|
|||||||
exp_month: number;
|
exp_month: number;
|
||||||
exp_year: number;
|
exp_year: number;
|
||||||
cardholder_name?: string;
|
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('');
|
let customerQuery = $state('');
|
||||||
@@ -129,9 +139,17 @@
|
|||||||
// irrelevant to the backend gate, so `enabled` is always true.
|
// irrelevant to the backend gate, so `enabled` is always true.
|
||||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||||
let customerTwoFactorEnabled = $state(false);
|
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({
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
enabled: () => true,
|
enabled: () => true,
|
||||||
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
|
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
|
||||||
|
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
|
||||||
mint: () =>
|
mint: () =>
|
||||||
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
|
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
|
||||||
});
|
});
|
||||||
@@ -384,6 +402,16 @@
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
responseStatus = res.status;
|
responseStatus = res.status;
|
||||||
const errText = await res.text();
|
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');
|
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -405,11 +433,75 @@
|
|||||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||||
paymentError = msg;
|
paymentError = msg;
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
isProcessingPaymentSync = false;
|
isProcessingPaymentSync = false;
|
||||||
processing = 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>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-xl border bg-card">
|
<div class="rounded-xl border bg-card">
|
||||||
@@ -793,10 +885,9 @@
|
|||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-xs text-amber-800">
|
<p class="text-xs text-amber-800">
|
||||||
This card may require bank app confirmation to complete. Ensure the customer has
|
Your card issuer will ask you to approve this payment in your banking app.
|
||||||
their phone ready.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -836,18 +927,32 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button
|
{#if awaitingSCA}
|
||||||
class="mt-3 w-full"
|
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
|
||||||
onclick={chargeCart}
|
<div
|
||||||
loading={processing}
|
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||||
disabled={!canCharge ||
|
></div>
|
||||||
processing ||
|
<p class="mt-3 text-sm font-medium text-gray-700">
|
||||||
twoFactor.missing ||
|
Waiting for customer to approve in their banking app…
|
||||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
</p>
|
||||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
>
|
The customer may need to approve this payment in their banking app
|
||||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
</p>
|
||||||
</Button>
|
</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-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
Gift card sales are processed through the till; retail items require manual recording for
|
Gift card sales are processed through the till; retail items require manual recording for
|
||||||
|
|||||||
@@ -46,8 +46,15 @@
|
|||||||
isNonceStale,
|
isNonceStale,
|
||||||
isOverflowTipConfirmationRequired,
|
isOverflowTipConfirmationRequired,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
submitPaymentWithRetry
|
isVerificationRequiredSignal,
|
||||||
|
shouldFallbackTo2FA,
|
||||||
|
submitPaymentWithRetry,
|
||||||
|
VERIFICATION_REQUIRED_MESSAGE
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
|
import {
|
||||||
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationResult
|
||||||
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||||
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
||||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||||
@@ -107,6 +114,9 @@
|
|||||||
exp_month: number;
|
exp_month: number;
|
||||||
exp_year: number;
|
exp_year: number;
|
||||||
is_default?: boolean;
|
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);
|
let paymentMethodsLoading = $state(false);
|
||||||
@@ -158,10 +168,15 @@
|
|||||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
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({
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
enabled: () => twoFactorEnabled,
|
enabled: () => twoFactorEnabled,
|
||||||
gateActive: () =>
|
gateActive: () =>
|
||||||
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard)
|
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard),
|
||||||
|
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
|
||||||
});
|
});
|
||||||
|
|
||||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||||
@@ -520,6 +535,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
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
|
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the deposit can be retried with a fresh code.
|
// and reveal the input so the deposit can be retried with a fresh code.
|
||||||
@@ -610,6 +639,56 @@
|
|||||||
depositTokenizedForSaveCard = false;
|
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);
|
let paymentAttempted = $state(false);
|
||||||
|
|
||||||
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
|
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
exp_month: number;
|
exp_month: number;
|
||||||
exp_year: number;
|
exp_year: number;
|
||||||
is_default?: boolean;
|
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 {
|
let {
|
||||||
@@ -104,7 +108,7 @@
|
|||||||
{#if twoFactorEnabled}
|
{#if twoFactorEnabled}
|
||||||
<div class="rounded-md border border-blue-200 bg-blue-50 p-3">
|
<div class="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||||
<p class="text-sm text-blue-800">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
<!-- DEV-ONLY mock card form — enabled only by VITE_SQUARE_ENVIRONMENT === 'mock'
|
<!-- 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
|
(never in production). The PAN lives only in local component state; only a
|
||||||
cnon: token is ever returned. -->
|
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">
|
<script lang="ts">
|
||||||
import CardBrandIcon from './CardBrandIcon.svelte';
|
import CardBrandIcon from './CardBrandIcon.svelte';
|
||||||
|
|
||||||
@@ -15,14 +42,46 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onReady = () => {}
|
onReady = () => {},
|
||||||
}: { disabled?: boolean; onReady?: (ready: boolean) => void } = $props();
|
// 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 cardNumber = $state('');
|
||||||
let expiry = $state('');
|
let expiry = $state('');
|
||||||
let cvc = $state('');
|
let cvc = $state('');
|
||||||
let cardholderName = $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 cardNumberTouched = $state(false);
|
||||||
let expiryTouched = $state(false);
|
let expiryTouched = $state(false);
|
||||||
let cvcTouched = $state(false);
|
let cvcTouched = $state(false);
|
||||||
@@ -169,8 +228,12 @@
|
|||||||
/**
|
/**
|
||||||
* Mirrors SquareCardInput.tokenizeWithVerification() so the dev mock
|
* Mirrors SquareCardInput.tokenizeWithVerification() so the dev mock
|
||||||
* exercises the full SCA path (card nonce + verification token) end to end.
|
* exercises the full SCA path (card nonce + verification token) end to end.
|
||||||
* The fake verification token is deterministic and the backend mock accepts
|
* The fake verification token is deterministic — it encodes the card prefix,
|
||||||
* it alongside the cnon: nonce.
|
* 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
|
* @param amount The amount that WILL be charged, in pence — same pence input
|
||||||
* contract as the real form. The real form serializes this to
|
* contract as the real form. The real form serializes this to
|
||||||
@@ -190,13 +253,36 @@
|
|||||||
if (!complete) {
|
if (!complete) {
|
||||||
throw new Error('Card details are incomplete');
|
throw new Error('Card details are incomplete');
|
||||||
}
|
}
|
||||||
|
const outcome = await runChallenge();
|
||||||
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
|
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
|
||||||
const prefix = digits.slice(0, 4) || 'test';
|
const prefix = digits.slice(0, 4) || 'test';
|
||||||
|
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
nonce: token,
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -282,4 +368,21 @@
|
|||||||
class={inputClasses}
|
class={inputClasses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -12,12 +12,19 @@
|
|||||||
campaignDiscountPence,
|
campaignDiscountPence,
|
||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
|
isVerificationRequiredSignal,
|
||||||
sanitizeDecimalInput,
|
sanitizeDecimalInput,
|
||||||
|
shouldFallbackTo2FA,
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
submitPaymentWithRetry,
|
submitPaymentWithRetry,
|
||||||
|
VERIFICATION_REQUIRED_MESSAGE,
|
||||||
adminRequestNewTwoFactorCode,
|
adminRequestNewTwoFactorCode,
|
||||||
requestNewTwoFactorCode
|
requestNewTwoFactorCode
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
|
import {
|
||||||
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationResult
|
||||||
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
@@ -44,6 +51,7 @@
|
|||||||
| 'gift-confirming'
|
| 'gift-confirming'
|
||||||
| 'saved-card-selecting'
|
| 'saved-card-selecting'
|
||||||
| 'saved-card-processing'
|
| 'saved-card-processing'
|
||||||
|
| 'saved-card-waiting-sca'
|
||||||
| 'success'
|
| 'success'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
@@ -78,6 +86,10 @@
|
|||||||
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
|
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
|
||||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||||
let customerTwoFactorEnabled = $state(false);
|
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);
|
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||||
let useLoyalty = $state(false);
|
let useLoyalty = $state(false);
|
||||||
@@ -92,6 +104,7 @@
|
|||||||
enabled: () => true,
|
enabled: () => true,
|
||||||
gateActive: () =>
|
gateActive: () =>
|
||||||
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
|
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
|
||||||
|
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
|
||||||
mint: () => {
|
mint: () => {
|
||||||
const customerID = booking.user_id ?? booking.user?.id;
|
const customerID = booking.user_id ?? booking.user?.id;
|
||||||
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
|
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
|
||||||
@@ -739,6 +752,7 @@
|
|||||||
exp_month: number;
|
exp_month: number;
|
||||||
exp_year: number;
|
exp_year: number;
|
||||||
cardholder_name?: string;
|
cardholder_name?: string;
|
||||||
|
square_card_id?: string;
|
||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
let loadingSavedCards = $state(false);
|
let loadingSavedCards = $state(false);
|
||||||
@@ -829,7 +843,18 @@
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
responseStatus = response.status;
|
responseStatus = response.status;
|
||||||
const errData = await response.text();
|
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();
|
const data = await response.json();
|
||||||
@@ -854,13 +879,17 @@
|
|||||||
onComplete(paymentResult);
|
onComplete(paymentResult);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
// A definitive 402 on the saved-card path means the issuer still
|
||||||
// definitive 402 on the saved-card path means the issuer still
|
// requires verification. A structured verification-required signal
|
||||||
// requires verification — retrying the same saved card can never
|
// surfaces the SCA-first guidance; the legacy saved-card check is the
|
||||||
// succeed. Surface the fix instead of the generic backend text.
|
// 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';
|
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||||
if (isSavedCardVerificationRequired(responseStatus, true))
|
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
|
||||||
msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
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
|
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the charge can be retried with a fresh code.
|
// and reveal the input so the charge can be retried with a fresh code.
|
||||||
@@ -884,6 +913,101 @@
|
|||||||
fetchSavedCards();
|
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>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||||
@@ -1526,8 +1650,7 @@
|
|||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-xs text-amber-800">
|
<p class="text-xs text-amber-800">
|
||||||
This card may require bank app confirmation to complete. Ensure the customer has their
|
Your card issuer will ask you to approve this payment in your banking app.
|
||||||
phone ready.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1563,12 +1686,19 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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="flex flex-col items-center justify-center py-8">
|
||||||
<div
|
<div
|
||||||
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||||
></div>
|
></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>
|
</div>
|
||||||
{:else if status === 'error' && error}
|
{:else if status === 'error' && error}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,164 @@
|
|||||||
<script module lang="ts">
|
<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
|
* 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
|
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
|
||||||
@@ -43,34 +203,6 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||||
import type MockCardForm from './MockCardForm.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 {
|
interface Props {
|
||||||
/** Disable the form while a payment is processing. */
|
/** Disable the form while a payment is processing. */
|
||||||
|
|||||||
@@ -18,11 +18,18 @@
|
|||||||
isNonceStale,
|
isNonceStale,
|
||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
|
isVerificationRequiredSignal,
|
||||||
sanitizeDecimalInput,
|
sanitizeDecimalInput,
|
||||||
|
shouldFallbackTo2FA,
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
submitPaymentWithRetry
|
submitPaymentWithRetry,
|
||||||
|
VERIFICATION_REQUIRED_MESSAGE
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
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
|
// 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
|
// 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.
|
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
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({
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
enabled: () => twoFactorEnabled,
|
enabled: () => twoFactorEnabled,
|
||||||
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
|
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
|
||||||
|
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCardValid = $derived(cardSelectionValid);
|
const isCardValid = $derived(cardSelectionValid);
|
||||||
@@ -313,7 +325,17 @@
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
responseStatus = response.status;
|
responseStatus = response.status;
|
||||||
const errorText = await response.text();
|
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';
|
paymentState = 'success';
|
||||||
@@ -332,12 +354,19 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentState = 'error';
|
paymentState = 'error';
|
||||||
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
// A definitive 402 on the saved-card path means the issuer still
|
||||||
// definitive 402 on the saved-card path means the issuer still
|
// requires verification. A structured verification-required signal
|
||||||
// requires verification — retrying the same saved card can never
|
// surfaces the SCA-first guidance; the legacy saved-card check is
|
||||||
// succeed. Surface the fix instead of the generic backend text.
|
// the fallback for generic 402s.
|
||||||
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
|
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
|
||||||
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
|
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
|
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// 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() {
|
function retryPayment() {
|
||||||
paymentState = 'idle';
|
paymentState = 'idle';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<!--
|
<!--
|
||||||
TwoFactorCodeInput.svelte — B6/B10 2FA verification-code input for saved-card
|
TwoFactorCodeInput.svelte — B6/B10 2FA verification-code input for saved-card
|
||||||
charges. The backend's requireTwoFactorForCardAccess gate requires the CARD
|
charges. With Square 3DS SCA now the PRIMARY authorisation for saved-card
|
||||||
OWNER's current one-time code on every saved-card charge in an enforced
|
(ccof) charges, this input is the BACKUP path: it shows only when the charge
|
||||||
environment; this input collects it so the charge body carries
|
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
|
`verification_code`. Shared by the customer booking, tip, admin booking and
|
||||||
till saved-card surfaces so the field name, hint copy and the
|
till saved-card surfaces so the field name, hint copy and the
|
||||||
enabled/not-enabled presentation can't drift between them.
|
enabled/not-enabled presentation can't drift between them.
|
||||||
@@ -47,8 +50,7 @@
|
|||||||
class="mt-1 font-mono tracking-widest"
|
class="mt-1 font-mono tracking-widest"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
A current 2FA verification code is required for this saved-card charge. Ask the customer
|
Your bank doesn't support in-app approval — enter the code sent to you / your phone.
|
||||||
for their code, or retrieve it from the server log.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -23,10 +23,17 @@
|
|||||||
isOverflowTipConfirmationRequired,
|
isOverflowTipConfirmationRequired,
|
||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
|
isVerificationRequiredSignal,
|
||||||
sanitizeDecimalInput,
|
sanitizeDecimalInput,
|
||||||
|
shouldFallbackTo2FA,
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
submitPaymentWithRetry
|
submitPaymentWithRetry,
|
||||||
|
VERIFICATION_REQUIRED_MESSAGE
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
|
import {
|
||||||
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationResult
|
||||||
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
|
|
||||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||||
|
|
||||||
@@ -58,9 +65,14 @@
|
|||||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
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({
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
enabled: () => twoFactorEnabled,
|
enabled: () => twoFactorEnabled,
|
||||||
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
|
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
|
||||||
|
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
|
||||||
});
|
});
|
||||||
|
|
||||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||||
@@ -540,7 +552,20 @@
|
|||||||
status = 'idle';
|
status = 'idle';
|
||||||
return;
|
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();
|
const data = await response.json();
|
||||||
@@ -581,12 +606,16 @@
|
|||||||
status = 'error';
|
status = 'error';
|
||||||
overflowConfirm = null;
|
overflowConfirm = null;
|
||||||
let msg = _err instanceof Error ? _err.message : 'Payment declined';
|
let msg = _err instanceof Error ? _err.message : 'Payment declined';
|
||||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
// A 402 with the structured verification-required signal (or the
|
||||||
// definitive 402 on the saved-card path means the issuer still
|
// dev/mock text parity) surfaces the SCA-first guidance; the legacy
|
||||||
// requires verification — retrying the same saved card can never
|
// saved-card verification check is the fallback for generic 402s.
|
||||||
// succeed. Surface the fix instead of the generic backend text.
|
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
|
||||||
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
|
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
|
||||||
if (verificationFailure) msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
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
|
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the charge can be retried with a fresh code.
|
// 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 the overpayment: resend the SAME rejected request with
|
||||||
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
|
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
|
||||||
// both pre-start and post-start overflows (B12).
|
// both pre-start and post-start overflows (B12).
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
NONCE_STALENESS_MS,
|
NONCE_STALENESS_MS,
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
|
VERIFICATION_REQUIRED_MESSAGE,
|
||||||
adminRequestNewTwoFactorCode,
|
adminRequestNewTwoFactorCode,
|
||||||
campaignDiscountPence,
|
campaignDiscountPence,
|
||||||
canSaveCardsForRole,
|
canSaveCardsForRole,
|
||||||
@@ -12,8 +13,10 @@ import {
|
|||||||
isOverflowTipConfirmationRequired,
|
isOverflowTipConfirmationRequired,
|
||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
|
isVerificationRequiredSignal,
|
||||||
requestNewTwoFactorCode,
|
requestNewTwoFactorCode,
|
||||||
sanitizeDecimalInput,
|
sanitizeDecimalInput,
|
||||||
|
shouldFallbackTo2FA,
|
||||||
submitPaymentWithRetry
|
submitPaymentWithRetry
|
||||||
} from './square';
|
} from './square';
|
||||||
import type * as SquareModule 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.length).toBeGreaterThan(0);
|
||||||
expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification');
|
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', () => {
|
describe('isTwoFactorVerificationGateFailure', () => {
|
||||||
|
|||||||
@@ -125,6 +125,59 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b
|
|||||||
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
|
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
|
/** User-facing guidance for a saved-card charge the issuer requires
|
||||||
* verification to complete. Retrying the same saved card is pointless — the
|
* verification to complete. Retrying the same saved card is pointless — the
|
||||||
* buyer must pay with a new card or re-add their card. */
|
* buyer must pay with a new card or re-add their card. */
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export type SavedCard = {
|
|||||||
exp_year: number;
|
exp_year: number;
|
||||||
cardholder_name?: string;
|
cardholder_name?: string;
|
||||||
is_default: boolean;
|
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() {
|
function createSavedCardsStore() {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import { requestNewTwoFactorCode } from '$lib/square/square';
|
|||||||
* card is selected, or a new card is being saved for reuse.
|
* card is selected, or a new card is being saved for reuse.
|
||||||
* The surface passes its exact gate expression so each
|
* The surface passes its exact gate expression so each
|
||||||
* surface's gate semantics are preserved verbatim.
|
* 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
|
* - `mint()` — optional; the code-request call. Customer surfaces omit
|
||||||
* it (defaults to the session-scoped /api/user/2fa/code:
|
* it (defaults to the session-scoped /api/user/2fa/code:
|
||||||
* session user == card owner). Admin surfaces MUST pass
|
* session user == card owner). Admin surfaces MUST pass
|
||||||
@@ -36,6 +43,7 @@ export function useTwoFactorCodeForSavedCard(options: {
|
|||||||
enabled: () => boolean;
|
enabled: () => boolean;
|
||||||
gateActive: () => boolean;
|
gateActive: () => boolean;
|
||||||
mint?: () => ReturnType<typeof requestNewTwoFactorCode>;
|
mint?: () => ReturnType<typeof requestNewTwoFactorCode>;
|
||||||
|
scaAvailable?: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
// Kept populated across retries so an invalid/expired code can be corrected
|
// Kept populated across retries so an invalid/expired code can be corrected
|
||||||
// without re-typing it.
|
// without re-typing it.
|
||||||
@@ -48,8 +56,10 @@ export function useTwoFactorCodeForSavedCard(options: {
|
|||||||
let requesting = $state(false);
|
let requesting = $state(false);
|
||||||
|
|
||||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
// 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.
|
// gate AND SCA isn't available to authorise instead (2FA is the BACKUP, not
|
||||||
const showInput = $derived(reveal || options.gateActive());
|
// 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() === '');
|
const missing = $derived(showInput && options.enabled() && code.trim() === '');
|
||||||
|
|
||||||
async function requestNewCode() {
|
async function requestNewCode() {
|
||||||
|
|||||||
@@ -15,9 +15,16 @@
|
|||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isSquareConfigured,
|
isSquareConfigured,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
|
isVerificationRequiredSignal,
|
||||||
|
shouldFallbackTo2FA,
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
submitPaymentWithRetry
|
submitPaymentWithRetry,
|
||||||
|
VERIFICATION_REQUIRED_MESSAGE
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
|
import {
|
||||||
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationResult
|
||||||
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
import { generateUUID } from '$lib/utils/uuid';
|
import { generateUUID } from '$lib/utils/uuid';
|
||||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||||
@@ -257,9 +264,14 @@
|
|||||||
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||||
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
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({
|
const buyTwoFactor = useTwoFactorCodeForSavedCard({
|
||||||
enabled: () => buyTwoFactorEnabled,
|
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
|
// 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.
|
// SCA check needs it, and text() can only be read once.
|
||||||
const status = res.status;
|
const status = res.status;
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
// A saved-card (ccof) charge skips the client-side SCA step, so a
|
// Saved-card (ccof) SCA: the backend returns 402 +
|
||||||
// definitive 402 on the saved-card path means the issuer still
|
// `verification_required` when Square requires buyer verification
|
||||||
// requires verification — surface the fix instead of the generic
|
// and no verification_token was supplied. Run the client-side 3DS
|
||||||
// backend text.
|
// challenge and retry with the fresh token (same idempotency key).
|
||||||
const verificationRequired = isSavedCardVerificationRequired(status, !!buySelectedCard);
|
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
|
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the charge can be retried with a fresh code.
|
// and reveal the input so the charge can be retried with a fresh code.
|
||||||
const buyErrMsg = verificationRequired
|
const buyErrMsg = verificationRequired
|
||||||
? SAVED_CARD_VERIFICATION_MESSAGE
|
? scaVerificationRequired
|
||||||
|
? VERIFICATION_REQUIRED_MESSAGE
|
||||||
|
: SAVED_CARD_VERIFICATION_MESSAGE
|
||||||
: extractErrorMessage(errText) || 'Failed to purchase gift card';
|
: extractErrorMessage(errText) || 'Failed to purchase gift card';
|
||||||
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
|
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
|
||||||
buyTwoFactor.reveal = true;
|
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(
|
function formatAndPreserveCursor(
|
||||||
input: HTMLInputElement,
|
input: HTMLInputElement,
|
||||||
formatter: (val: string) => string,
|
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",
|
"type": "split",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "4dec2140f7c65f3e",
|
"id": "d45436e02729bff7",
|
||||||
"type": "tabs",
|
"type": "tabs",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "6776d739ee18449c",
|
"id": "7b4ed20d72674471",
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"state": {
|
"state": {
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "Crussell/User Manual.md",
|
"file": "Crussell/payments and money processes.md",
|
||||||
"mode": "source",
|
"mode": "source",
|
||||||
"source": false
|
"source": false
|
||||||
},
|
},
|
||||||
"icon": "lucide-file",
|
"icon": "lucide-file",
|
||||||
"title": "User Manual"
|
"title": "payments and money processes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -41,7 +41,9 @@
|
|||||||
"type": "file-explorer",
|
"type": "file-explorer",
|
||||||
"state": {
|
"state": {
|
||||||
"sortOrder": "alphabetical",
|
"sortOrder": "alphabetical",
|
||||||
"autoReveal": false
|
"autoReveal": false,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
},
|
},
|
||||||
"icon": "lucide-folder-closed",
|
"icon": "lucide-folder-closed",
|
||||||
"title": "Files"
|
"title": "Files"
|
||||||
@@ -78,7 +80,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"direction": "horizontal",
|
"direction": "horizontal",
|
||||||
"width": 300
|
"width": 300,
|
||||||
|
"collapsed": true
|
||||||
},
|
},
|
||||||
"right": {
|
"right": {
|
||||||
"id": "2750d7726f904ef3",
|
"id": "2750d7726f904ef3",
|
||||||
@@ -94,7 +97,7 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "backlink",
|
"type": "backlink",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "Crussell/Future Work - Gap Backlog.md",
|
"file": "Crussell/payments and money processes.md",
|
||||||
"collapseAll": false,
|
"collapseAll": false,
|
||||||
"extraContext": false,
|
"extraContext": false,
|
||||||
"sortOrder": "alphabetical",
|
"sortOrder": "alphabetical",
|
||||||
@@ -104,7 +107,7 @@
|
|||||||
"unlinkedCollapsed": true
|
"unlinkedCollapsed": true
|
||||||
},
|
},
|
||||||
"icon": "links-coming-in",
|
"icon": "links-coming-in",
|
||||||
"title": "Backlinks for Future Work - Gap Backlog"
|
"title": "Backlinks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -113,12 +116,12 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "outgoing-link",
|
"type": "outgoing-link",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "Crussell/Crussell Nails.md",
|
"file": "Crussell/payments and money processes.md",
|
||||||
"linksCollapsed": false,
|
"linksCollapsed": false,
|
||||||
"unlinkedCollapsed": true
|
"unlinkedCollapsed": true
|
||||||
},
|
},
|
||||||
"icon": "links-going-out",
|
"icon": "links-going-out",
|
||||||
"title": "Outgoing links from Crussell Nails"
|
"title": "Outgoing links"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,16 +145,17 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "outline",
|
"type": "outline",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "Crussell/Crussell Nails.md",
|
"file": "Crussell/payments and money processes.md",
|
||||||
"followCursor": false,
|
"followCursor": false,
|
||||||
"showSearch": false,
|
"showSearch": false,
|
||||||
"searchQuery": ""
|
"searchQuery": ""
|
||||||
},
|
},
|
||||||
"icon": "lucide-list",
|
"icon": "lucide-list",
|
||||||
"title": "Outline of Crussell Nails"
|
"title": "Outline"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"currentTab": 3
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"direction": "horizontal",
|
"direction": "horizontal",
|
||||||
@@ -169,19 +173,20 @@
|
|||||||
"bases:Create new base": false
|
"bases:Create new base": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"active": "6776d739ee18449c",
|
"active": "7b4ed20d72674471",
|
||||||
"lastOpenFiles": [
|
"lastOpenFiles": [
|
||||||
|
"Untitled.canvas",
|
||||||
|
"Crussell/payments and money processes.md",
|
||||||
|
"Crussell/Overview.md",
|
||||||
|
"Crussell/User Manual.md",
|
||||||
"Crussell/Future Work - Gap Backlog.md",
|
"Crussell/Future Work - Gap Backlog.md",
|
||||||
"Crussell/Technical Manual.md",
|
"Crussell/Technical Manual.md",
|
||||||
"Crussell/Loyalty & Discount System Reference.md",
|
"Crussell/Loyalty & Discount System Reference.md",
|
||||||
"Crussell/Overview.md",
|
|
||||||
"Crussell/User Manual.md",
|
|
||||||
"Crussell/Admin Manual.md",
|
"Crussell/Admin Manual.md",
|
||||||
"Crussell/Test Implementation Plan.md",
|
"Crussell/Test Implementation Plan.md",
|
||||||
"Crussell/Crussell Nails.md",
|
"Crussell/Crussell Nails.md",
|
||||||
"Crussell/Backend/bookings.md",
|
"Crussell/Backend/bookings.md",
|
||||||
"Untitled.base",
|
"Untitled.base",
|
||||||
"Untitled.canvas",
|
|
||||||
"Express.js Cheat Sheet.md"
|
"Express.js Cheat Sheet.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -158,16 +158,16 @@ Anonymous rate cap: max 50 reservations per IP in 10 minutes.
|
|||||||
|
|
||||||
## 2. Payments
|
## 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)
|
**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)
|
### 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)
|
### 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.
|
**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]]
|
**Related:** [[Gift Cards|4. Gift Cards]], [[VAT Calculation|2.10 VAT Calculation]]
|
||||||
|
|
||||||
### 2.5 Saved Cards
|
### 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."
|
**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
|
### 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.
|
**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]]
|
**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
|
## 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]]
|
**Related:** [[Admin Dashboard|5. Admin Dashboard]] (Gift Card Management), [[Admin Audit Trail|4.9 Admin Audit Trail]]
|
||||||
|
|
||||||
### 4.7 VAT Treatment (SPV vs MPV)
|
### 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."
|
**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]]
|
**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
|
### 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."
|
**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
|
## 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)
|
**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.
|
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.**
|
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.
|
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
|
### 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 error tracking** — Sentry DSN not configured, `log.Printf()` only
|
||||||
- **No automated DB backups** — no `pg_dump` cron or point-in-time recovery
|
- **No automated DB backups** — no `pg_dump` cron or point-in-time recovery
|
||||||
- **No API documentation** — no OpenAPI/Swagger spec
|
- **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
|
- **A11y checks via Svelte 5 compiler** — ESLint a11y plugin rules removed; compiler built-in checks used instead
|
||||||
|
|
||||||
## Prerequisites
|
## 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/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 |
|
| 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/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/generate` | None | — | Generate email verification or password reset code |
|
||||||
| POST | `/api/verify/check` | None | — | Verify code |
|
| POST | `/api/verify/check` | None | — | Verify code |
|
||||||
| GET | `/api/health` | None | — | Health check (DB, S3, Square, frontend status) |
|
| 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.
|
- **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:**
|
**VAT Treatment:**
|
||||||
- SPV: VAT charged at purchase, not at redemption (default)
|
- SPV: VAT charged at purchase, not at redemption (default and only effective type — a stored MPV is overridden to SPV at read time)
|
||||||
- MPV: VAT charged at redemption (configurable via `business_settings.voucher_type`)
|
- 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
|
- `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.
|
**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** (`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`).
|
- 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.
|
- 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
|
- `vat_registration_number` — VAT number
|
||||||
- `is_vat_registered` — boolean
|
- `is_vat_registered` — boolean
|
||||||
- `gift_card_expiry_months` — default 12 (configurable, but actual expiry logic uses 24 months)
|
- `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:**
|
**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
|
- `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.
|
- `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
|
- `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.
|
- **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.
|
- **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 `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.
|
- **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`.
|
- **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.
|
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?
|
### 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