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 # deliberately route a dev build to the real production API. Never set in a
# deployed production build. # deployed production build.
SQUARE_ALLOW_REAL_API= SQUARE_ALLOW_REAL_API=
# 2FA — merchant-level authorization gate on saved-card online payments (NOT # 2FA — backup merchant-level authorization gate on saved-card online payments.
# PSD2 SCA; Square buyer verification is the SCA mechanism and is wired for # NOT PSD2 SCA: Square buyer verification (3-D Secure / SCA) is the PRIMARY
# new-card charges). Kept as an additional fraud control until Square buyer # authorisation for saved-card charges and is wired for both new-card and
# verification is wired for saved-card charges. Enforcement is FAIL-CLOSED: # 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) # ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive)
# OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test. # OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test.
# Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced # 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 # (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 # startup warning in that case). Set REQUIRE_2FA=false only in controlled
# environments. # environments.
# Code delivery: there is NO email/SMS transport yet. The verification code is # Code delivery: the intended channel is email/SMS (the method chosen at
# delivered via the server log (a [2FA]-prefixed line). In enforced/production # 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; # environments an operator must relay the logged code to the user out-of-band;
# the API never returns the code while enforcement is ON. # the API never returns the code while enforcement is ON.
REQUIRE_2FA=true REQUIRE_2FA=true
@@ -136,9 +141,11 @@ DAV_BASE_URL=http://localhost:8080
# DAV_ADMIN_PASSWORD — REQUIRED, FAIL-CLOSED. sabredav/server.php refuses to # 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 # 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 # 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 # openssl rand -hex 32
DAV_ADMIN_PASSWORD= DAV_ADMIN_PASSWORD=changeme-admin-password
# Logging # Logging
# Set to "true" to disable ANSI color escape sequences in log output # 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. `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) ### 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. `REQUIRE_2FA` gates the **2FA backup** for saved-card online payments. Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`) is now the **primary authorisation** for saved-card charges — a customer-initiated stored-credential charge is a PSR 2017-regulated transaction, and Square's verification token both satisfies SCA and shifts chargeback liability to the card scheme. The homegrown 2FA gate fires **only when SCA is unavailable** (e.g. a customer's bank does not support the in-app approval flow) and is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. The intended 2FA delivery channel is email/SMS (the method chosen at setup), **not yet wired**; until it lands, the 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) — but only in production builds when `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` is set (an explicit, insecure opt-in); without it, production code issuance fails closed (503) and no user can complete 2FA setup, which then 403s every enforced saved-card payment on the fallback path. Every admin mint-or-reuse of a fallback code is recorded in the admin audit log, and every admin saved-card charge writes its own audit row.
`TWO_FACTOR_PEPPER` (backend `.env`) is a server-side pepper for HMAC-hashing 2FA codes: with it set, codes are hashed as HMAC-SHA256 keyed by the pepper; if unset, dev/test builds fall back to the legacy unsalted SHA-256 digest with a one-time warning. It is **required in production builds** — code issuance fails closed when it is unset, because an unsalted digest in the 1M code space would be offline-brute-forceable from a log/DB leak (mirroring the fail-fast `JWT_SECRET_KEY` check). Generate a strong random value with `openssl rand -base64 32`. `TWO_FACTOR_PEPPER` (backend `.env`) is a server-side pepper for HMAC-hashing 2FA codes: with it set, codes are hashed as HMAC-SHA256 keyed by the pepper; if unset, dev/test builds fall back to the legacy unsalted SHA-256 digest with a one-time warning. It is **required in production builds** — code issuance fails closed when it is unset, because an unsalted digest in the 1M code space would be offline-brute-forceable from a log/DB leak (mirroring the fail-fast `JWT_SECRET_KEY` check). Generate a strong random value with `openssl rand -base64 32`.
`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) ### Local dev (tmux)
```bash ```bash
@@ -97,7 +103,7 @@ Default logins (password: `password`):
```bash ```bash
cd backend && go build -o bin/backend ./main.go cd backend && go build -o bin/backend ./main.go
cd frontend && npm ci && npm run build 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) 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 — # NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks —
# those suites share package-global state (Square mock ledger, in-memory webhook # those suites share package-global state (Square mock ledger, in-memory webhook
+1 -1
View File
@@ -17,7 +17,7 @@ linters:
linters-settings: linters-settings:
errcheck: errcheck:
exclude-functions: exclude-functions:
- encoding/json.Encoder.Encode - (*encoding/json.Encoder).Encode
- io.WriteString - io.WriteString
- (io.Closer).Close - (io.Closer).Close
+4 -4
View File
@@ -14,7 +14,7 @@ require (
github.com/kovidgoyal/imaging v1.8.21 github.com/kovidgoyal/imaging v1.8.21
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/text v0.38.0 golang.org/x/text v0.41.0
) )
require ( require (
@@ -54,8 +54,8 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/valyala/fastjson v1.6.10 // indirect github.com/valyala/fastjson v1.6.10 // indirect
golang.org/x/image v0.43.0 // indirect golang.org/x/image v0.45.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.22.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
@@ -70,5 +70,5 @@ require (
github.com/nyaruka/phonenumbers v1.8.0 github.com/nyaruka/phonenumbers v1.8.0
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/crypto v0.53.0 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= 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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= 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 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, Token: tokenString,
JTI: jti, JTI: jti,
RefreshToken: refreshToken, RefreshToken: refreshToken,
@@ -561,7 +561,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
return 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, Token: newToken,
JTI: jti, JTI: jti,
RefreshToken: newRefreshToken, RefreshToken: newRefreshToken,
-5
View File
@@ -339,11 +339,6 @@ type AdminBookingDetail struct {
DurationMinutes int `json:"duration_minutes"` 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 // Helper function to parse query parameters
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
req := GetAllBookingsRequest{ req := GetAllBookingsRequest{
+137 -3
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -103,9 +104,9 @@ func TestChargeFailureStatus(t *testing.T) {
// classifications. // classifications.
func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) { func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
err error err error
want int want int
}{ }{
{"structured 429 rate limited (retryable) → 503", structuredSquareAPIError(t, http.StatusTooManyRequests), http.StatusServiceUnavailable}, {"structured 429 rate limited (retryable) → 503", structuredSquareAPIError(t, http.StatusTooManyRequests), http.StatusServiceUnavailable},
{"structured 408 request timeout (ambiguous) → 503", structuredSquareAPIError(t, http.StatusRequestTimeout), http.StatusServiceUnavailable}, {"structured 408 request timeout (ambiguous) → 503", structuredSquareAPIError(t, http.StatusRequestTimeout), http.StatusServiceUnavailable},
@@ -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.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") 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 // the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT
// having completed 2FA setup, persisting a card is blocked with 403 and no card // having completed 2FA setup, persisting a card is blocked with 403 and no card
// row is created — the save-card endpoint is not an un-gated side door. // row is created — the save-card endpoint is not an un-gated side door.
+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" st.CancellationReason = "The 14-day cancellation period has expired"
default: default:
paymentID, _, paymentOK, perr := findGiftCardPurchasePayment(ctx, q, purchaserID, "", purchaseAmount, purchasedAt) 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) log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr)
st.CancellationReason = "The original purchase payment could not be verified" 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" st.CancellationReason = "The original purchase payment could not be found"
} else { default:
// A completed/pending refund row means the money already // A completed/pending refund row means the money already
// returned (or is in flight) — the card cannot be cancelled a // returned (or is in flight) — the card cannot be cancelled a
// second time. // 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 // returned. Refund the DIFFERENCE via Square with a fresh
// deterministic key before neutralising, so the un-refunded remainder // deterministic key before neutralising, so the un-refunded remainder
// is never silently swallowed. // is never silently swallowed.
refundAmount = refundAmount - priorAmount refundAmount -= priorAmount
refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) 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) log.Printf("Gift-card purchase %s has a prior partial refund of £%.2f — issuing the £%.2f remainder", paymentID, priorAmount, refundAmount)
case "pending": case "pending":
+1 -5
View File
@@ -3099,7 +3099,6 @@ func buildTerminalSplitRecords(primary PaymentRecord, info *BookingPaymentInfo,
bal.IdempotencyKey = &k bal.IdempotencyKey = &k
} }
records = append(records, bal) records = append(records, bal)
splitIdx++
} }
if tipAmount > 0.004 { if tipAmount > 0.004 {
@@ -3309,10 +3308,7 @@ func isDefinitiveCardSaveFailure(err error) bool {
case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD": case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD":
return true return true
} }
if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" { return square.ErrorCategory(err) == "INVALID_REQUEST_ERROR"
return true
}
return false
} }
func RefundPayment(w http.ResponseWriter, r *http.Request) { 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) log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err)
} else if loyaltyUsed { } else if loyaltyUsed {
loyaltyTag, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) 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) 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) 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) 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 appliedCampaignsBeforeInsert := false
if hasTip && bookingInfo != nil && bErr == nil { if hasTip && bookingInfo != nil && bErr == nil {
if pendingCampaignDiscountAmount(ctx, tx, bookingID, bookingUserID, bookingInfo.TotalAmount) > 0.004 { 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 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() w := httptest.NewRecorder()
GetTillCheckoutStatus(w, req) GetTillCheckoutStatus(w, req)
req = adminRequestCtx(req)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) 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" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"testing" "testing"
"crussell/db" "crussell/db"
@@ -141,7 +142,8 @@ func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing
// trail: a 2FA-fallback saved-card charge (no verification token, code verified) // 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 // 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 // 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) { func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
helperEnvEnforce2FA(t) helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(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) w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var auditCount int details := assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
require.NoError(t, tx.QueryRow(ctx, ` require.Equal(t, false, details["sca_performed"])
SELECT COUNT(*) FROM admin_audit_log require.Equal(t, "verification_unavailable", details["fallback_reason"])
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") // 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- // 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") 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 // TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
// allows every request without touching the DB (no user rows are consulted). // allows every request without touching the DB (no user rows are consulted).
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED // Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
@@ -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.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") 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) bookingLondon := ob.StartTime.In(londonLocation)
ourWeekday := int((bookingLondon.Weekday() + 6) % 7) ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
proposed, _ := proposedByWeekday[ourWeekday] proposed := proposedByWeekday[ourWeekday]
isConflict := false isConflict := false
if !proposed.IsOpen { if !proposed.IsOpen {
isConflict = true isConflict = true
@@ -1204,7 +1204,11 @@ func GetScheduledDefaultHoursChange(w http.ResponseWriter, r *http.Request) {
} }
var scheduledHours []DefaultHours 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{ resp := ScheduledHoursChange{
EffectiveDate: *effDate, 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 // Go: Sun=0,Mon=1,...,Sat=6 → Our convention: Mon=0,...,Sun=6
ourWeekday := int((bookingLondon.Weekday() + 6) % 7) ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
proposed, _ := proposedByWeekday[ourWeekday] proposed := proposedByWeekday[ourWeekday]
isConflict := false isConflict := false
if !proposed.IsOpen { if !proposed.IsOpen {
@@ -501,13 +501,14 @@ func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Re
// For midnight-crossing bookings (endMinutes < startMinutes), the booking // For midnight-crossing bookings (endMinutes < startMinutes), the booking
// extends past midnight and always conflicts with daily hours since the // extends past midnight and always conflicts with daily hours since the
// day's open window cannot span past midnight. // day's open window cannot span past midnight.
if startMinutes < 0 || endMinutes < 0 { switch {
case startMinutes < 0 || endMinutes < 0:
// parse error — treat as conflict // parse error — treat as conflict
isConflict = true isConflict = true
} else if endMinutes < startMinutes { case endMinutes < startMinutes:
// Booking crosses midnight — always a conflict with daily hours // Booking crosses midnight — always a conflict with daily hours
isConflict = true isConflict = true
} else if startMinutes < propStartMinutes || endMinutes > propEndMinutes { case startMinutes < propStartMinutes || endMinutes > propEndMinutes:
isConflict = true isConflict = true
} }
} }
@@ -30,6 +30,7 @@ import (
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/internal/square" "crussell/internal/square"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -3888,7 +3889,7 @@ func TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache(t *testing.T
} }
origSquare := payments.SquareClient origSquare := payments.SquareClient
payments.SquareClient = square.NewDevClient() payments.SquareClient = testutils.NewTestSquareClient()
defer func() { payments.SquareClient = origSquare }() defer func() { payments.SquareClient = origSquare }()
t.Cleanup(func() { payments.InvalidateSquareCustomerCache(guestID) }) t.Cleanup(func() { payments.InvalidateSquareCustomerCache(guestID) })
+66 -3
View File
@@ -15,9 +15,7 @@ import (
"crussell/testutils" "crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
) ) // ============================================================
// ============================================================
// GetGDPRExportHandler Tests // 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) { func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) 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 // imports neither handlers/user nor handlers/payments, so the payments
// card-access gate can verify a real 2FA challenge without an import cycle // 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 // (B11c). The identifiers below are thin aliases/wrappers so the HTTP handlers
// and the existing tests keep their original names. // 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
// hashTwoFACode returns the hex digest of a verification code as stored in the // 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 // DB (pepper-driven HMAC-SHA256, or the legacy plain SHA-256 when the pepper is
// unset). Delegates to the shared implementation. // unset). Delegates to the shared implementation.
func hashTwoFACode(code string) string { return twofa.Hash(code) } 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. // twoFAAttemptState aliases the shared per-user attempt state.
type twoFAAttemptState = twofa.AttemptState 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 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 // deliverTwoFACode generates a fresh verification code and persists only its
// digest plus the pending expiry (updating two_factor_method when method is // digest plus the pending expiry (updating two_factor_method when method is
// non-empty). // non-empty).
+17
View File
@@ -44,6 +44,23 @@ func twofaEnvEnforced(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production") 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) { func twofaEnvUnenforced(t *testing.T) {
t.Helper() t.Helper()
// Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED // Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
+8 -18
View File
@@ -45,23 +45,6 @@ import (
// DeleteAccountHandler Coverage Tests // 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 // TestDeleteAccount_Unauthorized verifies that deleting an account without
// setting user ID in context returns 401 Unauthorized. // setting user ID in context returns 401 Unauthorized.
func TestDeleteAccount_Unauthorized(t *testing.T) { 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 // the same (anonymized) user must re-mint a fresh Square customer instead of
// reusing the deleted one's stale cached id. // reusing the deleted one's stale cached id.
func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) { 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 savedSquareClient := payments.SquareClient
payments.SquareClient = square.NewDevClient() payments.SquareClient = testutils.NewTestSquareClient()
t.Cleanup(func() { payments.SquareClient = savedSquareClient }) t.Cleanup(func() { payments.SquareClient = savedSquareClient })
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
+6 -3
View File
@@ -14,6 +14,7 @@ import (
"crussell/db" "crussell/db"
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/internal/square" "crussell/internal/square"
"crussell/testutils"
"crussell/testutils/testdb" "crussell/testutils/testdb"
) )
@@ -378,13 +379,15 @@ func (c *recordingErasureClient) customerDeletes() []string {
return append([]string(nil), c.deletedCustomers...) 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 // 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 { func newErasureTestClient(t *testing.T) *recordingErasureClient {
t.Helper() t.Helper()
t.Setenv("SQUARE_ENVIRONMENT", "mock") 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 // seedErasureOutboxRow inserts a soft-deleted user_saved_cards row that the
+206 -13
View File
@@ -1403,20 +1403,20 @@ func TestDevClient_CreatePayment_RejectsRawPAN(t *testing.T) {
{"amex", "378282246310005"}, {"amex", "378282246310005"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{ result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Amount: 5000,
Currency: "GBP", Currency: "GBP",
SourceID: tt.pan, SourceID: tt.pan,
IdempotencyKey: "raw-pan-" + tt.name, IdempotencyKey: "raw-pan-" + tt.name,
ReferenceID: "booking-raw", ReferenceID: "booking-raw",
})
require.Error(t, err, "raw PAN must be rejected for production parity")
assert.Nil(t, result)
assert.Contains(t, err.Error(), "invalid source_id")
}) })
} require.Error(t, err, "raw PAN must be rejected for production parity")
assert.Nil(t, result)
assert.Contains(t, err.Error(), "invalid source_id")
})
}
} }
// TestDevClient_CreatePayment_CardOnFileRequiresCustomerID verifies the mock // TestDevClient_CreatePayment_CardOnFileRequiresCustomerID verifies the mock
@@ -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") 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: { languageOptions: {
globals: { ...globals.browser, ...globals.node } 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"> <script lang="ts">
import { apiFetch } from '$lib/utils/api'; 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 { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe'; import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -16,7 +16,11 @@
getLunchProtectionForSlots, getLunchProtectionForSlots,
timeToMinutes timeToMinutes
} from '$lib/lunchProtection'; } 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 ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -71,7 +75,7 @@
// ─── Date constants ───────────────────────────────────── // ─── Date constants ─────────────────────────────────────
const todayCalendarDate = getLondonTodayCalendarDate(); const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate; const minDate = todayCalendarDate;
const maxDate = new Date( const maxDate = new SvelteDate(
todayCalendarDate.year, todayCalendarDate.year,
todayCalendarDate.month - 1, todayCalendarDate.month - 1,
todayCalendarDate.day todayCalendarDate.day
@@ -458,7 +462,7 @@
const daysToCheck = Math.min(daysDifference, 180); const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) { for (let i = 0; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate); const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i); nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -2,7 +2,7 @@
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe'; 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 { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay'; import { formatUserName } from '$lib/utils/nameDisplay';
@@ -217,7 +217,7 @@
// Date Boundaries // Date Boundaries
const today = getLondonTodayCalendarDate(); const today = getLondonTodayCalendarDate();
const minDate = today; 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); maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate( const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(), maxDate.getFullYear(),
@@ -408,7 +408,7 @@
); );
const daysToCheck = Math.min(daysDifference, 180); const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) { for (let i = 0; i <= daysToCheck; i++) {
const checkDate = new Date(now); const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i); checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate( const calDate = new CalendarDate(
@@ -456,29 +456,29 @@
/> />
{/if} {/if}
<div> <div>
{#if selectedBooking.user} {#if selectedBooking.user}
{@const customer = selectedBooking.user} {@const customer = selectedBooking.user}
{@const userName = formatUserName( {@const userName = formatUserName(
customer.full_name || '—', customer.full_name || '—',
customer.previous_first_name, customer.previous_first_name,
customer.previous_last_name customer.previous_last_name
)} )}
<div class="text-lg font-semibold"> <div class="text-lg font-semibold">
{#if openUserModal} {#if openUserModal}
<button <button
type="button" type="button"
class="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline" class="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline"
onclick={() => openUserModal(customer.id)} onclick={() => openUserModal(customer.id)}
> >
{userName}
</button>
{:else}
{userName} {userName}
</button> {/if}
{:else} </div>
{userName} {:else}
{/if} <div class="text-lg font-semibold"></div>
</div> {/if}
{:else}
<div class="text-lg font-semibold"></div>
{/if}
{#if selectedBooking.user?.date_of_birth} {#if selectedBooking.user?.date_of_birth}
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
{calculateAge(selectedBooking.user.date_of_birth)} years old {calculateAge(selectedBooking.user.date_of_birth)} years old
@@ -388,8 +388,8 @@
<span class="text-xs text-muted-foreground">Expiry Period</span> <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-sm font-medium">{settings.gift_card_expiry_months} months</p>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Rolling from last use — each balance check, top-up, redemption or payment Rolling from last use — each balance check, top-up, redemption or payment resets the
resets the timer. timer.
</p> </p>
</div> </div>
<div> <div>
@@ -5,6 +5,7 @@
import { range, formatDuration } from '$lib/utils/format'; import { range, formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay'; import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots'; import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components // shadcn-svelte components
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -179,7 +180,7 @@
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) { function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new Date(fromISO + 'T00:00:00Z'); const from = new Date(fromISO + 'T00:00:00Z');
const to = new Date(toISO + '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 day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day; const daysToMonday = day === 0 ? -6 : 1 - day;
@@ -187,7 +188,7 @@
first.setDate(first.getDate() + daysToMonday); first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range // 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))); dest.push(isoDateOf(new Date(d)));
} }
} }
@@ -749,7 +750,7 @@
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="h-6 text-xs ml-auto" class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings} onclick={checkConflictingBookings}
> >
Refresh Refresh
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe'; import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
@@ -58,7 +58,7 @@
const today = getLondonTodayCalendarDate(); const today = getLondonTodayCalendarDate();
const minDate = today; 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); maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate( const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(), maxDate.getFullYear(),
@@ -391,7 +391,7 @@
); );
const daysToCheck = Math.min(daysDifference, 180); const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) { for (let i = 1; i <= daysToCheck; i++) {
const checkDate = new Date(now); const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i); checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate( const calDate = new CalendarDate(
@@ -458,8 +458,8 @@
<details class="ml-6 text-xs text-gray-500"> <details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">When to use this</summary> <summary class="cursor-pointer hover:text-gray-700">When to use this</summary>
<p class="mt-1"> <p class="mt-1">
Use for genuinely excusable cancellations, or when the salon cancels and chooses Use for genuinely excusable cancellations, or when the salon cancels and chooses not
not to keep the money. When unchecked, standard notice-period fees apply (e.g. a to keep the money. When unchecked, standard notice-period fees apply (e.g. a
customer who calls up to cancel). customer who calls up to cancel).
</p> </p>
</details> </details>
@@ -6,24 +6,27 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe'; import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte'; import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import { import {
isSquareConfigured, CARD_VERIFICATION_RETRY_MESSAGE,
isTwoFactorVerificationGateFailure, isSquareConfigured,
isVerificationRequiredSignal, isTwoFactorVerificationGateFailure,
shouldFallbackTo2FA, isVerificationRequiredSignal,
submitPaymentWithRetry, shouldFallbackTo2FA,
adminRequestNewTwoFactorCode, SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
requestNewTwoFactorCode, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE adminRequestNewTwoFactorCode,
} from '$lib/square/square'; requestNewTwoFactorCode,
import { VERIFICATION_REQUIRED_MESSAGE
tokenizeSavedCardWithVerification, } from '$lib/square/square';
type SavedCardVerificationResult import {
} from '$lib/components/payments/SquareCardInput.svelte'; tokenizeSavedCardWithVerification,
import { authStore } from '$lib/stores/auth.svelte'; type SavedCardVerificationResult
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte'; } from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
type CartItem = { type CartItem = {
id: string; id: string;
@@ -65,7 +68,7 @@
// the customer is not charged twice. A changed cart/amount/payment method // the customer is not charged twice. A changed cart/amount/payment method
// yields a different composite key, so genuinely new sales get fresh keys. // yields a different composite key, so genuinely new sales get fresh keys.
// Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern. // 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 { function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
// saved_card charges also key on the selected card id so switching to a // saved_card charges also key on the selected card id so switching to a
@@ -148,10 +151,13 @@
let awaitingSCA = $state(false); let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({ const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true, enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card', gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome), scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () => 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 // 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 // challenge and retry the SAME sale line with the fresh token and
// its SAME cached idempotency key. runTillSavedCardSCA throws to // its SAME cached idempotency key. runTillSavedCardSCA throws to
// stop the whole sale on any non-verified outcome. // 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); await runTillSavedCardSCA(body);
continue; continue;
} }
@@ -433,75 +442,71 @@
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true; if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
paymentError = msg; paymentError = msg;
toast.error(msg); toast.error(msg);
} finally { } finally {
isProcessingPaymentSync = false; isProcessingPaymentSync = false;
processing = false; processing = false;
}
} }
}
/** /**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402 * Saved-card (ccof) SCA challenge, run when a till sale line came back 402
* with the verification-required signal. The CUSTOMER approves the 3DS * with the verification-required signal. The CUSTOMER approves the 3DS
* challenge in their banking app; the operator's screen shows the waiting * challenge in their banking app; the operator's screen shows the waiting
* state. 'verified' retries the SAME sale line with the fresh verification_token * state. 'verified' retries the SAME sale line with the fresh verification_token
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable' * and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
* demotes 2FA from backup to the available gate; 'challenge-cancelled' / * demotes 2FA from backup to the available gate; 'challenge-cancelled' /
* 'sca-failed' keep the pending row retryable (the idempotency key stays * 'sca-failed' keep the pending row retryable (the idempotency key stays
* cached). Throws to stop the whole sale on any non-verified outcome. * 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; const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by // The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge. // 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100); const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true; awaitingSCA = true;
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
}
let result: SavedCardVerificationResult;
try { try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, { if (!squareCardId) {
email: selectedCustomer?.email lastSCAOutcome = 'sca-unavailable';
}); twoFactor.reveal = true;
} catch (err) { throw new Error(VERIFICATION_REQUIRED_MESSAGE);
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
} }
return; let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
return;
}
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`);
}
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally {
awaitingSCA = false;
} }
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(
`${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
);
}
throw new Error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
} finally {
awaitingSCA = false;
} }
}
</script> </script>
<div class="rounded-xl border bg-card"> <div class="rounded-xl border bg-card">
@@ -585,13 +590,13 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
> >
</div> </div>
{#if giftCardAmountTooHigh} {#if giftCardAmountTooHigh}
<p class="text-xs text-red-700" <p class="text-xs text-red-700">
>Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})</p Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})
> </p>
{/if} {/if}
<p class="text-xs text-muted-foreground" <p class="text-xs text-muted-foreground">
>Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction</p Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction
> </p>
</div> </div>
{:else} {:else}
<Button <Button
@@ -885,9 +890,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
<line x1="12" y1="8" x2="12" y2="12" /> <line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" /> <line x1="12" y1="16" x2="12.01" y2="16" />
</svg> </svg>
<p class="text-xs text-amber-800"> <p class="text-xs text-amber-800">
Your card issuer will ask you to approve this payment in your banking app. Your card issuer will ask you to approve this payment in your banking app.
</p> </p>
</div> </div>
{/if} {/if}
@@ -928,7 +933,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
{/if} {/if}
{#if awaitingSCA} {#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 <div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary" class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div> ></div>
@@ -204,7 +204,8 @@
const sortedBlockers = $derived.by(() => { const sortedBlockers = $derived.by(() => {
return [...blockers].sort( 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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="h-6 text-xs ml-auto" class="ml-auto h-6 text-xs"
onclick={checkOverlappingBookings} onclick={checkOverlappingBookings}
> >
Refresh Refresh
@@ -731,8 +731,8 @@
selectedUser.previousFirstName, selectedUser.previousFirstName,
selectedUser.previousLastName selectedUser.previousLastName
)} )}
? They will no longer need a 2FA code for card payments. Use this only when the user has ? They will no longer need a 2FA code for card payments. Use this only when the user has lost
lost access to their 2FA method. access to their 2FA method.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
@@ -3,7 +3,7 @@
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe'; 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 { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay'; import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime } from '$lib/utils/timeSlots'; import { formatLocalDateTime } from '$lib/utils/timeSlots';
@@ -479,7 +479,7 @@
const [hours, minutes] = availableStartTime.split(':').map(Number); const [hours, minutes] = availableStartTime.split(':').map(Number);
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number); 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 { } else {
// Fallback: Calculate immediate start time (rounded to next 15 min) // Fallback: Calculate immediate start time (rounded to next 15 min)
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -491,8 +491,8 @@
}); });
const [y, m, d] = londonDateStr.split('-').map(Number); const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number); const [h, min] = londonTimeStr.split(':').map(Number);
const now = new Date(y, m - 1, d, h, min, 0, 0); const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
start = new Date(now); start = new SvelteDate(now);
const minutes = start.getMinutes(); const minutes = start.getMinutes();
const remainder = 15 - (minutes % 15); const remainder = 15 - (minutes % 15);
if (remainder !== 15 && remainder !== 0) { if (remainder !== 15 && remainder !== 0) {
@@ -6,6 +6,7 @@
import { formatUserName } from '$lib/utils/nameDisplay'; import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots'; import { parseWallClockDate } from '$lib/utils/timeSlots';
import { formatDuration, range } from '$lib/utils/format'; import { formatDuration, range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components // shadcn-svelte components
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -228,7 +229,7 @@
} }
function getDefaultEffectiveDate(): string { function getDefaultEffectiveDate(): string {
const d = new Date(); const d = new SvelteDate();
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
@@ -814,7 +815,7 @@
</div> </div>
<!-- Effective Date & Conflict Resolution --> <!-- 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 class="space-y-3">
<div> <div>
<label for="effective_date" class="mb-1 block text-sm font-medium text-gray-700"> <label for="effective_date" class="mb-1 block text-sm font-medium text-gray-700">
@@ -879,7 +880,7 @@
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="h-6 text-xs ml-auto" class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings} onclick={checkConflictingBookings}
> >
Refresh Refresh
@@ -19,6 +19,7 @@
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at // 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. // "10am June 15" stays at 10am BST regardless of when the booking was made.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
@@ -40,6 +41,7 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy'; import { POLICY } from '$lib/constants/policy';
import { import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole, canSaveCardsForRole,
campaignDiscountPence, campaignDiscountPence,
depositChargePence, depositChargePence,
@@ -173,6 +175,12 @@
// from backup to the only available gate (scaAvailable → false); every other // from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry. // outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); 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({ const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled, enabled: () => twoFactorEnabled,
gateActive: () => gateActive: () =>
@@ -412,6 +420,9 @@
depositTokenAmount = amountPence; depositTokenAmount = amountPence;
depositTokenizedAt = Date.now(); depositTokenizedAt = Date.now();
depositTokenizedForSaveCard = depositSaveCard; depositTokenizedForSaveCard = depositSaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed'); toast.error(err instanceof Error ? err.message : 'Card entry failed');
return; return;
@@ -449,14 +460,18 @@
// the cached idempotency key above (never regenerated across the // the cached idempotency key above (never regenerated across the
// challenge-then-charge). // challenge-then-charge).
if (selectedPaymentMethod && !verificationToken) { if (selectedPaymentMethod && !verificationToken) {
const proactive = await runDepositSCAProactively(amountPence); waitingForSCA = true;
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') { try {
toast.error( const proactive = await runDepositSCAProactively(amountPence);
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
); depositError = CARD_VERIFICATION_RETRY_MESSAGE;
return; toast.error(depositError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
} }
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} }
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
@@ -562,7 +577,8 @@
// charge), surface the guidance and let the user retry — never re-run SCA // charge), surface the guidance and let the user retry — never re-run SCA
// silently mid-flow. // silently mid-flow.
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) { if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
toast.warning(VERIFICATION_REQUIRED_MESSAGE); depositError = VERIFICATION_REQUIRED_MESSAGE;
toast.warning(depositError);
return; return;
} }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
@@ -589,9 +605,7 @@
amountPence, amountPence,
overflowPence: Math.max( overflowPence: Math.max(
0, 0,
amountPence - amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100) - depositDiscountPence
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
), ),
chargePence: Math.max(0, amountPence - depositDiscountPence), chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount, depositAmount,
@@ -626,21 +640,22 @@
depositPaid = confirmedBooking.deposit_paid; depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!'); toast.success('Payment successful!');
} else { } else {
toast.warning( depositError =
extractErrorMessage(text) || 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 { } catch {
toast.warning( depositError =
extractErrorMessage(text) || 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 { } else {
toast.warning( depositError =
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.' 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 // A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) — // nonce + SCA verification token (Square nonces are single-use) —
@@ -653,6 +668,17 @@
depositTokenAmount = 0; depositTokenAmount = 0;
depositTokenizedAt = 0; depositTokenizedAt = 0;
depositTokenizedForSaveCard = false; 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 // Initialize date boundaries
const today = getLondonTodayCalendarDate(); const today = getLondonTodayCalendarDate();
const minDate = today; 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); maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate( const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(), maxDate.getFullYear(),
@@ -1083,7 +1109,7 @@
const daysToCheck = Math.min(daysDifference, 180); const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) { for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate); const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i); nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -1306,7 +1332,7 @@
// =============== Time Slot Generation =============== // =============== Time Slot Generation ===============
function calculateEndTime(startTime: string, durationMinutes: number): string { function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number); const [hours, minutes] = startTime.split(':').map(Number);
const date = new Date(); const date = new SvelteDate();
date.setHours(hours, minutes, 0, 0); date.setHours(hours, minutes, 0, 0);
date.setMinutes(date.getMinutes() + durationMinutes); date.setMinutes(date.getMinutes() + durationMinutes);
const endHours = date.getHours().toString().padStart(2, '0'); const endHours = date.getHours().toString().padStart(2, '0');
@@ -2695,6 +2721,38 @@
onCancel={cancelOverflowConfirmation} onCancel={cancelOverflowConfirmation}
/> />
{:else} {: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 <BookingSummary
services={selectedServices} services={selectedServices}
date={selectedDate} date={selectedDate}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { formatTime } from '$lib/utils/timeSlots'; import { formatTime } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
type DefaultHours = { type DefaultHours = {
weekday: number; // 0=Mon, 1=Tue, ..., 6=Sun weekday: number; // 0=Mon, 1=Tue, ..., 6=Sun
@@ -80,7 +81,7 @@
const today = getLondonDate(); const today = getLondonDate();
const dayOfWeek = today.getDay(); // 0=Sun const dayOfWeek = today.getDay(); // 0=Sun
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(today); const monday = new SvelteDate(today);
monday.setDate(today.getDate() + offset); monday.setDate(today.getDate() + offset);
return fmtDate(monday); return fmtDate(monday);
} }
@@ -115,7 +115,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3"> <div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800"> <p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments. 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> </p>
</div> </div>
{/if} {/if}
@@ -127,7 +129,7 @@
<button <button
type="button" type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId === class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm card.id && !showNewCardForm
? 'border-input bg-accent' ? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}" : 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { onclick={() => {
@@ -373,8 +373,10 @@
<div class="rounded-md border border-amber-300 bg-amber-50 p-3"> <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="text-sm font-medium text-amber-800">Waiting for approval in the banking app…</p>
<p class="mt-1 text-xs text-amber-700"> <p class="mt-1 text-xs text-amber-700">
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome ({challengeResult ===
({challengeResult === 'deny' ? 'deny' : 'approve'}). 'deny'
? 'deny'
: 'approve'}).
</p> </p>
<button <button
type="button" type="button"
@@ -51,22 +51,9 @@
</div> </div>
</div> </div>
<div class="mt-4 flex gap-2"> <div class="mt-4 flex gap-2">
<Button <Button class="flex-1" {loading} disabled={loading} autofocus onclick={onConfirm}>
class="flex-1"
loading={loading}
disabled={loading}
autofocus
onclick={onConfirm}
>
Confirm Confirm
</Button> </Button>
<Button <Button variant="outline" class="flex-1" disabled={loading} onclick={onCancel}>Cancel</Button>
variant="outline"
class="flex-1"
disabled={loading}
onclick={onCancel}
>
Cancel
</Button>
</div> </div>
</div> </div>
@@ -9,13 +9,13 @@
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking'; import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import { import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence, campaignDiscountPence,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE, SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE, VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode, adminRequestNewTwoFactorCode,
@@ -491,6 +491,20 @@
onClose(); 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 // 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 // refresh the booking/payment data) and then close the modal. Kept separate
// from handleClose so a success state never closes without the callback. // from handleClose so a success state never closes without the callback.
@@ -852,7 +866,9 @@
await runSavedCardSCA(chargeAmount); await runSavedCardSCA(chargeAmount);
return; 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; (err as { bodyText?: string }).bodyText = errData;
throw err; throw err;
} }
@@ -879,16 +895,14 @@
onComplete(paymentResult); onComplete(paymentResult);
} catch (_err) { } catch (_err) {
status = 'error'; status = 'error';
// A definitive 402 on the saved-card path means the issuer still // A 402 carrying the structured verification-required signal (or the
// requires verification. A structured verification-required signal // dev/mock text parity) surfaces the SCA-first guidance. A plain
// surfaces the SCA-first guidance; the legacy saved-card check is the // decline 402 shows the normal decline error — it must not be
// fallback for generic 402s (the SCA flow above intercepts the // relabeled "requires verification".
// structured ones, so this is the belt-and-braces path).
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment'; let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? ''; const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText); if (isVerificationRequiredSignal(responseStatus, bodyText)) {
if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) { msg = VERIFICATION_REQUIRED_MESSAGE;
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
} }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated // code, brute-force lockout) is recoverable — keep the code populated
@@ -1004,13 +1018,20 @@
status = 'error'; status = 'error';
error = error =
result.outcome === 'sca-unavailable' result.outcome === 'sca-unavailable'
? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.` ? `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`
: "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."; : CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error); toast.error(error);
} }
</script> </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.Content class="max-w-lg">
<Dialog.Header> <Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title> <Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
@@ -1250,7 +1271,7 @@
<button <button
type="button" type="button"
disabled={nothingToCharge} 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' 'savedcard'
? 'border-input bg-fuchsia-100 text-foreground' ? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}" : 'border-input hover:bg-fuchsia-50'}"
@@ -1276,7 +1297,7 @@
<button <button
type="button" type="button"
disabled={nothingToCharge} 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' 'giftcard'
? 'border-input bg-fuchsia-100 text-foreground' ? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}" : 'border-input hover:bg-fuchsia-50'}"
@@ -1657,7 +1678,11 @@
<!-- B6/B10: saved-card charges require the customer's current 2FA <!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. --> 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} {#if twoFactor.showInput}
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh 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"> <p class="text-lg font-medium text-gray-700">
Waiting for customer to approve in their banking app… Waiting for customer to approve in their banking app…
</p> </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} {:else}
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p> <p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
{/if} {/if}
@@ -1,5 +1,18 @@
<script module lang="ts"> <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 * Billing contact passed to Square's tokenize() verificationDetails for
@@ -18,21 +31,6 @@
verificationToken: string | null; 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. */ /** Square Web Payments `card.tokenize()` verification details shape. */
interface SquareVerificationDetails { interface SquareVerificationDetails {
amount: string; amount: string;
@@ -43,14 +41,6 @@
sellerKeyedIn: boolean; sellerKeyedIn: boolean;
} }
/** Square Web Payments `card.tokenize()` result shape (verification path). */
interface SquareTokenizeResult {
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}
/** /**
* Runs the SCA challenge for a SAVED card (ccof) whose charge Square refused * 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 * with a "verification required" signal. Square's card-on-file flow binds
@@ -136,27 +126,13 @@
return { verificationToken: null, outcome: 'sca-unavailable' }; return { verificationToken: null, outcome: 'sca-unavailable' };
} }
if (result.status === 'OK' && result.verificationResult?.token) { // The shared parse maps the SDK result to the saved-card outcome:
return { verificationToken: result.verificationResult.token, outcome: 'verified' }; // `status === 'OK'` → 'verified' (the SCA-verified token is `result.token`
} // in the current SDK — never a nested verificationResult, which only
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean); // exists on the deprecated verifyBuyer() flow), tokenless when the issuer
const errorText = // demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable;
codes.join(' ') + // CARD_DECLINED_VERIFICATION_REQUIRED → 2FA fallback.
' ' + return parseTokenizeVerificationResult(result);
(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' };
} }
/** /**
@@ -239,12 +215,7 @@
attach: (selector: string) => Promise<void>; attach: (selector: string) => Promise<void>;
tokenize: ( tokenize: (
verificationDetails?: SquareVerificationDetails verificationDetails?: SquareVerificationDetails
) => Promise<{ ) => Promise<SquareTokenizeResult>;
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
destroy: () => void; destroy: () => void;
}>; }>;
}; };
@@ -289,14 +260,7 @@
return mockForm.tokenize(); return mockForm.tokenize();
} }
const card = cardInstance as { const card = cardInstance as {
tokenize: ( tokenize: (verificationDetails?: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
} | null; } | null;
if (!card) { if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again'); 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); return mockForm.tokenizeWithVerification(amount, contact, saveCard);
} }
const card = cardInstance as { const card = cardInstance as {
tokenize: ( tokenize: (verificationDetails: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
verificationDetails: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
} | null; } | null;
if (!card) { if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again'); 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 // In the current tokenize-with-verification flow the returned nonce
// (result.token) is ALREADY the 3DS-verified token — Square binds the // (result.token) is ALREADY the 3DS-verified token — Square binds the
// SCA challenge to this exact amount, so charging it as // SCA challenge to this exact amount, so charging it as
// `new_card_token`/`card_token` is sufficient. `verificationResult` // `new_card_token`/`card_token` is sufficient and no separate
// only exists on the deprecated verifyBuyer() flow; we still read it // verification_token exists (that nested shape only came from the
// defensively since the backend accepts an explicit verification_token. // deprecated verifyBuyer() flow).
return { return { nonce: result.token, verificationToken: null };
nonce: result.token,
verificationToken: result.verificationResult?.token ?? null
};
} }
const detail = const detail =
result.errors result.errors
@@ -14,14 +14,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { generateUUID } from '$lib/utils/uuid'; import { generateUUID } from '$lib/utils/uuid';
import { import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole, canSaveCardsForRole,
isNonceStale, isNonceStale,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square'; } from '$lib/square/square';
@@ -125,6 +124,13 @@
// from backup to the only available gate (scaAvailable → false); every other // from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry. // outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); 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({ const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled, enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard), gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
@@ -277,6 +283,9 @@
tipTokenAmount = tipAmount; tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now(); tipTokenizedAt = Date.now();
tipTokenizedForSaveCard = saveCard; tipTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed'); toast.error(err instanceof Error ? err.message : 'Card entry failed');
return; return;
@@ -291,7 +300,6 @@
paymentState = 'processing'; paymentState = 'processing';
const usedSavedCard = !!selectedCardId;
let responseStatus = 0; let responseStatus = 0;
try { try {
@@ -299,32 +307,36 @@
// rationale as the booking/account flows). Include the card so a // rationale as the booking/account flows). Include the card so a
// same-amount tip on a DIFFERENT card gets a fresh key instead of // same-amount tip on a DIFFERENT card gets a fresh key instead of
// deduping against the previous card's charge. // deduping against the previous card's charge.
const cardKey = selectedCardId || 'new-card'; const cardKey = selectedCardId || 'new-card';
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) { if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
tipIdempotencyKey = generateUUID(); tipIdempotencyKey = generateUUID();
tipKeyedAmount = tipAmount; tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey; tipKeyedCard = cardKey;
}
const amountInPence = Math.round(tipAmount * 100);
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first tip charge attempt so it carries a fresh
// verification_token — a naked ccof is never sent. Only
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
// fallback); a cancelled/failed challenge does NOT charge — the user
// 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) {
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."
);
return;
} }
if (proactive.verificationToken) verificationToken = proactive.verificationToken; const amountInPence = Math.round(tipAmount * 100);
} // Proactive saved-card (ccof) SCA: run the client-side challenge
const body: Record<string, unknown> = { // BEFORE the first tip charge attempt so it carries a fresh
// verification_token — a naked ccof is never sent. Only
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
// fallback); a cancelled/failed challenge does NOT charge — the user
// 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';
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, amount: amountInPence,
idempotency_key: tipIdempotencyKey, idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}), ...(selectedCardId ? { card_id: selectedCardId } : {}),
@@ -372,19 +384,14 @@
} catch (err) { } catch (err) {
paymentState = 'error'; paymentState = 'error';
let errorMessage = err instanceof Error ? err.message : 'Payment failed'; let errorMessage = err instanceof Error ? err.message : 'Payment failed';
// A definitive 402 on the saved-card path means the issuer still // A 402 carrying the structured verification-required signal (or
// requires verification. A structured verification-required signal // the dev/mock text parity) surfaces the SCA-first guidance. A
// surfaces the SCA-first guidance; the legacy saved-card check is // plain decline 402 shows the normal decline error — it must not
// the fallback for generic 402s. // be relabeled "requires verification".
const bodyText = (err as { bodyText?: string })?.bodyText ?? ''; const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText); const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if ( if (verificationFailure) {
scaVerificationRequired || errorMessage = VERIFICATION_REQUIRED_MESSAGE;
isSavedCardVerificationRequired(responseStatus, usedSavedCard)
) {
errorMessage = scaVerificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: SAVED_CARD_VERIFICATION_MESSAGE;
} }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated // code, brute-force lockout) is recoverable — keep the code populated
@@ -392,16 +399,28 @@
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) { if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
twoFactor.reveal = true; twoFactor.reveal = true;
} }
tipError = errorMessage;
toast.error(errorMessage); toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce // A definitive charge failure (e.g. declined card) consumes the nonce
// and SCA verification token — they can never succeed again. Clear the // and SCA verification token — they can never succeed again. Clear the
// cached pair so the next retry re-tokenizes fresh. The idempotency // cached pair so the next retry re-tokenizes fresh.
// key is kept: it's still correct for network-timeout dedup.
tipNonce = ''; tipNonce = '';
tipVerificationToken = ''; tipVerificationToken = '';
tipTokenAmount = 0; tipTokenAmount = 0;
tipTokenizedAt = 0; tipTokenizedAt = 0;
tipTokenizedForSaveCard = false; 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 { } finally {
isSubmittingTipSync = false; isSubmittingTipSync = false;
@@ -447,6 +466,7 @@
function retryPayment() { function retryPayment() {
paymentState = 'idle'; paymentState = 'idle';
tipError = null;
} }
</script> </script>
@@ -609,11 +629,29 @@
{#if paymentState === 'error'} {#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4"> <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> <Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
</div> </div>
{/if} {/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 <Button
class="w-full" class="w-full"
size="lg" size="lg"
@@ -57,7 +57,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3"> <div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800"> <p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments. 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> </p>
</div> </div>
{/if} {/if}
@@ -17,16 +17,15 @@
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid'; import { generateUUID } from '$lib/utils/uuid';
import { import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence, campaignDiscountPence,
depositChargePence, depositChargePence,
isNonceStale, isNonceStale,
isOverflowTipConfirmationRequired, isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square'; } from '$lib/square/square';
@@ -70,6 +69,11 @@
// from backup to the only available gate (scaAvailable → false); every other // from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry. // outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); 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({ const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled, enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard), gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
@@ -443,6 +447,10 @@
newCardTokenAmount = amountPence; newCardTokenAmount = amountPence;
newCardTokenizedAt = Date.now(); newCardTokenizedAt = Date.now();
newCardTokenizedForSaveCard = saveCard; 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) { } catch (_err) {
status = 'error'; status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed'; 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 // 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 // challenge does NOT charge — the user taps Pay again to re-run it, and
// the cached idempotency key above is never regenerated across the // 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) { if (cardId && !verificationToken) {
const proactive = await runSavedCardSCAProactively(amountPence, cardId); waitingForSCA = true;
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') { try {
status = 'error'; const proactive = await runSavedCardSCAProactively(amountPence, cardId);
error = if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."; status = 'error';
toast.error(error); error = CARD_VERIFICATION_RETRY_MESSAGE;
return; toast.error(error);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
} }
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} }
await submitBookingPayment( await submitBookingPayment(
@@ -624,14 +637,13 @@
overflowConfirm = null; overflowConfirm = null;
let msg = _err instanceof Error ? _err.message : 'Payment declined'; let msg = _err instanceof Error ? _err.message : 'Payment declined';
// A 402 with the structured verification-required signal (or the // A 402 with the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance; the legacy // dev/mock text parity) surfaces the SCA-first guidance. A plain
// saved-card verification check is the fallback for generic 402s. // decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
const bodyText = (_err as { bodyText?: string })?.bodyText ?? ''; const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText); const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
const verificationFailure =
scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, !!cardId);
if (verificationFailure) { 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 // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated // code, brute-force lockout) is recoverable — keep the code populated
@@ -649,6 +661,19 @@
newCardTokenAmount = 0; newCardTokenAmount = 0;
newCardTokenizedAt = 0; newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false; 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(); releaseLock();
} }
} }
@@ -792,6 +817,11 @@
open={true} open={true}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) return; 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 // ESC while the overflow-confirm prompt is showing must dismiss the
// prompt (back to the amount-editing form) instead of closing the whole // 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 // 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> <span>Slot no longer secured — please close and retry</span>
</div> </div>
{/if} {/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 --> <!-- Service Breakdown -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4"> <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> <div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
@@ -145,14 +145,7 @@
if (closingTime) { if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number); const [ch, cm] = closingTime.split(':').map(Number);
const today = new Date(); const today = new Date();
const closing = new Date( const closing = new Date(today.getFullYear(), today.getMonth(), today.getDate(), ch, cm, 0);
today.getFullYear(),
today.getMonth(),
today.getDate(),
ch,
cm,
0
);
const minutesToClosing = Math.max( const minutesToClosing = Math.max(
0, 0,
Math.floor((closing.getTime() - endTime.getTime()) / 60000) Math.floor((closing.getTime() - endTime.getTime()) / 60000)
@@ -187,8 +187,7 @@
const data = await response.json(); const data = await response.json();
const newApprovals = (data.approvals || []).sort( const newApprovals = (data.approvals || []).sort(
(a: PendingApproval, b: PendingApproval) => (a: PendingApproval, b: PendingApproval) =>
parseWallClockDate(a.created_at).getTime() - parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()
parseWallClockDate(b.created_at).getTime()
); );
const newJson = JSON.stringify(newApprovals); const newJson = JSON.stringify(newApprovals);
if (newJson !== prevApprovalsJson) { if (newJson !== prevApprovalsJson) {
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import { CalendarDate } from '@internationalized/date'; import { CalendarDate } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe'; import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay'; import { formatUserName } from '$lib/utils/nameDisplay';
@@ -117,7 +118,7 @@
const weekStartStr = $derived.by(() => { const weekStartStr = $derived.by(() => {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }); 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 day = d.getDay();
const diff = day === 0 ? 6 : day - 1; const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff); d.setDate(d.getDate() - diff);
@@ -125,7 +126,7 @@
}); });
const weekEndStr = $derived.by(() => { 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); d.setDate(d.getDate() + 6);
return formatDateISO(d); return formatDateISO(d);
}); });
@@ -4,6 +4,7 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { formatLocalDateTime } from '$lib/utils/timeSlots'; import { formatLocalDateTime } from '$lib/utils/timeSlots';
import { range } from '$lib/utils/format'; import { range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
type TodayAppointment = { type TodayAppointment = {
id: string; id: string;
@@ -54,7 +55,7 @@
let spansClosed = false; let spansClosed = false;
for (let i = 1; i <= 14; i++) { for (let i = 1; i <= 14; i++) {
const d = new Date(now); const d = new SvelteDate(now);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -71,7 +72,7 @@
if (day?.isOpen) { if (day?.isOpen) {
const closeTime = day.endTime; const closeTime = day.endTime;
const [h, m] = closeTime.split(':').map(Number); const [h, m] = closeTime.split(':').map(Number);
const closeDate = new Date(d); const closeDate = new SvelteDate(d);
closeDate.setHours(h, m, 0, 0); closeDate.setHours(h, m, 0, 0);
return { cutoff: formatLocalDateTime(closeDate), spansClosed }; return { cutoff: formatLocalDateTime(closeDate), spansClosed };
} else { } else {
@@ -80,7 +81,7 @@
} }
} }
const fallback = new Date(now); const fallback = new SvelteDate(now);
fallback.setDate(fallback.getDate() - 1); fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0); fallback.setHours(17, 0, 0, 0);
return { cutoff: formatLocalDateTime(fallback), spansClosed }; return { cutoff: formatLocalDateTime(fallback), spansClosed };
@@ -108,7 +108,7 @@
// media-query variant is a different twMerge group than the plain // media-query variant is a different twMerge group than the plain
// utility, so it survives consumer `max-h-*` overrides that would // utility, so it survives consumer `max-h-*` overrides that would
// otherwise wipe the keyboard-safe mobile height. // 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) stripZIndexClasses(className)
)} )}
{...restProps} {...restProps}
@@ -32,11 +32,6 @@ export function nextZIndex(): number {
return top; 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 * 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. * hot reloads / repeated test runs don't let the counter climb forever.
@@ -26,7 +26,7 @@
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( 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', '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', 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className 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" 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 <a
href={href} {href}
target="_blank" target="_blank"
rel="noopener noreferrer external" rel="noopener noreferrer external"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100" 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, isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
parseTokenizeVerificationResult,
requestNewTwoFactorCode, requestNewTwoFactorCode,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, shouldFallbackTo2FA,
@@ -240,20 +241,26 @@ describe('isVerificationRequiredSignal', () => {
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => { it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
expect( expect(
isVerificationRequiredSignal(402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification') isVerificationRequiredSignal(
402,
'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification'
)
).toBe(true); ).toBe(true);
}); });
it('matches the plain "verification required" phrasing', () => { it('matches the plain "verification required" phrasing', () => {
expect(isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')).toBe( expect(
true isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')
); ).toBe(true);
}); });
it('is false for a 402 body with a different code', () => { it('is false for a 402 body with a different code', () => {
expect(isVerificationRequiredSignal(402, JSON.stringify({ error: 'Declined', code: 'card_declined' }))).toBe( expect(
false 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', () => { 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', () => { it('is false for any non-402 status even with the code present', () => {
expect(isVerificationRequiredSignal(503, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe( expect(
false isVerificationRequiredSignal(
); 503,
expect(isVerificationRequiredSignal(400, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe( JSON.stringify({ error: 'x', code: 'verification_required' })
false )
); ).toBe(false);
expect(isVerificationRequiredSignal(200, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe( expect(
false 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)', () => { it('is false when the code is not an exact match (guards against prefix drift)', () => {
expect( expect(
isVerificationRequiredSignal(402, JSON.stringify({ error: 'x', code: 'verification_required_extra' })) isVerificationRequiredSignal(
402,
JSON.stringify({ error: 'x', code: 'verification_required_extra' })
)
).toBe(false); ).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', () => { describe('isTwoFactorVerificationGateFailure', () => {
it.each([ it.each([
[403, 'A two-factor verification code is required to use this saved card', true], [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 () => { it('surfaces the 429 mint-cooldown error message', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', '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'); const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
+73 -1
View File
@@ -180,12 +180,82 @@ export function shouldFallbackTo2FA(scaOutcome: string): boolean {
return scaOutcome === 'sca-unavailable'; 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 /** User-facing guidance for a saved-card charge whose issuer requires Strong
* Customer Authentication: the buyer must approve the payment in their banking * Customer Authentication: the buyer must approve the payment in their banking
* app (the client-side tokenizeSavedCardWithVerification challenge does this). */ * app (the client-side tokenizeSavedCardWithVerification challenge does this). */
export const VERIFICATION_REQUIRED_MESSAGE = export const VERIFICATION_REQUIRED_MESSAGE =
'Your card issuer requires verification. Approve this payment in your banking app.'; '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 /** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless the * verification to complete. Retrying the same saved card is pointless the
* buyer must pay with a new card or re-add their card. */ * buyer must pay with a new card or re-add their card. */
@@ -264,7 +334,9 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
* the CUSTOMER's userID, so the code is delivered to the customer and can * 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 * satisfy the card-owner gate the admin's session never receives or
* authenticates the customer's card. */ * 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`); return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
} }
+2 -1
View File
@@ -95,7 +95,8 @@ export interface Payment {
id: string; id: string;
booking_id: string; booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial'; 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; vendor_code?: string;
invoice_number?: number; invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded'; 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 { export function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString( const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
'en-GB', month: 'long'
{ month: 'long' } });
);
const day = date.day; const day = date.day;
if (day > 3 && day < 21) return monthName + ' ' + day + 'th'; if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
switch (day % 10) { switch (day % 10) {
+1 -1
View File
@@ -70,7 +70,7 @@
/> />
</svelte:head> </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 /> <NavBar />
<Toaster position={toasterPosition} /> <Toaster position={toasterPosition} />
+76 -32
View File
@@ -10,14 +10,13 @@
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte'; import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import { import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole, canSaveCardsForRole,
isNonceStale, isNonceStale,
isSavedCardVerificationRequired,
isSquareConfigured, isSquareConfigured,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
shouldFallbackTo2FA, shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square'; } from '$lib/square/square';
@@ -269,6 +268,12 @@
// from backup to the only available gate (scaAvailable → false); every other // from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry. // outcome keeps SCA primary for the next retry.
let buyLastSCAOutcome = $state(''); 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({ const buyTwoFactor = useTwoFactorCodeForSavedCard({
enabled: () => buyTwoFactorEnabled, enabled: () => buyTwoFactorEnabled,
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard), gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard),
@@ -446,6 +451,9 @@
buyTokenAmount = buyAmount * 100; buyTokenAmount = buyAmount * 100;
buyTokenizedAt = Date.now(); buyTokenizedAt = Date.now();
buyTokenizedForSaveCard = buySaveCard; buyTokenizedForSaveCard = buySaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
buyLastSCAOutcome = 'verified';
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed'); toast.error(err instanceof Error ? err.message : 'Card entry failed');
buyingGiftCard = false; buyingGiftCard = false;
@@ -488,14 +496,18 @@
// user taps Buy again to re-run it, and the cached idempotency // user taps Buy again to re-run it, and the cached idempotency
// key above is never regenerated across the challenge-then-charge. // key above is never regenerated across the challenge-then-charge.
if (cardId && !verificationToken) { if (cardId && !verificationToken) {
const proactive = await runBuySCAProactively(); buyWaitingForSCA = true;
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') { try {
toast.error( const proactive = await runBuySCAProactively();
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead." if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
); buyError = CARD_VERIFICATION_RETRY_MESSAGE;
return; toast.error(buyError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
buyWaitingForSCA = false;
} }
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} }
const res = await submitPaymentWithRetry(() => const res = await submitPaymentWithRetry(() =>
@@ -539,42 +551,41 @@
// can only be read once. // can only be read once.
const status = res.status; const status = res.status;
const errText = await res.text(); const errText = await res.text();
// Defensive fallback: a `verification_required` 402 on a // A 402 carrying the structured verification-required signal
// saved-card buy should no longer happen — the first attempt // (or the dev/mock text parity) surfaces the SCA-first
// carried a proactive verification_token or demoted to 2FA. If // guidance. A plain decline 402 shows the normal decline
// it still occurs (e.g. a stale token was consumed between // error — it must not be relabeled "requires verification".
// tokenize and charge), surface the guidance and let the user const verificationRequired = isVerificationRequiredSignal(status, errText);
// 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);
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated // code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code. // and reveal the input so the charge can be retried with a fresh code.
const buyErrMsg = verificationRequired const buyErrMsg = verificationRequired
? scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE
? VERIFICATION_REQUIRED_MESSAGE
: SAVED_CARD_VERIFICATION_MESSAGE
: extractErrorMessage(errText) || 'Failed to purchase gift card'; : extractErrorMessage(errText) || 'Failed to purchase gift card';
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) { if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
buyTwoFactor.reveal = true; buyTwoFactor.reveal = true;
} }
buyError = buyErrMsg;
toast.error(buyErrMsg); toast.error(buyErrMsg);
// A definitive charge failure (e.g. declined card) consumes the // A definitive charge failure (e.g. declined card) consumes the
// nonce + SCA verification token — clear the cached pair so the // nonce + SCA verification token — clear the cached pair so the
// next retry re-tokenizes fresh. The idempotency key stays for // next retry re-tokenizes fresh.
// network-timeout dedup.
buyNonce = ''; buyNonce = '';
buyVerificationToken = ''; buyVerificationToken = '';
buyTokenAmount = 0; buyTokenAmount = 0;
buyTokenizedAt = 0; buyTokenizedAt = 0;
buyTokenizedForSaveCard = false; 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) { } catch (err) {
console.error('buyGiftCard error:', err); console.error('buyGiftCard error:', err);
@@ -611,7 +622,9 @@
outcome: SavedCardVerificationOutcome; outcome: SavedCardVerificationOutcome;
verificationToken?: string; 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) { if (!squareCardId) {
buyLastSCAOutcome = 'sca-unavailable'; buyLastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' }; return { outcome: 'sca-unavailable' };
@@ -2564,7 +2577,7 @@
> >
{formatCardCode(purchaseResultCode)} {formatCardCode(purchaseResultCode)}
</div> </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. ⚠️ Please save this code and send it to your friend — no email was sent.
</p> </p>
{/if} {/if}
@@ -2681,6 +2694,37 @@
{/if} {/if}
</div> </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 <Button
onclick={buyGiftCard} onclick={buyGiftCard}
disabled={buyingGiftCard || disabled={buyingGiftCard ||
@@ -3299,7 +3343,7 @@
<!-- Password Change Modal --> <!-- Password Change Modal -->
{#if showPasswordModal} {#if showPasswordModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <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.Header>
<Card.Title>Change Password</Card.Title> <Card.Title>Change Password</Card.Title>
<Card.Description>Enter your current and new password</Card.Description> <Card.Description>Enter your current and new password</Card.Description>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { parseWallClockDate } from '$lib/utils/timeSlots'; import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
@@ -289,7 +290,7 @@
const d = parseWallClockDate(iso); const d = parseWallClockDate(iso);
const now = new Date(); const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 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); tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()); const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000); const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
@@ -9,6 +9,7 @@
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { formatDuration } from '$lib/utils/format'; import { formatDuration } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
import { formatUserName } from '$lib/utils/nameDisplay'; import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots'; import { parseWallClockDate } from '$lib/utils/timeSlots';
import BookingModal from '$lib/components/admin/BookingModal.svelte'; import BookingModal from '$lib/components/admin/BookingModal.svelte';
@@ -83,7 +84,7 @@
const today = getLondonToday(); const today = getLondonToday();
const dayOfWeek = today.getDay(); const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1; const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today); const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff); monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0); monday.setHours(0, 0, 0, 0);
weekStart = monday; weekStart = monday;
@@ -93,7 +94,7 @@
// -- Helpers -- // -- Helpers --
function getWeekDays(start: Date): Date[] { function getWeekDays(start: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => { return Array.from({ length: 7 }, (_, i) => {
const d = new Date(start); const d = new SvelteDate(start);
d.setDate(d.getDate() + i); d.setDate(d.getDate() + i);
return d; return d;
}); });
@@ -133,7 +134,7 @@
} }
function formatWeekLabel(start: Date): string { function formatWeekLabel(start: Date): string {
const end = new Date(start); const end = new SvelteDate(start);
end.setDate(end.getDate() + 6); end.setDate(end.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }; const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
@@ -226,7 +227,7 @@
if (!initialized) loading = true; if (!initialized) loading = true;
try { try {
const startStr = formatDate(weekStart); const startStr = formatDate(weekStart);
const end = new Date(weekStart); const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6); end.setDate(end.getDate() + 6);
const endStr = formatDate(end); const endStr = formatDate(end);
@@ -312,7 +313,7 @@
function navigateWeek(delta: number) { function navigateWeek(delta: number) {
if (!weekStart) return; if (!weekStart) return;
const d = new Date(weekStart); const d = new SvelteDate(weekStart);
d.setDate(d.getDate() + delta * 7); d.setDate(d.getDate() + delta * 7);
weekStart = d; weekStart = d;
} }
@@ -321,7 +322,7 @@
const today = getLondonToday(); const today = getLondonToday();
const dayOfWeek = today.getDay(); const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1; const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today); const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff); monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0); monday.setHours(0, 0, 0, 0);
weekStart = monday; weekStart = monday;
@@ -420,7 +421,7 @@
const isCurrentWeek = $derived( const isCurrentWeek = $derived(
weekStart !== undefined && weekStart !== undefined &&
(() => { (() => {
const end = new Date(weekStart); const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6); end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999); end.setHours(23, 59, 59, 999);
return today >= weekStart && today <= end; return today >= weekStart && today <= end;
@@ -67,9 +67,9 @@
<strong>36 hours in advance</strong>. <strong>36 hours in advance</strong>.
</p> </p>
<p class="mb-3"> <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. Payments toward your booking are capped at 100% of the total booking value based on service
Once you have paid the full amount, any additional payments above 100% of the booking value will be processed as tips (see prices at time of booking. Once you have paid the full amount, any additional payments above
Section 8 below). 100% of the booking value will be processed as tips (see Section 8 below).
</p> </p>
<p class="mb-3"> <p class="mb-3">
To maintain fairness and prevent scheduling abuse, accounts with outstanding deposit 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> <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> <h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund before service</h3>
<p class="mb-3"> <p class="mb-3">
We understand that plans can change. Eligibility for a refund before your service depends strictly on the amount We understand that plans can change. Eligibility for a refund before your service depends
of notice provided prior to your scheduled appointment time. These thresholds represent a strictly on the amount of notice provided prior to your scheduled appointment time. These
genuine pre-estimate of the operational costs and loss of business incurred by late thresholds represent a genuine pre-estimate of the operational costs and loss of business
cancellations. Refunds apply to <strong>booking payments only</strong>, up to 100% of the total incurred by late cancellations. Refunds apply to <strong>booking payments only</strong>, up
booking value. to 100% of the total booking value.
</p> </p>
<div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200"> <div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200">
<div class="bg-gray-50/50 p-4"> <div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of more than 72 hours</p> <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"> <div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p> <p class="font-semibold text-gray-900">Notice of less than 24 hours</p>
<p class="mt-1 text-xs text-gray-600"> <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 All booking payments and deposits are entirely non-refundable and will be retained. The
appointment history strike. cancellation will be logged as a missed appointment history strike.
</p> </p>
</div> </div>
</div> </div>
@@ -144,12 +143,14 @@
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund after service</h3> <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"> <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
href={resolve('/contact')} based on the booking and reason, to arrange a refund please <a
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. 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.
</p> </p>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund Payment Method</h3> <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"> <p class="mb-3 text-sm leading-relaxed text-gray-700">
Refunds are returned to the original payment method where possible: Refunds are returned to the original payment method where possible:
@@ -161,9 +162,9 @@
days). days).
</li> </li>
<li> <li>
<strong>Gift card payments</strong>: Refunded back to the original gift card (or, if <strong>Gift card payments</strong>: Refunded back to the original gift card (or, if you
you paid from your account balance, back to that balance). The gift card's remaining paid from your account balance, back to that balance). The gift card's remaining balance
balance is incremented and is immediately available for use. Expired gift cards are is incremented and is immediately available for use. Expired gift cards are
non-refundable. non-refundable.
</li> </li>
<li> <li>
@@ -247,31 +248,30 @@
Regulations 2013 does not apply to online bookings scheduled for a specific date or time. Regulations 2013 does not apply to online bookings scheduled for a specific date or time.
</p> </p>
<p class="mb-3"> <p class="mb-3">
That exclusion does not apply to gift cards: online gift-card purchases may be That exclusion does not apply to gift cards: online gift-card purchases may be cancelled
cancelled within 14 days for a refund to the original payment method under the within 14 days for a refund to the original payment method under the Consumer Contracts
Consumer Contracts Regulations 2013. If the card has been partly used, the amount Regulations 2013. If the card has been partly used, the amount already spent on salon
already spent on salon services is not refundable, and the remaining unspent balance services is not refundable, and the remaining unspent balance is refunded to the original
is refunded to the original payment method; the card is then cancelled. A card that payment method; the card is then cancelled. A card that has been redeemed to an account
has been redeemed to an account balance or fully spent cannot be cancelled. balance or fully spent cannot be cancelled.
</p> </p>
<p class="mb-3"> <p class="mb-3">
Where a partly-used card is cancelled, the card is cancelled automatically when the Where a partly-used card is cancelled, the card is cancelled automatically when the refund
refund is issued, so the remaining balance cannot then be spent. See our Gift Card is issued, so the remaining balance cannot then be spent. See our Gift Card Terms for the
Terms for the full position. full position.
</p> </p>
<p class="mb-3"> <p class="mb-3">
If you believe your statutory consumer rights have not been met, you can get free, If you believe your statutory consumer rights have not been met, you can get free, impartial
impartial advice from advice from
<a <a
href="https://consumeradvice.scot" href="https://consumeradvice.scot"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800" class="font-medium text-blue-600 underline hover:text-blue-800">consumeradvice.scot</a
>consumeradvice.scot</a
> >
(advice.scot). If that does not resolve the issue, you can escalate your complaint to your (advice.scot). If that does not resolve the issue, you can escalate your complaint to your local
local Trading Standards office. Claims up to &pound;5,000 can also be pursued through the Trading Standards office. Claims up to &pound;5,000 can also be pursued through the Scottish courts'
Scottish courts' Simple Procedure. Simple Procedure.
</p> </p>
<p class="mb-3"> <p class="mb-3">
We recognize that genuine emergencies, sudden severe illness, or bereavement can occur. Our We recognize that genuine emergencies, sudden severe illness, or bereavement can occur. Our
@@ -328,13 +328,14 @@
</p> </p>
<p class="mb-3"> <p class="mb-3">
Tips can be added via your account after the booking has started, or at the time of payment 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 when paying in person at the salon. When paying by card at the terminal, you will be
to add a tip if you wish. prompted to add a tip if you wish.
</p> </p>
<p class="mb-3 text-xs text-gray-500 italic"> <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 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" <a
>Contact Channels</a href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">Contact Channels</a
> and we will review your case. > and we will review your case.
</p> </p>
</section> </section>
+4 -6
View File
@@ -83,11 +83,7 @@
const dob = new Date(dateStr); const dob = new Date(dateStr);
const today = new Date(); const today = new Date();
const sixteenYearsAgo = new Date( const sixteenYearsAgo = new Date(today.getFullYear() - 16, today.getMonth(), today.getDate());
today.getFullYear() - 16,
today.getMonth(),
today.getDate()
);
const isValid = dob <= sixteenYearsAgo; const isValid = dob <= sixteenYearsAgo;
validationErrors.dateOfBirth = isValid validationErrors.dateOfBirth = isValid
@@ -509,7 +505,9 @@
bind:value={formData.dateOfBirth} bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)} onblur={() => validateAge(formData.dateOfBirth)}
max={new Date( 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' })} ).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
required required
/> />
@@ -128,7 +128,7 @@
<title>Leave a Tip - Crussell</title> <title>Leave a Tip - Crussell</title>
</svelte:head> </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'} {#if loading || pageState === 'loading'}
<div class="space-y-6"> <div class="space-y-6">
<div class="text-center"> <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> <h2 class="mb-3 text-base font-semibold text-gray-900">1. Introduction</h2>
<p class="mb-3"> <p class="mb-3">
This Privacy Policy explains how Crussell Salon (&ldquo;we&rdquo;, &ldquo;us&rdquo;, 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 &ldquo;our&rdquo;) collects, uses, and protects your personal data when you use our booking
booking platform (&ldquo;Platform&rdquo;). platform (&ldquo;Platform&rdquo;).
</p> </p>
<p class="mb-3"> <p class="mb-3">
We are committed to protecting your privacy and complying with the We are committed to protecting your privacy and complying with the
@@ -76,7 +76,7 @@
<p class="mt-1">Crussell Salon</p> <p class="mt-1">Crussell Salon</p>
<p>Edinburgh, Scotland</p> <p>Edinburgh, Scotland</p>
<!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. --> <!-- 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> </div>
</section> </section>
@@ -84,7 +84,9 @@
<section> <section>
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Data We Collect</h2> <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> <p class="mb-2 font-medium text-gray-800">Account Information:</p>
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<li>Name (first, last)</li> <li>Name (first, last)</li>
@@ -97,8 +99,8 @@
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<li>Appointment dates, times, services</li> <li>Appointment dates, times, services</li>
<li> <li>
Treatment notes and preferences (one health-and-safety record &mdash; includes Treatment notes and preferences (one health-and-safety record &mdash; includes allergies,
allergies, skin sensitivities, and access needs) skin sensitivities, and access needs)
</li> </li>
<li>Allergy and patch test records (health data &mdash; special category)</li> <li>Allergy and patch test records (health data &mdash; special category)</li>
<li>Payment history and transaction records</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 GDPR; we record it so we can treat you safely and make reasonable adjustments (Equality Act
2010). 2010).
</p> </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"> <p class="mb-4">
On account deletion the rest of your record is erased or anonymized, and your notes are 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 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>Gift card codes and balances</li>
<li>Account balances</li> <li>Account balances</li>
<li>Payment transaction records (processed via Square, not stored by us)</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> <li>Dormant balance records (Account ID only, no PII)</li>
</ul> </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"> <p class="mb-3">
When you choose to <strong>save a card for next time</strong>, we store a tokenised 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 reference to your card with our payment processor, <strong>Square</strong> (a data processor),
processor), rather than on our own systems. rather than on our own systems.
</p> </p>
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<li> <li>
<strong>What Square stores:</strong> a tokenised reference to your card (never your <strong>What Square stores:</strong> a tokenised reference to your card (never your full card
full card number or CVV), plus the name and email address we already hold on your number or CVV), plus the name and email address we already hold on your account, grouped into
account, grouped into a Square customer profile. a Square customer profile.
</li> </li>
<li> <li>
<strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) &mdash; necessary for the <strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) &mdash; necessary for the performance
performance of the contract (you asked to save your card for future payments). of the contract (you asked to save your card for future payments).
</li> </li>
<li> <li>
<strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases <strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases without
without re-entering your card details. re-entering your card details.
</li> </li>
<li> <li>
<strong>One-off payments:</strong> if you do not tick &ldquo;save this card&rdquo;, <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 <strong>no card is stored and no Square customer profile is created</strong> for you &mdash;
&mdash; your card is used only for that single payment. your card is used only for that single payment.
</li> </li>
<li> <li>
<strong>Retention &amp; removal:</strong> the reference remains stored until you delete <strong>Retention &amp; removal:</strong> the reference remains stored until you delete the
the card from your account (Account &rarr; Saved Cards) or your account is deleted. You card from your account (Account &rarr; Saved Cards) or your account is deleted. You can remove
can remove a saved card at any time. a saved card at any time.
</li> </li>
<li> <li>
<strong>Square&rsquo;s privacy policy:</strong> <strong>Square&rsquo;s privacy policy:</strong>
@@ -167,19 +176,22 @@
href="https://squareup.com/gb/en/legal/privacy-no-account" href="https://squareup.com/gb/en/legal/privacy-no-account"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800" class="font-medium text-blue-600 underline hover:text-blue-800">Square Privacy Policy</a
>Square Privacy Policy</a
> >
applies to data Square holds on our behalf. applies to data Square holds on our behalf.
</li> </li>
</ul> </ul>
<p class="mb-4"> <p class="mb-4">
We never store full card numbers, card security codes (CVV), or card expiry data on our We never store full card numbers, card security codes (CVV), or card expiry data on our own
own systems at any point. systems at any point.
</p> </p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Special Category Data (Health Data)</h3> <h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
<p class="mb-3">We collect health-related information with your <strong>explicit consent</strong>:</p> 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"> <ul class="mb-3 list-disc space-y-1 pl-5">
<li>Allergy records</li> <li>Allergy records</li>
<li>Patch test results</li> <li>Patch test results</li>
@@ -188,15 +200,17 @@
</ul> </ul>
<p class="mb-3"> <p class="mb-3">
<strong>Legal basis:</strong> UK GDPR Article 9(2)(a) &mdash; Explicit consent<br /> <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 <strong>Retention:</strong> 7 years (insurance requirement); patch-test records are kept unlinked
unlinked to you if your account is deleted, and allergy/access information held in your to you if your account is deleted, and allergy/access information held in your treatment notes
treatment notes is retained de-identified (see &sect;3.1). is retained de-identified (see &sect;3.1).
</p> </p>
</section> </section>
<!-- Section 3 --> <!-- Section 3 -->
<section> <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> <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"> <div class="overflow-x-auto rounded-md border border-gray-200">
@@ -242,11 +256,13 @@
<td class="px-3 py-2">Insurance requirement</td> <td class="px-3 py-2">Insurance requirement</td>
</tr> </tr>
<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"> <td class="px-3 py-2">
Retained after account deletion in de-identified form while they may be needed for 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 safety adjustments or legal-claims defence; the rest of the account record is erased
erased at deletion at deletion
</td> </td>
<td class="px-3 py-2">Legitimate interest (safety &amp; legal-claims defence)</td> <td class="px-3 py-2">Legitimate interest (safety &amp; legal-claims defence)</td>
</tr> </tr>
@@ -289,10 +305,9 @@
</li> </li>
</ol> </ol>
<p class="mb-3"> <p class="mb-3">
<strong>Saved cards:</strong> Deleting your account also removes your saved-card <strong>Saved cards:</strong> Deleting your account also removes your saved-card references from
references from our system and disables the corresponding card tokens at Square (see our system and disables the corresponding card tokens at Square (see &sect;2.2). Card transaction
&sect;2.2). Card transaction records for payments already made are retained per the HMRC records for payments already made are retained per the HMRC schedule above.
schedule above.
</p> </p>
<p class="mb-2 font-medium text-gray-800">Inactive account deletion (automatic):</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"> <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"> <ul class="mb-4 list-disc space-y-1 pl-5">
<li><strong>Access</strong> your personal data (Article 15)</li> <li><strong>Access</strong> your personal data (Article 15)</li>
<li><strong>Rectify</strong> inaccurate data (Article 16)</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>Restrict</strong> processing (Article 18)</li>
<li><strong>Data Portability</strong> (Article 20)</li> <li><strong>Data Portability</strong> (Article 20)</li>
<li><strong>Object</strong> to processing (Article 21)</li> <li><strong>Object</strong> to processing (Article 21)</li>
<li><strong>Withdraw Consent</strong> (Article 7(3))</li> <li><strong>Withdraw Consent</strong> (Article 7(3))</li>
</ul> </ul>
<p class="mb-4"> <p class="mb-4">
To exercise these rights, contact {"{{SUPPORT_EMAIL}}"}. You also have the right to To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
complain to the Information Commissioner&rsquo;s Office (ICO) at any time. to the Information Commissioner&rsquo;s Office (ICO) at any time.
</p> </p>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Questions about how we handle your data? Please use our official 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> <title>Leave a Tip - Crussell</title>
</svelte:head> </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} {#if loading}
<div class="space-y-6"> <div class="space-y-6">
<div class="text-center"> <div class="text-center">
+1 -5
View File
@@ -189,11 +189,7 @@
</div> </div>
<!-- Modals --> <!-- Modals -->
<BookingModal <BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} {openUserModal} />
bind:open={showBookingModal}
bookingId={selectedBookingId ?? ''}
{openUserModal}
/>
<EditBookingModal <EditBookingModal
bind:open={showEditBookingModal} bind:open={showEditBookingModal}
+9
View File
@@ -1068,6 +1068,15 @@ BEGIN
-- Anonymize admin notification references (not customer data — just drop the user link) -- Anonymize admin notification references (not customer data — just drop the user link)
UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id; 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) -- Clear login audit trail (no ongoing legal basis after account closure)
DELETE FROM login_audit WHERE user_id = target_id; 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 ```bash
cd backend 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 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. **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. 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). 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. 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** (`twoFactorEnforced`, `handlers/payments/twofa.go`):
- Enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT`, including empty and unknown values, which are treated as production-enforced. It is disabled only when `REQUIRE_2FA` is an explicit disable value (`false`/`0`/`off`/`no`, case-insensitive) **or** `SQUARE_ENVIRONMENT` is an explicit dev/mock value (`mock`, `dev`, `development`, `test`). - Enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT`, including empty and unknown values, which are treated as production-enforced. It is disabled only when `REQUIRE_2FA` is an explicit disable value (`false`/`0`/`off`/`no`, case-insensitive) **or** `SQUARE_ENVIRONMENT` is an explicit dev/mock value (`mock`, `dev`, `development`, `test`).
- A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing. - A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
- **Residual brute-force exposure (accepted):** a fresh-code delivery (setup, or a disable that mints because no pending code exists) resets the shared 5-attempt counter. An authenticated attacker who already holds the victim's password can therefore loop `disable` with wrong codes to obtain an unlimited series of fresh codes, each granting 5 guesses — the 2FA gate then reduces to a 6-digit guessing game bounded only by the per-IP rate limit (120 req/min on `/api/user`) and the 10-minute code TTL. This is the same reset-on-delivery tradeoff that makes codes deliverable to locked-out users; it is documented rather than fixed because a hard per-user lockout would strand a legitimate user who lost their code, with no email/SMS transport to recover (P6). Revisit when real delivery lands. (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. **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):** **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). - `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 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. - 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. **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 ### 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 | | 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: 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 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. 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)]]). - **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. - **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 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. 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. 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 ### 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'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. 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 ### 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. 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 ### 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. 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 ### 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. 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") ### 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. 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. 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. 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 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 ### 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** 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 **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. - 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.** 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.**