fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity

7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent c4c65d9dd8
commit b7122be3a0
78 changed files with 2861 additions and 1120 deletions
+15 -8
View File
@@ -55,18 +55,23 @@ SQUARE_ENVIRONMENT=mock
# deliberately route a dev build to the real production API. Never set in a
# deployed production build.
SQUARE_ALLOW_REAL_API=
# 2FA — merchant-level authorization gate on saved-card online payments (NOT
# PSD2 SCA; Square buyer verification is the SCA mechanism and is wired for
# new-card charges). Kept as an additional fraud control until Square buyer
# verification is wired for saved-card charges. Enforcement is FAIL-CLOSED:
# 2FA — backup merchant-level authorization gate on saved-card online payments.
# NOT PSD2 SCA: Square buyer verification (3-D Secure / SCA) is the PRIMARY
# authorisation for saved-card charges and is wired for both new-card and
# saved-card charges. The 2FA gate fires only when SCA is unavailable (e.g. a
# customer's bank does not support the in-app approval flow) and every such
# fallback charge is audited (admin_audit_log 2fa_fallback_charge). Enforcement
# is FAIL-CLOSED:
# ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive)
# OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test.
# Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced
# (a mistyped env var can never silently disarm the gate; the backend logs a
# startup warning in that case). Set REQUIRE_2FA=false only in controlled
# environments.
# Code delivery: there is NO email/SMS transport yet. The verification code is
# delivered via the server log (a [2FA]-prefixed line). In enforced/production
# Code delivery: the intended channel is email/SMS (the method chosen at
# setup) — NOT wired yet. Until it lands, the verification code is delivered
# via the server log (a [2FA]-prefixed line) when the operator opts into
# TWO_FACTOR_ALLOW_LOG_DELIVERY=true (see below); in enforced/production
# environments an operator must relay the logged code to the user out-of-band;
# the API never returns the code while enforcement is ON.
REQUIRE_2FA=true
@@ -136,9 +141,11 @@ DAV_BASE_URL=http://localhost:8080
# DAV_ADMIN_PASSWORD — REQUIRED, FAIL-CLOSED. sabredav/server.php refuses to
# start when unset or set to a known weak/default value ('admin' etc.) — this
# server exposes customer PII vCards, so no public default credential is ever
# acceptable. Generate a strong random value:
# acceptable. The placeholder below satisfies compose validation only; it MUST
# be replaced before any deployment (compose.yml fails fast via
# ${DAV_ADMIN_PASSWORD:?} if it is ever unset). Generate a strong random value:
# openssl rand -hex 32
DAV_ADMIN_PASSWORD=
DAV_ADMIN_PASSWORD=changeme-admin-password
# Logging
# Set to "true" to disable ANSI color escape sequences in log output
+7 -1
View File
@@ -74,12 +74,18 @@ docker compose up --build -d
`SQUARE_WEBHOOK_NOTIFICATION_URL` and `SQUARE_WEBHOOK_SIGNATURE_KEY` in `.env` must exactly match the webhook subscription configured in the Square Dashboard. An unset URL defaults to `http://localhost:8080/webhooks/square`, which is fail-closed (503 without the signing key, 403 on missing/bad signature). If you don't need webhooks, leave both empty — the handler still rejects cleanly.
### Request snapshot encryption (`SNAPSHOT_ENC_KEY`)
The backend replays a byte-identical request to Square when it rescues a stale pending payment, so every charge's exact request body is stored on the pending row. Those snapshots contain the buyer's email and saved-card (`ccof:`) tokens — personal data — so in non-mock (sandbox/production) deployments they are encrypted at rest with AES-256-GCM under `SNAPSHOT_ENC_KEY` (a base64-encoded 32-byte key; generate with `openssl rand -base64 32`). If the key is unset or invalid, the backend logs a one-time CRITICAL warning at startup and falls back to plaintext storage — a warning, not a refusal, because money-safety first: losing a replayable snapshot would strand pending rows forever. The dev mock stores snapshots in plaintext (no real data).
### Two-factor authentication (2FA)
`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_FALLBACK` (backend `.env`, defaults to `true`) is the SCA-primary/2FA-backup posture switch. While it is on, a saved-card charge that carries no Square verification token (SCA unavailable — e.g. the customer's bank cannot run the in-app approval flow) may fall back to the homegrown 2FA code gate. Set `TWO_FACTOR_FALLBACK=false` for a security-first **SCA-only posture**: a token-less saved-card charge is then rejected 402 `verification_required` — the frontend surfaces the SCA challenge, and if the bank cannot complete it the charge fails — and cannot proceed via 2FA at all. Every charge actually authorised by the fallback writes an audited `2fa_fallback_charge` admin-audit-log row (SCA not performed, card last four, charge reference).
### Local dev (tmux)
```bash
@@ -97,7 +103,7 @@ Default logins (password: `password`):
```bash
cd backend && go build -o bin/backend ./main.go
cd frontend && npm ci && npm run build
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,333 tests compiled under the test,dev tags, as of 14 Aug 2026 (~2min)
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,440 backend test functions under the test,dev tags + 61 frontend vitest cases, as of 15 Aug 2026 (~2min)
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
# NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks —
# those suites share package-global state (Square mock ledger, in-memory webhook
+1 -1
View File
@@ -17,7 +17,7 @@ linters:
linters-settings:
errcheck:
exclude-functions:
- encoding/json.Encoder.Encode
- (*encoding/json.Encoder).Encode
- io.WriteString
- (io.Closer).Close
+4 -4
View File
@@ -14,7 +14,7 @@ require (
github.com/kovidgoyal/imaging v1.8.21
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1
golang.org/x/text v0.38.0
golang.org/x/text v0.41.0
)
require (
@@ -54,8 +54,8 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/valyala/fastjson v1.6.10 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/sync v0.22.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
@@ -70,5 +70,5 @@ require (
github.com/nyaruka/phonenumbers v1.8.0
github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/crypto v0.53.0
golang.org/x/sys v0.46.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+8 -8
View File
@@ -126,14 +126,14 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+2 -2
View File
@@ -485,7 +485,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the login response contract
Token: tokenString,
JTI: jti,
RefreshToken: refreshToken,
@@ -561,7 +561,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
return
}
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the refresh-token response contract
Token: newToken,
JTI: jti,
RefreshToken: newRefreshToken,
-5
View File
@@ -339,11 +339,6 @@ type AdminBookingDetail struct {
DurationMinutes int `json:"duration_minutes"`
}
// roundTo2 rounds a float64 to 2 decimal places
func roundTo2(f float64) float64 {
return float64(int(f*100+0.5)) / 100
}
// Helper function to parse query parameters
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
req := GetAllBookingsRequest{
+134
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
@@ -565,6 +566,139 @@ func TestCreateTillSale_SCARequired_ReturnsStructured402(t *testing.T) {
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")
}
// =============================================================================
// Verification-required surfacing at the OTHER 4 charge sites (booking, tip,
// terminal, gift-card). The till site is covered by
// TestCreateTillSale_SCARequired_ReturnsStructured402 above.
// =============================================================================
func assertStructuredVerificationRequired(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
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")
}
func assertPlain402(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
require.NotContains(t, w.Body.String(), "verification_required", "a real decline must stay a plain 402, never the SCA challenge body")
}
// TestVerificationRequiredSurfacing_AllChargeSites drives a Square
// CARD_DECLINED_VERIFICATION_REQUIRED failure through the booking, tip,
// terminal-saved-card, and gift-card charge sites: each must surface 402 with
// the structured {code:verification_required} body (so the frontend triggers
// the 3DS challenge), while a real CARD_DECLINED decline at the same site
// stays a plain 402.
func TestVerificationRequiredSurfacing_AllChargeSites(t *testing.T) {
scaErr := func(t *testing.T) error {
return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR")
}
declineErr := func(t *testing.T) error {
return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR")
}
installErr := func(t *testing.T, err error) {
t.Helper()
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: err}
t.Cleanup(func() { SquareClient = origClient })
}
t.Run("booking_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
installErr(t, scaErr(t))
cardToken := "cnon:sca-booking"
req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "sca-site-booking"}
assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx))
})
t.Run("booking_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
installErr(t, declineErr(t))
cardToken := "cnon:decline-booking"
req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "decline-site-booking"}
assertPlain402(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx))
})
t.Run("tip_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
installErr(t, scaErr(t))
cardToken := "cnon:sca-tip"
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "sca-site-tip"}
assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx))
})
t.Run("tip_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
installErr(t, declineErr(t))
cardToken := "cnon:decline-tip"
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "decline-site-tip"}
assertPlain402(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx))
})
t.Run("terminal_saved_card_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_sca_terminal", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installErr(t, scaErr(t))
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sca-site-terminal-" + bookingID}
assertStructuredVerificationRequired(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx))
})
t.Run("terminal_saved_card_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_decline_terminal", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installErr(t, declineErr(t))
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "decline-site-terminal-" + bookingID}
assertPlain402(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx))
})
t.Run("gift_card_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
installErr(t, scaErr(t))
cardToken := "cnon:sca-giftcard"
req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "sca-site-giftcard"}
assertStructuredVerificationRequired(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx))
})
t.Run("gift_card_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
installErr(t, declineErr(t))
cardToken := "cnon:decline-giftcard"
req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "decline-site-giftcard"}
assertPlain402(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx))
})
}
// the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT
// having completed 2FA setup, persisting a card is blocked with 403 and no card
// row is created — the save-card endpoint is not an un-gated side door.
+5 -4
View File
@@ -2313,12 +2313,13 @@ func assessGiftCardCancellation(ctx context.Context, q db.Querier, code string,
st.CancellationReason = "The 14-day cancellation period has expired"
default:
paymentID, _, paymentOK, perr := findGiftCardPurchasePayment(ctx, q, purchaserID, "", purchaseAmount, purchasedAt)
if perr != nil {
switch {
case perr != nil:
log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr)
st.CancellationReason = "The original purchase payment could not be verified"
} else if !paymentOK {
case !paymentOK:
st.CancellationReason = "The original purchase payment could not be found"
} else {
default:
// A completed/pending refund row means the money already
// returned (or is in flight) — the card cannot be cancelled a
// second time.
@@ -2628,7 +2629,7 @@ func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.R
// returned. Refund the DIFFERENCE via Square with a fresh
// deterministic key before neutralising, so the un-refunded remainder
// is never silently swallowed.
refundAmount = refundAmount - priorAmount
refundAmount -= priorAmount
refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10)
log.Printf("Gift-card purchase %s has a prior partial refund of £%.2f — issuing the £%.2f remainder", paymentID, priorAmount, refundAmount)
case "pending":
+1 -5
View File
@@ -3099,7 +3099,6 @@ func buildTerminalSplitRecords(primary PaymentRecord, info *BookingPaymentInfo,
bal.IdempotencyKey = &k
}
records = append(records, bal)
splitIdx++
}
if tipAmount > 0.004 {
@@ -3309,10 +3308,7 @@ func isDefinitiveCardSaveFailure(err error) bool {
case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD":
return true
}
if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" {
return true
}
return false
return square.ErrorCategory(err) == "INVALID_REQUEST_ERROR"
}
func RefundPayment(w http.ResponseWriter, r *http.Request) {
+4 -3
View File
@@ -695,11 +695,12 @@ func ProcessCancellationRefundTx(
log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err)
} else if loyaltyUsed {
loyaltyTag, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
if loyaltyErr != nil {
switch {
case loyaltyErr != nil:
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr)
} else if loyaltyTag.RowsAffected() == 0 {
case loyaltyTag.RowsAffected() == 0:
slog.Error("CRITICAL: loyalty stamp refund UPDATE affected 0 rows — user not found", "user_id", bookingUserID, "booking_id", bookingID)
} else {
default:
log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID)
}
}
+3 -1
View File
@@ -2013,7 +2013,9 @@ func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, booking
appliedCampaignsBeforeInsert := false
if hasTip && bookingInfo != nil && bErr == nil {
if pendingCampaignDiscountAmount(ctx, tx, bookingID, bookingUserID, bookingInfo.TotalAmount) > 0.004 {
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil)
if applyErr := applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil); applyErr != nil {
slog.Error("Failed to apply eligible campaigns before terminal payment insert", "booking_id", bookingID, "err", applyErr)
}
appliedCampaignsBeforeInsert = true
}
}
@@ -0,0 +1,393 @@
//go:build test && dev
package payments
// Tests for the CreateTerminalPayment SCA/2FA decision model: the
// VerificationToken field (handlers.go:114, validation 440-444, extraction
// 843-846, gate skip 1024, forwarding 1113), the admin-actor 2FA-fallback
// audit rows on the terminal and till saved-card surfaces, and the
// customer_initiated classification (Square's SCA/liability-shift signal) on
// every saved-card charge site.
//
// Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv stay sequential
// (no t.Parallel) — see the note at the top of twofa_test.go. They use
// SQUARE_ENVIRONMENT=staging (enforced, but square.NewDevClient() returns the
// in-memory mock — helperEnvEnforce2FA's production would panic a dev-build
// NewDevClient, see twofa_gate_consume_test.go).
import (
"encoding/json"
"net/http"
"testing"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// helperEnvEnforce2FAStaging flips the env to an ENFORCED non-mock Square env
// that still resolves to the in-memory mock client.
func helperEnvEnforce2FAStaging(t *testing.T) {
t.Helper()
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
}
// installRecordingClient wraps the dev mock in the shared recording client so
// tests can assert the exact CreatePaymentReq the handler sent to Square.
func installRecordingClient(t *testing.T) *recordingPaymentClient {
t.Helper()
origClient := SquareClient
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
t.Cleanup(func() { SquareClient = origClient })
return rec
}
// TestTerminalSavedCard_VerificationToken_Passthrough pins (d): a saved-card
// charge through CreateTerminalPayment forwards the SCA verification token
// verbatim on the CreatePaymentReq.
func TestTerminalSavedCard_VerificationToken_Passthrough(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
rec := installRecordingClient(t)
vrf := "vrf_terminal_sca_1"
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-sca-passthrough-" + bookingID,
VerificationToken: &vrf,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.VerificationToken
rec.mu.Unlock()
require.Equal(t, vrf, got, "the SCA verification token must be forwarded to Square on the CreatePaymentReq")
}
// TestTerminalSavedCard_VerificationToken_TooLong_Rejected pins (d): an
// oversized verification_token is rejected with 400 by ValidateVerificationToken
// before any charge branch runs.
func TestTerminalSavedCard_VerificationToken_TooLong_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
big := make([]byte, 600)
for i := range big {
big[i] = 'a'
}
token := string(big)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
IdempotencyKey: "terminal-sca-too-long-" + bookingID,
VerificationToken: &token,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "an oversized verification_token must be rejected with 400, body: %s", w.Body.String())
}
// TestTerminalSavedCard_VerificationToken_Skips2FA pins (d): when the charge
// carries a Square verification_token (SCA performed), the 2FA gate is SKIPPED
// even for a user with NO 2FA setup — a charge that would 403 on the fallback
// path succeeds via the token.
func TestTerminalSavedCard_VerificationToken_Skips2FA(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_skip", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installRecordingClient(t)
vrf := "vrf_terminal_sca_skip_1"
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-sca-skip-" + bookingID,
VerificationToken: &vrf,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "SCA performed — the token must skip the 2FA gate, body: %s", 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 terminal charge must not write a 2FA-fallback audit row")
}
// TestTerminalSavedCard_2FAFallback_Audit_AdminActor pins (c)+(d): a token-less
// terminal saved-card charge falls back to the card owner's 2FA code and writes
// a strict 2fa_fallback_charge audit row for the ADMIN actor (admin_id = the
// charging admin, target_user_id = the card owner).
func TestTerminalSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
seedTwoFAPendingCode(t, tx, userID, "334411")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_fallback", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
rec := installRecordingClient(t)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-2fa-fallback-" + bookingID,
VerificationCode: "334411",
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.VerificationToken
rec.mu.Unlock()
require.Empty(t, got, "a 2FA-fallback charge must not carry a verification token")
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
}
// TestTerminalSavedCard_2FAFallbackDisabled_402 pins (d): when the deployment
// opts out of the 2FA fallback (TWO_FACTOR_FALLBACK=false), a token-less saved-
// card terminal charge is denied 402 with the structured verification_required
// body the frontend keys on to run the SCA challenge.
func TestTerminalSavedCard_2FAFallbackDisabled_402(t *testing.T) {
helperEnvEnforce2FAStaging(t)
t.Setenv("TWO_FACTOR_FALLBACK", "false")
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
seedTwoFAPendingCode(t, tx, userID, "998800")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_no_fallback", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installRecordingClient(t)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-sca-no-fallback-" + bookingID,
VerificationCode: "998800",
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less charge with the fallback disabled must be denied 402, body: %s", w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestTillSavedCard_2FAFallback_Audit_AdminActor pins (c): an admin till
// saved-card sale authorized by the 2FA fallback writes a strict
// 2fa_fallback_charge audit row for the ADMIN actor with the till sale id as
// the reference.
func TestTillSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
seedTwoFAPendingCode(t, tx, userID, "665544")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_till_audit", "VISA", "4242")
require.NoError(t, err)
installRecordingClient(t)
key := "2fa-till-fallback-audit"
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
VerificationCode: "665544",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
var tillSaleID string
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, key).Scan(&tillSaleID))
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, tillSaleID, "admin till saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
}
// TestCustomerInitiated_ChargeClassification pins (f): the customer_initiated
// flag on the CreatePaymentReq — false for the ADMIN surfaces (terminal
// saved-card handlers.go:1121, till saved-card till.go:1179: merchant-
// initiated) and true for the CUSTOMER surfaces (booking handlers.go:2467,
// tip handlers.go:4708, gift-card giftcards.go:1676).
func TestCustomerInitiated_ChargeClassification(t *testing.T) {
t.Run("admin_terminal_saved_card_merchant_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_terminal", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
rec := installRecordingClient(t)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "ci-terminal-" + bookingID,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd, "an admin saved-card terminal charge must carry customer_details")
require.False(t, cd.CustomerInitiated, "an admin-initiated terminal saved-card charge is merchant-initiated")
})
t.Run("admin_till_saved_card_merchant_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_till", "VISA", "4242")
require.NoError(t, err)
rec := installRecordingClient(t)
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: "ci-till",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd, "an admin till saved-card charge must carry customer_details")
require.False(t, cd.CustomerInitiated, "an admin-initiated till saved-card charge is merchant-initiated")
})
t.Run("customer_booking_cardholder_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
rec := installRecordingClient(t)
cardToken := "cnon:ci-booking"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "ci-booking-" + bookingID,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd)
require.True(t, cd.CustomerInitiated, "a customer booking charge is cardholder-initiated")
})
t.Run("customer_tip_cardholder_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
rec := installRecordingClient(t)
cardToken := "cnon:ci-tip"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
IdempotencyKey: "ci-tip-" + bookingID,
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd)
require.True(t, cd.CustomerInitiated, "a customer tip charge is cardholder-initiated")
})
t.Run("customer_gift_card_cardholder_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_gc", "VISA", "4242")
require.NoError(t, err)
rec := installRecordingClient(t)
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
CardID: &cardID,
IdempotencyKey: "ci-gc",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd)
require.True(t, cd.CustomerInitiated, "a customer gift-card purchase is cardholder-initiated")
})
}
-1
View File
@@ -656,7 +656,6 @@ func TestGetTillCheckoutStatus_EmptyCheckoutID(t *testing.T) {
w := httptest.NewRecorder()
GetTillCheckoutStatus(w, req)
req = adminRequestCtx(req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
@@ -0,0 +1,59 @@
//go:build !dev
package payments
// Tests for the PRODUCTION 2FA delivery predicate (twofa_delivery_prod.go).
//
// LIMITATION (documented): the 503 "2FA requires an email or SMS delivery
// channel" branch in requireTwoFactorForCardAccess (twofa.go:193) is only
// reachable when twoFADeliveryAvailable() returns false, which happens ONLY in
// a production build (!dev && !test). Under BOTH required test runs — the
// "test,dev" run and the "test,!dev" prod-shape run — the dev/test delivery
// variant (twofa_delivery_dev.go, build tag `dev || test`) is the compiled
// function and is trivially true, so the 503 branch cannot be exercised there.
// The two test invocations DO however compile this file, and the prod-variant
// marker (twofaDeliveryProdVariant) tells the test which delivery function is
// live: a genuine production build (no dev/test tags, e.g. `go test ./...`)
// compiles twofa_delivery_prod.go, and this test then asserts the real prod
// predicate end to end.
import (
"os"
"testing"
"github.com/stretchr/testify/require"
)
// TestTwoFADeliveryAvailable_ProdPredicate asserts the production gating that
// twofa_delivery_prod.go implements: TWO_FACTOR_ALLOW_LOG_DELIVERY unset →
// no channel (false), exactly "true" → channel (true), any other value →
// no channel. In a dev/test build the marker is false and the test skips,
// because the always-true dev variant is compiled and the 503 branch is
// unreachable (documented limitation — see the file header).
func TestTwoFADeliveryAvailable_ProdPredicate(t *testing.T) {
if !twofaDeliveryProdVariant {
t.Skip("twoFADeliveryAvailable() is the dev/test build's trivially-true variant (twofa_delivery_dev.go, `dev || test`); the 503 delivery-unavailable branch is unreachable under the test tag — see the file header for the documented limitation")
}
t.Run("unset_env_is_no_channel", func(t *testing.T) {
os.Unsetenv("TWO_FACTOR_ALLOW_LOG_DELIVERY")
require.False(t, twoFADeliveryAvailable(), "production without the explicit opt-in must have NO 2FA delivery channel")
})
t.Run("empty_env_is_no_channel", func(t *testing.T) {
os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "")
require.False(t, twoFADeliveryAvailable())
})
t.Run("exact_true_is_a_channel", func(t *testing.T) {
os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "true")
require.True(t, twoFADeliveryAvailable(), "the explicit insecure log-delivery opt-in must open the channel")
})
t.Run("any_other_value_is_no_channel", func(t *testing.T) {
for _, v := range []string{"1", "yes", "on", "True", "TRUE", "false"} {
os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", v)
require.False(t, twoFADeliveryAvailable(), "value %q must NOT open the delivery channel (exact 'true' only)", v)
}
})
}
@@ -0,0 +1,11 @@
//go:build dev || test
package payments
// twofaDeliveryProdVariant reports whether the PRODUCTION delivery predicate
// (twofa_delivery_prod.go, !dev && !test) is the compiled function in this
// build. Under `dev` OR `test` tags the dev/test delivery variant
// (twofa_delivery_dev.go) is compiled instead — always true, so the 503
// delivery-unavailable branch is unreachable there.
//lint:ignore U1000 referenced only from the prod-tag test (twofa_delivery_prod_test.go, !dev && !test); deliberately unused under dev/test tags
const twofaDeliveryProdVariant = false
@@ -0,0 +1,9 @@
//go:build !dev && !test
package payments
// twofaDeliveryProdVariant reports whether the PRODUCTION delivery predicate
// (twofa_delivery_prod.go, !dev && !test) is the compiled function in this
// build. True only in a genuine production build — neither dev nor test tag —
// where twoFADeliveryAvailable() gates on TWO_FACTOR_ALLOW_LOG_DELIVERY.
const twofaDeliveryProdVariant = true
+208 -7
View File
@@ -17,6 +17,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"crussell/db"
@@ -141,7 +142,8 @@ func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing
// 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.
// fallback-authorized ones — and the row's details JSON must carry the strict
// shape (sca_performed:false, fallback_reason, card_last4, reference_id, notes).
func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
@@ -163,12 +165,31 @@ func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
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")
details := assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
require.Equal(t, false, details["sca_performed"])
require.Equal(t, "verification_unavailable", details["fallback_reason"])
}
// assertTwoFAFallbackAuditDetails reads the most recent '2fa_fallback_charge'
// admin_audit_log row for (target_user_id, admin_id) and asserts the strict
// insertTwoFAFallbackAudit details shape: sca_performed=false,
// fallback_reason="verification_unavailable", card_last4, reference_id, notes.
func assertTwoFAFallbackAuditDetails(t *testing.T, ctx context.Context, q db.Querier, userID, adminID, referenceID, notes, wantLast4 string) map[string]any {
t.Helper()
var detailsJSON []byte
require.NoError(t, q.QueryRow(ctx, `
SELECT details FROM admin_audit_log
WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $2
ORDER BY created_at DESC LIMIT 1
`, userID, adminID).Scan(&detailsJSON))
var details map[string]any
require.NoError(t, json.Unmarshal(detailsJSON, &details))
require.Equal(t, false, details["sca_performed"], "details.sca_performed must be false")
require.Equal(t, "verification_unavailable", details["fallback_reason"], "details.fallback_reason")
require.Equal(t, wantLast4, details["card_last4"], "details.card_last4")
require.Equal(t, referenceID, details["reference_id"], "details.reference_id")
require.Equal(t, notes, details["notes"], "details.notes")
return details
}
// TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA-
@@ -202,6 +223,125 @@ func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row")
}
// TestTwoFactorEnforced_TipSavedCard_Fallback_Audit pins the strict audit
// trail on the TIP SAVE gate (CreateTipPayment with save_card=true): the
// token-less save is authorized by the 2FA fallback, and the resulting
// charge writes a 2fa_fallback_charge row for the customer actor.
func TestTwoFactorEnforced_TipSavedCard_Fallback_Audit(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "556600")
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
cardToken := "cnon:2fa-tip-save-audit"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-tip-save-audit",
VerificationCode: "556600",
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
}
// TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit pins the strict audit
// trail on the TIP CHARGE gate (CreateTipPayment charging an existing saved
// card): the token-less charge is authorized by the 2FA fallback and writes a
// 2fa_fallback_charge row for the customer actor.
func TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "112211")
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_charge_audit", "VISA", "4242")
require.NoError(t, err)
req := CreateTipPaymentRequest{
Amount: 500,
CardID: &cardID,
IdempotencyKey: "2fa-tip-charge-audit",
VerificationCode: "112211",
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
}
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit pins the strict
// audit trail on the gift-card purchase saved-card gate (giftcards.go): the
// token-less charge is authorized by the 2FA fallback and writes a
// 2fa_fallback_charge row for the customer actor with the payment id as the
// reference.
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "778811")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_gc_audit", "VISA", "4242")
require.NoError(t, err)
key := "2fa-buy-gc-fallback-audit"
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
CardID: &cardID,
IdempotencyKey: key,
VerificationCode: "778811",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
var payID string
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE idempotency_key = $1`, key).Scan(&payID))
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, payID, "gift-card purchase authorized via 2FA fallback (SCA unavailable)", "4242")
}
// TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit pins the strict audit
// trail on the add-card save gate (handlers.go CreatePaymentMethod): persisting
// a card via the 2FA fallback writes a 2fa_fallback_charge row for the customer
// actor with an empty reference id (no charge reference exists for a save).
func TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
t.Cleanup(func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
})
token := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "998877")
req := CreatePaymentMethodRequest{
CardToken: "cnon:2fa-pm-save-audit",
VerificationCode: "998877",
}
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, "", "card persisted via 2FA fallback (SCA unavailable)", "4242")
}
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
// allows every request without touching the DB (no user rows are consulted).
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
@@ -631,3 +771,64 @@ func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t *
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&tipCount))
require.Equal(t, 1, tipCount, "the retry must not create a second tip")
}
// TestTwoFactorFallbackEnabled pins the TWO_FACTOR_FALLBACK policy-switch
// parse matrix (twofa.go:100-107): false/0/off/no are case-insensitive
// DISABLES; every other value — empty, unset, unknown, true/1/on/yes — keeps
// the fallback enabled (the shipped default). The exported
// PaymentService.TwoFactorFallbackEnabled wrapper must match the unexported
// predicate. Sequential (no t.Parallel): flips process-global env vars.
func TestTwoFactorFallbackEnabled(t *testing.T) {
tests := []struct {
name string
val string
want bool
}{
{"false disables", "false", false},
{"zero disables", "0", false},
{"off disables", "off", false},
{"no disables", "no", false},
{"case_insensitive_False_disables", "False", false},
{"case_insensitive_OFF_disables", "OFF", false},
{"case_insensitive_No_disables", "No", false},
{"empty_keeps_enabled", "", true},
{"unknown_keeps_enabled", "enable", true},
{"true_keeps_enabled", "true", true},
{"one_keeps_enabled", "1", true},
{"on_keeps_enabled", "on", true},
{"yes_keeps_enabled", "yes", true},
{"case_insensitive_TRUE_keeps_enabled", "TRUE", true},
{"case_insensitive_ON_keeps_enabled", "ON", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("TWO_FACTOR_FALLBACK", tt.val)
require.Equal(t, tt.want, twoFactorFallbackEnabled())
require.Equal(t, tt.want, NewPaymentService().TwoFactorFallbackEnabled(), "exported wrapper must match twoFactorFallbackEnabled")
})
}
t.Run("unset_keeps_enabled_default", func(t *testing.T) {
prev, had := os.LookupEnv("TWO_FACTOR_FALLBACK")
os.Unsetenv("TWO_FACTOR_FALLBACK")
defer func() {
if had {
os.Setenv("TWO_FACTOR_FALLBACK", prev)
} else {
os.Unsetenv("TWO_FACTOR_FALLBACK")
}
}()
require.True(t, twoFactorFallbackEnabled())
require.True(t, NewPaymentService().TwoFactorFallbackEnabled())
})
}
// TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue documents the dev/test
// delivery predicate: twofa_delivery_dev.go (`dev || test`) always reports a
// delivery channel (the [2FA] log relay), so the 503 "2FA requires an email or
// SMS delivery channel" branch (twofa.go:193) is UNREACHABLE in this build.
// The production predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered
// by twofa_delivery_prod_test.go in a !dev build (see its header for the
// documented limitation).
func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) {
require.True(t, twoFADeliveryAvailable())
}
+6 -2
View File
@@ -1046,7 +1046,7 @@ func GetDefaultHoursConflictingBookings(w http.ResponseWriter, r *http.Request)
bookingLondon := ob.StartTime.In(londonLocation)
ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
proposed, _ := proposedByWeekday[ourWeekday]
proposed := proposedByWeekday[ourWeekday]
isConflict := false
if !proposed.IsOpen {
isConflict = true
@@ -1204,7 +1204,11 @@ func GetScheduledDefaultHoursChange(w http.ResponseWriter, r *http.Request) {
}
var scheduledHours []DefaultHours
json.Unmarshal([]byte(*hoursJSON), &scheduledHours)
if err := json.Unmarshal([]byte(*hoursJSON), &scheduledHours); err != nil {
log.Printf("Failed to unmarshal scheduled default hours JSON: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
resp := ScheduledHoursChange{
EffectiveDate: *effDate,
@@ -483,7 +483,7 @@ func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Re
// Go: Sun=0,Mon=1,...,Sat=6 → Our convention: Mon=0,...,Sun=6
ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
proposed, _ := proposedByWeekday[ourWeekday]
proposed := proposedByWeekday[ourWeekday]
isConflict := false
if !proposed.IsOpen {
@@ -501,13 +501,14 @@ func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Re
// For midnight-crossing bookings (endMinutes < startMinutes), the booking
// extends past midnight and always conflicts with daily hours since the
// day's open window cannot span past midnight.
if startMinutes < 0 || endMinutes < 0 {
switch {
case startMinutes < 0 || endMinutes < 0:
// parse error — treat as conflict
isConflict = true
} else if endMinutes < startMinutes {
case endMinutes < startMinutes:
// Booking crosses midnight — always a conflict with daily hours
isConflict = true
} else if startMinutes < propStartMinutes || endMinutes > propEndMinutes {
case startMinutes < propStartMinutes || endMinutes > propEndMinutes:
isConflict = true
}
}
@@ -30,6 +30,7 @@ import (
"crussell/handlers/payments"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
@@ -3888,7 +3889,7 @@ func TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache(t *testing.T
}
origSquare := payments.SquareClient
payments.SquareClient = square.NewDevClient()
payments.SquareClient = testutils.NewTestSquareClient()
defer func() { payments.SquareClient = origSquare }()
t.Cleanup(func() { payments.InvalidateSquareCustomerCache(guestID) })
+66 -3
View File
@@ -15,9 +15,7 @@ import (
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
)
// ============================================================
) // ============================================================
// GetGDPRExportHandler Tests
// ============================================================
@@ -503,6 +501,71 @@ func TestAnonymizeUser_RetainsEditRequestNotes(t *testing.T) {
}
}
// TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback closes the GDPR erasure gap
// for admin_audit_log: a '2fa_fallback_charge' row (insertTwoFAFallbackAudit,
// handlers/payments) carries target_user_id = the erased user PLUS
// details.card_last4 — the audit row MUST survive erasure (GDPR Art 30 records
// of processing / financial audit trail) but de-identified: the user link is
// NULLed exactly as delete_guest_user() does (which scrubs target_user_id only,
// leaving details untouched — anonymize_user mirrors that consistency).
func TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// A 2FA-fallback audit row for a CIT saved-card charge: target_user_id is
// the customer and admin_id is the customer's own userID (the CIT actor —
// see insertTwoFAFallbackAudit). details carries the card_last4 PII.
var auditID string
err = tx.QueryRow(ctx, `
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
VALUES ($1, '2fa_fallback_charge', $1, $2::jsonb)
RETURNING id
`, userID, `{"sca_performed": false, "fallback_reason": "verification_unavailable", "card_last4": "4242", "reference_id": "booking123", "notes": "saved-card charge authorized via 2FA fallback (SCA unavailable)"}`).Scan(&auditID)
if err != nil {
t.Fatalf("failed to insert 2fa_fallback_charge audit row: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
// The audit row survives erasure (audit retention) but is de-identified:
// target_user_id is NULLed. details is left untouched, mirroring
// delete_guest_user() exactly (it scrubs target_user_id only).
var targetUserID interface{}
var details json.RawMessage
err = tx.QueryRow(ctx, `
SELECT target_user_id, details FROM admin_audit_log WHERE id = $1
`, auditID).Scan(&targetUserID, &details)
if err != nil {
t.Fatalf("failed to query audit row after anonymization: %v", err)
}
if targetUserID != nil {
t.Errorf("expected target_user_id to be NULL after anonymization, got %v", targetUserID)
}
if len(details) == 0 {
t.Error("expected the audit row to survive erasure (retained, de-identified)")
}
// No residual audit rows may still reference the erased user.
var remaining int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_log WHERE target_user_id = $1
`, userID).Scan(&remaining)
if err != nil {
t.Fatalf("failed to count residual audit rows: %v", err)
}
if remaining != 0 {
t.Errorf("expected 0 audit rows still referencing the erased user, got %d", remaining)
}
}
func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
@@ -0,0 +1,37 @@
//go:build test
package user
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crussell/mw"
"github.com/stretchr/testify/require"
)
// deleteAccountRequest builds the DELETE /api/user/account request the handler
// requires (finding 3): the current password in the body, plus a 2FA code
// when one is supplied (enforced environments + 2FA-enabled users only).
//
// Defined in a shared, non-dev test helper file so prod-tag (test,!dev) test
// builds can keep exercising the deletion path (gdpr_test.go, profile_test.go)
// even though the coverage suite in user_coverage_test.go is dev-only.
func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request {
t.Helper()
body := map[string]string{"current_password": password}
if code != "" {
body["verification_code"] = code
}
b, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
return req
}
+1 -24
View File
@@ -74,30 +74,13 @@ var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS deliv
// imports neither handlers/user nor handlers/payments, so the payments
// card-access gate can verify a real 2FA challenge without an import cycle
// (B11c). The identifiers below are thin aliases/wrappers so the HTTP handlers
// and the existing tests keep their original names.
// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed
// before the pending code is invalidated and a new one must be requested.
const twoFAMaxAttempts = twofa.MaxAttempts
// twoFAAttemptWindow bounds how long a per-user attempt counter lives before
// resetting, and doubles as the stale-entry eviction horizon for the map.
const twoFAAttemptWindow = twofa.AttemptWindow
// keep their original names.
// hashTwoFACode returns the hex digest of a verification code as stored in the
// DB (pepper-driven HMAC-SHA256, or the legacy plain SHA-256 when the pepper is
// unset). Delegates to the shared implementation.
func hashTwoFACode(code string) string { return twofa.Hash(code) }
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest.
func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) }
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
// digest, always in constant time. Delegates to the shared implementation.
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
return twofa.VerifyHash(reqCode, storedHash)
}
// twoFAAttemptState aliases the shared per-user attempt state.
type twoFAAttemptState = twofa.AttemptState
@@ -116,12 +99,6 @@ func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool {
return !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown
}
// twoFAResetAttempts zeroes the shared per-user attempt counter in place.
// Called on successful verify only — a fresh code mint must NOT reset it (B11b).
func twoFAResetAttempts(userID string) {
twofa.ResetAttempts(userID)
}
// deliverTwoFACode generates a fresh verification code and persists only its
// digest plus the pending expiry (updating two_factor_method when method is
// non-empty).
+17
View File
@@ -44,6 +44,23 @@ func twofaEnvEnforced(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
}
// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed
// before the pending code is invalidated and a new one must be requested.
const twoFAMaxAttempts = twofa.MaxAttempts
// twoFAAttemptWindow bounds how long a per-user attempt counter lives before
// resetting, and doubles as the stale-entry eviction horizon for the map.
const twoFAAttemptWindow = twofa.AttemptWindow
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest.
func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) }
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
// digest, always in constant time. Delegates to the shared implementation.
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
return twofa.VerifyHash(reqCode, storedHash)
}
func twofaEnvUnenforced(t *testing.T) {
t.Helper()
// Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
+8 -18
View File
@@ -45,23 +45,6 @@ import (
// DeleteAccountHandler Coverage Tests
// =============================================================================
// deleteAccountRequest builds the DELETE /api/user/account request the handler
// now requires (finding 3): the current password in the body, plus a 2FA code
// when one is supplied (enforced environments + 2FA-enabled users only).
func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request {
t.Helper()
body := map[string]string{"current_password": password}
if code != "" {
body["verification_code"] = code
}
b, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
return req
}
// TestDeleteAccount_Unauthorized verifies that deleting an account without
// setting user ID in context returns 401 Unauthorized.
func TestDeleteAccount_Unauthorized(t *testing.T) {
@@ -996,8 +979,15 @@ func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) {
// the same (anonymized) user must re-mint a fresh Square customer instead of
// reusing the deleted one's stale cached id.
func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) {
// Use a prod-safe in-memory Square stand-in whose delete methods always
// succeed. The handler fires an ASYNC goroutine for Square erasure; if a
// delete fails it raises a critical notification through notifyCtx, which
// in tests carries the request transaction — writing to the SAME pgx.Tx the
// test below reads, racing it (pgx LRUCache/PgConn). Deterministic success
// keeps the goroutine on its own background pool context, so the test's tx
// is never touched concurrently.
savedSquareClient := payments.SquareClient
payments.SquareClient = square.NewDevClient()
payments.SquareClient = testutils.NewTestSquareClient()
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
ctx, tx := testutils.SetupTestTx(t)
+6 -3
View File
@@ -14,6 +14,7 @@ import (
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/testdb"
)
@@ -378,13 +379,15 @@ func (c *recordingErasureClient) customerDeletes() []string {
return append([]string(nil), c.deletedCustomers...)
}
// newErasureTestClient builds a recording client over the dev mock, forcing
// newErasureTestClient builds a recording client over a prod-safe in-memory
// Square stand-in (testutils.NewTestSquareClient compiles under both dev and
// prod build tags, unlike the dev-only square.NewDevClient), forcing
// SQUARE_ENVIRONMENT=mock so a developer's production env var can never panic
// NewDevClient mid-test.
// a dev NewDevClient mid-test.
func newErasureTestClient(t *testing.T) *recordingErasureClient {
t.Helper()
t.Setenv("SQUARE_ENVIRONMENT", "mock")
return &recordingErasureClient{SquareClient: square.NewDevClient()}
return &recordingErasureClient{SquareClient: testutils.NewTestSquareClient()}
}
// seedErasureOutboxRow inserts a soft-deleted user_saved_cards row that the
+193
View File
@@ -2456,3 +2456,196 @@ func TestDevClient_CreatePayment_SavedCardVerification_Grandfathered(t *testing.
})
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "a non-grandfathered card must stay gated")
}
// =============================================================================
// Deterministic verification-token parsing and prefix binding
// =============================================================================
// TestParseVerifyToken pins the deterministic verify_mock_<prefix>_<amount>
// [_ok|_deny] encoding: the outcome suffix defaults to approval, "_deny" sets
// denied=true, and anything malformed is not parseable (an opaque token — the
// same shape as real Square's verification tokens).
func TestParseVerifyToken(t *testing.T) {
tests := []struct {
name string
token string
wantOK bool
wantDenied bool
wantAmount int64
wantPrefix string
}{
{"explicit ok suffix", "verify_mock_mock_5000_ok", true, false, 5000, "mock"},
{"deny suffix sets denied", "verify_mock_mock_5000_deny", true, true, 5000, "mock"},
{"no suffix defaults to approval", "verify_mock_4242_2500", true, false, 2500, "4242"},
{"prefix with underscores", "verify_mock_visa_3000_deny", true, true, 3000, "visa"},
{"opaque token is not parseable", "vrf_opaque_123", false, false, 0, ""},
{"marker with empty rest", "verify_mock_", false, false, 0, ""},
{"non-numeric amount", "verify_mock_mock_abc_ok", false, false, 0, ""},
{"missing amount", "verify_mock_mock_ok", false, false, 0, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed, ok := parseVerifyToken(tt.token)
require.Equal(t, tt.wantOK, ok)
if ok {
require.Equal(t, tt.wantDenied, parsed.denied)
require.Equal(t, tt.wantAmount, parsed.amount)
require.Equal(t, tt.wantPrefix, parsed.prefix)
}
})
}
}
// TestVerificationTokenPrefixForSource pins the card prefix a verify_mock_*
// token binds to for a source_id: the SAME derivation the dev frontend uses, so
// the two sides cannot drift.
func TestVerificationTokenPrefixForSource(t *testing.T) {
assert.Equal(t, "4242", verificationTokenPrefixForSource("cnon:test-card"))
assert.Equal(t, "4111", verificationTokenPrefixForSource("cnon:visa"))
assert.Equal(t, "5555", verificationTokenPrefixForSource("cnon:mastercard"))
assert.Equal(t, "3782", verificationTokenPrefixForSource("cnon:amex"))
assert.Equal(t, "mock", verificationTokenPrefixForSource("ccof:mock_card"))
assert.Equal(t, "abc", verificationTokenPrefixForSource("ccof:abc"))
assert.Equal(t, "abcd", verificationTokenPrefixForSource("ccof:abcd"))
assert.Equal(t, "", verificationTokenPrefixForSource("unknown-source"))
}
// TestVerificationTokenInvalidError pins Square's VERIFICATION_TOKEN_INVALID
// rejection builder: a definitive payment error with the PAYMENT_METHOD_ERROR
// category and a 400 status.
func TestVerificationTokenInvalidError(t *testing.T) {
err := verificationTokenInvalidError("vrf_bad", "ccof:card_1")
require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
require.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
require.True(t, IsDefinitivePaymentError(err), "VERIFICATION_TOKEN_INVALID must classify as a definitive payment error")
}
// =============================================================================
// Challenge resolution: ApprovePendingVerification, ChallengeResult auto/deny,
// and the stateless _deny token suffix
// =============================================================================
// TestDevClient_ApprovePendingVerification_OpaqueTokenResolution locks the
// shared-state challenge resolution for OPAQUE (real-Square-shaped) tokens: a
// token for a pending challenge is invalid, ApprovePendingVerification marks
// the challenge approved, and the next tokenized charge then succeeds.
func TestDevClient_ApprovePendingVerification_OpaqueTokenResolution(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_approve"
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-gate",
})
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
_, err = client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-before",
VerificationToken: "vrf_opaque_not_approved",
})
require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err), "an opaque token for a still-pending challenge must be invalid")
client.ApprovePendingVerification(ccof)
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr", IdempotencyKey: "approve-after",
VerificationToken: "vrf_opaque_approved_1",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status, "an approved challenge must resolve the opaque token")
}
// TestDevClient_ApprovePendingVerification_CreatesIfMissing locks the
// create-if-missing behavior: approving a card with NO recorded challenge still
// lets a subsequent tokenized charge through.
func TestDevClient_ApprovePendingVerification_CreatesIfMissing(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_approve_fresh"
client.ApprovePendingVerification(ccof)
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_appr2", IdempotencyKey: "approve-fresh",
VerificationToken: "vrf_opaque_fresh_1",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
}
// TestDevClient_ChallengeResult_Auto locks ChallengeResult="auto": the banking-
// app challenge resolves itself as approved at gate time, so the next tokenized
// retry succeeds WITHOUT an explicit ApprovePendingVerification call.
func TestDevClient_ChallengeResult_Auto(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
client.ChallengeResult = "auto"
ctx := context.Background()
const ccof = "ccof:mock_auto"
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_auto", IdempotencyKey: "auto-gate",
})
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_auto", IdempotencyKey: "auto-retry",
VerificationToken: "vrf_opaque_auto_1",
})
require.NoError(t, err, "ChallengeResult=auto must pre-approve the challenge so the retry succeeds")
require.Equal(t, "COMPLETED", result.Status)
}
// TestDevClient_ChallengeResult_Deny locks ChallengeResult="deny": the buyer
// denies every banking-app challenge, so ANY verification token — including a
// statelessly-approved deterministic one — is definitively rejected.
func TestDevClient_ChallengeResult_Deny(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
client.ChallengeResult = "deny"
ctx := context.Background()
const ccof = "ccof:mock_deny_all"
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny", IdempotencyKey: "deny-1",
VerificationToken: "verify_mock_mock_5000_ok",
})
require.Error(t, err)
require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err), "a buyer denial must reject even a deterministically-ok token")
_, err = client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny", IdempotencyKey: "deny-2",
VerificationToken: "verify_mock_mock_5000_deny",
})
require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
}
// TestDevClient_VerifyMockDenyTokenSuffix locks the stateless _deny token
// suffix: verify_mock_<prefix>_<amount>_deny encodes a buyer denial and is
// rejected with VERIFICATION_TOKEN_INVALID even with NO shared-state challenge,
// while the _ok encoding of the SAME binding succeeds (the control).
func TestDevClient_VerifyMockDenyTokenSuffix(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
const ccof = "ccof:mock_deny_suffix"
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny2", IdempotencyKey: "deny-suffix",
VerificationToken: "verify_mock_mock_5000_deny",
})
require.Error(t, err)
require.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
require.True(t, IsDefinitivePaymentError(err), "a _deny token is a definitive rejection")
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_deny2", IdempotencyKey: "deny-suffix-ok",
VerificationToken: "verify_mock_mock_5000_ok",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status, "the _ok encoding of the same binding must succeed (control)")
}
+92
View File
@@ -0,0 +1,92 @@
//go:build test
package testutils
import (
"context"
"crypto/sha256"
"fmt"
"sync"
"time"
"crussell/clock"
"crussell/internal/square"
)
// NewTestSquareClient returns an in-memory SquareClient for tests that must
// compile under BOTH the dev ("test,dev") and prod ("test,!dev") build tags.
// The real dev mock (internal/square.NewDevClient) is only compiled under the
// dev tag, so prod-tag tests cannot reference it. This lightweight stand-in
// implements just the surface the GDPR erasure / cache-invalidation tests
// exercise (CreateCustomer, CreateCardOnFile, DeleteCustomer,
// DeleteCardOnFile); any other method panics (never called by those tests).
//
// CreateCustomer is deterministic per email: re-provisioning the SAME email
// returns the same id, while a different email (e.g. the anonymized
// anon-{id}@anon.invalid address) mints a different id — the property the
// cache-invalidation tests assert on.
func NewTestSquareClient() square.SquareClient {
return &testSquareClient{
customersByEmail: map[string]*square.CustomerResult{},
}
}
// testSquareClient is a minimal in-memory SquareClient for test-only use.
// It embeds the square.SquareClient interface so it satisfies the full
// interface without implementing every method; only the methods below are
// overridden and actually called by the erasure/cache tests.
type testSquareClient struct {
square.SquareClient // embedded interface — satisfies SquareClient; unoverridden methods panic if called
mu sync.Mutex
seq int
customersByEmail map[string]*square.CustomerResult
}
func (c *testSquareClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if existing, ok := c.customersByEmail[email]; ok {
return existing, nil
}
sum := sha256.Sum256([]byte(email))
customer := &square.CustomerResult{
ID: "cus_test_" + fmt.Sprintf("%x", sum)[:12],
Email: email,
CreatedAt: clock.Now().UTC().Format(time.RFC3339),
}
c.customersByEmail[email] = customer
return customer, nil
}
func (c *testSquareClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.seq++
return &square.CardOnFile{
ID: fmt.Sprintf("test_card_%d", c.seq),
CardID: fmt.Sprintf("ccof:test_%d", c.seq),
Brand: "VISA",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
ReferenceID: userID,
Enabled: true,
}, nil
}
func (c *testSquareClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return nil
}
func (c *testSquareClient) DeleteCustomer(ctx context.Context, customerID string) error {
c.mu.Lock()
defer c.mu.Unlock()
for email, customer := range c.customersByEmail {
if customer.ID == customerID {
delete(c.customersByEmail, email)
return nil
}
}
return nil
}
+12
View File
@@ -28,6 +28,18 @@ export default defineConfig(
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// Match eslint.config.js: underscore-prefixed variables are
// intentionally unused (catch params, no-op callbacks, map keys).
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}
]
}
},
{
+657 -466
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -16,7 +16,11 @@
getLunchProtectionForSlots,
timeToMinutes
} from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots';
import {
formatLocalDateTime,
getLondonTodayCalendarDate,
parseWallClockDate
} from '$lib/utils/timeSlots';
import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -71,7 +75,7 @@
// ─── Date constants ─────────────────────────────────────
const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate;
const maxDate = new Date(
const maxDate = new SvelteDate(
todayCalendarDate.year,
todayCalendarDate.month - 1,
todayCalendarDate.day
@@ -458,7 +462,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate);
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -2,7 +2,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteDate, SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -217,7 +217,7 @@
// Date Boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -408,7 +408,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const checkDate = new Date(now);
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -388,8 +388,8 @@
<span class="text-xs text-muted-foreground">Expiry Period</span>
<p class="text-sm font-medium">{settings.gift_card_expiry_months} months</p>
<p class="text-xs text-muted-foreground">
Rolling from last use — each balance check, top-up, redemption or payment
resets the timer.
Rolling from last use — each balance check, top-up, redemption or payment resets the
timer.
</p>
</div>
<div>
@@ -5,6 +5,7 @@
import { range, formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -179,7 +180,7 @@
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new Date(fromISO + 'T00:00:00Z');
const to = new Date(toISO + 'T00:00:00Z');
const first = new Date(from);
const first = new SvelteDate(from);
const day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day;
@@ -187,7 +188,7 @@
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new Date(d)));
}
}
@@ -749,7 +750,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings}
>
Refresh
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
@@ -58,7 +58,7 @@
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -391,7 +391,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const checkDate = new Date(now);
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -458,8 +458,8 @@
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">When to use this</summary>
<p class="mt-1">
Use for genuinely excusable cancellations, or when the salon cancels and chooses
not to keep the money. When unchecked, standard notice-period fees apply (e.g. a
Use for genuinely excusable cancellations, or when the salon cancels and chooses not
to keep the money. When unchecked, standard notice-period fees apply (e.g. a
customer who calls up to cancel).
</p>
</details>
@@ -6,13 +6,16 @@
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
@@ -65,7 +68,7 @@
// the customer is not charged twice. A changed cart/amount/payment method
// yields a different composite key, so genuinely new sales get fresh keys.
// Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern.
let idempotencyKeys = new Map<string, string>();
let idempotencyKeys = new SvelteMap<string, string>();
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
// saved_card charges also key on the selected card id so switching to a
@@ -148,10 +151,13 @@
let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () =>
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
selectedCustomer?.id
? adminRequestNewTwoFactorCode(selectedCustomer.id)
: requestNewTwoFactorCode()
});
// The saved-card option is hidden outright unless a customer is selected
@@ -408,7 +414,10 @@
// 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)) {
if (
paymentMethod === 'saved_card' &&
isVerificationRequiredSignal(responseStatus, errText)
) {
await runTillSavedCardSCA(body);
continue;
}
@@ -437,9 +446,9 @@
isProcessingPaymentSync = false;
processing = false;
}
}
}
/**
/**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
* with the verification-required signal. The CUSTOMER approves the 3DS
* challenge in their banking app; the operator's screen shows the waiting
@@ -449,7 +458,7 @@
* '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> {
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.
@@ -491,17 +500,13 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
}
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(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`);
}
throw new Error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally {
awaitingSCA = false;
}
}
}
</script>
<div class="rounded-xl border bg-card">
@@ -585,13 +590,13 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
>
</div>
{#if giftCardAmountTooHigh}
<p class="text-xs text-red-700"
>Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})</p
>
<p class="text-xs text-red-700">
Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})
</p>
{/if}
<p class="text-xs text-muted-foreground"
>Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction</p
>
<p class="text-xs text-muted-foreground">
Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction
</p>
</div>
{:else}
<Button
@@ -928,7 +933,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
{/if}
{#if awaitingSCA}
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
<div
class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
@@ -204,7 +204,8 @@
const sortedBlockers = $derived.by(() => {
return [...blockers].sort(
(a, b) => parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
(a, b) =>
parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
);
});
@@ -878,7 +879,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkOverlappingBookings}
>
Refresh
@@ -731,8 +731,8 @@
selectedUser.previousFirstName,
selectedUser.previousLastName
)}
? They will no longer need a 2FA code for card payments. Use this only when the user has
lost access to their 2FA method.
? They will no longer need a 2FA code for card payments. Use this only when the user has lost
access to their 2FA method.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -3,7 +3,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
@@ -479,7 +479,7 @@
const [hours, minutes] = availableStartTime.split(':').map(Number);
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
start = new Date(y, m - 1, d, hours, minutes, 0, 0);
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
} else {
// Fallback: Calculate immediate start time (rounded to next 15 min)
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -491,8 +491,8 @@
});
const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number);
const now = new Date(y, m - 1, d, h, min, 0, 0);
start = new Date(now);
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
start = new SvelteDate(now);
const minutes = start.getMinutes();
const remainder = 15 - (minutes % 15);
if (remainder !== 15 && remainder !== 0) {
@@ -6,6 +6,7 @@
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { formatDuration, range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -228,7 +229,7 @@
}
function getDefaultEffectiveDate(): string {
const d = new Date();
const d = new SvelteDate();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
@@ -814,7 +815,7 @@
</div>
<!-- Effective Date & Conflict Resolution -->
<div class="border-t px-4 pb-4 pt-4">
<div class="border-t px-4 pt-4 pb-4">
<div class="space-y-3">
<div>
<label for="effective_date" class="mb-1 block text-sm font-medium text-gray-700">
@@ -879,7 +880,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings}
>
Refresh
@@ -19,6 +19,7 @@
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at
// "10am June 15" stays at 10am BST regardless of when the booking was made.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte';
@@ -40,6 +41,7 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
campaignDiscountPence,
depositChargePence,
@@ -173,6 +175,12 @@
// 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 proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let waitingForSCA = $state(false);
// Retryable deposit failure message shown on the payment step (challenge
// cancelled/failed, decline) so the retry affordance matches the outcome.
let depositError = $state<string | null>(null);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () =>
@@ -412,6 +420,9 @@
depositTokenAmount = amountPence;
depositTokenizedAt = Date.now();
depositTokenizedForSaveCard = depositSaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -449,14 +460,18 @@
// the cached idempotency key above (never regenerated across the
// challenge-then-charge).
if (selectedPaymentMethod && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runDepositSCAProactively(amountPence);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
depositError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(depositError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
}
const body: Record<string, unknown> = {
@@ -562,7 +577,8 @@
// charge), surface the guidance and let the user retry — never re-run SCA
// silently mid-flow.
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
toast.warning(VERIFICATION_REQUIRED_MESSAGE);
depositError = VERIFICATION_REQUIRED_MESSAGE;
toast.warning(depositError);
return;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
@@ -589,9 +605,7 @@
amountPence,
overflowPence: Math.max(
0,
amountPence -
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100) - depositDiscountPence
),
chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount,
@@ -626,21 +640,22 @@
depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!');
} else {
toast.warning(
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
} catch {
toast.warning(
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
} else {
toast.warning(
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.'
);
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
// A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) —
@@ -653,6 +668,17 @@
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the deposit
// charge did NOT land — a retry that re-runs SCA and mints a fresh
// token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED under the
// same key. Regenerate the key on 402 so the next Pay click gets a
// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
// and on challenge-cancelled/sca-failed (no charge was attempted).
if (response.status === 402) {
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
}
}
/**
@@ -999,7 +1025,7 @@
// Initialize date boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -1083,7 +1109,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate);
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -1306,7 +1332,7 @@
// =============== Time Slot Generation ===============
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
const date = new Date();
const date = new SvelteDate();
date.setHours(hours, minutes, 0, 0);
date.setMinutes(date.getMinutes() + durationMinutes);
const endHours = date.getHours().toString().padStart(2, '0');
@@ -2695,6 +2721,38 @@
onCancel={cancelOverflowConfirmation}
/>
{:else}
<!-- Out-of-band SCA challenge: the buyer approves in their
banking app while this panel shows. -->
{#if waitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
{#if depositError}
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-800">{depositError}</p>
<Button
variant="outline"
size="sm"
class="mt-3 w-full"
onclick={() => (depositError = null)}
>
Try Again
</Button>
</div>
{/if}
<BookingSummary
services={selectedServices}
date={selectedDate}
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { formatTime } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
type DefaultHours = {
weekday: number; // 0=Mon, 1=Tue, ..., 6=Sun
@@ -80,7 +81,7 @@
const today = getLondonDate();
const dayOfWeek = today.getDay(); // 0=Sun
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(today.getDate() + offset);
return fmtDate(monday);
}
@@ -115,7 +115,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
</p>
</div>
{/if}
@@ -373,8 +373,10 @@
<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'}).
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome ({challengeResult ===
'deny'
? 'deny'
: 'approve'}).
</p>
<button
type="button"
@@ -51,22 +51,9 @@
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={loading}
disabled={loading}
autofocus
onclick={onConfirm}
>
<Button class="flex-1" {loading} disabled={loading} autofocus onclick={onConfirm}>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={loading}
onclick={onCancel}
>
Cancel
</Button>
<Button variant="outline" class="flex-1" disabled={loading} onclick={onCancel}>Cancel</Button>
</div>
</div>
@@ -9,13 +9,13 @@
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
@@ -491,6 +491,20 @@
onClose();
}
// True while a charge (or the out-of-band SCA challenge the customer must
// approve in their banking app) is in flight — ESC/overlay close must be
// blocked then, because the charge may still land.
function isChargeInFlight(status: PaymentStatus): boolean {
return (
status === 'card-processing' ||
status === 'card-polling' ||
status === 'cash-confirming' ||
status === 'gift-confirming' ||
status === 'saved-card-processing' ||
status === 'saved-card-waiting-sca'
);
}
// Called from the success state's Done button: notify the parent (so it can
// refresh the booking/payment data) and then close the modal. Kept separate
// from handleClose so a success state never closes without the callback.
@@ -852,7 +866,9 @@
await runSavedCardSCA(chargeAmount);
return;
}
const err = new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
const err = new Error(
extractErrorMessage(errData) || 'Failed to process saved card payment'
);
(err as { bodyText?: string }).bodyText = errData;
throw err;
}
@@ -879,16 +895,14 @@
onComplete(paymentResult);
} catch (_err) {
status = 'error';
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is the
// fallback for generic 402s (the SCA flow above intercepts the
// structured ones, so this is the belt-and-braces path).
// A 402 carrying the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) {
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -1004,13 +1018,20 @@
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.";
? `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`
: CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
}
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Root
open={true}
onOpenChange={(open) => {
if (open) return;
if (isChargeInFlight(status)) return;
handleClose();
}}
>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
@@ -1250,7 +1271,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
'savedcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1276,7 +1297,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1657,7 +1678,11 @@
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput bind:code={twoFactor.code} showInput={twoFactor.showInput} enabled={true} />
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={true}
/>
{#if twoFactor.showInput}
<p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh
@@ -1695,7 +1720,9 @@
<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>
<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}
@@ -1,5 +1,18 @@
<script module lang="ts">
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
import {
getSquarePayments,
isSquareConfigured,
isSquareMock,
parseTokenizeVerificationResult,
type SquareTokenizeResult
} from '$lib/square/square';
/** Re-exported for the payment surfaces that import these from this
* component — the types now live with the shared parse logic in square.ts. */
export type SavedCardVerificationOutcome =
import('$lib/square/square').SavedCardVerificationOutcome;
export type SavedCardVerificationResult =
import('$lib/square/square').SavedCardVerificationResult;
/**
* Billing contact passed to Square's tokenize() verificationDetails for
@@ -18,21 +31,6 @@
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;
@@ -43,14 +41,6 @@
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
@@ -136,27 +126,13 @@
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 shared parse maps the SDK result to the saved-card outcome:
// `status === 'OK'` → 'verified' (the SCA-verified token is `result.token`
// in the current SDK — never a nested verificationResult, which only
// exists on the deprecated verifyBuyer() flow), tokenless when the issuer
// demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable;
// CARD_DECLINED_VERIFICATION_REQUIRED → 2FA fallback.
return parseTokenizeVerificationResult(result);
}
/**
@@ -239,12 +215,7 @@
attach: (selector: string) => Promise<void>;
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
) => Promise<SquareTokenizeResult>;
destroy: () => void;
}>;
};
@@ -289,14 +260,7 @@
return mockForm.tokenize();
}
const card = cardInstance as {
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
tokenize: (verificationDetails?: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -350,14 +314,7 @@
return mockForm.tokenizeWithVerification(amount, contact, saveCard);
}
const card = cardInstance as {
tokenize: (
verificationDetails: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
tokenize: (verificationDetails: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -384,13 +341,10 @@
// In the current tokenize-with-verification flow the returned nonce
// (result.token) is ALREADY the 3DS-verified token — Square binds the
// SCA challenge to this exact amount, so charging it as
// `new_card_token`/`card_token` is sufficient. `verificationResult`
// only exists on the deprecated verifyBuyer() flow; we still read it
// defensively since the backend accepts an explicit verification_token.
return {
nonce: result.token,
verificationToken: result.verificationResult?.token ?? null
};
// `new_card_token`/`card_token` is sufficient and no separate
// verification_token exists (that nested shape only came from the
// deprecated verifyBuyer() flow).
return { nonce: result.token, verificationToken: null };
}
const detail =
result.errors
@@ -14,14 +14,13 @@
import { onMount } from 'svelte';
import { generateUUID } from '$lib/utils/uuid';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -125,6 +124,13 @@
// 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 proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let waitingForSCA = $state(false);
// The retryable failure message shown in the error panel (challenge
// cancelled/failed, decline) — surfaced so the panel text matches the
// specific outcome instead of the generic "Payment failed".
let tipError = $state<string | null>(null);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
@@ -277,6 +283,9 @@
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
tipTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -291,7 +300,6 @@
paymentState = 'processing';
const usedSavedCard = !!selectedCardId;
let responseStatus = 0;
try {
@@ -314,15 +322,19 @@
// taps Pay Tip again to re-run it, and the cached idempotency key
// above is never regenerated across the challenge-then-charge.
if (selectedCardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runTipSCAProactively(amountInPence, selectedCardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
paymentState = 'error';
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
tipError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(tipError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
}
const body: Record<string, unknown> = {
amount: amountInPence,
@@ -372,19 +384,14 @@
} catch (err) {
paymentState = 'error';
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
// 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.
// A 402 carrying the structured verification-required signal (or
// the dev/mock text parity) surfaces the SCA-first guidance. A
// plain decline 402 shows the normal decline error — it must not
// be relabeled "requires verification".
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (
scaVerificationRequired ||
isSavedCardVerificationRequired(responseStatus, usedSavedCard)
) {
errorMessage = scaVerificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: SAVED_CARD_VERIFICATION_MESSAGE;
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
errorMessage = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -392,16 +399,28 @@
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
twoFactor.reveal = true;
}
tipError = errorMessage;
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
// and SCA verification token — they can never succeed again. Clear the
// cached pair so the next retry re-tokenizes fresh. The idempotency
// key is kept: it's still correct for network-timeout dedup.
// cached pair so the next retry re-tokenizes fresh.
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the tip
// charge did NOT land — a retry that re-runs SCA and mints a fresh
// token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED under
// the same key. Regenerate the key on 402 so the next Pay Tip click
// gets a fresh key + fresh pending row. Keep it on 503/network
// (ambiguous) and on challenge-cancelled/sca-failed (no charge was
// attempted).
if (responseStatus === 402) {
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipKeyedCard = '';
}
}
} finally {
isSubmittingTipSync = false;
@@ -447,6 +466,7 @@
function retryPayment() {
paymentState = 'idle';
tipError = null;
}
</script>
@@ -609,11 +629,29 @@
{#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-red-700">Payment failed. Please try again.</p>
<p class="text-red-700">{tipError ?? 'Payment failed. Please try again.'}</p>
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
</div>
{/if}
{#if waitingForSCA}
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
<Button
class="w-full"
size="lg"
@@ -57,7 +57,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
</p>
</div>
{/if}
@@ -17,16 +17,15 @@
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -70,6 +69,11 @@
// 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 proactive saved-card SCA challenge (card.tokenize with
// verificationDetails) is in flight — the challenge is out-of-band (the
// buyer approves in their banking app), so the form shows a waiting panel
// and blocks modal close until it resolves.
let waitingForSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
@@ -443,6 +447,10 @@
newCardTokenAmount = amountPence;
newCardTokenizedAt = Date.now();
newCardTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it (the 2FA
// input only surfaces while SCA genuinely cannot authorise).
lastSCAOutcome = 'verified';
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
@@ -486,17 +494,22 @@
// proceeds token-less (the 2FA gate is the fallback); a cancelled/failed
// challenge does NOT charge — the user taps Pay again to re-run it, and
// the cached idempotency key above is never regenerated across the
// challenge-then-charge.
// challenge-then-charge. waitingForSCA drives the "approve in your
// banking app" panel and blocks modal close while the challenge is open.
if (cardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error =
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
error = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
}
await submitBookingPayment(
@@ -624,14 +637,13 @@
overflowConfirm = null;
let msg = _err instanceof Error ? _err.message : 'Payment declined';
// A 402 with the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance; the legacy
// saved-card verification check is the fallback for generic 402s.
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
const verificationFailure =
scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, !!cardId);
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -649,6 +661,19 @@
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the charge
// did NOT land — Square's idempotency key would otherwise reject a
// retry that re-runs SCA and mints a fresh token (the key's purpose
// is dedup on AMBIGUOUS 503 retries, where the charge may have
// landed). Regenerate the key on 402 so the next Pay click gets a
// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
// and on challenge-cancelled/sca-failed (no charge was attempted).
if (responseStatus === 402) {
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
}
releaseLock();
}
}
@@ -792,6 +817,11 @@
open={true}
onOpenChange={(open) => {
if (open) return;
// ESC/overlay while a payment is in flight (the charge, or the
// out-of-band SCA challenge the buyer must approve in their banking
// app) must NOT close the modal — the charge may still land, and
// closing mid-challenge strands it. Block close until it resolves.
if (status === 'processing' || waitingForSCA) return;
// ESC while the overflow-confirm prompt is showing must dismiss the
// prompt (back to the amount-editing form) instead of closing the whole
// modal — the payment was rejected by the guard and the user needs to
@@ -882,6 +912,25 @@
<span>Slot no longer secured — please close and retry</span>
</div>
{/if}
<!-- Out-of-band SCA challenge: the buyer approves in their banking
app while this panel shows. -->
{#if waitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
<!-- Service Breakdown -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
@@ -145,14 +145,7 @@
if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number);
const today = new Date();
const closing = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
ch,
cm,
0
);
const closing = new Date(today.getFullYear(), today.getMonth(), today.getDate(), ch, cm, 0);
const minutesToClosing = Math.max(
0,
Math.floor((closing.getTime() - endTime.getTime()) / 60000)
@@ -187,8 +187,7 @@
const data = await response.json();
const newApprovals = (data.approvals || []).sort(
(a: PendingApproval, b: PendingApproval) =>
parseWallClockDate(a.created_at).getTime() -
parseWallClockDate(b.created_at).getTime()
parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()
);
const newJson = JSON.stringify(newApprovals);
if (newJson !== prevApprovalsJson) {
@@ -1,6 +1,7 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { CalendarDate } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -117,7 +118,7 @@
const weekStartStr = $derived.by(() => {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const d = new Date(londonDateStr + 'T00:00:00Z');
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
@@ -125,7 +126,7 @@
});
const weekEndStr = $derived.by(() => {
const d = new Date(weekStartStr + 'T00:00:00Z');
const d = new SvelteDate(weekStartStr + 'T00:00:00Z');
d.setDate(d.getDate() + 6);
return formatDateISO(d);
});
@@ -4,6 +4,7 @@
import * as Card from '$lib/components/ui/card';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
import { range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
type TodayAppointment = {
id: string;
@@ -54,7 +55,7 @@
let spansClosed = false;
for (let i = 1; i <= 14; i++) {
const d = new Date(now);
const d = new SvelteDate(now);
d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -71,7 +72,7 @@
if (day?.isOpen) {
const closeTime = day.endTime;
const [h, m] = closeTime.split(':').map(Number);
const closeDate = new Date(d);
const closeDate = new SvelteDate(d);
closeDate.setHours(h, m, 0, 0);
return { cutoff: formatLocalDateTime(closeDate), spansClosed };
} else {
@@ -80,7 +81,7 @@
}
}
const fallback = new Date(now);
const fallback = new SvelteDate(now);
fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0);
return { cutoff: formatLocalDateTime(fallback), spansClosed };
@@ -108,7 +108,7 @@
// media-query variant is a different twMerge group than the plain
// utility, so it survives consumer `max-h-*` overrides that would
// otherwise wipe the keyboard-safe mobile height.
'fixed bottom-0 left-0 right-0 grid w-full max-w-none translate-x-0 translate-y-0 gap-4 rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-h-[90vh] max-sm:max-h-[calc(100dvh-4rem)] overflow-y-auto sm:top-[50%] sm:left-[50%] sm:right-auto sm:bottom-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
'fixed right-0 bottom-0 left-0 grid max-h-[90vh] w-full max-w-none translate-x-0 translate-y-0 gap-4 overflow-y-auto rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-sm:max-h-[calc(100dvh-4rem)] sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
stripZIndexClasses(className)
)}
{...restProps}
@@ -32,11 +32,6 @@ export function nextZIndex(): number {
return top;
}
/** The current top of the stack (the last claimed z-index). */
export function currentZIndex(): number {
return top;
}
/**
* Reset the stack to its base. Called once from the root layout on mount so
* hot reloads / repeated test runs don't let the counter climb forever.
@@ -26,7 +26,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 md:h-9 md:min-h-9',
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:h-9 md:min-h-9 dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
@@ -51,7 +51,7 @@
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
>
<a
href={href}
{href}
target="_blank"
rel="noopener noreferrer external"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
+93 -18
View File
@@ -14,6 +14,7 @@ import {
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
parseTokenizeVerificationResult,
requestNewTwoFactorCode,
sanitizeDecimalInput,
shouldFallbackTo2FA,
@@ -240,20 +241,26 @@ describe('isVerificationRequiredSignal', () => {
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
expect(
isVerificationRequiredSignal(402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification')
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
);
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
);
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', () => {
@@ -269,20 +276,32 @@ describe('isVerificationRequiredSignal', () => {
});
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
);
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' }))
isVerificationRequiredSignal(
402,
JSON.stringify({ error: 'x', code: 'verification_required_extra' })
)
).toBe(false);
});
});
@@ -311,6 +330,58 @@ describe('shouldFallbackTo2FA', () => {
});
});
describe('parseTokenizeVerificationResult', () => {
it('maps a status OK result with a token to a verified outcome carrying the SAME token', () => {
expect(
parseTokenizeVerificationResult({ status: 'OK', token: 'ccof:sca-verified-token' })
).toEqual({
verificationToken: 'ccof:sca-verified-token',
outcome: 'verified'
});
});
it('maps a status OK result with NO token to a verified, tokenless outcome (no SCA required)', () => {
expect(parseTokenizeVerificationResult({ status: 'OK' })).toEqual({
verificationToken: null,
outcome: 'verified'
});
});
it('maps a VERIFICATION_CHALLENGE status to a retryable challenge-cancelled outcome', () => {
expect(parseTokenizeVerificationResult({ status: 'VERIFICATION_CHALLENGE' })).toEqual({
verificationToken: null,
outcome: 'challenge-cancelled'
});
});
it('maps a cancel-coded error to a retryable challenge-cancelled outcome', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CANCEL', message: 'challenge cancelled by buyer' }]
})
).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' });
});
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CARD_DECLINED_VERIFICATION_REQUIRED' }]
})
).toEqual({ verificationToken: null, outcome: 'sca-unavailable' });
});
it('maps any other failure to sca-failed', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CARD_DECLINED', message: 'card declined' }]
})
).toEqual({ verificationToken: null, outcome: 'sca-failed' });
});
});
describe('isTwoFactorVerificationGateFailure', () => {
it.each([
[403, 'A two-factor verification code is required to use this saved card', true],
@@ -545,7 +616,11 @@ describe('adminRequestNewTwoFactorCode', () => {
it('surfaces the 429 mint-cooldown error message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429))
vi
.fn()
.mockResolvedValue(
jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429)
)
);
const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(false);
+73 -1
View File
@@ -180,12 +180,82 @@ export function shouldFallbackTo2FA(scaOutcome: string): boolean {
return scaOutcome === 'sca-unavailable';
}
/** 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()` result shape. Per the CURRENT SDK
* (Square.js /v1/), tokenize returns `{ status, token, details?, errors }`
* the SCA-verified token for both the new-card and the card-on-file
* (`card.tokenize(verificationDetails, cardId)`) flows comes back in the SAME
* `token` field. There is NO nested `verificationResult` that only exists on
* the deprecated `payments.verifyBuyer()` flow, which Square is retiring (see
* developer.squareup.com/docs/web-payments/take-card-payment "Migrate from
* Payments.verifyBuyer()"). */
export interface SquareTokenizeResult {
status: string;
token?: string;
errors?: Array<{ message?: string; code?: string }>;
}
/**
* Maps a Square `card.tokenize()` result to the saved-card SCA outcome.
*
* - `status === 'OK'` means buyer verification either completed or was NOT
* required by the issuer the charge may proceed. The verification-aware
* token (when present) is the `token` field; a tokenless OK means no SCA was
* demanded, so the charge proceeds token-less (the backend 2FA gate / Square
* risk rules are the fallback), never a dead-end.
* - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was
* shown but not completed the buyer can retry, so this is retryable.
* - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run SCA
* is unavailable and the surface falls back to the 2FA gate.
* - anything else is a hard SCA failure.
*/
export function parseTokenizeVerificationResult(
result: SquareTokenizeResult
): SavedCardVerificationResult {
if (result.status === 'OK') {
return { verificationToken: result.token ?? null, outcome: 'verified' };
}
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
const errorText =
codes.join(' ') + ' ' + (result.errors ?? []).map((e) => e.message ?? '').join(' ');
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
return { verificationToken: null, outcome: 'challenge-cancelled' };
}
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
return { verificationToken: null, outcome: 'sca-unavailable' };
}
return { verificationToken: null, outcome: 'sca-failed' };
}
/** 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 message for a saved-card SCA challenge that was cancelled or did
* not complete. Retryable via SCA deliberately does NOT promise the 2FA code
* input, which the customer surfaces only surface on 'sca-unavailable'. */
export const CARD_VERIFICATION_RETRY_MESSAGE =
"Card verification was cancelled or didn't complete. Please try again.";
/** User-facing guidance appended to VERIFICATION_REQUIRED_MESSAGE when the
* issuer's SCA challenge genuinely cannot run the 2FA code input is the
* only available authorisation and is surfaced as the fallback gate. */
export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE =
"In-app approval isn't available for this card — enter the verification code instead.";
/** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless the
* buyer must pay with a new card or re-add their card. */
@@ -264,7 +334,9 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
* the CUSTOMER's userID, so the code is delivered to the customer and can
* satisfy the card-owner gate the admin's session never receives or
* authenticates the customer's card. */
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
export async function adminRequestNewTwoFactorCode(
userID: string
): Promise<TwoFactorCodeRequestResult> {
return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
}
+2 -1
View File
@@ -95,7 +95,8 @@ export interface Payment {
id: string;
booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount' | 'on_the_house';
payment_method:
'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount' | 'on_the_house';
vendor_code?: string;
invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded';
+3 -4
View File
@@ -95,10 +95,9 @@ export function timeToMinutes(time: string): number {
}
export function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
'en-GB',
{ month: 'long' }
);
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
month: 'long'
});
const day = date.day;
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
switch (day % 10) {
+1 -1
View File
@@ -70,7 +70,7 @@
/>
</svelte:head>
<div class="flex min-h-screen supports-[height:100dvh]:min-h-dvh flex-col">
<div class="flex min-h-screen flex-col supports-[height:100dvh]:min-h-dvh">
<NavBar />
<Toaster position={toasterPosition} />
+71 -27
View File
@@ -10,14 +10,13 @@
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -269,6 +268,12 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let buyLastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let buyWaitingForSCA = $state(false);
// Retryable purchase failure message shown above the Pay button (challenge
// cancelled/failed, decline) so the retry affordance matches the outcome.
let buyError = $state<string | null>(null);
const buyTwoFactor = useTwoFactorCodeForSavedCard({
enabled: () => buyTwoFactorEnabled,
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard),
@@ -446,6 +451,9 @@
buyTokenAmount = buyAmount * 100;
buyTokenizedAt = Date.now();
buyTokenizedForSaveCard = buySaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
buyLastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
buyingGiftCard = false;
@@ -488,14 +496,18 @@
// user taps Buy again to re-run it, and the cached idempotency
// key above is never regenerated across the challenge-then-charge.
if (cardId && !verificationToken) {
buyWaitingForSCA = true;
try {
const proactive = await runBuySCAProactively();
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
buyError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(buyError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
buyWaitingForSCA = false;
}
}
const res = await submitPaymentWithRetry(() =>
@@ -539,42 +551,41 @@
// can only be read once.
const status = res.status;
const errText = await res.text();
// Defensive fallback: a `verification_required` 402 on a
// saved-card buy should no longer happen — the first attempt
// carried a proactive verification_token or demoted to 2FA. If
// it still occurs (e.g. a stale token was consumed between
// tokenize and charge), surface the guidance and let the user
// retry — never re-run SCA silently mid-flow (the
// classification below maps the signal to
// VERIFICATION_REQUIRED_MESSAGE).
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is
// the fallback for generic 402s.
const scaVerificationRequired = isVerificationRequiredSignal(status, errText);
const verificationRequired =
scaVerificationRequired || isSavedCardVerificationRequired(status, !!buySelectedCard);
// A 402 carrying the structured verification-required signal
// (or the dev/mock text parity) surfaces the SCA-first
// guidance. A plain decline 402 shows the normal decline
// error — it must not be relabeled "requires verification".
const verificationRequired = isVerificationRequiredSignal(status, errText);
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
const buyErrMsg = verificationRequired
? scaVerificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: SAVED_CARD_VERIFICATION_MESSAGE
: extractErrorMessage(errText) || 'Failed to purchase gift card';
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
buyTwoFactor.reveal = true;
}
buyError = buyErrMsg;
toast.error(buyErrMsg);
// A definitive charge failure (e.g. declined card) consumes the
// nonce + SCA verification token — clear the cached pair so the
// next retry re-tokenizes fresh. The idempotency key stays for
// network-timeout dedup.
// next retry re-tokenizes fresh.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the
// purchase did NOT land — a retry that re-runs SCA and mints a
// fresh token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED
// under the same key. Regenerate the key on 402 so the next Buy
// click gets a fresh key + fresh pending row. Keep it on
// 503/network (ambiguous) and on challenge-cancelled/sca-failed.
if (status === 402) {
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
}
}
} catch (err) {
console.error('buyGiftCard error:', err);
@@ -611,7 +622,9 @@
outcome: SavedCardVerificationOutcome;
verificationToken?: string;
}> {
const squareCardId = savedCardsStore.cards.find((c) => c.id === buySelectedCard)?.square_card_id;
const squareCardId = savedCardsStore.cards.find(
(c) => c.id === buySelectedCard
)?.square_card_id;
if (!squareCardId) {
buyLastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
@@ -2564,7 +2577,7 @@
>
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-amber-600 italic font-semibold">
<p class="text-[10px] font-semibold text-amber-600 italic">
⚠️ Please save this code and send it to your friend — no email was sent.
</p>
{/if}
@@ -2681,6 +2694,37 @@
{/if}
</div>
{#if buyWaitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
{#if buyError}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{buyError}</p>
<Button
variant="outline"
size="sm"
class="mt-2 w-full"
onclick={() => (buyError = null)}
>
Try Again
</Button>
</div>
{/if}
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard ||
@@ -3299,7 +3343,7 @@
<!-- Password Change Modal -->
{#if showPasswordModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card.Root class="w-full max-w-md max-h-[calc(100dvh-2rem)] overflow-y-auto">
<Card.Root class="max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto">
<Card.Header>
<Card.Title>Change Password</Card.Title>
<Card.Description>Enter your current and new password</Card.Description>
@@ -1,5 +1,6 @@
<script lang="ts">
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -289,7 +290,7 @@
const d = parseWallClockDate(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
const tomorrow = new SvelteDate(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
@@ -9,6 +9,7 @@
import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button';
import { formatDuration } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
@@ -83,7 +84,7 @@
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
@@ -93,7 +94,7 @@
// -- Helpers --
function getWeekDays(start: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(start);
const d = new SvelteDate(start);
d.setDate(d.getDate() + i);
return d;
});
@@ -133,7 +134,7 @@
}
function formatWeekLabel(start: Date): string {
const end = new Date(start);
const end = new SvelteDate(start);
end.setDate(end.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
@@ -226,7 +227,7 @@
if (!initialized) loading = true;
try {
const startStr = formatDate(weekStart);
const end = new Date(weekStart);
const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6);
const endStr = formatDate(end);
@@ -312,7 +313,7 @@
function navigateWeek(delta: number) {
if (!weekStart) return;
const d = new Date(weekStart);
const d = new SvelteDate(weekStart);
d.setDate(d.getDate() + delta * 7);
weekStart = d;
}
@@ -321,7 +322,7 @@
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
@@ -420,7 +421,7 @@
const isCurrentWeek = $derived(
weekStart !== undefined &&
(() => {
const end = new Date(weekStart);
const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return today >= weekStart && today <= end;
@@ -67,9 +67,9 @@
<strong>36 hours in advance</strong>.
</p>
<p class="mb-3">
Payments toward your booking are capped at 100% of the total booking value based on service prices at time of booking.
Once you have paid the full amount, any additional payments above 100% of the booking value will be processed as tips (see
Section 8 below).
Payments toward your booking are capped at 100% of the total booking value based on service
prices at time of booking. Once you have paid the full amount, any additional payments above
100% of the booking value will be processed as tips (see Section 8 below).
</p>
<p class="mb-3">
To maintain fairness and prevent scheduling abuse, accounts with outstanding deposit
@@ -107,14 +107,13 @@
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Cancellation & Refund Tiers</h2>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund before service</h3>
<p class="mb-3">
We understand that plans can change. Eligibility for a refund before your service depends strictly on the amount
of notice provided prior to your scheduled appointment time. These thresholds represent a
genuine pre-estimate of the operational costs and loss of business incurred by late
cancellations. Refunds apply to <strong>booking payments only</strong>, up to 100% of the total
booking value.
We understand that plans can change. Eligibility for a refund before your service depends
strictly on the amount of notice provided prior to your scheduled appointment time. These
thresholds represent a genuine pre-estimate of the operational costs and loss of business
incurred by late cancellations. Refunds apply to <strong>booking payments only</strong>, up
to 100% of the total booking value.
</p>
<div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200">
<div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of more than 72 hours</p>
@@ -135,8 +134,8 @@
<div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p>
<p class="mt-1 text-xs text-gray-600">
All booking payments and deposits are entirely non-refundable and will be retained. The cancellation will be logged as a missed
appointment history strike.
All booking payments and deposits are entirely non-refundable and will be retained. The
cancellation will be logged as a missed appointment history strike.
</p>
</div>
</div>
@@ -144,12 +143,14 @@
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund after service</h3>
<p class="mb-3 text-sm leading-relaxed text-gray-700">
Refunds after booked appoinments have been carried out are at the salon owners discretion based on the booking and reason, to arrange a refund please <a
Refunds after booked appoinments have been carried out are at the salon owners discretion
based on the booking and reason, to arrange a refund please <a
href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">contact</a> us to discuss a fair refund up to 100% of the value of the booking. Any paid tips will not be considered as part of the refund as they are processed differently.
class="font-medium text-blue-600 underline hover:text-blue-800">contact</a
> us to discuss a fair refund up to 100% of the value of the booking. Any paid tips will not be
considered as part of the refund as they are processed differently.
</p>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund Payment Method</h3>
<p class="mb-3 text-sm leading-relaxed text-gray-700">
Refunds are returned to the original payment method where possible:
@@ -161,9 +162,9 @@
days).
</li>
<li>
<strong>Gift card payments</strong>: Refunded back to the original gift card (or, if
you paid from your account balance, back to that balance). The gift card's remaining
balance is incremented and is immediately available for use. Expired gift cards are
<strong>Gift card payments</strong>: Refunded back to the original gift card (or, if you
paid from your account balance, back to that balance). The gift card's remaining balance
is incremented and is immediately available for use. Expired gift cards are
non-refundable.
</li>
<li>
@@ -247,31 +248,30 @@
Regulations 2013 does not apply to online bookings scheduled for a specific date or time.
</p>
<p class="mb-3">
That exclusion does not apply to gift cards: online gift-card purchases may be
cancelled within 14 days for a refund to the original payment method under the
Consumer Contracts Regulations 2013. If the card has been partly used, the amount
already spent on salon services is not refundable, and the remaining unspent balance
is refunded to the original payment method; the card is then cancelled. A card that
has been redeemed to an account balance or fully spent cannot be cancelled.
That exclusion does not apply to gift cards: online gift-card purchases may be cancelled
within 14 days for a refund to the original payment method under the Consumer Contracts
Regulations 2013. If the card has been partly used, the amount already spent on salon
services is not refundable, and the remaining unspent balance is refunded to the original
payment method; the card is then cancelled. A card that has been redeemed to an account
balance or fully spent cannot be cancelled.
</p>
<p class="mb-3">
Where a partly-used card is cancelled, the card is cancelled automatically when the
refund is issued, so the remaining balance cannot then be spent. See our Gift Card
Terms for the full position.
Where a partly-used card is cancelled, the card is cancelled automatically when the refund
is issued, so the remaining balance cannot then be spent. See our Gift Card Terms for the
full position.
</p>
<p class="mb-3">
If you believe your statutory consumer rights have not been met, you can get free,
impartial advice from
If you believe your statutory consumer rights have not been met, you can get free, impartial
advice from
<a
href="https://consumeradvice.scot"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800"
>consumeradvice.scot</a
class="font-medium text-blue-600 underline hover:text-blue-800">consumeradvice.scot</a
>
(advice.scot). If that does not resolve the issue, you can escalate your complaint to your
local Trading Standards office. Claims up to &pound;5,000 can also be pursued through the
Scottish courts' Simple Procedure.
(advice.scot). If that does not resolve the issue, you can escalate your complaint to your local
Trading Standards office. Claims up to &pound;5,000 can also be pursued through the Scottish courts'
Simple Procedure.
</p>
<p class="mb-3">
We recognize that genuine emergencies, sudden severe illness, or bereavement can occur. Our
@@ -328,13 +328,14 @@
</p>
<p class="mb-3">
Tips can be added via your account after the booking has started, or at the time of payment
when paying in person at the salon. When paying by card at the terminal, you will be prompted
to add a tip if you wish.
when paying in person at the salon. When paying by card at the terminal, you will be
prompted to add a tip if you wish.
</p>
<p class="mb-3 text-xs text-gray-500 italic">
If you believe a tip was added in error, please contact us via our official
<a href={resolve('/contact')} class="font-medium text-blue-600 underline hover:text-blue-800"
>Contact Channels</a
<a
href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">Contact Channels</a
> and we will review your case.
</p>
</section>
+4 -6
View File
@@ -83,11 +83,7 @@
const dob = new Date(dateStr);
const today = new Date();
const sixteenYearsAgo = new Date(
today.getFullYear() - 16,
today.getMonth(),
today.getDate()
);
const sixteenYearsAgo = new Date(today.getFullYear() - 16, today.getMonth(), today.getDate());
const isValid = dob <= sixteenYearsAgo;
validationErrors.dateOfBirth = isValid
@@ -509,7 +505,9 @@
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)}
max={new Date(
new Date().setFullYear(new Date().getFullYear() - 16)
new Date().getFullYear() - 16,
new Date().getMonth(),
new Date().getDate()
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
required
/>
@@ -128,7 +128,7 @@
<title>Leave a Tip - Crussell</title>
</svelte:head>
<div class="mx-auto min-h-screen supports-[height:100dvh]:min-h-dvh px-4 py-8 sm:max-w-md md:py-12">
<div class="mx-auto min-h-screen px-4 py-8 supports-[height:100dvh]:min-h-dvh sm:max-w-md md:py-12">
{#if loading || pageState === 'loading'}
<div class="space-y-6">
<div class="text-center">
+60 -43
View File
@@ -63,8 +63,8 @@
<h2 class="mb-3 text-base font-semibold text-gray-900">1. Introduction</h2>
<p class="mb-3">
This Privacy Policy explains how Crussell Salon (&ldquo;we&rdquo;, &ldquo;us&rdquo;,
&ldquo;our&rdquo;) collects, uses, and protects your personal data when you use our
booking platform (&ldquo;Platform&rdquo;).
&ldquo;our&rdquo;) collects, uses, and protects your personal data when you use our booking
platform (&ldquo;Platform&rdquo;).
</p>
<p class="mb-3">
We are committed to protecting your privacy and complying with the
@@ -76,7 +76,7 @@
<p class="mt-1">Crussell Salon</p>
<p>Edinburgh, Scotland</p>
<!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. -->
<p>Email: {"{{SUPPORT_EMAIL}}"}</p>
<p>Email: {'{{SUPPORT_EMAIL}}'}</p>
</div>
</section>
@@ -84,7 +84,9 @@
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Data We Collect</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.1 Personal Data (Identifiable Information)</h3>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.1 Personal Data (Identifiable Information)
</h3>
<p class="mb-2 font-medium text-gray-800">Account Information:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Name (first, last)</li>
@@ -97,8 +99,8 @@
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Appointment dates, times, services</li>
<li>
Treatment notes and preferences (one health-and-safety record &mdash; includes
allergies, skin sensitivities, and access needs)
Treatment notes and preferences (one health-and-safety record &mdash; includes allergies,
skin sensitivities, and access needs)
</li>
<li>Allergy and patch test records (health data &mdash; special category)</li>
<li>Payment history and transaction records</li>
@@ -115,7 +117,9 @@
GDPR; we record it so we can treat you safely and make reasonable adjustments (Equality Act
2010).
</p>
<p class="mb-3">These notes are seen only by the salon owner and are never shared or exported.</p>
<p class="mb-3">
These notes are seen only by the salon owner and are never shared or exported.
</p>
<p class="mb-4">
On account deletion the rest of your record is erased or anonymized, and your notes are
retained in a form that cannot be traced back to you. We keep them so we can still make safe
@@ -127,39 +131,44 @@
<li>Gift card codes and balances</li>
<li>Account balances</li>
<li>Payment transaction records (processed via Square, not stored by us)</li>
<li>Saved-card references (tokenised, stored with our payment provider Square &mdash; see &sect;2.2)</li>
<li>
Saved-card references (tokenised, stored with our payment provider Square &mdash; see
&sect;2.2)
</li>
<li>Dormant balance records (Account ID only, no PII)</li>
</ul>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.2 Saved Cards &amp; Payment Provider (Square)</h3>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.2 Saved Cards &amp; Payment Provider (Square)
</h3>
<p class="mb-3">
When you choose to <strong>save a card for next time</strong>, we store a tokenised
reference to your card with our payment processor, <strong>Square</strong> (a data
processor), rather than on our own systems.
reference to your card with our payment processor, <strong>Square</strong> (a data processor),
rather than on our own systems.
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
<strong>What Square stores:</strong> a tokenised reference to your card (never your
full card number or CVV), plus the name and email address we already hold on your
account, grouped into a Square customer profile.
<strong>What Square stores:</strong> a tokenised reference to your card (never your full card
number or CVV), plus the name and email address we already hold on your account, grouped into
a Square customer profile.
</li>
<li>
<strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) &mdash; necessary for the
performance of the contract (you asked to save your card for future payments).
<strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) &mdash; necessary for the performance
of the contract (you asked to save your card for future payments).
</li>
<li>
<strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases
without re-entering your card details.
<strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases without
re-entering your card details.
</li>
<li>
<strong>One-off payments:</strong> if you do not tick &ldquo;save this card&rdquo;,
<strong>no card is stored and no Square customer profile is created</strong> for you
&mdash; your card is used only for that single payment.
<strong>no card is stored and no Square customer profile is created</strong> for you &mdash;
your card is used only for that single payment.
</li>
<li>
<strong>Retention &amp; removal:</strong> the reference remains stored until you delete
the card from your account (Account &rarr; Saved Cards) or your account is deleted. You
can remove a saved card at any time.
<strong>Retention &amp; removal:</strong> the reference remains stored until you delete the
card from your account (Account &rarr; Saved Cards) or your account is deleted. You can remove
a saved card at any time.
</li>
<li>
<strong>Square&rsquo;s privacy policy:</strong>
@@ -167,19 +176,22 @@
href="https://squareup.com/gb/en/legal/privacy-no-account"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800"
>Square Privacy Policy</a
class="font-medium text-blue-600 underline hover:text-blue-800">Square Privacy Policy</a
>
applies to data Square holds on our behalf.
</li>
</ul>
<p class="mb-4">
We never store full card numbers, card security codes (CVV), or card expiry data on our
own systems at any point.
We never store full card numbers, card security codes (CVV), or card expiry data on our own
systems at any point.
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Special Category Data (Health Data)</h3>
<p class="mb-3">We collect health-related information with your <strong>explicit consent</strong>:</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.3 Special Category Data (Health Data)
</h3>
<p class="mb-3">
We collect health-related information with your <strong>explicit consent</strong>:
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Allergy records</li>
<li>Patch test results</li>
@@ -188,15 +200,17 @@
</ul>
<p class="mb-3">
<strong>Legal basis:</strong> UK GDPR Article 9(2)(a) &mdash; Explicit consent<br />
<strong>Retention:</strong> 7 years (insurance requirement); patch-test records are kept
unlinked to you if your account is deleted, and allergy/access information held in your
treatment notes is retained de-identified (see &sect;3.1).
<strong>Retention:</strong> 7 years (insurance requirement); patch-test records are kept unlinked
to you if your account is deleted, and allergy/access information held in your treatment notes
is retained de-identified (see &sect;3.1).
</p>
</section>
<!-- Section 3 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Data Retention &amp; Deletion Process</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">
3. Data Retention &amp; Deletion Process
</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">3.1 Retention Schedule</h3>
<div class="overflow-x-auto rounded-md border border-gray-200">
@@ -242,11 +256,13 @@
<td class="px-3 py-2">Insurance requirement</td>
</tr>
<tr>
<td class="px-3 py-2">Treatment &amp; safety notes (incl. allergy/access information)</td>
<td class="px-3 py-2"
>Treatment &amp; safety notes (incl. allergy/access information)</td
>
<td class="px-3 py-2">
Retained after account deletion in de-identified form while they may be needed for
safety adjustments or legal-claims defence; the rest of the account record is
erased at deletion
safety adjustments or legal-claims defence; the rest of the account record is erased
at deletion
</td>
<td class="px-3 py-2">Legitimate interest (safety &amp; legal-claims defence)</td>
</tr>
@@ -289,10 +305,9 @@
</li>
</ol>
<p class="mb-3">
<strong>Saved cards:</strong> Deleting your account also removes your saved-card
references from our system and disables the corresponding card tokens at Square (see
&sect;2.2). Card transaction records for payments already made are retained per the HMRC
schedule above.
<strong>Saved cards:</strong> Deleting your account also removes your saved-card references from
our system and disables the corresponding card tokens at Square (see &sect;2.2). Card transaction
records for payments already made are retained per the HMRC schedule above.
</p>
<p class="mb-2 font-medium text-gray-800">Inactive account deletion (automatic):</p>
<ol class="mb-3 list-decimal space-y-1 pl-5">
@@ -313,15 +328,17 @@
<ul class="mb-4 list-disc space-y-1 pl-5">
<li><strong>Access</strong> your personal data (Article 15)</li>
<li><strong>Rectify</strong> inaccurate data (Article 16)</li>
<li><strong>Erase</strong> your data (Article 17 &mdash; subject to HMRC/insurance retention)</li>
<li>
<strong>Erase</strong> your data (Article 17 &mdash; subject to HMRC/insurance retention)
</li>
<li><strong>Restrict</strong> processing (Article 18)</li>
<li><strong>Data Portability</strong> (Article 20)</li>
<li><strong>Object</strong> to processing (Article 21)</li>
<li><strong>Withdraw Consent</strong> (Article 7(3))</li>
</ul>
<p class="mb-4">
To exercise these rights, contact {"{{SUPPORT_EMAIL}}"}. You also have the right to
complain to the Information Commissioner&rsquo;s Office (ICO) at any time.
To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
to the Information Commissioner&rsquo;s Office (ICO) at any time.
</p>
<p class="text-xs text-gray-500">
Questions about how we handle your data? Please use our official
+1 -1
View File
@@ -143,7 +143,7 @@
<title>Leave a Tip - Crussell</title>
</svelte:head>
<div class="mx-auto min-h-screen supports-[height:100dvh]:min-h-dvh px-4 py-8 sm:max-w-md md:py-12">
<div class="mx-auto min-h-screen px-4 py-8 supports-[height:100dvh]:min-h-dvh sm:max-w-md md:py-12">
{#if loading}
<div class="space-y-6">
<div class="text-center">
+1 -5
View File
@@ -189,11 +189,7 @@
</div>
<!-- Modals -->
<BookingModal
bind:open={showBookingModal}
bookingId={selectedBookingId ?? ''}
{openUserModal}
/>
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} {openUserModal} />
<EditBookingModal
bind:open={showEditBookingModal}
+9
View File
@@ -1068,6 +1068,15 @@ BEGIN
-- Anonymize admin notification references (not customer data — just drop the user link)
UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id;
-- De-identify admin audit references: admin_audit_log holds a processing
-- record (GDPR Art 30) that MUST survive erasure for audit retention, but
-- rows like '2fa_fallback_charge' (insertTwoFAFallbackAudit) carry
-- target_user_id = the erased user, so the user link is NULLed to keep the
-- record de-identified. Mirrors delete_guest_user() exactly — only
-- target_user_id is scrubbed, NOT details (delete_guest_user also leaves
-- details untouched, so the two erasure paths stay consistent).
UPDATE admin_audit_log SET target_user_id = NULL WHERE target_user_id = target_id;
-- Clear login audit trail (no ongoing legal basis after account closure)
DELETE FROM login_audit WHERE user_id = target_id;
+1 -1
View File
@@ -220,7 +220,7 @@ npm run dev # Dev server with HMR
```bash
cd backend
go test -tags "test,dev" ./... # 2,333 tests passed (4 skipped), as of 14 Aug 2026
go test -tags "test,dev" ./... # 2,440 backend test functions passed (4 skipped) + 61 frontend vitest cases, as of 15 Aug 2026
go test -tags "test,dev" -v -run TestName ./... # Single test
```
+7 -3
View File
@@ -689,6 +689,8 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
**Concurrency guard:** `CreateBookingPayment` acquires a PostgreSQL session-level advisory **try-lock** (`pg_try_advisory_lock(hashtext('crussell:payment:' || booking_id))`, via `acquireAdvisoryLock` in `handlers/payments/locks.go`) at entry and releases it in a defer. The lock is **bounded**: `tryAdvisoryLock` retries `pg_try_advisory_lock` ~30 times with a 100ms backoff (~3s total), and a contended second request gets a 409 "payment in progress, try again" instead of blocking a pool connection (blocking would hold the pinned connection hostage across the lock-holder's up-to-30s Square round-trip and could exhaust the pool). After the lock, the handler re-checks booking status (a concurrent payment may have promoted it) and runs a payment-type duplicate guard that prevents two `'full'` or `'deposit'` payments from being created for the same booking, even with different idempotency keys.
**CIT vs MIT classification (saved-card charges):** how Square classifies the charge depends on who initiates it. Online customer charges — the booking payment (`CreateBookingPayment`) and tips (`CreateTipPayment`) — send `customer_details.customer_initiated=true` (CIT, "C3"): SCA applies, and the charge carries Square's `verification_token` from the frontend's proactive buyer verification (tokenize-before-first-charge — a saved card is never charged as a naked `ccof:`). The admin saved-card paths are **merchant-initiated (MIT)** and send `customer_initiated=false``CreateTerminalPayment`'s admin "Charge Saved Card" (`handlers.go:1114-1121`) and the till's saved-card branch (`till.go:1174-1179`): Square reads those as SCA-exempt with **no liability shift**, because the cardholder is not at the keyboard. The homegrown 2FA gate (see the Two-Factor Authentication section) is the authorisation on the saved-card paths whenever SCA does not cover the charge.
3. **Deposit deadline passes without payment**`CleanupExpiredDeposits()` moves the booking to `pending_release`. The slot becomes vulnerable — another booking can claim it via eviction. An admin notification `deposit_not_paid_by_deadline` is created. The user's time blocker reservations are also cleaned up.
4. **Slot claimed by another booking** → If a new booking overlaps a `pending_release` slot, `EvictPendingReleaseOverlapping` (a shared function in `bookings.go`) evicts the pending_release booking to `deposit_lapsed`. The eviction runs inside the same transaction as the new booking's creation, so it rolls back if the new booking fails. The function is called by all 4 eviction sites: `CreateBookingHandler`, `ConfirmBookingHandler`, `AdminCreateBookingForUserHandler`, and `AdminRescheduleBookingHandler`. A `PAYMENT_IN_FLIGHT` time_blocker guard prevents evicting a booking that the user is currently paying for (5-minute window).
5. **Payment arrives after deadline but before eviction** → The 20% threshold check promotes `pending_release` back to `confirmed` — the booking is saved and the slot is no longer vulnerable.
@@ -830,15 +832,17 @@ validTransitions := map[string]map[string]bool{
**Enforcement** (`twoFactorEnforced`, `handlers/payments/twofa.go`):
- Enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT`, including empty and unknown values, which are treated as production-enforced. It is disabled only when `REQUIRE_2FA` is an explicit disable value (`false`/`0`/`off`/`no`, case-insensitive) **or** `SQUARE_ENVIRONMENT` is an explicit dev/mock value (`mock`, `dev`, `development`, `test`).
- A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
- **Residual brute-force exposure (accepted):** a fresh-code delivery (setup, or a disable that mints because no pending code exists) resets the shared 5-attempt counter. An authenticated attacker who already holds the victim's password can therefore loop `disable` with wrong codes to obtain an unlimited series of fresh codes, each granting 5 guesses — the 2FA gate then reduces to a 6-digit guessing game bounded only by the per-IP rate limit (120 req/min on `/api/user`) and the 10-minute code TTL. This is the same reset-on-delivery tradeoff that makes codes deliverable to locked-out users; it is documented rather than fixed because a hard per-user lockout would strand a legitimate user who lost their code, with no email/SMS transport to recover (P6). Revisit when real delivery lands. (Because 2FA is now backup-only, the exposure is confined to the no-SCA fallback path — it no longer fronts every saved-card charge.)
- **Posture switch (`TWO_FACTOR_FALLBACK`, default `true`):** read by `twoFactorFallbackEnabled` (`handlers/payments/twofa.go`) and logged at startup by `main.go`. `false` = SCA-only posture: a saved-card charge carrying no Square `verification_token` is denied 402 `verification_required` (the frontend shows the SCA challenge; if the bank cannot complete it, the charge fails) rather than falling back to the 2FA gate. `true` (the default) = the 2FA gate may authorise a token-less saved-card charge when SCA is unavailable, the user has enabled 2FA, and a delivery channel exists.
- **Residual brute-force exposure (accepted, bounded by B11):** the 5-attempt counter is per-user and in-memory (`internal/twofa`, `MaxAttempts=5`, `AttemptWindow` = 10 minutes), and it resets **only** on a successful verify or when the attempt window lapses — **never** on a fresh-code delivery (`internal/twofa/twofa.go:49-51`; `ResetAttempts` is called only on successful verify, B11b). A fresh code therefore never grants a fresh guessing budget, and minting is additionally throttled to one code per minute per user (`twoFAMintCooldown`). The exposure that remains is a locked-out legitimate user who lost their code: they must wait out the 10-minute window, because a hard per-user lockout would strand them 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:** 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).
**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); the counter resets only on a successful verify or when the 10-minute attempt window elapses — never on a fresh-code delivery (B11b, 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, 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.
**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.
- Every saved-card charge **authorised by the 2FA fallback** (no Square `verification_token` — SCA unavailable) additionally writes a `2fa_fallback_charge` row via `insertTwoFAFallbackAudit` (`handlers/payments/handlers.go` ~70): `sca_performed:false`, `fallback_reason:"verification_unavailable"`, the card's last four digits, and the charge reference — so a fallback-authorised charge is always distinguishable from an SCA-authorised one.
- 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.
@@ -1328,7 +1332,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
### Test Coverage
**2,333 tests compiled** across all packages (4 skipped, 0 failures) — as of 14 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
**2,440 backend test functions compiled** (4 skipped, 0 failures) plus **61 frontend vitest cases** — as of 15 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
| Package | Coverage Area |
|---------|--------------|
@@ -272,7 +272,7 @@ A customer pays at the moment of booking (the deposit step) or afterwards from t
From there the customer chooses how to pay:
- **A new card.** The Square Web Payments form runs entirely in the browser, and Square returns a short-lived token plus a verification token confirming the card owner is real (the SCA step). Crussell never sees a card number — that token-only posture is [[#Chapter 9: Square Integration, the Real Client and the Realistic Mock|Chapter 9]]'s territory. Nonces expire, so the front end discards any token older than four minutes or minted for a different amount and [tokenises](https://en.wikipedia.org/wiki/Tokenization_(data_security)) again.
- **A saved card.** One-click checkout against a stored card, gated by the customer's current two-factor code at charge time, keyed to the card's owner, never to whoever operates the screen ([[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]). A stolen saved card is useless unless the thief also holds the customer's live code.
- **A saved card.** One-click checkout against a stored card, authenticated by Square 3DS2 SCA as the primary authorisation — the customer approves the charge in their banking app and the resulting verification token rides the charge to Square ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]). The customer-keyed two-factor code gate from [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] is the backup, firing only when the customer's bank cannot run SCA. A stolen saved card is useless unless the thief can also pass the cardholder's verification — the bank's challenge, or the customer's live code where the 2FA gate is the operative authorisation.
Customers who haven't verified their email can still pay with a new card, but cannot **save** one — saving is refused before anything is written.
@@ -283,7 +283,7 @@ The owner takes money in the salon from the admin payment modal, which accepts f
- **The card machine (Square Terminal).** A checkout is opened on the terminal and the app polls its status every two seconds until the charge completes. Tipping is folded into the amount up front (capped at £50) and Square's own tip prompt is disabled, so the customer is never asked twice (the till-tip rules are in [[#Chapter 5: Tips (Pre-Start vs Post-Start Rules)]]).
- **Cash.** The owner enters what the customer hands over; change is computed on screen and handled at the counter. Cash completes instantly.
- **A gift card or stored balance.** The code is entered or the balance looked up; the money is deducted from that pot *inside the same transaction* that records the payment, so the two can't diverge.
- **A saved card, charged at the till.** The owner charges the customer's card on file — again gated on the customer's 2FA code, which the owner relays to them.
- **A saved card, charged at the till.** The owner charges the customer's card on file — a merchant-initiated stored-credential charge (the cardholder is not at the keyboard), so Square classifies it `customer_initiated=false`: SCA-exempt, with no liability shift ([[#Chapter 14: Saved Cards & Square Customer Profiles|Chapter 14]]). The customer-keyed 2FA gate is the authorisation on this path, with the code relayed by the owner ([[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]).
One rule gates all of these: the admin can only take money for a booking that is **in progress or completed** — the till is for work actually happening or done.
@@ -940,7 +940,7 @@ Adding a card is itself 2FA-gated wherever enforcement is on, and it accepts onl
At charge time, the saved-card branch of the payment flow resolves the source from the card row, and the charge carries the customer's Square profile ID; a `ccof:` source simply cannot be charged without it. Rows created before customer provisioning existed can be retrofitted on the fly: the system looks up the profile, provisions one if the row predates P14, and persists the ID before the charge is allowed to proceed. A provisioning failure aborts the charge; the system never guesses.
Every saved-card charge is also marked as customer-initiated in the payload it sends Square, shaping how Square classifies issuer responses. And every saved-card charge in an enforced environment passes the two-factor gate described in the next chapter.
How Square classifies a saved-card charge depends on who initiates it. A **customer-initiated** charge — the customer paying online from their own booking flow or account page — is marked `customer_details.customer_initiated=true` in the payload it sends Square, shaping how Square classifies issuer responses, and carries Square's verification token (SCA performed) as its primary authorisation ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]). An **admin-initiated** charge — the till, or the admin booking payment — is merchant-initiated instead: `customer_initiated=false`, SCA-exempt, with no liability shift, because the cardholder is not at the keyboard. And every saved-card charge in an enforced environment still passes the two-factor gate described in the next chapter when the SCA path cannot authorise it.
### Deleting a card: disable before delete
@@ -980,6 +980,8 @@ The brute-force defences around codes are layered. A user gets five failed attem
The gate's key decision is **B10**: merely having 2FA enabled does not unlock saved-card charges. In an enforced environment, every saved-card charge must present an *actual code at charge time*. The enabled flag proves the user went through setup; the code proves the owner is present *right now*. This closes the hole where an attacker with a captured session could charge a saved card just because 2FA was configured.
The gate is the *backup*, and on the customer-initiated online paths it usually never fires: the frontend runs Square's buyer verification proactively — the **tokenize-before-first-charge** flow from [[#Appendix A: SCA & the Approve-in-App model|Appendix A]] — so the charge carries a fresh verification token (SCA performed) and the 2FA gate is skipped entirely. The gate becomes the operative authorisation only when that verification cannot complete.
A correct code authorises exactly one charge. The code is consumed at the gate, atomically with the successful check, so two concurrent attempts can never both pass on the same code. If the subsequent Square charge fails, the customer gets a freshly minted code rather than being allowed to re-verify the old one. Saving a card consumes its code the same way, because a save is a terminal operation with no charge to attach consumption to. The gate sits on every saved-card path: booking payments, tips, till charges, gift-card purchases, and the add-card endpoint, so there is no unguarded side door.
### Customer-keyed, and the admin relay
@@ -988,6 +990,8 @@ The single most important property is who the code belongs to. The gate verifies
That is why the admin cannot mint a code for themselves and pass it through. The admin relay endpoint (`POST /api/admin/users/{id}/2fa/code`) takes the target customer's ID and mints the code against the **customer's** record. A code minted against the admin's session would never match the gate's check against the card owner, so a session-scoped mint could never authorise the charge at all. The design keeps the customer as the authentication subject for their own card, always. And every admin mint-or-reuse writes an audit log entry (which admin, which customer, fresh or reused, remaining lifetime), so admin-assisted code issuance is never silent. If the code is being relayed, there is a record that it was.
And every charge actually authorised by the fallback writes its own strict audit row: `2fa_fallback_charge`, recording `sca_performed:false` (SCA was not performed — the charge carried no verification token), the card's last four digits, and the charge reference, so a fallback-authorised saved-card charge is always distinguishable from an SCA-authorised one ([[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] and [[#Appendix A: SCA & the Approve-in-App model|Appendix A]]).
### The relay model
Delivery today is the relay. In dev/test builds the plaintext code appears in the server log under a `[2FA]` marker, with the user ID and the code on separate lines so a single log record cannot trivially pair the two. The operator reads the customer's log line and relays it, or uses the not-yet-wired email/SMS channel once it lands.
@@ -1132,7 +1136,7 @@ The Today page is the operational dashboard: the current and next appointments,
### The admin audit log
Every money-touching admin action writes a row to `admin_audit_log`: who did it, what kind of action it was, which customer it touched, and a details object. Today the audited actions are the four that matter most: **2fa_code_mint** (the admin relayed a verification code to a customer, with whether the code was fresh or reused), **saved_card_charge** (the admin charged a customer's saved card), **till_saved_card_charge** (the same, at the till), and **balance_check** (the admin inspected a customer's gift-card balance).
Every money-touching admin action writes a row to `admin_audit_log`: who did it, what kind of action it was, which customer it touched, and a details object. Today the audited actions are the five that matter most: **2fa_code_mint** (the admin relayed a verification code to a customer, with whether the code was fresh or reused), **2fa_fallback_charge** (a saved-card charge authorised by the 2FA backup because SCA was unavailable — the row records `sca_performed:false`, the card's last four digits, and the charge reference), **saved_card_charge** (the admin charged a customer's saved card), **till_saved_card_charge** (the same, at the till), and **balance_check** (the admin inspected a customer's gift-card balance).
The writes are best-effort and non-fatal. Each audit insert runs in its own transaction, so a failed audit write rolls back only the audit write and can never abort a completed charge or a money movement. The audit log is deliberately the one part of the money path that is allowed to fail silently, because protecting the charge matters more than protecting the record of the charge. If the audit write fails, the money is still safe and the operator is still accountable through the payment row itself.
@@ -1279,18 +1283,18 @@ That is the decision in one line: **Square 3DS2 SCA is the primary authorisation
### The customer journey ("approve in your banking app")
1. The customer checks out with a saved card (online, or at the till on the owner's device).
1. The customer checks out with a saved card online — their own booking flow or account page, the customer-initiated path this appendix documents.
2. The app runs Square's buyer-verification step. The customer's bank presents the 3DS2 challenge — a push notification or in-app approval in their banking app.
3. The customer approves. Square returns a verification token; the backend attaches it to the charge and the payment completes.
4. If the bank approves, that charge is SCA-authenticated: the bank, not the salon, carries the fraud liability, and the whole transaction is PSR 2017-compliant without the salon doing anything else.
The flow is deliberately **customer-initiated** (CIT), not merchant-initiated (MIT). A merchant-initiated charge (the cardholder not present, e.g. a subscription or a scheduled recurring take) follows a different Square classification and a different SCA exemption. Crussell's saved-card charges are always customer-initiated — the owner and the customer are present together — so the CIT path is the one this appendix documents. (Chapter 14's "Using a card" already records the customer-initiated marker on every saved-card charge.)
The flow is deliberately **customer-initiated** (CIT), not merchant-initiated (MIT). A merchant-initiated charge (the cardholder not present, e.g. a subscription or a scheduled recurring take) follows a different Square classification and a different SCA exemption. Crussell's *online* saved-card charges are customer-initiated — the customer is at the keyboard in their own booking flow or account page — so the CIT path is the one this appendix documents. The two admin surfaces are merchant-initiated instead: the till saved-card path and the admin booking "charge saved card" action both send `customer_initiated=false`, which Square reads as MIT — SCA-exempt, with no liability shift. (Chapter 14's "Using a card" records the CIT/MIT marker on every saved-card charge.)
### The saved-card flow: CIT vs MIT, verification tokens, and what Square says when verification is missing
The saved-card branch of the payment flow (`CreateTerminalPayment`'s `saved_card` path, the booking and tip flows, and the gift-card saved-card path) resolves the card's `ccof:` token, attaches the owning Square customer profile (Chapter 14), marks the charge customer-initiated, and — when SCA is the operative authorisation — carries a `verification_token` obtained from Square's buyer verification. The backend validates the token's shape before it is forwarded (`ValidateVerificationToken`) and passes it straight through to the Square request; it never evaluates the token itself, because the token's meaning is Square's and the bank's.
The saved-card branch of the payment flow (`CreateTerminalPayment`'s `saved_card` path, the booking and tip flows, and the gift-card saved-card path) resolves the card's `ccof:` token, attaches the owning Square customer profile (Chapter 14), sets the CIT/MIT marker described above, and — when SCA is the operative authorisation — carries a `verification_token` obtained from Square's buyer verification. This is the **tokenize-before-first-charge** flow: the frontend runs the buyer-verification step *before* the first charge attempt against the card, so a saved card is never charged as a naked `ccof:` without SCA. The backend validates the token's shape before it is forwarded (`ValidateVerificationToken`) and passes it straight through to the Square request; it never evaluates the token itself, because the token's meaning is Square's and the bank's.
When a charge is declined because the buyer could not be verified, Square answers with a specific structured code: **`CARD_DECLINED_VERIFICATION_REQUIRED`** (alongside the sibling codes `VERIFICATION_TOKEN_EXPIRED`, `VERIFICATION_TOKEN_INVALID`, `CVV_VERIFICATION_REQUIRED`, `ADDRESS_VERIFICATION_REQUIRED`, `MISSING_VERIFICATION_TOKEN`). The backend's error classification treats all of these as **definitive** — the same request can never succeed by retrying it; the buyer must re-verify or the card be re-[tokenized](https://en.wikipedia.org/wiki/Tokenization_(data_security)) (Chapter 9 explains why definitive failures are never retried by the sweeps). The dev mock mirrors the behaviour through its `SimulateVerificationRequired` toggle, so the SCA-required failure mode is exercisable in development.
When a charge is declined because the buyer could not be verified, Square answers with a specific structured code: **`CARD_DECLINED_VERIFICATION_REQUIRED`**, alongside the sibling SCA-challenge codes `VERIFICATION_TOKEN_EXPIRED`, `VERIFICATION_TOKEN_INVALID`, and `MISSING_VERIFICATION_TOKEN`. Those four are the complete SCA-challenge set — CVV and address re-entry codes such as `CVV_VERIFICATION_REQUIRED` are deliberately not part of it, because they mean re-entering card data, not a 3DS challenge. The backend's error classification treats all four as **definitive** — the same request can never succeed by retrying it; the buyer must re-verify or the card be re-[tokenized](https://en.wikipedia.org/wiki/Tokenization_(data_security)) (Chapter 9 explains why definitive failures are never retried by the sweeps). The dev mock mirrors the behaviour through its `SimulateSavedCardVerificationRequired` toggle, so the SCA-required failure mode for saved-card charges is exercisable in development.
### The 2FA fallback policy: backup-only, when it fires, and the audit trail
@@ -1305,6 +1309,7 @@ And because it is now a fallback on a regulated path, it carries a **strict audi
- Every **admin** mint-or-reuse of a code for a customer writes an `admin_audit_log` row (`2fa_code_mint`, with whether the code was fresh or reused and its remaining lifetime) — `POST /api/admin/users/{id}/2fa/code`. The admin relay mints against the **customer**, so the audit row names who authorised whom.
- Every **admin** saved-card charge writes its own audit row: `saved_card_charge` for the online path and `till_saved_card_charge` for the till (`insertAdminAuditCharge`, described in Chapter 17). The two rows together let the owner reconstruct, for any fallback-authorised charge, who minted the code, who charged the card, and with whose authorisation.
- Every charge actually **authorised by the 2FA fallback** (SCA unavailable) writes an additional `2fa_fallback_charge` row (`insertTwoFAFallbackAudit`): `sca_performed:false`, `fallback_reason:"verification_unavailable"`, the card's last four digits, and the charge reference — so the operator can tell a fallback-authorised charge apart from an SCA-authorised one at a glance.
- A customer's own code requests (`POST /api/user/2fa/code`) are rate-limited per user and logged like every other 2FA delivery.
The rule for the operator: **when a customer's bank cannot do SCA, the fallback code flow is the supported path — but every step of it is recorded, and the plaintext code must reach the customer through the configured delivery channel, never a guess.**