Compare commits
218
Commits
9e85bc766b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8e521f4e7 | ||
|
|
5051368d9b | ||
|
|
509b2d926d | ||
|
|
4d179385e8 | ||
|
|
a358c8c0ea | ||
|
|
ee49532774 | ||
|
|
8accc2ba6f | ||
|
|
c38dee1f62 | ||
|
|
d90e25d1ff | ||
|
|
d03ce79c19 | ||
|
|
37f6723d9e | ||
|
|
348a2e5bfc | ||
|
|
e5c151f994 | ||
|
|
35572e1d70 | ||
|
|
c62ed029b3 | ||
|
|
bb51c1512f | ||
|
|
80c5c6ce43 | ||
|
|
efea213ccd | ||
|
|
e46de8e9e5 | ||
|
|
73f0258b97 | ||
|
|
4146f8e09a | ||
|
|
9a12a2d886 | ||
|
|
fba00a10ad | ||
|
|
93be93f86c | ||
|
|
e3fa8a22b7 | ||
|
|
049c361e16 | ||
|
|
e9b34d0ad6 | ||
|
|
62dca184df | ||
|
|
1d6d3e2f8d | ||
|
|
985b114c8b | ||
|
|
77317e4a45 | ||
|
|
01b20b4420 | ||
|
|
95d2ee3ccf | ||
|
|
6bd952238e | ||
|
|
01e7cc575d | ||
|
|
f9e8385d5a | ||
|
|
1429eddd34 | ||
|
|
1d9c87d6d6 | ||
|
|
af685df40c | ||
|
|
d0d72d8caf | ||
|
|
8d9f72b9f0 | ||
|
|
8dcfe52ac8 | ||
|
|
8ba76aa958 | ||
|
|
0fdb2f02cd | ||
|
|
9a75ebc794 | ||
|
|
69a854d857 | ||
|
|
16304bd295 | ||
|
|
42130865f4 | ||
|
|
2a47021673 | ||
|
|
d25ba16aa7 | ||
|
|
2cdbad0cea | ||
|
|
1179293777 | ||
|
|
3130b39c77 | ||
|
|
4e398a7a2b | ||
|
|
3866cc5963 | ||
|
|
4e64e32f09 | ||
|
|
dfe856b181 | ||
|
|
36887167c6 | ||
|
|
b7122be3a0 | ||
|
|
c4c65d9dd8 | ||
|
|
5dae0bba08 | ||
|
|
ecef5da516 | ||
|
|
1543160f6a | ||
|
|
9a182db932 | ||
|
|
b46927336b | ||
|
|
7c424b28b8 | ||
|
|
03d85c6d13 | ||
|
|
6d00c3004f | ||
|
|
a6a4683b74 | ||
|
|
a8bf24ee23 | ||
|
|
4d5d2cd381 | ||
|
|
fe88f2084d | ||
|
|
faceb9809c | ||
|
|
6d82535780 | ||
|
|
78e6d00dc5 | ||
|
|
9111258461 | ||
|
|
6691cd5657 | ||
|
|
3894f53778 | ||
|
|
5cc5a7f6d2 | ||
|
|
67cf5b9a45 | ||
|
|
39cc42b239 | ||
|
|
fdf3f64a13 | ||
|
|
9bb812669e | ||
|
|
e9b0f0f2a7 | ||
|
|
4b28e93710 | ||
|
|
5e3dc9b428 | ||
|
|
7df983052b | ||
|
|
ea771ffaf5 | ||
|
|
c7d7169cd2 | ||
|
|
05bb142cfd | ||
|
|
d9c2c5ac2c | ||
|
|
3f6250ea81 | ||
|
|
2b4c50b4b0 | ||
|
|
858ae87e9c | ||
|
|
bb843fe7dd | ||
|
|
39d914417f | ||
|
|
01ac211408 | ||
|
|
197d4c4b9b | ||
|
|
7f1c649f1e | ||
|
|
63226debb7 | ||
|
|
a8d54f1e2a | ||
|
|
726ac8cb65 | ||
|
|
5fea301e92 | ||
|
|
965da86b64 | ||
|
|
5605402e13 | ||
|
|
8a3a7ec062 | ||
|
|
352f9e50d4 | ||
|
|
8416f033d6 | ||
|
|
cec7167469 | ||
|
|
439fc16402 | ||
|
|
8720e28dbe | ||
|
|
5ea89da2ad | ||
|
|
c9d55817f6 | ||
|
|
f3d50f6990 | ||
|
|
2a78383a2d | ||
|
|
57bdeb9232 | ||
|
|
deb630991e | ||
|
|
e8abf7b4b3 | ||
|
|
8ff77bc39b | ||
|
|
f0099714ff | ||
|
|
457f7a452e | ||
|
|
91fe5ea399 | ||
|
|
e5c6458ec7 | ||
|
|
738f6b6a51 | ||
|
|
0d22f8d597 | ||
|
|
2652aa66be | ||
|
|
515b828550 | ||
|
|
b6f07fe6e8 | ||
|
|
e02567564e | ||
|
|
12af3af3b3 | ||
|
|
54a5b1024e | ||
|
|
fb21538532 | ||
|
|
52e2bfff55 | ||
|
|
7439fa86c1 | ||
|
|
dcc70df75a | ||
|
|
9ff591fa4e | ||
|
|
16240d67e3 | ||
|
|
53ca89603d | ||
|
|
64d4b65083 | ||
|
|
1cdefb1834 | ||
|
|
ae8735ba2f | ||
|
|
54f6bf3c1a | ||
|
|
d54f526b56 | ||
|
|
4f5dd5c426 | ||
|
|
3db8b54923 | ||
|
|
bbb55dae82 | ||
|
|
73dd2c2dea | ||
|
|
5a373a3b0b | ||
|
|
28f0ddb328 | ||
|
|
a4441b6acf | ||
|
|
2459ddc919 | ||
|
|
4abcb324c9 | ||
|
|
f6caaab8a3 | ||
|
|
51d29e6bd4 | ||
|
|
9d76b3a87b | ||
|
|
1f3d834f0c | ||
|
|
df4c9afeb9 | ||
|
|
aad364bbd1 | ||
|
|
3c0c4dd962 | ||
|
|
8384197ef0 | ||
|
|
1fb84b8998 | ||
|
|
991f3af540 | ||
|
|
c80ad467e7 | ||
|
|
503449b0bc | ||
|
|
9eecb375cf | ||
|
|
a991642157 | ||
|
|
aa31b19f1c | ||
|
|
0323313921 | ||
|
|
6470a3f6c9 | ||
|
|
a6cb803ff2 | ||
|
|
70613497b4 | ||
|
|
d0f233ae70 | ||
|
|
2a840bb7e6 | ||
|
|
8f99b8b5c3 | ||
|
|
52caf1b7b5 | ||
|
|
bc37283009 | ||
|
|
3eec71a56c | ||
|
|
388ac5948a | ||
|
|
5deefa1205 | ||
|
|
9170990340 | ||
|
|
6567ff4904 | ||
|
|
5ed24f263a | ||
|
|
291868f11a | ||
|
|
e27db9202e | ||
|
|
b5f215aaa4 | ||
|
|
04d5c21f8c | ||
|
|
bea1582088 | ||
|
|
117c37eaff | ||
|
|
24af3a1fca | ||
|
|
6777fb9b97 | ||
|
|
bbbffce20d | ||
|
|
35bc021857 | ||
|
|
5d9fa1178b | ||
|
|
0bef0f7973 | ||
|
|
0edc111bfe | ||
|
|
8ae592d0c5 | ||
|
|
b864873310 | ||
|
|
11fbf869e1 | ||
|
|
0afa642a10 | ||
|
|
7d6cb0c375 | ||
|
|
d6a3e30503 | ||
|
|
c8051a76d6 | ||
|
|
0c1fc2b819 | ||
|
|
05c5d73d30 | ||
|
|
ce0bb43ebf | ||
|
|
e4a56ab04d | ||
|
|
8b911d788c | ||
|
|
b3729c8a5a | ||
|
|
a3243fab27 | ||
|
|
4d87bf791f | ||
|
|
f147be0b99 | ||
|
|
ad15127777 | ||
|
|
df325487cf | ||
|
|
ac104e19c5 | ||
|
|
9b235a1298 | ||
|
|
d410dce0e0 | ||
|
|
9f9f0eed98 | ||
|
|
7a51bcc49a |
+121
-7
@@ -10,34 +10,111 @@ POSTGRES_DB=mydb
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
JWT_SECRET_KEY="a-very-secret-key-that-should-be-in-env"
|
||||
# JWT_SECRET_KEY — REQUIRED, FAIL-CLOSED. The backend refuses to start with an
|
||||
# empty, weak (<32 chars), or known-placeholder value, because a shared/public
|
||||
# signing key lets anyone forge an admin JWT. Generate a strong random key:
|
||||
# openssl rand -hex 32
|
||||
JWT_SECRET_KEY=
|
||||
|
||||
# S3/R2 Configuration (for image storage)
|
||||
# Dev: Uses local Rustfs container (see compose.yml)
|
||||
# Prod: Use Cloudflare R2 credentials
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_PUBLIC_URL=http://192.168.1.135:9000
|
||||
# Public URL the BROWSER fetches images from. This is HOST-SPECIFIC: it must be
|
||||
# the machine's current LAN IP (DHCP changes it). Run `hostname -I` to check.
|
||||
# A stale IP makes images fail to load even though the objects exist in Rustfs.
|
||||
S3_PUBLIC_URL=http://192.168.0.45:9000
|
||||
S3_ACCESS_KEY=rustfsadmin
|
||||
S3_SECRET_KEY=rustfsadmin
|
||||
S3_BUCKET=crussell
|
||||
S3_PROFILE_PICS_BUCKET=crussell-profile-pics
|
||||
AWS_REGION=eu-west-2
|
||||
|
||||
# Set DAV_SKIP_INIT=1 to skip CardDAV server initialization (e.g., in CI/test environments).
|
||||
DAV_SKIP_INIT=1
|
||||
|
||||
# Prod only: Cloudflare R2 (overrides S3_* vars in non-dev builds).
|
||||
# Local dev uses the S3_* vars above (from .env). Not needed for local builds.
|
||||
R2_ENDPOINT=
|
||||
# R2_ACCESS_KEY=<your-access-key>
|
||||
# R2_SECRET_KEY=<your-secret-key>
|
||||
# R2_BUCKET=crussell
|
||||
# R2_PUBLIC_URL=https://pub-<your-domain>.r2.dev
|
||||
# Required for production object storage — the prod S3 client (backend/internal/s3/s3.go,
|
||||
# via getEnv) reads all four below; not needed for local dev builds.
|
||||
R2_ACCESS_KEY=
|
||||
R2_SECRET_KEY=
|
||||
R2_BUCKET=crussell
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# Square Payment Gateway
|
||||
SQUARE_ACCESS_TOKEN=
|
||||
SQUARE_LOCATION_ID=
|
||||
SQUARE_TERMINAL_DEVICE_ID=
|
||||
SQUARE_ENVIRONMENT=mock
|
||||
# SQUARE_ALLOW_REAL_API — dev-build safety valve. In a `//go:build dev` build the
|
||||
# backend HARD-FAILS (refuses to construct the client) when SQUARE_ENVIRONMENT
|
||||
# is 'production', because a leftover/typo'd production env + real key in a dev
|
||||
# shell would create real charges. Set SQUARE_ALLOW_REAL_API=1 ONLY to
|
||||
# deliberately route a dev build to the real production API. Never set in a
|
||||
# deployed production build.
|
||||
SQUARE_ALLOW_REAL_API=
|
||||
# 2FA — homegrown two-factor authentication for ADMIN/ACCOUNT VERIFICATION ONLY
|
||||
# (setup, disable, delete-account re-auth). It is NEVER a card-charge
|
||||
# authorisation. Saved-card online payments are authorised EXCLUSIVELY by
|
||||
# Square PSD2 SCA (3-D Secure / buyer verification), wired for both new-card
|
||||
# and saved-card charges. PSR 2017 reg 100 makes SCA mandatory and non-waivable
|
||||
# for customer-initiated stored-credential charges, so a saved-card charge
|
||||
# carrying no Square verification token is REFUSED 402 verification_required
|
||||
# (the payment does not go through; the customer can try again later, or at
|
||||
# the till they can be invited to pay online later instead) — the homegrown
|
||||
# 2FA fallback was removed entirely and no TWO_FACTOR_FALLBACK switch exists.
|
||||
# Enforcement is FAIL-CLOSED:
|
||||
# ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive)
|
||||
# OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test.
|
||||
# Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced
|
||||
# (a mistyped env var can never silently disarm the gate; the backend logs a
|
||||
# startup warning in that case). Set REQUIRE_2FA=false only in controlled
|
||||
# environments. The dev mock simulates SCA
|
||||
# (SimulateSavedCardVerificationRequired + cnon:sca-... tokenize-results), so
|
||||
# development has full parity with the SCA-only production posture.
|
||||
# Code delivery: the intended channel is email/SMS (the method chosen at
|
||||
# setup) — NOT wired yet (P6). Until it lands, verification/2FA codes are
|
||||
# delivered to the LOCAL DEV stdout log (a [2FA]-prefixed line) in dev/test
|
||||
# builds ONLY — stdout-log delivery is a dev-only convenience, never a
|
||||
# production channel. Production builds have no delivery channel and code
|
||||
# issuance FAILS CLOSED (503) until email/SMS is implemented; the API never
|
||||
# returns the code while enforcement is ON.
|
||||
REQUIRE_2FA=true
|
||||
# TWO_FACTOR_PEPPER — server-side pepper for HMAC-hashing 2FA codes. REQUIRED
|
||||
# in production builds: code issuance FAILS CLOSED when it is unset (an
|
||||
# unsalted SHA-256 digest in the 1M code space would be offline-brute-forceable
|
||||
# from a log/DB leak), mirroring JWT_SECRET_KEY's fail-fast stance. Optional
|
||||
# only in dev/test builds, where an unset pepper falls back to the legacy
|
||||
# digest with a one-time warning. Generate with:
|
||||
# openssl rand -base64 32
|
||||
TWO_FACTOR_PEPPER=
|
||||
# SNAPSHOT_ENC_KEY — base64-encoded 32-byte AES-256 key for encrypting stored
|
||||
# square_request_snapshot rows (buyer PII: email + ccof card tokens) at rest in
|
||||
# non-mock (production/sandbox) deployments. If unset/invalid, snapshots fall
|
||||
# back to PLAINTEXT with a one-time CRITICAL log warning (money-safety first:
|
||||
# the replayable snapshot must not be lost). Generate with:
|
||||
# openssl rand -base64 32
|
||||
SNAPSHOT_ENC_KEY=
|
||||
# Webhook config MUST exactly match the Square Dashboard webhook subscription
|
||||
# (URL + signature key). If SQUARE_WEBHOOK_NOTIFICATION_URL is left unset it
|
||||
# defaults to http://localhost:8080/webhooks/square, which is fail-closed (503
|
||||
# without the signing key, 403 on missing/bad signature). Leave both empty if
|
||||
# you do not use webhooks.
|
||||
SQUARE_WEBHOOK_SIGNATURE_KEY=
|
||||
SQUARE_WEBHOOK_NOTIFICATION_URL=
|
||||
|
||||
# Frontend (public — safe for the browser). Square Web Payments SDK:
|
||||
# VITE_SQUARE_APPLICATION_ID — client-side application ID (sandbox IDs start with "sandbox-")
|
||||
# VITE_SQUARE_LOCATION_ID — Square location ID
|
||||
# VITE_SQUARE_ENVIRONMENT — 'mock' | 'sandbox' | 'production'. Local dev: 'mock' renders the
|
||||
# frontend's built-in mock card form (tokens only; pairs with
|
||||
# SQUARE_ENVIRONMENT=mock above). NEVER set 'mock' in production.
|
||||
VITE_SQUARE_APPLICATION_ID=
|
||||
VITE_SQUARE_LOCATION_ID=
|
||||
VITE_SQUARE_ENVIRONMENT=mock
|
||||
|
||||
# Test Database (separate from main DB)
|
||||
# Used by testutils/testdb for running tests without corrupting dev data
|
||||
TEST_DB_HOST=localhost
|
||||
@@ -49,8 +126,28 @@ TEST_DB_DSN=
|
||||
GO_TESTING=
|
||||
|
||||
# CardDAV (SabreDAV) — profile photo sync
|
||||
# DAV_BASE_URL is retained for reference only: the Go backend no longer reads it
|
||||
# — updateCardDAV writes through the dav.Service directly (Postgres-backed
|
||||
# CardDAV store), it never calls this URL. Only the sabredav PHP container needs
|
||||
# the server-side credential below.
|
||||
DAV_BASE_URL=http://localhost:8080
|
||||
DAV_ADMIN_PASSWORD=admin
|
||||
# DAV_ADMIN_PASSWORD — REQUIRED, FAIL-CLOSED. This CardDAV/CalDAV server
|
||||
# exposes customer PII vCards, so no public/default credential is ever
|
||||
# acceptable. Leave it EMPTY here (as below): compose.yml fails fast via
|
||||
# ${DAV_ADMIN_PASSWORD:?} when it is unset or empty, so a copy-paste
|
||||
# `cp .env.example .env` deployment is caught BEFORE anything boots. As a
|
||||
# second layer, sabredav/server.php refuses to start when the value is a known
|
||||
# weak/default placeholder ('changeme-admin-password', 'changeme', 'secret',
|
||||
# 'test', ...) or has <16 characters / <8 distinct characters (mirrors
|
||||
# isWeakJWTSecret in backend/main.go). Generate a strong random value before
|
||||
# any deployment:
|
||||
# openssl rand -hex 32
|
||||
# Production email/SMS delivery of verification codes is NOT wired yet (P6);
|
||||
# code delivery happens only in dev/test builds via the LOCAL DEV stdout log
|
||||
# ([2FA]/[VERIFY] prefixes). Production builds have no delivery channel, so
|
||||
# 2FA/verification-code issuance FAILS CLOSED (503) until email/SMS delivery
|
||||
# is implemented — see the 2FA section of README.md.
|
||||
DAV_ADMIN_PASSWORD=
|
||||
|
||||
# Logging
|
||||
# Set to "true" to disable ANSI color escape sequences in log output
|
||||
@@ -59,6 +156,23 @@ NO_COLOR=
|
||||
# Frontend
|
||||
VITE_BACKEND_URL=http://localhost:8080
|
||||
|
||||
# Backend CORS allowlist — comma-separated list of allowed frontend origins
|
||||
# (read by the backend CORS middleware, see backend/main.go). Falls back to
|
||||
# http://localhost:5173 when unset.
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
|
||||
# TRUST_PROXY_HEADERS — defaults false. Set to true ONLY when a trusted proxy
|
||||
# (nginx and/or the Cloudflare edge) sits between clients and this backend and
|
||||
# overwrites X-Real-IP / CF-Connecting-IP with the real client IP. When true,
|
||||
# the per-IP rate limiter keys requests on those proxy-set headers and main.go
|
||||
# registers chi's ClientIPFromHeader("X-Real-IP") middleware. MUST be true
|
||||
# behind nginx/Cloudflare, or every request keyed by IP collapses onto the
|
||||
# proxy's IP — one client exhausting the limit throttles everyone, and per-IP
|
||||
# limiter protection is effectively bypassed. MUST stay false when the backend
|
||||
# is origin-exposed: a client talking directly to the backend could otherwise
|
||||
# rotate X-Real-IP/CF-Connecting-IP to bypass per-IP rate limiting.
|
||||
TRUST_PROXY_HEADERS=false
|
||||
|
||||
# Local S3 (Rustfs) — requires GO_TESTING=1 or dev build tag
|
||||
# These are dev-only overrides used by the dev S3 implementation
|
||||
RUSTFS_ENDPOINT=http://rustfs:9000
|
||||
|
||||
+94
-50
@@ -28,8 +28,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -40,7 +41,8 @@ jobs:
|
||||
run: apk add --no-cache git
|
||||
|
||||
- name: Install gitleaks
|
||||
run: go install github.com/zricethezav/gitleaks/v8@latest
|
||||
# Pinned to a released version (supply-chain: never install @latest).
|
||||
run: go install github.com/gitleaks/gitleaks/v8@v8.30.1
|
||||
|
||||
- name: Detect secrets
|
||||
run: gitleaks detect --source . --verbose --no-banner
|
||||
@@ -86,8 +88,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
@@ -120,8 +123,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Vet (dev tags)
|
||||
run: go vet -tags "test,dev" ./...
|
||||
@@ -154,8 +158,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Vet (prod tags)
|
||||
run: go vet -tags "test,!dev" ./...
|
||||
@@ -188,12 +193,13 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: golangci-lint
|
||||
run: |
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.12.2
|
||||
golangci-lint run ./... --timeout 5m
|
||||
working-directory: backend
|
||||
|
||||
@@ -224,12 +230,13 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Staticcheck (dev tags)
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
go install honnef.co/go/tools/cmd/staticcheck@2026.1
|
||||
staticcheck -tags "test,dev" ./...
|
||||
working-directory: backend
|
||||
|
||||
@@ -260,12 +267,13 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Staticcheck (prod tags)
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
go install honnef.co/go/tools/cmd/staticcheck@2026.1
|
||||
staticcheck -tags "test,!dev" ./...
|
||||
working-directory: backend
|
||||
|
||||
@@ -296,12 +304,13 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: gosec (dev tags)
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1
|
||||
gosec -severity medium -tags "test,dev" ./...
|
||||
working-directory: backend
|
||||
|
||||
@@ -332,12 +341,13 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: gosec (prod tags)
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1
|
||||
gosec -severity medium -tags "test,!dev" ./...
|
||||
working-directory: backend
|
||||
|
||||
@@ -368,8 +378,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: go mod tidy check
|
||||
run: |
|
||||
@@ -381,6 +392,7 @@ jobs:
|
||||
working-directory: backend
|
||||
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
name: Tests (${{ matrix.label }})
|
||||
needs: [secrets-scan, go-vet-dev, go-vet-prod, go-lint, go-staticcheck-dev, go-staticcheck-prod, go-gosec-dev, go-gosec-prod, go-mod-tidy, vulns]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -431,8 +443,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Install psql client
|
||||
run: apk add --no-cache postgresql-client
|
||||
@@ -449,9 +462,10 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
run: go test -tags "${{ matrix.gotags }}" -count=1 -timeout 1800s ${{ matrix.verbose }} ${{ matrix.coverflags }} ./...
|
||||
run: go test -tags "${{ matrix.gotags }}" -count=1 -timeout 120s ${{ matrix.verbose }} ${{ matrix.coverflags }} ./...
|
||||
env:
|
||||
GO_TESTING: "1"
|
||||
DAV_SKIP_INIT: "1"
|
||||
POSTGRES_HOST: postgres
|
||||
TEST_DB_HOST: postgres
|
||||
|
||||
@@ -466,6 +480,19 @@ jobs:
|
||||
echo "No coverage file generated"
|
||||
fi
|
||||
|
||||
- name: Check coverage minimum
|
||||
if: matrix.label == 'dev'
|
||||
working-directory: backend
|
||||
run: |
|
||||
if [ -f coverage.out ]; then
|
||||
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||
if awk "BEGIN {exit !($COVERAGE < 60)}"; then
|
||||
echo "FAIL: Coverage $COVERAGE% is below 60% minimum"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Coverage $COVERAGE% meets 60% minimum"
|
||||
fi
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: matrix.label == 'dev'
|
||||
run: |
|
||||
@@ -475,6 +502,7 @@ jobs:
|
||||
fi
|
||||
|
||||
race:
|
||||
timeout-minutes: 30
|
||||
name: Race (${{ matrix.label }})
|
||||
needs: [secrets-scan, go-vet-dev, go-vet-prod, go-lint, go-staticcheck-dev, go-staticcheck-prod, go-gosec-dev, go-gosec-prod, go-mod-tidy, vulns]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -521,8 +549,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Install psql + build-base
|
||||
run: apk add --no-cache postgresql-client build-base
|
||||
@@ -539,9 +568,10 @@ jobs:
|
||||
|
||||
- name: Run race detector
|
||||
working-directory: backend
|
||||
run: go test -tags "${{ matrix.gotags }}" -race -count=1 -timeout 1800s ./...
|
||||
run: go test -tags "${{ matrix.gotags }}" -race -count=1 -timeout 480s ./...
|
||||
env:
|
||||
GO_TESTING: "1"
|
||||
DAV_SKIP_INIT: "1"
|
||||
POSTGRES_HOST: postgres
|
||||
TEST_DB_HOST: postgres
|
||||
CGO_ENABLED: "1"
|
||||
@@ -573,13 +603,14 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Go vulnerability scan
|
||||
working-directory: backend
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
govulncheck ./...
|
||||
|
||||
frontend-deps:
|
||||
@@ -593,8 +624,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -683,8 +715,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Restore npm dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -735,8 +768,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Restore npm dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -766,8 +800,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Restore npm dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -796,8 +831,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -827,8 +863,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Restore npm dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -863,6 +900,8 @@ jobs:
|
||||
cmd: npm run check
|
||||
- task: lint
|
||||
cmd: npm run lint
|
||||
- task: test
|
||||
cmd: npm test
|
||||
- task: audit
|
||||
cmd: npm audit --audit-level=info
|
||||
steps:
|
||||
@@ -870,8 +909,9 @@ jobs:
|
||||
|
||||
- name: Fix node toolcache path for Post-step cleanup
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin
|
||||
ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node
|
||||
NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])")
|
||||
mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin
|
||||
ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node
|
||||
|
||||
- name: Restore npm dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -902,7 +942,11 @@ jobs:
|
||||
run: apk add --no-cache docker-cli docker-compose
|
||||
|
||||
- name: Create env file for compose validation
|
||||
run: cp .env.example backend/.env
|
||||
# compose.yml reads ./.env at the REPO ROOT (env_file paths resolve
|
||||
# relative to the compose file — compose.yml:25), not backend/.env.
|
||||
# A backend/.env file would leave the interpolation vars unset and the
|
||||
# config validation would not exercise the real file the stack uses.
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Validate compose.yml
|
||||
run: docker compose -f compose.yml config --quiet
|
||||
|
||||
+4
-26
@@ -13,13 +13,12 @@ NC='\033[0m' # No Colour
|
||||
|
||||
FAILED=0
|
||||
|
||||
# ---------- Frontend (prettier --write, then eslint) ----------
|
||||
FRONTEND_STAGED=$(git diff --cached --name-only -- 'frontend/' | head -1)
|
||||
if [ -n "$FRONTEND_STAGED" ]; then
|
||||
# ---------- Frontend (prettier --write staged, then eslint) ----------
|
||||
STAGED_FRONTEND=$(git diff --cached --name-only --diff-filter=ACMR | grep '^frontend/' || true)
|
||||
if [ -n "$STAGED_FRONTEND" ]; then
|
||||
printf "${YELLOW}Auto-formatting staged frontend files with prettier...${NC}\n"
|
||||
cd frontend && npx prettier --write . 2>&1
|
||||
echo "$STAGED_FRONTEND" | xargs npx prettier --write 2>/dev/null || true
|
||||
# Re-stage any files prettier modified so formatting is included in the commit
|
||||
cd ..
|
||||
git diff --name-only -- 'frontend/' | xargs -r git add
|
||||
fi
|
||||
|
||||
@@ -41,27 +40,6 @@ if [ -n "$BACKEND_FILES" ]; then
|
||||
fi
|
||||
cd ..
|
||||
|
||||
printf "${YELLOW}Checking golangci-lint...${NC}\n"
|
||||
if ! cd backend && golangci-lint run ./... --timeout 3m 2>&1; then
|
||||
printf "${RED}✖ golangci-lint failed${NC}\n"
|
||||
FAILED=1
|
||||
fi
|
||||
cd ..
|
||||
|
||||
printf "${YELLOW}Checking staticcheck...${NC}\n"
|
||||
if ! cd backend && staticcheck -tags "test,dev" ./... 2>&1; then
|
||||
printf "${RED}✖ staticcheck failed${NC}\n"
|
||||
FAILED=1
|
||||
fi
|
||||
cd ..
|
||||
|
||||
printf "${YELLOW}Checking gosec...${NC}\n"
|
||||
if ! cd backend && gosec -severity medium -tags "test,dev" ./... 2>&1; then
|
||||
printf "${RED}✖ gosec failed${NC}\n"
|
||||
FAILED=1
|
||||
fi
|
||||
cd ..
|
||||
|
||||
printf "${YELLOW}Checking go mod tidy...${NC}\n"
|
||||
cp backend/go.sum backend/go.sum.bak
|
||||
cp backend/go.mod backend/go.mod.bak
|
||||
|
||||
+27
-1
@@ -45,6 +45,10 @@ backend/.DS_Store
|
||||
# 3. Node/Svelte (Frontend)
|
||||
# ------------------------------------
|
||||
|
||||
# Root-level npm artifacts (from nested npm installs)
|
||||
node_modules/
|
||||
package.json
|
||||
|
||||
# Dependencies
|
||||
frontend/node_modules
|
||||
|
||||
@@ -65,7 +69,24 @@ sabredav/vendor/
|
||||
composer.lock
|
||||
|
||||
# ------------------------------------
|
||||
# 5. Local Tools and Notes
|
||||
# 5. Obsidian vault junk (machine-local state / plugin binaries)
|
||||
# ------------------------------------
|
||||
|
||||
# Untitled canvas scratch file
|
||||
obsidian/Untitled.canvas
|
||||
|
||||
# Volatile per-session workspace/graph/view state
|
||||
obsidian/.obsidian/workspace.json
|
||||
obsidian/.obsidian/graph.json
|
||||
obsidian/.obsidian/appearance.json
|
||||
obsidian/.obsidian/core-plugins.json
|
||||
|
||||
# Plugin binaries (multi-MB main.js files) — regenerated on plugin install
|
||||
obsidian/.obsidian/plugins/
|
||||
obsidian/Crussell/.obsidian/plugins/
|
||||
|
||||
# ------------------------------------
|
||||
# 6. Local Tools and Notes
|
||||
# ------------------------------------
|
||||
|
||||
# Bruno testing environments (keep collection files, ignore secrets)
|
||||
@@ -79,6 +100,10 @@ bruno/*/environments/
|
||||
.Trash/
|
||||
Thumbs.db
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# ------------------------------------
|
||||
# Git-specific
|
||||
# ------------------------------------
|
||||
@@ -98,3 +123,4 @@ nginx/*.log
|
||||
|
||||
# Temp files
|
||||
frontend/node_modules/.vite-temp
|
||||
.sisyphus/
|
||||
|
||||
@@ -12,10 +12,13 @@ paths = [
|
||||
# Test fixtures with mock data
|
||||
"backend/testutils/",
|
||||
"backend/handlers/.*_test.go",
|
||||
"backend/internal/square/.*_test.go",
|
||||
# Example env file with placeholder values
|
||||
".env.example",
|
||||
# SabreDAV dependency files
|
||||
"sabredav/composer.json",
|
||||
# Frontend env examples
|
||||
"frontend/.env.production",
|
||||
# Obsidian docs — contain API curl examples with Authorization headers
|
||||
"obsidian/",
|
||||
]
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# Test Coverage Improvement Report
|
||||
|
||||
**Date**: 2026-07-10
|
||||
**Overall Coverage**: 50.4% of statements
|
||||
**Test Command**: `go test -tags "test,dev" -coverprofile=coverage.out -covermode=atomic ./...`
|
||||
|
||||
---
|
||||
|
||||
## Coverage by Package
|
||||
|
||||
| Package | Coverage | Status |
|
||||
|---------|----------|--------|
|
||||
| `handlers/webhooks` | 100.0% | ✅ |
|
||||
| `internal/images` | 100.0% | ✅ |
|
||||
| `internal/jobs` | 93.5% | ✅ |
|
||||
| `mw` | 89.3% | ✅ (small gaps) |
|
||||
| `internal/validators` | 82.9% | ✅ |
|
||||
| `clock` | 80.0% | ✅ |
|
||||
| `internal/square` | 74.4% | ✅ |
|
||||
| `handlers/scheduling` | 73.1% | ✅ |
|
||||
| `handlers/notifications` | 71.5% | ✅ |
|
||||
| `handlers/admin` | 68.6% | ✅ |
|
||||
| `handlers/auth` | 66.0% | ✅ |
|
||||
| `db` | 66.7% | ✅ |
|
||||
| `handlers/today` | 64.5% | ✅ |
|
||||
| `handlers/payments` | 55.4% | 🟡 Moderate |
|
||||
| `handlers/bookings` | 47.7% | 🟡 Low |
|
||||
| `handlers/portfolio` | 48.9% | 🟡 Low |
|
||||
| `auth` | 45.0% | 🟡 Low |
|
||||
| `handlers/user` | 39.1% | 🔴 Very Low |
|
||||
| `handlers/services` | 30.3% | 🔴 Very Low |
|
||||
| `crussell (root)` | 5.4% | 🔴 Critical |
|
||||
| `internal/dav` | 0.0% | ⚫ Zero |
|
||||
| `internal/logutil` | 0.0% | ⚫ Zero |
|
||||
| `internal/s3` | 0.0% | ⚫ Zero |
|
||||
| `internal/zxcvbnjs` | 0.0% | ⚫ Zero |
|
||||
|
||||
---
|
||||
|
||||
## Critical Finding: Cross-Package Coverage Blind Spot
|
||||
|
||||
**Several functions show 0% in their own package but HAVE tests in the `admin` package.** Go's per-package coverage (`go test ./handlers/services/`) only counts tests within that package. Cross-package test calls (from `handlers/admin/`) don't count.
|
||||
|
||||
Affected handlers (all tested in `handlers/admin/` but show 0% in their own package):
|
||||
|
||||
| Function | Actual Coverage | Where Tested |
|
||||
|----------|----------------|-------------|
|
||||
| `handlers/services`: ToggleService, CreateServiceHandler, DeleteServiceHandler, AllServicesHandler | ✅ Tested | `admin/services_test.go` |
|
||||
| `handlers/user`: GetAdminUserHandler, ListAdminUsersHandler, GetEligiblePatchTestServicesHandler, AddPatchTestHandler | ✅ Tested | `admin/users_test.go` |
|
||||
| `handlers/bookings`: GetAllAdminBookingsHandler, GetAdminBookingHandler, GetAllBookingsByUserHandler, SearchAdminBookingsHandler, etc. | ❌ **Untested** | No admin tests written |
|
||||
| `handlers/bookings`: AdminListPendingBookingsHandler, AdminGetInProgressBookingHandler | ❌ **Dead code** | No routes registered in main.go |
|
||||
|
||||
---
|
||||
|
||||
## Priority 1: Immediate Wins (<30 min each, no new dependencies)
|
||||
|
||||
### 1.1 `internal/logutil/logutil_test.go` — 70 lines, pure stdlib
|
||||
**Functions**: `ColoredDuration(d time.Duration) string`, `ColoredRows(n int) string`
|
||||
**What to test**:
|
||||
- `ColoredDuration`: 3 branches (<500ms → green, <5s → yellow, >=5s → red)
|
||||
- `ColoredRows`: singular (n=1) vs plural (n≠1)
|
||||
- `NO_COLOR` env var behavior (ANSI codes vs empty strings)
|
||||
**Lines added**: ~40
|
||||
|
||||
### 1.2 `internal/zxcvbnjs/zxcvbn_test.go` — 68 Go lines, 1 public function
|
||||
**Function**: `Score(password string) (int, error)`
|
||||
**What to test**:
|
||||
- Known weak passwords (`"password"`, `"123456"`) → score 0-1
|
||||
- Strong passwords → score 3-4
|
||||
- Empty string → handle gracefully
|
||||
- `sync.Once` lazy init works across repeated calls
|
||||
**Lines added**: ~30
|
||||
|
||||
### 1.3 `auth/jwt_test.go` — Refresh token functions (already have handler-level tests)
|
||||
**Functions**: `generateRefreshTokenString()`, `GenerateRefreshToken(ctx, userID, role)`, `VerifyRefreshToken(ctx, tokenString)`
|
||||
**What to test**:
|
||||
- `generateRefreshTokenString`: format (64-char hex) + uniqueness (100 calls)
|
||||
- `GenerateRefreshToken`: stores in DB, returns non-empty token
|
||||
- `VerifyRefreshToken`: verify + token rotation (2nd call fails), expired, revoked
|
||||
**Pattern**: Already works in `handlers/auth/auth_test.go:TestRefreshToken_Generation` — adapt for package-level
|
||||
**Test infra**: `auth/testmain_test.go` already sets up `InitJWT` + test DB pool — no setup needed
|
||||
|
||||
### 1.4 `handlers/bookings/repo_test.go` — 4 trivial DB helpers (~5 lines each)
|
||||
**Functions**: `GetBookingStatus`, `GetBookingStartTime`, `BookingExists`, `CountUserBookingsInStatus`
|
||||
**What to test**:
|
||||
- Happy path: insert booking → call function → assert result
|
||||
- Not-found: non-existent ID → assert error
|
||||
**Pattern**: `ctx, tx := testutils.SetupTestTx(t)` → `fixtures.CreateTestUser(tx)` → `fixtures.CreateTestService(tx)` → `fixtures.CreateTestBooking(tx, userID, svcID)` → call function directly
|
||||
|
||||
### 1.5 `mw/response_test.go` — Trivial helpers
|
||||
**Functions**: `RespondJSON(w, status, data)`, `RespondError(w, status, message)`
|
||||
**What to test**:
|
||||
- `RespondJSON`: basic write, error encoding, nil data
|
||||
- `RespondError`: confirm JSON shape `{"error": "..."}`
|
||||
- **Action**: `RespondError` is dead code (never called anywhere) — either remove it or test + start using it
|
||||
**Ease**: 5-line pure functions, `httptest.ResponseRecorder` + standard assertions
|
||||
|
||||
### 1.6 `internal/dav/types_test.go` — Pure string builders
|
||||
**Functions**: `GenerateICalEvent(EventInput) string`, `GenerateVCard(ContactInput) string`
|
||||
**What to test**:
|
||||
- All-day vs timed events, attendees list, special characters (iCal escaping)
|
||||
- vCard formatting with various field combinations
|
||||
- Edge cases: empty fields, long strings
|
||||
**No DB needed**: Pure functions, no dependencies
|
||||
|
||||
---
|
||||
|
||||
## Priority 2: Medium Effort (follow existing test patterns, 1-2h each)
|
||||
|
||||
### 2.1 `handlers/services/services_test.go` — Jump 30% → ~90%
|
||||
**What**: Move/add in-package tests for `ToggleService`, `CreateServiceHandler`, `DeleteServiceHandler`, `AllServicesHandler`
|
||||
**Why**: These are already tested in `handlers/admin/services_test.go` — just need to replicate in the `services` package
|
||||
**Pattern**: Add helper `makeAdminContextRequest` (5 lines setting `mw.UserIDKey` + `mw.UserRoleKey` on chi context like `admin/test_helpers.go`)
|
||||
**Bonus**: Remove dead code on lines 200-208 of `services.go` (unreachable after unconditional `return`)
|
||||
|
||||
### 2.2 `handlers/user/*_test.go` — Jump 39% → ~55%
|
||||
**What**: Add in-package tests for `GetAdminUserHandler`, `ListAdminUsersHandler`, `GetEligiblePatchTestServicesHandler`, `AddPatchTestHandler`
|
||||
**Why**: Same cross-package issue — tested in `admin/users_test.go`
|
||||
**Also add**:
|
||||
- `CreateGuestUserHandler` success path (currently only validation failure tests)
|
||||
- `processProfileImage` unit test with a real JPEG file in `testdata/`
|
||||
- `GetAdminUserHandler` error paths (not-found, social login query error)
|
||||
|
||||
### 2.3 `handlers/bookings/admin_*_test.go` — Admin GET handler tests
|
||||
**What**: Add HTTP handler tests for uncovered admin GET handlers in `bookings.go`:
|
||||
- `GetAllAdminBookingsHandler`, `GetAdminBookingHandler`, `GetAllBookingsByUserHandler`
|
||||
- `SearchAdminBookingsHandler`, `GetOverlappingBookings*`, `GetBookingsBy*Range*`
|
||||
**Pattern**: `serveChiHandler` with manual admin context injection (already used in `overlap_test.go` and `admin_reserve_test.go`)
|
||||
**Verify**: `AdminListPendingBookingsHandler` and `AdminGetInProgressBookingHandler` — confirmed unreachable (no routes). Either remove or add routes + tests.
|
||||
|
||||
### 2.4 `mw/ratelimit_test.go` — Middleware wrapper tests
|
||||
**Functions** (dev stubs): `ProgressiveRateLimit`, `RateLimit`
|
||||
**What to test**: Wrap handler with middleware in dev mode, verify pass-through behavior
|
||||
**Pattern**: `httptest.NewRecorder` + `httptest.NewRequest` + standard HTTP test
|
||||
|
||||
---
|
||||
|
||||
## Priority 3: Heavier Effort (needs mocks or refactoring, 2-4h each)
|
||||
|
||||
### 3.1 `handlers/portfolio/images_test.go` — UploadImage (currently 7.5%)
|
||||
**Blockers**: `s3.Client` is nil in tests → handler returns "Storage not configured" early
|
||||
**Fix**: Mock `s3.Uploader` interface (already exists), set `s3.Client = &mockUploader{}` in test setup
|
||||
**What to test**: Multi-part form with JPEG data, verify 200, verify DB record inserted with correct URLs
|
||||
**Also**: `ListFilters` 31.7% — add tests for `?filter[category]=value` (dual-query path), `?tag=forest`, combined filters
|
||||
|
||||
### 3.2 `handlers/user/profile_test.go` — UploadProfilePictureHandler (8.8%)
|
||||
**Blocker**: Same S3 nil issue as portfolio
|
||||
**Fix**: Same mock approach — `s3.Client = &mockUploader{}`
|
||||
**Also**: `updateCardDAV` (11.1%) — currently requires `DAV_BASE_URL` env var and HTTP server. Mock the function or use `httptest.NewServer`.
|
||||
|
||||
### 3.3 `internal/dav/shared_test.go` — DB-backed CardDAV operations
|
||||
**Functions**: 14 `BaseService` methods (CRUD for calendars, contacts, events)
|
||||
**Requires**: PostgreSQL test DB pool (existing pattern in `testutils/testdb/`)
|
||||
**Pattern**: Create test fixtures (calendar, contact records), call methods, assert DB state
|
||||
**Note**: Importing `dav` package triggers `service_dev.go`'s `init()` which tries to connect to Postgres. Either set `GO_TESTING` env var or construct `BaseService` directly.
|
||||
|
||||
### 3.4 `internal/s3/s3_test.go` — S3 storage
|
||||
**Approach**:
|
||||
- Unit tests via `Uploader` interface mock
|
||||
- `Connect()` integration test requires local S3 (RustFS/MinIO) → tag `//go:build integration`
|
||||
- `GetURL` URL-builder logic can be tested independently (currently 0% on both build tags)
|
||||
|
||||
---
|
||||
|
||||
## Dead Code Found During Investigation
|
||||
|
||||
| Location | Function | Status |
|
||||
|----------|----------|--------|
|
||||
| `mw/response.go:19` | `RespondError` | **Defined but NEVER called** — remove or wire up |
|
||||
| `bookings/manage.go:309` | `AdminListPendingBookingsHandler` | **No route registered** in main.go |
|
||||
| `bookings/manage.go:320` | `AdminGetInProgressBookingHandler` | **No route registered** in main.go |
|
||||
| `services/services.go:200-208` | Duplicate-key error block | **Unreachable** — after unconditional `return` on line 198 |
|
||||
|
||||
---
|
||||
|
||||
## Summary: Coverage Impact by Action
|
||||
|
||||
| Action | Estimated Coverage Gain |
|
||||
|--------|----------------------|
|
||||
| Fix cross-package gap (services) | +15-20% in `handlers/services` |
|
||||
| Fix cross-package gap (user admin handlers) | +10-15% in `handlers/user` |
|
||||
| Add repo.go tests | +2-3% in `handlers/bookings` |
|
||||
| Add admin booking GET handler tests | +15-20% in `handlers/bookings` |
|
||||
| Auth refresh token tests | +15-20% in `auth` |
|
||||
| logutil + zxcvbnjs tests | 0% → 80-100% in those packages |
|
||||
| RespondJSON test | 0% → 100% in `mw/response.go` |
|
||||
| Portfolio S3 mock + tests | 7.5% → 60-70% in `handlers/portfolio` |
|
||||
| dav/types pure func tests | 0% → ~25% in `internal/dav` |
|
||||
| Remove dead code | Removes false-negative 0% entries |
|
||||
| **Total estimated improvement** | **~50% → ~65-70% overall** |
|
||||
@@ -1,431 +0,0 @@
|
||||
# Plan: Consolidated `jobs` Package for Background Cron Tasks
|
||||
|
||||
**Goal:** Extract 9 side-effect cleanup functions from `GetAvailableHours` HTTP handler into a dedicated `jobs` package with cron-scheduled, parallel execution. Remove the lazy-cleanup antipattern.
|
||||
|
||||
**Current state:**
|
||||
- 9 cleanup functions in `backend/handlers/scheduling/time-blockers.go` — all called synchronously in `default-hours.go:364-407` on every `GET /available-hours` request
|
||||
- Only `CleanupOldReservations` ALSO runs on a background goroutine (`main.go:432-450`, 5min ticker)
|
||||
- 4 other ad-hoc background goroutines exist (JWT cleanup, GDPR cache, login state, rate limiter)
|
||||
- `robfig/cron/v3` v3.0.1 is already a `go.mod` dependency (currently used only for parsing time-blocker cron expressions)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Create `backend/internal/jobs/` Package
|
||||
|
||||
**New package:** `crussell/internal/jobs`
|
||||
|
||||
### File: `scheduler.go`
|
||||
|
||||
Core scheduler abstraction built on `robfig/cron/v3`:
|
||||
|
||||
```go
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// Job defines a single periodic task.
|
||||
type Job struct {
|
||||
Name string // Human-readable name (for logging)
|
||||
Schedule string // Standard cron expression ("*/5 * * * *")
|
||||
Timeout time.Duration // Per-execution timeout
|
||||
Concurrency int // Max concurrent runs (0 = unlimited, 1 = serial)
|
||||
Handler func(context.Context) error // The actual work
|
||||
}
|
||||
|
||||
// Scheduler manages all registered cron jobs with parallel execution.
|
||||
type Scheduler struct {
|
||||
cron *cron.Cron
|
||||
entries []cron.EntryID
|
||||
registry []Job
|
||||
baseCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
semaphores map[string]chan struct{} // Per-job concurrency limit
|
||||
}
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
1. Each job runs in its own goroutine (cron v3 default) — parallel by nature
|
||||
2. Per-job concurrency control via channel semaphore — prevents overlapping runs of the same job
|
||||
3. Panic recovery wrapper — matches existing pattern in `jwt.go`, `gdpr_export.go`, `auth/local.go`
|
||||
4. Timeout via `context.WithTimeout` — matches existing `main.go` pattern
|
||||
5. Graceful shutdown via `baseCtx` — the cron scheduler is stopped, then active jobs drain
|
||||
|
||||
```go
|
||||
func New() *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Scheduler{
|
||||
cron: cron.New(cron.WithLocation(londonLocation)),
|
||||
baseCtx: ctx,
|
||||
cancel: cancel,
|
||||
semaphores: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Register(job Job) {
|
||||
// Validate cron expression at registration time
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
if _, err := parser.Parse(job.Schedule); err != nil {
|
||||
log.Fatalf("jobs: invalid cron schedule %q for job %q: %v", job.Schedule, job.Name, err)
|
||||
}
|
||||
s.registry = append(s.registry, job)
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
for _, job := range s.registry {
|
||||
j := job // capture
|
||||
entryID, err := s.cron.AddFunc(j.Schedule, s.wrapJob(j))
|
||||
if err != nil {
|
||||
log.Fatalf("jobs: failed to register %q: %v", j.Name, err)
|
||||
}
|
||||
s.entries = append(s.entries, entryID)
|
||||
}
|
||||
s.cron.Start()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Shutdown() <-chan struct{} {
|
||||
ctx := s.cron.Stop() // Stop scheduler (returns ctx that completes when all jobs finish)
|
||||
s.cancel() // Cancel base context so in-flight jobs know to stop
|
||||
return ctx.Done() // Caller can wait for this
|
||||
}
|
||||
|
||||
// wrapJob adds panic recovery, timeout, concurrency control, and logging.
|
||||
func (s *Scheduler) wrapJob(job Job) func() {
|
||||
// Set up semaphore if concurrency limited
|
||||
var sem chan struct{}
|
||||
if job.Concurrency > 0 {
|
||||
sem = make(chan struct{}, job.Concurrency)
|
||||
sem <- struct{}{} // Initial slot filled
|
||||
}
|
||||
|
||||
return func() {
|
||||
// Concurrency guard
|
||||
if sem != nil {
|
||||
select {
|
||||
case <-sem:
|
||||
// Acquired — proceed
|
||||
default:
|
||||
log.Printf("jobs: %q skipped (previous run still in progress)", job.Name)
|
||||
return
|
||||
}
|
||||
defer func() { sem <- struct{}{} }()
|
||||
}
|
||||
|
||||
// Panic recovery
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("jobs: panic recovered in %q: %v", job.Name, r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Timeout
|
||||
ctx, cancel := context.WithTimeout(s.baseCtx, job.Timeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
if err := job.Handler(ctx); err != nil {
|
||||
log.Printf("jobs: %q failed: %v (duration: %v)", job.Name, err, time.Since(start))
|
||||
} else {
|
||||
log.Printf("jobs: %q completed (duration: %v)", job.Name, time.Since(start))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File: `cleanup.go`
|
||||
|
||||
Registration of all 9 scheduling cleanup functions + JWT cleanup.
|
||||
|
||||
```go
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crussell/auth"
|
||||
"crussell/handlers/scheduling"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegisterAll registers every background job.
|
||||
func RegisterAll(s *Scheduler) {
|
||||
// === HIGH FREQUENCY (every 5 min) ===
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-reservations",
|
||||
Schedule: "*/5 * * * *",
|
||||
Timeout: 30 * time.Second,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupOldReservations,
|
||||
})
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-expired-deposits",
|
||||
Schedule: "*/5 * * * *",
|
||||
Timeout: 30 * time.Second,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupExpiredDeposits,
|
||||
})
|
||||
|
||||
// === MID FREQUENCY (hourly) ===
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-expired-loyalty-redemptions",
|
||||
Schedule: "0 * * * *",
|
||||
Timeout: 30 * time.Second,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupExpiredLoyaltyRedemptions,
|
||||
})
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-old-idempotency-keys",
|
||||
Schedule: "0 * * * *",
|
||||
Timeout: 30 * time.Second,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupOldIdempotencyKeys,
|
||||
})
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-revoked-jtis",
|
||||
Schedule: "0 * * * *",
|
||||
Timeout: 30 * time.Second,
|
||||
Concurrency: 1,
|
||||
Handler: func(ctx context.Context) error {
|
||||
auth.CleanupRevokedJTIs(ctx)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
// === LOW FREQUENCY (daily, off-peak) ===
|
||||
|
||||
s.Register(Job{
|
||||
Name: "anonymize-stale-guest-accounts",
|
||||
Schedule: "0 3 * * *", // 3am
|
||||
Timeout: 5 * time.Minute,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.AnonymizeStaleGuestAccounts,
|
||||
})
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-expired-financial-records",
|
||||
Schedule: "0 4 * * *", // 4am
|
||||
Timeout: 10 * time.Minute,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupExpiredFinancialRecords,
|
||||
})
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-expired-gift-cards",
|
||||
Schedule: "0 5 * * *", // 5am
|
||||
Timeout: 5 * time.Minute,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupExpiredGiftCards,
|
||||
})
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-idle-accounts",
|
||||
Schedule: "30 3 * * *", // 3:30am (offset from financial)
|
||||
Timeout: 5 * time.Minute,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupIdleAccounts,
|
||||
})
|
||||
|
||||
s.Register(Job{
|
||||
Name: "cleanup-old-name-history",
|
||||
Schedule: "30 4 * * *", // 4:30am
|
||||
Timeout: 30 * time.Second,
|
||||
Concurrency: 1,
|
||||
Handler: scheduling.CleanupOldNameHistory,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Staggering rationale:** Daily jobs at 3am/3:30am/4am/4:30am/5am spread the load so heavy operations (financial aggregation, idle account scans) don't contend with each other or with the hourly jobs. High-frequency jobs (reservations, deposits) are decoupled on their own 5-min schedules.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Wire into `main.go`
|
||||
|
||||
### Before:
|
||||
```go
|
||||
// main.go:142
|
||||
auth.StartJTICleanup()
|
||||
|
||||
// main.go:432-450 (background goroutine for reservations)
|
||||
cleanupCtx, cleanupStop := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
ticker := time.NewTicker(reservationCleanupInterval)
|
||||
...
|
||||
}()
|
||||
|
||||
// main.go:457-468 (shutdown)
|
||||
cleanupStop()
|
||||
```
|
||||
|
||||
### After:
|
||||
```go
|
||||
// At package level, after constants but before init()
|
||||
var sched *jobs.Scheduler
|
||||
|
||||
// In main(), after initDB()/initSquare()/etc but before r := chi.NewRouter():
|
||||
sched = jobs.New()
|
||||
jobs.RegisterAll(sched)
|
||||
sched.Start()
|
||||
|
||||
// In main(), shutdown block (replace the old cleanupStop() call):
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
<-quit
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// Graceful HTTP shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
// Graceful job scheduler shutdown
|
||||
<-sched.Shutdown()
|
||||
log.Println("Background jobs stopped")
|
||||
}()
|
||||
```
|
||||
|
||||
**Remove:**
|
||||
- `reservationCleanupInterval` and `reservationCleanupTimeout` constants (`main.go:71-72`)
|
||||
- `cleanupCtx`/`cleanupStop` variables (`main.go:433`)
|
||||
- The goroutine block (`main.go:434-450`)
|
||||
- Import `scheduling` from `main.go` (no longer needed there)
|
||||
- `auth.StartJTICleanup()` call (`main.go:142`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Remove Side-Effects from `GetAvailableHours`
|
||||
|
||||
**File: `backend/handlers/scheduling/default-hours.go`**
|
||||
|
||||
Delete lines 364-407 (the entire cleanup block):
|
||||
|
||||
```diff
|
||||
- // Clean up old reservations (older than 1 hour)
|
||||
- if err := CleanupOldReservations(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup old reservations: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Anonymize stale guest accounts (6+ months after last booking)
|
||||
- if err := AnonymizeStaleGuestAccounts(r.Context()); err != nil {
|
||||
- log.Printf("Failed to anonymize stale guest accounts: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Clean up expired loyalty redemptions (pending past expires_at)
|
||||
- if err := CleanupExpiredLoyaltyRedemptions(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup expired loyalty redemptions: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Clean up expired financial records (aggregate + delete granular data)
|
||||
- if err := CleanupExpiredFinancialRecords(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup expired financial records: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Clean up bookings past deposit deadline (no deposit paid)
|
||||
- if err := CleanupExpiredDeposits(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup expired deposits: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Clean up expired gift cards (unused for 24+ months)
|
||||
- if err := CleanupExpiredGiftCards(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup expired gift cards: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Clean up idle accounts (2yr no money, 5yr with money)
|
||||
- if err := CleanupIdleAccounts(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup idle accounts: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Clean up old idempotency keys (24h+ and non-pending)
|
||||
- if err := CleanupOldIdempotencyKeys(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup old idempotency keys: %v", err)
|
||||
- }
|
||||
-
|
||||
- // Clean up old name history (6+ months)
|
||||
- if err := CleanupOldNameHistory(r.Context()); err != nil {
|
||||
- log.Printf("Failed to cleanup old name history: %v", err)
|
||||
- }
|
||||
```
|
||||
|
||||
Also remove `"log"` import from `default-hours.go` if it becomes unused after removal.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Remove `auth.StartJTICleanup()` (Optional but Recommended)
|
||||
|
||||
**File: `backend/auth/jwt.go`**
|
||||
|
||||
- Keep `CleanupRevokedJTIs(ctx)` — it's used by tests and now by the jobs package
|
||||
- Delete `StartJTICleanup()` entirely (no longer needed, replaced by the jobs package)
|
||||
- Remove the ticker goroutine
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Update Documentation
|
||||
|
||||
**File: `obsidian/Crussell/Technical Manual.md`**
|
||||
- Update lines ~545 and ~999: change "lazy cleanup triggered on availability fetch" to "all cleanup runs on cron schedules via the `jobs` package"
|
||||
- Remove the lazy-cleanup justification
|
||||
- Add a new section documenting the `jobs` package and its job registry
|
||||
|
||||
**File: `obsidian/Crussell/Future Work - Gap Backlog.md`**
|
||||
- Mark item #2 as fully complete (strikethrough)
|
||||
- Remove the note about remaining functions still running on `GET /api/availability`
|
||||
|
||||
**File: `README.md`** (optional)
|
||||
- Add a line about the background job scheduler
|
||||
|
||||
---
|
||||
|
||||
## Effort Estimate
|
||||
|
||||
| Step | Files Changed | Effort |
|
||||
|------|--------------|--------|
|
||||
| Phase 1a: `scheduler.go` | 1 new file | 2-3h |
|
||||
| Phase 1b: `cleanup.go` | 1 new file | 1h |
|
||||
| Phase 2: Wire `main.go` | 1 file | 30min |
|
||||
| Phase 3: Remove side-effects from handler | 1 file | 15min |
|
||||
| Phase 4: Remove `StartJTICleanup` | 1 file | 15min |
|
||||
| Phase 5: Update docs | 2-3 files | 30min |
|
||||
| Testing & go vet | — | 1h |
|
||||
| **Total** | **4-5 files (+2 new)** | **~6h** |
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| **Concurrent DB load** from multiple jobs at the same cron tick | Stagger daily jobs across 3am-5am range. High-frequency jobs (5min) are lightweight. Per-job `Concurrency: 1` prevents overlapping runs. |
|
||||
| **Job takes longer than interval** (e.g., financial aggregation >5min while scheduled every 5min) | `Concurrency: 1` + skip-logic: if previous run still in-flight, the new invocation is skipped and logged. |
|
||||
| **Cron expression parsing differs** from existing time-blocker parser | Both use `cron.NewParser(cron.Minute \| cron.Hour \| cron.Dom \| cron.Month \| cron.Dow)` — identical. |
|
||||
| **Tests expect cleanup side-effects** from `GetAvailableHours` | Search for tests that rely on the side-effect calls. Some may need an explicit cleanup call before assertions. |
|
||||
| **`CleanupExpiredDeposits` timing gap** — deposit-lapsed slots not freed until next cron tick | 5-min frequency is acceptable. The previous behavior freed on the *next* availability fetch, which could be minutes or hours apart depending on user activity. 5-min max latency is actually *better* than the lazy approach during quiet periods. |
|
||||
| **Panic in one job takes down the scheduler** | `wrapJob` has defer-recover. One job's panic cannot affect others (separate goroutines). |
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Exclusions
|
||||
|
||||
**Not in scope (Phase 1):**
|
||||
- `gdpr_export.go:init()` — GDPR cache cleanup (5min ticker). Tightly coupled to package-level state. Leave as-is.
|
||||
- `auth/local.go:init()` — Login state cleanup. Tightly coupled. Leave as-is.
|
||||
- `mw/ratelimit.go` goroutines — Rate limiter cleanup. Leave as-is.
|
||||
- These can be migrated to the scheduler in a follow-up if desired, but require more refactoring.
|
||||
|
||||
**Not a concern:**
|
||||
- `backend/handlers/scheduling/time-blockers.go` already uses `robfig/cron/v3` — no new dependency, no version conflict.
|
||||
- All 9 cleanup functions have signature `func(context.Context) error` — perfectly uniform for the `Job.Handler` type.
|
||||
- The `scheduling` package is already imported in `main.go` — no new import needed for that.
|
||||
|
||||
**Key invariant:** All cleanup functions are idempotent (documented in code). Running them on cron instead of on-demand has zero correctness impact — they produce the same result regardless of how often they run.
|
||||
@@ -4,36 +4,36 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL
|
||||
|
||||
## Features
|
||||
|
||||
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 20 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation extracted into a reusable `closing_time` helper.
|
||||
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (per-type TTLs: logged-in users 1 hour, anonymous guests 10 minutes, admin walk-in and call-in 15 minutes, edit requests 24 hours; the `cleanup-reservations` cron job expires them every 5 minutes). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 27 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, token/code cleanup, and the S3 deletion retry. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours.
|
||||
|
||||
**Payments**: Square Terminal (in-person) + Web Payments SDK (online). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A PostgreSQL `pg_advisory_lock` serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases now insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record.
|
||||
**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-hex-character code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment ≥20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first up-to-50% of the booking total (minus anything already deposited) is carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. The frontend computes deposit figures from the shared `POLICY` constants (`frontend/src/lib/constants/policy.ts`): `REQUIRED_DEPOSIT_PCT` (0.2) and `PROTECTED_DEPOSIT_MAX_PCT` (0.5), single-sourced with the backend's `refund_policy.go` (`RequiredDepositPct` / `ProtectedDepositMaxPct`) instead of per-file literals. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge.
|
||||
|
||||
**Gift Cards**: Multi-method purchase (cash, card machine, online card, giveaway). Inventory cards for stock management. 24-month rolling expiry. Idle account cleanup (2yr/5yr thresholds). Expired balance recovery with admin audit trail. Transaction audit log. Idempotency keys for purchases.
|
||||
**Gift Cards**: Multi-method purchase (cash, card machine, online card, giveaway). 12-hex-character codes (48 bits of randomness — never guessable, never logged). Inventory cards for stock management. 24-month rolling expiry. Idle account cleanup (2yr/5yr thresholds). Expired balance recovery with admin audit trail. Transaction audit log. Idempotency keys for purchases. 14-day statutory cancellation for online purchases.
|
||||
|
||||
**Scheduling**: Default weekly hours, holiday/exceptional groups, time blockers (one-off + recurring with cron). Lunch protection. Late-night lock (22:00–11:00). Admin schedule page (Google Calendar-style week view).
|
||||
**Scheduling**: Default weekly hours, holiday/exceptional groups, time blockers (one-off + recurring with cron), **staged default hours changes** (schedule future changes with effective date picker, conflict detection, and auto-apply at midnight). Lunch protection. Late-night lock (22:00–11:00). Admin schedule page (Google Calendar-style week view).
|
||||
|
||||
**Custom Services**: One-off or special-request services not in the permanent catalog. Admin management with create, edit, promote to permanent service (migrates booking references), and delete. Full CRUD API with search, popular sorting, and pagination. Can be added to any booking alongside regular services.
|
||||
|
||||
**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Custom services (one-off services with create/edit/promote/delete). Discount campaigns (time-based and milestone). Time blocker CRUD. Portfolio image upload with tag management. Gift card management. Business settings (VAT, gift card config). Notification queue with priority ordering.
|
||||
**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Custom services (one-off services with create/edit/promote/delete). Discount campaigns (time-based and milestone). Time blocker CRUD. Portfolio image upload with tag management. Gift card management. Business settings (VAT, gift card config). Notification queue with priority ordering. The money-critical `critical_payment_log` / `refresh_token_reuse` notification queue is flood-capped at `adminnotify.MaxUnacknowledgedCriticalLogs` (100 unacknowledged rows per reason), folded atomically into the INSERT at every insert site (Square webhooks, account-erasure cleanup, time-blockers cleanup, the critical-log scan job, refresh-token-reuse detection, the payment-sweep path, the booking-creation `new_booking`/`pending_booking` inserts, the cancellation and edit-request inserts, the `refund_failed` insert, the deposit-deadline cleanup's `deposit_not_paid_by_deadline` insert, and the 1-week/1-month unpaid-booking notices); at the cap, further inserts are suppressed with an operator-facing log until outstanding notifications are acknowledged (which re-arms inserts).
|
||||
|
||||
**Loyalty & Discounts**: 1 stamp per paid appointment (max 1/day). 10 stamps → 10% off via opt-in checkbox at payment or till. Stamps refunded on cancellation. Campaigns auto-apply at both payment and completion: time-based, per-user milestone, global milestone (in-person only), anniversary. All discounts stack additively against original total. Discount payment records excluded from refund calculations.
|
||||
|
||||
**Compliance**: GDPR Article 15 data export (async, 12h cache, 21-section JSON + PDF — excludes verification codes as authentication tokens). Account deletion with external system scrubbing (S3, Square). Guest PII anonymized 6 months post-appointment. UK financial data retention (7 years). Gift card SPV/MPV VAT treatment configurable.
|
||||
**Compliance**: GDPR Article 15 data export (async, 12h cache, 23-section JSON + PDF — excludes verification codes as authentication tokens). Account deletion with external system scrubbing (S3, Square). Guest PII anonymized 6 months post-appointment. UK financial data retention (7 years). Gift card VAT treated as single-purpose vouchers (SPV at purchase; a stored MPV setting is overridden to SPV at read time). **ICO registration is an operator responsibility**: the sole-trader controller must register with the Information Commissioner's Office (ICO) and pay the data-protection fee unless exempt, before processing personal data at scale (see the Pre-Launch checklist in the Technical Manual).
|
||||
|
||||
**Frontend**: Portfolio gallery with fuzzy tag search (relevance-sorted) and exact category filters (date-sorted), multi-format images (AVIF/WebP/JPEG/JXL with WASM client-side encoding), cursor-based pagination. MapLibre GL map on contact page. PhoneInput component with UK validation. CharCounter for long notes.
|
||||
|
||||
**Infrastructure**: Docker Compose (postgres, backend, sabredav, nginx). Dev mock for Square payments (`//go:build dev`). RustFS dev storage, Cloudflare R2 for prod. SabreDAV CardDAV sync for profile photos.
|
||||
**Infrastructure**: Docker Compose (postgres, backend, sabredav, nginx). Dev mock for Square payments (`//go:build dev`) that mirrors production PCI-DSS behaviour (rejects raw PANs; accepts `cnon:`/`ccof:` tokens only). RustFS dev storage, Cloudflare R2 for prod. SabreDAV CardDAV sync for profile photos.
|
||||
|
||||
**Middleware**: `JsonContentType` sets `Content-Type: application/json` globally, replacing ~80+ individual `w.Header().Set()` calls. `RespondJSON`/`RespondError` helpers standardise API response format. Progressive rate limiting (dual-window) on login/register with account lockout.
|
||||
**Middleware**: `JsonContentType` sets `Content-Type: application/json` globally, replacing ~80+ individual `w.Header().Set()` calls. `RespondJSON`/`RespondError` helpers standardise API response format. Progressive rate limiting (dual-window) on login/register with account lockout (per-account, after 5 failed attempts a 15-minute lockout, escalating to 30 minutes at 7+ failures and 60 minutes at 10+ — an attacker who keeps guessing past each unlock makes the lock LONGER, and the response stays a uniform 401 so locked vs wrong-password is never distinguishable).
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Single employee** — no multi-staff scheduling, no team management
|
||||
- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, and notifications are UI-only
|
||||
- **No production S3/R2** — prod storage stubs return "not implemented"
|
||||
- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, notifications, and 2FA code delivery are UI-only/log-delivery (planned upcoming body of work; until email/SMS lands, 2FA codes are delivered to the local dev stdout log (`[2FA]` prefix) in dev/test builds only — production builds have no delivery channel and code issuance fails closed (503); there is deliberately no production opt-in — see the 2FA section above)
|
||||
- **No production S3/R2** — prod storage stubs return "not implemented" (planned upcoming body of work)
|
||||
- **No social auth** — OAuth providers (Google, Microsoft, Facebook) not registered
|
||||
- **No dark mode, no PWA, no recurring bookings, no CSV export**
|
||||
- **Password reset flow exists backend-only — no frontend link**
|
||||
- **Password reset flow exists backend-only — no frontend link** (the repeatable lockout DoS — 5 wrong passwords → 15min lock, repeatable — is mitigated by the escalating lock: 15/30/60-minute tiers mean an attacker who keeps guessing makes the lock LONGER, up to an hour; a locked account's recovery remains the backend-only reset flow or an operator clearing the `failed_attempts`/`locked_until` columns)
|
||||
- **No error tracking/monitoring** — Sentry not configured
|
||||
|
||||
## Prerequisites
|
||||
@@ -47,18 +47,43 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL
|
||||
|
||||
## Getting Started
|
||||
|
||||
Create the two env files first. The backend container reads `.env` at the repo root, and the frontend build reads `frontend/.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env — set POSTGRES_*, JWT_SECRET_KEY
|
||||
cp .env.example .env # backend + postgres + Square + S3 credentials
|
||||
cp frontend/.env.example frontend/.env # frontend VITE_* vars (VITE_SQUARE_ENVIRONMENT=mock)
|
||||
# Edit .env — set POSTGRES_*, JWT_SECRET_KEY, and any Square credentials for sandbox/production
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
`VITE_SQUARE_ENVIRONMENT=mock` (default in `frontend/.env`) makes the frontend render its built-in mock card form, pairing with the backend's `SQUARE_ENVIRONMENT=mock` for a token-only local walkthrough. Set it to `sandbox` or `production` only once real Square credentials are configured, never `mock` in a deployed build.
|
||||
|
||||
`FRONTEND_ORIGIN` (backend `.env`) is a comma-separated CORS allowlist for the API. `corsAllowedOrigins()` in `backend/main.go` splits on commas, trims, drops blanks, and falls back to `http://localhost:5173` when the var is unset or empty. Matching is exact-match only (`originAllowed()`), never reflected: `Access-Control-Allow-Origin` and `Vary: Origin` are set only when the request `Origin` is in the allowlist.
|
||||
|
||||
`SQUARE_ALLOW_REAL_API` is a dev-build safety valve: a `//go:build dev` build **HARD-FAILS** (panics) when `SQUARE_ENVIRONMENT=production` unless this is set to `1`, so a typo'd or leftover production value in a dev shell cannot create real charges. Sandbox is allowed in a dev build (with a loud banner). Never set it in a deployed production build.
|
||||
|
||||
`TRUST_PROXY_HEADERS` (backend `.env`) defaults to `false`. The backend sits behind a trusted proxy in every real deployment — the nginx in `compose.yml` and/or the Cloudflare edge — which overwrites `X-Real-IP` / `CF-Connecting-IP` with the real client IP. Set `TRUST_PROXY_HEADERS=true` for those deployments: without it every per-IP rate-limit key collapses onto the proxy's IP, so any one client can exhaust the shared per-IP budget and throttle the whole surface for everyone (and per-IP limiter protection is effectively bypassed). Keep it `false` only when the backend is origin-exposed. `compose.yml` deliberately never sets it — the operator decides per deployment (the value is passed through the repo-root `.env` via `env_file`).
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Frontend | http://localhost |
|
||||
| API | http://localhost/api |
|
||||
| SabreDAV | http://localhost/dav |
|
||||
|
||||
### Square webhooks (production)
|
||||
|
||||
`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)
|
||||
|
||||
Saved-card online payments are authorised **exclusively** by Square **PSD2 SCA** (buyer verification via the Web Payments SDK's `tokenizeWithVerification`). A customer-initiated stored-credential charge is a PSR 2017-regulated transaction: Square's verification token both satisfies SCA and shifts chargeback liability to the card scheme. On the wire, the tokenize-result is sent as the charge **source** (`new_card_token`, which the backend passes to Square as `source_id`) alongside the saved-card reference — not as a separate `verification_token` (the legacy `ccof:` + `verification_token` shape is still accepted but is no longer the primary contract). A saved-card charge carrying **no** Square verification token is **refused outright** — 402 `verification_required` — and the payment does not go through (the customer can try again later; at the till, the customer is told they can pay online later instead). There is **no homegrown 2FA fallback**: PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges, and a merchant-side 2FA check with no bank involvement cannot legally substitute for it (authorising a token-less charge via 2FA would leave the merchant liable for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6) compensation regardless of consent). The `TWO_FACTOR_FALLBACK` switch was **removed entirely**. The dev Square mock simulates SCA (`SimulateSavedCardVerificationRequired` + `cnon:sca-...` tokenize-results), so development has full parity with the SCA-only production posture.
|
||||
|
||||
Homegrown 2FA remains for **admin and account verification only** — 2FA setup, disable, and delete-account re-authentication — **never** for authorising a card charge. The gate itself 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** (P6). Until it lands, the 6-digit code is delivered to the **local dev stdout log** (`[2FA]` prefix; the developer/operator relays it) in **dev/test builds only** — stdout-log delivery is a local-dev convenience, never a production channel. Production builds have **no delivery channel at all** and 2FA code issuance **fails closed (503)** — no user can complete 2FA setup or disable — until the email/SMS transport is implemented.
|
||||
|
||||
### Local dev (tmux)
|
||||
|
||||
```bash
|
||||
@@ -76,24 +101,38 @@ Default logins (password: `password`):
|
||||
```bash
|
||||
cd backend && go build -o bin/backend ./main.go
|
||||
cd frontend && npm ci && npm run build
|
||||
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # ~1,642 tests passed (~13s)
|
||||
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,781 backend test functions under the test,dev tags (per `go test -tags "test,dev" -list 'Test.*'`) + 161 frontend vitest cases, as of 20 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=10 -parallel 8 ./... # thorough verification (~2-3min)
|
||||
# NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks —
|
||||
# those suites share package-global state (Square mock ledger, in-memory webhook
|
||||
# dedup cache, fixed-ID test rows) that leaks across in-process iterations.
|
||||
# Use -count=1 there; -count=N works for the other packages.
|
||||
```
|
||||
|
||||
### Pre-commit hooks
|
||||
|
||||
`.githooks/pre-commit` runs on every commit (configured via `git config core.hooksPath .githooks`):
|
||||
- **Frontend**: `prettier --write` auto-format, then `eslint` all files
|
||||
- **Backend** (only if `backend/` changed): `go vet`, `golangci-lint` (3m timeout), `staticcheck`, `gosec`, `go mod tidy` check
|
||||
- **Frontend**: `eslint` all files (runs on every commit); `prettier --write` auto-format only when `frontend/` files are staged
|
||||
- **Backend** (only if `backend/` files changed): `go vet` (with `test,dev` tags) and a `go mod tidy` drift check
|
||||
- **Global**: `gitleaks` secret scan (skips gracefully if not installed)
|
||||
|
||||
To bypass: `git commit --no-verify`.
|
||||
|
||||
The heavier static analyzers (`golangci-lint`, `staticcheck`, `gosec`) are **not** part of the local hook — they run in CI (`.gitea/workflows/ci.yaml`): `golangci-lint` runs once without build tags, `staticcheck` and `gosec` run against both `test,dev` and `test,!dev` build tags, and CI also runs `govulncheck` (dependency vulnerabilities), alongside `go vet`, `go mod tidy`, and the gitleaks scan.
|
||||
|
||||
### CI caching
|
||||
|
||||
CI caches Go modules (`~/go/pkg/mod`) and npm dependencies (`~/.npm`, `node_modules`) via `actions/cache` — keyed on `go.sum` and `package-lock.json` respectively. Cache is served by Gitea's built-in cache server at `git.popertots.com`. First run downloads everything (~3m35s), subsequent runs restore from cache in seconds.
|
||||
|
||||
### Database schema policy (pre-launch — no ALTERs)
|
||||
|
||||
The schema is single-source in `init-scripts/init-script.sql`, applied automatically on a **fresh** volume via `docker-entrypoint-initdb.d`. This project is **pre-launch**: there is no production database, and all dev work starts from a fresh DB recreation. Therefore:
|
||||
|
||||
- **No `ALTER TABLE` / `ALTER TYPE` / `ADD VALUE` statements anywhere** — not in `init-script.sql`, not in tests, not in code.
|
||||
- Any schema change is edited **directly into the `CREATE` statements** in `init-script.sql`.
|
||||
- There is **no migration-managed delta** and no "apply before deploying" step. If a local dev DB needs updating, drop and recreate it (`docker compose down -v && docker compose up --build -d`), or apply the change by hand locally — never commit ALTERs.
|
||||
- Do not document changes as migration snippets; the schema diff on the next recreate is the migration.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosec
|
||||
- gocritic
|
||||
disable:
|
||||
- exhaustruct
|
||||
- nlreturn
|
||||
- wsl
|
||||
- wrapcheck
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*encoding/json.Encoder).Encode
|
||||
- io.WriteString
|
||||
- (io.Closer).Close
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
- path: handlers/testutils/
|
||||
linters:
|
||||
- gosec
|
||||
- path: internal/square/square_dev.go
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
+2
-2
@@ -5,8 +5,8 @@ WORKDIR /app
|
||||
# Copy in your prebuilt Go binary (from local ./backend/bin/backend)
|
||||
COPY bin/backend ./backend
|
||||
|
||||
# Copy env file if you want to bake it in (or mount via volume/env_file in compose)
|
||||
COPY .env ./
|
||||
# Secrets are injected at runtime via compose env_file / environment (see
|
||||
# docker-compose.yml backend env_file: ./.env) — NEVER baked into the image.
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
+502
-56
@@ -7,10 +7,13 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/adminnotify"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
@@ -19,11 +22,60 @@ import (
|
||||
|
||||
var TokenAuth *jwtauth.JWTAuth
|
||||
|
||||
// AuthResponse is the response structure for login/refresh endpoints
|
||||
// RefreshTokenLifetime is how long an issued refresh token stays valid before
|
||||
// it expires (90 days). Minted rows get expires_at = NOW() + RefreshTokenLifetime
|
||||
// (see GenerateRefreshToken / GenerateRefreshTokenInFamily), and the cleanup
|
||||
// jobs that expire refresh-token families and retain revoked tokens
|
||||
// (handlers/scheduling CleanupExpiredRefreshTokens, jobs SweepSquareWebhookEvents)
|
||||
// reference the SAME constant so the SQL window can never drift from the mint.
|
||||
const RefreshTokenLifetime = 90 * 24 * time.Hour
|
||||
|
||||
// refreshTokenLifetimeDays is RefreshTokenLifetime expressed in whole days, fed
|
||||
// to the SQL make_interval(days => ...) calls in the mint queries.
|
||||
const refreshTokenLifetimeDays = int64(RefreshTokenLifetime / (24 * time.Hour))
|
||||
|
||||
// refreshTokenReuseGrace is how long after a rotation a used-token replay is
|
||||
// treated as a BENIGN concurrent refresh (two tabs sharing one refresh token in
|
||||
// localStorage both refreshing on load) instead of theft. A replay inside the
|
||||
// grace window gets the generic error but does NOT kill the rotation family and
|
||||
// does NOT raise the refresh_token_reuse alert — only a replay after the window
|
||||
// has elapsed is treated as theft (see VerifyRefreshToken).
|
||||
//
|
||||
// LOW-2: 20s (was 60s). The frontend's cross-tab coordination
|
||||
// (frontend/src/lib/stores/auth.svelte.ts: REFRESH_LOCK_TTL_MS = 15s, plus a
|
||||
// 20s wait-for-timeout) guarantees only ONE tab performs a rotation and every
|
||||
// sibling tab adopts the rotated pair instead of replaying the old token, so
|
||||
// the only legitimately-arriving replays are same-tick races — two in-flight
|
||||
// fetches that crossed before the lock settled, sub-second. The old 60s window
|
||||
// handed a stolen refresh token up to a full minute of freshness before reuse
|
||||
// detection fired; 20s keeps comfortable margin over the cross-tab coordination
|
||||
// bound while cutting the undetected-theft window to a third. The ideal fix
|
||||
// (only kill when the replay's IP/User-Agent differs from the rotation's)
|
||||
// would need the rotation origin persisted per family, which the locked schema
|
||||
// cannot express today.
|
||||
const refreshTokenReuseGrace = 20 * time.Second
|
||||
|
||||
// refreshTokenReuseGraceSecs is the grace window in whole seconds for the SQL
|
||||
// make_interval(secs => ...) comparison in VerifyRefreshToken's reuse branch.
|
||||
const refreshTokenReuseGraceSecs = int64(refreshTokenReuseGrace / time.Second)
|
||||
|
||||
// accessTokenFamilyClaim is the JWT claim that binds an access token to the
|
||||
// refresh-token rotation family it was minted alongside. VerifyToken rejects an
|
||||
// access token whose family_id no longer exists in refresh_tokens, so when
|
||||
// reuse detection DELETEs a family every access token minted by that lineage
|
||||
// dies immediately instead of remaining valid for its 1-hour TTL (HIGH 1).
|
||||
const accessTokenFamilyClaim = "family_id"
|
||||
|
||||
// AuthResponse is the response structure for login/refresh endpoints.
|
||||
// RefreshToken is a 90-day opaque, DB-hashed, single-use credential; the
|
||||
// client stores it and presents it (Bearer) to POST /api/refresh-token in
|
||||
// exchange for a fresh access token + a rotated refresh token. It is returned
|
||||
// in JSON so the SPA can persist it — omitting it would make the refresh flow
|
||||
// unusable — but it is never logged and never returned by any other endpoint.
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
JTI string `json:"jti"`
|
||||
RefreshToken string `json:"-"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
}
|
||||
|
||||
// generateJTI generates a UUID v4 string using crypto/rand
|
||||
@@ -38,35 +90,35 @@ func generateJTI() (string, error) {
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
// RevokeJTI adds a JTI to the revoked set in PostgreSQL
|
||||
func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) {
|
||||
// RevokeJTI adds a JTI to the revoked set in PostgreSQL.
|
||||
// Returns an error if the operation fails.
|
||||
func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) error {
|
||||
if db.Conn == nil {
|
||||
return
|
||||
return fmt.Errorf("revoke JTI: db.Conn is nil")
|
||||
}
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("WARN: Failed to begin transaction for JTI revocation: %v", err)
|
||||
return
|
||||
return fmt.Errorf("revoke JTI: begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2)
|
||||
ON CONFLICT (jti) DO NOTHING`,
|
||||
jti, expiresAt)
|
||||
if err != nil {
|
||||
// Log but don't fail - this is best effort
|
||||
log.Printf("WARN: Failed to revoke JTI %s: %v", jti, err)
|
||||
return
|
||||
return fmt.Errorf("revoke JTI %s: %w", jti, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("WARN: Failed to commit transaction for JTI revocation: %v", err)
|
||||
return fmt.Errorf("revoke JTI: commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsJTIRevoked checks if a JTI is in the revoked set via PostgreSQL.
|
||||
@@ -87,6 +139,164 @@ func IsJTIRevoked(ctx context.Context, jti string) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
// familyAliveCacheTTL bounds how long a family-alive verdict stays cached.
|
||||
// VerifyToken already runs one DB query per request for the JTI revocation
|
||||
// check; the family-alive check would add a second. Caching confirmed verdicts
|
||||
// for 30s turns that second query into an in-memory lookup for the common case
|
||||
// (MEDIUM-1), cutting auth-path DB amplification in half. The TTL is short so a
|
||||
// killed family is re-observed quickly, and the cache is explicitly invalidated
|
||||
// on every family kill (VerifyRefreshToken reuse branch, LogoutHandler) so
|
||||
// bound access tokens die immediately when theft is detected (HIGH 1).
|
||||
const familyAliveCacheTTL = 30 * time.Second
|
||||
|
||||
// familyAliveRecheckGrace is how close a cached family-alive verdict must be to
|
||||
// its TTL before verifyFamilyAlive re-validates it against the DB instead of
|
||||
// trusting the cache (Loop B finding 3). The daily refresh-token cleanup
|
||||
// captures the affected families, DELETEs them, commits, and then invalidates
|
||||
// the in-memory cache — a crash between the DELETE commit and the
|
||||
// invalidation leaves the cache warm for up to familyAliveCacheTTL, accepting a
|
||||
// bound access token after its family was killed. Re-validating an ALIVE
|
||||
// verdict within this grace of its expiry bounds that residual window to the
|
||||
// grace itself; a small grace (5s of a 30s TTL) preserves the MEDIUM-1
|
||||
// query-amplification win — only near-expiry lookups re-query.
|
||||
const familyAliveRecheckGrace = 5 * time.Second
|
||||
|
||||
// familyAliveCacheMaxEntries bounds the in-memory map so a flood of distinct
|
||||
// family ids cannot grow it without bound.
|
||||
const familyAliveCacheMaxEntries = 10_000
|
||||
|
||||
// familyAliveCacheEntry is one cached family-alive verdict. Only DB-CONFIRMED
|
||||
// results are ever stored — a failed query fails open and is never cached, so a
|
||||
// transient outage cannot freeze a stale rejection or admission into the cache.
|
||||
type familyAliveCacheEntry struct {
|
||||
alive bool
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// familyAliveCache is a mutex-guarded, bounded cache of family-alive verdicts
|
||||
// keyed by "<family_id>|<user_id>" (the family belongs to one user, but the
|
||||
// composite key keeps the verdict aligned with the SQL conjunct).
|
||||
var familyAliveCache struct {
|
||||
mu sync.Mutex
|
||||
m map[string]familyAliveCacheEntry
|
||||
}
|
||||
|
||||
func init() {
|
||||
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
|
||||
}
|
||||
|
||||
// familyAliveLookup returns a cached verdict for a family key and whether it is
|
||||
// still fresh, evicting expired entries opportunistically.
|
||||
func familyAliveLookup(key string) (alive bool, ok bool) {
|
||||
e, ok := familyAliveLookupEntry(key)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return e.alive, true
|
||||
}
|
||||
|
||||
// familyAliveLookupEntry returns the cached verdict entry (with its expiry) for
|
||||
// a family key and whether it is still fresh, evicting expired entries
|
||||
// opportunistically. Unlike familyAliveLookup it hands the caller the entry so
|
||||
// verifyFamilyAlive can re-validate a near-expiry ALIVE verdict against the DB
|
||||
// (Loop B finding 3 — the residual crash window of the refresh-token cleanup's
|
||||
// post-commit cache invalidation); familyAliveLookup stays as the simple
|
||||
// (alive, ok) accessor used by the tests.
|
||||
func familyAliveLookupEntry(key string) (familyAliveCacheEntry, bool) {
|
||||
familyAliveCache.mu.Lock()
|
||||
defer familyAliveCache.mu.Unlock()
|
||||
e, ok := familyAliveCache.m[key]
|
||||
if !ok {
|
||||
return familyAliveCacheEntry{}, false
|
||||
}
|
||||
if clock.Now().After(e.expires) {
|
||||
delete(familyAliveCache.m, key)
|
||||
return familyAliveCacheEntry{}, false
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
// familyAliveStore records a DB-confirmed verdict, evicting expired entries
|
||||
// and then the oldest live entry when the cache is at capacity.
|
||||
func familyAliveStore(key string, alive bool) {
|
||||
familyAliveCache.mu.Lock()
|
||||
defer familyAliveCache.mu.Unlock()
|
||||
now := clock.Now()
|
||||
e := familyAliveCacheEntry{alive: alive, expires: now.Add(familyAliveCacheTTL)}
|
||||
if len(familyAliveCache.m) >= familyAliveCacheMaxEntries {
|
||||
for k, ce := range familyAliveCache.m {
|
||||
if now.After(ce.expires) {
|
||||
delete(familyAliveCache.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(familyAliveCache.m) >= familyAliveCacheMaxEntries {
|
||||
var oldestKey string
|
||||
var oldestAt time.Time
|
||||
for k, ce := range familyAliveCache.m {
|
||||
if oldestKey == "" || ce.expires.Before(oldestAt) {
|
||||
oldestKey, oldestAt = k, ce.expires
|
||||
}
|
||||
}
|
||||
delete(familyAliveCache.m, oldestKey)
|
||||
}
|
||||
familyAliveCache.m[key] = e
|
||||
}
|
||||
|
||||
// InvalidateFamilyAlive drops every cached verdict for a family so the next
|
||||
// VerifyToken re-queries the DB. Called whenever a rotation family is deleted
|
||||
// (refresh-token reuse kill, logout) so bound access tokens die on their next
|
||||
// verification instead of riding the cache TTL (HIGH 1).
|
||||
func InvalidateFamilyAlive(familyID string) {
|
||||
InvalidateFamilyAliveBatch([]string{familyID})
|
||||
}
|
||||
|
||||
// InvalidateFamilyAliveBatch drops every cached family-alive verdict for a set
|
||||
// of families so the next VerifyToken re-queries the DB. Called wherever
|
||||
// rotation families are deleted — the reuse kill, logout, and the scheduled
|
||||
// cleanup of expired refresh tokens (handlers/scheduling/scheduled-cleanup.go)
|
||||
// — so access tokens bound to a family whose last member was deleted die on
|
||||
// their next verification instead of riding the familyAliveCacheTTL (LOW 6 /
|
||||
// finding 3). Empty and blank family ids are skipped.
|
||||
func InvalidateFamilyAliveBatch(familyIDs []string) {
|
||||
if len(familyIDs) == 0 {
|
||||
return
|
||||
}
|
||||
familyAliveCache.mu.Lock()
|
||||
defer familyAliveCache.mu.Unlock()
|
||||
for _, familyID := range familyIDs {
|
||||
if familyID == "" {
|
||||
continue
|
||||
}
|
||||
for k := range familyAliveCache.m {
|
||||
if strings.HasPrefix(k, familyID+"|") {
|
||||
delete(familyAliveCache.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateFamilyAliveByUser drops every cached family-alive verdict for a
|
||||
// user, so access tokens bound to ANY of the user's rotation families are
|
||||
// re-checked against the DB on their next verification. Called when a user's
|
||||
// credentials die wholesale — a password change deletes every refresh token
|
||||
// the user holds, and GDPR erasure does the same inside anonymize_user() /
|
||||
// delete_guest_user() — so killed families' access tokens die immediately
|
||||
// instead of riding the familyAliveCacheTTL (LOW 5).
|
||||
func InvalidateFamilyAliveByUser(userID string) {
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
familyAliveCache.mu.Lock()
|
||||
defer familyAliveCache.mu.Unlock()
|
||||
suffix := "|" + userID
|
||||
for k := range familyAliveCache.m {
|
||||
if strings.HasSuffix(k, suffix) {
|
||||
delete(familyAliveCache.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupRevokedJTIs removes expired entries from PostgreSQL and returns the count of deleted rows.
|
||||
func CleanupRevokedJTIs(ctx context.Context) (int, error) {
|
||||
if db.Conn == nil {
|
||||
@@ -98,10 +308,10 @@ func CleanupRevokedJTIs(ctx context.Context) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tag, err := tx.Exec(ctx,
|
||||
`DELETE FROM revoked_jtis WHERE expires_at < NOW()`)
|
||||
@@ -122,26 +332,45 @@ func InitJWT(secret string) {
|
||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||
}
|
||||
|
||||
// GenerateToken creates a JWT with user_id, role, and a unique jti claim
|
||||
// Returns the token string, the JTI, and any error
|
||||
// GenerateToken creates a JWT with user_id, role, and a unique jti claim.
|
||||
// Returns the token string, the JTI, and any error. The token carries NO
|
||||
// family_id claim, so it is exempt from the family-alive check in VerifyToken —
|
||||
// kept for callers minting transient/test tokens. Production login and refresh
|
||||
// paths use GenerateTokenForFamily so a killed rotation family cannot keep an
|
||||
// access token alive (HIGH 1).
|
||||
func GenerateToken(userID string, role string) (string, string, error) {
|
||||
return GenerateTokenForFamily(userID, role, "")
|
||||
}
|
||||
|
||||
// GenerateTokenForFamily mints an access token carrying a family_id claim that
|
||||
// binds it to a refresh-token rotation family. VerifyToken rejects a token whose
|
||||
// family_id no longer exists in refresh_tokens, so when reuse detection DELETEs
|
||||
// the whole family (VerifyRefreshToken), every access token minted by that
|
||||
// lineage dies immediately instead of remaining valid for its 1-hour TTL.
|
||||
// familyID == "" mints an unbound token (same as GenerateToken).
|
||||
func GenerateTokenForFamily(userID string, role string, familyID string) (string, string, error) {
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
||||
claims := map[string]any{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"jti": jti,
|
||||
"exp": clock.Now().Add(1 * time.Hour).Unix(), // 1 hour
|
||||
})
|
||||
}
|
||||
if familyID != "" {
|
||||
claims[accessTokenFamilyClaim] = familyID
|
||||
}
|
||||
|
||||
_, tokenString, err := TokenAuth.Encode(claims)
|
||||
return tokenString, jti, err
|
||||
}
|
||||
|
||||
// VerifyToken validates JWT and returns user_id, role, and jti
|
||||
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, jti string, err error) {
|
||||
token, err := TokenAuth.Decode(tokenString)
|
||||
token, err := jwtauth.VerifyToken(TokenAuth, tokenString)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
@@ -177,9 +406,106 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
|
||||
return "", "", "", fmt.Errorf("token revoked")
|
||||
}
|
||||
|
||||
// HIGH 1: reject an access token bound (family_id claim) to a rotation
|
||||
// family that reuse detection has killed. A token minted at rotation carries
|
||||
// family_id; when that family no longer exists in refresh_tokens the token is
|
||||
// dead even though its JTI was never revoked — closing the up-to-1-hour
|
||||
// window where a stolen refresh token's freshly-minted access token could
|
||||
// still hit money endpoints (gift-card buy, saved-card booking payment, tip).
|
||||
if err := verifyFamilyAlive(ctx, token, userID); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
return userID, role, jti, nil
|
||||
}
|
||||
|
||||
// verifyFamilyAlive rejects access tokens bound to a rotation family that no
|
||||
// longer exists in refresh_tokens. A token WITHOUT a family_id claim is unbound
|
||||
// (minted via GenerateToken — tests/legacy callers) and passes. DB-confirmed
|
||||
// verdicts are cached for familyAliveCacheTTL (MEDIUM-1) so VerifyToken does
|
||||
// not run a second query per request; the cache is invalidated on family kills
|
||||
// so a killed family's access tokens die on their next verification (HIGH 1).
|
||||
// On a live-DB query error the check FAILS OPEN with a WARN log — matching
|
||||
// IsJTIRevoked — because genuine theft is already handled by the family kill in
|
||||
// VerifyRefreshToken's reuse branch, and a transient DB error must not turn
|
||||
// into a total 401 outage for every authenticated request.
|
||||
//
|
||||
// Loop B finding 3 (crash-safety residual window): an ALIVE verdict within
|
||||
// familyAliveRecheckGrace of its TTL is re-validated against the DB. The daily
|
||||
// refresh-token cleanup (handlers/scheduling/scheduled-cleanup.go
|
||||
// CleanupExpiredRefreshTokens) invalidates the family-alive cache AFTER its
|
||||
// DELETE commits, so a crash between the commit and the invalidation leaves the
|
||||
// cache warm for the rest of the TTL — a family killed by that missed
|
||||
// invalidation would otherwise keep admitting its bound access tokens. The
|
||||
// near-expiry re-check closes the gap to the grace window.
|
||||
func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string) error {
|
||||
var familyVal any
|
||||
if err := token.Get(accessTokenFamilyClaim, &familyVal); err != nil {
|
||||
return nil
|
||||
}
|
||||
familyID, ok := familyVal.(string)
|
||||
if !ok || familyID == "" {
|
||||
return nil
|
||||
}
|
||||
if db.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
key := familyID + "|" + userID
|
||||
if e, cached := familyAliveLookupEntry(key); cached {
|
||||
if !e.alive {
|
||||
return fmt.Errorf("token revoked")
|
||||
}
|
||||
// A fresh verdict is trusted (the query-amplification win); only a
|
||||
// near-expiry ALIVE verdict falls through to the DB re-check below.
|
||||
if clock.Now().Before(e.expires.Add(-familyAliveRecheckGrace)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var exists bool
|
||||
err := db.Conn.QueryRow(ctx,
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM refresh_tokens WHERE family_id = $1 AND user_id = $2
|
||||
)`, familyID, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
slog.Warn("family-alive check failed — failing open (access token admitted)", "family_id", familyID, "err", err)
|
||||
return nil
|
||||
}
|
||||
familyAliveStore(key, exists)
|
||||
if !exists {
|
||||
return fmt.Errorf("token revoked")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FamilyIDFromToken returns the access token's family_id claim, or "" when the
|
||||
// token carries none (unbound — test/legacy minting via GenerateToken). It only
|
||||
// DECODES the token without re-verifying the signature; callers must only use
|
||||
// it on a token that already passed VerifyToken (e.g. RequireAuth middleware).
|
||||
// Used by LogoutHandler to scope refresh-token revocation to the presented
|
||||
// session's rotation family (LOW-1).
|
||||
func FamilyIDFromToken(tokenString string) string {
|
||||
if TokenAuth == nil || tokenString == "" {
|
||||
return ""
|
||||
}
|
||||
decoded, err := TokenAuth.Decode(tokenString)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var fv any
|
||||
if err := decoded.Get(accessTokenFamilyClaim, &fv); err != nil {
|
||||
return ""
|
||||
}
|
||||
familyID, _ := fv.(string)
|
||||
return familyID
|
||||
}
|
||||
|
||||
// jwtClaimGetter is the minimal subset of jwt.Token needed to read a claim
|
||||
// (the token returned by jwtauth.VerifyToken). Kept as an interface so the
|
||||
// lestrrat-go/jwx dependency stays out of this file's imports.
|
||||
type jwtClaimGetter interface {
|
||||
Get(string, interface{}) error
|
||||
}
|
||||
|
||||
// generateRefreshTokenString creates a cryptographically random opaque refresh token
|
||||
func generateRefreshTokenString() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
@@ -189,18 +515,61 @@ func generateRefreshTokenString() (string, error) {
|
||||
return fmt.Sprintf("%x", b), nil
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a refresh token stored in the database
|
||||
// Returns the opaque token string to return to the client
|
||||
func GenerateRefreshToken(ctx context.Context, userID string, role string) (string, error) {
|
||||
// GenerateRefreshToken creates a refresh token stored in the database. Returns
|
||||
// the opaque token string to return to the client plus the id of the rotation
|
||||
// family the new row was created in (so the caller can bind the matching access
|
||||
// token to it via GenerateTokenForFamily — see HIGH 1).
|
||||
func GenerateRefreshToken(ctx context.Context, userID string, role string) (string, string, error) {
|
||||
token, err := generateRefreshTokenString()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Store hashed version in DB with the shared RefreshTokenLifetime expiry
|
||||
query := `
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at)
|
||||
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, gen_random_uuid(), NOW() + make_interval(days => $4))
|
||||
RETURNING id, family_id`
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var tokenID int64
|
||||
var familyID string
|
||||
err = tx.QueryRow(ctx, query, userID, token, role, refreshTokenLifetimeDays).Scan(&tokenID, &familyID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return token, familyID, nil
|
||||
}
|
||||
|
||||
// GenerateRefreshTokenInFamily creates a refresh token in the SAME rotation
|
||||
// family as its parent (the family_id returned by VerifyRefreshToken). Rotation
|
||||
// must mint the descendant in the parent's family so a replayed (already-used)
|
||||
// ancestor can revoke the ENTIRE lineage — the descendant included — instead of
|
||||
// leaving a fresh 90-day token alive after theft is detected.
|
||||
func GenerateRefreshTokenInFamily(ctx context.Context, userID string, role string, familyID string) (string, error) {
|
||||
token, err := generateRefreshTokenString()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Store hashed version in DB with 90-day expiry
|
||||
// Store hashed version in DB with the shared RefreshTokenLifetime expiry, in the given family
|
||||
query := `
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at)
|
||||
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, NOW() + INTERVAL '90 days')
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at)
|
||||
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, $4, NOW() + make_interval(days => $5))
|
||||
RETURNING id`
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
@@ -208,13 +577,13 @@ func GenerateRefreshToken(ctx context.Context, userID string, role string) (stri
|
||||
return "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var tokenID int64
|
||||
err = tx.QueryRow(ctx, query, userID, token, role).Scan(&tokenID)
|
||||
err = tx.QueryRow(ctx, query, userID, token, role, familyID, refreshTokenLifetimeDays).Scan(&tokenID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
@@ -226,39 +595,116 @@ func GenerateRefreshToken(ctx context.Context, userID string, role string) (stri
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyRefreshToken checks a refresh token and returns user details if valid
|
||||
// The token is consumed (deleted) upon successful verification, implementing rotation.
|
||||
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, err error) {
|
||||
// VerifyRefreshToken checks a refresh token and returns user details if valid.
|
||||
// The token is consumed (marked used) upon successful verification — rotation —
|
||||
// and its family_id is returned so the caller can mint the descendant in the
|
||||
// SAME family. If an ALREADY-ROTATED token is presented again after the
|
||||
// refreshTokenReuseGrace window (a replay: the attacker rotated it, then the
|
||||
// victim replayed it), the entire rotation family is revoked (the descendant
|
||||
// minted at rotation dies too, and so does every access token bound to the
|
||||
// family — HIGH 1) and a critical admin notification (reason
|
||||
// 'refresh_token_reuse') is raised. A replay WITHIN the grace window is a
|
||||
// benign concurrent refresh (two tabs sharing one localStorage refresh token
|
||||
// refreshing on load): it gets the generic error but kills NOTHING and raises
|
||||
// NO alert, so the legitimately-rotated session survives. The caller always gets
|
||||
// the generic "invalid or expired refresh token" error so reuse is never leaked.
|
||||
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, familyID string, err error) {
|
||||
query := `
|
||||
DELETE FROM refresh_tokens
|
||||
UPDATE refresh_tokens SET used_at = NOW()
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
AND expires_at > NOW()
|
||||
AND NOT revoked
|
||||
RETURNING user_id, role`
|
||||
AND used_at IS NULL
|
||||
RETURNING user_id, role, family_id`
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
return "", "", "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", fmt.Errorf("invalid or expired refresh token")
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
return "", "", fmt.Errorf("failed to verify refresh token: %w", err)
|
||||
}()
|
||||
|
||||
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role, &familyID)
|
||||
if err == nil {
|
||||
// Rotation: the token is marked used (kept in the row) so a later
|
||||
// replay can be detected, and its family_id is returned.
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
return userID, role, familyID, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", "", fmt.Errorf("failed to verify refresh token: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
// The rotation UPDATE matched nothing: the token is expired, revoked, or
|
||||
// never issued — OR it was already used (replayed). A used token replayed
|
||||
// AFTER the grace window is theft: the descendant minted at rotation would
|
||||
// otherwise stay valid for 90 days. A used token replayed WITHIN the grace
|
||||
// window is a benign concurrent refresh (two tabs, one shared refresh token)
|
||||
// and falls through to the generic error below — no family kill, no alert.
|
||||
var reusedUserID, reusedFamilyID string
|
||||
reuseErr := tx.QueryRow(ctx, `
|
||||
SELECT user_id, family_id FROM refresh_tokens
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
AND used_at IS NOT NULL
|
||||
AND used_at < NOW() - make_interval(secs => $2)
|
||||
`, tokenString, refreshTokenReuseGraceSecs).Scan(&reusedUserID, &reusedFamilyID)
|
||||
|
||||
if reuseErr == nil {
|
||||
// (i) Revoke the ENTIRE family — the reused token and every descendant.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM refresh_tokens WHERE family_id = $1`, reusedFamilyID); err != nil {
|
||||
slog.Error("CRITICAL: refresh token reuse detected but family revocation failed", "userID", reusedUserID, "familyID", reusedFamilyID, "err", err)
|
||||
} else {
|
||||
// The family is gone — drop its cached verdict so bound access
|
||||
// tokens die on their next verification instead of riding the
|
||||
// family-alive cache TTL (HIGH 1).
|
||||
InvalidateFamilyAlive(reusedFamilyID)
|
||||
}
|
||||
// (ii) Surface the theft in the admin notification centre. The NOT
|
||||
// EXISTS guard keeps ONE alert per reused family until an admin
|
||||
// acknowledges it — mirroring insertCriticalPaymentNotification — and
|
||||
// the GLOBAL cap (adminnotify.MaxUnacknowledgedCriticalLogs) bounds the
|
||||
// unacknowledged 'refresh_token_reuse' queue ATOMICALLY (Round 2 Loop B
|
||||
// finding 1): without it a register-botnet — N accounts, each rotated
|
||||
// once and replayed past the grace window — could bury the single-operator
|
||||
// notification centre under unbounded alerts. The cap is folded into
|
||||
// the INSERT's WHERE clause (count-then-insert is atomic, closing the
|
||||
// TOCTOU), and the pre-check logs the suppression for operator
|
||||
// visibility.
|
||||
if adminnotify.CriticalLogsCapExceeded(ctx, tx, "refresh_token_reuse") {
|
||||
slog.Error("CRITICAL: refresh token reuse detected but admin alert suppressed — unacknowledged 'refresh_token_reuse' queue at the cap", "userID", reusedUserID, "cap", adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
} else if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, user_id, created_at)
|
||||
SELECT 'refresh_token_reuse', $1, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM admin_notifications an
|
||||
WHERE an.reason = 'refresh_token_reuse'
|
||||
AND an.user_id = $1
|
||||
AND an.acknowledged_at IS NULL
|
||||
)
|
||||
AND (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'refresh_token_reuse'
|
||||
AND _an.acknowledged_at IS NULL) < $2
|
||||
`, reusedUserID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
slog.Error("CRITICAL: refresh token reuse detected but admin alert insert failed", "userID", reusedUserID, "err", err)
|
||||
}
|
||||
// Commit the family revocation + alert — NOT the deferred rollback.
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
// (iii) CRITICAL log; (iv) generic error — never leak that reuse was seen.
|
||||
slog.Error("CRITICAL: refresh token reuse detected — rotation family revoked", "userID", reusedUserID, "familyID", reusedFamilyID)
|
||||
return "", "", "", fmt.Errorf("invalid or expired refresh token")
|
||||
}
|
||||
if !errors.Is(reuseErr, pgx.ErrNoRows) {
|
||||
return "", "", "", fmt.Errorf("failed to verify refresh token: %w", reuseErr)
|
||||
}
|
||||
|
||||
// Token was consumed (DELETE returned it) — this is rotation
|
||||
// If a token is used twice, the second DELETE returns no rows = invalid
|
||||
return userID, role, nil
|
||||
// Never-issued / expired / revoked token — indistinguishable from a replay
|
||||
// to the client, as before.
|
||||
return "", "", "", fmt.Errorf("invalid or expired refresh token")
|
||||
}
|
||||
|
||||
+381
-14
@@ -165,7 +165,9 @@ func TestVerifyToken_RevokedJTI(t *testing.T) {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
|
||||
RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour))
|
||||
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
|
||||
t.Fatalf("RevokeJTI() failed: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, err = VerifyToken(token, ctx)
|
||||
if err == nil {
|
||||
@@ -176,6 +178,22 @@ func TestVerifyToken_RevokedJTI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyToken_ExpiredToken creates a token whose "exp" claim is in the past
|
||||
// and verifies that VerifyToken rejects it with an error containing "expired".
|
||||
func TestVerifyToken_ExpiredToken(t *testing.T) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
||||
"user_id": "user-expired",
|
||||
"role": "verified_email",
|
||||
"jti": "test-jti-expired",
|
||||
"exp": clock.Now().Add(-1 * time.Hour).Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, _, err = VerifyToken(tokenString, context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expired")
|
||||
}
|
||||
|
||||
// TestVerifyToken_MissingJTI creates a token without a "jti" claim (using
|
||||
// TokenAuth.Encode directly) and verifies that VerifyToken returns an error
|
||||
// containing "invalid jti claim".
|
||||
@@ -225,7 +243,9 @@ func TestRevokeJTI_AddsToSet(t *testing.T) {
|
||||
t.Fatal("JTI should not be revoked before calling RevokeJTI")
|
||||
}
|
||||
|
||||
RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour))
|
||||
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
|
||||
t.Fatalf("RevokeJTI() failed: %v", err)
|
||||
}
|
||||
|
||||
if !IsJTIRevoked(ctx, jti) {
|
||||
t.Error("expected IsJTIRevoked to return true after RevokeJTI")
|
||||
@@ -255,7 +275,9 @@ func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) {
|
||||
}
|
||||
|
||||
// Add with future expiry so IsJTIRevoked sees it
|
||||
RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour))
|
||||
if err := RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour)); err != nil {
|
||||
t.Fatalf("RevokeJTI() failed: %v", err)
|
||||
}
|
||||
|
||||
if !IsJTIRevoked(ctx, jti) {
|
||||
t.Fatal("JTI should be in revoked set after RevokeJTI")
|
||||
@@ -285,7 +307,9 @@ func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) {
|
||||
}
|
||||
|
||||
// Add with future expiry
|
||||
RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour))
|
||||
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
|
||||
t.Fatalf("RevokeJTI() failed: %v", err)
|
||||
}
|
||||
|
||||
if !IsJTIRevoked(ctx, jti) {
|
||||
t.Fatal("JTI should be in revoked set before cleanup")
|
||||
@@ -354,7 +378,7 @@ func TestGenerateRefreshToken_Success(t *testing.T) {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
@@ -394,13 +418,13 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
// First verify should succeed
|
||||
retUserID, retRole, err := VerifyRefreshToken(ctx, token)
|
||||
retUserID, retRole, _, err := VerifyRefreshToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRefreshToken() failed: %v", err)
|
||||
}
|
||||
@@ -412,7 +436,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
// Second verify with same token must fail (rotation — token consumed)
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for consumed token, got nil")
|
||||
}
|
||||
@@ -431,19 +455,19 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
// First call should succeed
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("first verification should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
// Second call with the same token must fail
|
||||
_, _, err = VerifyRefreshToken(ctx, token)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rotated token, got nil")
|
||||
}
|
||||
@@ -452,12 +476,293 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts verifies the reuse
|
||||
// detection: generate a token → rotate it once (minting a descendant in the
|
||||
// SAME family via GenerateRefreshTokenInFamily) → present the ORIGINAL token
|
||||
// again. The replay must (i) delete the ENTIRE rotation family (the descendant
|
||||
// included) from refresh_tokens and (ii) insert an admin_notifications row with
|
||||
// reason 'refresh_token_reuse' for the user.
|
||||
func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// 1. Generate a refresh token (new family) and rotate it once.
|
||||
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
||||
if err != nil {
|
||||
t.Fatalf("first verification should succeed, got: %v", err)
|
||||
}
|
||||
if familyID == "" {
|
||||
t.Fatal("expected non-empty family_id from rotation")
|
||||
}
|
||||
|
||||
// 2. Mint the descendant in the SAME family (as RefreshTokenHandler does).
|
||||
descendant, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
||||
}
|
||||
|
||||
var famCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
|
||||
t.Fatalf("failed to count family rows: %v", err)
|
||||
}
|
||||
if famCount != 2 {
|
||||
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
|
||||
}
|
||||
|
||||
// Reuse grace window: backdate the original's used_at past the reuse grace
|
||||
// so the replay below is genuine theft. WITHOUT this, a replay
|
||||
// moments after rotation is a benign two-tab concurrent refresh and the
|
||||
// family must NOT be killed.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE refresh_tokens
|
||||
SET used_at = NOW() - make_interval(secs => 120)
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
`, original); err != nil {
|
||||
t.Fatalf("failed to backdate used_at for reuse test: %v", err)
|
||||
}
|
||||
|
||||
// 3. Replay the ORIGINAL token — theft.
|
||||
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for replayed token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid or expired") {
|
||||
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
||||
}
|
||||
|
||||
// (i) The entire family is revoked: the used original AND the descendant.
|
||||
var famAfter int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famAfter); err != nil {
|
||||
t.Fatalf("failed to count family rows after replay: %v", err)
|
||||
}
|
||||
if famAfter != 0 {
|
||||
t.Errorf("expected 0 refresh tokens in family after reuse (descendant killed), got %d", famAfter)
|
||||
}
|
||||
|
||||
var descHashCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex')`,
|
||||
descendant).Scan(&descHashCount); err != nil {
|
||||
t.Fatalf("failed to check descendant: %v", err)
|
||||
}
|
||||
if descHashCount != 0 {
|
||||
t.Errorf("expected descendant to be deleted, got %d rows", descHashCount)
|
||||
}
|
||||
|
||||
// (ii) An admin alert with reason 'refresh_token_reuse' exists for the user.
|
||||
var alertCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refresh_token_reuse' AND user_id = $1`,
|
||||
userID).Scan(&alertCount); err != nil {
|
||||
t.Fatalf("failed to query admin_notifications: %v", err)
|
||||
}
|
||||
if alertCount != 1 {
|
||||
t.Errorf("expected 1 'refresh_token_reuse' alert, got %d", alertCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_ReplayWithinGrace_IsBenign verifies the reuse
|
||||
// hardening: a used-token replay WITHIN the grace window (two tabs sharing
|
||||
// one localStorage refresh token both refreshing on load) is a benign
|
||||
// concurrent refresh — the generic error is returned, but the rotation family
|
||||
// survives and no refresh_token_reuse alert is raised.
|
||||
func TestVerifyRefreshToken_ReplayWithinGrace_IsBenign(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
||||
if err != nil {
|
||||
t.Fatalf("first verification should succeed, got: %v", err)
|
||||
}
|
||||
if familyID == "" {
|
||||
t.Fatal("expected non-empty family_id from rotation")
|
||||
}
|
||||
|
||||
// Mint the descendant in the same family (as RefreshTokenHandler does).
|
||||
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
|
||||
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
||||
}
|
||||
|
||||
// Replay the original IMMEDIATELY — inside the grace window → benign.
|
||||
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for within-grace replay, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid or expired") {
|
||||
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
||||
}
|
||||
|
||||
// The family survives: used original + descendant both still present.
|
||||
var famCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
|
||||
t.Fatalf("failed to count family rows: %v", err)
|
||||
}
|
||||
if famCount != 2 {
|
||||
t.Errorf("expected 2 refresh tokens in family after within-grace replay, got %d", famCount)
|
||||
}
|
||||
|
||||
// No theft alert.
|
||||
var alertCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refresh_token_reuse' AND user_id = $1`,
|
||||
userID).Scan(&alertCount); err != nil {
|
||||
t.Fatalf("failed to query admin_notifications: %v", err)
|
||||
}
|
||||
if alertCount != 0 {
|
||||
t.Errorf("expected 0 'refresh_token_reuse' alerts for a within-grace replay, got %d", alertCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_GraceBoundary pins the reuse-grace boundary (LOW-2,
|
||||
// reduced 60s → 20s): a used-token replay just INSIDE the grace window is a
|
||||
// benign concurrent refresh — the family survives — while a replay just past
|
||||
// the window is genuine theft and revokes the ENTIRE rotation family. The
|
||||
// exact boundary is refreshTokenReuseGraceSecs (derived from
|
||||
// refreshTokenReuseGrace), so this test holds the reduced value honest.
|
||||
func TestVerifyRefreshToken_GraceBoundary(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
require.NoError(t, err)
|
||||
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, familyID)
|
||||
_, err = GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
backdate := func(secs int64) {
|
||||
t.Helper()
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE refresh_tokens
|
||||
SET used_at = NOW() - make_interval(secs => $2)
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
`, original, secs)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Replay just INSIDE the grace window (grace - 1s) → benign: the family
|
||||
// (used original + descendant) survives and no theft alert is raised.
|
||||
backdate(refreshTokenReuseGraceSecs - 1)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||
require.Error(t, err, "a within-grace replay must still return the generic error")
|
||||
require.Contains(t, err.Error(), "invalid or expired")
|
||||
var famCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount))
|
||||
require.Equal(t, 2, famCount, "a within-grace replay must NOT kill the rotation family")
|
||||
|
||||
// Replay just OUTSIDE the grace window (grace + 1s) → theft: the ENTIRE
|
||||
// family is revoked (descendant included) and a critical alert is raised.
|
||||
backdate(refreshTokenReuseGraceSecs + 1)
|
||||
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||
require.Error(t, err, "a post-grace replay must return the generic error")
|
||||
require.Contains(t, err.Error(), "invalid or expired")
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount))
|
||||
require.Equal(t, 0, famCount, "a post-grace replay must revoke the ENTIRE rotation family")
|
||||
}
|
||||
|
||||
// TestAccessTokenKilledWithRotationFamily verifies the HIGH 1 fix: an access
|
||||
// token minted at rotation is bound (family_id claim) to the rotation family,
|
||||
// so when reuse detection DELETEs the family the access token — which the
|
||||
// attacker was handed at rotation — stops verifying immediately instead of
|
||||
// staying valid for its 1-hour TTL.
|
||||
func TestAccessTokenKilledWithRotationFamily(t *testing.T) {
|
||||
ctx, tx := testtx.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||
}
|
||||
|
||||
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
||||
if err != nil {
|
||||
t.Fatalf("first verification should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
// The rotation response: a descendant refresh token in the same family and
|
||||
// an access token minted in that SAME family (as RefreshTokenHandler does).
|
||||
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
|
||||
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
||||
}
|
||||
attackerToken, attackerJTI, err := GenerateTokenForFamily(userID, "verified_email", familyID)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTokenForFamily() failed: %v", err)
|
||||
}
|
||||
if attackerJTI == "" {
|
||||
t.Fatal("expected non-empty JTI")
|
||||
}
|
||||
|
||||
// While its family is alive the access token verifies (JTI not revoked).
|
||||
if _, _, _, err := VerifyToken(attackerToken, ctx); err != nil {
|
||||
t.Fatalf("access token must verify while its rotation family is alive: %v", err)
|
||||
}
|
||||
|
||||
// Backdate used_at past the grace window, then replay the original — theft
|
||||
// detected, whole family DELETEd.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE refresh_tokens
|
||||
SET used_at = NOW() - make_interval(secs => 120)
|
||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||
`, original); err != nil {
|
||||
t.Fatalf("failed to backdate used_at: %v", err)
|
||||
}
|
||||
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||
if err == nil {
|
||||
t.Fatal("expected theft detection on replayed token, got nil")
|
||||
}
|
||||
|
||||
var famCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
|
||||
t.Fatalf("failed to count family rows: %v", err)
|
||||
}
|
||||
if famCount != 0 {
|
||||
t.Fatalf("expected 0 refresh tokens in family after reuse, got %d", famCount)
|
||||
}
|
||||
|
||||
// The attacker's access token is now dead even though its JTI was never
|
||||
// revoked — the family-alive check rejects it (HIGH 1).
|
||||
if _, _, _, err := VerifyToken(attackerToken, ctx); err == nil {
|
||||
t.Fatal("access token must be rejected once its rotation family is killed")
|
||||
} else if !strings.Contains(err.Error(), "token revoked") {
|
||||
t.Fatalf("expected 'token revoked' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake
|
||||
// token string and expects it to fail with "invalid or expired".
|
||||
func TestVerifyRefreshToken_InvalidToken(t *testing.T) {
|
||||
ctx, _ := testtx.SetupTestTx(t)
|
||||
|
||||
_, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
|
||||
_, _, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token, got nil")
|
||||
}
|
||||
@@ -491,8 +796,10 @@ func TestRevokeJTI_NilConn(t *testing.T) {
|
||||
db.Conn = nil
|
||||
t.Cleanup(func() { db.Conn = savedConn })
|
||||
|
||||
// Should not panic when db.Conn is nil
|
||||
RevokeJTI(context.Background(), "test-jti", time.Now())
|
||||
// Should return an error when db.Conn is nil
|
||||
if err := RevokeJTI(context.Background(), "test-jti", time.Now()); err == nil {
|
||||
t.Error("expected error when db.Conn is nil, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsJTIRevoked_NilConn verifies that IsJTIRevoked returns false when db.Conn is nil.
|
||||
@@ -615,3 +922,63 @@ func TestVerifyToken_WrongRoleType(t *testing.T) {
|
||||
t.Errorf("expected 'invalid role claim' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidateFamilyAliveByUser pins LOW 5: dropping the cached family-alive
|
||||
// verdicts for a USER (password change / GDPR erasure) removes every family of
|
||||
// that user while leaving other users' families untouched.
|
||||
func TestInvalidateFamilyAliveByUser(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
familyAliveCache.mu.Lock()
|
||||
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
|
||||
familyAliveCache.mu.Unlock()
|
||||
})
|
||||
|
||||
familyAliveStore("family-a|user-1", true)
|
||||
familyAliveStore("family-b|user-1", true)
|
||||
familyAliveStore("family-c|user-2", true)
|
||||
|
||||
InvalidateFamilyAliveByUser("user-1")
|
||||
|
||||
_, okA := familyAliveLookup("family-a|user-1")
|
||||
_, okB := familyAliveLookup("family-b|user-1")
|
||||
_, okC := familyAliveLookup("family-c|user-2")
|
||||
require.False(t, okA, "user-1's family-a verdict must be dropped")
|
||||
require.False(t, okB, "user-1's family-b verdict must be dropped")
|
||||
require.True(t, okC, "user-2's family-c verdict must survive")
|
||||
|
||||
// An empty user id is a no-op, never a panic.
|
||||
InvalidateFamilyAliveByUser("")
|
||||
}
|
||||
|
||||
// TestInvalidateFamilyAliveBatch pins the LOW finding-3 contract: the batch
|
||||
// form drops every cached verdict for the affected families (used by the
|
||||
// scheduled cleanup when expired refresh tokens kill whole families) while
|
||||
// leaving unrelated families untouched. Empty and blank ids are no-ops.
|
||||
func TestInvalidateFamilyAliveBatch(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
familyAliveCache.mu.Lock()
|
||||
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
|
||||
familyAliveCache.mu.Unlock()
|
||||
})
|
||||
|
||||
familyAliveStore("family-a|user-1", true)
|
||||
familyAliveStore("family-b|user-1", true)
|
||||
familyAliveStore("family-c|user-2", true)
|
||||
familyAliveStore("family-a|user-2", true)
|
||||
|
||||
InvalidateFamilyAliveBatch([]string{"family-a", "family-b"})
|
||||
|
||||
_, okA1 := familyAliveLookup("family-a|user-1")
|
||||
_, okB1 := familyAliveLookup("family-b|user-1")
|
||||
_, okA2 := familyAliveLookup("family-a|user-2")
|
||||
_, okC2 := familyAliveLookup("family-c|user-2")
|
||||
require.False(t, okA1, "family-a's verdict for user-1 must be dropped")
|
||||
require.False(t, okB1, "family-b's verdict for user-1 must be dropped")
|
||||
require.False(t, okA2, "family-a's verdict for user-2 must be dropped")
|
||||
require.True(t, okC2, "family-c's verdict must survive")
|
||||
|
||||
// Empty, blank and nil inputs are no-ops, never a panic.
|
||||
InvalidateFamilyAliveBatch(nil)
|
||||
InvalidateFamilyAliveBatch([]string{""})
|
||||
InvalidateFamilyAlive("")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,77 @@ func TestNow_ReturnsUTC(t *testing.T) {
|
||||
if now.Location() != time.UTC {
|
||||
t.Errorf("clock.Now() returned time in %v, expected UTC", now.Location())
|
||||
}
|
||||
// UTC is a fixed-offset zone: the zone abbreviation and offset must both be
|
||||
// the canonical UTC values so SQL TIMESTAMPTZ comparisons and duration math
|
||||
// never drift.
|
||||
name, off := now.Zone()
|
||||
if name != "UTC" || off != 0 {
|
||||
t.Errorf("clock.Now() returned zone %q offset %d, expected UTC offset 0", name, off)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLondon_Location_IsEuropeLondon proves the single London location used for
|
||||
// wall-clock business decisions is the IANA Europe/London zone (which carries
|
||||
// the full DST rule table for GMT<->BST).
|
||||
func TestLondon_Location_IsEuropeLondon(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := London.String(); got != "Europe/London" {
|
||||
t.Errorf("clock.London.String() = %q, expected %q", got, "Europe/London")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLondon_DSTTransitions pins the two 2026 Europe/London transitions:
|
||||
//
|
||||
// Spring forward (2026-03-29 01:00 GMT -> 02:00 BST):
|
||||
// - 2026-03-29 00:30 UTC = 01:30 GMT, offset +0 (still winter time)
|
||||
// - 2026-03-29 01:00 UTC = 02:00 BST, offset +1 (the transition instant)
|
||||
// Fall back (2026-10-25 02:00 BST -> 01:00 GMT):
|
||||
// - 2026-10-25 00:30 UTC = 01:30 BST, offset +1 (still summer time)
|
||||
// - 2026-10-25 01:00 UTC = 01:00 GMT, offset +0 (the ambiguous hour, which
|
||||
// Go resolves to the SECOND occurrence)
|
||||
//
|
||||
// These instants are the boundary of every London wall-clock decision the app
|
||||
// makes (closing hours, default-hours effective dates, today summaries), so
|
||||
// clock.London must get them exactly right.
|
||||
func TestLondon_DSTTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
utc time.Time
|
||||
wantWall string // London wall-clock, "2006-01-02 15:04"
|
||||
wantZone string // "GMT" or "BST"
|
||||
wantOffsetH int
|
||||
}{
|
||||
{"spring before", time.Date(2026, 3, 29, 0, 30, 0, 0, time.UTC), "2026-03-29 00:30", "GMT", 0},
|
||||
{"spring transition", time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC), "2026-03-29 02:00", "BST", 1},
|
||||
{"autumn before", time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC), "2026-10-25 01:30", "BST", 1},
|
||||
{"autumn ambiguous->GMT", time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC), "2026-10-25 01:00", "GMT", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
l := tc.utc.In(London)
|
||||
if got := l.Format("2006-01-02 15:04"); got != tc.wantWall {
|
||||
t.Errorf("%s UTC in London = %q, expected wall-clock %q", tc.utc.Format(time.RFC3339), got, tc.wantWall)
|
||||
}
|
||||
zone, off := l.Zone()
|
||||
if zone != tc.wantZone {
|
||||
t.Errorf("%s UTC in London zone = %q, expected %q", tc.utc.Format(time.RFC3339), zone, tc.wantZone)
|
||||
}
|
||||
if off != tc.wantOffsetH*3600 {
|
||||
t.Errorf("%s UTC in London offset = %ds, expected %dh", tc.utc.Format(time.RFC3339), off, tc.wantOffsetH)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sanity: the two wall-clock times on the autumn transition day share the
|
||||
// same 01:xx wall hour (the ambiguous hour) but at different UTC instants —
|
||||
// proving Go resolves the fold to the second occurrence when converting the
|
||||
// UTC instant back to London.
|
||||
fold1 := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC).In(London).Format("15:04") // 01:30 BST
|
||||
fold2 := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC).In(London).Format("15:04") // 01:30 GMT
|
||||
if fold1 != "01:30" || fold2 != "01:30" {
|
||||
t.Errorf("expected both sides of the autumn fold to show 01:30, got %q and %q", fold1, fold2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNow_IsReasonable(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -27,6 +30,27 @@ func Connect() error {
|
||||
return err
|
||||
}
|
||||
poolCfg.ConnConfig.RuntimeParams["timezone"] = "UTC"
|
||||
// Scan TIMESTAMPTZ into time.Time in UTC, never the host's local timezone.
|
||||
// Without this, pgx scans into time.Local, so the JSON offset in API
|
||||
// responses silently depends on the deployment host's TZ (e.g. +01:00 on a
|
||||
// London host, Z on a UTC Docker host) — the instant is the same but the
|
||||
// documented "backend emits UTC" invariant would be violated.
|
||||
// The codec is registered as a NEW *Type rather than mutating the Type
|
||||
// returned by TypeForOID: every connection's type map shares the same
|
||||
// *Type pointers with the package default map, so mutating .Codec on the
|
||||
// shared Type would be a data race when concurrent connections establish.
|
||||
poolCfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error {
|
||||
tzType, ok := conn.TypeMap().TypeForOID(pgtype.TimestamptzOID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
conn.TypeMap().RegisterType(&pgtype.Type{
|
||||
Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
|
||||
Name: tzType.Name,
|
||||
OID: tzType.OID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -27,6 +30,27 @@ func Connect() error {
|
||||
return err
|
||||
}
|
||||
poolCfg.ConnConfig.RuntimeParams["timezone"] = "UTC"
|
||||
// Scan TIMESTAMPTZ into time.Time in UTC, never the host's local timezone.
|
||||
// Without this, pgx scans into time.Local, so the JSON offset in API
|
||||
// responses silently depends on the deployment host's TZ (e.g. +01:00 on a
|
||||
// London host, Z on a UTC Docker host) — the instant is the same but the
|
||||
// documented "backend emits UTC" invariant would be violated.
|
||||
// The codec is registered as a NEW *Type rather than mutating the Type
|
||||
// returned by TypeForOID: every connection's type map shares the same
|
||||
// *Type pointers with the package default map, so mutating .Codec on the
|
||||
// shared Type would be a data race when concurrent connections establish.
|
||||
poolCfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error {
|
||||
tzType, ok := conn.TypeMap().TypeForOID(pgtype.TimestamptzOID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
conn.TypeMap().RegisterType(&pgtype.Type{
|
||||
Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
|
||||
Name: tzType.Name,
|
||||
OID: tzType.OID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetEnv() {
|
||||
@@ -71,6 +72,42 @@ func TestConnect_PingViaTestDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanLocation_UTC proves the AfterConnect hook: TIMESTAMPTZ values are
|
||||
// scanned into time.Time in UTC, never the host's local timezone. Without the
|
||||
// hook, pgx scans into time.Local, so a London-host dev server would emit
|
||||
// +01:00 JSON offsets while a UTC Docker host emits Z — same instant, but the
|
||||
// documented "backend emits UTC" invariant would silently depend on the
|
||||
// deployment host's TZ.
|
||||
func TestScanLocation_UTC(t *testing.T) {
|
||||
closePool()
|
||||
resetEnv()
|
||||
|
||||
err := Connect()
|
||||
if err != nil {
|
||||
t.Fatalf("Connect() failed: %v", err)
|
||||
}
|
||||
defer closePool()
|
||||
|
||||
poolConn, err := Conn.Acquire(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire failed: %v", err)
|
||||
}
|
||||
defer poolConn.Release()
|
||||
|
||||
// A known instant: 2026-06-15 00:30 BST = 2026-06-14 23:30 UTC.
|
||||
instant := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
var scanned time.Time
|
||||
if err := poolConn.QueryRow(context.Background(), "SELECT $1::timestamptz", instant).Scan(&scanned); err != nil {
|
||||
t.Fatalf("timestamptz scan failed: %v", err)
|
||||
}
|
||||
if !scanned.Equal(instant) {
|
||||
t.Errorf("scanned instant = %s, expected %s", scanned.Format(time.RFC3339Nano), instant.Format(time.RFC3339Nano))
|
||||
}
|
||||
if scanned.Location() != time.UTC {
|
||||
t.Errorf("scanned time.Time location = %v, expected time.UTC (host TZ is %v)", scanned.Location(), time.Local)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Connection failure scenarios
|
||||
// =============================================================================
|
||||
|
||||
@@ -30,4 +30,7 @@ func init() {
|
||||
if os.Getenv("GO_TESTING") == "" {
|
||||
os.Setenv("GO_TESTING", "1")
|
||||
}
|
||||
if os.Getenv("DAV_SKIP_INIT") == "" {
|
||||
os.Setenv("DAV_SKIP_INIT", "1")
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -7,13 +7,14 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0
|
||||
github.com/aws/smithy-go v1.27.2
|
||||
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/kovidgoyal/imaging v1.8.21
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/text v0.38.0
|
||||
golang.org/x/text v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -30,7 +31,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||
github.com/aws/smithy-go v1.27.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
@@ -54,8 +54,8 @@ require (
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
golang.org/x/image v0.43.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -70,5 +70,5 @@ require (
|
||||
github.com/nyaruka/phonenumbers v1.8.0
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
+8
-8
@@ -126,14 +126,14 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -104,7 +105,9 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
|
||||
if services == nil {
|
||||
services = []CustomService{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(services)
|
||||
if err := json.NewEncoder(w).Encode(services); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,13 +170,15 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
|
||||
// so pgx does not return "conn busy" on the same transaction.
|
||||
if q != "" {
|
||||
var countTotal int64
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1", "%"+q+"%").Scan(&countTotal)
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1", "%"+q+"%").Scan(&countTotal); err != nil {
|
||||
log.Printf("Failed to scan filtered custom services count: %v", err)
|
||||
}
|
||||
total = countTotal
|
||||
} else {
|
||||
var countTotal int64
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services").Scan(&countTotal)
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services").Scan(&countTotal); err != nil {
|
||||
log.Printf("Failed to scan custom services count: %v", err)
|
||||
}
|
||||
total = countTotal
|
||||
}
|
||||
|
||||
@@ -189,22 +194,25 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
|
||||
if services == nil {
|
||||
services = []CustomService{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(CustomServiceListResponse{
|
||||
if err := json.NewEncoder(w).Encode(CustomServiceListResponse{
|
||||
Services: services,
|
||||
Total: total,
|
||||
PerPage: perPage,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateCustomServiceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -262,7 +270,9 @@ func CreateCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(cs)
|
||||
if err := json.NewEncoder(w).Encode(cs); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func GetCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -302,7 +312,9 @@ func GetCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
cs.LastUsedAt = &lastUsedAt.Time
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(cs)
|
||||
if err := json.NewEncoder(w).Encode(cs); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -314,11 +326,12 @@ func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req UpdateCustomServiceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -382,10 +395,10 @@ func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := tx.Exec(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
@@ -402,7 +415,9 @@ func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Custom service updated"})
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Custom service updated"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func PromoteCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -418,10 +433,10 @@ func PromoteCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var name, desc, notes sql.NullString
|
||||
var price float64
|
||||
@@ -491,11 +506,13 @@ func PromoteCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Custom service promoted to regular service",
|
||||
"new_service_id": newServiceID,
|
||||
"custom_service_id": id,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -526,10 +543,10 @@ func DeleteCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := tx.Exec(r.Context(), `DELETE FROM custom_services WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
@@ -546,7 +563,9 @@ func DeleteCustomService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Custom service deleted"})
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Custom service deleted"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func joinStrings(strs []string, sep string) string {
|
||||
|
||||
@@ -211,12 +211,13 @@ func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateCampaignRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -281,10 +282,10 @@ func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Insert new campaign
|
||||
query := `
|
||||
@@ -416,12 +417,13 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req UpdateCampaignRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -518,10 +520,10 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
@@ -646,10 +648,10 @@ func DeleteDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := tx.Exec(r.Context(), query, campaignID)
|
||||
if err != nil {
|
||||
@@ -669,10 +671,12 @@ func DeleteDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"message": "Campaign deleted successfully",
|
||||
"id": campaignID,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCampaignStats handles GET /api/admin/discount-campaigns/{id}/stats
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"crussell/internal/validators"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// PatchTest represents a patch test definition
|
||||
@@ -74,7 +77,9 @@ func GetPatchTests(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(patchTests)
|
||||
if err := json.NewEncoder(w).Encode(patchTests); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePatchTest handles POST /api/admin/patch-tests
|
||||
@@ -86,7 +91,8 @@ func CreatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,10 +108,10 @@ func CreatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var id string
|
||||
err = tx.QueryRow(r.Context(), query, req.Name, req.Description, req.NoticeDurationHours, req.ExpiryMonths, req.ServiceIDs).Scan(&id)
|
||||
@@ -120,7 +126,9 @@ func CreatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"id": id}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePatchTest handles PUT /api/admin/patch-tests/{id}
|
||||
@@ -138,7 +146,8 @@ func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,10 +191,10 @@ func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
@@ -215,10 +224,10 @@ func DeletePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(r.Context(), "DELETE FROM patch_tests WHERE id = $1", id)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"crussell/db"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type BusinessSettings struct {
|
||||
@@ -69,7 +72,9 @@ func GetPublicBusinessInfo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(info)
|
||||
if err := json.NewEncoder(w).Encode(info); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func GetBusinessSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -91,7 +96,9 @@ func GetBusinessSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(s)
|
||||
if err := json.NewEncoder(w).Encode(s); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateBusinessSettingsRequest struct {
|
||||
@@ -162,8 +169,12 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "voucher_type must be 'SPV' or 'MPV'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.GiftCardExpiryMonths != nil && *req.GiftCardExpiryMonths < 1 {
|
||||
http.Error(w, "gift_card_expiry_months must be at least 1", http.StatusBadRequest)
|
||||
if req.GiftCardExpiryMonths != nil && *req.GiftCardExpiryMonths < 12 {
|
||||
// Legal floor, not arbitrary: UK Consumer Rights Act 2015 requires
|
||||
// expiry terms to be "fair and transparent", and CMA guidance flags
|
||||
// sub-12-month expiry windows as at risk of being an unfair contract
|
||||
// term. 24 months is the documented default (matches John Lewis, M&S).
|
||||
http.Error(w, "gift_card_expiry_months must be at least 12 (CMA guidance flags sub-12-month expiry as an unfair contract term; 24 is recommended)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.DefaultVATRate != nil && (*req.DefaultVATRate < 0 || *req.DefaultVATRate > 100) {
|
||||
@@ -246,10 +257,10 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(r.Context(), query.String(), args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -725,6 +725,65 @@ func TestUpdateBusinessSettings_VoucherType_SPV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBusinessSettings_Expiry_RejectsSub12 rejects gift_card_expiry_months
|
||||
// below the legal floor. The setting now actually drives gift-card expiry (the
|
||||
// cleanup job + every expiry_date write), so a sub-12-month window — flagged by
|
||||
// CMA guidance as an unfair contract term under the Consumer Rights Act 2015 —
|
||||
// must not be settable.
|
||||
func TestUpdateBusinessSettings_Expiry_RejectsSub12(t *testing.T) {
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
|
||||
handler := http.HandlerFunc(UpdateBusinessSettings)
|
||||
body := UpdateBusinessSettingsRequest{
|
||||
GiftCardExpiryMonths: intPtr(6),
|
||||
}
|
||||
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400 for 6-month expiry, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBusinessSettings_Expiry_RejectsZero rejects gift_card_expiry_months
|
||||
// of 0 (previously the only invalid value, now the legal floor subsumes it).
|
||||
func TestUpdateBusinessSettings_Expiry_RejectsZero(t *testing.T) {
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
|
||||
handler := http.HandlerFunc(UpdateBusinessSettings)
|
||||
body := UpdateBusinessSettingsRequest{
|
||||
GiftCardExpiryMonths: intPtr(0),
|
||||
}
|
||||
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400 for 0-month expiry, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBusinessSettings_Expiry_Accepts24 verifies a 24-month window (the
|
||||
// documented default) is accepted and persisted.
|
||||
func TestUpdateBusinessSettings_Expiry_Accepts24(t *testing.T) {
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
|
||||
handler := http.HandlerFunc(UpdateBusinessSettings)
|
||||
body := UpdateBusinessSettingsRequest{
|
||||
GiftCardExpiryMonths: intPtr(24),
|
||||
}
|
||||
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200 for 24-month expiry, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var s BusinessSettings
|
||||
if err := parseResponseBody(w, &s); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if s.GiftCardExpiryMonths != 24 {
|
||||
t.Errorf("expected GiftCardExpiryMonths 24, got %d", s.GiftCardExpiryMonths)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBusinessSettings_VoucherType_MPV accepts MPV.
|
||||
func TestUpdateBusinessSettings_VoucherType_MPV(t *testing.T) {
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
@@ -764,7 +823,7 @@ func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) {
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != "gift_card_expiry_months must be at least 1\n" {
|
||||
if !strings.Contains(w.Body.String(), "must be at least 12") {
|
||||
t.Errorf("unexpected error message: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -782,8 +841,8 @@ func TestUpdateBusinessSettings_ExpiryMonths_Negative(t *testing.T) {
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400 for negative expiry, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != "gift_card_expiry_months must be at least 1\n" {
|
||||
t.Errorf("unexpected error message: %s", w.Body.String())
|
||||
if !strings.Contains(w.Body.String(), "must be at least 12") {
|
||||
t.Errorf("expected legal-floor error message, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -848,13 +848,17 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) {
|
||||
}
|
||||
|
||||
// Now seed EXCEPTIONAL hours making today CLOSED.
|
||||
// Need: group → hours → application with week_start = Monday of this week
|
||||
weekday := now.Weekday()
|
||||
// Need: group → hours → application with week_start = Monday of this week.
|
||||
// The Monday must be computed in LONDON time (like the handler's isDayOpen),
|
||||
// not UTC — around the 23:00-00:00 UTC boundary UTC and London are on
|
||||
// different days, and a UTC-derived week_start would not overlap the
|
||||
// handler's London date, silently leaving today "open".
|
||||
weekday := londonNow.Weekday()
|
||||
daysSinceMonday := int(weekday) - 1
|
||||
if daysSinceMonday < 0 {
|
||||
daysSinceMonday = 6
|
||||
}
|
||||
monday := now.AddDate(0, 0, -daysSinceMonday)
|
||||
monday := londonNow.AddDate(0, 0, -daysSinceMonday)
|
||||
mondayStr := monday.Format("2006-01-02")
|
||||
|
||||
var groupID int
|
||||
|
||||
+680
-110
File diff suppressed because it is too large
Load Diff
+631
-100
@@ -6,9 +6,12 @@ import (
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/dav"
|
||||
"crussell/internal/twofa"
|
||||
"crussell/internal/validators"
|
||||
"crussell/internal/zxcvbnjs"
|
||||
"crussell/mw"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -32,28 +35,231 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// maxLoginInProgress caps the loginInProgress map (Round 2 Loop A finding 5):
|
||||
// at most this many logins may be mid-flight at once before the next is
|
||||
// rejected 429. Stale entries are evicted before the cap is consulted (see
|
||||
// evictStaleLoginEntriesLocked), so a single attacker holding N fake entries
|
||||
// cannot permanently exhaust the global budget — only genuinely concurrent
|
||||
// logins occupy it, and each entry self-releases via the handler's deferred
|
||||
// delete.
|
||||
const maxLoginInProgress = 20
|
||||
|
||||
// loginInProgressWindow is how long a loginInProgress entry is considered
|
||||
// live before it is stale and evictable.
|
||||
const loginInProgressWindow = 30 * time.Second
|
||||
|
||||
// maxConcurrentBcrypt bounds how many bcrypt operations may run concurrently
|
||||
// across BOTH /login and /register (Round 2 Loop A finding 4b + Round 2 Loop B
|
||||
// finding 4). The progressive per-IP middleware sleeps BEFORE this handler, so
|
||||
// without the cap a flood of throttled requests could stack an unbounded number
|
||||
// of goroutines that all hit bcrypt the moment their sleeps elapse — a
|
||||
// CPU-amplification vector (a register-botnet also burns CPU on
|
||||
// bcrypt.GenerateFromPassword). Beyond the cap the request is rejected 429
|
||||
// immediately (nothing has been processed, so nothing leaks).
|
||||
//
|
||||
// Round 2 Loop B finding 5b — ACCEPTED BOUNDED-DoS TRADE-OFF: the 20-slot
|
||||
// global bound is shared by login AND register AND, by extension, every
|
||||
// authenticated user. A sustained flood at either endpoint can therefore
|
||||
// starve bcrypt for everyone for up to one request at a time (429 "server
|
||||
// busy"). That is the intended trade-off: 20 genuinely concurrent bcrypt
|
||||
// operations (~20 × ~60ms ≈ 1.2s of wall time) is far more than a single
|
||||
// salon ever produces, and bounding the CPU is the point of the cap.
|
||||
const maxConcurrentBcrypt = 20
|
||||
|
||||
// Login state management
|
||||
var (
|
||||
loginStateMu sync.Mutex
|
||||
loginInProgress = make(map[string]time.Time)
|
||||
// authBcryptSlots is the counting semaphore backing maxConcurrentBcrypt,
|
||||
// shared by LoginHandler and RegisterHandler.
|
||||
authBcryptSlots = make(chan struct{}, maxConcurrentBcrypt)
|
||||
)
|
||||
|
||||
// CleanupStaleLoginEntries removes stuck loginInProgress entries older than 30 seconds.
|
||||
// Called by the centralised jobs scheduler.
|
||||
func CleanupStaleLoginEntries(ctx context.Context) (int, error) {
|
||||
loginStateMu.Lock()
|
||||
defer loginStateMu.Unlock()
|
||||
now := clock.Now()
|
||||
// acquireBcryptSlot tries to reserve a concurrent bcrypt slot. ok=false
|
||||
// means the handler must respond 429. Shared by login and register so the
|
||||
// bcrypt CPU budget is global, not per-endpoint.
|
||||
func acquireBcryptSlot() (release func(), ok bool) {
|
||||
select {
|
||||
case authBcryptSlots <- struct{}{}:
|
||||
return func() { <-authBcryptSlots }, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// evictStaleLoginEntriesLocked removes loginInProgress entries older than
|
||||
// loginInProgressWindow. Caller must hold loginStateMu.
|
||||
func evictStaleLoginEntriesLocked(now time.Time) {
|
||||
for userID, startedAt := range loginInProgress {
|
||||
if now.Sub(startedAt) > 30*time.Second {
|
||||
if now.Sub(startedAt) > loginInProgressWindow {
|
||||
delete(loginInProgress, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupStaleLoginEntries removes stuck loginInProgress entries older than
|
||||
// loginInProgressWindow. Called by the centralised jobs scheduler.
|
||||
func CleanupStaleLoginEntries(ctx context.Context) (int, error) {
|
||||
loginStateMu.Lock()
|
||||
defer loginStateMu.Unlock()
|
||||
evictStaleLoginEntriesLocked(clock.Now())
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// dummyPasswordHash is a real bcrypt hash of a fixed throwaway string, used to
|
||||
// burn the same constant-time bcrypt work on the login no-user path as a real
|
||||
// wrong-password compare (see LoginHandler). It MUST be a well-formed bcrypt
|
||||
// hash: CompareHashAndPassword on a malformed hash returns immediately (fast),
|
||||
// which would reintroduce the timing oracle it exists to remove.
|
||||
const dummyPasswordHash = "$2a$10$x9x4AEOAU.UbaGCmVsVwu.TUhfOfR2LfbmWjB/H2At8Sx69WIlkri"
|
||||
|
||||
// Verification-code purpose values (verification_purpose enum in init-script.sql).
|
||||
const (
|
||||
verificationCodePurposeEmailVerify = "email_verify"
|
||||
verificationCodePurposePasswordReset = "password_reset"
|
||||
)
|
||||
|
||||
// verificationCodePepperEnv is the environment variable whose value keys the
|
||||
// HMAC-SHA256 of stored verification codes (the SAME pepper the 2FA path uses —
|
||||
// crussell/internal/twofa Hash). Read through the build-tagged
|
||||
// verificationCodeEnsureIssueAllowed (verifycode_dev.go / verifycode_prod.go):
|
||||
// dev/test builds fall back to the legacy plain SHA-256 digest with the 2FA
|
||||
// warning, while production builds refuse to issue codes without the pepper.
|
||||
const verificationCodePepperEnv = "TWO_FACTOR_PEPPER"
|
||||
|
||||
// Email-verification attempt budget (Round 2 Loop A finding 8 + hardening):
|
||||
// POST /verify/check had no per-user attempt counter, so a client holding a
|
||||
// code could fail it indefinitely and the endpoint doubled as an unbounded
|
||||
// guessing oracle. Mirror the 2FA attempt pattern: an in-memory map keys a
|
||||
// 5-attempt budget. The key is the RESOLVED USER id whenever a submitted code
|
||||
// matches a verification_codes row (a code belongs to exactly one user, so the
|
||||
// budget follows the ACCOUNT being attacked, not the submitted code value) and
|
||||
// the submitted code value only when no row exists to resolve a user (a pure
|
||||
// guess cannot be attributed). Keying per-user closes the evasion where an
|
||||
// attacker holding several codes for one victim (or probing which values are
|
||||
// live) drained a fresh budget per code. A successful verify clears the entry;
|
||||
// the 5th failed attempt exhausts the budget (429). The map is bounded and
|
||||
// stale entries are evicted, so a flood of random guesses cannot grow it
|
||||
// without bound.
|
||||
const (
|
||||
emailVerifyMaxAttempts = 5
|
||||
emailVerifyAttemptWindow = 30 * time.Minute
|
||||
emailVerifyMaxTrackedCodes = 10_000
|
||||
)
|
||||
|
||||
type emailVerifyAttempt struct {
|
||||
count int
|
||||
lastAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
emailVerifyMu sync.Mutex
|
||||
emailVerifyAttempts = make(map[string]emailVerifyAttempt)
|
||||
ipVerifyMu sync.Mutex
|
||||
ipVerifyAttempts = make(map[string]emailVerifyAttempt)
|
||||
)
|
||||
|
||||
// ipAttemptsExhausted reports whether the IP's attempt budget is already
|
||||
// spent, rejecting the request before any DB work. Uses the same window as
|
||||
// email-verify tracking with a higher cap (50 per IP per window) as a
|
||||
// secondary per-IP fallback budget.
|
||||
func ipAttemptsExhausted(ip string) bool {
|
||||
ipVerifyMu.Lock()
|
||||
defer ipVerifyMu.Unlock()
|
||||
evictStaleIPVerifyAttemptsLocked()
|
||||
a, ok := ipVerifyAttempts[ip]
|
||||
return ok && a.count >= 50
|
||||
}
|
||||
|
||||
// ipAttemptFailed registers one failed attempt for the IP and reports whether
|
||||
// the budget for that IP is now exhausted.
|
||||
func ipAttemptFailed(ip string) bool {
|
||||
ipVerifyMu.Lock()
|
||||
defer ipVerifyMu.Unlock()
|
||||
evictStaleIPVerifyAttemptsLocked()
|
||||
now := clock.Now()
|
||||
a := ipVerifyAttempts[ip]
|
||||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||||
a.count = 0
|
||||
}
|
||||
a.count++
|
||||
a.lastAt = now
|
||||
ipVerifyAttempts[ip] = a
|
||||
return a.count >= 50
|
||||
}
|
||||
|
||||
// ipAttemptsClear drops the budget for an IP after a successful verify.
|
||||
func ipAttemptsClear(ip string) {
|
||||
ipVerifyMu.Lock()
|
||||
delete(ipVerifyAttempts, ip)
|
||||
ipVerifyMu.Unlock()
|
||||
}
|
||||
|
||||
// evictStaleIPVerifyAttemptsLocked bounds the IP attempts map. Caller must
|
||||
// hold ipVerifyMu.
|
||||
func evictStaleIPVerifyAttemptsLocked() {
|
||||
if len(ipVerifyAttempts) < emailVerifyMaxTrackedCodes {
|
||||
return
|
||||
}
|
||||
now := clock.Now()
|
||||
for k, a := range ipVerifyAttempts {
|
||||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||||
delete(ipVerifyAttempts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emailVerifyAttemptsExhausted reports whether the key's (a user id, or a
|
||||
// submitted code with no resolvable user) attempt budget is already spent,
|
||||
// rejecting the request before any DB work.
|
||||
func emailVerifyAttemptsExhausted(key string) bool {
|
||||
emailVerifyMu.Lock()
|
||||
defer emailVerifyMu.Unlock()
|
||||
evictStaleEmailVerifyAttemptsLocked()
|
||||
a, ok := emailVerifyAttempts[key]
|
||||
return ok && a.count >= emailVerifyMaxAttempts
|
||||
}
|
||||
|
||||
// emailVerifyAttemptFailed registers one failed verification attempt for the
|
||||
// key and reports whether the budget for that key is now exhausted (the handler
|
||||
// should respond 429).
|
||||
func emailVerifyAttemptFailed(key string) bool {
|
||||
emailVerifyMu.Lock()
|
||||
defer emailVerifyMu.Unlock()
|
||||
evictStaleEmailVerifyAttemptsLocked()
|
||||
now := clock.Now()
|
||||
a := emailVerifyAttempts[key]
|
||||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||||
a.count = 0
|
||||
}
|
||||
a.count++
|
||||
a.lastAt = now
|
||||
emailVerifyAttempts[key] = a
|
||||
return a.count >= emailVerifyMaxAttempts
|
||||
}
|
||||
|
||||
// emailVerifyAttemptsClear drops the budget for a key after a successful
|
||||
// verify (the code is consumed; the entry would only leak stale state).
|
||||
func emailVerifyAttemptsClear(key string) {
|
||||
emailVerifyMu.Lock()
|
||||
delete(emailVerifyAttempts, key)
|
||||
emailVerifyMu.Unlock()
|
||||
}
|
||||
|
||||
// evictStaleEmailVerifyAttemptsLocked bounds the attempts map. Caller must
|
||||
// hold emailVerifyMu.
|
||||
func evictStaleEmailVerifyAttemptsLocked() {
|
||||
if len(emailVerifyAttempts) < emailVerifyMaxTrackedCodes {
|
||||
return
|
||||
}
|
||||
now := clock.Now()
|
||||
for k, a := range emailVerifyAttempts {
|
||||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||||
delete(emailVerifyAttempts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
FirstName string `json:"firstName" validate:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" validate:"required,min=1,max=50"`
|
||||
@@ -79,13 +285,14 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Must accept terms
|
||||
if !req.AgreedToPolicy {
|
||||
http.Error(w, "must agree to terms", http.StatusBadRequest)
|
||||
mw.RespondError(w, http.StatusBadRequest, "must agree to terms")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,13 +311,28 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Password must not exceed bcrypt's 72-byte limit
|
||||
if len(req.Password) > 72 {
|
||||
http.Error(w, "password must be 72 characters or less", http.StatusBadRequest)
|
||||
mw.RespondError(w, http.StatusBadRequest, "password must be 72 characters or less")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 6 {
|
||||
http.Error(w, "password must be at least 6 characters", http.StatusBadRequest)
|
||||
mw.RespondError(w, http.StatusBadRequest, "password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
// Round 2 Loop B finding 4: /register previously ran the zxcvbn strength
|
||||
// scoring AND bcrypt.GenerateFromPassword with NO concurrency cap — a
|
||||
// register-botnet could stack unbounded goroutines burning CPU, and each
|
||||
// registered account also fuels the notification-flood and attempt-map
|
||||
// findings (1/3). Share the login bcrypt slot budget (acquireBcryptSlot —
|
||||
// the global 20-slot cap, see maxConcurrentBcrypt): beyond it the
|
||||
// registration is rejected 429 immediately. The slot wraps the expensive
|
||||
// part (zxcvbn + bcrypt) and is released via defer on every path.
|
||||
release, ok := acquireBcryptSlot()
|
||||
if !ok {
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Server-side password strength check using the same @zxcvbn-ts/core as the frontend
|
||||
// via goja (ExecJS-style). Guarantees exact parity with frontend scoring.
|
||||
// Skipped when GO_TESTING=1 (dev/test environments) to allow weaker passwords.
|
||||
@@ -146,7 +368,8 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Validate email format
|
||||
if err := validators.ValidateEmail(req.Email); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -220,10 +443,10 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
now := clock.Now()
|
||||
|
||||
@@ -311,12 +534,13 @@ func ValidateUKPhoneNumber(phone string) (string, error) {
|
||||
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
mw.RespondError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Login validation failed: %v", err)
|
||||
mw.RespondError(w, http.StatusBadRequest, "Email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -332,31 +556,69 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
`, req.Email).Scan(&userID, &passwordHash, &role)
|
||||
|
||||
if err != nil {
|
||||
// F2-HIGH (user-existence timing oracle): a non-existent email used to
|
||||
// return before any bcrypt work, so its latency (~1 DB round trip) was
|
||||
// measurably shorter than a wrong-password attempt against an existing
|
||||
// account (~1 DB round trip + ~60ms bcrypt) — an attacker could probe
|
||||
// which emails are registered from response timing. Burn the same
|
||||
// constant-time bcrypt compare a real login would, under the shared
|
||||
// bcrypt slot budget, and discard the result. The dummy hash is a real
|
||||
// bcrypt hash (see dummyPasswordHash) so the compare runs the full cost.
|
||||
if release, ok := acquireBcryptSlot(); ok {
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(req.Password))
|
||||
release()
|
||||
}
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
// Check if account is locked (F5.5): the response MUST be indistinguishable
|
||||
// from a generic invalid-credentials failure — same status, same body, and
|
||||
// the same constant-time bcrypt work — so an attacker can never tell
|
||||
// "locked" from "wrong password". A distinguishable lockout (the old 429
|
||||
// "account is temporarily locked") is an account-existence oracle AND a
|
||||
// lockout-probing signal: an attacker burning 5 wrong passwords to DoS a
|
||||
// victim could then watch the victim's lockout state flip. The lockout
|
||||
// itself is inherent to the 5-attempt progressive policy; hiding the state
|
||||
// is what removes the oracle. The audit line stays server-side only.
|
||||
// A locked-out user's recovery is the backend-only password-reset flow (see
|
||||
// the success-path TODO below) or an operator clearing the columns at the DB.
|
||||
var failedAttempts int
|
||||
var lockedUntil *time.Time
|
||||
err = db.Conn.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
|
||||
if err == nil && lockedUntil != nil && clock.Now().Before(*lockedUntil) {
|
||||
http.Error(w, "account is temporarily locked. try again later.", http.StatusTooManyRequests)
|
||||
// Burn the same constant-time bcrypt compare a real login would, so
|
||||
// the locked path's latency cannot distinguish it either. The compare
|
||||
// result is discarded: a locked account stays locked (and the result
|
||||
// could match if the attacker guessed the password — still no login).
|
||||
if release, ok := acquireBcryptSlot(); ok {
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password))
|
||||
release()
|
||||
}
|
||||
log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context()))
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is already logging in
|
||||
// Per-user in-flight slot (finding 5): the same account cannot have two
|
||||
// concurrent login flows. A re-entry inside the slot window is rejected 429
|
||||
// (not 409 — no conflict with a finished attempt, and a Conflict response
|
||||
// would leak that a login for this account is mid-flight). Stale entries are
|
||||
// evicted before the cap check so a single attacker holding N fake entries
|
||||
// cannot exhaust the global budget: at most maxLoginInProgress genuinely
|
||||
// concurrent logins occupy the map, each self-releasing via the deferred
|
||||
// delete below.
|
||||
loginStateMu.Lock()
|
||||
if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second {
|
||||
evictStaleLoginEntriesLocked(clock.Now())
|
||||
if t, ok := loginInProgress[userID]; ok && clock.Now().Sub(t) < loginInProgressWindow {
|
||||
loginStateMu.Unlock()
|
||||
http.Error(w, "login already in progress", http.StatusConflict) // 409
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "login already in progress")
|
||||
return
|
||||
}
|
||||
// Cap the map size - drop new request if at capacity
|
||||
if len(loginInProgress) >= maxLoginInProgress {
|
||||
loginStateMu.Unlock()
|
||||
http.Error(w, "server busy, try again later", http.StatusTooManyRequests)
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||||
return
|
||||
}
|
||||
loginInProgress[userID] = clock.Now()
|
||||
@@ -369,8 +631,19 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
loginStateMu.Unlock()
|
||||
}()
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||
// Verify password under the concurrency cap (finding 4b). The slot is
|
||||
// released immediately after the compare — bcrypt is the expensive,
|
||||
// amplifier-prone part; the DB work below is cheap. The deferred delete
|
||||
// above releases this user's in-flight slot on every path.
|
||||
release, ok := acquireBcryptSlot()
|
||||
if !ok {
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||||
return
|
||||
}
|
||||
passwordOK := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) == nil
|
||||
release()
|
||||
|
||||
if !passwordOK {
|
||||
// Increment failed attempts in DB with progressive lockout
|
||||
var newFailed int
|
||||
var newLockedUntil *time.Time
|
||||
@@ -381,18 +654,17 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
UPDATE users
|
||||
SET failed_attempts = failed_attempts + 1,
|
||||
locked_until = CASE
|
||||
WHEN failed_attempts + 1 >= 5 THEN NOW() + (CASE
|
||||
WHEN failed_attempts + 1 >= 20 THEN INTERVAL '2 hours'
|
||||
WHEN failed_attempts + 1 >= 10 THEN INTERVAL '1 hour'
|
||||
WHEN failed_attempts + 1 >= 10 THEN INTERVAL '60 minutes'
|
||||
WHEN failed_attempts + 1 >= 7 THEN INTERVAL '30 minutes'
|
||||
ELSE INTERVAL '15 minutes'
|
||||
END)
|
||||
@@ -421,19 +693,37 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// On success, clear lockout and update last_login
|
||||
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
|
||||
// failed_attempts and locked_until — a locked-out user can't call this handler.
|
||||
//
|
||||
// LOW 6 documented gap (finding 6): there is NO password-reset UI — the
|
||||
// backend-only reset flow (GenerateVerificationCodeHandler/
|
||||
// VerifyCodeHandler) has no frontend link, so a user locked out by a
|
||||
// guessing attacker has no self-service recovery until locked_until lapses
|
||||
// (15min at 5+ failures, 30min at 7+, 60min at 10+ — see the failure path
|
||||
// above); the operator can only intervene at the DB. The escalating ceiling
|
||||
// is the repeat-DoS mitigation: an attacker who keeps guessing past each
|
||||
// unlock makes the lock LONGER (up to 60 minutes) instead of merely
|
||||
// sustaining the 15-minute tier, raising the effort-per-DoS ratio while the
|
||||
// response stays the uniform 401 (never distinguishable from a wrong
|
||||
// password). The lockout counter stays keyed per-user (not per-(user,IP))
|
||||
// because this codebase deliberately rejects IP-in-the-key for account-level
|
||||
// budgets (see the 2FA limiter note in main.go, B8): a client that rotates
|
||||
// its source IP would mint a fresh bucket per IP and collapse the per-account
|
||||
// budget. The 60-minute ceiling is the bounded-DoS compromise; a successful
|
||||
// 2FA verify also clears the lockout (internal/twofa.Check), and — since
|
||||
// the HIGH finding wiring — so does a successful password_reset verification
|
||||
// code (VerifyCodeHandler), giving a locked-out user a self-service recovery
|
||||
// path (generate → verify → log in → change password).
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
@@ -448,28 +738,72 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
tokenString, jti, err := auth.GenerateToken(userID, role)
|
||||
// Issue an opaque refresh token (B5): the 90-day credential is stored
|
||||
// hashed in refresh_tokens and rotated on every use. A stolen ACCESS token
|
||||
// can no longer self-renew — only a valid, unexpired, unrevoked refresh
|
||||
// token can mint a new pair. The refresh token is returned in the body so
|
||||
// the SPA can persist it and present it to POST /api/refresh-token. The
|
||||
// access token is bound to the new rotation family (HIGH 1) so that if the
|
||||
// login refresh token is ever replayed the whole family — access token
|
||||
// included — is killed.
|
||||
refreshToken, familyID, err := auth.GenerateRefreshToken(r.Context(), userID, role)
|
||||
if err != nil {
|
||||
log.Printf("failed to issue refresh token for user %s: %v", userID, err)
|
||||
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the access token AFTER the refresh token so it can be bound to
|
||||
// the same rotation family.
|
||||
tokenString, jti, err := auth.GenerateTokenForFamily(userID, role, familyID)
|
||||
if err != nil {
|
||||
log.Printf("failed to generate access token for user %s: %v", userID, err)
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
Token: tokenString,
|
||||
JTI: jti,
|
||||
})
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the login response contract
|
||||
Token: tokenString,
|
||||
JTI: jti,
|
||||
RefreshToken: refreshToken,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/refresh-token (requires auth middleware)
|
||||
// POST /api/refresh-token
|
||||
// Requires a valid refresh token in the Authorization header (Bearer). The
|
||||
// opaque refresh token is validated against the DB (hashed), consumed
|
||||
// (rotated), and exchanged for a fresh access token + a NEW refresh token.
|
||||
//
|
||||
// B5 (security): the handler deliberately does NOT accept the access token.
|
||||
// VerifyRefreshToken rotates (marks used) the presented refresh token, so a
|
||||
// stolen access token can never self-renew — it expires in 1 hour and only a
|
||||
// valid, unexpired, unrevoked refresh token can mint a new pair. A replayed
|
||||
// refresh token (used twice) returns 401, detecting theft via rotation and
|
||||
// revoking the entire rotation family with a critical admin alert.
|
||||
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := mw.GetUserID(r.Context())
|
||||
role, _ := mw.GetUserRole(r.Context())
|
||||
oldJTI, _ := mw.GetJTI(r.Context())
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization header"})
|
||||
return
|
||||
}
|
||||
refreshToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// VerifyRefreshToken consumes (rotates) the refresh token: the used token
|
||||
// is marked used in refresh_tokens, so a stolen/leaked refresh token cannot
|
||||
// be replayed and an access token alone can never mint a new session. A
|
||||
// replayed (already-rotated) token revokes the entire rotation family and
|
||||
// raises a critical admin alert, but still surfaces as this generic 401.
|
||||
userID, role, familyID, err := auth.VerifyRefreshToken(r.Context(), refreshToken)
|
||||
if err != nil {
|
||||
mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user still exists and role hasn't changed
|
||||
var currentRole string
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
err = db.Conn.QueryRow(r.Context(), `
|
||||
SELECT account_role FROM users WHERE id = $1
|
||||
`, userID).Scan(¤tRole)
|
||||
|
||||
@@ -483,22 +817,34 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke the old token's JTI before issuing a new one (rotation)
|
||||
if oldJTI != "" {
|
||||
auth.RevokeJTI(r.Context(), oldJTI, clock.Now().Add(90*24*time.Hour)) // match refresh token lifetime
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
newToken, jti, err := auth.GenerateToken(userID, currentRole)
|
||||
// Issue a fresh access token + refresh token pair. The rotated refresh
|
||||
// token is minted in the SAME family (familyID from VerifyRefreshToken) so
|
||||
// a replayed ancestor can revoke the whole lineage, descendants included.
|
||||
// The access token is bound to that same family (HIGH 1): when reuse
|
||||
// detection kills the family, the freshly-minted access token handed to the
|
||||
// attacker at rotation dies with it instead of staying valid for 1 hour.
|
||||
newRefreshToken, err := auth.GenerateRefreshTokenInFamily(r.Context(), userID, currentRole, familyID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
log.Printf("failed to issue rotated refresh token for user %s: %v", userID, err)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "could not generate refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
Token: newToken,
|
||||
JTI: jti,
|
||||
})
|
||||
// Mint the access token AFTER the descendant refresh token so it can be
|
||||
// bound to the same rotation family.
|
||||
newToken, jti, err := auth.GenerateTokenForFamily(userID, currentRole, familyID)
|
||||
if err != nil {
|
||||
mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the refresh-token response contract
|
||||
Token: newToken,
|
||||
JTI: jti,
|
||||
RefreshToken: newRefreshToken,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/logout (requires auth middleware)
|
||||
@@ -508,15 +854,54 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID, _ := mw.GetUserID(r.Context())
|
||||
|
||||
// Revoke the JTI — match the access token lifetime (1 hour)
|
||||
auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour))
|
||||
if err := auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour)); err != nil {
|
||||
slog.Error("logout: failed to revoke JTI", "err", err)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "failed to revoke token. please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
// B5: logging out must also kill the outstanding refresh credential for
|
||||
// THIS session, or a previously-issued (possibly stolen) refresh token
|
||||
// would keep the session alive past logout.
|
||||
//
|
||||
// LOW-1: the revocation is scoped to the PRESENTED access token's rotation
|
||||
// family (family_id claim) instead of every refresh token the user holds.
|
||||
// A stolen access token can no longer wipe every session the user keeps on
|
||||
// other devices — only this token's own lineage dies, which is all B5
|
||||
// needs (the presented refresh token lives in that family). An unbound
|
||||
// token (no family_id claim — test/legacy minting via GenerateToken)
|
||||
// falls back to the user-wide delete, preserving the old behaviour for
|
||||
// those tokens.
|
||||
familyID := auth.FamilyIDFromToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if familyID != "" {
|
||||
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE family_id = $1`, familyID); err != nil {
|
||||
slog.Error("logout: failed to revoke refresh token family", "familyID", familyID, "err", err)
|
||||
}
|
||||
// Drop the family-alive cache verdict so this family's access tokens
|
||||
// (the logged-out one and any in-flight duplicates) are re-checked
|
||||
// against the now-empty refresh_tokens on their next request.
|
||||
auth.InvalidateFamilyAlive(familyID)
|
||||
} else if userID != "" {
|
||||
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE user_id = $1`, userID); err != nil {
|
||||
slog.Error("logout: failed to revoke refresh tokens", "userID", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type VerificationCodeRequest struct {
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
// Purpose is the verification_purpose the code authorises: "email_verify"
|
||||
// (default, escalates unverified_email → verified_email) or "password_reset"
|
||||
// (clears a login lockout — see VerifyCodeHandler). Validated in code so the
|
||||
// comparison is case-insensitive after trim/lower.
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyCodeRequest struct {
|
||||
@@ -528,6 +913,37 @@ type VerificationResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// generateVerificationCode returns a 12-hex-character code (48 bits of
|
||||
// randomness), matching the old DB-default generator
|
||||
// (gen_random_bytes(6) hex). Only its HMAC-SHA256 digest is ever persisted; the
|
||||
// plaintext exists solely to be delivered out-of-band (dev [VERIFY] log relay,
|
||||
// or the future SMTP channel) and is never stored.
|
||||
func generateVerificationCode() (string, error) {
|
||||
buf := make([]byte, 6)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// POST /api/verify/generate
|
||||
// Creates a verification_codes row for the account matching the submitted
|
||||
// email (if any), storing ONLY the HMAC-SHA256 digest of a fresh
|
||||
// 12-hex-char code (pepper-keyed via crussell/internal/twofa — see
|
||||
// verificationCodePepperEnv). The plaintext code is delivered build-dependently
|
||||
// (verifycode_dev.go / verifycode_prod.go): dev/test builds write it to the
|
||||
// server log ([VERIFY] prefix) — the loose-fake stand-in for the not-yet-wired
|
||||
// email/SMS transport (P6) — while production builds fail closed when
|
||||
// TWO_FACTOR_PEPPER is unset (an unsalted digest in the 48-bit code space
|
||||
// would be offline-brute-forceable from a DB leak) or when no delivery channel
|
||||
// is configured (email/SMS unwired; stdout-log delivery is a dev/test-only
|
||||
// local feature). The response is IDENTICAL whether or not the email exists, so
|
||||
// the endpoint cannot be used to enumerate registered addresses.
|
||||
//
|
||||
// The purpose field wires the lockout-recovery flow (HIGH finding): a locked-out
|
||||
// user requests a password_reset code for their own email, obtains it (dev log /
|
||||
// operator relay), verifies it at /api/verify/check, and the lockout is cleared
|
||||
// so they can log in and change their password.
|
||||
func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req VerificationCodeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -535,7 +951,8 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,13 +962,41 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
purpose := strings.TrimSpace(strings.ToLower(req.Purpose))
|
||||
if purpose == "" {
|
||||
purpose = verificationCodePurposeEmailVerify
|
||||
}
|
||||
if purpose != verificationCodePurposeEmailVerify && purpose != verificationCodePurposePasswordReset {
|
||||
http.Error(w, "purpose must be 'email_verify' or 'password_reset'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// A locked-out user reaches this endpoint UNAUTHENTICATED by design (the
|
||||
// whole point of password_reset recovery), so no auth middleware guards it;
|
||||
// the per-IP rate limit on the route is the only throttle, matching the
|
||||
// 2FA mint paths.
|
||||
|
||||
// Build-dependent issuance gate (pepper + delivery channel in production;
|
||||
// always allowed in dev/test — see verifycode_dev.go / verifycode_prod.go).
|
||||
if err := verificationCodeEnsureIssueAllowed(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Fail-closed reference: the user lookup happens AFTER the issuance gate so
|
||||
// a prod deployment without the pepper/delivery channel refuses BEFORE any
|
||||
// per-email work (and before the enumeration-uniform path below is reached).
|
||||
var userID string
|
||||
err := db.Conn.QueryRow(r.Context(),
|
||||
"SELECT id FROM users WHERE LOWER(email) = $1", email,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
_ = json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"})
|
||||
// Uniform anti-enumeration response — byte-identical to the
|
||||
// existing-user branch.
|
||||
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to look up user: %v", err)
|
||||
@@ -559,22 +1004,47 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate verification code: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
|
||||
var code string
|
||||
err = db.Conn.QueryRow(r.Context(),
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt,
|
||||
).Scan(&code)
|
||||
// Persist ONLY the digest; the plaintext code exists only in the delivery
|
||||
// channel (log relay / future SMTP).
|
||||
_, err = db.Conn.Exec(r.Context(),
|
||||
`INSERT INTO verification_codes (user_id, purpose, code, expires_at) VALUES ($1, $2, $3, $4)`,
|
||||
userID, purpose, twofa.Hash(code), expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert verification code: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"})
|
||||
verificationCodeDeliver(userID, purpose, code)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/verify/check
|
||||
// Consumes a verification code submitted by an UNAUTHENTICATED caller (there is
|
||||
// no auth middleware on this route — the password_reset recovery flow must be
|
||||
// reachable by a locked-out user). The submitted code is hashed the same way it
|
||||
// was stored (twofa.Hash) and matched against verification_codes; a match
|
||||
// resolves the owning user, and the brute-force attempt budget is keyed PER
|
||||
// USER from that point on (see the emailVerifyAttempts* docs). On a valid,
|
||||
// unexpired, unused code:
|
||||
//
|
||||
// - purpose email_verify: escalates the account to verified_email;
|
||||
// - purpose password_reset: clears failed_attempts / locked_until so the
|
||||
// account owner can log in and change their password (the lockout-recovery
|
||||
// path for the login-DoS finding).
|
||||
func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req VerifyCodeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -582,7 +1052,8 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -592,33 +1063,46 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Finding 8: a spent attempt budget rejects before any DB work — the code
|
||||
// can no longer be guessed against. This pre-check uses the submitted code
|
||||
// as the key (a guess's miss path; the per-user key cannot be derived until
|
||||
// a row resolves it).
|
||||
if emailVerifyAttemptsExhausted(code) {
|
||||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
// Per-IP fallback budget: when no user resolves, the attempt budget is
|
||||
// keyed on the submitted CODE value — rotating codes gives unlimited
|
||||
// guesses. The per-IP check prevents a single source from exhausting the
|
||||
// endpoint regardless of code rotation.
|
||||
ip := mw.ClientIP(r)
|
||||
if ipAttemptsExhausted(ip) {
|
||||
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
codeDigest := twofa.Hash(code)
|
||||
|
||||
var userID string
|
||||
var purpose string
|
||||
var expiresAt time.Time
|
||||
var usedAt *time.Time
|
||||
|
||||
err := db.Conn.QueryRow(r.Context(),
|
||||
`SELECT user_id, purpose, expires_at FROM verification_codes
|
||||
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`,
|
||||
code,
|
||||
).Scan(&userID, &purpose, &expiresAt)
|
||||
`SELECT user_id, purpose, expires_at, used_at FROM verification_codes
|
||||
WHERE code = $1`,
|
||||
codeDigest,
|
||||
).Scan(&userID, &purpose, &expiresAt, &usedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Check if code exists but was already used or expired
|
||||
var checkUsedAt *time.Time
|
||||
checkErr := db.Conn.QueryRow(r.Context(),
|
||||
`SELECT used_at FROM verification_codes WHERE code = $1`, code,
|
||||
).Scan(&checkUsedAt)
|
||||
if checkErr != nil {
|
||||
// Code doesn't exist at all
|
||||
http.Error(w, "invalid or expired code", http.StatusBadRequest)
|
||||
// No row with this digest at all — a guess. No user can be
|
||||
// resolved, so the attempt budget stays keyed per submitted code.
|
||||
ipAttemptFailed(ip)
|
||||
if emailVerifyAttemptFailed(code) {
|
||||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
// Code exists but was already used
|
||||
if checkUsedAt != nil {
|
||||
http.Error(w, "code already used", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Code exists but expired
|
||||
http.Error(w, "invalid or expired code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -627,6 +1111,30 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The code resolved to exactly one user — from here the attempt budget is
|
||||
// keyed PER USER, so an attacker draining a victim's codes cannot get a
|
||||
// fresh budget per submitted value.
|
||||
if emailVerifyAttemptsExhausted(userID) {
|
||||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
// Code exists but was already used — a definite state, not a guess.
|
||||
if usedAt != nil {
|
||||
http.Error(w, "code already used", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Code exists but expired — count it against the user's budget too.
|
||||
if !expiresAt.After(clock.Now()) {
|
||||
ipAttemptFailed(ip)
|
||||
if emailVerifyAttemptFailed(userID) {
|
||||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid or expired code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
@@ -634,14 +1142,14 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tx.Exec(r.Context(),
|
||||
`UPDATE verification_codes SET used_at = NOW() WHERE code = $1`,
|
||||
code,
|
||||
codeDigest,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to mark code as used: %v", err)
|
||||
@@ -649,7 +1157,9 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if purpose == "email_verify" {
|
||||
message := "Email verified successfully"
|
||||
switch purpose {
|
||||
case verificationCodePurposeEmailVerify:
|
||||
_, err = tx.Exec(r.Context(),
|
||||
`UPDATE users SET account_role = 'verified_email' WHERE id = $1 AND account_role = 'unverified_email'`,
|
||||
userID,
|
||||
@@ -659,6 +1169,21 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case verificationCodePurposePasswordReset:
|
||||
// Lockout-recovery consumer (HIGH finding): a verified password_reset
|
||||
// code proves control of the account's email, so the login lockout is
|
||||
// lifted. The user then logs in and changes their password via the
|
||||
// existing change-password flow.
|
||||
message = "Verification successful - login lockout cleared"
|
||||
_, err = tx.Exec(r.Context(),
|
||||
`UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to clear login lockout for password_reset: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
@@ -667,7 +1192,13 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"})
|
||||
// A successful verify clears the user's attempt budget (and the miss-path
|
||||
// key the submitted code used on earlier guesses).
|
||||
emailVerifyAttemptsClear(userID)
|
||||
emailVerifyAttemptsClear(code)
|
||||
ipAttemptsClear(ip)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: message}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//go:build test
|
||||
|
||||
package auth
|
||||
|
||||
// F5.5 regression: a locked account must respond EXACTLY like a generic
|
||||
// invalid-credentials failure — same status, same body — so an attacker can
|
||||
// never distinguish "locked" from "wrong password". The old distinguishable
|
||||
// 429 lockout was an account-existence oracle and a lockout-probing signal.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLogin_LockedAccount_IndistinguishableFromWrongPassword pins the F5.5
|
||||
// fix: a login attempt against a locked account returns the SAME status and
|
||||
// byte-identical body as a wrong-password attempt against a normal account.
|
||||
// Before the fix the locked account answered 429 "account is temporarily
|
||||
// locked..." — distinguishable from the 401 "invalid credentials" a wrong
|
||||
// password returns, revealing account existence and lockout state.
|
||||
func TestLogin_LockedAccount_IndistinguishableFromWrongPassword(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
normalID, err := fixtures.CreateTestUserWithEmail(tx, "normal@test.com", "verified_email")
|
||||
require.NoError(t, err)
|
||||
defer fixtures.DeleteUser(tx, normalID)
|
||||
|
||||
lockedID, err := fixtures.CreateTestUserWithEmail(tx, "locked@test.com", "verified_email")
|
||||
require.NoError(t, err)
|
||||
defer fixtures.DeleteUser(tx, lockedID)
|
||||
// Lock the account the way the failure path would after 5 wrong passwords.
|
||||
_, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = NOW() + INTERVAL '15 minutes' WHERE id = $1`, lockedID)
|
||||
require.NoError(t, err)
|
||||
|
||||
wWrong := testutils.MakeRequestNoAuth(handler, "POST", "/api/login",
|
||||
LoginRequest{Email: "normal@test.com", Password: "wrong-password"}, ctx)
|
||||
wLocked := testutils.MakeRequestNoAuth(handler, "POST", "/api/login",
|
||||
LoginRequest{Email: "locked@test.com", Password: "wrong-password"}, ctx)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, wWrong.Code, "a wrong password must be 401")
|
||||
require.Equal(t, http.StatusUnauthorized, wLocked.Code, "a locked account must be 401, not 429 (F5.5)")
|
||||
require.Equal(t, wWrong.Body.String(), wLocked.Body.String(),
|
||||
"the locked-account body must be byte-identical to the wrong-password body (no lockout oracle)")
|
||||
}
|
||||
|
||||
// TestLogin_LockedAccount_CorrectPasswordStillUniform verifies the locked path
|
||||
// stays indistinguishable even when the attacker submits the CORRECT password:
|
||||
// the account is still refused with the generic 401 — the compare result is
|
||||
// deliberately discarded on the locked path.
|
||||
func TestLogin_LockedAccount_CorrectPasswordStillUniform(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
// fixtures.CreateTestUserWithEmail hashes "testpassword123".
|
||||
lockedID, err := fixtures.CreateTestUserWithEmail(tx, "locked-correct@test.com", "verified_email")
|
||||
require.NoError(t, err)
|
||||
defer fixtures.DeleteUser(tx, lockedID)
|
||||
_, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = NOW() + INTERVAL '15 minutes' WHERE id = $1`, lockedID)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login",
|
||||
LoginRequest{Email: "locked-correct@test.com", Password: "testpassword123"}, ctx)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code, "a locked account must refuse even the correct password (F5.5)")
|
||||
require.Equal(t, "invalid credentials\n", w.Body.String())
|
||||
}
|
||||
|
||||
// TestLogin_LockoutEscalation pins the escalating lockout (LOW 6 follow-up): the
|
||||
// lock duration grows with sustained failure rounds — 15min at 5-6 failures,
|
||||
// 30min at 7-9, 60min at 10+ — so an attacker who keeps guessing past each
|
||||
// unlock makes the lock LONGER instead of merely sustaining the 15-minute tier.
|
||||
// Every round re-arms the account (locked_until reset to NULL, simulating the
|
||||
// attacker retrying after each unlock); each attempt stays a uniform 401, so
|
||||
// the escalation adds no distinguishable response (F5.5).
|
||||
func TestLogin_LockoutEscalation(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(tx, "escalate@test.com", "verified_email")
|
||||
require.NoError(t, err)
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
// lockSeconds re-arms the account with the given failure count, performs one
|
||||
// wrong-password login, and returns how long the resulting lock lasts.
|
||||
lockSeconds := func(failedAttempts int) float64 {
|
||||
t.Helper()
|
||||
_, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = $1, locked_until = NULL WHERE id = $2`, failedAttempts, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login",
|
||||
LoginRequest{Email: "escalate@test.com", Password: "wrong-password"}, ctx)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code, "every lockout round stays a uniform 401 (F5.5)")
|
||||
|
||||
var secs float64
|
||||
err = tx.QueryRow(ctx, `SELECT EXTRACT(EPOCH FROM (locked_until - NOW())) FROM users WHERE id = $1`, userID).Scan(&secs)
|
||||
require.NoError(t, err)
|
||||
return secs
|
||||
}
|
||||
|
||||
require.InDelta(t, 900, lockSeconds(4), 5, "5th failure locks for 15 minutes")
|
||||
require.InDelta(t, 900, lockSeconds(5), 5, "6th failure stays on the 15-minute tier")
|
||||
require.InDelta(t, 1800, lockSeconds(6), 5, "7th failure escalates to 30 minutes")
|
||||
require.InDelta(t, 1800, lockSeconds(8), 5, "9th failure stays on the 30-minute tier")
|
||||
require.InDelta(t, 3600, lockSeconds(9), 5, "10th failure escalates to the 60-minute ceiling")
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build dev || test
|
||||
|
||||
package auth
|
||||
|
||||
// Dev/test builds of the verification-code flow (POST /api/verify/generate):
|
||||
// the [VERIFY] log line is the LOCAL DEV delivery channel — the stand-in for
|
||||
// the not-yet-wired email/SMS transport (P6) — and a missing TWO_FACTOR_PEPPER
|
||||
// still falls back to the legacy unsalted SHA-256 digest (via the shared
|
||||
// crussell/internal/twofa provider, which logs its own one-time warning).
|
||||
// Production builds (!dev && !test) never log the code — stdout-log delivery
|
||||
// is a dev/test-only local feature — and fail closed — see verifycode_prod.go.
|
||||
|
||||
import "log"
|
||||
|
||||
// verificationCodeEnsureIssueAllowed always permits code issuance in dev/test
|
||||
// builds: the loose-fake delivery (the [VERIFY] log line) is the documented
|
||||
// stand-in until email/SMS lands. Production builds fail closed here — no
|
||||
// TWO_FACTOR_PEPPER, no codes (see verifycode_prod.go).
|
||||
func verificationCodeEnsureIssueAllowed() error { return nil }
|
||||
|
||||
// verificationCodeDeliver delivers a fresh verification code to the user.
|
||||
// Dev/test: the [VERIFY] log line is the delivery channel — an operator relays
|
||||
// the code to the user out-of-band until email/SMS lands (mirrors the [2FA]
|
||||
// log relay, which the 2FA flow uses identically). MEDIUM-3b: the user id and
|
||||
// the plaintext code go to SEPARATE log lines so a single record cannot trivially
|
||||
// pair a code with its owner.
|
||||
func verificationCodeDeliver(userID, purpose, code string) {
|
||||
log.Printf("[VERIFY] code delivery requested (user=%s, purpose=%s)", userID, purpose)
|
||||
log.Printf("[VERIFY] code: %s", code)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build !dev && !test
|
||||
|
||||
package auth
|
||||
|
||||
// Production builds (!dev && !test) of the verification-code flow
|
||||
// (POST /api/verify/generate): code issuance fails closed. The stdout-log
|
||||
// relay ([VERIFY] prefix) is a DEV/TEST-ONLY local feature — the stand-in for
|
||||
// the not-yet-wired email/SMS transport (P6) — and is deliberately never used
|
||||
// in a production build:
|
||||
//
|
||||
// - a missing TWO_FACTOR_PEPPER: an unsalted SHA-256 digest in the 48-bit
|
||||
// code space would be offline-brute-forceable from a log/DB leak; and
|
||||
// - no delivery channel: there is no production email/SMS transport yet (P6)
|
||||
// and no production opt-in to log delivery, so a minted code could never
|
||||
// reach the user.
|
||||
//
|
||||
// The plaintext code is therefore never written to the server log in a
|
||||
// production build, under any configuration.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// verificationCodeEnsureIssueAllowed reports whether a verification code may be
|
||||
// issued in this deployment. Production requires TWO_FACTOR_PEPPER and, after
|
||||
// that, a real delivery channel — which does not exist until email/SMS lands
|
||||
// (P6) — so issuance is ALWAYS refused (fail-closed); dev/test builds always
|
||||
// allow issuance (verifycode_dev.go).
|
||||
func verificationCodeEnsureIssueAllowed() error {
|
||||
if os.Getenv(verificationCodePepperEnv) == "" {
|
||||
return errors.New("verification code issuance requires TWO_FACTOR_PEPPER (an unsalted digest in the 48-bit code space would be offline-brute-forceable); set it in the environment")
|
||||
}
|
||||
return errors.New("verification code issuance requires a delivery channel; email/SMS is not wired yet (P6) — production has no delivery channel until it lands")
|
||||
}
|
||||
|
||||
// verificationCodeDeliver delivers a fresh verification code to the user.
|
||||
// Production: a deliberate no-op — the plaintext code is NEVER written to the
|
||||
// server log, so this is unreachable (verificationCodeEnsureIssueAllowed
|
||||
// already refused issuance). The dev/test build (verifycode_dev.go) writes the
|
||||
// [VERIFY] log line instead — stdout-log delivery is a dev/test-only local
|
||||
// feature until email/SMS lands (P6).
|
||||
func verificationCodeDeliver(userID, purpose, code string) {
|
||||
// Deliberate no-op: production never logs plaintext codes, under any
|
||||
// configuration. Delivery is dev/test-only until email/SMS lands (P6).
|
||||
}
|
||||
@@ -2,20 +2,20 @@ package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/mw"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ServiceOverrideRequest represents override values for a specific service in a reservation
|
||||
@@ -87,7 +87,8 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
|
||||
allIDs := append([]string{}, req.ServiceIDs...)
|
||||
allIDs = append(allIDs, req.CustomServiceIDs...)
|
||||
svcDuration, err = calculateServiceDurationWithOverrides(r.Context(), allIDs, req.ServiceOverrides)
|
||||
if err != nil {
|
||||
log.Printf("Failed to calculate duration: %v", err)
|
||||
@@ -117,16 +118,18 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
var closeStr string
|
||||
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Not open on this day", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
closeStr, err := getClosingTimeForDate(r.Context(), db.Conn, weekday, localStart)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get hours: %v", err)
|
||||
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 00:00 means the day is closed under the staged schedule — reject outright
|
||||
if closeStr == "00:00" || closeStr == "00:00:00" {
|
||||
http.Error(w, "Not open on this day", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
|
||||
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
|
||||
@@ -144,14 +147,12 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// used by CheckTimeBlockerOverlap. The in-transaction DELETE is kept
|
||||
// as a safety net for the insert-phase.
|
||||
// Also clean up anonymous reservations matching this admin's IP
|
||||
// (edge case: admin previously reserved without authentication).
|
||||
ip := r.Header.Get("CF-Connecting-IP")
|
||||
if ip == "" {
|
||||
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
}
|
||||
// (edge case: admin previously reserved without authentication). The IP
|
||||
// goes through the SAME gated resolution the rate limiter uses
|
||||
// (mw.ClientIP): CF-Connecting-IP is honored ONLY when
|
||||
// TRUST_PROXY_HEADERS=true, so an origin-exposed backend can never be
|
||||
// forced to key the ipHash on a client-controlled header (B7).
|
||||
ip := mw.ClientIP(r)
|
||||
ipHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip)))[:8]
|
||||
if _, delErr := db.Conn.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers
|
||||
@@ -178,10 +179,10 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check booking overlap inside transaction
|
||||
// pending_release is excluded — those bookings are evicted at creation time
|
||||
@@ -258,7 +259,6 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
TTLMinutes: req.TTLMinutes,
|
||||
}
|
||||
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("Failed to encode response: %v", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/internal/dav"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
@@ -18,13 +19,13 @@ import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -339,11 +340,6 @@ type AdminBookingDetail struct {
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
// roundTo2 rounds a float64 to 2 decimal places
|
||||
func roundTo2(f float64) float64 {
|
||||
return float64(int(f*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// Helper function to parse query parameters
|
||||
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
|
||||
req := GetAllBookingsRequest{
|
||||
@@ -442,8 +438,8 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
FROM bookings b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS amount_paid,
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < b.start_time), 0) AS pre_start_amount_paid
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND payment_type <> 'tip'), 0) AS amount_paid,
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND payment_type <> 'tip' AND created_at < b.start_time), 0) AS pre_start_amount_paid
|
||||
FROM payments
|
||||
WHERE booking_id = b.id
|
||||
) pt ON true` + whereClause
|
||||
@@ -655,7 +651,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
booking_id,
|
||||
SUM(amount) AS total_paid
|
||||
FROM payments
|
||||
WHERE status = 'completed'
|
||||
WHERE status = 'completed' AND payment_type <> 'tip'
|
||||
GROUP BY booking_id
|
||||
)
|
||||
SELECT
|
||||
@@ -681,6 +677,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
|
||||
FROM payments
|
||||
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
|
||||
AND payment_type <> 'tip'
|
||||
) pre_pay ON true
|
||||
`
|
||||
|
||||
@@ -1056,8 +1053,8 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paymentRows, err := db.Conn.Query(r.Context(), `
|
||||
SELECT p.booking_id,
|
||||
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed'), 0) AS amount_paid,
|
||||
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.created_at < b.start_time), 0) AS pre_start_paid
|
||||
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type <> 'tip'), 0) AS amount_paid,
|
||||
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type <> 'tip' AND p.created_at < b.start_time), 0) AS pre_start_paid
|
||||
FROM payments p
|
||||
JOIN bookings b ON b.id = p.booking_id
|
||||
WHERE p.booking_id = ANY($1)
|
||||
@@ -1287,7 +1284,13 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
p.InvoiceNumber = &num
|
||||
}
|
||||
booking.Payments = append(booking.Payments, p)
|
||||
if p.Status == "completed" {
|
||||
// A tip is gratuity paid beyond the booking total — it must not reduce
|
||||
// the balance owed. This mirrors the list views (which filter
|
||||
// payment_type <> 'tip' in SQL) and GetBookingPaymentInfo; without the
|
||||
// exclusion AmountDue would be understated by the tip and the frontend
|
||||
// would charge less than the true remaining balance, leaving the
|
||||
// booking never completed and the merchant short.
|
||||
if p.Status == "completed" && p.PaymentType != "tip" {
|
||||
amountPaid += p.Amount
|
||||
if p.CreatedAt.Before(booking.StartTime) {
|
||||
preStartAmountPaid += p.Amount
|
||||
@@ -1336,7 +1339,8 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1449,10 +1453,10 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Evict any pending_release bookings that overlap this slot.
|
||||
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, startTime, newEndTime); evictErr != nil {
|
||||
@@ -1678,7 +1682,13 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
p.InvoiceNumber = &num
|
||||
}
|
||||
booking.Payments = append(booking.Payments, p)
|
||||
if p.Status == "completed" {
|
||||
// A tip is gratuity paid beyond the booking total — it must not reduce
|
||||
// the balance owed. This mirrors the list views (which filter
|
||||
// payment_type <> 'tip' in SQL) and GetBookingPaymentInfo; without the
|
||||
// exclusion AmountDue would be understated by the tip and the frontend
|
||||
// would charge less than the true remaining balance, leaving the
|
||||
// booking never completed and the merchant short.
|
||||
if p.Status == "completed" && p.PaymentType != "tip" {
|
||||
amountPaid += p.Amount
|
||||
if p.CreatedAt.Before(booking.StartTime) {
|
||||
preStartAmountPaid += p.Amount
|
||||
@@ -1751,7 +1761,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
booking_id,
|
||||
SUM(amount) AS total_paid
|
||||
FROM payments
|
||||
WHERE status = 'completed'
|
||||
WHERE status = 'completed' AND payment_type <> 'tip'
|
||||
GROUP BY booking_id
|
||||
)
|
||||
SELECT
|
||||
@@ -1774,6 +1784,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
|
||||
FROM payments
|
||||
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
|
||||
AND payment_type <> 'tip'
|
||||
) pre_pay ON true
|
||||
-- NOTE: ILIKE with leading wildcard prevents B-tree index usage.
|
||||
-- At scale, replace with pg_trgm GIN index: CREATE INDEX idx_bookings_search_trgm ON bookings USING GIN (id gin_trgm_ops, notes gin_trgm_ops);
|
||||
@@ -1998,13 +2009,16 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Get payment info
|
||||
var preStartPaid float64
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingBooking.ID).Scan(&preStartPaid)
|
||||
if err := db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingBooking.ID).Scan(&preStartPaid); err != nil {
|
||||
log.Printf("Failed to scan preStartPaid for booking %s: %v", existingBooking.ID, err)
|
||||
}
|
||||
populateDepositFields(&existingBooking, existingBooking.DepositRequired, preStartPaid)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(existingBooking)
|
||||
if err := json.NewEncoder(w).Encode(existingBooking); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// If err is sql.ErrNoRows, proceed with creation
|
||||
@@ -2033,7 +2047,8 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2191,12 +2206,17 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
var closeStr string
|
||||
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
||||
|
||||
closeStr, err := getClosingTimeForDate(r.Context(), db.Conn, weekday, localStart)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get hours: %v", err)
|
||||
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if closeStr == "00:00" || closeStr == "00:00:00" {
|
||||
http.Error(w, "Not open on this day", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
|
||||
@@ -2230,10 +2250,10 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Evict any pending_release bookings that overlap this slot.
|
||||
if _, err := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, endTime); err != nil {
|
||||
@@ -2312,10 +2332,20 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Always create low-priority notification for all bookings
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('new_booking', $1, $2)
|
||||
`, booking.ID, userID); err != nil {
|
||||
// Always create low-priority notification for all bookings. C5: the
|
||||
// unacknowledged 'new_booking' queue is flood-capped at
|
||||
// adminnotify.MaxUnacknowledgedCriticalLogs (pre-check logs the
|
||||
// suppression; the fold inside the INSERT enforces it atomically), so a
|
||||
// booking flood cannot bury the operator's notification centre.
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "new_booking") {
|
||||
log.Printf("Suppressed new_booking admin notification for booking %s — unacknowledged 'new_booking' queue at the cap", booking.ID)
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'new_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'new_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, booking.ID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -2334,9 +2364,15 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if needsApproval {
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2)
|
||||
`, booking.ID, userID); err != nil {
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "pending_booking") {
|
||||
log.Printf("Suppressed pending_booking admin notification for booking %s — unacknowledged 'pending_booking' queue at the cap", booking.ID)
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'pending_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'pending_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, booking.ID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create pending approval notification for booking %s: %v", booking.ID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -2371,6 +2407,21 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// booking.TotalAmount is still 0 here: the INSERT..RETURNING row predates
|
||||
// the recalc_booking_duration_and_total trigger (fired by the
|
||||
// booking_services INSERT above). Re-read the trigger-maintained values so
|
||||
// the response and deposit fields use the real total — with 0, DepositPaid
|
||||
// computes TRUE on an unpaid booking, and the deposit is never charged.
|
||||
var bookingTotal float64
|
||||
if err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT total_amount, total_duration_minutes FROM bookings WHERE id = $1
|
||||
`, booking.ID).Scan(&bookingTotal, &booking.DurationMinutes); err != nil {
|
||||
log.Printf("Failed to re-read booking total after creation: %v", err)
|
||||
}
|
||||
booking.TotalAmount = bookingTotal
|
||||
booking.AmountPaid = 0
|
||||
booking.AmountDue = bookingTotal
|
||||
|
||||
// Populate deposit display fields on the creation response.
|
||||
// No payments exist yet so pre-start paid is 0 and DepositPaid will be false.
|
||||
populateDepositFields(&booking, depositRequiredSnapshot, 0)
|
||||
@@ -2404,7 +2455,8 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2440,10 +2492,10 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var durationMinutes int
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
@@ -2574,7 +2626,8 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2598,10 +2651,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Read current status before updating to validate the transition
|
||||
var currentStatus string
|
||||
@@ -2657,307 +2710,12 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if currentStatus == "completed" {
|
||||
log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID)
|
||||
} else {
|
||||
// Collect patch test IDs first so the rows are consumed before INSERT operations.
|
||||
var patchTestIDs []string
|
||||
ptRows, err := tx.Query(r.Context(), `
|
||||
SELECT DISTINCT pt.id
|
||||
FROM patch_tests pt
|
||||
JOIN booking_services bs ON bs.booking_id = $1
|
||||
WHERE pt.id IN (
|
||||
SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids)
|
||||
)
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
|
||||
} else {
|
||||
for ptRows.Next() {
|
||||
var ptID string
|
||||
if err := ptRows.Scan(&ptID); err == nil {
|
||||
patchTestIDs = append(patchTestIDs, ptID)
|
||||
}
|
||||
}
|
||||
ptRows.Close()
|
||||
}
|
||||
for _, ptID := range patchTestIDs {
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
|
||||
`, booking.User.ID, ptID); err != nil {
|
||||
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, ptID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var bookingTotal float64
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT total_amount FROM bookings WHERE id = $1
|
||||
`, bookingID).Scan(&bookingTotal); err != nil {
|
||||
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
// Don't award a stamp if this booking already used a loyalty redemption
|
||||
// (take or receive, never both).
|
||||
var loyaltyAppliedOnThisBooking bool
|
||||
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking); err != nil {
|
||||
log.Printf("Failed to check loyalty applied on booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
var newStampCount int
|
||||
if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
UPDATE users
|
||||
SET loyalty_stamps = loyalty_stamps + 1
|
||||
WHERE id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.user_id = users.id
|
||||
AND b.status = 'completed'
|
||||
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
|
||||
AND b.id != $2
|
||||
)
|
||||
RETURNING loyalty_stamps
|
||||
`, booking.User.ID, bookingID).Scan(&newStampCount); err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create pending redemption when stamps reach LoyaltyStampCost
|
||||
if newStampCount == payments.LoyaltyStampCost {
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
||||
VALUES ($1, $2, 'pending', NOW())
|
||||
`, booking.User.ID, payments.LoyaltyStampCost)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip time-based campaign if already applied at payment time
|
||||
var timeBasedApplied bool
|
||||
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied); err != nil {
|
||||
log.Printf("Failed to check time-based campaign applied on booking %s: %v", bookingID, err)
|
||||
}
|
||||
if bookingTotal > 0 && !timeBasedApplied {
|
||||
var campaignID string
|
||||
var campaignPercent float64
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
||||
AND start_date <= NOW() AND end_date >= NOW()
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
ORDER BY discount_percent DESC LIMIT 1
|
||||
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, campaignID); err != nil {
|
||||
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bookingTotal > 0 {
|
||||
var userBookingCount int
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount)
|
||||
|
||||
var milestoneCampaignID string
|
||||
var milestonePercent float64
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
|
||||
AND milestone_value = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
|
||||
`, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent); err != nil {
|
||||
log.Printf("Failed to query per-user milestone campaign for booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
if milestoneCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, milestoneCampaignID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var globalMilestoneApplied bool
|
||||
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied); err != nil {
|
||||
log.Printf("Failed to check global milestone applied on booking %s: %v", bookingID, err)
|
||||
}
|
||||
if !globalMilestoneApplied {
|
||||
var globalCount int
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
|
||||
|
||||
var hasInPersonPayment bool
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment); err != nil {
|
||||
log.Printf("Failed to check in-person payment on booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
if hasInPersonPayment {
|
||||
var globalCampaignID string
|
||||
var globalPercent float64
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
|
||||
AND milestone_value <= $1
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
ORDER BY milestone_value DESC LIMIT 1
|
||||
`, globalCount).Scan(&globalCampaignID, &globalPercent); err != nil {
|
||||
log.Printf("Failed to query global milestone campaign for booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
if globalCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, globalCampaignID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var firstVisitDate time.Time
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = tx.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
|
||||
if !firstVisitDate.IsZero() {
|
||||
annRows, err := tx.Query(r.Context(), `
|
||||
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
|
||||
`, booking.User.ID)
|
||||
if err == nil {
|
||||
// Collect anniversary campaigns first to avoid interleaving rows with writes.
|
||||
type annCampaign struct {
|
||||
id string
|
||||
pct float64
|
||||
value int
|
||||
unit string
|
||||
}
|
||||
var campaigns []annCampaign
|
||||
for annRows.Next() {
|
||||
var c annCampaign
|
||||
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
|
||||
campaigns = append(campaigns, c)
|
||||
}
|
||||
}
|
||||
annRows.Close()
|
||||
|
||||
// Sort by milestone_value descending so we apply the longest anniversary only
|
||||
sort.Slice(campaigns, func(i, j int) bool {
|
||||
return campaigns[i].value > campaigns[j].value
|
||||
})
|
||||
for _, c := range campaigns {
|
||||
var matches bool
|
||||
elapsed := time.Since(firstVisitDate)
|
||||
switch c.unit {
|
||||
case "months":
|
||||
months := int(elapsed.Hours() / (30 * 24))
|
||||
matches = months >= c.value
|
||||
case "years":
|
||||
years := int(elapsed.Hours() / (365.25 * 24))
|
||||
matches = years >= c.value
|
||||
}
|
||||
if matches {
|
||||
discountAmount := roundTo2(bookingTotal * c.pct / 100)
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, c.id); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
break // apply longest matching only
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paymentExists bool
|
||||
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists {
|
||||
var newDepositsRequired int
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1)
|
||||
WHERE id = $1
|
||||
RETURNING deposits_required
|
||||
`, booking.User.ID).Scan(&newDepositsRequired); err != nil {
|
||||
log.Printf("ALERT: failed to update deposits_required: %v", err)
|
||||
} else if newDepositsRequired == 0 {
|
||||
// After 3 paid bookings, forget no-shows so the counter resets.
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO forgiven_no_shows (booking_id)
|
||||
SELECT id FROM bookings
|
||||
WHERE user_id = $1 AND status = 'no_show'
|
||||
AND start_time >= NOW() - INTERVAL '6 months'
|
||||
AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id)
|
||||
`, booking.User.ID); err != nil {
|
||||
log.Printf("ALERT: failed to auto-forgive no-shows: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Consume unconsumed name_history entries — this booking is the "first post-name-change
|
||||
// booking" that completes. After this, we no longer show "formerly" on displays.
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
UPDATE name_history SET booking_id = $1
|
||||
WHERE user_id = $2 AND booking_id IS NULL
|
||||
`, bookingID, booking.User.ID); err != nil {
|
||||
log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err)
|
||||
}
|
||||
// Completion side-effects (patch tests, loyalty stamps, campaign
|
||||
// discounts, deposits_required reduction, name_history consumption)
|
||||
// live in the payments package so the admin progress endpoint and
|
||||
// the payment paths share one implementation. Must stay inside this
|
||||
// transaction with the status UPDATE.
|
||||
payments.ApplyBookingCompletionSideEffects(r.Context(), tx, bookingID, booking.User.ID)
|
||||
} // close the else from alreadyCompleted check
|
||||
}
|
||||
|
||||
@@ -2990,7 +2748,8 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3023,10 +2782,10 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var bkStart time.Time
|
||||
var dur int
|
||||
@@ -3158,18 +2917,19 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if dav.Service != nil {
|
||||
var durationMinutes int
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = db.Conn.QueryRow(r.Context(), `
|
||||
if err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT total_duration_minutes FROM bookings WHERE id = $1
|
||||
`, bookingID).Scan(&durationMinutes)
|
||||
if durationMinutes == 0 {
|
||||
`, bookingID).Scan(&durationMinutes); err != nil {
|
||||
log.Printf("ALERT: failed to scan total_duration_minutes for booking %s: %v", bookingID, err)
|
||||
durationMinutes = 60
|
||||
}
|
||||
_ = dav.Service.CreateEvent(1, dav.EventInput{
|
||||
if err := dav.Service.CreateEvent(1, dav.EventInput{
|
||||
Summary: "Crussell Booking",
|
||||
Start: booking.StartTime,
|
||||
End: booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute),
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to create DAV calendar event for booking %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -3195,7 +2955,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var paymentExists bool
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)", bookingID).Scan(&paymentExists); err != nil {
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND EXISTS (SELECT 1 FROM bookings WHERE id = $2 AND user_id = $3))", bookingID, bookingID, userID).Scan(&paymentExists); err != nil {
|
||||
log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -3209,7 +2969,8 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
@@ -3247,9 +3008,11 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Refund processing failed for booking %s — cancellation aborted: %v", bookingID, calcErr)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Refund processing failed — cancellation aborted. Please try again or contact support.",
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3262,10 +3025,10 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := tx.Exec(r.Context(), `
|
||||
UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3
|
||||
@@ -3305,9 +3068,15 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
|
||||
`, "cancelled_booking", bookingID, userID); err != nil {
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "cancelled_booking") {
|
||||
log.Printf("Suppressed cancelled_booking admin notification for booking %s — unacknowledged 'cancelled_booking' queue at the cap", bookingID)
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'cancelled_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'cancelled_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -3341,7 +3110,9 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3353,10 +3124,10 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID); err != nil {
|
||||
log.Printf("Failed to delete admin notifications for booking %s: %v", bookingID, err)
|
||||
@@ -3382,10 +3153,12 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"message": "Booking deleted successfully",
|
||||
"id": bookingID,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/bookings/{id}
|
||||
@@ -3563,7 +3336,13 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
p.CreatedBy = &pCreatedBy.String
|
||||
}
|
||||
|
||||
if p.Status == "completed" {
|
||||
// A tip is gratuity paid beyond the booking total — it must not reduce
|
||||
// the balance owed. This mirrors the list views (which filter
|
||||
// payment_type <> 'tip' in SQL) and GetBookingPaymentInfo; without the
|
||||
// exclusion AmountDue would be understated by the tip and the frontend
|
||||
// would charge less than the true remaining balance, leaving the
|
||||
// booking never completed and the merchant short.
|
||||
if p.Status == "completed" && p.PaymentType != "tip" {
|
||||
amountPaid += p.Amount
|
||||
if p.CreatedAt.Before(booking.StartTime) {
|
||||
preStartAmountPaid += p.Amount
|
||||
@@ -4146,7 +3925,8 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4191,10 +3971,10 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow
|
||||
if forgiveNoShow && bookingUserID != "" {
|
||||
@@ -4210,6 +3990,19 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees
|
||||
if forgiveFees {
|
||||
log.Printf("[AUDIT] Admin %s rescheduled booking %s with fee forgiveness (start_time: %s)", adminID, bookingID, req.StartTime.Format(time.RFC3339))
|
||||
// MEDIUM-3a coverage: fee forgiveness on a reschedule is an admin money
|
||||
// action (the deposit-protection retention that would have applied is
|
||||
// waived) — record it in admin_audit_log (best-effort, own tx, mirrors
|
||||
// the InsertAdminAuditCharge pattern). The forgiven amount is the
|
||||
// booking's total paid, which is what the retention would have drawn on.
|
||||
if payInfo, payErr := payments.NewPaymentService().GetBookingPaymentInfo(r.Context(), bookingID); payErr == nil {
|
||||
payments.InsertAdminAuditCharge(r.Context(), adminID, bookingUserID, "admin_reschedule_fee_forgiven", map[string]any{
|
||||
"booking_id": bookingID,
|
||||
"forgiven_amount": payInfo.TotalPaid,
|
||||
})
|
||||
} else {
|
||||
log.Printf("Failed to fetch payment info for reschedule fee-forgiveness audit on booking %s: %v", bookingID, payErr)
|
||||
}
|
||||
}
|
||||
|
||||
var durationMinutes int
|
||||
@@ -4266,6 +4059,11 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Cannot reschedule to a closed day", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Also check the day isn't closed under a staged default hours change
|
||||
if closeStr, err := getClosingTimeForDate(r.Context(), tx, weekday, localStart); err == nil && (closeStr == "00:00" || closeStr == "00:00:00") {
|
||||
http.Error(w, "This day will be closed under the upcoming schedule change", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
|
||||
log.Printf("Failed to evict pending_release bookings on reschedule: %v", evictErr)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -4329,14 +4127,63 @@ type EvictedBooking struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
// evictedBookingPaymentInfo mirrors payments.GetBookingPaymentInfo (start time,
|
||||
// total amount, and net paid minus prior completed/pending refunds) but reads
|
||||
// through the eviction's own transaction so the totals are consistent with the
|
||||
// same tx that ProcessCancellationRefundTx refunds against. The exclusions
|
||||
// (discount / on-the-house rows, tips) match exactly what the cancellation-refund
|
||||
// machinery treats as refundable.
|
||||
func evictedBookingPaymentInfo(ctx context.Context, tx pgx.Tx, bookingID string) (startTime time.Time, totalAmount, totalPaid float64, err error) {
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT b.start_time,
|
||||
COALESCE(b.total_amount, 0),
|
||||
COALESCE(pt.total_paid, 0) - COALESCE(rr.total_refunded, 0)
|
||||
FROM bookings b
|
||||
LEFT JOIN (
|
||||
SELECT booking_id, SUM(amount) AS total_paid
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_method NOT IN ('discount', 'on_the_house')
|
||||
AND payment_type <> 'tip'
|
||||
GROUP BY booking_id
|
||||
) pt ON b.id = pt.booking_id
|
||||
LEFT JOIN (
|
||||
SELECT p.booking_id, SUM(r.amount) AS total_refunded
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
|
||||
AND p.payment_type <> 'tip'
|
||||
GROUP BY p.booking_id
|
||||
) rr ON b.id = rr.booking_id
|
||||
WHERE b.id = $1
|
||||
`, bookingID).Scan(&startTime, &totalAmount, &totalPaid)
|
||||
return
|
||||
}
|
||||
|
||||
// EvictPendingReleaseOverlapping evicts any pending_release bookings whose slot
|
||||
// overlaps with [startTime, endTime). The PAYMENT_IN_FLIGHT guard prevents
|
||||
// evicting a booking that a user is currently paying for (the 5-minute
|
||||
// time_blocker window). Returns the list of evicted bookings (id + user_id)
|
||||
// for any caller that needs to react (e.g. notify the affected user).
|
||||
//
|
||||
// Money-safety (C4): a pending_release booking may already carry a paid
|
||||
// deposit, and evicting it re-sells the slot to someone else — the customer
|
||||
// must not lose that money. Every evicted booking's refund is therefore
|
||||
// processed FIRST (through the same exported cancellation-refund machinery the
|
||||
// admin cancellation handler uses — payments.ProcessCancellationRefundTx — in
|
||||
// this same transaction so the refund rows commit atomically with the status
|
||||
// flip), and only THEN is the booking flipped to 'deposit_lapsed'. The
|
||||
// eviction is business-initiated (the salon is re-selling the customer's
|
||||
// slot), so the full-refund override applies: the business keeps nothing,
|
||||
// mirroring a business-initiated admin cancellation. Card refunds are recorded
|
||||
// 'pending' and settled post-commit by the sweep-pending-square-refunds job
|
||||
// (the post-commit Square pass cannot run here — the caller owns the commit).
|
||||
// A refund failure aborts the eviction so the caller rolls the whole
|
||||
// transaction back rather than re-selling a slot over a customer's money.
|
||||
func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, endTime time.Time) ([]EvictedBooking, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
UPDATE bookings SET status = 'deposit_lapsed', updated_at = NOW()
|
||||
SELECT id, user_id
|
||||
FROM bookings
|
||||
WHERE status = 'pending_release'
|
||||
AND start_time < $2
|
||||
AND end_time > $1
|
||||
@@ -4345,12 +4192,11 @@ func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, e
|
||||
WHERE description = 'PAYMENT_IN_FLIGHT:' || bookings.id
|
||||
AND start_time + (duration_minutes * INTERVAL '1 minute') > NOW()
|
||||
)
|
||||
RETURNING id, user_id
|
||||
FOR UPDATE
|
||||
`, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var evicted []EvictedBooking
|
||||
for rows.Next() {
|
||||
@@ -4360,7 +4206,41 @@ func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, e
|
||||
}
|
||||
evicted = append(evicted, e)
|
||||
}
|
||||
return evicted, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Refund FIRST — before any status flip. The refund rows must exist (or the
|
||||
// eviction must abort) before the customer's slot is re-sold.
|
||||
for _, e := range evicted {
|
||||
bookingStart, totalAmount, totalPaid, infoErr := evictedBookingPaymentInfo(ctx, tx, e.ID)
|
||||
if infoErr != nil {
|
||||
return nil, fmt.Errorf("failed to read payment info for evicted booking %s: %w", e.ID, infoErr)
|
||||
}
|
||||
if totalPaid <= 0 {
|
||||
continue
|
||||
}
|
||||
// forceFullRefund=true: the eviction is the business taking the slot
|
||||
// back, so the notice-tier retention would be unfair — the salon keeps
|
||||
// nothing, exactly like a business-initiated admin cancellation.
|
||||
if _, refundErr := payments.ProcessCancellationRefundTx(ctx, tx, e.ID, totalAmount, totalPaid, bookingStart, clock.Now(), "deposit_lapsed", nil, true); refundErr != nil {
|
||||
return nil, fmt.Errorf("failed to process cancellation refund for evicted booking %s: %w", e.ID, refundErr)
|
||||
}
|
||||
}
|
||||
|
||||
// THEN flip the status. The FOR UPDATE row locks above hold the booking in
|
||||
// pending_release until the caller commits, so the guard predicate stays true.
|
||||
for _, e := range evicted {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE bookings SET status = 'deposit_lapsed', updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending_release'
|
||||
`, e.ID); err != nil {
|
||||
return nil, fmt.Errorf("failed to flip evicted booking %s to deposit_lapsed: %w", e.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return evicted, nil
|
||||
}
|
||||
|
||||
// TODO: notify the evicted user that their slot was released
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/user"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
@@ -4232,6 +4233,65 @@ func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookings_Create_DepositPaidFalseOnUnpaidBooking is a regression test for
|
||||
// the P0 deposit-never-charged bug: CreateBookingHandler returned the booking
|
||||
// from INSERT..RETURNING, which predates the recalc trigger, so TotalAmount
|
||||
// serialized as 0 and DepositPaid computed TRUE on an unpaid booking. The
|
||||
// frontend gate then trusted deposit_paid:true and never charged the deposit.
|
||||
// The create response must report the real trigger-maintained total and
|
||||
// deposit_paid=false.
|
||||
func TestBookings_Create_DepositPaidFalseOnUnpaidBooking(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)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
if _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID); err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(tx, serviceID)
|
||||
|
||||
var price float64
|
||||
if err := tx.QueryRow(ctx, "SELECT price FROM services WHERE id = $1", serviceID).Scan(&price); err != nil {
|
||||
t.Fatalf("failed to read service price: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
start := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 10, 0, 0, 0, start.Location())
|
||||
|
||||
w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
|
||||
CreateBookingRequest{StartTime: start, ServiceIDs: []string{serviceID}}, token, ctx)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var booking Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse create response: %v", err)
|
||||
}
|
||||
|
||||
if booking.TotalAmount != price {
|
||||
t.Errorf("create response TotalAmount = %v, want service price %v (must not be 0)", booking.TotalAmount, price)
|
||||
}
|
||||
if booking.DepositRequired && booking.DepositAmount <= 0 {
|
||||
t.Errorf("create response DepositAmount = %v, want > 0 for a deposit-required booking", booking.DepositAmount)
|
||||
}
|
||||
if booking.DepositPaid {
|
||||
t.Error("create response DepositPaid = true for a freshly created UNPAID booking — this defeats the frontend deposit gate and the deposit is never charged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookings_Get_DepositFieldsReturned verifies that GET /api/bookings returns
|
||||
// the deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline).
|
||||
func TestBookings_Get_DepositFieldsReturned(t *testing.T) {
|
||||
@@ -7642,3 +7702,315 @@ func TestGetAllUserBookings_TotalCountMatches(t *testing.T) {
|
||||
t.Errorf("expected no nextCursor on last page, got %q", *resp3.NextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvictPendingReleaseOverlapping_RefundsPaidDeposit pins C4: evicting a
|
||||
// pending_release booking that already carries a paid deposit must NOT lose the
|
||||
// customer's money while the slot is re-sold. The eviction refunds every
|
||||
// payment through the exported cancellation-refund machinery
|
||||
// (payments.ProcessCancellationRefundTx) inside the same transaction, then
|
||||
// flips the booking to 'deposit_lapsed'. The eviction is business-initiated,
|
||||
// so the full-refund override applies: a refund row is created, the user's
|
||||
// balance is credited, and the status flips to the terminal state.
|
||||
func TestEvictPendingReleaseOverlapping_RefundsPaidDeposit(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
future := clock.Now().Add(72 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set pending_release: %v", err)
|
||||
}
|
||||
|
||||
// Customer paid a £20 cash deposit before the eviction.
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 20.00, "cash", "deposit", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create deposit payment: %v", err)
|
||||
}
|
||||
|
||||
evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err)
|
||||
}
|
||||
if len(evicted) != 1 || evicted[0].ID != bookingID {
|
||||
t.Fatalf("expected 1 evicted booking (%s), got %+v", bookingID, evicted)
|
||||
}
|
||||
|
||||
// Status flipped to the terminal deposit_lapsed state.
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "deposit_lapsed" {
|
||||
t.Errorf("expected booking status 'deposit_lapsed', got %q", status)
|
||||
}
|
||||
|
||||
// Refund row created via the cancellation-refund machinery.
|
||||
var rPaymentID, rStatus, rReason, rOrigin string
|
||||
var rAmount float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT payment_id, amount, status, reason, origin
|
||||
FROM refunds WHERE booking_id = $1
|
||||
`, bookingID).Scan(&rPaymentID, &rAmount, &rStatus, &rReason, &rOrigin); err != nil {
|
||||
t.Fatalf("failed to query refund row: %v", err)
|
||||
}
|
||||
if rPaymentID != paymentID {
|
||||
t.Errorf("expected refund for payment %s, got %s", paymentID, rPaymentID)
|
||||
}
|
||||
if rAmount != 20.0 {
|
||||
t.Errorf("expected refund amount 20.00, got %.2f", rAmount)
|
||||
}
|
||||
if rStatus != "completed" {
|
||||
t.Errorf("expected refund status 'completed' (cash refund credited), got %q", rStatus)
|
||||
}
|
||||
if rReason != "deposit_lapsed" {
|
||||
t.Errorf("expected refund reason 'deposit_lapsed', got %q", rReason)
|
||||
}
|
||||
if rOrigin != "cancellation" {
|
||||
t.Errorf("expected refund origin 'cancellation', got %q", rOrigin)
|
||||
}
|
||||
|
||||
// Balance check: the cash refund credited the booking user's balance.
|
||||
var balance float64
|
||||
if err := tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance); err != nil {
|
||||
t.Fatalf("failed to query user balance: %v", err)
|
||||
}
|
||||
if balance != 20.0 {
|
||||
t.Errorf("expected user balance 20.00, got %.2f", balance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvictPendingReleaseOverlapping_CardRefundPending pins the card half of
|
||||
// C4: an evicted booking paid by card records its refund as 'pending' (the
|
||||
// post-commit sweep-pending-square-refunds job settles it against Square), so
|
||||
// card money is protected too — the row exists in the same transaction that
|
||||
// flips the status, and no money is silently kept.
|
||||
func TestEvictPendingReleaseOverlapping_CardRefundPending(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
future := clock.Now().Add(72 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set pending_release: %v", err)
|
||||
}
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 30.00, "online_square", "deposit", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create deposit payment: %v", err)
|
||||
}
|
||||
|
||||
evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err)
|
||||
}
|
||||
if len(evicted) != 1 || evicted[0].ID != bookingID {
|
||||
t.Fatalf("expected 1 evicted booking (%s), got %+v", bookingID, evicted)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "deposit_lapsed" {
|
||||
t.Errorf("expected booking status 'deposit_lapsed', got %q", status)
|
||||
}
|
||||
|
||||
// Card refunds are recorded 'pending' for the post-commit Square sweep.
|
||||
var rPaymentID, rStatus, rReason string
|
||||
var rSquareRefundID *string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT payment_id, status, reason, square_refund_id
|
||||
FROM refunds WHERE booking_id = $1
|
||||
`, bookingID).Scan(&rPaymentID, &rStatus, &rReason, &rSquareRefundID); err != nil {
|
||||
t.Fatalf("failed to query refund row: %v", err)
|
||||
}
|
||||
if rPaymentID != paymentID {
|
||||
t.Errorf("expected refund for payment %s, got %s", paymentID, rPaymentID)
|
||||
}
|
||||
if rStatus != "pending" {
|
||||
t.Errorf("expected refund status 'pending' (Square sweep settles it), got %q", rStatus)
|
||||
}
|
||||
if rReason != "deposit_lapsed" {
|
||||
t.Errorf("expected refund reason 'deposit_lapsed', got %q", rReason)
|
||||
}
|
||||
if rSquareRefundID != nil {
|
||||
t.Errorf("expected no Square refund id yet, got %q", *rSquareRefundID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateBooking_Notifications_NewBookingFloodCap pins C5 for the
|
||||
// 'new_booking' insert site: the unacknowledged queue is flood-capped at
|
||||
// adminnotify.MaxUnacknowledgedCriticalLogs, so a booking flood cannot bury
|
||||
// the operator's notification centre. The booking itself is still created
|
||||
// (suppression only drops the notification), and the queue stays bounded.
|
||||
func TestCreateBooking_Notifications_NewBookingFloodCap(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)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(tx, serviceID)
|
||||
|
||||
// Fill the unacknowledged 'new_booking' queue to the cap before the booking
|
||||
// is created, so the insert site must suppress instead of growing it.
|
||||
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, user_id, created_at)
|
||||
VALUES ('new_booking', $1, NOW())
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("failed to seed new_booking notification %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "new_booking") {
|
||||
t.Fatal("expected the unacknowledged new_booking queue to be at the cap")
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The booking was still created, but the new_booking queue stayed at the cap.
|
||||
var n int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'new_booking'`).Scan(&n); err != nil {
|
||||
t.Fatalf("failed to count new_booking notifications: %v", err)
|
||||
}
|
||||
if n != adminnotify.MaxUnacknowledgedCriticalLogs {
|
||||
t.Errorf("expected the new_booking queue to stay capped at %d, got %d", adminnotify.MaxUnacknowledgedCriticalLogs, n)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IDOR Fix: DeleteBookingHandler cross-user payment existence leak
|
||||
// =============================================================================
|
||||
|
||||
// TestDeleteBooking_OtherUser_NoPaymentLeak verifies the IDOR fix: when user 2
|
||||
// tries to DELETE booking A (which belongs to user 1 and HAS payments), the
|
||||
// response must be 404 — NOT 400 asking for a cancellation reason — so user 2
|
||||
// cannot infer that booking A has payments.
|
||||
func TestDeleteBooking_OtherUser_NoPaymentLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// Create user 1 (the actual booking owner).
|
||||
userA, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user A: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userA)
|
||||
|
||||
// Create user 2 (the attacker).
|
||||
userB, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user B: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userB)
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id IN ($1, $2)", userA, userB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(tx, serviceID)
|
||||
|
||||
// Booking A belongs to user A. Use a far-future booking so the no-show
|
||||
// check does not interfere with the test's cancellation path.
|
||||
future := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
bookingAID, err := fixtures.CreateTestBookingAtTime(tx, userA, serviceID, future)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking A: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(tx, bookingAID)
|
||||
|
||||
// Add a payment to booking A (this is what the IDOR check leaks).
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingAID, 50.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment for booking A: %v", err)
|
||||
}
|
||||
|
||||
// User 2 tries to delete booking A. The handler must NOT reveal that
|
||||
// payments exist (400 asking for reason) — it must return 404.
|
||||
tokenB := jwt.GenerateUserToken(userB)
|
||||
handler := http.HandlerFunc(DeleteBookingHandler)
|
||||
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingAID, nil, tokenB, ctx)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for cross-user delete, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify the response body does NOT contain "reason" or "client_cancelled"
|
||||
// or "Invalid request" — any of which would leak the payment existence.
|
||||
bodyStr := w.Body.String()
|
||||
if bodyStr != "" {
|
||||
var resp map[string]interface{}
|
||||
if err := parseResponseBody(w, &resp); err == nil {
|
||||
if _, hasReason := resp["reason"]; hasReason {
|
||||
t.Error("response must not leak a 'reason' field — that would reveal payments exist")
|
||||
}
|
||||
if _, hasMsg := resp["error"]; hasMsg {
|
||||
if msg, ok := resp["error"].(string); ok && msg != "Booking not found or access denied" && msg != "Booking not found" {
|
||||
t.Errorf("response error %q must not leak payment existence", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify booking A still exists (nothing was deleted).
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM bookings WHERE id = $1)", bookingAID).Scan(&exists); err != nil {
|
||||
t.Fatalf("failed to check booking A existence: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("booking A was deleted by the cross-user request — IDOR vulnerability!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
)
|
||||
|
||||
// ErrPastClosing is returned by checkClosingHours when the booking's end time
|
||||
@@ -15,6 +19,52 @@ var ErrPastClosing = errors.New("booking extends beyond closing hours")
|
||||
// time) does not exceed the closing time stored as "HH:MM" in the working_hours
|
||||
// table. Returns ErrPastClosing if the booking runs past close, or a generic
|
||||
// error if closeStr cannot be parsed.
|
||||
// getClosingTimeForDate resolves the closing time for a booking on a given
|
||||
// date/weekday. It first checks for a pending default_hours_scheduled_changes
|
||||
// whose effective_date <= the booking date; if found, the staged hours' closing
|
||||
// time for that weekday is returned. Otherwise falls back to the current
|
||||
// working_hours table. Returns the closing time as "HH:MM" (or "HH:MM:SS").
|
||||
func getClosingTimeForDate(ctx context.Context, q db.Querier, weekday int, bookingDate time.Time) (string, error) {
|
||||
// Check for a pending staged change that applies to this date
|
||||
type stagedHour struct {
|
||||
Weekday int `json:"weekday"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
IsOpen bool `json:"isOpen"`
|
||||
}
|
||||
var hoursJSON string
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT hours::text
|
||||
FROM default_hours_scheduled_changes
|
||||
WHERE effective_date <= $1::date
|
||||
AND applied_at IS NULL
|
||||
AND cancelled_at IS NULL
|
||||
ORDER BY effective_date DESC
|
||||
LIMIT 1
|
||||
`, bookingDate.Format("2006-01-02")).Scan(&hoursJSON)
|
||||
if err == nil && hoursJSON != "" {
|
||||
var staged []stagedHour
|
||||
if json.Unmarshal([]byte(hoursJSON), &staged) == nil {
|
||||
for _, h := range staged {
|
||||
if h.Weekday == weekday && h.IsOpen {
|
||||
return h.EndTime, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Staged change found but weekday is closed — return "00:00" so the
|
||||
// caller can reject the booking (the day would be closed under the
|
||||
// staged schedule).
|
||||
return "00:00", nil
|
||||
}
|
||||
|
||||
// Fall back to current working_hours table
|
||||
var closeStr string
|
||||
if err := q.QueryRow(ctx, `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return closeStr, nil
|
||||
}
|
||||
|
||||
func checkClosingHours(localEnd time.Time, closeStr string) error {
|
||||
parts := strings.Split(closeStr, ":")
|
||||
if len(parts) < 2 {
|
||||
|
||||
@@ -5,6 +5,10 @@ package bookings
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
|
||||
func TestCheckClosingHours_WithinHours(t *testing.T) {
|
||||
@@ -91,3 +95,107 @@ func TestCheckClosingHours_InvalidFormat(t *testing.T) {
|
||||
func IsPastClosing(err error) bool {
|
||||
return err == ErrPastClosing
|
||||
}
|
||||
|
||||
func TestGetClosingTimeForDate_FallsBackToWorkingHours(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
closeStr, err := getClosingTimeForDate(ctx, tx, 1, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if closeStr == "" {
|
||||
t.Error("expected non-empty closing time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClosingTimeForDate_StagedChangeOpenDay(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, adminID)
|
||||
|
||||
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
||||
VALUES ($1, $2, $3)
|
||||
`, tomorrow, adminID, hoursJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert staged change: %v", err)
|
||||
}
|
||||
|
||||
bookingDate := time.Now().AddDate(0, 0, 1)
|
||||
closeStr, err := getClosingTimeForDate(ctx, tx, 0, bookingDate)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if closeStr != "18:00" {
|
||||
t.Errorf("expected 18:00 from staged change, got %q", closeStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClosingTimeForDate_StagedChangeClosedDay(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, adminID)
|
||||
|
||||
hoursJSON := `[{"weekday":0,"startTime":"00:00","endTime":"00:00","isOpen":false}]`
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
||||
VALUES ($1, $2, $3)
|
||||
`, tomorrow, adminID, hoursJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert staged change: %v", err)
|
||||
}
|
||||
|
||||
tomorrowLondon := time.Now().AddDate(0, 0, 1).In(clock.London)
|
||||
weekday := int((tomorrowLondon.Weekday() + 6) % 7)
|
||||
|
||||
closeStr, err := getClosingTimeForDate(ctx, tx, weekday, tomorrowLondon)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if closeStr != "00:00" {
|
||||
t.Errorf("expected 00:00 for closed day, got %q", closeStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClosingTimeForDate_StagedChangeNotYetEffective(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, adminID)
|
||||
|
||||
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
||||
VALUES ($1, $2, $3)
|
||||
`, tomorrow, adminID, hoursJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert staged change: %v", err)
|
||||
}
|
||||
|
||||
closeStr, err := getClosingTimeForDate(ctx, tx, 1, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if closeStr == "" {
|
||||
t.Error("expected non-empty closing time")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
@@ -360,9 +363,11 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
// Booking starting in 48 hours (24-72h window, no payments).
|
||||
midRange := clock.Now().Add(48 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
|
||||
// Booking 24-72h from now (no-show warning tier) at a fixed working-hour slot:
|
||||
// 10:00 UTC two days out is always 35-58h away, so the warning fires but the
|
||||
// "too close" 403 never does, at any wall-clock hour.
|
||||
bookingTime := fixtures.NextWorkingDayAt(2, 10)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
@@ -373,7 +378,8 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
newTime := midRange.Add(48 * time.Hour)
|
||||
// 10:00 UTC four days out — same slot as bookingTime, inside working hours.
|
||||
newTime := bookingTime.Add(48 * time.Hour)
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
||||
@@ -425,7 +431,14 @@ func TestRequestEditHandler_NoticePeriod_AllowsWhenEnoughNotice(t *testing.T) {
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
newTime := bookingTime.Add(48 * time.Hour)
|
||||
// Set the new time to 10:00 London time on a weekday that's clearly within
|
||||
// working hours. Compute from the current time to avoid DST boundary issues.
|
||||
londonNow := clock.Now().In(clock.London)
|
||||
nextWeekday := londonNow.AddDate(0, 0, 1)
|
||||
for nextWeekday.Weekday() == time.Sunday || nextWeekday.Weekday() == time.Monday {
|
||||
nextWeekday = nextWeekday.AddDate(0, 0, 1)
|
||||
}
|
||||
newTime := time.Date(nextWeekday.Year(), nextWeekday.Month(), nextWeekday.Day(), 10, 0, 0, 0, clock.London).UTC()
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
||||
@@ -567,6 +580,12 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// A real admin user so the refund row's created_by FK is satisfied.
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
@@ -584,20 +603,45 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
// Give the payment a Square reference so the post-commit sweep does NOT
|
||||
// terminal-pre-pass it to 'failed' (NULL square refs are unresolvable).
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_forgive_fees' WHERE id = $1", paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set square_payment_id: %v", err)
|
||||
}
|
||||
|
||||
// Commit the setup so the handler runs at pool level: AdminCancelBookingHandler
|
||||
// acquires pg_advisory_xact_lock on the card payments, and inside the test-env
|
||||
// outer per-test transaction (savepoints don't release xact locks) the locks
|
||||
// would deadlock the post-commit sweep's session locks. Pool-level mirrors
|
||||
// production, where the handler's tx commits and releases the locks.
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit test tx: %v", err)
|
||||
}
|
||||
freshCtx := context.Background()
|
||||
|
||||
// Make the post-commit refund processing leave the row 'pending' (ambiguous
|
||||
// Square transport failure → refund_attempts=1) so the test can assert the
|
||||
// row exists as pending with the full amount.
|
||||
origSquare := payments.SquareClient
|
||||
forgiveFeesClient := &forgiveFeesAmbiguousClient{SquareClient: square.NewDevClient()}
|
||||
payments.SquareClient = forgiveFeesClient
|
||||
defer func() { payments.SquareClient = origSquare }()
|
||||
|
||||
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
|
||||
"forgive_fees": true,
|
||||
}, func(baseCtx context.Context) context.Context {
|
||||
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
if info := extractUserFromTestJWT(adminToken); info != nil {
|
||||
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
|
||||
}
|
||||
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
||||
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
|
||||
return baseCtx
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -618,6 +662,70 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
|
||||
if refundCalc["refundable_amount"] != 100.0 {
|
||||
t.Errorf("expected refundable_amount 100, got %v", refundCalc["refundable_amount"])
|
||||
}
|
||||
|
||||
// The forgiven-fees FULL refund must actually execute — a refund row for
|
||||
// the whole £100 must exist as 'pending' (created by ProcessCancellationRefundTx,
|
||||
// resolved post-commit), not just a synthetic response claim.
|
||||
var refundCount int
|
||||
err = db.Conn.QueryRow(freshCtx,
|
||||
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refunds: %v", err)
|
||||
}
|
||||
if refundCount < 1 {
|
||||
t.Errorf("expected at least 1 refund row for forgiven fees full refund, got %d", refundCount)
|
||||
}
|
||||
var refundStatus string
|
||||
var refundAmount float64
|
||||
err = db.Conn.QueryRow(freshCtx,
|
||||
"SELECT status, amount FROM refunds WHERE booking_id = $1 LIMIT 1", bookingID).Scan(&refundStatus, &refundAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refund row: %v", err)
|
||||
}
|
||||
if refundStatus != "pending" {
|
||||
t.Errorf("expected refund status 'pending', got %q", refundStatus)
|
||||
}
|
||||
if refundAmount != 100 {
|
||||
t.Errorf("expected refund amount 100, got %.2f", refundAmount)
|
||||
}
|
||||
|
||||
// The Square sweep must be called with the forgiven-fees reason — the
|
||||
// reason is threaded from the refund rows through to the post-commit
|
||||
// ProcessPendingSquareRefunds call (P3b), not the generic admin_cancelled.
|
||||
calls := forgiveFeesClient.refundCalls()
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("expected the post-commit sweep to call Square RefundPayment")
|
||||
}
|
||||
if calls[0].Reason != "admin_forgiven_fees" {
|
||||
t.Errorf("expected Square refund reason %q, got %q", "admin_forgiven_fees", calls[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// forgiveFeesAmbiguousClient simulates a transport-level Square failure so the
|
||||
// ForgiveFees test's post-commit refund processing leaves the row 'pending'.
|
||||
// It also records every RefundPayment request so the test can assert the
|
||||
// forgiven-fees reason reaches Square.
|
||||
type forgiveFeesAmbiguousClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
calls []square.RefundPaymentReq
|
||||
}
|
||||
|
||||
func (c *forgiveFeesAmbiguousClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
||||
c.mu.Lock()
|
||||
c.calls = append(c.calls, req)
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("network error: connection reset by peer")
|
||||
}
|
||||
|
||||
func (c *forgiveFeesAmbiguousClient) refundCalls() []square.RefundPaymentReq {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]square.RefundPaymentReq(nil), c.calls...)
|
||||
}
|
||||
|
||||
func (c *forgiveFeesAmbiguousClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
||||
return nil, fmt.Errorf("network error: connection reset by peer")
|
||||
}
|
||||
|
||||
func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -103,10 +104,18 @@ func createTestCampaign(t *testing.T, name, campaignType string, percent float64
|
||||
|
||||
func insertInPersonCardPayment(t *testing.T, bookingID string, ctx context.Context) {
|
||||
t.Helper()
|
||||
_, err := db.Conn.Exec(ctx, `
|
||||
// F1 fixture: the in-person card payment covers the DEPOSIT (half the
|
||||
// booking total), not the full amount. A full-amount fixture would now
|
||||
// trigger the completion-time over-credit cap (capDiscountToRemainingObligation),
|
||||
// which correctly skips the stacked campaign discounts these tests assert.
|
||||
var total float64
|
||||
err := db.Conn.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&total)
|
||||
require.NoError(t, err)
|
||||
deposit := math.Round(total*100/2) / 100
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
VALUES ($1, 'full', 'in_person_card', $2, 'completed', NOW(), NOW())
|
||||
`, bookingID, deposit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//go:build test && dev
|
||||
|
||||
package bookings
|
||||
|
||||
// Strict timezone/DST/midnight-boundary tests for the booking closing-hours
|
||||
// pipeline and the date-window queries. All assertions use fixed time.Date
|
||||
// instants (no wall-clock "now" drift) so they are deterministic on every run.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
)
|
||||
|
||||
// TestCheckClosingHours_GMTvsBST proves the closing-time pipeline is
|
||||
// DST-safe: the same 16:30 wall-clock end is accepted and the same 17:30
|
||||
// wall-clock end is rejected during BOTH a GMT season (2026-01-15, offset +0)
|
||||
// and a BST season (2026-06-15, offset +1). The same wall-clock instant maps
|
||||
// to UTC instants that differ by exactly the 1h DST offset between seasons —
|
||||
// the conversion via londonLocation must absorb that shift.
|
||||
func TestCheckClosingHours_GMTvsBST(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// 2026-01-15 is a Thursday (DB weekday 3), 2026-06-15 is a Monday (DB
|
||||
// weekday 0). Pin closing at 17:00 on both so the resolution goes through
|
||||
// the real getClosingTimeForDate -> working_hours path.
|
||||
for _, wd := range []int{0, 3} {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, '08:00', '17:00', true)
|
||||
ON CONFLICT (weekday) DO UPDATE SET start_time = '08:00', end_time = '17:00', is_open = true
|
||||
`, wd)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set working hours for weekday %d: %v", wd, err)
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
localEnd time.Time // Europe/London wall-clock booking end
|
||||
wantUTC int // expected UTC hour of that same instant
|
||||
}{
|
||||
{"GMT 2026-01-15", time.Date(2026, 1, 15, 16, 30, 0, 0, clock.London), 16},
|
||||
{"BST 2026-06-15", time.Date(2026, 6, 15, 16, 30, 0, 0, clock.London), 15},
|
||||
}
|
||||
|
||||
// The SAME 16:30 wall-clock end is 16:30 UTC in GMT and 15:30 UTC in BST —
|
||||
// a shift of exactly 1h (the DST offset).
|
||||
if diff := cases[0].localEnd.UTC().Hour() - cases[1].localEnd.UTC().Hour(); diff != 1 {
|
||||
t.Fatalf("expected the two 16:30-local ends to differ by exactly 1h in UTC, got %dh", diff)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+" pass 16:30", func(t *testing.T) {
|
||||
weekday := int((tc.localEnd.Weekday() + 6) % 7)
|
||||
closeStr, err := getClosingTimeForDate(ctx, tx, weekday, tc.localEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("getClosingTimeForDate failed: %v", err)
|
||||
}
|
||||
// The DB stores end_time as TIME so ::text returns "17:00:00".
|
||||
if closeStr != "17:00" && closeStr != "17:00:00" {
|
||||
t.Fatalf("expected closing 17:00 for %s, got %q", tc.name, closeStr)
|
||||
}
|
||||
if err := checkClosingHours(tc.localEnd, closeStr); err != nil {
|
||||
t.Errorf("16:30 local end on %s must pass closing 17:00, got: %v", tc.name, err)
|
||||
}
|
||||
if got := tc.localEnd.UTC().Hour(); got != tc.wantUTC {
|
||||
t.Errorf("16:30 local on %s should be %02d:30 UTC, got hour %d", tc.name, tc.wantUTC, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
failCases := []struct {
|
||||
name string
|
||||
localEnd time.Time
|
||||
}{
|
||||
{"GMT 2026-01-15", time.Date(2026, 1, 15, 17, 30, 0, 0, clock.London)},
|
||||
{"BST 2026-06-15", time.Date(2026, 6, 15, 17, 30, 0, 0, clock.London)},
|
||||
}
|
||||
for _, tc := range failCases {
|
||||
t.Run(tc.name+" fail 17:30", func(t *testing.T) {
|
||||
if err := checkClosingHours(tc.localEnd, "17:00"); !IsPastClosing(err) {
|
||||
t.Errorf("17:30 local end on %s must be rejected, got: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookingDateWindow_DSTMidnight proves the booking date-window queries
|
||||
// (bookings.go GetAllUserBookingsHandler start_date/end_date conversion) bucket
|
||||
// bookings by their LONDON calendar date, not their UTC date. A booking at
|
||||
// 2026-06-14 23:30 UTC is 2026-06-15 00:30 BST — inside the 00:00-01:00 BST
|
||||
// window where the UTC date differs from the London date — and must appear in
|
||||
// the "2026-06-15" window, not "2026-06-14".
|
||||
func TestBookingDateWindow_DSTMidnight(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
// Precondition: 2026-06-14 23:30 UTC must be 2026-06-15 00:30 BST (the
|
||||
// UTC-date != London-date window). This is the exact property under test.
|
||||
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
if got := boundary.In(clock.London).Format("2006-01-02"); got != "2026-06-15" {
|
||||
t.Fatalf("test setup invariant: 2026-06-14 23:30 UTC must be London date 2026-06-15, got %s", got)
|
||||
}
|
||||
|
||||
// Booking A: the midnight-boundary booking (00:30 BST on the 15th).
|
||||
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, boundary)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create boundary booking: %v", err)
|
||||
}
|
||||
// Booking B: 22:30 UTC on the 14th = 23:30 BST on the 14th (clearly the 14th).
|
||||
bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create day-before booking: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Window 2026-06-15: only the boundary booking belongs (00:30 BST on the
|
||||
// 15th); the 22:30-UTC booking is 23:30 BST on the 14th.
|
||||
w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET",
|
||||
"/api/bookings?start_date=2026-06-15&end_date=2026-06-15", nil, token, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp BookingListResponse
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 || len(resp.Bookings) != 1 || resp.Bookings[0].ID != bookingA {
|
||||
t.Errorf("window 2026-06-15: expected only booking A (%s) at 00:30 BST, got total=%d ids=%v",
|
||||
bookingA, resp.Total, bookingIDs(resp.Bookings))
|
||||
}
|
||||
|
||||
// Window 2026-06-14: only booking B belongs; the 23:30-UTC boundary booking
|
||||
// has already rolled over to London date 2026-06-15.
|
||||
w2 := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET",
|
||||
"/api/bookings?start_date=2026-06-14&end_date=2026-06-14", nil, token, ctx)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var resp2 BookingListResponse
|
||||
if err := parseResponseBody(w2, &resp2); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp2.Total != 1 || len(resp2.Bookings) != 1 || resp2.Bookings[0].ID != bookingB {
|
||||
t.Errorf("window 2026-06-14: expected only booking B (%s), got total=%d ids=%v",
|
||||
bookingB, resp2.Total, bookingIDs(resp2.Bookings))
|
||||
}
|
||||
}
|
||||
|
||||
func bookingIDs(bookings []Booking) []string {
|
||||
ids := make([]string, len(bookings))
|
||||
for i, b := range bookings {
|
||||
ids[i] = b.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1574,10 +1574,10 @@ func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create first booking at time T
|
||||
// Use a start time <48h away so auto-approval doesn't trigger at request-
|
||||
// creation time, allowing us to test the approval-time overlap check.
|
||||
baseTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
|
||||
// Booking2 (at baseTime + dur/2) is the edited booking; its start must sit
|
||||
// in the [24h,48h] window so RequestEditHandler neither 403s (too close) nor
|
||||
// auto-approves at creation.
|
||||
baseTime := fixtures.NextEditWindowTime(time.Duration(serviceDuration/2) * time.Minute)
|
||||
|
||||
booking1, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
if err != nil {
|
||||
@@ -2283,11 +2283,10 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) {
|
||||
t.Fatalf("first edit request failed: %d", w.Code)
|
||||
}
|
||||
|
||||
// Capture first notification's created_at
|
||||
var firstCreatedAt time.Time
|
||||
var firstNotifID string
|
||||
err := tx.QueryRow(ctx,
|
||||
"SELECT created_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'",
|
||||
bookingID).Scan(&firstCreatedAt)
|
||||
"SELECT id FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'",
|
||||
bookingID).Scan(&firstNotifID)
|
||||
if err != nil {
|
||||
t.Fatalf("expected first notification to exist: %v", err)
|
||||
}
|
||||
@@ -2303,10 +2302,7 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) {
|
||||
t.Fatalf("expected 1 notification after first request, got %d", notifCount)
|
||||
}
|
||||
|
||||
// Wait a moment so timestamps differ
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create second edit request (upsert)
|
||||
// Create second edit request (upsert) — deletes old notification, inserts new one
|
||||
w = makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request",
|
||||
map[string]interface{}{"new_start_time": time2.Format(time.RFC3339)}, token, ctx)
|
||||
if w.Code != http.StatusCreated {
|
||||
@@ -2324,14 +2320,17 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) {
|
||||
t.Errorf("expected 1 notification after upsert, got %d", notifCount)
|
||||
}
|
||||
|
||||
// Verify the notification has a fresh created_at (newer than original)
|
||||
var secondCreatedAt time.Time
|
||||
// Verify the notification was recreated (different ID from original)
|
||||
var secondNotifID string
|
||||
err = tx.QueryRow(ctx,
|
||||
"SELECT created_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'",
|
||||
bookingID).Scan(&secondCreatedAt)
|
||||
"SELECT id FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'",
|
||||
bookingID).Scan(&secondNotifID)
|
||||
if err != nil {
|
||||
t.Fatalf("expected notification to exist after upsert: %v", err)
|
||||
}
|
||||
if secondNotifID == firstNotifID {
|
||||
t.Errorf("expected notification to be recreated (new ID), but got same ID: %s", secondNotifID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected verifies that admin cannot approve an edit request
|
||||
@@ -2388,3 +2387,93 @@ func TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected(t *testing.T) {
|
||||
t.Errorf("expected status 409 (conflict), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestEditHandler_PastClosing_Blocked verifies that proposing a time
|
||||
// whose end extends past closing hours returns a 400 error.
|
||||
// Uses the bookings test DB hours (all days 08:00-20:00).
|
||||
func TestRequestEditHandler_PastClosing_Blocked(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, _, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
|
||||
// Test DB seed data: all days 08:00-20:00. Propose 19:30 which ends at
|
||||
// 20:30 for a 60-min service — past the 20:00 closing.
|
||||
nextDay := clock.Now().In(clock.London).AddDate(0, 0, 1)
|
||||
pastClose := time.Date(nextDay.Year(), nextDay.Month(), nextDay.Day(), 19, 30, 0, 0, clock.London).UTC()
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
||||
"new_start_time": pastClose.Format(time.RFC3339),
|
||||
}, token, ctx)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for past-closing time, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestEditHandler_StagedHoursClosed_Blocked verifies that proposing a
|
||||
// time on a day that becomes closed under a staged default hours change is
|
||||
// blocked with a 400 error.
|
||||
func TestRequestEditHandler_StagedHoursClosed_Blocked(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
_ = serviceID
|
||||
|
||||
// Insert a staged change closing a weekday that is normally open
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, adminID)
|
||||
|
||||
tomorrow := clock.Now().In(clock.London).AddDate(0, 0, 1).Format("2006-01-02")
|
||||
// Stage: all days closed
|
||||
hoursJSON := `[{"weekday":0,"startTime":"00:00","endTime":"00:00","isOpen":false},{"weekday":1,"startTime":"00:00","endTime":"00:00","isOpen":false},{"weekday":2,"startTime":"00:00","endTime":"00:00","isOpen":false},{"weekday":3,"startTime":"00:00","endTime":"00:00","isOpen":false},{"weekday":4,"startTime":"00:00","endTime":"00:00","isOpen":false},{"weekday":5,"startTime":"00:00","endTime":"00:00","isOpen":false},{"weekday":6,"startTime":"00:00","endTime":"00:00","isOpen":false}]`
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
||||
VALUES ($1, $2, $3)
|
||||
`, tomorrow, adminID, hoursJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert staged change: %v", err)
|
||||
}
|
||||
|
||||
// Propose a time on a day that is now closed under the staged change
|
||||
londonNow := clock.Now().In(clock.London)
|
||||
target := londonNow.AddDate(0, 0, 1)
|
||||
for target.Weekday() == time.Sunday {
|
||||
target = target.AddDate(0, 0, 1)
|
||||
}
|
||||
proposedTime := time.Date(target.Year(), target.Month(), target.Day(), 10, 0, 0, 0, clock.London).UTC()
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
||||
"new_start_time": proposedTime.Format(time.RFC3339),
|
||||
}, token, ctx)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for staged-closed day, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestEditHandler_ValidTime_Succeeds verifies that proposing a time
|
||||
// within open hours succeeds (returns 201).
|
||||
func TestRequestEditHandler_ValidTime_Succeeds(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, _, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
|
||||
// Propose 10:00 on a weekday that is open
|
||||
londonNow := clock.Now().In(clock.London)
|
||||
target := londonNow.AddDate(0, 0, 1)
|
||||
for target.Weekday() == time.Sunday {
|
||||
target = target.AddDate(0, 0, 1)
|
||||
}
|
||||
validTime := time.Date(target.Year(), target.Month(), target.Day(), 10, 0, 0, 0, clock.London).UTC()
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
||||
"new_start_time": validTime.Format(time.RFC3339),
|
||||
}, token, ctx)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201 for valid time, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,25 @@ import (
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
//lint:ignore U1000 referenced from tests
|
||||
//lint:ignore U1000 referenced from tests; route DELETE /api/bookings/{id} conflicts with bookings.DeleteBookingHandler at main.go:360
|
||||
func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
@@ -44,7 +46,7 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
@@ -95,11 +97,18 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("ALERT: failed to delete admin_notifications: %v", err)
|
||||
}
|
||||
|
||||
// Notify admins about the cancellation
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
// Notify admins about the cancellation — C5: the 'cancelled_booking' queue
|
||||
// is flood-capped per reason so a cancellation flood cannot bury the
|
||||
// operator's notification centre.
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "cancelled_booking") {
|
||||
log.Printf("ALERT: suppressed cancelled_booking admin notification — unacknowledged 'cancelled_booking' queue at the cap")
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ('cancelled_booking', $1, $2)
|
||||
`, bookingID, userID); err != nil {
|
||||
SELECT 'cancelled_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'cancelled_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("ALERT: failed to create admin notification for cancellation: %v", err)
|
||||
}
|
||||
|
||||
@@ -160,10 +169,10 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Get current status and user ID — use FOR UPDATE to lock the row so
|
||||
// the refund and status change are atomic.
|
||||
@@ -197,20 +206,20 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Status update succeeded — now process the refund in the SAME transaction
|
||||
// so that a commit failure rolls back both the status change and the refund.
|
||||
if forgiveFees && totalPaid > 0 {
|
||||
refundResult = &payments.RefundCalculationResult{
|
||||
TotalPrePaid: totalPaid,
|
||||
RefundableAmount: totalPaid,
|
||||
KeptAmount: 0,
|
||||
Tier: "admin_full_refund",
|
||||
// refundReason threads through to the refund rows AND the post-commit Square
|
||||
// sweep so forgiven-fee bookings are labelled "admin_forgiven_fees" (audit).
|
||||
refundReason := "admin_cancelled"
|
||||
if totalPaid > 0 {
|
||||
if forgiveFees {
|
||||
refundReason = "admin_forgiven_fees"
|
||||
// Full refund of the net pre-paid amount regardless of notice tier —
|
||||
// the override makes the refund actually execute (rows created and
|
||||
// swept), instead of the old synthetic response-only claim.
|
||||
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, true)
|
||||
} else if calculatedRefund {
|
||||
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, false)
|
||||
}
|
||||
}
|
||||
if calculatedRefund {
|
||||
var calc *payments.RefundCalculationResult
|
||||
calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID)
|
||||
if err == nil {
|
||||
refundResult = calc
|
||||
} else {
|
||||
if err != nil {
|
||||
refundFailed = true
|
||||
log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err)
|
||||
}
|
||||
@@ -232,14 +241,20 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// No admin notification needed for pending/admin-initiated cancellations —
|
||||
// the admin is the one performing the cancellation, so notifying them would
|
||||
// be self-referential.
|
||||
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
||||
if originalStatus != "pending" {
|
||||
notificationQuery := `
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "cancelled_booking") {
|
||||
log.Printf("Suppressed cancelled_booking admin notification for booking %s — unacknowledged 'cancelled_booking' queue at the cap", bookingID)
|
||||
} else if _, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1
|
||||
`
|
||||
_, err = tx.Exec(r.Context(), notificationQuery, bookingID)
|
||||
if err != nil {
|
||||
AND (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'cancelled_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $2
|
||||
`, bookingID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -271,8 +286,8 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Process pending Square refunds after the transaction commits successfully.
|
||||
// This ensures Square API calls only happen if the DB records persist.
|
||||
if calculatedRefund {
|
||||
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled")
|
||||
if totalPaid > 0 {
|
||||
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, refundReason)
|
||||
}
|
||||
|
||||
if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
|
||||
@@ -286,7 +301,9 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
resp["refund_failed"] = true
|
||||
resp["warning"] = "Booking was cancelled but refund processing failed — please process refund manually or retry"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,7 +322,7 @@ type AdminCreateBookingForUserRequest struct {
|
||||
OutOfHours bool `json:"out_of_hours"`
|
||||
}
|
||||
|
||||
//lint:ignore U1000 referenced from tests
|
||||
//lint:ignore U1000 referenced from tests; route GET /api/admin/today/current-next conflicts with today.GetCurrentAndNextHandler at main.go:452
|
||||
func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var b Booking
|
||||
var userID, fullName string
|
||||
@@ -359,7 +376,8 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -413,13 +431,16 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Get deposit info — deposit_required already fetched above in the main booking query.
|
||||
var preStartPaid float64
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure (aggregate with COALESCE)
|
||||
_ = db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
|
||||
if err := db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid); err != nil {
|
||||
log.Printf("Failed to scan preStartPaid for booking %s: %v", existingID, err)
|
||||
}
|
||||
populateDepositFields(&existingBooking, existingBooking.DepositRequired, preStartPaid)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(existingBooking)
|
||||
if err := json.NewEncoder(w).Encode(existingBooking); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -633,10 +654,19 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Also check if the day is closed under a staged default hours change
|
||||
// whose effective_date has been reached.
|
||||
closeStr, err := getClosingTimeForDate(r.Context(), db.Conn, weekday, localStart)
|
||||
if err == nil && (closeStr == "00:00" || closeStr == "00:00:00") {
|
||||
http.Error(w, "Not open on this day under upcoming schedule change", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlapping confirmed/in_progress/completed bookings
|
||||
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
|
||||
allIDs := append([]string{}, req.ServiceIDs...)
|
||||
allIDs = append(allIDs, req.CustomServiceIDs...)
|
||||
var dur int
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(SUM(dur), 0) FROM (
|
||||
@@ -666,10 +696,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Evict any pending_release bookings that overlap this slot.
|
||||
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil {
|
||||
@@ -1212,10 +1242,10 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Delete the edit request for this booking
|
||||
res, err := tx.Exec(r.Context(), `
|
||||
@@ -1286,13 +1316,11 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// M8
|
||||
// L5
|
||||
|
||||
// Validate: at least one of new_start_time, new_services, or notes must be provided
|
||||
if req.NewStartTime == nil && len(req.NewServices) == 0 && req.Notes == nil {
|
||||
http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest)
|
||||
@@ -1320,8 +1348,9 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Check booking is not already completed/cancelled
|
||||
var currentStatus string
|
||||
var currentStartTime time.Time
|
||||
var currentDuration int
|
||||
var depositRequired bool
|
||||
err = db.Conn.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, ¤tStartTime, &depositRequired)
|
||||
err = db.Conn.QueryRow(r.Context(), "SELECT status, start_time, total_duration_minutes, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, ¤tStartTime, ¤tDuration, &depositRequired)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -1389,6 +1418,32 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate proposed time is within working hours (including staged hours changes)
|
||||
if req.NewStartTime != nil {
|
||||
newTime := req.NewStartTime.In(londonLocation)
|
||||
weekday := int((newTime.Weekday() + 6) % 7)
|
||||
|
||||
// Get closing time (respects staged default hours changes)
|
||||
closeStr, err := getClosingTimeForDate(r.Context(), db.Conn, weekday, newTime)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get hours for edit request: %v", err)
|
||||
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if closeStr == "00:00" || closeStr == "00:00:00" {
|
||||
http.Error(w, "The salon is closed at this time on this day", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check the proposed end time (based on the booking's service duration)
|
||||
// doesn't extend beyond closing hours.
|
||||
localEnd := newTime.Add(time.Duration(currentDuration) * time.Minute)
|
||||
if err := checkClosingHours(localEnd, closeStr); err != nil {
|
||||
http.Error(w, "This time would extend beyond closing hours", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
@@ -1396,10 +1451,10 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Delete any existing edit request for this booking (upsert behavior)
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
@@ -1561,10 +1616,12 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"auto_approved": true,
|
||||
"edit_request": editReq,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1623,11 +1680,15 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "edit_requested") {
|
||||
log.Printf("Suppressed edit_requested admin notification for booking %s — unacknowledged 'edit_requested' queue at the cap", bookingID)
|
||||
} else if _, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ('edit_requested', $1, $2)
|
||||
`, bookingID, userID)
|
||||
if err != nil {
|
||||
SELECT 'edit_requested', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'edit_requested'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1644,11 +1705,15 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "pending_booking") {
|
||||
log.Printf("Suppressed pending_booking admin notification for booking %s — unacknowledged 'pending_booking' queue at the cap", bookingID)
|
||||
} else if _, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ('pending_booking', $1, $2)
|
||||
`, bookingID, userID)
|
||||
if err != nil {
|
||||
SELECT 'pending_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'pending_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
@@ -1661,7 +1726,9 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(editReq)
|
||||
if err := json.NewEncoder(w).Encode(editReq); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListEditRequestsHandler returns all edit requests
|
||||
@@ -1684,8 +1751,9 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var total int
|
||||
|
||||
// Count query (no ORDER BY needed).
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total); err != nil {
|
||||
log.Printf("Failed to scan booking edit request count: %v", err)
|
||||
}
|
||||
|
||||
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
|
||||
if err != nil {
|
||||
@@ -1741,10 +1809,12 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"requests": requests,
|
||||
"total": total,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminApproveEditRequestHandler approves an edit request and updates the booking
|
||||
@@ -1768,10 +1838,10 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Get the edit request
|
||||
var bookingID string
|
||||
@@ -1930,6 +2000,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Cannot approve: the proposed time falls during a period when the salon is closed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Also check the day isn't closed under a staged default hours change
|
||||
if closeStr, err := getClosingTimeForDate(r.Context(), tx, weekday, localStart); err == nil && (closeStr == "00:00" || closeStr == "00:00:00") {
|
||||
http.Error(w, "This day will be closed under the upcoming schedule change", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build update query for bookings table
|
||||
@@ -2057,10 +2133,10 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Delete the edit request
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
@@ -2155,9 +2231,11 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"edit_request": nil,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
||||
@@ -2174,9 +2252,11 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"edit_request": enriched,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetMyEditRequestsHandler returns all pending edit requests for the current user across all bookings.
|
||||
@@ -2238,9 +2318,11 @@ func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"edit_requests": enrichedRequests,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings.
|
||||
@@ -2295,9 +2377,11 @@ func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"edit_requests": enrichedRequests,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminGetBookingEditRequestHandler returns the pending edit request for a specific booking.
|
||||
@@ -2337,17 +2421,19 @@ func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"edit_request": enriched,
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"edit_request": enriched,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -840,9 +840,10 @@ func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) {
|
||||
}
|
||||
dur := durationMinutes(t, ctx, tx, serviceID)
|
||||
|
||||
// Use <48h from now so RequestEditHandler does NOT auto-approve
|
||||
nearTime := clock.Now().Add(40 * time.Hour)
|
||||
nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location())
|
||||
// Booking B (at +2h) is the edited booking; its start must sit in the
|
||||
// [24h,48h] window so RequestEditHandler neither 403s (too close) nor
|
||||
// auto-approves at creation. 10:00 UTC keeps it inside working hours.
|
||||
nearTime := fixtures.NextEditWindowTime(2 * time.Hour)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
@@ -1852,8 +1853,9 @@ func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) {
|
||||
}
|
||||
dur := durationMinutes(t, ctx, tx, serviceID)
|
||||
|
||||
// Use a booking <48h from now so RequestEdit does NOT auto-approve
|
||||
nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
|
||||
// Booking A is the edited booking; its start must sit in the [24h,48h]
|
||||
// window so RequestEditHandler neither 403s (too close) nor auto-approves.
|
||||
nearTime := fixtures.NextEditWindowTime(0)
|
||||
|
||||
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
|
||||
if err != nil {
|
||||
@@ -2163,6 +2165,11 @@ func TestAdminReserveSlot_CleansUpAnonReservation(t *testing.T) {
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
// B7: mw.ClientIP honors CF-Connecting-IP ONLY when TRUST_PROXY_HEADERS
|
||||
// is true (unset here, so the header is ignored); the derived IP falls
|
||||
// back to RemoteAddr. Set RemoteAddr so the handler's ipHash keys on
|
||||
// the intended test IP, matching the origin-exposed deployment model.
|
||||
req.RemoteAddr = ip + ":1234"
|
||||
req.Header.Set("CF-Connecting-IP", ip)
|
||||
baseCtx := req.Context()
|
||||
if len(requestCtx) > 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package bookings
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -16,7 +17,8 @@ import (
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"net"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ReserveSlotRequest represents the request body for reserving a slot
|
||||
@@ -45,7 +47,8 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := validators.Validate.Struct(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,14 +64,13 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// b. Extract client IP: check CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr
|
||||
ip := r.Header.Get("CF-Connecting-IP")
|
||||
if ip == "" {
|
||||
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
}
|
||||
// b. Extract client IP through the SAME gated resolution the rate limiter
|
||||
// uses (mw.ClientIP): CF-Connecting-IP is honored ONLY when
|
||||
// TRUST_PROXY_HEADERS=true, so an origin-exposed backend can never be
|
||||
// forced to key the anonymous-reservation ipHash on a client-controlled
|
||||
// header (B7). An attacker who could rotate the header would otherwise mint
|
||||
// a fresh anon bucket per request and evade the per-IP reservation cleanup.
|
||||
ip := mw.ClientIP(r)
|
||||
|
||||
// c. Detect auth: try context first (set by OptionalAuth middleware).
|
||||
// The inline Bearer fallback below is a safety net for the 22+ test
|
||||
@@ -181,10 +183,10 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check booking overlap inside transaction (TOCTOU fix)
|
||||
// pending_release is excluded — those bookings are evicted at creation time.
|
||||
@@ -255,18 +257,18 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check anon rate cap inside transaction
|
||||
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
|
||||
var anonCount int
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM time_blockers
|
||||
WHERE description LIKE 'RESERVATION:anon:%' AND created_at > $1
|
||||
`, tenMinutesAgo).Scan(&anonCount); err != nil {
|
||||
WHERE description LIKE 'RESERVATION:anon:' || $1 || ':%' AND created_at > $2
|
||||
`, ipHash, tenMinutesAgo).Scan(&anonCount); err != nil {
|
||||
log.Printf("Failed to check anon cap: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -342,5 +344,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package notifications
|
||||
import (
|
||||
"context"
|
||||
"crussell/db"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/internal/validators"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
@@ -25,8 +28,24 @@ type AdminNotification struct {
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
UserName *string `json:"user_name,omitempty"`
|
||||
BookingStartTime *time.Time `json:"booking_start_time,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Money-critical event detail (amount / square id / description): populated
|
||||
// by the 'critical_payment_log' / 'refund_failed' insert sites when they
|
||||
// adopt the admin_notifications event-detail columns (see the column
|
||||
// contract on adminnotify.go) so the operator can see WHAT happened without
|
||||
// opening the CRITICAL logs.
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
SquareID *string `json:"square_id,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminNotificationSuppression is one flood-cap suppression counter for a
|
||||
// capped reason whose unacknowledged queue is still at the cap.
|
||||
type AdminNotificationSuppression struct {
|
||||
Reason string `json:"reason"`
|
||||
SuppressedCount int `json:"suppressed_count"`
|
||||
LastSuppressedAt time.Time `json:"last_suppressed_at"`
|
||||
}
|
||||
|
||||
type AdminNotificationListResponse struct {
|
||||
@@ -35,6 +54,13 @@ type AdminNotificationListResponse struct {
|
||||
PerPage int `json:"per_page"`
|
||||
Total int `json:"total"`
|
||||
NextCursor *string `json:"next_cursor,omitempty"`
|
||||
// Suppressed is how many money-critical alerts were dropped by the flood cap
|
||||
// (adminnotify.MaxUnacknowledgedCriticalLogs) while each reason's
|
||||
// unacknowledged queue stayed at the cap — the "suppressed this cycle"
|
||||
// count. It resets once the operator works the queue down. SuppressedDetails
|
||||
// carries the per-reason breakdown.
|
||||
Suppressed int `json:"suppressed"`
|
||||
SuppressedDetails []AdminNotificationSuppression `json:"suppressed_details,omitempty"`
|
||||
}
|
||||
|
||||
// parseCursor splits a "createdAt|id" cursor string into its components.
|
||||
@@ -45,6 +71,13 @@ type AdminNotificationListResponse struct {
|
||||
// cursor, per_page — pagination (cursor-based)
|
||||
// include_acknowledged — if "true", returns all notifications sorted newest-first.
|
||||
// Default (false/omitted): only unacknowledged, sorted by priority then oldest-first.
|
||||
// Money-critical reasons ('critical_payment_log', 'refund_failed',
|
||||
// 'refresh_token_reuse', 'gift_card_purchased_for_friend') sort ABOVE routine
|
||||
// notifications so the operator's only pager surfaces money/security events first.
|
||||
// reason — filter to a single reason.
|
||||
// The response additionally carries the flood-cap "suppressed this cycle" count
|
||||
// (suppressed / suppressed_details) for reasons whose unacknowledged queue is
|
||||
// still at adminnotify.MaxUnacknowledgedCriticalLogs.
|
||||
func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse query params
|
||||
perPage := 20
|
||||
@@ -61,6 +94,7 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT an.id, an.reason, an.booking_id, an.user_id,
|
||||
u.n_first_name || ' ' || u.n_last_name AS user_name,
|
||||
b.start_time AS booking_start_time,
|
||||
an.amount, an.square_id, an.description,
|
||||
an.acknowledged_at, an.created_at
|
||||
FROM admin_notifications an
|
||||
LEFT JOIN users u ON an.user_id = u.id
|
||||
@@ -106,16 +140,24 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
baseQuery += " ORDER BY an.created_at DESC, an.id DESC"
|
||||
} else {
|
||||
baseQuery += ` ORDER BY CASE an.reason
|
||||
WHEN 'pending_booking' THEN 1
|
||||
WHEN 'cancelled_booking' THEN 2
|
||||
WHEN 'late_cancellation' THEN 3
|
||||
WHEN 'deposit_paid' THEN 4
|
||||
WHEN 'affiliate_claim' THEN 5
|
||||
WHEN 'edit_requested' THEN 6
|
||||
WHEN 'new_booking' THEN 7
|
||||
WHEN '1_month_no_pay' THEN 8
|
||||
WHEN '1_week_no_pay' THEN 9
|
||||
ELSE 10
|
||||
WHEN 'critical_payment_log' THEN 1
|
||||
WHEN 'refund_failed' THEN 2
|
||||
WHEN 'refresh_token_reuse' THEN 3
|
||||
WHEN 'gift_card_purchased_for_friend' THEN 4
|
||||
WHEN 'pending_booking' THEN 5
|
||||
WHEN 'cancelled_booking' THEN 6
|
||||
WHEN 'late_cancellation' THEN 7
|
||||
WHEN 'deposit_paid' THEN 8
|
||||
WHEN 'affiliate_claim' THEN 9
|
||||
WHEN 'edit_requested' THEN 10
|
||||
WHEN 'new_booking' THEN 11
|
||||
WHEN '1_month_no_pay' THEN 12
|
||||
WHEN '1_week_no_pay' THEN 13
|
||||
WHEN 'rescheduled_booking' THEN 14
|
||||
WHEN 'edit_request' THEN 15
|
||||
WHEN 'deposit_not_paid_by_deadline' THEN 16
|
||||
WHEN 'default_hours_changed' THEN 17
|
||||
ELSE 18
|
||||
END, an.created_at ASC, an.id ASC`
|
||||
}
|
||||
baseQuery += fmt.Sprintf(" LIMIT $%d", param)
|
||||
@@ -126,8 +168,9 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
if !includeAcknowledged {
|
||||
countWhere += " WHERE an.acknowledged_at IS NULL"
|
||||
}
|
||||
//nolint:errcheck // zero value is acceptable fallback on scan failure
|
||||
_ = db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM admin_notifications an"+countWhere).Scan(&total)
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM admin_notifications an"+countWhere).Scan(&total); err != nil {
|
||||
log.Printf("Failed to scan notification count: %v", err)
|
||||
}
|
||||
|
||||
// Query
|
||||
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
|
||||
@@ -146,6 +189,9 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
var userID sql.NullString
|
||||
var userName sql.NullString
|
||||
var bookingStartTime sql.NullTime
|
||||
var amount sql.NullFloat64
|
||||
var squareID sql.NullString
|
||||
var description sql.NullString
|
||||
var acknowledgedAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -155,6 +201,9 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
&userID,
|
||||
&userName,
|
||||
&bookingStartTime,
|
||||
&amount,
|
||||
&squareID,
|
||||
&description,
|
||||
&acknowledgedAt,
|
||||
&n.CreatedAt,
|
||||
)
|
||||
@@ -176,6 +225,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
if bookingStartTime.Valid {
|
||||
n.BookingStartTime = &bookingStartTime.Time
|
||||
}
|
||||
if amount.Valid {
|
||||
n.Amount = &amount.Float64
|
||||
}
|
||||
if squareID.Valid {
|
||||
n.SquareID = &squareID.String
|
||||
}
|
||||
if description.Valid {
|
||||
n.Description = &description.String
|
||||
}
|
||||
if acknowledgedAt.Valid {
|
||||
n.AcknowledgedAt = &acknowledgedAt.Time
|
||||
}
|
||||
@@ -203,6 +261,20 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
NextCursor: nextCursor,
|
||||
}
|
||||
|
||||
if suppressions, err := adminnotify.ActiveSuppressions(r.Context(), db.Conn); err != nil {
|
||||
log.Printf("Failed to fetch flood-cap suppressions: %v", err)
|
||||
} else {
|
||||
resp.SuppressedDetails = make([]AdminNotificationSuppression, 0, len(suppressions))
|
||||
for _, s := range suppressions {
|
||||
resp.Suppressed += s.SuppressedCount
|
||||
resp.SuppressedDetails = append(resp.SuppressedDetails, AdminNotificationSuppression{
|
||||
Reason: s.Reason,
|
||||
SuppressedCount: s.SuppressedCount,
|
||||
LastSuppressedAt: s.LastSuppressedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("Failed to encode response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -244,10 +316,10 @@ func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
query := `
|
||||
UPDATE admin_notifications
|
||||
@@ -273,9 +345,11 @@ func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func AcknowledgePendingBookingNotification(tx any, ctx context.Context, bookingID string) error {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
|
||||
@@ -199,9 +200,8 @@ func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID := createTestUser(t, ctx, tx)
|
||||
|
||||
// Create two pending_booking notifications with a time gap
|
||||
// Create two pending_booking notifications (created_at is NOW() so both get same tx timestamp; id ASC breaks ties)
|
||||
createNotification(t, ctx, tx, "pending_booking", userID, false)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
createNotification(t, ctx, tx, "pending_booking", userID, false)
|
||||
|
||||
handler := http.HandlerFunc(GetNotifications)
|
||||
@@ -228,7 +228,6 @@ func TestNotifications_AllNotifications_NewestFirst(t *testing.T) {
|
||||
userID := createTestUser(t, ctx, tx)
|
||||
|
||||
createNotification(t, ctx, tx, "pending_booking", userID, false)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
createNotification(t, ctx, tx, "cancelled_booking", userID, true)
|
||||
|
||||
handler := http.HandlerFunc(GetNotifications)
|
||||
@@ -693,3 +692,196 @@ func TestAcknowledgeNotification_BeginError(t *testing.T) {
|
||||
t.Errorf("expected 500, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Money-critical priority ordering (FIX 2)
|
||||
// =============================================================================
|
||||
|
||||
// TestNotifications_Priority_MoneyCriticalFirst pins FIX 2: money-critical
|
||||
// reasons (webhook/sweep 'critical_payment_log', 'refund_failed',
|
||||
// 'refresh_token_reuse', gift-card events) must sort ABOVE routine
|
||||
// notifications in the unacknowledged feed so the operator's only pager
|
||||
// surfaces money/security events first. The money rows are inserted LAST to
|
||||
// prove the priority CASE — not insertion order — drives the sort.
|
||||
func TestNotifications_Priority_MoneyCriticalFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID := createTestUser(t, ctx, tx)
|
||||
|
||||
moneyReasons := []string{
|
||||
"critical_payment_log",
|
||||
"refund_failed",
|
||||
"refresh_token_reuse",
|
||||
"gift_card_purchased_for_friend",
|
||||
}
|
||||
routineReasons := []string{
|
||||
"pending_booking",
|
||||
"cancelled_booking",
|
||||
"late_cancellation",
|
||||
"deposit_paid",
|
||||
"affiliate_claim",
|
||||
"edit_requested",
|
||||
"new_booking",
|
||||
"1_month_no_pay",
|
||||
"1_week_no_pay",
|
||||
"rescheduled_booking",
|
||||
"edit_request",
|
||||
"deposit_not_paid_by_deadline",
|
||||
"default_hours_changed",
|
||||
}
|
||||
moneySet := make(map[string]bool, len(moneyReasons))
|
||||
for _, r := range moneyReasons {
|
||||
moneySet[r] = true
|
||||
}
|
||||
|
||||
for _, reason := range routineReasons {
|
||||
createNotification(t, ctx, tx, reason, userID, false)
|
||||
}
|
||||
for _, reason := range moneyReasons {
|
||||
createNotification(t, ctx, tx, reason, userID, false)
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(GetNotifications)
|
||||
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
|
||||
|
||||
var resp AdminNotificationListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Notifications) != len(moneyReasons)+len(routineReasons) {
|
||||
t.Fatalf("expected %d notifications, got %d", len(moneyReasons)+len(routineReasons), len(resp.Notifications))
|
||||
}
|
||||
|
||||
// Every money-critical reason must appear strictly before the first routine
|
||||
// reason; each money reason must be present.
|
||||
lastMoney := -1
|
||||
firstRoutine := len(resp.Notifications)
|
||||
for i, n := range resp.Notifications {
|
||||
if moneySet[n.Reason] {
|
||||
lastMoney = i
|
||||
} else if firstRoutine == len(resp.Notifications) {
|
||||
firstRoutine = i
|
||||
}
|
||||
}
|
||||
if lastMoney == -1 {
|
||||
t.Fatal("expected at least one money-critical reason in the response")
|
||||
}
|
||||
if lastMoney > firstRoutine {
|
||||
t.Errorf("money-critical reasons must sort above routine notifications: last money position %d, first routine position %d", lastMoney, firstRoutine)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Flood-cap suppression count in the response (FIX 3a)
|
||||
// =============================================================================
|
||||
|
||||
// TestNotifications_Response_SuppressedCount pins FIX 3a: when the flood cap
|
||||
// has suppressed alerts, GET /api/admin/notifications exposes the per-reason
|
||||
// "suppressed this cycle" count, and acknowledging the queue back below the cap
|
||||
// resets it to zero.
|
||||
func TestNotifications_Response_SuppressedCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID := createTestUser(t, ctx, tx)
|
||||
handler := http.HandlerFunc(GetNotifications)
|
||||
|
||||
// No suppressions yet → the response reports zero.
|
||||
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
|
||||
var resp AdminNotificationListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp.Suppressed != 0 {
|
||||
t.Errorf("expected suppressed 0 before any cap hit, got %d", resp.Suppressed)
|
||||
}
|
||||
if len(resp.SuppressedDetails) != 0 {
|
||||
t.Errorf("expected no suppressed_details before any cap hit, got %d", len(resp.SuppressedDetails))
|
||||
}
|
||||
|
||||
// Fill the critical_payment_log queue to the cap, then hit the cap twice the
|
||||
// way an insert site would (pre-check records each suppression).
|
||||
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, user_id, created_at)
|
||||
VALUES ('critical_payment_log', $1, NOW())
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("failed to fill the queue to the cap: %v", err)
|
||||
}
|
||||
}
|
||||
if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
|
||||
t.Fatal("expected the queue to be at the cap")
|
||||
}
|
||||
if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
|
||||
t.Fatal("expected the queue to stay at the cap")
|
||||
}
|
||||
|
||||
w = makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp.Suppressed != 2 {
|
||||
t.Errorf("expected suppressed 2, got %d", resp.Suppressed)
|
||||
}
|
||||
if len(resp.SuppressedDetails) != 1 {
|
||||
t.Fatalf("expected 1 suppressed_details entry, got %d", len(resp.SuppressedDetails))
|
||||
}
|
||||
if resp.SuppressedDetails[0].Reason != "critical_payment_log" {
|
||||
t.Errorf("expected suppressed_details reason critical_payment_log, got %s", resp.SuppressedDetails[0].Reason)
|
||||
}
|
||||
if resp.SuppressedDetails[0].SuppressedCount != 2 {
|
||||
t.Errorf("expected suppressed_details count 2, got %d", resp.SuppressedDetails[0].SuppressedCount)
|
||||
}
|
||||
|
||||
// Acknowledging the queue below the cap resets the visible count.
|
||||
if _, err := tx.Exec(ctx, "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log'"); err != nil {
|
||||
t.Fatalf("failed to acknowledge the queue: %v", err)
|
||||
}
|
||||
w = makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp.Suppressed != 0 {
|
||||
t.Errorf("expected suppressed 0 after the queue was worked down, got %d", resp.Suppressed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifications_Response_CarriesEventDetail pins FIX 3b: the GET endpoint
|
||||
// surfaces the money-critical event detail columns (amount, square_id,
|
||||
// description) when the insert site has populated them.
|
||||
func TestNotifications_Response_CarriesEventDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
var notificationID string
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO admin_notifications (reason, amount, square_id, description)
|
||||
VALUES ('critical_payment_log', 42.50, 'sq_dispute_123', 'Dispute received for charge 42.50')
|
||||
RETURNING id
|
||||
`).Scan(¬ificationID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create notification with event detail: %v", err)
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(GetNotifications)
|
||||
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
|
||||
|
||||
var resp AdminNotificationListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Notifications) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
|
||||
}
|
||||
n := resp.Notifications[0]
|
||||
if n.Amount == nil || *n.Amount != 42.50 {
|
||||
t.Errorf("expected amount 42.50, got %v", n.Amount)
|
||||
}
|
||||
if n.SquareID == nil || *n.SquareID != "sq_dispute_123" {
|
||||
t.Errorf("expected square_id sq_dispute_123, got %v", n.SquareID)
|
||||
}
|
||||
if n.Description == nil || *n.Description != "Dispute received for charge 42.50" {
|
||||
t.Errorf("expected description populated, got %v", n.Description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// ADVERSARIAL QA ATTACK TESTS — break-the-app scenarios
|
||||
// =============================================================================
|
||||
|
||||
// TestAttack_NegativeOverrideAmount_MintsGiftCardBalance attempts to mint
|
||||
// money: a terminal giftcard payment with a NEGATIVE override_amount. The
|
||||
// handler validates only req.Amount, then replaces amount with the override —
|
||||
// a negative override on the giftcard path flips the balance deduction into a
|
||||
// credit (balance - (-50) = balance + 50).
|
||||
func TestAttack_NegativeOverrideAmount_MintsGiftCardBalance(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataAtTime(t, ctx, tx, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
// Give the booking user a gift-card account balance.
|
||||
_, err := tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 100.00)", userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// amount=5000 (£50) passes ValidateAmount; override_amount=-5000 is the attack.
|
||||
neg := int64(-5000)
|
||||
pm := "giftcard"
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
OverrideAmount: &neg,
|
||||
PaymentMethod: &pm,
|
||||
}
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
|
||||
var balance float64
|
||||
err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
||||
require.NoError(t, err)
|
||||
|
||||
// INVARIANT: a payment request must never increase a gift-card balance.
|
||||
// Expected: 400 reject (invalid override) and balance unchanged at 100.00.
|
||||
// Actual (bug): 200 COMPLETED, balance mints to 150.00.
|
||||
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if balance != 100.00 {
|
||||
t.Errorf("BUG: negative override_amount changed balance to %.2f (was 100.00) — money minted/lost", balance)
|
||||
}
|
||||
var payCount int
|
||||
_ = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND amount < 0", bookingID).Scan(&payCount)
|
||||
if payCount > 0 {
|
||||
t.Errorf("BUG: %d negative-amount payment record(s) created", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_ZeroOverrideAmount_CreatesFreePayment attempts a £0 gift-card
|
||||
// payment via override_amount=0. The giftcard balance is untouched but a
|
||||
// completed £0 payment is recorded.
|
||||
func TestAttack_ZeroOverrideAmount_CreatesFreePayment(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataAtTime(t, ctx, tx, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
_, err := tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 100.00)", userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
zero := int64(0)
|
||||
pm := "giftcard"
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
OverrideAmount: &zero,
|
||||
PaymentMethod: &pm,
|
||||
}
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
_ = w
|
||||
|
||||
var zeroPayCount int
|
||||
_ = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND amount = 0", bookingID).Scan(&zeroPayCount)
|
||||
if zeroPayCount > 0 {
|
||||
t.Errorf("BUG: %d zero-amount completed payment record(s) created", zeroPayCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_TerminalGiftCard_CancellationRefund_LosesBalance: a user pays a
|
||||
// booking from their gift-card ACCOUNT balance via the terminal. The terminal
|
||||
// giftcard branch never stores gift_card_id on the payment row, so the
|
||||
// cancellation refund loop cannot credit the balance back ("cannot refund to
|
||||
// card. Skipping") yet still inserts a COMPLETED refund row — money is lost
|
||||
// while the books claim it was refunded.
|
||||
func TestAttack_TerminalGiftCard_CancellationRefund_LosesBalance(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataAtTime(t, ctx, tx, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
_, err := tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 100.00)", userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// Pay £30 from the gift-card account balance via terminal.
|
||||
pm := "giftcard"
|
||||
req := CreateTerminalPaymentRequest{Amount: 3000, PaymentType: "full", PaymentMethod: &pm}
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "terminal giftcard payment failed: %s", w.Body.String())
|
||||
|
||||
var balanceAfterPay float64
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balanceAfterPay))
|
||||
require.Equal(t, 70.00, balanceAfterPay)
|
||||
|
||||
// Cancel the booking >72h before start → full £30 refund.
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, innerTx)
|
||||
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
||||
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 30, start, now, "client_cancelled", &userID, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
if result.RefundableAmount != 30 {
|
||||
t.Fatalf("expected refundable 30, got %.2f", result.RefundableAmount)
|
||||
}
|
||||
|
||||
var balanceAfterCancel float64
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balanceAfterCancel))
|
||||
|
||||
// INVARIANT: a completed £30 refund must put the £30 back on the account.
|
||||
// Expected: 100.00. Actual (bug): 70.00 — refund row 'completed' but no money returned.
|
||||
if balanceAfterCancel != 100.00 {
|
||||
t.Errorf("BUG: after full refund, balance is %.2f (was 70.00 post-payment) — refund recorded but money not credited", balanceAfterCancel)
|
||||
}
|
||||
|
||||
// The refund row must be pending (Square) or completed-with-money-moved.
|
||||
var refundStatus string
|
||||
var refundAmount float64
|
||||
if err := tx.QueryRow(ctx, "SELECT status, amount FROM refunds WHERE booking_id = $1 ORDER BY created_at DESC LIMIT 1", bookingID).Scan(&refundStatus, &refundAmount); err != nil {
|
||||
t.Fatalf("no refund row recorded: %v", err)
|
||||
}
|
||||
if refundStatus != "completed" || refundAmount != 30 {
|
||||
t.Errorf("BUG: refund row is status=%s amount=%.2f — expected completed £30 (it is completed but balance was NOT credited back)", refundStatus, refundAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_Refund_SameKeyDifferentAmount_ReportsWrongAmount: the client
|
||||
// reuses an idempotency key for a refund with a DIFFERENT amount. The dedup
|
||||
// returns the existing completed refund but echoes the NEW requested amount in
|
||||
// the response — the admin believes the new amount was refunded.
|
||||
func TestAttack_Refund_SameKeyDifferentAmount_ReportsWrongAmount(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")
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "in_person_card", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_attack_samekey' WHERE id = $1", paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := RefundPayment
|
||||
key := "attack-same-key-diff-amount"
|
||||
w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", RefundRequest{Amount: 2000, Reason: "first", IdempotencyKey: key}, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w1.Code, "first refund failed: %s", w1.Body.String())
|
||||
|
||||
// Same key, DIFFERENT amount (£30).
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", RefundRequest{Amount: 3000, Reason: "second", IdempotencyKey: key}, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w2.Code, "second refund failed: %s", w2.Body.String())
|
||||
|
||||
var resp RefundResponse
|
||||
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp))
|
||||
|
||||
// The stored refund row for this key must hold the actually-refunded amount.
|
||||
var storedAmount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT amount FROM refunds WHERE idempotency_key IS NOT NULL AND payment_id = $1 ORDER BY created_at DESC LIMIT 1", paymentID).Scan(&storedAmount))
|
||||
|
||||
// INVARIANT: the response must report the amount actually refunded (£20),
|
||||
// never the newly-requested £30 for a dedup'd key.
|
||||
if resp.Amount != int64(storedAmount*100) {
|
||||
t.Errorf("BUG: same-key different-amount retry reports refunded %d pence, but stored refund is %.2f — admin misled", resp.Amount, storedAmount)
|
||||
}
|
||||
if resp.Status != "completed" {
|
||||
t.Errorf("expected completed, got %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_AdvisoryLockTimeout_Returns409 holds the booking payment advisory
|
||||
// lock for longer than the ~3s bound and verifies the handler returns 409
|
||||
// (not a hang, not a double charge).
|
||||
func TestAttack_AdvisoryLockTimeout_Returns409(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Hold the session advisory lock on a dedicated pool connection.
|
||||
holder, err := db.Conn.Acquire(context.Background())
|
||||
require.NoError(t, err)
|
||||
defer holder.Release()
|
||||
_, err = holder.Exec(context.Background(), "SELECT pg_advisory_lock(hashtext($1))", "crussell:payment:"+bookingID)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_, _ = holder.Exec(context.Background(), "SELECT pg_advisory_unlock(hashtext($1))", "crussell:payment:"+bookingID)
|
||||
}()
|
||||
|
||||
cardToken := "cnon:attack-lock-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "attack-lock-timeout-key",
|
||||
}
|
||||
start := time.Now()
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, token, ctx)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Expected: 409 Conflict after ~3s (bounded try-lock), no charge.
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("BUG: expected 409 when advisory lock is held, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if elapsed > 15*time.Second {
|
||||
t.Errorf("BUG: handler hung for %v waiting on the lock", elapsed)
|
||||
}
|
||||
|
||||
// No payment record and no Square charge.
|
||||
var payCount int
|
||||
_ = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = 'attack-lock-timeout-key'", bookingID).Scan(&payCount)
|
||||
if payCount != 0 {
|
||||
t.Errorf("BUG: %d payment record(s) created while lock held", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_RefundOvercharge verifies the over-refund guard: refunding more
|
||||
// than the payment amount must be rejected.
|
||||
func TestAttack_RefundOvercharge(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")
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 30, "in_person_card", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_attack_overcharge' WHERE id = $1", paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", RefundRequest{Amount: 5000, Reason: "overcharge"}, adminToken, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("BUG: over-refund expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var refundCount int
|
||||
_ = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount)
|
||||
if refundCount != 0 {
|
||||
t.Errorf("BUG: over-refund created %d refund row(s)", refundCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_ZeroAmount_Rejected: £0 booking payment must be rejected.
|
||||
func TestAttack_ZeroAmount_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:attack-zero-card"
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment",
|
||||
CreateBookingPaymentRequest{Amount: 0, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "attack-zero"}, token, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("BUG: zero amount expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_NegativeAmount_Rejected: negative booking payment must be rejected.
|
||||
func TestAttack_NegativeAmount_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:attack-negative-card"
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment",
|
||||
CreateBookingPaymentRequest{Amount: -1000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "attack-negative"}, token, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("BUG: negative amount expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_HugeAmount_Rejected: a £1,000,000+ booking payment must be rejected.
|
||||
func TestAttack_HugeAmount_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:attack-huge-card"
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment",
|
||||
CreateBookingPaymentRequest{Amount: 100_000_000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "attack-huge"}, token, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("BUG: huge amount expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_RawPAN_NoCharge: a raw PAN as new_card_token must not produce a
|
||||
// completed payment (PCI-DSS: only cnon:/ccof: tokens are accepted). The mock
|
||||
// mirrors production and rejects non-token source IDs.
|
||||
func TestAttack_RawPAN_NoCharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
pan := "4111111111111111"
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment",
|
||||
CreateBookingPaymentRequest{Amount: 5000, PaymentType: "full", NewCardToken: &pan, IdempotencyKey: "attack-rawpan"}, token, ctx)
|
||||
// The request must not be treated as success.
|
||||
if w.Code == http.StatusOK {
|
||||
t.Errorf("BUG: raw PAN accepted as a charge: %s", w.Body.String())
|
||||
}
|
||||
var completed int
|
||||
_ = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND idempotency_key = 'attack-rawpan'", bookingID).Scan(&completed)
|
||||
if completed != 0 {
|
||||
t.Errorf("BUG: raw PAN produced a completed payment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_MissingAuth_Rejected: payment route without a user context must be rejected.
|
||||
func TestAttack_MissingAuth_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
cardToken := "cnon:attack-noauth-card"
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment",
|
||||
CreateBookingPaymentRequest{Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "attack-noauth"}, "", ctx)
|
||||
if w.Code == http.StatusOK {
|
||||
t.Errorf("BUG: unauthenticated payment accepted: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_GiftCardSpendBeyondBalance: spending more than the remaining gift
|
||||
// card balance must be rejected — a negative balance must never occur.
|
||||
func TestAttack_GiftCardSpendBeyondBalance(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataAtTime(t, ctx, tx, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
_, err := tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 10.00)", userID)
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
pm := "giftcard"
|
||||
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: &pm} // £50 > £10 balance
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("BUG: spend beyond gift-card balance expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var balance float64
|
||||
_ = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
||||
if balance < 0 {
|
||||
t.Errorf("BUG: gift card balance went negative: %.2f", balance)
|
||||
}
|
||||
if balance != 10.00 {
|
||||
t.Errorf("balance changed to %.2f after rejected payment (want 10.00)", balance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_ConcurrentRefunds_NoOverRefund: two concurrent full-amount refunds
|
||||
// on the same payment must result in exactly ONE Square refund call and one
|
||||
// refund row.
|
||||
func TestAttack_ConcurrentRefunds_NoOverRefund(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "in_person_card", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_attack_concurrent_refund' WHERE id = $1", paymentID)
|
||||
require.NoError(t, err)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowRefundClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, innerTx)
|
||||
require.NoError(t, innerTx.Commit(ctx))
|
||||
|
||||
pool := context.Background()
|
||||
startBoth := make(chan struct{})
|
||||
codes := make([]int, 2)
|
||||
done := make(chan struct{}, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
go func(idx int) {
|
||||
<-startBoth
|
||||
req := RefundRequest{Amount: 5000, Reason: "concurrent", IdempotencyKey: "attack-concurrent-refund-" + string(rune('a'+idx))}
|
||||
w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, pool)
|
||||
codes[idx] = w.Code
|
||||
done <- struct{}{}
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
<-done
|
||||
<-done
|
||||
|
||||
okCount := 0
|
||||
for _, c := range codes {
|
||||
if c == http.StatusOK {
|
||||
okCount++
|
||||
}
|
||||
}
|
||||
if okCount != 1 {
|
||||
t.Errorf("BUG: expected exactly 1 successful refund, got %d (codes %v)", okCount, codes)
|
||||
}
|
||||
var refundCount int
|
||||
_ = db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount)
|
||||
if refundCount != 1 {
|
||||
t.Errorf("BUG: expected exactly 1 refund row, got %d", refundCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_BookingPaymentRacingCancellation_NoCompletedPaymentWithoutRefund
|
||||
// exercises the C5 fix: the post-charge booking-status recheck
|
||||
// (recheckBookingPayable in charge_helpers.go) now reads the booking row with
|
||||
// `FOR UPDATE`, so a concurrent cancellation — which also locks the row with
|
||||
// FOR UPDATE before writing its cancelled status — serializes with it. Without
|
||||
// the row lock, the recheck's plain SELECT could read the pre-cancellation
|
||||
// status and commit a COMPLETED payment on a CANCELLED booking; the
|
||||
// cancellation refund loop had already run while the charge was still pending,
|
||||
// so NO refund would ever be generated for the money taken at Square.
|
||||
//
|
||||
// INVARIANT: never a completed payment on a cancelled booking without a
|
||||
// refund. Either the payment is rejected (no completed payment row) or it
|
||||
// succeeds AND a refund is generated.
|
||||
func TestAttack_BookingPaymentRacingCancellation_NoCompletedPaymentWithoutRefund(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx) // status in_progress, start 2099-12-31
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
// Commit the setup so the payment and the cancellation run at pool level on
|
||||
// INDEPENDENT connections — the advisory lock and the FOR UPDATE row lock
|
||||
// only serialize across separate sessions.
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, innerTx)
|
||||
require.NoError(t, innerTx.Commit(ctx))
|
||||
|
||||
// Delay the Square charge so the cancellation can interleave between the
|
||||
// pending-record commit and the post-charge status recheck.
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
key := "attack-c5-" + bookingID
|
||||
cardToken := "cnon:attack-c5-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
var payRec *httptest.ResponseRecorder
|
||||
payDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(payDone)
|
||||
payRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, token, pool)
|
||||
}()
|
||||
|
||||
// Wait until the pending payment record is committed — the payment has now
|
||||
// passed the pre-charge status check and is inside the (slow) Square call,
|
||||
// holding the advisory lock. Starting the cancellation from here makes the
|
||||
// interleaving deterministic.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
var n int
|
||||
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'pending'`, bookingID, key).Scan(&n); err == nil && n > 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("timed out waiting for the payment to reach the pending-record commit")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
cancelAt := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) // >72h notice → full-refund tier
|
||||
|
||||
cancelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(cancelDone)
|
||||
cancelTx, err := db.Conn.Begin(pool)
|
||||
if err != nil {
|
||||
t.Errorf("cancellation: begin tx: %v", err)
|
||||
return
|
||||
}
|
||||
defer cancelTx.Rollback(pool)
|
||||
|
||||
// FOR UPDATE on the SAME booking row the payment's post-charge recheck
|
||||
// locks (C5). Held until commit, so the recheck MUST block on it and
|
||||
// then observe the committed cancelled status.
|
||||
var st string
|
||||
if err := cancelTx.QueryRow(pool, `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, bookingID).Scan(&st); err != nil {
|
||||
t.Errorf("cancellation: lock booking row: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := cancelTx.Exec(pool, `UPDATE bookings SET status = 'client_cancelled', updated_at = NOW() WHERE id = $1`, bookingID); err != nil {
|
||||
t.Errorf("cancellation: set status: %v", err)
|
||||
return
|
||||
}
|
||||
// Run the cancellation refund loop while the charge is still pending —
|
||||
// it must find no COMPLETED payment to refund.
|
||||
if _, err := ProcessCancellationRefundTx(pool, cancelTx, bookingID, 50, 50, start, cancelAt, "client_cancelled", &userID, false); err != nil {
|
||||
t.Errorf("cancellation: refund: %v", err)
|
||||
return
|
||||
}
|
||||
// Hold the row lock past the payment's Square round-trip so the
|
||||
// payment's recheck is forced to block on it, then commit.
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
if err := cancelTx.Commit(pool); err != nil {
|
||||
t.Errorf("cancellation: commit: %v", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
<-payDone
|
||||
<-cancelDone
|
||||
|
||||
// Ground truth: the booking is cancelled.
|
||||
var bookingStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
||||
require.Equal(t, "client_cancelled", bookingStatus)
|
||||
|
||||
// C5 INVARIANT: never a completed payment on a cancelled booking without a
|
||||
// refund.
|
||||
var completedCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount))
|
||||
var refundCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM refunds WHERE booking_id = $1`, bookingID).Scan(&refundCount))
|
||||
if completedCount > 0 && refundCount == 0 {
|
||||
t.Errorf("BUG (C5): %d completed payment(s) on cancelled booking %s with NO refund — money taken at Square but never refunded", completedCount, bookingID)
|
||||
}
|
||||
|
||||
// Case-by-case: a 200 payment MUST be accompanied by a refund; any other
|
||||
// result MUST NOT leave a completed payment behind.
|
||||
if payRec.Code == http.StatusOK {
|
||||
if refundCount == 0 {
|
||||
t.Errorf("BUG (C5): payment succeeded (HTTP 200) on cancelled booking %s but no refund was generated", bookingID)
|
||||
}
|
||||
} else {
|
||||
var status string
|
||||
if err := db.Conn.QueryRow(pool, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&status); err == nil && status == "completed" {
|
||||
t.Errorf("BUG (C5): payment failed (HTTP %d) but the payment row is status %q", payRec.Code, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack_DeletedGiftCard_RefundMarkedFailed exercises the M2 fix: the
|
||||
// cancellation refund loop checks `RowsAffected()` on the gift-card balance
|
||||
// UPDATE in ProcessCancellationRefundTx. When the gift card has been deleted,
|
||||
// the UPDATE matches 0 rows — no money can move back onto the card — so the
|
||||
// refund row must be recorded 'failed' (with a CRITICAL log), NEVER 'completed'.
|
||||
// A 'completed' row on a deleted card would claim the customer's money was
|
||||
// returned when it never moved.
|
||||
func TestAttack_DeletedGiftCard_RefundMarkedFailed(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
// Direct card redemption: a completed giftcard payment bound to a specific
|
||||
// gift card (gift_card_id set — NOT the user's pooled account balance).
|
||||
var gcID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (50.00, 50.00, $1) RETURNING id`, userID).Scan(&gcID))
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "giftcard", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET gift_card_id = $1 WHERE id = $2", gcID, paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete the gift card (audit rows first so the FK allows the delete).
|
||||
_, err = tx.Exec(ctx, "DELETE FROM gift_card_transactions WHERE gift_card_id = $1", gcID)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "DELETE FROM gift_cards WHERE id = $1", gcID)
|
||||
require.NoError(t, err)
|
||||
var gone int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", gcID).Scan(&gone))
|
||||
require.Zero(t, gone, "precondition: the gift card must be deleted")
|
||||
|
||||
// Capture the CRITICAL slog output the M2 fix emits when the gift-card
|
||||
// refund UPDATE matches 0 rows.
|
||||
var sb syncBuffer
|
||||
origLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&sb, nil)))
|
||||
defer slog.SetDefault(origLogger)
|
||||
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, innerTx)
|
||||
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
cancelAt := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) // >72h → full £50 refund
|
||||
|
||||
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, cancelAt, "client_cancelled", &userID, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
if result.RefundableAmount != 50 {
|
||||
t.Fatalf("expected refundable 50, got %.2f", result.RefundableAmount)
|
||||
}
|
||||
|
||||
// INVARIANT: the refund row must be 'failed' — the money could not be
|
||||
// credited to a deleted card, so 'completed' would falsely claim a refund.
|
||||
var refundStatus string
|
||||
var refundAmount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT status, amount FROM refunds WHERE booking_id = $1 ORDER BY created_at DESC LIMIT 1", bookingID).Scan(&refundStatus, &refundAmount))
|
||||
if refundStatus != "failed" {
|
||||
t.Errorf("BUG (M2): refund on deleted gift card is status=%q — must be 'failed' (money never moved)", refundStatus)
|
||||
}
|
||||
if refundAmount != 50 {
|
||||
t.Errorf("expected refund amount 50, got %.2f", refundAmount)
|
||||
}
|
||||
|
||||
// The fail-closed expiry check must have emitted the CRITICAL log.
|
||||
logs := sb.String()
|
||||
if !strings.Contains(logs, "CRITICAL") || !strings.Contains(logs, "refusing refund to expired card") {
|
||||
t.Errorf("BUG (M2): expected a CRITICAL 'refusing refund to expired card' log, got: %q", logs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// resolveChargeSource resolves the Square payment source for a card charge,
|
||||
// shared by CreateBookingPayment, CreateTipPayment, BuyGiftCard, and the
|
||||
// CreateTerminalPayment saved-card branch (which passes the booking user's id
|
||||
// and a nil new-card token).
|
||||
//
|
||||
// New-card path (cnon: nonce): the nonce is used DIRECTLY for one-off charges
|
||||
// (no card-on-file is created — the old tokenize-then-charge flow left orphan
|
||||
// cards at Square). When saveCard is true the user's Square customer is
|
||||
// provisioned FIRST and the card is tokenized against it (a ccof: source MUST
|
||||
// carry its customer — R6), then saved via SaveCardForUser.
|
||||
//
|
||||
// Saved-card path (ccof:): a saved-card row predating P14 has an empty
|
||||
// square_customer_id; the user's Square customer is lazily provisioned and
|
||||
// persisted on the row BEFORE charging (a ccof: source can never be charged
|
||||
// without a CustomerID).
|
||||
//
|
||||
// On any error the helper writes the HTTP response and returns ok=false — the
|
||||
// caller must return immediately.
|
||||
func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *PaymentService, userID string, newCardToken, cardID *string, saveCard bool, notFoundMsg string) (sourceID string, savedCardID *string, squareCustomerID string, ok bool) {
|
||||
// The saved-card branch is resolved FIRST so a request carrying BOTH a
|
||||
// new-card token and a saved card id — the SCA tokenize-result wire
|
||||
// contract (Square's CURRENT "Charge a Card on File" flow, where
|
||||
// card.tokenize(verificationDetails, cardId) returns a token that must be
|
||||
// sent as source_id, NOT a separate verification_token) — resolves the
|
||||
// customer from the saved-card row and uses the fresh one-time token as
|
||||
// the source. Without the token it is a plain saved-card (ccof:) charge.
|
||||
// Only a token with NO saved card falls through to the new-card path below.
|
||||
if cardID != nil {
|
||||
card, err := svc.GetCardByID(ctx, *cardID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, notFoundMsg, http.StatusNotFound)
|
||||
return "", nil, "", false
|
||||
}
|
||||
log.Printf("Failed to get card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
if card.SquareCustomerID == "" {
|
||||
if userID == "" {
|
||||
// Defensive parity with the original saved-card block: a card
|
||||
// with no bookable owner cannot be provisioned. Unreachable in
|
||||
// practice — GetCardByID above filters on user_id and would
|
||||
// have 404'd for an empty owner.
|
||||
http.Error(w, "Saved card has no owner and cannot be charged", http.StatusBadRequest)
|
||||
return "", nil, "", false
|
||||
}
|
||||
provisioned, provErr := svc.EnsureSquareCustomerForSavedCard(ctx, *cardID, userID)
|
||||
if provErr != nil {
|
||||
log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *cardID, userID, provErr)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
card.SquareCustomerID = provisioned
|
||||
}
|
||||
if newCardToken != nil && *newCardToken != "" {
|
||||
// SCA tokenize-result for a saved card: the token is the charge
|
||||
// source (a fresh one-time token minted only after the issuer
|
||||
// completed buyer verification for THIS card + amount); the stored
|
||||
// ccof: id is NOT sent. customer_id still derives from the saved
|
||||
// card row — Square requires it for the card-on-file charge.
|
||||
return *newCardToken, cardID, card.SquareCustomerID, true
|
||||
}
|
||||
return card.SquareCardID, cardID, card.SquareCustomerID, true
|
||||
}
|
||||
if newCardToken != nil && *newCardToken != "" {
|
||||
if saveCard {
|
||||
sqCustomerID, custErr := svc.EnsureSquareCustomer(ctx, userID)
|
||||
if custErr != nil {
|
||||
log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *newCardToken, sqCustomerID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create card on file: %v", err)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
sourceID = cardOnFile.CardID
|
||||
squareCustomerID = sqCustomerID
|
||||
// CreateCardOnFile runs before the charge. If the subsequent payment
|
||||
// fails, this card-on-file is intentionally NOT deleted: the pending
|
||||
// record's retry re-creates it via the deterministic sha256
|
||||
// idempotency key (the SAVE path), and Square returns the same card —
|
||||
// deleting it would break that retry.
|
||||
savedRowID, saveErr := svc.SaveCardForUser(ctx, userID, sqCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
|
||||
if saveErr != nil {
|
||||
log.Printf("Failed to save card: %v", saveErr)
|
||||
// The Square card was JUST created by this call (CreateCardOnFile
|
||||
// above) but the local DB save failed, so the card-on-file is
|
||||
// orphaned at Square — no user_saved_cards row references it, yet
|
||||
// it is a live, chargeable card. Best-effort cleanup: disable it
|
||||
// so it cannot be charged without a DB row. This is deliberately
|
||||
// NOT the payment-failure path below — that path intentionally
|
||||
// keeps the card so the pending record's retry re-creates it via
|
||||
// the deterministic sha256 idempotency key. Here the save never
|
||||
// landed, so there is no retry to preserve. A cleanup failure must
|
||||
// never fail the charge: log the redacted card id so the orphan is
|
||||
// auditable for manual cleanup.
|
||||
if delErr := SquareClient.DeleteCardOnFile(ctx, cardOnFile.CardID); delErr != nil {
|
||||
log.Printf("WARN: created Square card %s not disabled after local save failed — orphan card-on-file requires manual cleanup: %v", square.TokenPrefix(cardOnFile.CardID), delErr)
|
||||
}
|
||||
} else {
|
||||
savedCardID = &savedRowID
|
||||
}
|
||||
} else {
|
||||
// One-off new-card charge: use the cnon: nonce DIRECTLY as the
|
||||
// source. No card-on-file is created (nothing to orphan, no
|
||||
// customer needed).
|
||||
sourceID = *newCardToken
|
||||
}
|
||||
if savedCardID == nil && saveCard {
|
||||
// sourceID is cardOnFile.CardID on this branch (the ccof card).
|
||||
log.Printf("Card was not saved despite save_card=true for user %s (card %s)", userID, square.TokenPrefix(sourceID))
|
||||
}
|
||||
return sourceID, savedCardID, squareCustomerID, true
|
||||
}
|
||||
// Neither a new-card token nor a saved card — validation upstream
|
||||
// (ValidateCardInfo) guarantees one of them is present.
|
||||
return "", nil, "", false
|
||||
}
|
||||
|
||||
// acquireBookingPaymentLock acquires a pinned pool connection and a bounded
|
||||
// try-lock (R6) on lockKey, serializing payment attempts per booking (the core
|
||||
// defence against the two-tab double-payment race). A blocking pg_advisory_lock
|
||||
// would hold the pinned pool connection for the full Square round-trip of
|
||||
// whichever request holds the lock; the bounded try-lock loop gives up after
|
||||
// ~3s and surfaces a 409 instead of exhausting the pool. On any failure the
|
||||
// helper writes the HTTP response and returns ok=false — the caller must
|
||||
// return. On success the caller MUST defer releaseBookingPaymentLock(pinConn,
|
||||
// lockKey): the lock and connection stay held for the whole handler so
|
||||
// pg_advisory_unlock runs on the SAME session that acquired the lock.
|
||||
func acquireBookingPaymentLock(ctx context.Context, w http.ResponseWriter, lockKey, conflictMsg string) (*pgxpool.Conn, bool) {
|
||||
pinConn, err := db.Conn.Acquire(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for payment lock (%s): %v", lockKey, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return nil, false
|
||||
}
|
||||
lockOK, err := acquireAdvisoryLock(ctx, pinConn, lockKey)
|
||||
if err != nil {
|
||||
pinConn.Release()
|
||||
log.Printf("Failed to acquire payment serialization lock %s: %v", lockKey, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return nil, false
|
||||
}
|
||||
if !lockOK {
|
||||
pinConn.Release()
|
||||
log.Printf("Payment serialization lock %s not acquired within bound — a payment is already in progress", lockKey)
|
||||
http.Error(w, conflictMsg, http.StatusConflict)
|
||||
return nil, false
|
||||
}
|
||||
return pinConn, true
|
||||
}
|
||||
|
||||
// releaseBookingPaymentLock releases the advisory lock acquired by
|
||||
// acquireBookingPaymentLock and returns the pinned connection to the pool.
|
||||
// Both run on the same session that holds the lock.
|
||||
func releaseBookingPaymentLock(pinConn *pgxpool.Conn, lockKey string) {
|
||||
if _, err := pinConn.Exec(context.Background(), `
|
||||
SELECT pg_advisory_unlock(hashtext($1))
|
||||
`, lockKey); err != nil {
|
||||
log.Printf("Failed to release payment serialization lock %s: %v", lockKey, err)
|
||||
}
|
||||
pinConn.Release()
|
||||
}
|
||||
|
||||
// writeChargeSnapshot stores the verbatim request JSON so the sweep can replay
|
||||
// the charge with an IDENTICAL body under the same key (M1). The write is
|
||||
// immutability-guarded: it records the FIRST attempt's body and stays immutable
|
||||
// so a nonce-changing retry can never redirect the sweep's replay away from the
|
||||
// original charge. Reuse paths that legitimately refresh the snapshot (a new
|
||||
// source on a pending-reuse retry) do so via the Go-reencrypt refresh
|
||||
// (refreshTillSnapshotSource / the gift-card reuse refresh), not by overwriting
|
||||
// the guard. table is 'payments' or 'till_sales'; label names the flow for log
|
||||
// messages (e.g. "payment", "tip payment", "gift-card payment", "till sale").
|
||||
// Best-effort: failures are logged and the row stays snapshot-less — the sweep
|
||||
// overrides the replay source from the live square_source_id column.
|
||||
func writeChargeSnapshot(ctx context.Context, q db.Querier, table, rowID string, body any, label string) {
|
||||
snap, mErr := json.Marshal(body)
|
||||
if mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for %s %s: %v", label, rowID, mErr)
|
||||
return
|
||||
}
|
||||
stored, eErr := encryptSnapshot(snap)
|
||||
if eErr != nil {
|
||||
log.Printf("Failed to encrypt square_request_snapshot for %s %s: %v", label, rowID, eErr)
|
||||
return
|
||||
}
|
||||
if _, sErr := q.Exec(ctx, `UPDATE `+table+` SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), rowID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for %s %s: %v", label, rowID, sErr)
|
||||
}
|
||||
}
|
||||
|
||||
// writeChargeSnapshotUnconditional stores the verbatim request JSON on a
|
||||
// payments/till_sales row WITHOUT the first-attempt immutability guard that
|
||||
// writeChargeSnapshot applies. It is used ONLY by the gift-card (BuyGiftCard)
|
||||
// and till-sale (CreateTillSale) charge sites — the two sites that were
|
||||
// UNCONDITIONAL before the writeChargeSnapshot consolidation (Loop A, finding 1
|
||||
// regression check 4a). Their pending-reuse branches deliberately refresh
|
||||
// square_request_snapshot in the SAME transaction as the square_source_id
|
||||
// refresh (the Go-reencrypt refresh in giftcards.go / refreshTillSnapshotSource
|
||||
// in till.go, B6), and this post-commit write stores the FRESH full body for
|
||||
// the CURRENT attempt. The guard would wrongly skip this write on the reuse
|
||||
// path when the in-transaction refresh failed best-effort — the row would then
|
||||
// keep the stale first-attempt body while the live square_source_id column
|
||||
// already points at the new source (the pre-consolidation code explicitly
|
||||
// warned against "fixing" these sites into the guarded form). The booking/tip/
|
||||
// terminal saved-card paths keep the guarded writeChargeSnapshot: their reuse
|
||||
// paths do NOT refresh the snapshot, so the guard correctly records the
|
||||
// immutable first-attempt body.
|
||||
func writeChargeSnapshotUnconditional(ctx context.Context, q db.Querier, table, rowID string, body any, label string) {
|
||||
snap, mErr := json.Marshal(body)
|
||||
if mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for %s %s: %v", label, rowID, mErr)
|
||||
return
|
||||
}
|
||||
stored, eErr := encryptSnapshot(snap)
|
||||
if eErr != nil {
|
||||
log.Printf("Failed to encrypt square_request_snapshot for %s %s: %v", label, rowID, eErr)
|
||||
return
|
||||
}
|
||||
if _, sErr := q.Exec(ctx, `UPDATE `+table+` SET square_request_snapshot = $1 WHERE id = $2`, string(stored), rowID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for %s %s: %v", label, rowID, sErr)
|
||||
}
|
||||
}
|
||||
|
||||
// recheckBookingPayable re-reads the booking status after a Square charge
|
||||
// succeeded (R9): a concurrent cancellation/eviction can move the booking out
|
||||
// of a payable state between the pre-charge status check and the charge
|
||||
// completing. A charge landing on a cancelled/lapsed/no-show booking must NOT
|
||||
// be recorded as a completed payment — the cancellation refund path computes
|
||||
// refunds from completed payments and would silently exclude it. Returns the
|
||||
// re-read status and whether a completed payment is still allowed; the caller
|
||||
// owns the CRITICAL logging, the mark-failed write (whose target and
|
||||
// transaction semantics differ per path), and the 409 conflict response.
|
||||
func recheckBookingPayable(ctx context.Context, q db.Querier, bookingID string) (string, bool, error) {
|
||||
var status string
|
||||
// FOR UPDATE (C5): a concurrent cancellation takes the same row lock and
|
||||
// commits before this transaction commits, so the recheck cannot observe a
|
||||
// status that changes between the read and the commit. Without the lock a
|
||||
// cancellation could slip in between, leaving a completed payment on a
|
||||
// cancelled booking with no refund.
|
||||
if err := q.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, bookingID).Scan(&status); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return status, bookingStatusAllowsCompletedPayment(status), nil
|
||||
}
|
||||
|
||||
// postChargeRecheck re-reads the booking status after a Square charge
|
||||
// succeeded and, when the booking is no longer payable, marks the payment row
|
||||
// failed, commits the caller's transaction, writes the 409 conflict response
|
||||
// and returns false — the caller must abort. The recheck and the failed mark
|
||||
// run in ONE transaction so the FOR UPDATE row lock taken inside
|
||||
// recheckBookingPayable persists to commit (C5). Shared by the booking, tip
|
||||
// and terminal saved-card paths so the R9 recheck cannot drift between them.
|
||||
// On the not-payable branch the caller's deferred rollback becomes a harmless
|
||||
// no-op (the commit already closed the transaction). chargeNoun labels the
|
||||
// CRITICAL log (e.g. "payment", "tip"); conflictMsg is the 409 body.
|
||||
func postChargeRecheck(ctx context.Context, w http.ResponseWriter, tx pgx.Tx, bookingID, paymentID, sqStatus, sqPayID, chargeNoun, conflictMsg string) (bool, error) {
|
||||
recheckStatus, payable, err := recheckBookingPayable(ctx, tx, bookingID)
|
||||
if err != nil {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but re-reading booking %s status failed: %v — manual reconciliation required", sqStatus, sqPayID, bookingID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return false, err
|
||||
}
|
||||
if !payable {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) for booking %s was processed but booking is now %q — marking %s failed; money taken at Square MUST be refunded manually",
|
||||
sqStatus, sqPayID, bookingID, recheckStatus, chargeNoun)
|
||||
// R10: guard the failed mark on status='pending' so it can never
|
||||
// clobber a row the Square payment.completed webhook already resolved
|
||||
// to 'completed'. In the race where the webhook wins the booking
|
||||
// FOR UPDATE lock between the Square call and this recheck, the
|
||||
// payment is genuinely completed — the cancellation refund path
|
||||
// computes refunds from completed payments and will reverse the money
|
||||
// there. Marking such a row 'failed' would strand a completed charge
|
||||
// off the refund ledger entirely. A 0-row update (row already
|
||||
// completed/failed) is left as-is; the CRITICAL log below still
|
||||
// alerts ops that money landed on a no-longer-payable booking.
|
||||
if res, upErr := tx.Exec(ctx, `UPDATE payments SET status = 'failed' WHERE id = $1 AND status = 'pending'`, paymentID); upErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s but marking %s failed errored: %v — manual reconciliation required",
|
||||
sqStatus, sqPayID, recheckStatus, bookingID, chargeNoun, upErr)
|
||||
} else if res.RowsAffected() == 0 {
|
||||
log.Printf("Square payment %s (ID=%s) landed on %q booking %s but payment row %s is no longer 'pending' (webhook/sweep already resolved it) — leaving the row as-is; not marking it failed",
|
||||
sqStatus, sqPayID, bookingID, chargeNoun, paymentID)
|
||||
}
|
||||
if cErr := tx.Commit(ctx); cErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s and committing the failed mark errored: %v — manual reconciliation required",
|
||||
sqStatus, sqPayID, recheckStatus, bookingID, cErr)
|
||||
}
|
||||
// The CRITICAL log line alone was the only operator signal for a
|
||||
// stranded charge — no admin-visible trace. Raise the flood-capped
|
||||
// critical-payment admin notification (sweep.go's
|
||||
// insertCriticalPaymentNotification, which applies the shared
|
||||
// unacknowledged-queue cap atomically and dedups per
|
||||
// booking/user). It runs AFTER the commit: this transaction held the
|
||||
// booking row FOR UPDATE (recheckBookingPayable), and the notification
|
||||
// INSERT's FK check on bookings(id) takes FOR KEY SHARE — inserting
|
||||
// before the commit would self-deadlock against this tx's own booking
|
||||
// lock (the sweep's gateStalePaymentRescueOnBooking commits first for
|
||||
// the same reason).
|
||||
insertCriticalPaymentNotification(ctx, &bookingID, nil)
|
||||
http.Error(w, conflictMsg, http.StatusConflict)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// snapshotEncMarker prefixes the at-rest encrypted form of a stored
|
||||
// square_request_snapshot (PII: buyer email + ccof tokens) so decryptSnapshot
|
||||
// can distinguish encrypted values from plaintext (dev/mock environments and
|
||||
// legacy pre-encryption rows). The marker itself is not secret.
|
||||
const snapshotEncMarker = "enc:v1:"
|
||||
|
||||
// snapshotEncKeyWarningOnce throttles the missing-key CRITICAL log to one line
|
||||
// per process: a deployment without a usable SNAPSHOT_ENC_KEY falls back to
|
||||
// plaintext (money-safety first — the replayable snapshot must not be lost),
|
||||
// and the single loud warning makes the misconfiguration impossible to miss.
|
||||
var snapshotEncKeyWarningOnce sync.Once
|
||||
|
||||
// snapshotEncKey parses the AES-256-GCM key from the SNAPSHOT_ENC_KEY
|
||||
// environment variable (base64-encoded 32 bytes). It is read on every call so
|
||||
// tests can flip the env; the parse is cheap and encryption happens once per
|
||||
// payment.
|
||||
func snapshotEncKey() ([]byte, error) {
|
||||
raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY"))
|
||||
if raw == "" {
|
||||
return nil, errors.New("SNAPSHOT_ENC_KEY is not set")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SNAPSHOT_ENC_KEY is not valid base64: %w", err)
|
||||
}
|
||||
if len(decoded) != 32 {
|
||||
return nil, fmt.Errorf("SNAPSHOT_ENC_KEY must decode to 32 bytes for AES-256, got %d", len(decoded))
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// encryptSnapshot returns the snapshot body ready for storage. In dev/mock
|
||||
// environments it returns the body unchanged (no key required, tests keep
|
||||
// passing); in non-mock environments (SQUARE_ENVIRONMENT production/sandbox —
|
||||
// the same gate IsExplicitDevOrMockEnv drives) it AES-256-GCM-encrypts the
|
||||
// body and returns "enc:v1:" + base64(nonce || ciphertext) so the PII at rest
|
||||
// (buyer email, ccof card tokens) is encrypted. The transformation is
|
||||
// lossless: decryptSnapshot recovers the ORIGINAL bytes exactly, which Square's
|
||||
// identical-body idempotency replay depends on. A missing/unusable key in a
|
||||
// non-mock deployment falls back to plaintext with a one-time CRITICAL log —
|
||||
// breaking the replayable snapshot to protect PII would strand pending rows,
|
||||
// so money-safety wins over best-effort hardening.
|
||||
func encryptSnapshot(body []byte) ([]byte, error) {
|
||||
if IsExplicitDevOrMockEnv() {
|
||||
return body, nil
|
||||
}
|
||||
key, err := snapshotEncKey()
|
||||
if err != nil {
|
||||
snapshotEncKeyWarningOnce.Do(func() {
|
||||
log.Printf("CRITICAL: %v — storing square_request_snapshot PLAINTEXT; set SNAPSHOT_ENC_KEY to a base64-encoded 32-byte key in non-mock deployments", err)
|
||||
})
|
||||
return body, nil
|
||||
}
|
||||
gcm, err := newSnapshotGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to read snapshot encryption nonce: %w", err)
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, body, nil)
|
||||
out := append([]byte(snapshotEncMarker), []byte(base64.StdEncoding.EncodeToString(sealed))...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decryptSnapshot reverses encryptSnapshot for a stored square_request_snapshot.
|
||||
// Marker-prefixed values are base64-decoded and AES-256-GCM-decrypted back to
|
||||
// the byte-identical original request body (the sweep's by-key replay depends
|
||||
// on this); values without the marker (dev/mock plaintext or legacy
|
||||
// pre-encryption rows) are returned unchanged. Exporting it lets the sweep's
|
||||
// stale-pending reconcile decrypt stored snapshots before replay.
|
||||
func decryptSnapshot(data []byte) ([]byte, error) {
|
||||
if !bytes.HasPrefix(data, []byte(snapshotEncMarker)) {
|
||||
return data, nil
|
||||
}
|
||||
key, err := snapshotEncKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot decrypt stored square_request_snapshot: %w", err)
|
||||
}
|
||||
gcm, err := newSnapshotGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(string(data), snapshotEncMarker))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stored square_request_snapshot is not valid base64: %w", err)
|
||||
}
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(sealed) < nonceSize {
|
||||
return nil, errors.New("stored square_request_snapshot ciphertext is too short")
|
||||
}
|
||||
nonce, ciphertext := sealed[:nonceSize], sealed[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stored square_request_snapshot failed AES-GCM authentication: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// newSnapshotGCM builds the AES-256-GCM AEAD for the given 32-byte key.
|
||||
func newSnapshotGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to init snapshot AES cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to init snapshot AES-GCM: %w", err)
|
||||
}
|
||||
return gcm, nil
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// orphanCardClient wraps the dev Square client to record every DeleteCardOnFile
|
||||
// call. Used to assert the save-card orphan cleanup in resolveChargeSource
|
||||
// WITHOUT needing to reach the real Square API. failDelete simulates a Square
|
||||
// disable failure so tests can verify the charge is not failed by cleanup.
|
||||
type orphanCardClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
failDelete bool
|
||||
}
|
||||
|
||||
func (c *orphanCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
c.mu.Lock()
|
||||
c.deleted = append(c.deleted, cardID)
|
||||
c.mu.Unlock()
|
||||
if c.failDelete {
|
||||
return errors.New("square: network error disabling card at Square")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *orphanCardClient) deletedIDs() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.deleted...)
|
||||
}
|
||||
|
||||
// TestResolveChargeSource_SaveCard_HappyPath guards the save-card branch: the
|
||||
// Square card is created, persisted locally via SaveCardForUser, and NO
|
||||
// DeleteCardOnFile cleanup is triggered (a saved card must never be deleted).
|
||||
func TestResolveChargeSource_SaveCard_HappyPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID, err := fixtures.CreateTestUser(db.Conn)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
InvalidateSquareCustomerCache(userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
}()
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &orphanCardClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
token := "cnon:test-save-happy"
|
||||
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
|
||||
require.True(t, ok, "save-card source resolution must succeed on the happy path")
|
||||
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID)
|
||||
require.NotNil(t, savedCardID, "a successful SaveCardForUser must return the local row id")
|
||||
require.NotEmpty(t, sqCustID, "the provisioned Square customer id must be returned")
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1 AND square_card_id = $2`, userID, sourceID).Scan(&rows))
|
||||
require.Equal(t, 1, rows, "the card must be persisted as a user_saved_cards row")
|
||||
|
||||
require.Empty(t, rec.deletedIDs(), "a successfully saved card must never be disabled at Square")
|
||||
}
|
||||
|
||||
// TestResolveChargeSource_SaveCard_OrphanCleanedUp verifies the orphan-card
|
||||
// fix: when CreateCardOnFile succeeds but the local DB save fails, the
|
||||
// just-created Square card is disabled (DeleteCardOnFile) so no card-on-file
|
||||
// is left at Square without a DB row. The charge must still resolve ok=true.
|
||||
func TestResolveChargeSource_SaveCard_OrphanCleanedUp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// A non-existent user: EnsureSquareCustomer is bypassed via the cache, and
|
||||
// SaveCardForUser's INSERT fails on the users(id) FK — a clean injection of
|
||||
// the DB-save failure without touching other test state.
|
||||
userID := "c_orphan_00"
|
||||
squareCustomerCache.Store(userID, "cus_orphan")
|
||||
defer InvalidateSquareCustomerCache(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &orphanCardClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
token := "cnon:test-orphan-cleanup"
|
||||
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
|
||||
require.True(t, ok, "a local save failure must NOT fail the charge")
|
||||
require.Nil(t, savedCardID, "no local saved-card row must exist after the failed save")
|
||||
require.Equal(t, "cus_orphan", sqCustID)
|
||||
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must still be the created card-on-file, got %q", sourceID)
|
||||
|
||||
deletes := rec.deletedIDs()
|
||||
require.Len(t, deletes, 1, "the just-created Square card must be disabled exactly once")
|
||||
require.Equal(t, sourceID, deletes[0], "the disabled card must be the one this call just created")
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
|
||||
require.Zero(t, rows, "no orphaned saved-card row may exist")
|
||||
}
|
||||
|
||||
// TestResolveChargeSource_SaveCard_CleanupFailureStillCharges verifies the
|
||||
// best-effort contract: when the DB save fails AND the Square disable also
|
||||
// fails, the charge must still resolve ok=true (the orphan is only logged for
|
||||
// manual cleanup, never allowed to fail the request).
|
||||
func TestResolveChargeSource_SaveCard_CleanupFailureStillCharges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := "c_orphan_01"
|
||||
squareCustomerCache.Store(userID, "cus_orphan")
|
||||
defer InvalidateSquareCustomerCache(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &orphanCardClient{SquareClient: square.NewDevClient(), failDelete: true}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
token := "cnon:test-orphan-cleanup-fail"
|
||||
sourceID, savedCardID, _, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
|
||||
require.True(t, ok, "a failed Square disable must never fail the charge")
|
||||
require.Nil(t, savedCardID)
|
||||
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID)
|
||||
require.Equal(t, []string{sourceID}, rec.deletedIDs(), "the disable must be attempted even when it will fail")
|
||||
}
|
||||
|
||||
// snapshotEncKeyForTest returns a deterministic base64-encoded 32-byte
|
||||
// AES-256 key so encryption tests do not depend on a real env secret.
|
||||
func snapshotEncKeyForTest() string {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
|
||||
// TestEncryptDecryptSnapshot_RoundTrip pins the M9 lossless constraint: in a
|
||||
// non-mock environment with SNAPSHOT_ENC_KEY set, encryptSnapshot must not
|
||||
// store plaintext and decryptSnapshot must recover the ORIGINAL bytes exactly
|
||||
// — Square's identical-body idempotency replay depends on byte-for-byte
|
||||
// fidelity. It also covers the dev/mock path (plaintext passthrough) and the
|
||||
// legacy/unmarked plaintext path through decryptSnapshot.
|
||||
func TestEncryptDecryptSnapshot_RoundTrip(t *testing.T) {
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest())
|
||||
|
||||
body := []byte(`{"source_id":"cnon:test-nonce","buyer_email_address":"buyer@example.com","idempotency_key":"test-key"}`)
|
||||
|
||||
enc, err := encryptSnapshot(body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, bytes.Equal(enc, body), "production-mode snapshots must not be stored in plaintext")
|
||||
require.True(t, bytes.HasPrefix(enc, []byte(snapshotEncMarker)), "encrypted snapshot must carry the enc:v1: marker")
|
||||
|
||||
dec, err := decryptSnapshot(enc)
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal(dec, body), "decrypt must recover the byte-identical original snapshot (Square idempotent replay depends on it)")
|
||||
|
||||
// Plaintext / legacy / dev-mock values pass through decrypt unchanged.
|
||||
decPlain, err := decryptSnapshot(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal(decPlain, body), "unmarked snapshot values must pass through unchanged")
|
||||
}
|
||||
|
||||
// TestEncryptSnapshot_DevMockStoresPlaintext pins the M9 gate: in dev/mock
|
||||
// environments the snapshot stays plaintext (no key required), so the mock
|
||||
// test suite keeps working unchanged.
|
||||
func TestEncryptSnapshot_DevMockStoresPlaintext(t *testing.T) {
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "mock")
|
||||
t.Setenv("SNAPSHOT_ENC_KEY", "")
|
||||
|
||||
body := []byte(`{"source_id":"cnon:test-nonce"}`)
|
||||
enc, err := encryptSnapshot(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal(enc, body), "mock-mode snapshots must stay plaintext")
|
||||
}
|
||||
|
||||
// TestEncryptDecryptSnapshot_WrongKeyFails pins the auth failure path: a
|
||||
// snapshot encrypted with one key must not decrypt (silently or otherwise)
|
||||
// with a different key — GCM authentication must reject it.
|
||||
func TestEncryptDecryptSnapshot_WrongKeyFails(t *testing.T) {
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest())
|
||||
|
||||
enc, err := encryptSnapshot([]byte(`{"source_id":"cnon:test-nonce"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// A different valid 32-byte key must fail GCM authentication.
|
||||
other := make([]byte, 32)
|
||||
for i := range other {
|
||||
other[i] = 0xFF
|
||||
}
|
||||
t.Setenv("SNAPSHOT_ENC_KEY", base64.StdEncoding.EncodeToString(other))
|
||||
_, err = decryptSnapshot(enc)
|
||||
require.Error(t, err, "a snapshot encrypted with a different key must not decrypt")
|
||||
}
|
||||
|
||||
// TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource pins the SCA
|
||||
// wire contract at the source-resolution level: a call carrying BOTH a
|
||||
// new-card token (the SCA tokenize-result) and a saved card id must return the
|
||||
// TOKEN as the charge source — never the stored ccof id — while still deriving
|
||||
// the Square customer from the saved card row.
|
||||
func TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID, err := fixtures.CreateTestUser(db.Conn)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
InvalidateSquareCustomerCache(userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
}()
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(db.Conn, userID, "ccof:sca-tokenize-unit", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := "cnon:sca-tokenize-unit"
|
||||
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, &cardID, false, "")
|
||||
require.True(t, ok, "the tokenize-result source must resolve")
|
||||
require.Equal(t, token, sourceID, "the tokenize-result token must be the charge source")
|
||||
require.NotEqual(t, "ccof:sca-tokenize-unit", sourceID, "the stored ccof id must NOT be the source")
|
||||
require.NotNil(t, savedCardID, "the saved-card row id must be returned")
|
||||
require.Equal(t, cardID, *savedCardID, "the saved-card row id must match the input card")
|
||||
require.NotEmpty(t, sqCustID, "customer_id must derive from the saved card row")
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crussell/db"
|
||||
"errors"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ApplyBookingCompletionSideEffects runs the post-completion business logic:
|
||||
// patch tests, loyalty stamps, campaign discounts, deposits_required
|
||||
// reduction, and name_history consumption. It MUST be called within the same
|
||||
// transaction that set the booking to 'completed'.
|
||||
//
|
||||
// The helper lives in the payments package (not bookings) because both
|
||||
// entry points that complete a booking — the admin progress endpoint
|
||||
// (bookings.ProgressBookingHandler) and the payment paths — need it. bookings
|
||||
// already imports payments, so it can call this exported function; moving the
|
||||
// helper the other way (into bookings) would create a circular import because
|
||||
// payments cannot import bookings.
|
||||
func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID, userID string) {
|
||||
// Self-contained: if the caller does not already hold the user id,
|
||||
// re-query it from the booking row.
|
||||
if userID == "" {
|
||||
if err := tx.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID); err != nil {
|
||||
log.Printf("Failed to load user_id for completion side-effects on booking %s: %v", bookingID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Collect patch test IDs first so the rows are consumed before INSERT operations.
|
||||
var patchTestIDs []string
|
||||
ptRows, err := tx.Query(ctx, `
|
||||
SELECT DISTINCT pt.id
|
||||
FROM patch_tests pt
|
||||
JOIN booking_services bs ON bs.booking_id = $1
|
||||
WHERE pt.id IN (
|
||||
SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids)
|
||||
)
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
|
||||
} else {
|
||||
for ptRows.Next() {
|
||||
var ptID string
|
||||
if err := ptRows.Scan(&ptID); err == nil {
|
||||
patchTestIDs = append(patchTestIDs, ptID)
|
||||
}
|
||||
}
|
||||
ptRows.Close()
|
||||
}
|
||||
for _, ptID := range patchTestIDs {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
|
||||
`, userID, ptID); err != nil {
|
||||
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", userID, ptID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var bookingTotal float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT total_amount FROM bookings WHERE id = $1
|
||||
`, bookingID).Scan(&bookingTotal); err != nil {
|
||||
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
// Don't award a stamp if this booking already used a loyalty redemption
|
||||
// (take or receive, never both).
|
||||
var loyaltyAppliedOnThisBooking bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking); err != nil {
|
||||
log.Printf("Failed to check loyalty applied on booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
var newStampCount int
|
||||
if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
|
||||
// Loop B MEDIUM (stamp farming via refund + re-charge): the stamp must
|
||||
// be awarded at most ONCE per booking, no matter how many times the
|
||||
// booking is re-completed. A refund never moves the booking out of
|
||||
// 'in_progress', so a re-payment re-completes it — without this guard
|
||||
// each in_progress→completed transition would re-award a stamp with no
|
||||
// net merchant cash flow. The bookings.loyalty_stamp_awarded_at marker
|
||||
// blocks a booking that already earned its stamp; the marker is written
|
||||
// (same tx) only when the award actually landed, so a daily-cap-blocked
|
||||
// completion does not permanently forfeit the booking's stamp. The
|
||||
// existing "no OTHER completed booking within a day" cap is kept.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET loyalty_stamps = loyalty_stamps + 1
|
||||
WHERE id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.user_id = users.id
|
||||
AND b.status = 'completed'
|
||||
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
|
||||
AND b.id != $2
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.id = $2 AND b.loyalty_stamp_awarded_at IS NOT NULL
|
||||
)
|
||||
RETURNING loyalty_stamps
|
||||
`, userID, bookingID).Scan(&newStampCount); err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if newStampCount > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE bookings SET loyalty_stamp_awarded_at = NOW()
|
||||
WHERE id = $1 AND loyalty_stamp_awarded_at IS NULL
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to mark loyalty stamp awarded for booking %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create pending redemption when stamps reach LoyaltyStampCost
|
||||
if newStampCount == LoyaltyStampCost {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
||||
VALUES ($1, $2, 'pending', NOW())
|
||||
`, userID, LoyaltyStampCost)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create loyalty redemption for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip time-based campaign if already applied at payment time
|
||||
var timeBasedApplied bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied); err != nil {
|
||||
log.Printf("Failed to check time-based campaign applied on booking %s: %v", bookingID, err)
|
||||
}
|
||||
if bookingTotal > 0 && !timeBasedApplied {
|
||||
var campaignID string
|
||||
var campaignPercent float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
||||
AND start_date <= NOW() AND end_date >= NOW()
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
ORDER BY discount_percent DESC LIMIT 1
|
||||
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
||||
|
||||
// F1: never over-credit at completion. The admin "Take Payment"
|
||||
// flow can charge the FULL amount while a campaign is still
|
||||
// eligible — the discount must be capped (or skipped when real
|
||||
// money already covers the total) so paid + discounts never exceed
|
||||
// the booking total.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
|
||||
// B13 atomic reservation FIRST — mirror the apply-at-payment
|
||||
// path (discounts.go ApplyEligibleDiscount). The eligibility
|
||||
// SELECT above is a plain read; a concurrent completion on
|
||||
// another booking can exhaust the campaign between that read
|
||||
// and here. The conditional UPDATE only increments while the
|
||||
// campaign still has headroom (PostgreSQL re-evaluates the
|
||||
// WHERE against the post-lock row), so exactly one concurrent
|
||||
// completion wins the redemption. On pgx.ErrNoRows the
|
||||
// discount is SKIPPED — the completion still succeeds, we just
|
||||
// log and move on rather than minting a discount row for a
|
||||
// redemption that never happened.
|
||||
var reservedID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
|
||||
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
RETURNING id
|
||||
`, campaignID).Scan(&reservedID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Skipping time_based campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", campaignID, bookingID)
|
||||
} else {
|
||||
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", campaignID, bookingID, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
||||
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping time_based campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", campaignID, bookingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bookingTotal > 0 {
|
||||
var userBookingCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount); err != nil {
|
||||
log.Printf("Failed to scan user completed booking count: %v", err)
|
||||
}
|
||||
|
||||
var milestoneCampaignID string
|
||||
var milestonePercent float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
|
||||
AND milestone_value = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
|
||||
`, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent); err != nil {
|
||||
log.Printf("Failed to query per-user milestone campaign for booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
if milestoneCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
||||
// F1 over-credit guard — see the time-based block above.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
// B13 atomic reservation FIRST — see the time-based block above.
|
||||
var reservedID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
|
||||
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
RETURNING id
|
||||
`, milestoneCampaignID).Scan(&reservedID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", milestoneCampaignID, bookingID)
|
||||
} else {
|
||||
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", milestoneCampaignID, bookingID, err)
|
||||
}
|
||||
} else {
|
||||
// Once-per-user backstop: the eligibility NOT EXISTS above
|
||||
// is a plain read, so two concurrent completions of
|
||||
// DIFFERENT bookings of this user can both pass it. The
|
||||
// partial unique index uq_booking_discounts_user_milestone_campaign
|
||||
// on (user_id, source_id) for milestone campaigns is the
|
||||
// schema backstop — the second INSERT is suppressed by
|
||||
// ON CONFLICT DO NOTHING and the discount is skipped.
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
||||
ON CONFLICT (user_id, source_id) WHERE discount_source = 'campaign' AND milestone_type IN ('per_user_booking_count', 'anniversary') DO NOTHING
|
||||
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount)
|
||||
if err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
} else if tag.RowsAffected() == 0 {
|
||||
log.Printf("Per-user milestone campaign %s already applied for user %s — skipping duplicate at completion for booking %s", milestoneCampaignID, userID, bookingID)
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", milestoneCampaignID, bookingID)
|
||||
}
|
||||
}
|
||||
|
||||
var globalMilestoneApplied bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied); err != nil {
|
||||
log.Printf("Failed to check global milestone applied on booking %s: %v", bookingID, err)
|
||||
}
|
||||
if !globalMilestoneApplied {
|
||||
var globalCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount); err != nil {
|
||||
log.Printf("Failed to scan global completed booking count: %v", err)
|
||||
}
|
||||
|
||||
var hasInPersonPayment bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment); err != nil {
|
||||
log.Printf("Failed to check in-person payment on booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
if hasInPersonPayment {
|
||||
var globalCampaignID string
|
||||
var globalPercent float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
|
||||
AND milestone_value <= $1
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
ORDER BY milestone_value DESC LIMIT 1
|
||||
`, globalCount).Scan(&globalCampaignID, &globalPercent); err != nil {
|
||||
log.Printf("Failed to query global milestone campaign for booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
if globalCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
||||
// F1 over-credit guard — see the time-based block above.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
// B13 atomic reservation FIRST — see the time-based block above.
|
||||
var reservedID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
|
||||
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
RETURNING id
|
||||
`, globalCampaignID).Scan(&reservedID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Skipping global milestone campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", globalCampaignID, bookingID)
|
||||
} else {
|
||||
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", globalCampaignID, bookingID, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
||||
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping global milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", globalCampaignID, bookingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var firstVisitDate time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate); err != nil {
|
||||
log.Printf("Failed to scan first visit date: %v", err)
|
||||
}
|
||||
if !firstVisitDate.IsZero() {
|
||||
annRows, err := tx.Query(ctx, `
|
||||
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
|
||||
`, userID)
|
||||
if err == nil {
|
||||
// Collect anniversary campaigns first to avoid interleaving rows with writes.
|
||||
type annCampaign struct {
|
||||
id string
|
||||
pct float64
|
||||
value int
|
||||
unit string
|
||||
}
|
||||
var campaigns []annCampaign
|
||||
for annRows.Next() {
|
||||
var c annCampaign
|
||||
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
|
||||
campaigns = append(campaigns, c)
|
||||
}
|
||||
}
|
||||
annRows.Close()
|
||||
|
||||
// Sort by milestone_value descending so we apply the longest anniversary only
|
||||
sort.Slice(campaigns, func(i, j int) bool {
|
||||
return campaigns[i].value > campaigns[j].value
|
||||
})
|
||||
for _, c := range campaigns {
|
||||
var matches bool
|
||||
elapsed := time.Since(firstVisitDate)
|
||||
switch c.unit {
|
||||
case "months":
|
||||
months := int(elapsed.Hours() / (30 * 24))
|
||||
matches = months >= c.value
|
||||
case "years":
|
||||
years := int(elapsed.Hours() / (365.25 * 24))
|
||||
matches = years >= c.value
|
||||
}
|
||||
if matches {
|
||||
discountAmount := roundTo2(bookingTotal * c.pct / 100)
|
||||
// F1 over-credit guard — see the time-based block above.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
// B13 atomic reservation FIRST — see the time-based block above.
|
||||
var reservedID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
|
||||
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
RETURNING id
|
||||
`, c.id).Scan(&reservedID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Skipping anniversary campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", c.id, bookingID)
|
||||
} else {
|
||||
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", c.id, bookingID, err)
|
||||
}
|
||||
} else {
|
||||
// Once-per-user backstop — see the per-user
|
||||
// milestone block above.
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
||||
ON CONFLICT (user_id, source_id) WHERE discount_source = 'campaign' AND milestone_type IN ('per_user_booking_count', 'anniversary') DO NOTHING
|
||||
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount)
|
||||
if err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
} else if tag.RowsAffected() == 0 {
|
||||
log.Printf("Anniversary campaign %s already applied for user %s — skipping duplicate at completion for booking %s", c.id, userID, bookingID)
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping anniversary campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", c.id, bookingID)
|
||||
}
|
||||
break // apply longest matching only
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paymentExists bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists {
|
||||
var newDepositsRequired int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1)
|
||||
WHERE id = $1
|
||||
RETURNING deposits_required
|
||||
`, userID).Scan(&newDepositsRequired); err != nil {
|
||||
log.Printf("ALERT: failed to update deposits_required: %v", err)
|
||||
} else if newDepositsRequired == 0 {
|
||||
// After 3 paid bookings, forget no-shows so the counter resets.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO forgiven_no_shows (booking_id)
|
||||
SELECT id FROM bookings
|
||||
WHERE user_id = $1 AND status = 'no_show'
|
||||
AND start_time >= NOW() - INTERVAL '6 months'
|
||||
AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id)
|
||||
`, userID); err != nil {
|
||||
log.Printf("ALERT: failed to auto-forgive no-shows: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Consume unconsumed name_history entries — this booking is the "first post-name-change
|
||||
// booking" that completes. After this, we no longer show "formerly" on displays.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE name_history SET booking_id = $1
|
||||
WHERE user_id = $2 AND booking_id IS NULL
|
||||
`, bookingID, userID); err != nil {
|
||||
log.Printf("Failed to consume name_history for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// bookingIsFullyPaid reports whether completed payments toward the booking
|
||||
// (excluding tips and on-the-house rows, but INCLUDING discount rows) cover
|
||||
// 100% of the booking total. A discount row represents real value applied
|
||||
// toward the booking: the customer's total obligation is the DISCOUNTED total,
|
||||
// so a booking is fully paid when real money + applied discounts == total
|
||||
// (e.g. a 10% campaign on a £50 booking completes once £45 + £5 discount is
|
||||
// recorded). Tips are excluded (gratuity, not payment toward the booking) as
|
||||
// are on-the-house rows (no real value moved). This deliberately differs from
|
||||
// GetBookingPaymentInfo.TotalPaid, which excludes discount rows because the
|
||||
// deposit/balance SPLIT must run against the full total and real money only.
|
||||
func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) bool {
|
||||
var fullyPaid bool
|
||||
if err := q.QueryRow(ctx, `
|
||||
WITH booking_total AS (
|
||||
SELECT total_amount * 100 AS total_pence FROM bookings WHERE id = $1
|
||||
),
|
||||
paid_total AS (
|
||||
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_pence
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_type != 'tip'
|
||||
AND payment_method NOT IN ('on_the_house')
|
||||
)
|
||||
SELECT pt.paid_pence >= bt.total_pence AND bt.total_pence > 0
|
||||
FROM booking_total bt, paid_total pt
|
||||
`, bookingID).Scan(&fullyPaid); err != nil {
|
||||
log.Printf("Failed to check full-payment threshold for booking %s: %v", bookingID, err)
|
||||
}
|
||||
return fullyPaid
|
||||
}
|
||||
|
||||
// discountHeadroomPence returns how much of the booking's total obligation is
|
||||
// still uncovered — the largest a NEW discount row may carry before the ledger
|
||||
// over-credits the customer (F1). Over-credit records real money + discounts
|
||||
// beyond the booking total, minting an orphaned credit the refund system can
|
||||
// never return: the admin "Take Payment" flow (frontend PaymentModal) sends
|
||||
// payment_type='full' with the FULL amount (subtotal minus discounts already
|
||||
// applied client-side), while applyEligibleCampaignsAtPayment auto-applies any
|
||||
// eligible campaign — without this guard the ledger would record £55 against a
|
||||
// £50 total. The correct fix is the frontend sending the discounted amount
|
||||
// (as the customer modal already does); this headroom computation is the
|
||||
// server-side money-safety half that caps/skips the discount instead.
|
||||
//
|
||||
// Headroom is:
|
||||
//
|
||||
// total - (completed real payments + completed discount rows + pending charge)
|
||||
//
|
||||
// where "real" excludes tip / discount / on_the_house rows (the same
|
||||
// classification bookingIsFullyPaid uses). The pending charge is the payment
|
||||
// completing in the caller's transaction, whose amount is not yet a completed
|
||||
// row when applyEligibleCampaignsAtPayment runs — it is read from the pending
|
||||
// row's stored amount (the amount the charge is being recorded at, i.e.
|
||||
// req.Amount, which is what the charge will settle for). A failed read returns
|
||||
// 0 (conservative: skip rather than over-credit).
|
||||
func discountHeadroomPence(ctx context.Context, q db.Querier, bookingID string) int64 {
|
||||
var totalPence, realPaidPence, discountPence, pendingPence int64
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(ROUND((SELECT total_amount FROM bookings WHERE id = $1) * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_type != 'tip' AND payment_method NOT IN ('discount', 'on_the_house')) * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_method = 'discount') * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'pending') * 100), 0)
|
||||
`, bookingID).Scan(&totalPence, &realPaidPence, &discountPence, &pendingPence)
|
||||
if err != nil {
|
||||
log.Printf("Failed to compute discount headroom for booking %s: %v", bookingID, err)
|
||||
return 0
|
||||
}
|
||||
headroom := totalPence - realPaidPence - discountPence - pendingPence
|
||||
if headroom < 0 {
|
||||
return 0
|
||||
}
|
||||
return headroom
|
||||
}
|
||||
|
||||
// capDiscountToRemainingObligation caps a discount amount (pounds) so the
|
||||
// booking's ledger never over-credits: real money paid + discounts recorded +
|
||||
// the charge in flight must never exceed the booking total. Returns the capped
|
||||
// amount and whether the discount may still be applied; a false second return
|
||||
// means real money already covers the obligation and the discount must be
|
||||
// skipped entirely (applying it would mint a phantom credit). The capped value
|
||||
// is the headroom in pence, so it can never round up past the obligation.
|
||||
func capDiscountToRemainingObligation(ctx context.Context, q db.Querier, bookingID string, discountAmount float64) (float64, bool) {
|
||||
discountPence := int64(math.Round(discountAmount * 100))
|
||||
headroom := discountHeadroomPence(ctx, q, bookingID)
|
||||
if discountPence <= headroom {
|
||||
return discountAmount, true
|
||||
}
|
||||
if headroom <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(headroom) / 100.0, true
|
||||
}
|
||||
|
||||
// completeActiveBookingFromPayment transitions an active booking to
|
||||
// 'completed' and runs the completion side-effects, all within tx. It is a
|
||||
// no-op if the booking is not in an active (completable) status, so cancelled,
|
||||
// no-show and deposit-lapsed bookings are never auto-completed — and once
|
||||
// completed it can never re-fire, because the status filter no longer matches.
|
||||
func completeActiveBookingFromPayment(ctx context.Context, tx pgx.Tx, bookingID string) {
|
||||
var completedID string
|
||||
err := tx.QueryRow(ctx, `
|
||||
UPDATE bookings SET status = 'completed', updated_at = NOW()
|
||||
WHERE id = $1 AND status IN ('pending', 'confirmed', 'in_progress', 'pending_release')
|
||||
RETURNING id
|
||||
`, bookingID).Scan(&completedID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("ALERT: failed to complete fully-paid booking %s: %v", bookingID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
if uErr := tx.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID); uErr != nil {
|
||||
log.Printf("ALERT: booking %s completed by payment but failed to load user for side-effects: %v", bookingID, uErr)
|
||||
return
|
||||
}
|
||||
ApplyBookingCompletionSideEffects(ctx, tx, bookingID, userID)
|
||||
}
|
||||
|
||||
// completeFullyPaidBooking checks whether the booking is now fully paid and,
|
||||
// if so, completes it. It runs in its OWN transaction (check + UPDATE +
|
||||
// side-effects are atomic) and is used after a payment path whose recording
|
||||
// transaction has already committed — currently the Square Terminal
|
||||
// completion in GetCheckoutStatus.
|
||||
func completeFullyPaidBooking(ctx context.Context, bookingID string) {
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("ALERT: failed to begin transaction for fully-paid completion of booking %s: %v", bookingID, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if rErr := tx.Rollback(ctx); rErr != nil && !errors.Is(rErr, pgx.ErrTxClosed) {
|
||||
log.Printf("Failed to rollback fully-paid completion transaction for booking %s: %v", bookingID, rErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if bookingIsFullyPaid(ctx, tx, bookingID) {
|
||||
completeActiveBookingFromPayment(ctx, tx, bookingID)
|
||||
}
|
||||
|
||||
if cErr := tx.Commit(ctx); cErr != nil {
|
||||
log.Printf("ALERT: failed to commit fully-paid completion transaction for booking %s: %v", bookingID, cErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// M15 webhook/sweep completion-asymmetry tests. A Square payment.updated
|
||||
// webhook (handlers/webhooks/square.go handlePaymentUpdated) and the stale
|
||||
// pending-payment sweep's rescue path can both try to complete the same
|
||||
// payment row: the webhook flips `payments.status` with a
|
||||
// `WHERE ... AND status = 'pending'` guard, and the sweep's rescue
|
||||
// (rescueStaleRowCompletedTx) flips the row with its own
|
||||
// `WHERE id = ... AND status = 'pending'` guard before applying the split /
|
||||
// VAT / fully-paid-booking completion side effects. Both guards make the
|
||||
// completion idempotent — the first writer wins, the second matches zero rows
|
||||
// and applies NO side effects. These tests lock that invariant in both race
|
||||
// orderings. Sequential (no t.Parallel): they swap the package-global
|
||||
// SquareClient and mutate the shared pool, like the other sweep tests.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
|
||||
// TestSweepRescueThenWebhook_CompletesExactlyOnce locks the sweep-first race
|
||||
// ordering: the sweep rescues a stale pending payment to 'completed' and
|
||||
// applies the booking-completion side effects exactly once; a webhook-style
|
||||
// idempotent status flip (the exact `WHERE ... AND status = 'pending'` UPDATE
|
||||
// handlePaymentUpdated runs) that arrives AFTER the rescue matches zero rows,
|
||||
// and a re-run of the sweep also does nothing — the side effects are never
|
||||
// doubled.
|
||||
func TestSweepRescueThenWebhook_CompletesExactlyOnce(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
// Give the booking a payable total so the rescue's fully-paid check can
|
||||
// complete it.
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set booking total: %v", err)
|
||||
}
|
||||
// VAT-registered — the rescue applies VAT to the split records.
|
||||
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
|
||||
t.Fatalf("failed to enable VAT in business_settings: %v", err)
|
||||
}
|
||||
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create pending payment: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", payID); err != nil {
|
||||
t.Fatalf("failed to age the payment: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 200000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "seed-asymmetry-completed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed completed Square payment: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, payID); err != nil {
|
||||
t.Fatalf("failed to set square_payment_id: %v", err)
|
||||
}
|
||||
SquareClient = mock
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit test tx: %v", err)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
|
||||
})
|
||||
|
||||
// 1. The sweep rescues the stale pending row (Square reports COMPLETED).
|
||||
if _, err := SweepStalePendingPayments(pool); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var payStatus string
|
||||
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", payID).Scan(&payStatus); err != nil {
|
||||
t.Fatalf("failed to query payment: %v", err)
|
||||
}
|
||||
if payStatus != "completed" {
|
||||
t.Fatalf("expected the sweep to rescue the payment to 'completed', got %q", payStatus)
|
||||
}
|
||||
|
||||
// The rescue must have completed the booking (fully paid) exactly once.
|
||||
var bookingStatus string
|
||||
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if bookingStatus != "completed" {
|
||||
t.Errorf("expected the sweep rescue to complete the fully-paid booking, got %q", bookingStatus)
|
||||
}
|
||||
|
||||
// Exactly the deposit + balance split records exist — no duplicates.
|
||||
var recordCount int
|
||||
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
|
||||
t.Fatalf("failed to count payment records: %v", err)
|
||||
}
|
||||
if recordCount != 2 {
|
||||
t.Errorf("expected exactly 2 split payment records after the rescue, got %d", recordCount)
|
||||
}
|
||||
|
||||
// VAT applied on both split records.
|
||||
var vatRows int
|
||||
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND vat_amount IS NOT NULL", bookingID).Scan(&vatRows); err != nil {
|
||||
t.Fatalf("failed to count VAT'd records: %v", err)
|
||||
}
|
||||
if vatRows != 2 {
|
||||
t.Errorf("expected VAT applied to both split records exactly once, got %d records with VAT", vatRows)
|
||||
}
|
||||
|
||||
// 2. The webhook's completion path arrives AFTER the rescue: its pending-
|
||||
// only UPDATE matches zero rows (idempotent status flip).
|
||||
tag, err := db.Conn.Exec(pool, `
|
||||
UPDATE payments SET status = 'completed', updated_at = NOW()
|
||||
WHERE square_payment_id = $1 AND status = 'pending'
|
||||
`, pay.SquarePayID)
|
||||
if err != nil {
|
||||
t.Fatalf("webhook-style update failed: %v", err)
|
||||
}
|
||||
if int(tag.RowsAffected()) != 0 {
|
||||
t.Errorf("expected the post-rescue webhook completion to match 0 pending rows, got %d", tag.RowsAffected())
|
||||
}
|
||||
|
||||
// 3. A re-run of the sweep finds nothing pending — no second rescue, no
|
||||
// second completion, no duplicate records.
|
||||
if _, err := SweepStalePendingPayments(pool); err != nil {
|
||||
t.Fatalf("second sweep failed: %v", err)
|
||||
}
|
||||
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
|
||||
t.Fatalf("failed to re-count payment records: %v", err)
|
||||
}
|
||||
if recordCount != 2 {
|
||||
t.Errorf("expected the second sweep to add no payment records, got %d", recordCount)
|
||||
}
|
||||
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
|
||||
t.Fatalf("failed to re-query booking: %v", err)
|
||||
}
|
||||
if bookingStatus != "completed" {
|
||||
t.Errorf("expected the booking to stay completed after the second sweep, got %q", bookingStatus)
|
||||
}
|
||||
var stampAwarded sql.NullTime
|
||||
if err := db.Conn.QueryRow(pool, "SELECT loyalty_stamp_awarded_at FROM bookings WHERE id = $1", bookingID).Scan(&stampAwarded); err != nil {
|
||||
t.Fatalf("failed to query loyalty stamp marker: %v", err)
|
||||
}
|
||||
if !stampAwarded.Valid {
|
||||
t.Error("expected the loyalty stamp awarded exactly once by the single completion")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookThenSweepRescue_CompletesExactlyOnce locks the webhook-first race
|
||||
// ordering: the webhook's pending-only status flip completes the payment
|
||||
// first, so the sweep finds no pending row left to rescue and applies NO
|
||||
// side effects — the booking is not double-completed and no split/VAT records
|
||||
// are minted by the sweep.
|
||||
func TestWebhookThenSweepRescue_CompletesExactlyOnce(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set booking total: %v", err)
|
||||
}
|
||||
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create pending payment: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_webhook_first' WHERE id = $1", payID); err != nil {
|
||||
t.Fatalf("failed to age the payment: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit test tx: %v", err)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
// 1. The webhook flips the pending row to completed first.
|
||||
tag, err := db.Conn.Exec(pool, `
|
||||
UPDATE payments SET status = 'completed', updated_at = NOW()
|
||||
WHERE square_payment_id = 'sqp_webhook_first' AND status = 'pending'
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("webhook-style update failed: %v", err)
|
||||
}
|
||||
if int(tag.RowsAffected()) != 1 {
|
||||
t.Fatalf("expected the webhook-style completion to match exactly 1 pending row, got %d", tag.RowsAffected())
|
||||
}
|
||||
|
||||
// 2. The sweep rescue path arrives after: the row is no longer pending, so
|
||||
// it is never fetched and NO side effects run.
|
||||
if _, err := SweepStalePendingPayments(pool); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var recordCount int
|
||||
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
|
||||
t.Fatalf("failed to count payment records: %v", err)
|
||||
}
|
||||
// Exactly ONE row — the webhook only flipped status; the sweep's split
|
||||
// logic must not have run (the rescue is gated on the row still pending).
|
||||
if recordCount != 1 {
|
||||
t.Errorf("expected the sweep to add no records after the webhook completed the row, got %d", recordCount)
|
||||
}
|
||||
|
||||
var bookingStatus string
|
||||
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if bookingStatus != "in_progress" {
|
||||
t.Errorf("expected the booking untouched by the sweep (no double-completion), got %q", bookingStatus)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCompleteActiveBookingFromPayment_RefusesCancelledBooking locks the M2
|
||||
// money-safety boundary of the sweep rescue's completion side-effect: a
|
||||
// cancelled / lapsed / no-show booking must NEVER be auto-completed by the
|
||||
// payment path — a charge landing on such a booking is failed + auto-refunded
|
||||
// by the sweep's F3 gate, and completing the booking would record money against
|
||||
// a booking the cancellation flow already closed.
|
||||
func TestCompleteActiveBookingFromPayment_RefusesCancelledBooking(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set booking we_cancelled: %v", err)
|
||||
}
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
completeActiveBookingFromPayment(ctx, pgxTx, bookingID)
|
||||
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "we_cancelled" {
|
||||
t.Errorf("expected the cancelled booking NOT auto-completed, got %q", status)
|
||||
}
|
||||
// The setup tx is rolled back at test end, so no pool-level cleanup is needed.
|
||||
}
|
||||
|
||||
// TestCompleteFullyPaidBooking_CompletesPayableBooking locks the sweep rescue's
|
||||
// completion side-effect (applyStaleRescueRecords → bookingIsFullyPaid →
|
||||
// completeActiveBookingFromPayment): a PAYABLE booking fully covered by
|
||||
// completed real money is completed by the rescue-completion path, exactly as
|
||||
// the live payment path would.
|
||||
func TestCompleteFullyPaidBooking_CompletesPayableBooking(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
|
||||
// A full £50 completed payment covers the £50 booking total.
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create completed payment: %v", err)
|
||||
}
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, payID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
completeFullyPaidBooking(pool, bookingID)
|
||||
|
||||
var status string
|
||||
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "completed" {
|
||||
t.Errorf("expected the fully-paid payable booking completed, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompletion_ConcurrentCompletions_CampaignMaxRedemptions proves the B13
|
||||
// atomic reservation in the completion path (completion.go): two concurrent
|
||||
// completions of two DIFFERENT bookings of the same user both pass the
|
||||
// eligibility SELECT while a max_redemptions=1 campaign still has headroom, but
|
||||
// only ONE may win the conditional reservation UPDATE. The loser matches zero
|
||||
// rows (pgx.ErrNoRows) and SKIPS the discount — the booking still completes.
|
||||
// times_redeemed must end at exactly 1, never 2.
|
||||
func TestCompletion_ConcurrentCompletions_CampaignMaxRedemptions(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
bookingIDs := make([]string, 2)
|
||||
for i := range bookingIDs {
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, i+1, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set booking in_progress: %v", err)
|
||||
}
|
||||
bookingIDs[i] = bookingID
|
||||
}
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
for _, bid := range bookingIDs {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bid)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bid)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bid)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bid)
|
||||
}
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM discount_campaigns WHERE name = 'Completion Race Campaign'`)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
// Commit the setup so both goroutines complete at pool level on independent
|
||||
// connections — a shared per-test tx would serialize them.
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx, "no transaction in context")
|
||||
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
|
||||
|
||||
var campaignID string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
||||
VALUES ('Completion Race Campaign', 'time_based', 10, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 1, 0)
|
||||
RETURNING id
|
||||
`).Scan(&campaignID))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
commitErrs := make([]error, 2)
|
||||
for i, bid := range bookingIDs {
|
||||
wg.Add(1)
|
||||
go func(idx int, booking string) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
cctx := context.Background()
|
||||
gtx, err := db.Conn.Begin(cctx)
|
||||
if err != nil {
|
||||
commitErrs[idx] = err
|
||||
return
|
||||
}
|
||||
defer func() { _ = gtx.Rollback(cctx) }()
|
||||
completeActiveBookingFromPayment(cctx, gtx, booking)
|
||||
commitErrs[idx] = gtx.Commit(cctx)
|
||||
}(i, bid)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range commitErrs {
|
||||
require.NoError(t, err, "completion tx %d must commit", i)
|
||||
}
|
||||
|
||||
// Both bookings must complete regardless of who won the discount race.
|
||||
for _, bid := range bookingIDs {
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bid).Scan(&status))
|
||||
assert.Equal(t, "completed", status, "a discount race must never fail a booking completion")
|
||||
}
|
||||
|
||||
// Exactly ONE booking got the campaign discount.
|
||||
var discountCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `
|
||||
SELECT COUNT(*) FROM booking_discounts
|
||||
WHERE source_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based'
|
||||
`, campaignID).Scan(&discountCount))
|
||||
assert.Equal(t, 1, discountCount, "exactly one completion must win the max_redemptions=1 campaign")
|
||||
|
||||
// The redemption counter never over-increments past max_redemptions.
|
||||
var redeemed int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&redeemed))
|
||||
assert.Equal(t, 1, redeemed, "times_redeemed must end at 1, never 2")
|
||||
}
|
||||
|
||||
// TestCompletion_CampaignExhaustedAtReservation_SkipsDiscount locks the
|
||||
// pgx.ErrNoRows skip branch of the completion's conditional reservation
|
||||
// deterministically. A concurrent redemption exhausts the campaign
|
||||
// (times_redeemed = max_redemptions) in an uncommitted transaction AFTER the
|
||||
// completion's eligibility SELECT reads headroom but BEFORE its conditional
|
||||
// UPDATE. Under READ COMMITTED the blocked UPDATE re-evaluates its WHERE
|
||||
// against the post-increment row, matches zero rows, and the completion must
|
||||
// STILL complete the booking while SKIPPING the discount — no booking_discounts
|
||||
// row, no discount payment, counter unchanged.
|
||||
func TestCompletion_CampaignExhaustedAtReservation_SkipsDiscount(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
|
||||
var campaignID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
||||
VALUES ('Exhausted At Completion', 'time_based', 10, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 1, 0)
|
||||
RETURNING id
|
||||
`).Scan(&campaignID))
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx, "no transaction in context")
|
||||
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM discount_campaigns WHERE id = $1`, campaignID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
// A separate transaction exhausts the campaign (times_redeemed = 1) WITHOUT
|
||||
// committing — its row lock makes the completion's conditional UPDATE block
|
||||
// until it is released, while READ COMMITTED keeps the completion's earlier
|
||||
// eligibility SELECT reading the old committed value (0). That is exactly
|
||||
// the read-then-write window the race exploits.
|
||||
htx, err := db.Conn.Begin(pool)
|
||||
require.NoError(t, err, "failed to begin holder tx")
|
||||
defer func() { _ = htx.Rollback(pool) }()
|
||||
_, err = htx.Exec(pool, `UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1`, campaignID)
|
||||
require.NoError(t, err, "failed to exhaust campaign on holder tx")
|
||||
|
||||
// Launch the completion on another connection — it passes the eligibility
|
||||
// SELECT and blocks on the conditional UPDATE. Releasing the holder lets the
|
||||
// blocked UPDATE re-evaluate and miss.
|
||||
done := make(chan struct{})
|
||||
var completeErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
cctx := context.Background()
|
||||
gtx, err := db.Conn.Begin(cctx)
|
||||
if err != nil {
|
||||
completeErr = err
|
||||
return
|
||||
}
|
||||
defer func() { _ = gtx.Rollback(cctx) }()
|
||||
completeActiveBookingFromPayment(cctx, gtx, bookingID)
|
||||
completeErr = gtx.Commit(cctx)
|
||||
}()
|
||||
|
||||
// Give the completion time to reach the blocked conditional UPDATE.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
require.NoError(t, htx.Commit(pool), "failed to release holder tx")
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("completion did not finish after holder release (deadlock?)")
|
||||
}
|
||||
require.NoError(t, completeErr, "completion must succeed despite the exhausted campaign")
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status))
|
||||
assert.Equal(t, "completed", status, "a discount race must never fail the booking completion")
|
||||
|
||||
var discountCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
assert.Zero(t, discountCount, "the exhausted campaign must NOT be applied at completion")
|
||||
|
||||
var redeemed int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&redeemed))
|
||||
assert.Equal(t, 1, redeemed, "times_redeemed stays at the concurrent redemption's 1")
|
||||
}
|
||||
|
||||
// TestCompletion_PerUserMilestone_DuplicateSuppressed locks the once-per-user
|
||||
// backstop for the per-user milestone (completion.go): the partial unique index
|
||||
// uq_booking_discounts_user_milestone_campaign + ON CONFLICT DO NOTHING. A
|
||||
// concurrent completion of ANOTHER of this user's bookings inserts the milestone
|
||||
// discount row (uncommitted — invisible to this completion's NOT EXISTS
|
||||
// eligibility read under READ COMMITTED). This completion's own INSERT then
|
||||
// blocks on the conflicting unique index entry and is suppressed after the
|
||||
// concurrent winner commits: exactly ONE milestone row exists for the user, no
|
||||
// discount payment is minted, and the booking still completes.
|
||||
func TestCompletion_PerUserMilestone_DuplicateSuppressed(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
|
||||
var campaignID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions)
|
||||
VALUES ('Milestone At Completion', 'milestone', 15, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 'per_user_booking_count', 1, 10)
|
||||
RETURNING id
|
||||
`).Scan(&campaignID))
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx, "no transaction in context")
|
||||
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM discount_campaigns WHERE id = $1`, campaignID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
// The concurrent winner already inserted the milestone row for this user
|
||||
// (uncommitted — the completion's NOT EXISTS eligibility read passes, its
|
||||
// INSERT then blocks on the unique index entry).
|
||||
htx, err := db.Conn.Begin(pool)
|
||||
require.NoError(t, err, "failed to begin holder tx")
|
||||
defer func() { _ = htx.Rollback(pool) }()
|
||||
_, err = htx.Exec(pool, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', 15, 50.00, 7.50)
|
||||
`, bookingID, userID, campaignID)
|
||||
require.NoError(t, err, "failed to insert concurrent milestone discount on holder tx")
|
||||
|
||||
done := make(chan struct{})
|
||||
var completeErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
cctx := context.Background()
|
||||
gtx, err := db.Conn.Begin(cctx)
|
||||
if err != nil {
|
||||
completeErr = err
|
||||
return
|
||||
}
|
||||
defer func() { _ = gtx.Rollback(cctx) }()
|
||||
completeActiveBookingFromPayment(cctx, gtx, bookingID)
|
||||
completeErr = gtx.Commit(cctx)
|
||||
}()
|
||||
|
||||
// Give the completion time to reach the blocked booking_discounts INSERT.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
require.NoError(t, htx.Commit(pool), "failed to release holder tx")
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("completion did not finish after holder release (deadlock?)")
|
||||
}
|
||||
require.NoError(t, completeErr, "completion must succeed despite the suppressed duplicate")
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status))
|
||||
assert.Equal(t, "completed", status, "a duplicate milestone must never fail the booking completion")
|
||||
|
||||
var milestoneCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `
|
||||
SELECT COUNT(*) FROM booking_discounts
|
||||
WHERE user_id = $1 AND source_id = $2 AND discount_source = 'campaign'
|
||||
`, userID, campaignID).Scan(&milestoneCount))
|
||||
assert.Equal(t, 1, milestoneCount, "the once-per-user milestone must apply at most once per user")
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `
|
||||
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
|
||||
`, bookingID).Scan(&payCount))
|
||||
assert.Zero(t, payCount, "the suppressed duplicate must not mint a discount payment")
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// slowCreatePaymentClient delays the Square charge so each handler holds its
|
||||
// advisory lock long enough that a concurrent same-key request would race it
|
||||
// without the lock (two goroutines both reading "no record", both charging).
|
||||
type slowCreatePaymentClient struct {
|
||||
square.SquareClient
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (c *slowCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
time.Sleep(c.delay)
|
||||
return c.SquareClient.CreatePayment(ctx, req)
|
||||
}
|
||||
|
||||
// cleanupConcurrentTestRows deletes the rows a concurrency test committed at
|
||||
// pool level. These tests must COMMIT their setup so the advisory locks work
|
||||
// across independent connections, which leaves committed rows in the shared
|
||||
// test DB — without cleanup they leak into parallel tests (e.g. GetGiftCards
|
||||
// counts gift_cards/user_giftcard_balances globally). FK-safe deletion order.
|
||||
func cleanupConcurrentTestRows(t *testing.T, pool context.Context, userID, bookingID string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
var gcIDs []string
|
||||
rows, err := db.Conn.Query(pool, `SELECT id FROM gift_cards WHERE created_by = $1`, userID)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
gcIDs = append(gcIDs, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
for _, gcID := range gcIDs {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, gcID)
|
||||
}
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM user_giftcard_balances WHERE user_id = $1`, userID)
|
||||
if bookingID != "" {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
}
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE created_by = $1 OR idempotency_key LIKE 'concurrent-%'`, userID)
|
||||
for _, gcID := range gcIDs {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, gcID)
|
||||
}
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_ConcurrentSameKey_SingleRecord proves the BuyGiftCard
|
||||
// advisory lock (giftcards.go): two goroutines POSTing the same idempotency key
|
||||
// must produce exactly ONE payment record and ONE funded gift card — never
|
||||
// 2× value for 1 charge. Without the lock, both goroutines pass the
|
||||
// idempotency check, both reuse/insert pending records, and both fund the card.
|
||||
func TestBuyGiftCard_ConcurrentSameKey_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, "")
|
||||
|
||||
// Commit the setup so both goroutines operate at pool level — the advisory
|
||||
// locks only serialize across independent connections, and a per-test tx
|
||||
// would route both sides through a single shared connection.
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
key := "buy-gc-concurrent-same-key"
|
||||
reqBody := map[string]interface{}{
|
||||
"amount": 2000,
|
||||
"recipient_type": "self",
|
||||
"new_card_token": "cnon:concurrent-card",
|
||||
"idempotency_key": key,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", reqBody, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
// Both requests must succeed — the lock serializes them and the second
|
||||
// finds the completed record (idempotent dedup, HTTP 200), so neither
|
||||
// double-charges nor errors.
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 201 (create) or 200 (dedup), got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly one payment record for this key.
|
||||
var payCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE idempotency_key = $1`, key).Scan(&payCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount)
|
||||
}
|
||||
|
||||
// Exactly one funded gift card for this user's self-purchase.
|
||||
var gcCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 20.00 AND created_by = $1`, userID).Scan(&gcCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count gift cards: %v", err)
|
||||
}
|
||||
if gcCount != 1 {
|
||||
t.Errorf("expected exactly 1 funded gift card, got %d (2× value!)", gcCount)
|
||||
}
|
||||
|
||||
// User balance credited exactly once.
|
||||
var balance float64
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query balance: %v", err)
|
||||
}
|
||||
if balance != 20.00 {
|
||||
t.Errorf("expected balance 20.00, got %.2f (double-credit!)", balance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTipPayment_ConcurrentSameKey_SingleRecord proves the tip advisory lock
|
||||
// (handlers.go): two goroutines POSTing the same booking with the same key must
|
||||
// produce exactly ONE tip payment record — never two charges for one booking.
|
||||
func TestTipPayment_ConcurrentSameKey_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
// Past start so the tip is accepted (tips require the booking to have started).
|
||||
start := clock.Now().Add(-1 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
||||
t.Fatalf("failed to create prior payment: %v", err)
|
||||
}
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
key := "tip-concurrent-same-key"
|
||||
cardToken := "cnon:concurrent-tip-card"
|
||||
reqBody := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", reqBody, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
var tipCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND idempotency_key = $2`, bookingID, key).Scan(&tipCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count tip payments: %v", err)
|
||||
}
|
||||
if tipCount != 1 {
|
||||
t.Errorf("expected exactly 1 tip payment record, got %d (double-charge!)", tipCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookingPayment_ConcurrentSameKey_SingleRecord proves the booking-payment
|
||||
// advisory lock (handlers.go): two goroutines paying the same booking with the
|
||||
// same key must produce exactly ONE payment record.
|
||||
func TestBookingPayment_ConcurrentSameKey_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
key := "booking-pay-concurrent-same-key"
|
||||
cardToken := "cnon:concurrent-booking-card"
|
||||
reqBody := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", reqBody, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
var payCount int
|
||||
err := db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&payCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
// countingCreatePaymentClient delays the Square charge (widening the advisory
|
||||
// lock race window) and counts every successful CreatePayment call so the test
|
||||
// can assert exactly one charge reaches Square.
|
||||
type countingCreatePaymentClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
charges int
|
||||
}
|
||||
|
||||
func (c *countingCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
c.mu.Lock()
|
||||
c.charges++
|
||||
c.mu.Unlock()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
return c.SquareClient.CreatePayment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *countingCreatePaymentClient) chargeCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.charges
|
||||
}
|
||||
|
||||
// TestBookingPayment_ConcurrentPartials_SingleCharge proves the in-lock
|
||||
// remaining-balance re-check (handlers.go): two concurrent 'partial' payments
|
||||
// whose combined amount exceeds the booking's remaining balance must yield ONE
|
||||
// successful charge — the loser is rejected with 4xx inside the advisory lock
|
||||
// BEFORE inserting a pending record or hitting Square. Without the re-check
|
||||
// both pass the pre-lock ValidatePartialAmount against the same balance, both
|
||||
// charge, and the overflow is silently recorded as a tip.
|
||||
func TestBookingPayment_ConcurrentPartials_SingleCharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
// The fixture service costs £50, so the booking's remaining balance is 5000
|
||||
// pence. Two £30 partials sum to £60 > £50 — only one may succeed.
|
||||
var remainingPence int64
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
`SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&remainingPence); err != nil {
|
||||
t.Fatalf("failed to read booking total: %v", err)
|
||||
}
|
||||
if remainingPence != 5000 {
|
||||
t.Fatalf("expected fixture booking total of 5000 pence, got %d", remainingPence)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &countingCreatePaymentClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
cardToken := "cnon:concurrent-partial-card"
|
||||
reqBody := CreateBookingPaymentRequest{
|
||||
Amount: 3000,
|
||||
PaymentType: "partial",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "partial-concurrent-" + bookingID,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", reqBody, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
// Exactly one request wins; the loser is rejected by the in-lock balance
|
||||
// re-check (409) — or by the pre-lock filter if it read the reduced balance
|
||||
// after the winner committed (400). Either way, never a second charge.
|
||||
okCount, rejectedCount := 0, 0
|
||||
for i, rec := range recs {
|
||||
switch {
|
||||
case rec.Code == http.StatusOK:
|
||||
okCount++
|
||||
case rec.Code == http.StatusBadRequest || rec.Code == http.StatusConflict:
|
||||
rejectedCount++
|
||||
default:
|
||||
t.Errorf("request %d unexpected status %d: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if okCount != 1 {
|
||||
t.Errorf("expected exactly 1 successful partial payment, got %d", okCount)
|
||||
}
|
||||
if rejectedCount != 1 {
|
||||
t.Errorf("expected exactly 1 rejected partial payment, got %d", rejectedCount)
|
||||
}
|
||||
|
||||
// Exactly one charge reached Square.
|
||||
if n := slow.chargeCount(); n != 1 {
|
||||
t.Errorf("expected exactly 1 Square charge, got %d (double-charge!)", n)
|
||||
}
|
||||
|
||||
// Exactly one completed payment for the booking, and no overpayment: the
|
||||
// recorded total must not exceed the booking's remaining balance.
|
||||
var payCount int
|
||||
if err := db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 completed payment, got %d (double-charge!)", payCount)
|
||||
}
|
||||
var paidPence int64
|
||||
if err := db.Conn.QueryRow(pool,
|
||||
`SELECT ROUND(COALESCE(SUM(amount), 0) * 100)::bigint FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'`,
|
||||
bookingID).Scan(&paidPence); err != nil {
|
||||
t.Fatalf("failed to sum paid amount: %v", err)
|
||||
}
|
||||
if paidPence > remainingPence {
|
||||
t.Errorf("overpayment recorded: paid %d pence exceeds remaining balance %d pence", paidPence, remainingPence)
|
||||
}
|
||||
}
|
||||
|
||||
// completedCheckoutClient forces GetCheckout to return a fixed COMPLETED
|
||||
// payment for any checkout id, deterministically exercising the terminal
|
||||
// completion dedup+insert path (the mock's real async goroutine would be
|
||||
// non-deterministic in a race test).
|
||||
type completedCheckoutClient struct {
|
||||
square.SquareClient
|
||||
result *square.PaymentResult
|
||||
}
|
||||
|
||||
func (c *completedCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
// TestGetCheckoutStatus_ConcurrentPolls_SingleRecord proves the P3 fix: the
|
||||
// terminal-completion path serializes on a per-payment advisory lock, so two
|
||||
// concurrent polls of the same completed checkout produce exactly ONE payment
|
||||
// record. Without the lock, both goroutines pass the dedup SELECT, both INSERT,
|
||||
// and the second dies on the idempotency_key UNIQUE constraint after the
|
||||
// customer already paid.
|
||||
func TestGetCheckoutStatus_ConcurrentPolls_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &completedCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
result: &square.PaymentResult{
|
||||
ID: "pay_terminal_race",
|
||||
Status: "COMPLETED",
|
||||
Amount: 5000,
|
||||
Fees: 88,
|
||||
SquarePayID: "pay_terminal_race",
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
ReceiptURL: "https://receipt.example/pay_terminal_race",
|
||||
EntryMethod: "EMV",
|
||||
LocationID: "loc",
|
||||
ReferenceID: bookingID,
|
||||
CreatedAt: "2026-07-31T00:00:00Z",
|
||||
UpdatedAt: "2026-07-31T00:00:00Z",
|
||||
},
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
req := httptest.NewRequest("GET", "/api/checkout/"+checkoutID+"/status?booking_id="+bookingID, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("checkout_id", checkoutID)
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
// GetCheckoutStatus is admin-only (defense-in-depth S-1 check).
|
||||
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
||||
req = req.WithContext(reqCtx)
|
||||
w := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w, req)
|
||||
recs[idx] = w
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly one completed terminal payment record for this booking.
|
||||
var payCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card' AND square_payment_id = $2`,
|
||||
bookingID, "pay_terminal_race").Scan(&payCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count terminal payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 terminal payment record, got %d (double-record race!)", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoyaltyRedemption_ConcurrentSameBooking_SingleApply proves the loyalty
|
||||
// redemption advisory lock (loyalty.go): two goroutines redeeming the same
|
||||
// booking must apply the 10% discount exactly once. One request succeeds (200)
|
||||
// and the other is rejected by the in-lock re-check ("A loyalty discount has
|
||||
// already been applied", 400) — never two discount payments for one booking.
|
||||
func TestLoyaltyRedemption_ConcurrentSameBooking_SingleApply(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupLoyaltyUser(t, ctx, tx, 10)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
// Commit the setup so both goroutines operate at pool level — the advisory
|
||||
// lock only serializes across independent connections.
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makeApplyRedemptionRequest(bookingID, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
// Exactly one request wins the redemption; the other finds the already
|
||||
// applied discount inside the lock and is rejected with 400 (the first
|
||||
// handler completes in milliseconds, well under the ~3s lock bound, so the
|
||||
// 409 lock-timeout path is not exercised).
|
||||
okCount, rejectedCount := 0, 0
|
||||
for i, rec := range recs {
|
||||
switch {
|
||||
case rec.Code == http.StatusOK:
|
||||
okCount++
|
||||
case rec.Code == http.StatusBadRequest || rec.Code == http.StatusConflict:
|
||||
rejectedCount++
|
||||
default:
|
||||
t.Errorf("request %d unexpected status %d: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if okCount != 1 {
|
||||
t.Errorf("expected exactly 1 successful redemption, got %d", okCount)
|
||||
}
|
||||
if rejectedCount != 1 {
|
||||
t.Errorf("expected exactly 1 rejected redemption, got %d", rejectedCount)
|
||||
}
|
||||
|
||||
// Exactly one loyalty discount and one discount payment row.
|
||||
var discountCount int
|
||||
if err := db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'`, bookingID).Scan(&discountCount); err != nil {
|
||||
t.Fatalf("failed to count loyalty discounts: %v", err)
|
||||
}
|
||||
if discountCount != 1 {
|
||||
t.Errorf("expected exactly 1 loyalty discount, got %d (double-apply!)", discountCount)
|
||||
}
|
||||
var payCount int
|
||||
if err := db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&payCount); err != nil {
|
||||
t.Fatalf("failed to count discount payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 discount payment, got %d", payCount)
|
||||
}
|
||||
|
||||
// The redemption is applied exactly once.
|
||||
var appliedCount int
|
||||
if err := db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'applied'`, userID).Scan(&appliedCount); err != nil {
|
||||
t.Fatalf("failed to count applied redemptions: %v", err)
|
||||
}
|
||||
if appliedCount != 1 {
|
||||
t.Errorf("expected exactly 1 applied redemption, got %d", appliedCount)
|
||||
}
|
||||
|
||||
// Clean up the committed loyalty_redemptions row (cleanupConcurrentTestRows
|
||||
// does not cover it; user_id is SET NULL by the FK so it would otherwise leak).
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM loyalty_redemptions WHERE user_id = $1 OR applied_to_booking_id = $2`, userID, bookingID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateTillSale_ConcurrentSameKey_SingleRecord proves the till-sale
|
||||
// advisory lock (till.go): two goroutines POSTing the same idempotency key must
|
||||
// produce exactly ONE till_sales row and ONE funded gift card — never 2× value
|
||||
// for one charge. Without the lock, both goroutines pass the idempotency check,
|
||||
// both fund a gift card, and one dies on the till_sales idempotency_key UNIQUE
|
||||
// constraint after the funding already committed.
|
||||
func TestCreateTillSale_ConcurrentSameKey_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
pool := context.Background()
|
||||
cleanupConcurrentTestRows(t, pool, adminID, "")
|
||||
key := "till-concurrent-same-key"
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE idempotency_key = $1`, key)
|
||||
})
|
||||
|
||||
// Commit the setup so both goroutines operate at pool level — the advisory
|
||||
// locks only serialize across independent connections, and a per-test tx
|
||||
// would route both sides through a single shared connection.
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:concurrent-till-card",
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", reqBody, adminToken, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
// Both requests must succeed — the lock serializes them and the second
|
||||
// finds the completed record (idempotent dedup), so neither double-charges
|
||||
// nor errors.
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 201 (create) or 200 (dedup), got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly one till_sales row for this key.
|
||||
var saleCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count till_sales: %v", err)
|
||||
}
|
||||
if saleCount != 1 {
|
||||
t.Errorf("expected exactly 1 till_sales row, got %d (double-charge!)", saleCount)
|
||||
}
|
||||
|
||||
// Exactly one funded gift card for this admin's till sale.
|
||||
var gcCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 50.00 AND created_by = $1`, adminID).Scan(&gcCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count gift cards: %v", err)
|
||||
}
|
||||
if gcCount != 1 {
|
||||
t.Errorf("expected exactly 1 funded gift card, got %d (2× value!)", gcCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferGiftCard_ConcurrentCrossTransfer_NoDeadlock proves the gift-card
|
||||
// transfer lock ordering (giftcards.go): two concurrent cross-transfers A→B and
|
||||
// B→A must both succeed. Locking "source first" (the caller's chosen order)
|
||||
// would deadlock — A→B locks A then B while B→A locks B then A — and Postgres
|
||||
// aborts one with SQLSTATE 40P01. Locking the lesser ID first makes both
|
||||
// transactions acquire the same lock sequence, so neither deadlocks.
|
||||
func TestTransferGiftCard_ConcurrentCrossTransfer_NoDeadlock(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||
token := jwt.GenerateTestToken(adminID, "admin")
|
||||
cleanupConcurrentTestRows(t, context.Background(), adminID, "")
|
||||
|
||||
// Both cards hold ample balance so either transfer direction succeeds no
|
||||
// matter which transaction wins the race (net effect is a wash).
|
||||
var cardAID, cardBID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (100.00, 100.00, $1) RETURNING id
|
||||
`, adminID).Scan(&cardAID); err != nil {
|
||||
t.Fatalf("failed to insert card A: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (100.00, 100.00, $1) RETURNING id
|
||||
`, adminID).Scan(&cardBID); err != nil {
|
||||
t.Fatalf("failed to insert card B: %v", err)
|
||||
}
|
||||
|
||||
// Commit the setup so both goroutines run at pool level on independent
|
||||
// connections — a shared per-test tx would serialize them on one connection
|
||||
// and mask the deadlock entirely.
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
|
||||
// Route each request through a chi router so chi.URLParam("from") resolves.
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/admin/gift-cards/{from}/transfer", TransferGiftCard)
|
||||
|
||||
transfer := func(from, to string, amount float64) int {
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"to_card_id": to,
|
||||
"amount": amount,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/admin/gift-cards/"+from+"/transfer", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
codes := make([]int, 2)
|
||||
|
||||
// Deterministically force the deadlock window instead of hoping two
|
||||
// sub-millisecond transactions interleave: hold each card's row lock on a
|
||||
// dedicated pool connection, launch both cross-transfers (each blocks on
|
||||
// its first lock), then release the held locks one at a time.
|
||||
//
|
||||
// Old "source first" order: G1 (A→B) holds A and queues on B; releasing
|
||||
// holderB grants B to G2 (B→A, queued first) which then queues on A held
|
||||
// by G1 → Postgres aborts one with 40P01 → 500.
|
||||
//
|
||||
// Fixed sorted order: both transactions want the SAME first lock, so only
|
||||
// one ever holds it; the loser waits, the winner proceeds, and both
|
||||
// succeed with no cycle.
|
||||
holderA, err := db.Conn.Acquire(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire holder conn A: %v", err)
|
||||
}
|
||||
defer holderA.Release()
|
||||
if _, err := holderA.Exec(pool, "SELECT amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", cardAID); err != nil {
|
||||
t.Fatalf("failed to hold lock on card A: %v", err)
|
||||
}
|
||||
|
||||
holderB, err := db.Conn.Acquire(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire holder conn B: %v", err)
|
||||
}
|
||||
defer holderB.Release()
|
||||
if _, err := holderB.Exec(pool, "SELECT amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", cardBID); err != nil {
|
||||
t.Fatalf("failed to hold lock on card B: %v", err)
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
codes[0] = transfer(cardAID, cardBID, 10.00)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
codes[1] = transfer(cardBID, cardAID, 10.00)
|
||||
}()
|
||||
close(startBoth)
|
||||
|
||||
// Give both goroutines time to reach their first blocked SELECT.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Release A first: the A→B transfer acquires A and queues on B (still
|
||||
// held). Then release B: the B→A transfer was queued on B first, so it
|
||||
// acquires B and queues on A — completing the cycle under old code.
|
||||
if _, err := holderA.Exec(pool, "COMMIT"); err != nil {
|
||||
t.Fatalf("failed to release holder A: %v", err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if _, err := holderB.Exec(pool, "COMMIT"); err != nil {
|
||||
t.Fatalf("failed to release holder B: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("concurrent cross-transfers deadlocked (no response within 30s)")
|
||||
}
|
||||
|
||||
for i, code := range codes {
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("cross-transfer %d expected 200, got %d (deadlock aborted one tx)", i, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Both transfers succeeded, so each card's balance is back to £100.
|
||||
for _, id := range []string{cardAID, cardBID} {
|
||||
var remaining float64
|
||||
if err := db.Conn.QueryRow(pool, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, id).Scan(&remaining); err != nil {
|
||||
t.Fatalf("failed to query balance for card %s: %v", id, err)
|
||||
}
|
||||
if remaining != 100.00 {
|
||||
t.Errorf("card %s expected net balance 100.00, got %.2f", id, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,16 +513,18 @@ func TestDiscountPreview_PaymentLock(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// First payment — discount should be applied
|
||||
// First payment — discount should be applied. A £10 deposit on the £50
|
||||
// booking leaves enough headroom for the 10% (£5) discount to pass the F1
|
||||
// over-credit cap (a full/overpaid fixture would now correctly skip it).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 1000, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 10.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create first payment: %v", err)
|
||||
}
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
var discountCount int
|
||||
tx.QueryRow(ctx,
|
||||
@@ -531,16 +533,18 @@ func TestDiscountPreview_PaymentLock(t *testing.T) {
|
||||
t.Errorf("expected 1 discount after first payment, got %d", discountCount)
|
||||
}
|
||||
|
||||
// Second payment — NO new discounts should be added (lock active)
|
||||
// Second payment — NO new discounts should be added (lock active). The £40
|
||||
// balance completes the £50 booking; the 2-completed-payment lock in
|
||||
// ComputeEligibleDiscounts is what refuses the second application.
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'online_square', 4000, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'full', 'online_square', 40.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create second payment: %v", err)
|
||||
}
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
tx.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount)
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// EligibleDiscount describes a single discount that is currently eligible for a
|
||||
// booking, computed identically for the discount preview and the
|
||||
// apply-at-payment path so the preview shows exactly what payment will apply.
|
||||
// Amount is the discounted value in pounds.
|
||||
type EligibleDiscount struct {
|
||||
Source string // "campaign" or "referral"
|
||||
Name string
|
||||
Percent float64
|
||||
Amount float64
|
||||
SourceID string // discount_campaigns.id or referral_discounts.id
|
||||
CampaignType string // "time_based", "milestone", or "" for referral
|
||||
MilestoneType *string // "per_user_booking_count", "anniversary", "global_booking_count", or nil
|
||||
IsReferral bool
|
||||
}
|
||||
|
||||
// ComputeEligibleDiscounts returns every campaign/referral discount currently
|
||||
// eligible for the booking, using the same queries the apply-at-payment path
|
||||
// runs (including the global in-person milestone discount that was previously
|
||||
// only computed at payment time). It is read-only: it never writes
|
||||
// booking_discounts, payments, or campaign counters. Callers pass the querier
|
||||
// that matches their context — db.Conn for the preview, the payment
|
||||
// transaction for the apply path.
|
||||
//
|
||||
// Existing booking_discounts for the booking are collected in ONE query up
|
||||
// front and checked in-memory, replacing the previous per-campaign
|
||||
// "SELECT 1 FROM booking_discounts WHERE booking_id=$1 AND source_id=$2" that
|
||||
// produced an N+1 inside the anniversary loop.
|
||||
func ComputeEligibleDiscounts(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64) []EligibleDiscount {
|
||||
// The apply path refuses to apply NEW discounts once a booking has 2+
|
||||
// completed real payments (the customer has already paid) — mirror that
|
||||
// here so the preview does not promise a discount apply will refuse.
|
||||
var existingPayment int
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
|
||||
`, bookingID).Scan(&existingPayment); err != nil {
|
||||
log.Printf("Failed to scan existing payment count: %v", err)
|
||||
}
|
||||
if existingPayment >= 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if bookingTotal <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Existing booking_discounts for THIS booking, keyed by source so a
|
||||
// campaign id can never collide with a referral id. Single query replaces
|
||||
// the N+1 per-campaign existence checks (both files).
|
||||
existingSources := map[string]bool{}
|
||||
{
|
||||
rows, err := q.Query(ctx, `
|
||||
SELECT COALESCE(discount_source, ''), COALESCE(source_id, '')
|
||||
FROM booking_discounts WHERE booking_id = $1
|
||||
`, bookingID)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var src, sid string
|
||||
if rows.Scan(&src, &sid) == nil {
|
||||
existingSources[src+"|"+sid] = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
} else {
|
||||
log.Printf("Failed to query existing booking discounts for booking %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var discounts []EligibleDiscount
|
||||
|
||||
// Time-based campaign: the highest-percent active time_based campaign.
|
||||
var campaignID, campaignName string
|
||||
var campaignPercent float64
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT id, discount_percent, name FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
||||
AND start_date <= NOW() AND end_date >= NOW()
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
ORDER BY discount_percent DESC LIMIT 1
|
||||
`).Scan(&campaignID, &campaignPercent, &campaignName); err == nil && campaignID != "" {
|
||||
if !existingSources["campaign|"+campaignID] {
|
||||
discounts = append(discounts, EligibleDiscount{
|
||||
Source: "campaign",
|
||||
Name: campaignName,
|
||||
Percent: campaignPercent,
|
||||
Amount: roundTo2(bookingTotal * campaignPercent / 100),
|
||||
SourceID: campaignID,
|
||||
CampaignType: "time_based",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Per-user booking-count milestone: the campaign matching the user's
|
||||
// completed-booking count that has not yet been used for this user.
|
||||
var userBookingCount int
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'
|
||||
`, userID).Scan(&userBookingCount); err != nil {
|
||||
log.Printf("Failed to scan user completed booking count: %v", err)
|
||||
}
|
||||
|
||||
var milestoneCampaignID, milestoneName string
|
||||
var milestonePercent float64
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT id, discount_percent, name FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
|
||||
AND milestone_value = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
|
||||
`, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent, &milestoneName); err != nil {
|
||||
log.Printf("Failed to query milestone campaign for user %s, count %d: %v", userID, userBookingCount, err)
|
||||
}
|
||||
|
||||
if milestoneCampaignID != "" && !existingSources["campaign|"+milestoneCampaignID] {
|
||||
mt := "per_user_booking_count"
|
||||
discounts = append(discounts, EligibleDiscount{
|
||||
Source: "campaign",
|
||||
Name: milestoneName,
|
||||
Percent: milestonePercent,
|
||||
Amount: roundTo2(bookingTotal * milestonePercent / 100),
|
||||
SourceID: milestoneCampaignID,
|
||||
CampaignType: "milestone",
|
||||
MilestoneType: &mt,
|
||||
})
|
||||
}
|
||||
|
||||
// Anniversary milestone: the first qualifying campaign for the user's
|
||||
// first visit, matched by elapsed time. Only the FIRST match is applied
|
||||
// (the apply path historically broke after one anniversary discount).
|
||||
var firstVisitDate time.Time
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'
|
||||
`, userID).Scan(&firstVisitDate); err != nil {
|
||||
log.Printf("Failed to scan first visit date: %v", err)
|
||||
}
|
||||
if !firstVisitDate.IsZero() {
|
||||
type annCamp struct {
|
||||
id string
|
||||
pct float64
|
||||
value int
|
||||
unit string
|
||||
name string
|
||||
}
|
||||
annRows, err := q.Query(ctx, `
|
||||
SELECT id, discount_percent, milestone_value, milestone_unit, name FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
|
||||
`, userID)
|
||||
if err == nil {
|
||||
var campaigns []annCamp
|
||||
for annRows.Next() {
|
||||
var c annCamp
|
||||
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit, &c.name) == nil {
|
||||
campaigns = append(campaigns, c)
|
||||
}
|
||||
}
|
||||
annRows.Close()
|
||||
|
||||
for _, c := range campaigns {
|
||||
if existingSources["campaign|"+c.id] {
|
||||
continue
|
||||
}
|
||||
|
||||
var matches bool
|
||||
elapsed := time.Since(firstVisitDate)
|
||||
switch c.unit {
|
||||
case "months":
|
||||
matches = int(elapsed.Hours()/(30*24)) >= c.value
|
||||
case "years":
|
||||
matches = int(elapsed.Hours()/(365.25*24)) >= c.value
|
||||
}
|
||||
if matches {
|
||||
mt := "anniversary"
|
||||
discounts = append(discounts, EligibleDiscount{
|
||||
Source: "campaign",
|
||||
Name: c.name,
|
||||
Percent: c.pct,
|
||||
Amount: roundTo2(bookingTotal * c.pct / 100),
|
||||
SourceID: c.id,
|
||||
CampaignType: "milestone",
|
||||
MilestoneType: &mt,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Failed to query anniversary campaigns: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Global booking-count milestone — only applies when the booking's first
|
||||
// real payment was taken in person (in_person_card).
|
||||
var firstPaymentMethod string
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1
|
||||
`, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" {
|
||||
var globalCount int
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bookings WHERE status = 'completed'
|
||||
`).Scan(&globalCount); err != nil {
|
||||
log.Printf("Failed to scan global completed booking count: %v", err)
|
||||
}
|
||||
|
||||
var globalCampaignID, globalName string
|
||||
var globalPercent float64
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT id, discount_percent, name FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
|
||||
AND milestone_value <= $1
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE source_id = discount_campaigns.id AND booking_id = $2)
|
||||
ORDER BY milestone_value DESC LIMIT 1
|
||||
`, globalCount, bookingID).Scan(&globalCampaignID, &globalPercent, &globalName); err != nil {
|
||||
log.Printf("Failed to query global milestone campaign: %v", err)
|
||||
}
|
||||
|
||||
if globalCampaignID != "" && !existingSources["campaign|"+globalCampaignID] {
|
||||
mt := "global_booking_count"
|
||||
discounts = append(discounts, EligibleDiscount{
|
||||
Source: "campaign",
|
||||
Name: globalName,
|
||||
Percent: globalPercent,
|
||||
Amount: roundTo2(bookingTotal * globalPercent / 100),
|
||||
SourceID: globalCampaignID,
|
||||
CampaignType: "milestone",
|
||||
MilestoneType: &mt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Referrer's unused referral discount.
|
||||
var rdID string
|
||||
var rdPercent float64
|
||||
if err := q.QueryRow(ctx, `
|
||||
SELECT id, discount_percent FROM referral_discounts
|
||||
WHERE user_id = $1 AND used = FALSE
|
||||
LIMIT 1
|
||||
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
|
||||
if !existingSources["referral|"+rdID] {
|
||||
discounts = append(discounts, EligibleDiscount{
|
||||
Source: "referral",
|
||||
Name: "Referral Discount (10%)",
|
||||
Percent: rdPercent,
|
||||
Amount: roundTo2(bookingTotal * rdPercent / 100),
|
||||
SourceID: rdID,
|
||||
IsReferral: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return discounts
|
||||
}
|
||||
|
||||
// ApplyEligibleDiscount persists a single eligible discount for the booking:
|
||||
// the booking_discounts row, the discount payment record, and the campaign
|
||||
// redemption counter (or the referral used flag). The caller holds the payment
|
||||
// transaction so these writes commit atomically with the payment. It is
|
||||
// idempotent per booking because ComputeEligibleDiscounts excludes discounts
|
||||
// whose source_id is already recorded for the booking.
|
||||
//
|
||||
// For campaign discounts the redemption counter is incremented FIRST, as an
|
||||
// ATOMIC CONDITIONAL UPDATE guarded by max_redemptions (B13): two concurrent
|
||||
// payments on different bookings for the same campaign can both pass the
|
||||
// caller's unlocked "is it exhausted?" read, but only the first conditional
|
||||
// increment matches — the loser's UPDATE affects zero rows (a 0-row result is
|
||||
// returned) and this function returns a *campaignExhaustedAtApplyError with
|
||||
// NOTHING written, so the caller can surface campaign_fully_redeemed. Doing the
|
||||
// reservation before the booking_discounts/payment inserts keeps the
|
||||
// transaction clean when a campaign is exhausted at apply time: no discount
|
||||
// rows are minted for a redemption that never happened.
|
||||
func ApplyEligibleDiscount(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64, d EligibleDiscount) error {
|
||||
if d.IsReferral {
|
||||
if _, err := q.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'referral', $3, NULL, NULL, $4, $5, $6)
|
||||
`, bookingID, userID, d.SourceID, d.Percent, bookingTotal, d.Amount); err != nil {
|
||||
log.Printf("Failed to insert referral discount: %v", err)
|
||||
return nil
|
||||
}
|
||||
if _, err := q.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, d.Amount, userID); err != nil {
|
||||
// The booking_discounts row was already inserted in this tx, so the
|
||||
// referral discount WAS redeemed — the used flag must still be set
|
||||
// below. Log ALERT and fall through to the UPDATE instead of
|
||||
// returning early (a lost used-flag would let the same referral
|
||||
// discount apply to a future booking).
|
||||
log.Printf("ALERT: failed to insert discount payment record for referral %s, booking %s: %v", d.SourceID, bookingID, err)
|
||||
}
|
||||
if _, err := q.Exec(ctx, `
|
||||
UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1
|
||||
`, d.SourceID); err != nil {
|
||||
log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var milestoneType any
|
||||
if d.MilestoneType != nil {
|
||||
milestoneType = *d.MilestoneType
|
||||
}
|
||||
|
||||
// B13: atomic conditional reservation. The UPDATE increments the counter
|
||||
// ONLY while the campaign still has headroom; PostgreSQL makes this safe
|
||||
// under READ COMMITTED — a concurrent same-row UPDATE blocks, then
|
||||
// re-evaluates this WHERE against the post-increment row, so the loser
|
||||
// matches zero rows instead of over-redeeming past max_redemptions. Zero
|
||||
// rows means a concurrent redemption on another booking exhausted the
|
||||
// campaign between the caller's preview computation and this apply-time
|
||||
// re-check; nothing has been written yet, so the caller surfaces the
|
||||
// campaign_fully_redeemed path (B13).
|
||||
var reservedID string
|
||||
if err := q.QueryRow(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
|
||||
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
RETURNING id
|
||||
`, d.SourceID).Scan(&reservedID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &campaignExhaustedAtApplyError{campaignID: d.SourceID, lostPence: int64(math.Round(d.Amount * 100))}
|
||||
}
|
||||
log.Printf("ALERT: failed to reserve redemption for campaign %s, booking %s: %v — discount NOT applied", d.SourceID, bookingID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := q.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, $4, $5, $6, $7, $8)
|
||||
`, bookingID, userID, d.SourceID, d.CampaignType, milestoneType, d.Percent, bookingTotal, d.Amount); err != nil {
|
||||
// The reservation (counter increment) already stands in this tx, so the
|
||||
// redemption was consumed. Log ALERT and skip the payment record — a
|
||||
// discount payment row without a booking_discounts row would be a
|
||||
// ledger anomaly. max_redemptions bounds the lost reservation: the next
|
||||
// eligible booking finds the campaign with one fewer redemption.
|
||||
log.Printf("ALERT: failed to insert campaign discount for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
|
||||
return nil
|
||||
}
|
||||
if _, err := q.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, d.Amount, userID); err != nil {
|
||||
// The booking_discounts row was already inserted in this tx, so the
|
||||
// campaign WAS redeemed and the reservation already stands. Log ALERT
|
||||
// and return (a lost record would hide the discount from the ledger).
|
||||
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// Strict timezone/DST tests for the payment refund tiers and the deposit
|
||||
// protection window. All assertions use fixed time.Date instants.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
)
|
||||
|
||||
// TestRefundTiers_TimezoneIndependent proves refund tiers depend only on the
|
||||
// ELAPSED time between cancellation and start (startTime.Sub(cancellationTime)
|
||||
// .Hours()), never on wall-clock days or the London calendar date. Two bookings
|
||||
// in different seasons — 2026-01-20 10:00 UTC (GMT) and 2026-06-20 10:00 UTC
|
||||
// (BST) — cancelled with identical notice must produce identical results for
|
||||
// every tier.
|
||||
func TestRefundTiers_TimezoneIndependent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gmtStart := time.Date(2026, 1, 20, 10, 0, 0, 0, time.UTC)
|
||||
bstStart := time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
// Precondition: the two starts are genuinely in different seasons — same
|
||||
// UTC instant-of-day but different London wall-clock zones.
|
||||
if gz, _ := gmtStart.In(clock.London).Zone(); gz != "GMT" {
|
||||
t.Fatalf("expected 2026-01-20 in London to be GMT, got %q", gz)
|
||||
}
|
||||
if bz, _ := bstStart.In(clock.London).Zone(); bz != "BST" {
|
||||
t.Fatalf("expected 2026-06-20 in London to be BST, got %q", bz)
|
||||
}
|
||||
|
||||
notices := []struct {
|
||||
name string
|
||||
hours float64
|
||||
want string
|
||||
}{
|
||||
{"over 72h -> full refund", 72.5, FullRefundTier},
|
||||
{"at 72h -> partial (strict > boundary)", 72, PartialRefundTier},
|
||||
{"48h -> partial refund", 48, PartialRefundTier},
|
||||
{"at 24h -> partial refund", 24, PartialRefundTier},
|
||||
{"under 24h -> no refund", 23.5, NoRefundTier},
|
||||
}
|
||||
|
||||
for _, tc := range notices {
|
||||
noticeDur := time.Duration(tc.hours * float64(time.Hour))
|
||||
cancellationGmt := gmtStart.Add(-noticeDur)
|
||||
cancellationBst := bstStart.Add(-noticeDur)
|
||||
|
||||
gmtRes := CalculateRefundForCancellation(100, 50, cancellationGmt, gmtStart)
|
||||
bstRes := CalculateRefundForCancellation(100, 50, cancellationBst, bstStart)
|
||||
|
||||
if gmtRes.Tier != tc.want || bstRes.Tier != tc.want {
|
||||
t.Errorf("%s: want tier %q, got GMT=%q BST=%q", tc.name, tc.want, gmtRes.Tier, bstRes.Tier)
|
||||
}
|
||||
// Elapsed-hours logic is DST-safe: identical inputs across seasons
|
||||
// produce byte-for-byte identical outputs.
|
||||
if gmtRes != bstRes {
|
||||
t.Errorf("%s: GMT and BST bookings with identical notice produced different refunds: GMT=%+v BST=%+v",
|
||||
tc.name, gmtRes, bstRes)
|
||||
}
|
||||
if gmtRes.HoursUntilAppointment != tc.hours {
|
||||
t.Errorf("%s: expected %.1f elapsed hours, got %.1f", tc.name, tc.hours, gmtRes.HoursUntilAppointment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDepositProtectionWindow_UTCInstant proves buildSplitRecords decides
|
||||
// "after booking starts" by an ABSOLUTE UTC-instant comparison
|
||||
// (clock.Now().After(info.StartTime)), never by the London calendar date. A
|
||||
// booking at 2026-06-15 00:30 BST == 2026-06-14 23:30 UTC — where the UTC date
|
||||
// and the London date disagree — is classified purely by its UTC instant.
|
||||
func TestDepositProtectionWindow_UTCInstant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 2026-06-14 23:30 UTC = 2026-06-15 00:30 BST: the 00:00-01:00 BST window
|
||||
// where the UTC date (2026-06-14) is the day before the London date.
|
||||
boundaryUTC := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
if got := boundaryUTC.In(clock.London).Format("2006-01-02"); got != "2026-06-15" {
|
||||
t.Fatalf("test setup invariant: 2026-06-14 23:30 UTC must be London 2026-06-15 00:30 BST, got %s", got)
|
||||
}
|
||||
if got := boundaryUTC.Format("2006-01-02"); got != "2026-06-14" {
|
||||
t.Fatalf("test setup invariant: boundary must remain UTC date 2026-06-14, got %s", got)
|
||||
}
|
||||
|
||||
// This instant is in the past -> the booking has started -> no deposit
|
||||
// protection window -> a single unsplit record.
|
||||
record := makeTestRecord("b-boundary-past", "full", 50)
|
||||
info := &BookingPaymentInfo{StartTime: boundaryUTC, TotalAmount: 50, TotalPaid: 0}
|
||||
records, err := buildSplitRecords(record, "full", info, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("buildSplitRecords: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("past BST-midnight booking: expected 1 record (no split), got %d", len(records))
|
||||
}
|
||||
if records[0].PaymentType != "full" {
|
||||
t.Errorf("past BST-midnight booking: expected single 'full' record, got %q", records[0].PaymentType)
|
||||
}
|
||||
|
||||
// Mirror case with the IDENTICAL UTC-date != London-date property but in the
|
||||
// future (2099-06-14 23:30 UTC = 2099-06-15 00:30 BST): the booking has NOT
|
||||
// started, so the deposit split must happen. Only the UTC instant differs
|
||||
// from the case above — the London date plays no role.
|
||||
futureBoundary := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
if got := futureBoundary.In(clock.London).Format("2006-01-02"); got != "2099-06-15" {
|
||||
t.Fatalf("test setup invariant: 2099-06-14 23:30 UTC must be London 2099-06-15, got %s", got)
|
||||
}
|
||||
|
||||
record2 := makeTestRecord("b-boundary-future", "full", 50)
|
||||
info2 := &BookingPaymentInfo{StartTime: futureBoundary, TotalAmount: 50, TotalPaid: 0}
|
||||
records2, err2 := buildSplitRecords(record2, "full", info2, 50)
|
||||
if err2 != nil {
|
||||
t.Fatalf("buildSplitRecords: %v", err2)
|
||||
}
|
||||
if len(records2) != 2 {
|
||||
t.Fatalf("future BST-midnight booking: expected 2 records (deposit split), got %d", len(records2))
|
||||
}
|
||||
if records2[0].PaymentType != "deposit" || records2[0].Amount != 25 {
|
||||
t.Errorf("future BST-midnight booking: expected first record deposit £25, got %q £%.2f",
|
||||
records2[0].PaymentType, records2[0].Amount)
|
||||
}
|
||||
if records2[1].PaymentType != "balance" || records2[1].Amount != 25 {
|
||||
t.Errorf("future BST-midnight booking: expected second record balance £25, got %q £%.2f",
|
||||
records2[1].PaymentType, records2[1].Amount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
)
|
||||
|
||||
// roundingEpsilon is the "effectively zero" guard for pound-denominated
|
||||
// payment splits (0.004 = 0.4 pence). Amounts at or below this threshold —
|
||||
// pure float64 rounding residue from dividing pence by 100 — are treated as
|
||||
// zero so a sub-penny slice never becomes a phantom payment row. Single
|
||||
// shared constant so the split builders and the cash/gift-card terminal
|
||||
// branches can never drift on the threshold.
|
||||
const roundingEpsilon = 0.004
|
||||
|
||||
// SquareRefundStatusToLocal maps Square's refund status to the local refunds
|
||||
// status enum, returning a (localStatus, terminal) pair. Square's PaymentRefund
|
||||
// states are PENDING, APPROVED, COMPLETED, CANCELED, FAILED and REJECTED
|
||||
// (developer.squareup.com/reference/square/objects/PaymentRefund). COMPLETED
|
||||
// and APPROVED are terminal-completed — APPROVED explicitly, because the
|
||||
// synchronous refund handlers resolve a returned APPROVED to 'completed' and
|
||||
// that behaviour must not be lost. FAILED/REJECTED are terminal-failed (Square
|
||||
// declined the refund and it must be surfaced as a definitive local failure).
|
||||
// Everything else (PENDING — money in flight, the sweep reconciles it later —
|
||||
// CANCELED, or any unknown status) is NON-terminal: the caller leaves the row
|
||||
// untouched rather than guessing. This is the single shared implementation for
|
||||
// both the payments refund handlers and the webhooks package, so the two can
|
||||
// never drift on the same Square status again.
|
||||
func SquareRefundStatusToLocal(status string) (string, bool) {
|
||||
switch status {
|
||||
case "COMPLETED", "APPROVED":
|
||||
return "completed", true
|
||||
case "FAILED", "REJECTED":
|
||||
return "failed", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// verificationRequiredCodes are Square CreatePayment error codes that mean the
|
||||
// buyer must complete Strong Customer Authentication (3DS/SCA) before the
|
||||
// charge can succeed: Square is demanding a fresh verification_token from the
|
||||
// cardholder's buyer-verification flow. These are NOT plain declines — the
|
||||
// frontend must surface the SCA challenge (the banking app / banking-app
|
||||
// approval) and retry the charge with the resulting verification token. This
|
||||
// is the SINGLE authoritative list of SCA-challenge codes; keep it in lock-step
|
||||
// with the dev mock's simulated SCA toggle (square_dev.go).
|
||||
var verificationRequiredCodes = map[string]bool{
|
||||
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
|
||||
"VERIFICATION_TOKEN_EXPIRED": true,
|
||||
"VERIFICATION_TOKEN_INVALID": true,
|
||||
"MISSING_VERIFICATION_TOKEN": true,
|
||||
}
|
||||
|
||||
// isVerificationRequiredError reports whether a SquareClient.CreatePayment
|
||||
// error is an SCA/verification-required rejection (the charge must be retried
|
||||
// through the buyer-verification flow with a fresh verification_token) rather
|
||||
// than a plain decline. Matches square.ErrorCode against the four SCA codes;
|
||||
// CVV_VERIFICATION_REQUIRED / ADDRESS_VERIFICATION_REQUIRED are deliberately
|
||||
// excluded — those mean re-entering card data, not a 3DS challenge.
|
||||
func isVerificationRequiredError(err error) bool {
|
||||
return verificationRequiredCodes[square.ErrorCode(err)]
|
||||
}
|
||||
|
||||
// writeVerificationRequiredResponse responds 402 with the structured
|
||||
// verification_required body the frontend keys on to trigger the SCA challenge
|
||||
// flow (mirrors the overflow_tip_confirmation_required / campaign_fully_redeemed
|
||||
// structured-error pattern — mw.RespondJSON, code + human message). The message
|
||||
// tells the buyer to approve the payment in their banking app. Used both by the
|
||||
// charge-failure paths (Square returned an SCA-required code) and by the SCA-only
|
||||
// saved-card gate, which refuses any token-less charge (the homegrown 2FA
|
||||
// fallback was removed — a token-less charge is always refused here, never
|
||||
// authorised by a 2FA code).
|
||||
func writeVerificationRequiredResponse(w http.ResponseWriter) {
|
||||
mw.RespondJSON(w, http.StatusPaymentRequired, map[string]string{
|
||||
"error": "Your card issuer requires verification. Approve this payment in your banking app.",
|
||||
"code": "verification_required",
|
||||
})
|
||||
}
|
||||
|
||||
// chargeFailureStatus classifies a SquareClient.CreatePayment error into the
|
||||
// HTTP status a payment handler should return:
|
||||
//
|
||||
// - 503 (Service Unavailable) for AMBIGUOUS failures: transport/network
|
||||
// errors, Square 5xx responses, context cancellation/deadline, the
|
||||
// retryable 4xx statuses 429 (rate limited), 408 (request timeout), and
|
||||
// 425 (too early), and the structured error code IDEMPOTENCY_KEY_REUSED —
|
||||
// the money state at Square is unknown, so the frontend should treat it as
|
||||
// a retry (the pending record is resumed on a same-key retry). Square's own
|
||||
// docs treat 429 as "retry later"; mapping it (or a timeout/early request)
|
||||
// to 402 would mislabel a retryable condition as a permanent decline.
|
||||
// - 402 (Payment Required) for DEFINITIVE declines: a structured Square
|
||||
// error (squareAPIError) carrying any OTHER 4xx status (400/402/422 etc.)
|
||||
// means Square positively rejected the charge (card declined/expired,
|
||||
// AVS/CVV failure) — retrying with the same inputs cannot succeed.
|
||||
//
|
||||
// IDEMPOTENCY_KEY_REUSED (Loop B CRITICAL-ish, finding 1) is AMBIGUOUS, never
|
||||
// a definitive decline: Square retains the key against the ORIGINAL request
|
||||
// body, so the error means a PREVIOUS attempt under this key used a different
|
||||
// body — the original charge may have LANDED at Square. Classifying it 402
|
||||
// would make the frontend regenerate the idempotency key (the 402 branch
|
||||
// clears the cached key) and issue a NEW charge under a fresh key — a double
|
||||
// charge when the original landed. Classifying it 503 keeps the key: a
|
||||
// same-key retry with the ORIGINAL body makes Square dedup to the original
|
||||
// payment (no new charge), and a retry with a different body keeps getting
|
||||
// IDEMPOTENCY_KEY_REUSED while the pending row stays rescuable by the sweep —
|
||||
// which already treats IDEMPOTENCY_KEY_REUSED as ambiguous (sweep.go:1088,
|
||||
// replayErrorProvesNoCharge in square_http_client.go:681). The check keys on
|
||||
// the structured ErrorCode, not the HTTP status, because Square may surface it
|
||||
// as 400 or 409 depending on the request shape.
|
||||
//
|
||||
// A nil error is never expected (callers only invoke this on the error path);
|
||||
// it maps to 402 defensively. The dev mock returns plain errors for simulated
|
||||
// failures, which classify as 503 (ambiguous) — correct for a mock standing in
|
||||
// for an unreachable Square.
|
||||
func chargeFailureStatus(err error) int {
|
||||
if err == nil {
|
||||
return http.StatusPaymentRequired
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
status := square.ErrorStatusCode(err)
|
||||
if status == 0 || status >= 500 {
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
// Retryable/ambiguous 4xx carve-outs: 429 (RATE_LIMITED), 408 (request
|
||||
// timeout), and 425 (too early) are not definitive declines — Square's
|
||||
// docs tell clients to retry later. Classify them as 503 so the pending
|
||||
// record stays resumable on a same-key retry instead of being labelled a
|
||||
// permanent decline. True declines (400/402/422 etc.) fall through to 402.
|
||||
if status == http.StatusTooManyRequests || status == http.StatusRequestTimeout || status == http.StatusTooEarly {
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
if status >= 400 && status < 500 {
|
||||
return http.StatusPaymentRequired
|
||||
}
|
||||
// Anything else (1xx/2xx/3xx — impossible in practice, but defensive) is
|
||||
// AMBIGUOUS: the money state at Square is unknown, so the failure must be
|
||||
// retryable. The default is deliberately 503, never 402 — a definitive
|
||||
// decline classification on an ambiguous outcome would suppress the
|
||||
// same-key retry that resumes the pending record.
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSquareRefundStatusToLocal_Table covers the full Square refund status
|
||||
// space: COMPLETED/APPROVED → 'completed' terminal, FAILED/REJECTED → 'failed'
|
||||
// terminal, and all other statuses (PENDING, CANCELED, unknown, empty,
|
||||
// lowercase) → non-terminal with empty local status.
|
||||
func TestSquareRefundStatusToLocal_Table(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
wantStatus string
|
||||
wantTerminal bool
|
||||
}{
|
||||
{"COMPLETED", "completed", true},
|
||||
{"APPROVED", "completed", true},
|
||||
{"FAILED", "failed", true},
|
||||
{"REJECTED", "failed", true},
|
||||
{"PENDING", "", false},
|
||||
{"CANCELED", "", false},
|
||||
{"", "", false},
|
||||
{"UNKNOWN", "", false},
|
||||
{"completed", "", false},
|
||||
{"failed", "", false},
|
||||
{"approved", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
status, terminal := SquareRefundStatusToLocal(tt.input)
|
||||
if status != tt.wantStatus {
|
||||
t.Errorf("SquareRefundStatusToLocal(%q) status = %q, want %q", tt.input, status, tt.wantStatus)
|
||||
}
|
||||
if terminal != tt.wantTerminal {
|
||||
t.Errorf("SquareRefundStatusToLocal(%q) terminal = %v, want %v", tt.input, terminal, tt.wantTerminal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundingEpsilon_AtBoundary verifies roundingEpsilon is within the
|
||||
// expected range (0.004 ± 0.001) so the money-safety zero threshold is stable.
|
||||
func TestRoundingEpsilon_AtBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if roundingEpsilon < 0.003 || roundingEpsilon > 0.005 {
|
||||
t.Errorf("roundingEpsilon = %.6f, expected ~0.004", roundingEpsilon)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsVerificationRequiredError_NilError safely handles nil input.
|
||||
func TestIsVerificationRequiredError_NilError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if isVerificationRequiredError(nil) {
|
||||
t.Error("nil error must not be verification required")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsVerificationRequiredError_PlainError safely handles a non-structured
|
||||
// error with no Square error code.
|
||||
func TestIsVerificationRequiredError_PlainError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if isVerificationRequiredError(errors.New("network timeout")) {
|
||||
t.Error("a plain network error must not be verification required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// structuredSquareAPIError returns an error of the SAME concrete type the real
|
||||
// Square client produces for structured API errors (the unexported
|
||||
// *square.squareAPIError, re-stamped with the given HTTP status). The type is
|
||||
// not nameable outside internal/square and there is no exported constructor,
|
||||
// so the helper clones the dev mock's real structured 400 error (the only
|
||||
// package-visible producer) via reflection and rewrites its status code. This
|
||||
// mirrors the existing test's "build through the mock" style while covering
|
||||
// status codes the mock cannot produce (429/408/425/422/500).
|
||||
func structuredSquareAPIError(t *testing.T, status int) error {
|
||||
t.Helper()
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 1000,
|
||||
Currency: "GBP",
|
||||
SourceID: "ccof:card_1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the mock to reject a ccof charge without a customer")
|
||||
}
|
||||
if square.ErrorStatusCode(err) == 0 {
|
||||
t.Fatal("expected the mock's ccof rejection to carry a structured status code")
|
||||
}
|
||||
v := reflect.ValueOf(err)
|
||||
if v.Kind() != reflect.Ptr {
|
||||
t.Fatalf("expected the structured error to be a pointer, got %v", v.Kind())
|
||||
}
|
||||
clone := reflect.New(v.Elem().Type())
|
||||
clone.Elem().Set(v.Elem())
|
||||
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
|
||||
return clone.Interface().(error)
|
||||
}
|
||||
|
||||
// TestChargeFailureStatus classifies Square CreatePayment errors into
|
||||
// 402 (definitive decline) vs 503 (ambiguous) so the charge handlers surface
|
||||
// retryable failures as 503 (the pending record is resumed on a same-key
|
||||
// retry) and only definitively-rejected charges as 402.
|
||||
func TestChargeFailureStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
|
||||
// The dev mock produces a structured 4xx squareAPIError for a
|
||||
// card-on-file charge missing its required customer (mirrors a real
|
||||
// Square 400 INVALID_REQUEST_ERROR) — exercises the definitive-decline
|
||||
// classification through the real error type.
|
||||
_, structuredErr := mc.CreatePayment(ctx, square.CreatePaymentReq{
|
||||
Amount: 1000,
|
||||
Currency: "GBP",
|
||||
SourceID: "ccof:card_1",
|
||||
})
|
||||
if structuredErr == nil {
|
||||
t.Fatal("expected the mock to reject a ccof charge without a customer")
|
||||
}
|
||||
if square.ErrorStatusCode(structuredErr) == 0 {
|
||||
t.Fatal("expected the mock's ccof rejection to carry a structured status code")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"structured 4xx decline → 402", structuredErr, http.StatusPaymentRequired},
|
||||
{"plain mock failure (ambiguous) → 503", errors.New("mock: payment declined (simulated failure)"), http.StatusServiceUnavailable},
|
||||
{"context deadline → 503", context.DeadlineExceeded, http.StatusServiceUnavailable},
|
||||
{"context cancelled → 503", context.Canceled, http.StatusServiceUnavailable},
|
||||
{"nil (defensive) → 402", nil, http.StatusPaymentRequired},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := chargeFailureStatus(tt.err); got != tt.want {
|
||||
t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChargeFailureStatus_RetryableCarveOuts locks the 429/408/425 carve-outs:
|
||||
// those retryable/ambiguous 4xx statuses must classify as 503 (ambiguous —
|
||||
// retry later), never as the 402 (definitive decline) that the generic 4xx
|
||||
// branch would produce. True declines (400/422) and 5xx keep their existing
|
||||
// classifications.
|
||||
func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"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 425 too early (ambiguous) → 503", structuredSquareAPIError(t, http.StatusTooEarly), http.StatusServiceUnavailable},
|
||||
{"structured 422 unprocessable (definitive) → 402", structuredSquareAPIError(t, http.StatusUnprocessableEntity), http.StatusPaymentRequired},
|
||||
{"structured 400 bad request (definitive) → 402", structuredSquareAPIError(t, http.StatusBadRequest), http.StatusPaymentRequired},
|
||||
{"structured 500 server error (ambiguous) → 503", structuredSquareAPIError(t, http.StatusInternalServerError), http.StatusServiceUnavailable},
|
||||
{"plain error (ambiguous) → 503", errors.New("mock: payment declined (simulated failure)"), http.StatusServiceUnavailable},
|
||||
{"context deadline (ambiguous) → 503", context.DeadlineExceeded, http.StatusServiceUnavailable},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := chargeFailureStatus(tt.err); got != tt.want {
|
||||
t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChargeFailureStatus_DefaultAndEdgeStatuses pins the full status-space
|
||||
// classification, including the ambiguous DEFAULT branch (1xx/2xx/3xx): the
|
||||
// default MUST be 503 (ambiguous → retryable) — never 402, which labels a
|
||||
// definitive decline and suppresses the same-key retry that resumes the pending
|
||||
// record. A generic 409 (a structured conflict that is NOT the
|
||||
// IDEMPOTENCY_KEY_REUSED code) is a definitive client error and must stay 402;
|
||||
// the IDEMPOTENCY_KEY_REUSED code is classified as 503 in the dedicated test
|
||||
// below (Loop B finding 1 — the original charge may have landed under the
|
||||
// retained key, so 402 would make the frontend regenerate the key and
|
||||
// double-charge).
|
||||
func TestChargeFailureStatus_DefaultAndEdgeStatuses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
want int
|
||||
}{
|
||||
{"0 (plain/transport error) → 503", 0, http.StatusServiceUnavailable},
|
||||
{"1xx → 503 (ambiguous default)", http.StatusContinue, http.StatusServiceUnavailable},
|
||||
{"3xx → 503 (ambiguous default)", http.StatusMultipleChoices, http.StatusServiceUnavailable},
|
||||
{"400 → 402 (definitive)", http.StatusBadRequest, http.StatusPaymentRequired},
|
||||
{"401 → 402 (definitive)", http.StatusUnauthorized, http.StatusPaymentRequired},
|
||||
{"403 → 402 (definitive)", http.StatusForbidden, http.StatusPaymentRequired},
|
||||
{"408 → 503 (retryable)", http.StatusRequestTimeout, http.StatusServiceUnavailable},
|
||||
{"409 → 402 (generic conflict, definitive)", http.StatusConflict, http.StatusPaymentRequired},
|
||||
{"425 → 503 (retryable)", http.StatusTooEarly, http.StatusServiceUnavailable},
|
||||
{"429 → 503 (retryable)", http.StatusTooManyRequests, http.StatusServiceUnavailable},
|
||||
{"500 → 503", http.StatusInternalServerError, http.StatusServiceUnavailable},
|
||||
{"503 → 503", http.StatusServiceUnavailable, http.StatusServiceUnavailable},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error
|
||||
if tt.status == 0 {
|
||||
err = errors.New("mock: payment declined (simulated failure)")
|
||||
} else {
|
||||
err = structuredSquareAPIError(t, tt.status)
|
||||
}
|
||||
if got := chargeFailureStatus(err); got != tt.want {
|
||||
t.Errorf("chargeFailureStatus(status=%d) = %d, want %d", tt.status, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChargeFailureStatus_IdempotencyKeyReused_Ambiguous pins the Loop B
|
||||
// CRITICAL-ish finding 1 classification: a structured IDEMPOTENCY_KEY_REUSED
|
||||
// error (Square retained the key against a DIFFERENT request body — the
|
||||
// original charge may have landed) is AMBIGUOUS and must classify as 503,
|
||||
// never 402. A 402 would make the frontend regenerate the idempotency key and
|
||||
// issue a NEW charge under a fresh key — double-charging the customer when the
|
||||
// original landed. The check keys on the structured ErrorCode, so BOTH the 400
|
||||
// (dev mock / real Square) and 409 (real Square) surfaces classify as 503,
|
||||
// while a generic 409 without the code stays a definitive 402 (covered above).
|
||||
func TestChargeFailureStatus_IdempotencyKeyReused_Ambiguous(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"IDEMPOTENCY_KEY_REUSED 400 → 503", structuredSquareErrorFull(t, http.StatusBadRequest, "IDEMPOTENCY_KEY_REUSED", "INVALID_REQUEST_ERROR"), http.StatusServiceUnavailable},
|
||||
{"IDEMPOTENCY_KEY_REUSED 409 → 503", structuredSquareErrorFull(t, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED", "INVALID_REQUEST_ERROR"), http.StatusServiceUnavailable},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := chargeFailureStatus(tt.err); got != tt.want {
|
||||
t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_AmbiguousSquareFailure_Returns503 verifies the
|
||||
// charge-failure classification end to end: the dev mock's simulated failure
|
||||
// is a PLAIN error (no structured Square status), so the handler now returns
|
||||
// 503 (ambiguous — the pending record stays pending for a same-key retry)
|
||||
// instead of 402 (which implied a definitive decline).
|
||||
func TestCreateBookingPayment_AmbiguousSquareFailure_Returns503(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
origClient := SquareClient
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
mc.ShouldFail = true
|
||||
SquareClient = mc
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "ambiguous-503-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 for ambiguous mock Square failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The pending record must be left pending (not failed) so a same-key retry
|
||||
// reuses it instead of creating a second Square charge.
|
||||
var status string
|
||||
err := tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query payment status: %v", err)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("expected payment status 'pending' after ambiguous failure, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M3 — till HTTP status classification (till.go CreateTillSale error path)
|
||||
// =============================================================================
|
||||
|
||||
// tillChargeFailureClient injects a Square CreatePayment failure into the till
|
||||
// sale handler. The embedded client carries every other method so the sale
|
||||
// setup (gift-card create/commit, advisory locks) runs exactly as in
|
||||
// production; only CreatePayment is overridden to return the fault.
|
||||
type tillChargeFailureClient struct {
|
||||
square.SquareClient
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (c *tillChargeFailureClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
return nil, c.createErr
|
||||
}
|
||||
|
||||
// structuredSquareErrorFull builds a structured *square.squareAPIError of the
|
||||
// same concrete type the real client produces, re-stamped with an arbitrary
|
||||
// HTTP status, Square error code, AND error category. The type is not nameable
|
||||
// outside internal/square, so the clone-through-reflection technique mirrors
|
||||
// structuredSquareAPIError above (which rewrites only the status code); here
|
||||
// the Code and Category are also rewritten so a CARD_DECLINED decline or an
|
||||
// INVALID_REQUEST_ERROR category can be produced for isDefinitiveCardSaveFailure
|
||||
// classification.
|
||||
func structuredSquareErrorFull(t *testing.T, status int, code, category string) error {
|
||||
t.Helper()
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 1000,
|
||||
Currency: "GBP",
|
||||
SourceID: "ccof:card_1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the mock to reject a ccof charge without a customer")
|
||||
}
|
||||
v := reflect.ValueOf(err)
|
||||
if v.Kind() != reflect.Ptr {
|
||||
t.Fatalf("expected the structured error to be a pointer, got %v", v.Kind())
|
||||
}
|
||||
clone := reflect.New(v.Elem().Type())
|
||||
clone.Elem().Set(v.Elem())
|
||||
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
|
||||
if code != "" {
|
||||
clone.Elem().FieldByName("Code").SetString(code)
|
||||
}
|
||||
if category != "" {
|
||||
clone.Elem().FieldByName("Category").SetString(category)
|
||||
}
|
||||
return clone.Interface().(error)
|
||||
}
|
||||
|
||||
// TestCreateTillSale_DefinitiveDecline_Returns402 covers M3: a definitive
|
||||
// Square decline (structured CARD_DECLINED) on a fresh online-square till sale
|
||||
// must surface as 402 (Payment Required) — never 503 — and the funded gift card
|
||||
// must be clawed back.
|
||||
func TestCreateTillSale_DefinitiveDecline_Returns402(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:till-status-definitive",
|
||||
IdempotencyKey: "till-status-definitive-key",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||
if w.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("expected 402 for a definitive CARD_DECLINED till charge, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Definitive rejection → the sale is marked failed and the funded gift
|
||||
// card is clawed back (a late retry must not re-complete against it).
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query till_sales: %v", err)
|
||||
}
|
||||
if status != "failed" {
|
||||
t.Errorf("expected till_sale status 'failed' after a definitive decline, got %q", status)
|
||||
}
|
||||
var gcCount int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM gift_cards gc
|
||||
JOIN till_sales ts ON gc.id = ts.item_id
|
||||
WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&gcCount); err != nil {
|
||||
t.Fatalf("failed to count gift cards: %v", err)
|
||||
}
|
||||
if gcCount != 0 {
|
||||
t.Errorf("expected the created gift card to be clawed back after a definitive decline, got %d rows", gcCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_AmbiguousFailure_Returns503 covers M3: an ambiguous
|
||||
// failure (simulated transport error — a plain error with no structured Square
|
||||
// status) must surface as 503 (Service Unavailable), NEVER 402: the money state
|
||||
// at Square is unknown, so the pending sale must stay resumable on a same-key
|
||||
// retry.
|
||||
func TestCreateTillSale_AmbiguousFailure_Returns503(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: errors.New("mock: payment declined (simulated failure)")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:till-status-ambiguous",
|
||||
IdempotencyKey: "till-status-ambiguous-key",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 for an ambiguous till charge failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Ambiguous failure → the sale stays pending for the stale-pending sweep
|
||||
// and the gift card stays funded so a late same-key retry can complete it.
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query till_sales: %v", err)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("expected till_sale status 'pending' after an ambiguous failure, got %q", status)
|
||||
}
|
||||
var remaining float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT amount_remaining FROM gift_cards gc
|
||||
JOIN till_sales ts ON gc.id = ts.item_id
|
||||
WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&remaining); err != nil {
|
||||
t.Fatalf("failed to query gift card balance: %v", err)
|
||||
}
|
||||
if remaining != 50.00 {
|
||||
t.Errorf("expected the gift card to stay funded (£50.00) after an ambiguous failure, got £%.2f", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// H3 — isDefinitiveCardSaveFailure (handlers.go CreatePaymentMethod path)
|
||||
// =============================================================================
|
||||
|
||||
// TestIsDefinitiveCardSaveFailure pins the H3 classification: a card-save
|
||||
// failure carrying Square's INVALID_REQUEST_ERROR category (e.g.
|
||||
// MISSING_REQUIRED_PARAMETER) is DEFINITIVE — the card can never be saved, so
|
||||
// the attempt must fail immediately (400) instead of being retried as 500. The
|
||||
// card-on-file creation codes SOURCE_USED / CARD_TOKEN_USED /
|
||||
// CARD_TOKEN_EXPIRED / INVALID_CARD are definitive too, as are the shared
|
||||
// definitive charge-decline codes. Generic structured errors (5xx, unknown
|
||||
// code/category) and plain transport errors are AMBIGUOUS.
|
||||
func TestIsDefinitiveCardSaveFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"INVALID_REQUEST_ERROR category (MISSING_REQUIRED_PARAMETER) → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_REQUIRED_PARAMETER", "INVALID_REQUEST_ERROR"), true},
|
||||
{"SOURCE_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "SOURCE_USED", "INVALID_REQUEST_ERROR"), true},
|
||||
{"CARD_TOKEN_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_USED", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"CARD_TOKEN_EXPIRED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"INVALID_CARD → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "INVALID_CARD", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"CARD_DECLINED charge code → definitive", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"generic structured 500 → ambiguous", structuredSquareErrorFull(t, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "API_ERROR"), false},
|
||||
{"generic structured 400 unknown code/category → ambiguous", structuredSquareErrorFull(t, http.StatusBadRequest, "SOMETHING_ELSE", "PAYMENT_METHOD_ERROR"), false},
|
||||
{"plain transport error → ambiguous", errors.New("network error: connection reset by peer"), false},
|
||||
{"nil → ambiguous", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isDefinitiveCardSaveFailure(tt.err); got != tt.want {
|
||||
t.Errorf("isDefinitiveCardSaveFailure(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// cardSaveFailClient overrides only CreateCardOnFile so a transport-level
|
||||
// card-save failure can be injected into CreatePaymentMethod without breaking
|
||||
// the customer-provisioning call that precedes it.
|
||||
type cardSaveFailClient struct {
|
||||
square.SquareClient
|
||||
createCardErr error
|
||||
}
|
||||
|
||||
func (c *cardSaveFailClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
|
||||
return nil, c.createCardErr
|
||||
}
|
||||
|
||||
// TestCreatePaymentMethod_SourceUsed_Definitive400 drives the H3 classification
|
||||
// end to end through the add-card handler: Square consumes a cnon: nonce on
|
||||
// card creation, so reusing it is rejected with a structured 400 SOURCE_USED.
|
||||
// isDefinitiveCardSaveFailure classifies that as definitive → 400 "Invalid
|
||||
// request" (the save fails immediately, no retry), NOT 500, and no card row is
|
||||
// persisted by the failed attempt.
|
||||
func TestCreatePaymentMethod_SourceUsed_Definitive400(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", 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)
|
||||
|
||||
origClient := SquareClient
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
mc.SimulateSourceUsed = true
|
||||
SquareClient = mc
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreatePaymentMethod
|
||||
cardToken := "cnon:reused-source"
|
||||
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("first save with a fresh nonce must succeed, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Reusing the consumed nonce → SOURCE_USED (INVALID_REQUEST_ERROR) →
|
||||
// definitive card-save failure → 400, NOT 500.
|
||||
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for a definitive SOURCE_USED card-save failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil {
|
||||
t.Fatalf("failed to count saved cards: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 saved card (the failed re-save must not persist a row), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePaymentMethod_AmbiguousCardSaveFailure_500 drives the H3
|
||||
// classification the other way: a plain transport error during card tokenization
|
||||
// carries no structured Square code, so isDefinitiveCardSaveFailure is false and
|
||||
// the handler returns 500 (retrying with the same inputs might succeed).
|
||||
func TestCreatePaymentMethod_AmbiguousCardSaveFailure_500(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", 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)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &cardSaveFailClient{SquareClient: square.NewDevClient(), createCardErr: errors.New("network error: connection reset by peer")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreatePaymentMethod
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:ambiguous-save"}, token, ctx)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500 for an ambiguous card-save failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil {
|
||||
t.Fatalf("failed to count saved cards: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("an ambiguous card-save failure must not persist a card, got %d rows", count)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// H4 — 2FA gate on CreatePaymentMethod and BuyGiftCard(SaveCard)
|
||||
// =============================================================================
|
||||
|
||||
// TestIsVerificationRequiredError pins the SCA-challenge classification: the
|
||||
// four buyer-verification codes must classify as verification-required (so the
|
||||
// handlers surface the structured 402 body the frontend keys on to trigger the
|
||||
// 3DS challenge), while a plain decline and CVV re-entry requests must not.
|
||||
func TestIsVerificationRequiredError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"CARD_DECLINED_VERIFICATION_REQUIRED → true", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"VERIFICATION_TOKEN_EXPIRED → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"VERIFICATION_TOKEN_INVALID → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_INVALID", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"MISSING_VERIFICATION_TOKEN → true", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_VERIFICATION_TOKEN", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"CARD_DECLINED plain decline → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), false},
|
||||
{"CVV_VERIFICATION_REQUIRED (re-entry, not a 3DS challenge) → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CVV_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), false},
|
||||
{"INSUFFICIENT_FUNDS → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "INSUFFICIENT_FUNDS", "PAYMENT_METHOD_ERROR"), false},
|
||||
{"plain transport error → false", errors.New("network error: connection reset by peer"), false},
|
||||
{"nil → false", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isVerificationRequiredError(tt.err); got != tt.want {
|
||||
t.Errorf("isVerificationRequiredError(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_SCARequired_ReturnsStructured402 verifies the SCA-required
|
||||
// surfacing end to end on a till charge: a Square
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED failure (the buyer must complete 3DS)
|
||||
// returns 402 with the structured verification_required body — the frontend
|
||||
// triggers the challenge — instead of the plain-text "Payment failed".
|
||||
func TestCreateTillSale_SCARequired_ReturnsStructured402(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:till-sca-required",
|
||||
IdempotencyKey: "till-sca-required-key",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code")
|
||||
require.Contains(t, body["error"], "card issuer requires verification")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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, persisting a card
|
||||
// from a NON-token-like source (a raw PAN — the only shape the gate still
|
||||
// refuses) is rejected 402 verification_required (SCA-only — the 2FA fallback
|
||||
// was removed) and no card row is created — the save-card endpoint is not an
|
||||
// un-gated side door. A genuine Square token-like card token (cnon:/ccof:) is
|
||||
// SCA-proven and skips the gate (see TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds).
|
||||
func TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_402(t *testing.T) {
|
||||
t.Setenv("REQUIRE_2FA", "true")
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
handler := CreatePaymentMethod
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "4111111111111111"}, token, ctx)
|
||||
if w.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("expected 402 verification_required when 2FA is enforced (SCA-only), got %d: %s", 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"])
|
||||
|
||||
var cardCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil {
|
||||
t.Fatalf("failed to count saved cards: %v", err)
|
||||
}
|
||||
if cardCount != 0 {
|
||||
t.Errorf("a refused token-less save must not persist a card, got %d rows", cardCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Blocked verifies that even
|
||||
// a VALID 2FA code cannot save a card through the add-card endpoint when
|
||||
// enforced (SCA-only — the homegrown 2FA fallback was removed entirely).
|
||||
func TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Blocked(t *testing.T) {
|
||||
t.Setenv("REQUIRE_2FA", "true")
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
seedTwoFAPendingCode(t, tx, userID, "778899")
|
||||
t.Cleanup(func() {
|
||||
InvalidateSquareCustomerCache(userID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
})
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
handler := CreatePaymentMethod
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "4111111111111111"}, token, ctx)
|
||||
if w.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("expected 402 verification_required even with a valid 2FA code (SCA-only), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var cardCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil {
|
||||
t.Fatalf("failed to count saved cards: %v", err)
|
||||
}
|
||||
if cardCount != 0 {
|
||||
t.Errorf("a valid 2FA code must not persist a card (SCA-only), got %d", cardCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_BuyGiftCard_SaveCard_Tokenless_402 verifies the H4 gate
|
||||
// fires on the gift-card purchase path too: BuyGiftCard with req.SaveCard=true
|
||||
// is refused 402 verification_required (SCA-only) when enforced, mirroring
|
||||
// CreatePaymentMethod/CreateBookingPayment. The purchase is rejected BEFORE any
|
||||
// payment row is inserted.
|
||||
func TestTwoFactorEnforced_BuyGiftCard_SaveCard_Tokenless_402(t *testing.T) {
|
||||
t.Setenv("REQUIRE_2FA", "true")
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:2fa-buy-gc"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 2000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "2fa-buy-gc-blocked",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
||||
if w.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("expected 402 for BuyGiftCard with SaveCard=true without SCA, got %d: %s", 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"])
|
||||
|
||||
var payCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE created_by = $1`, userID).Scan(&payCount); err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 0 {
|
||||
t.Errorf("a refused gift-card purchase must not create a payment row, got %d", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M7 — ConfirmOverflowTip (handlers.go CreateBookingPayment overflow gate)
|
||||
// =============================================================================
|
||||
|
||||
// TestBookingPayment_Overflow_PostStart_RequiresConfirmation locks B12: even
|
||||
// on a booking that has STARTED, an overpayment that would become a tip is
|
||||
// rejected with 400 overflow_tip_confirmation_required unless the client sets
|
||||
// confirm_overflow_tip — an accidental overpayment (stale amount_due +
|
||||
// discount preview) must never silently become gratuity. The pre-start
|
||||
// rejection and the confirmed paths are covered in
|
||||
// m4_tip_refund_redesign_test.go.
|
||||
func TestBookingPayment_Overflow_PostStart_RequiresConfirmation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// setupTestDataPast creates a booking whose start time is 1h ago — a
|
||||
// post-start booking (the fixture booking total is £50).
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:overflow-post-start"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 6000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "overflow-post-start-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for a post-start overflow without confirmation, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "overflow_tip_confirmation_required") {
|
||||
t.Fatalf("expected the post-start overflow to require confirmation, body: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// No payment may be recorded for the rejected overflow.
|
||||
var payCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 0 {
|
||||
t.Errorf("expected 0 completed payments after the rejected post-start overflow, got %d", payCount)
|
||||
}
|
||||
|
||||
// The same overflow WITH confirmation proceeds and carves the £10 excess
|
||||
// as a tip record (gratuity).
|
||||
req.ConfirmOverflowTip = true
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for a confirmed post-start overflow, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var tipCount int
|
||||
var tipAmount float64
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount); err != nil {
|
||||
t.Fatalf("failed to query tip records: %v", err)
|
||||
}
|
||||
if tipCount != 1 {
|
||||
t.Errorf("expected exactly 1 tip record for the confirmed post-start overflow, got %d", tipCount)
|
||||
}
|
||||
if tipAmount < 9.995 || tipAmount > 10.005 {
|
||||
t.Errorf("expected the tip to equal the £10 overflow, got %.2f", tipAmount)
|
||||
}
|
||||
|
||||
// The booking portion is the remaining £50 (payment_type='full', the
|
||||
// original request type — no deposit/balance split post-start).
|
||||
var bookingPortion float64
|
||||
if err := tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'full'`, bookingID).Scan(&bookingPortion); err != nil {
|
||||
t.Fatalf("failed to query booking portion: %v", err)
|
||||
}
|
||||
if bookingPortion < 49.995 || bookingPortion > 50.005 {
|
||||
t.Errorf("expected the booking portion to be £50.00, got %.2f", bookingPortion)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"crussell/db"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// errClawbackPartiallyReversed is returned by RevertGiftCardFunding when some
|
||||
// of the funding it was asked to revert had already been SPENT before the
|
||||
// charge failed — the clawback reverts everything still on the card/balance
|
||||
// but the spent portion cannot be reclaimed. The CRITICAL admin notification
|
||||
// for reconciliation is inserted inside RevertGiftCardFunding itself, so every
|
||||
// caller (till handler, stale-pending sweep, Square webhook) surfaces the
|
||||
// residual without forking the money logic.
|
||||
var errClawbackPartiallyReversed = errors.New("gift-card funding clawback partially reversed — funding was already spent")
|
||||
|
||||
// penceLess reports whether a < b comparing two pound-float balances in pence,
|
||||
// the only float-safe way to compare money.
|
||||
func penceLess(a, b float64) bool {
|
||||
return int64(math.Round(a*100)) < int64(math.Round(b*100))
|
||||
}
|
||||
|
||||
// RevertGiftCardFunding undoes the gift-card funding performed earlier in the
|
||||
// SAME till-sale request after a definitive Square charge rejection, matching
|
||||
// the gift_card_transactions accounting: a created card is deleted (with its
|
||||
// purchase transaction) and any immediate redeem-to-account credit reversed; a
|
||||
// topped-up card has the amount subtracted back out and its top-up transaction
|
||||
// removed. The clawback is claim-first: it atomically claims the till sale
|
||||
// with a gating `status='pending'` UPDATE whose row lock serializes against
|
||||
// the handler's completion UPDATE, then runs the card mutation + failed-mark
|
||||
// in the same transaction so a late same-key retry cannot re-complete a sale
|
||||
// whose gift card no longer exists.
|
||||
//
|
||||
// This is the SINGLE money-reversal implementation shared by the till handler
|
||||
// (revertGiftCardFunding), the stale-pending sweep (sweep.go) and the Square
|
||||
// webhook clawback (handlers/webhooks/square.go). Do not fork it: a divergent
|
||||
// clawback is real money loss.
|
||||
func RevertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin clawback transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback gift-card clawback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Claim the sale first: the row lock serializes against the handler's
|
||||
// completion UPDATE; a zero-row claim means the funding is not ours.
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE till_sales SET status = 'failed', updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending'`, tillSaleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to claim till sale for clawback: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errTillSaleNotPending
|
||||
}
|
||||
|
||||
// M6: partial flags that some of the funding was already spent before the
|
||||
// charge failed — the clawback reverts everything still on the card/balance
|
||||
// but the spent portion is unrecoverable. Set by the branches below and
|
||||
// resolved into a CRITICAL admin notification + errClawbackPartiallyReversed
|
||||
// after the commit.
|
||||
partial := false
|
||||
|
||||
if action == "create" {
|
||||
// A newly created card's transactions are scoped to THIS sale's
|
||||
// funding (reference_type='till_sale' AND reference_id=sale id) — never
|
||||
// a wholesale delete, which would destroy the value of a different
|
||||
// idempotency-keyed top-up sale that funded the same card before this
|
||||
// create resolved. Then remove the card itself.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, giftCardID, tillSaleID); err != nil {
|
||||
return fmt.Errorf("failed to delete gift card transaction: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil {
|
||||
return fmt.Errorf("failed to delete gift card: %w", err)
|
||||
}
|
||||
// If the card was immediately redeemed to a user balance in this
|
||||
// request, reverse that credit. M6: the reversal CLAMPS to zero instead
|
||||
// of the old guarded `balance >= amount` 0-row block — a partially-spent
|
||||
// redemption must still give back everything that remains on the
|
||||
// balance; the unreclaimable spent portion is surfaced as a CRITICAL
|
||||
// admin notification + errClawbackPartiallyReversed below.
|
||||
if redeemToUserID != nil && *redeemToUserID != "" {
|
||||
var balanceBefore float64
|
||||
err := tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, *redeemToUserID).Scan(&balanceBefore)
|
||||
if err != nil {
|
||||
// Balance row missing (the credit was never recorded / already
|
||||
// fully spent — nothing to reverse) or unreadable. A
|
||||
// non-ErrNoRows read failure means the debit could not be
|
||||
// verified — flag the sale as partially reversed so the owner
|
||||
// reconciles instead of silently keeping the credit.
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
partial = true
|
||||
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not read the balance credited to user %s (%v) — MANUAL RECONCILIATION REQUIRED", giftCardID, *redeemToUserID, err)
|
||||
}
|
||||
} else {
|
||||
if _, bErr := tx.Exec(ctx, `
|
||||
UPDATE user_giftcard_balances
|
||||
SET balance = GREATEST(0, balance - $1), updated_at = NOW()
|
||||
WHERE user_id = $2
|
||||
`, amount, *redeemToUserID); bErr != nil {
|
||||
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", bErr)
|
||||
}
|
||||
if penceLess(balanceBefore, amount) {
|
||||
partial = true
|
||||
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not fully reverse the £%.2f balance credited to user %s (some was already spent — remaining balance clamped to zero)", giftCardID, amount, *redeemToUserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Top-up: subtract the amount back out of the card. M6: the reversal
|
||||
// CLAMPS amount_remaining and total_funds_added to zero instead of the
|
||||
// old guarded `amount_remaining >= amount` 0-row block — a partially-
|
||||
// spent top-up must still give back everything still on the card; the
|
||||
// unreclaimable spent portion is surfaced as a CRITICAL admin
|
||||
// notification + errClawbackPartiallyReversed below.
|
||||
var remainingBefore float64
|
||||
if err := tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&remainingBefore); err != nil {
|
||||
// The card is gone (no FK — a concurrent gift-card cancellation can
|
||||
// remove it) or unreadable: nothing can be reverted, but the sale
|
||||
// must STILL be marked failed. Flag the funding as unrecovered so
|
||||
// the owner reconciles instead of the sale staying pending forever.
|
||||
partial = true
|
||||
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be reversed (card missing/unreadable: %v)", amount, giftCardID, err)
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET total_funds_added = GREATEST(0, total_funds_added - $1),
|
||||
amount_remaining = GREATEST(0, amount_remaining - $1)
|
||||
WHERE id = $2
|
||||
`, amount, giftCardID); err != nil {
|
||||
return fmt.Errorf("failed to reverse gift card top-up: %w", err)
|
||||
}
|
||||
if penceLess(remainingBefore, amount) {
|
||||
partial = true
|
||||
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (some of it was already spent — card balance clamped to zero)", amount, giftCardID)
|
||||
}
|
||||
}
|
||||
// Remove only this request's top-up transaction (reference_id = till
|
||||
// sale) so prior sales' accounting on the same card is untouched.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM gift_card_transactions
|
||||
WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2
|
||||
`, giftCardID, tillSaleID); err != nil {
|
||||
return fmt.Errorf("failed to delete gift card top-up transaction: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit clawback transaction: %w", err)
|
||||
}
|
||||
|
||||
// MEDIUM-3a coverage: every funding clawback is a money reversal that must
|
||||
// be auditable — the PARTIAL clawback included (it reverted the balance to
|
||||
// zero and must leave the same trail). Record it in admin_audit_log
|
||||
// (best-effort, own tx — InsertAdminAuditCharge's separate transaction
|
||||
// keeps a write failure from aborting the committed clawback). The create
|
||||
// branch DELETES the card, so target_gift_card_id must stay NULL (the FK
|
||||
// would otherwise block the card deletion); the card id is carried in the
|
||||
// details. admin_id is NULL too — this helper is the shared implementation
|
||||
// for the till handler, the stale-pending sweep and the Square webhook,
|
||||
// which carry no admin actor.
|
||||
insertGiftCardClawbackAudit(ctx, giftCardID, tillSaleID, action, amount)
|
||||
|
||||
if partial {
|
||||
// M6: the funding was partially spent before the charge failed — the
|
||||
// clawback reverted everything still on the card/balance but the spent
|
||||
// portion is gone. Surface a CRITICAL admin notification so the owner
|
||||
// reconciles the residual (the card/balance were clamped to zero and
|
||||
// the sale is failed; the spent money is unrecoverable and must be
|
||||
// reviewed) and return the sentinel so every caller knows the reversal
|
||||
// was not complete.
|
||||
insertCriticalPaymentNotification(ctx, nil, redeemToUserID)
|
||||
log.Printf("CRITICAL: till sale %s's gift-card funding clawback was PARTIAL (funding had already been spent) — card/balance reverted to zero; the spent portion is unrecoverable — MANUAL RECONCILIATION REQUIRED", tillSaleID)
|
||||
return errClawbackPartiallyReversed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertGiftCardClawbackAudit records a funding clawback in admin_audit_log
|
||||
// via the shared InsertAdminAuditCharge helper. Best-effort and non-fatal —
|
||||
// a failed audit write can never abort the already-committed money reversal.
|
||||
func insertGiftCardClawbackAudit(ctx context.Context, giftCardID, tillSaleID, action string, amount float64) {
|
||||
InsertAdminAuditCharge(ctx, "", "", "giftcard_clawback", map[string]any{
|
||||
"gift_card_id": giftCardID,
|
||||
"till_sale_id": tillSaleID,
|
||||
"action": action,
|
||||
"amount": amount,
|
||||
})
|
||||
}
|
||||
|
||||
// IsTillSaleNotPending reports whether err is the claim-first sentinel
|
||||
// (errTillSaleNotPending): the gating `status='pending'` UPDATE matched zero
|
||||
// rows, so the till sale is no longer pending and its gift card must be left
|
||||
// untouched. Exported so cross-package clawback callers (the Square webhook)
|
||||
// can detect the sentinel without reaching into the unexported error value.
|
||||
func IsTillSaleNotPending(err error) bool {
|
||||
return errors.Is(err, errTillSaleNotPending)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
|
||||
// TestRevertGiftCardFunding_TopupPartiallySpent_InsertsCriticalNotification
|
||||
// locks the M6 neither-path: when the top-up funding was already partially spent
|
||||
// before the charge failed, RevertGiftCardFunding clamps the card to zero,
|
||||
// returns errClawbackPartiallyReversed AND inserts a critical-payment admin
|
||||
// notification so the residual is surfaced in the admin notification centre
|
||||
// (a bare log line is not enough — an operator must see the reconciliation
|
||||
// item).
|
||||
func TestRevertGiftCardFunding_TopupPartiallySpent_InsertsCriticalNotification(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
var cardID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
|
||||
VALUES (50.00, 10.00, $1, FALSE)
|
||||
RETURNING id
|
||||
`, adminID).Scan(&cardID); err != nil {
|
||||
t.Fatalf("failed to seed gift card: %v", err)
|
||||
}
|
||||
|
||||
var saleID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, adminID).Scan(&saleID); err != nil {
|
||||
t.Fatalf("failed to seed till sale: %v", err)
|
||||
}
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, cardID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
|
||||
})
|
||||
|
||||
err = revertGiftCardFunding(pool, "topup", cardID, 50.00, nil, saleID)
|
||||
if !errors.Is(err, errClawbackPartiallyReversed) {
|
||||
t.Fatalf("expected errClawbackPartiallyReversed for the partially-spent top-up, got %v", err)
|
||||
}
|
||||
|
||||
// M6: the CRITICAL admin notification must exist for the residual (a till
|
||||
// sale has no booking or user attribution, so the notification dedup key is
|
||||
// (nil, nil)).
|
||||
var notifCount int
|
||||
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND user_id IS NULL`).Scan(¬ifCount); err != nil {
|
||||
t.Fatalf("failed to count admin notifications: %v", err)
|
||||
}
|
||||
if notifCount < 1 {
|
||||
t.Errorf("expected a critical-payment admin notification for the partial clawback, got %d", notifCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertGiftCardFunding_CreateWithRedeemPartiallySpent_ClampsAndFlags locks
|
||||
// the M6 create-with-redeem neither-path: a created card immediately redeemed to
|
||||
// a user balance that was partially spent before the charge failed is deleted,
|
||||
// the balance is clamped to zero, errClawbackPartiallyReversed is returned and
|
||||
// a critical admin notification is inserted for the unrecoverable spent
|
||||
// portion.
|
||||
func TestRevertGiftCardFunding_CreateWithRedeemPartiallySpent_ClampsAndFlags(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
customerID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create customer user: %v", err)
|
||||
}
|
||||
|
||||
var cardID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
|
||||
VALUES (50.00, 0.00, $1, FALSE)
|
||||
RETURNING id
|
||||
`, adminID).Scan(&cardID); err != nil {
|
||||
t.Fatalf("failed to seed gift card: %v", err)
|
||||
}
|
||||
// The redeem credited £50 to the customer, who spent £40 before the charge
|
||||
// failed.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, 10.00, NOW())
|
||||
`, customerID); err != nil {
|
||||
t.Fatalf("failed to seed user balance: %v", err)
|
||||
}
|
||||
|
||||
var saleID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, adminID).Scan(&saleID); err != nil {
|
||||
t.Fatalf("failed to seed till sale: %v", err)
|
||||
}
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM user_giftcard_balances WHERE user_id = $1`, customerID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, cardID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = ANY($1)`, []string{adminID, customerID})
|
||||
})
|
||||
|
||||
err = revertGiftCardFunding(pool, "create", cardID, 50.00, &customerID, saleID)
|
||||
if !errors.Is(err, errClawbackPartiallyReversed) {
|
||||
t.Fatalf("expected errClawbackPartiallyReversed for the partially-spent redeemed balance, got %v", err)
|
||||
}
|
||||
|
||||
// The created card is deleted and the balance clamped to zero.
|
||||
var cardCount int
|
||||
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, cardID).Scan(&cardCount); err != nil {
|
||||
t.Fatalf("failed to count gift cards: %v", err)
|
||||
}
|
||||
if cardCount != 0 {
|
||||
t.Errorf("expected the created card deleted by the clawback, got %d cards", cardCount)
|
||||
}
|
||||
var balance float64
|
||||
if err := db.Conn.QueryRow(pool, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, customerID).Scan(&balance); err != nil {
|
||||
t.Fatalf("failed to query user balance: %v", err)
|
||||
}
|
||||
if balance != 0.00 {
|
||||
t.Errorf("expected the partially-spent redeemed balance clamped to zero, got £%.2f", balance)
|
||||
}
|
||||
|
||||
// The sale is failed and a CRITICAL notification surfaced.
|
||||
var saleStatus string
|
||||
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&saleStatus); err != nil {
|
||||
t.Fatalf("failed to query till sale: %v", err)
|
||||
}
|
||||
if saleStatus != "failed" {
|
||||
t.Errorf("expected till sale marked failed, got %q", saleStatus)
|
||||
}
|
||||
var notifCount int
|
||||
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, customerID).Scan(¬ifCount); err != nil {
|
||||
t.Fatalf("failed to count admin notifications: %v", err)
|
||||
}
|
||||
if notifCount < 1 {
|
||||
t.Errorf("expected a critical-payment admin notification for the partial clawback, got %d", notifCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"crussell/db"
|
||||
)
|
||||
|
||||
// Gift-card purchase/transaction limits (owner decisions).
|
||||
//
|
||||
// - Every admin gift-card value operation (CreateGiftCard, TopUpGiftCard,
|
||||
// TransferGiftCard) is capped at £250 per transaction — tighter than the
|
||||
// £10,000 ceiling ValidateAmount enforces on other payment entry points.
|
||||
// - A customer (BuyGiftCard) may buy at most £500 of online gift cards per
|
||||
// UTC day.
|
||||
// - An admin may create/top-up/transfer at most £5,000 of gift-card value
|
||||
// per UTC day.
|
||||
//
|
||||
// till.go uses the same £250 transaction cap (maxAdminGiftCardTransactionPence)
|
||||
// for its gift-card creates/topups — this shared constant is the single source
|
||||
// of the owner decision.
|
||||
const (
|
||||
// maxAdminGiftCardTransactionPence caps a single admin gift-card
|
||||
// create/top-up/transfer at £250 (25,000 pence).
|
||||
maxAdminGiftCardTransactionPence = 25_000
|
||||
// maxUserGiftCardDailyPence caps one user's online gift-card purchases at
|
||||
// £500 (50,000 pence) per UTC day.
|
||||
maxUserGiftCardDailyPence = 500_00
|
||||
// maxAdminGiftCardDailyPence caps the gift-card value an admin can
|
||||
// create/top-up/transfer in one UTC day at £5,000 (500,000 pence).
|
||||
maxAdminGiftCardDailyPence = 500_000
|
||||
)
|
||||
|
||||
// userGiftCardSpentToday returns the total value (in pounds) the user has
|
||||
// spent on ONLINE gift-card purchases so far today, returned as a float64 so
|
||||
// the caller can convert to pence with math.Round, matching the repo's
|
||||
// currency convention.
|
||||
//
|
||||
// Signal: gift_card_transactions rows written by BuyGiftCard — the ONLY
|
||||
// customer-facing online purchase path. Every BuyGiftCard purchase (self and
|
||||
// friend) inserts a row with transaction_type='purchase', reference_type='api'
|
||||
// and user_id = the buyer (see giftcards.go). Admin-created cards
|
||||
// (CreateGiftCard/TopUpGiftCard) also write reference_type='api' but with the
|
||||
// ADMIN's user id, and till sales write reference_type='till_sale', so neither
|
||||
// can match a customer. The payments-based alternative (payments rows with
|
||||
// payment_type='gift_card') does NOT exist in this schema — the payment_type
|
||||
// enum is ('deposit','full','tip','balance','partial') and BuyGiftCard writes
|
||||
// payment_type='full' — so the transactions audit log is the correct signal.
|
||||
//
|
||||
// "Today" is the UTC day boundary (created_at >= CURRENT_DATE), matching the
|
||||
// repo's existing time convention: the DB session runs in timezone=UTC and
|
||||
// completion.go uses the same CURRENT_DATE boundary for its daily loyalty
|
||||
// stamp cap.
|
||||
func userGiftCardSpentToday(ctx context.Context, q db.Querier, userID string) (float64, error) {
|
||||
var spent float64
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM gift_card_transactions
|
||||
WHERE user_id = $1
|
||||
AND transaction_type = 'purchase'
|
||||
AND reference_type = 'api'
|
||||
AND created_at >= CURRENT_DATE
|
||||
`, userID).Scan(&spent)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return spent, nil
|
||||
}
|
||||
|
||||
// adminGiftCardValueToday returns the total gift-card value (in pounds) the
|
||||
// admin has created, topped up, transferred, or issued via the till today (UTC
|
||||
// day boundary, created_at >= CURRENT_DATE), returned as a float64 for pence
|
||||
// conversion. This is the SINGLE daily-cap signal shared by the admin API
|
||||
// surface (CreateGiftCard / TopUpGiftCard / TransferGiftCard) AND the till
|
||||
// (CreateTillSale) — an admin surface that otherwise could issue unlimited
|
||||
// balance (MEDIUM-5).
|
||||
//
|
||||
// Signal (chosen to be double-count free across the admin operations):
|
||||
//
|
||||
// 1. Cards the admin created today — SUM(total_funds_added). total_funds_added
|
||||
// is cumulative, so a card created today already reflects any same-day
|
||||
// top-up or transfer INTO it, and its creation amount. This covers cards
|
||||
// created through BOTH the admin API and the till (a till create inserts
|
||||
// the card with created_by = the admin).
|
||||
// 2. API top-ups executed by this admin today on cards created BEFORE today
|
||||
// (cards created today are excluded — term 1 already includes their
|
||||
// funding via total_funds_added, so counting the top-up row again would
|
||||
// double-count). This is the gift_card_transactions rows
|
||||
// (reference_type='api', user_id=admin) written by CreateGiftCard
|
||||
// ('purchase') and TopUpGiftCard ('topup', or 'purchase' on an inventory
|
||||
// card's first top-up).
|
||||
// 3. Till sales executed by this admin today on cards created BEFORE today —
|
||||
// till_sales rows (created_by = admin, status completed/pending — a
|
||||
// pending sale's card was already funded before the Square call). Cards
|
||||
// created today are excluded exactly like term 2, so a till-created card
|
||||
// is counted once via term 1's total_funds_added and a till top-up on an
|
||||
// older card is counted once here. A till sale's gift_card_transactions
|
||||
// row is attributed to the CUSTOMER (reference_type='till_sale'), so it
|
||||
// never enters term 2.
|
||||
//
|
||||
// Transfers INTO pre-existing cards leave no attributable audit row
|
||||
// (TransferGiftCard deliberately writes no gift_card_transactions entry), so
|
||||
// they are not directly counted; a transfer also creates no NEW gift-card
|
||||
// liability, so the daily cap still measures all value this admin has newly
|
||||
// issued today.
|
||||
func adminGiftCardValueToday(ctx context.Context, q db.Querier, adminID string) (float64, error) {
|
||||
var value float64
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE((
|
||||
SELECT SUM(gc.total_funds_added)
|
||||
FROM gift_cards gc
|
||||
WHERE gc.created_by = $1 AND gc.created_at >= CURRENT_DATE
|
||||
), 0)
|
||||
+ COALESCE((
|
||||
SELECT SUM(gct.amount)
|
||||
FROM gift_card_transactions gct
|
||||
WHERE gct.user_id = $1
|
||||
AND gct.reference_type = 'api'
|
||||
AND gct.transaction_type IN ('purchase', 'topup')
|
||||
AND gct.created_at >= CURRENT_DATE
|
||||
AND gct.gift_card_id NOT IN (
|
||||
SELECT gc2.id FROM gift_cards gc2
|
||||
WHERE gc2.created_by = $1 AND gc2.created_at >= CURRENT_DATE
|
||||
)
|
||||
), 0)
|
||||
+ COALESCE((
|
||||
SELECT SUM(ts.total_amount)
|
||||
FROM till_sales ts
|
||||
WHERE ts.created_by = $1
|
||||
AND ts.status IN ('completed', 'pending')
|
||||
AND ts.created_at >= CURRENT_DATE
|
||||
AND ts.item_id NOT IN (
|
||||
SELECT gc3.id FROM gift_cards gc3
|
||||
WHERE gc3.created_by = $1 AND gc3.created_at >= CURRENT_DATE
|
||||
)
|
||||
), 0)
|
||||
`, adminID).Scan(&value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ===========================================================================
|
||||
// userGiftCardSpentToday
|
||||
// ===========================================================================
|
||||
|
||||
func TestUserGiftCardSpentToday_ZeroRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, float64(0), spent, "expected 0 when no gift_card_transactions exist")
|
||||
}
|
||||
|
||||
func TestUserGiftCardSpentToday_SinglePurchase(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a purchase row with today's date
|
||||
gcID := insertGiftCard(ctx, t, tx, nil)
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 100.00)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 100.00, spent, 0.005, "expected £100.00 from single purchase")
|
||||
}
|
||||
|
||||
func TestUserGiftCardSpentToday_MultiplePurchases(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcID := insertGiftCard(ctx, t, tx, nil)
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 100.00)
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 200.00)
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 50.00)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 350.00, spent, 0.005, "expected £350.00 from 3 purchases")
|
||||
}
|
||||
|
||||
func TestUserGiftCardSpentToday_CapBoundary_Exactly(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// maxUserGiftCardDailyPence = 500_00 → £500.00
|
||||
capPounds := float64(maxUserGiftCardDailyPence) / 100.0
|
||||
gcID := insertGiftCard(ctx, t, tx, nil)
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", capPounds)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, capPounds, spent, 0.005, "expected exactly the cap at £%.2f", capPounds)
|
||||
}
|
||||
|
||||
func TestUserGiftCardSpentToday_OnePennyOverCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// One penny over the daily cap
|
||||
capPounds := float64(maxUserGiftCardDailyPence) / 100.0
|
||||
over := capPounds + 0.01
|
||||
gcID := insertGiftCard(ctx, t, tx, nil)
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", over)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, over, spent, 0.005, "expected £%.2f (one penny over cap)", over)
|
||||
require.True(t, spent > capPounds, "expected spent %.2f to exceed cap %.2f", spent, capPounds)
|
||||
}
|
||||
|
||||
func TestUserGiftCardSpentToday_FiltersTransactionType(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcID := insertGiftCard(ctx, t, tx, nil)
|
||||
// Only 'purchase' should be counted — insert a 'topup' row that must be excluded
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "topup", "api", 500.00)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, float64(0), spent, "expected 0 — 'topup' rows are not purchases")
|
||||
}
|
||||
|
||||
func TestUserGiftCardSpentToday_FiltersReferenceType(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcID := insertGiftCard(ctx, t, tx, nil)
|
||||
// Only 'api' reference type should be counted — 'till_sale' must be excluded
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "till_sale", 200.00)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, float64(0), spent, "expected 0 — 'till_sale' reference_type is excluded")
|
||||
}
|
||||
|
||||
func TestUserGiftCardSpentToday_DifferentUserNotCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
otherID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcID := insertGiftCard(ctx, t, tx, nil)
|
||||
// Other user's purchase should not count toward this user
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 100.00)
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, otherID, "purchase", "api", 300.00)
|
||||
|
||||
spent, err := userGiftCardSpentToday(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 100.00, spent, 0.005, "expected only this user's purchases")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// adminGiftCardValueToday
|
||||
// ===========================================================================
|
||||
|
||||
func TestAdminGiftCardValueToday_ZeroRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, float64(0), value, "expected 0 when no gift cards or transactions exist")
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_CardsCreatedToday(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Term 1: cards created today by this admin
|
||||
insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 200.00})
|
||||
insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 300.00})
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 500.00, value, 0.005, "expected £500 from two cards created today")
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_TopUpOnPreExistingCard(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Card created YESTERDAY (created_at = -48h) so it's NOT counted in term 1
|
||||
gcID := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour))
|
||||
|
||||
// Topup today on that pre-existing card — counted in term 2
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, adminID, "topup", "api", 50.00)
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 50.00, value, 0.005, "expected £50 from topup on pre-existing card")
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_TillSaleOnPreExistingCard(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Card created YESTERDAY so it's not counted in term 1
|
||||
gcID := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour))
|
||||
|
||||
// Till sale today on that pre-existing card — counted in term 3
|
||||
insertTillSale(ctx, t, tx, adminID, gcID, 75.00, "completed")
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 75.00, value, 0.005, "expected £75 from till sale on pre-existing card")
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_CombinedSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Term 1: card created today by admin
|
||||
insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 200.00})
|
||||
|
||||
// Pre-existing card (created yesterday) + topup today (term 2)
|
||||
gcPre := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour))
|
||||
insertGiftCardTransaction(ctx, t, tx, gcPre, adminID, "topup", "api", 50.00)
|
||||
|
||||
// Another pre-existing card + till sale today (term 3)
|
||||
gcPre2 := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour))
|
||||
insertTillSale(ctx, t, tx, adminID, gcPre2, 25.00, "completed")
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 275.00, value, 0.005, "expected £275 from all three sources combined")
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_DoubleCountPrevention(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Card created today with total_funds_added=200. This is counted in term 1.
|
||||
// A topup AND till sale on that SAME card (created today) must NOT also count
|
||||
// in terms 2 and 3 (the NOT IN subqueries exclude today-created cards).
|
||||
gcID := insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 200.00})
|
||||
insertGiftCardTransaction(ctx, t, tx, gcID, adminID, "topup", "api", 50.00)
|
||||
insertTillSale(ctx, t, tx, adminID, gcID, 25.00, "completed")
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
// Only term 1 should count: the 200 from total_funds_added.
|
||||
// The topup and till_sale are on today's card, so they're double-count protected.
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 200.00, value, 0.005, "expected only £200 from term 1; topup and sale must be excluded")
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_CapBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// maxAdminGiftCardDailyPence = 500_000 → £5,000
|
||||
capPounds := float64(maxAdminGiftCardDailyPence) / 100.0
|
||||
insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: capPounds})
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, capPounds, value, 0.005, "expected exactly the admin cap at £%.2f", capPounds)
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_OnePennyOverCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
capPounds := float64(maxAdminGiftCardDailyPence) / 100.0
|
||||
over := capPounds + 0.01
|
||||
insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: over})
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, over, value, 0.005, "expected £%.2f (one penny over cap)", over)
|
||||
require.True(t, value > capPounds, "expected value %.2f to exceed cap %.2f", value, capPounds)
|
||||
}
|
||||
|
||||
func TestAdminGiftCardValueToday_IgnoresOtherAdmins(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
otherAdmin, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00})
|
||||
insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: otherAdmin, totalFunds: 500.00})
|
||||
|
||||
value, err := adminGiftCardValueToday(ctx, tx, adminID)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 100.00, value, 0.005, "expected only this admin's card value")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Test helpers — build test data with minimal column requirements
|
||||
// ===========================================================================
|
||||
|
||||
type giftCardOptions struct {
|
||||
createdBy string
|
||||
totalFunds float64
|
||||
}
|
||||
|
||||
func insertGiftCard(ctx context.Context, t *testing.T, q db.Querier, opts *giftCardOptions) string {
|
||||
t.Helper()
|
||||
return insertGiftCardAt(ctx, t, q, opts, clock.Now())
|
||||
}
|
||||
|
||||
func insertGiftCardAt(ctx context.Context, t *testing.T, q db.Querier, opts *giftCardOptions, createdAt time.Time) string {
|
||||
t.Helper()
|
||||
if opts == nil {
|
||||
opts = &giftCardOptions{}
|
||||
}
|
||||
// created_by is nullable, pass nil when empty
|
||||
if opts.createdBy != "" {
|
||||
var id string
|
||||
err := q.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, created_at)
|
||||
VALUES ($1, $1, $2, $3)
|
||||
RETURNING id
|
||||
`, opts.totalFunds, opts.createdBy, createdAt).Scan(&id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := q.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_at)
|
||||
VALUES ($1, $1, $2)
|
||||
RETURNING id
|
||||
`, opts.totalFunds, createdAt).Scan(&id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
func insertGiftCardTransaction(ctx context.Context, t *testing.T, q db.Querier, giftCardID, userID, txType, refType string, amount float64) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := q.QueryRow(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
RETURNING id
|
||||
`, giftCardID, txType, amount, refType, userID).Scan(&id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
func insertTillSale(ctx context.Context, t *testing.T, q db.Querier, adminID, itemID string, totalAmount float64, status string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := q.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'test till sale', 1, $2, $2, 'online_square', $3, $4, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, itemID, totalAmount, status, adminID).Scan(&id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// M16 gift-card double-redeem TOCTOU test. RedeemGiftCard serializes on a
|
||||
// `SELECT ... FOR UPDATE` of the gift_cards row (READ COMMITTED re-reads the
|
||||
// latest committed version after the lock is granted), so two CONCURRENT
|
||||
// redemptions of the same code can never both succeed: exactly one wins the
|
||||
// row lock, zeroes the balance and credits its user; the loser re-reads the
|
||||
// committed row, sees redeemed_by set and is rejected 400. This test drives
|
||||
// two real HTTP requests in parallel goroutines and asserts exactly one
|
||||
// success, one rejection, and a single balance decrement + single
|
||||
// redeem_to_balance audit transaction.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestGiftCardRedeem_ConcurrentSameCode_ExactlyOneWins(t *testing.T) {
|
||||
// Seed on the pool directly (no SetupTestTx): each concurrent request
|
||||
// must run its own transaction on the shared pool.
|
||||
ctx := context.Background()
|
||||
userA, err := fixtures.CreateTestUser(db.Conn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user A: %v", err)
|
||||
}
|
||||
userB, err := fixtures.CreateTestUser(db.Conn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user B: %v", err)
|
||||
}
|
||||
|
||||
var code string
|
||||
if err := db.Conn.QueryRow(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining) VALUES (50.00, 50.00) RETURNING id`).Scan(&code); err != nil {
|
||||
t.Fatalf("failed to seed gift card: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, code)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_giftcard_balances WHERE user_id IN ($1, $2)`, userA, userB)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, code)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id IN ($1, $2)`, userA, userB)
|
||||
})
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.RequireAuth)
|
||||
router.Post("/api/user/giftcards/redeem", RedeemGiftCard)
|
||||
|
||||
// Both redemptions start at the same barrier instant so the row lock
|
||||
// genuinely contends.
|
||||
start := make(chan struct{})
|
||||
type attempt struct {
|
||||
status int
|
||||
}
|
||||
attempts := make([]attempt, 2)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
redeem := func(userID, token string, idx int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
body, _ := json.Marshal(map[string]any{"code": code})
|
||||
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
attempts[idx].status = w.Code
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go redeem(userA, jwt.GenerateTestToken(userA, "verified_email"), 0)
|
||||
go redeem(userB, jwt.GenerateTestToken(userB, "verified_email"), 1)
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
successes, failures := 0, 0
|
||||
for _, a := range attempts {
|
||||
switch a.status {
|
||||
case http.StatusOK:
|
||||
successes++
|
||||
case http.StatusBadRequest:
|
||||
failures++
|
||||
default:
|
||||
t.Errorf("unexpected redeem status %d", a.status)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Errorf("expected exactly ONE successful redemption, got %d", successes)
|
||||
}
|
||||
if failures != 1 {
|
||||
t.Errorf("expected exactly ONE rejected redemption, got %d", failures)
|
||||
}
|
||||
|
||||
// The balance was decremented exactly once (amount_remaining zeroed).
|
||||
var amountRemaining float64
|
||||
var redeemedBy *string
|
||||
if err := db.Conn.QueryRow(ctx, `SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1`, code).Scan(&amountRemaining, &redeemedBy); err != nil {
|
||||
t.Fatalf("failed to query gift card: %v", err)
|
||||
}
|
||||
if amountRemaining != 0 {
|
||||
t.Errorf("expected the card balance decremented to 0 exactly once, got %.2f", amountRemaining)
|
||||
}
|
||||
if redeemedBy == nil {
|
||||
t.Fatal("expected the card redeemed to exactly one user")
|
||||
}
|
||||
|
||||
// Exactly one user holds the credited balance; the other has none.
|
||||
var winners int
|
||||
var balanceSum float64
|
||||
if err := db.Conn.QueryRow(ctx, `
|
||||
SELECT COUNT(*), COALESCE(SUM(balance), 0)
|
||||
FROM user_giftcard_balances
|
||||
WHERE user_id IN ($1, $2)
|
||||
`, userA, userB).Scan(&winners, &balanceSum); err != nil {
|
||||
t.Fatalf("failed to query user balances: %v", err)
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Errorf("expected exactly one credited balance row, got %d", winners)
|
||||
}
|
||||
if balanceSum != 50 {
|
||||
t.Errorf("expected the winner credited 50.00, got %.2f", balanceSum)
|
||||
}
|
||||
|
||||
// Exactly one redeem_to_balance audit transaction exists.
|
||||
var txCount int
|
||||
if err := db.Conn.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM gift_card_transactions
|
||||
WHERE gift_card_id = $1 AND transaction_type = 'redeem_to_balance'
|
||||
`, code).Scan(&txCount); err != nil {
|
||||
t.Fatalf("failed to count redeem transactions: %v", err)
|
||||
}
|
||||
if txCount != 1 {
|
||||
t.Errorf("expected exactly one redeem_to_balance transaction, got %d", txCount)
|
||||
}
|
||||
}
|
||||
+2406
-204
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// ROUND 10 — CancelGiftCard resume vs an in-flight / differently-amounted
|
||||
// Square refund (adversarial double-refund probe)
|
||||
//
|
||||
// The resume path must inspect EVERY Square refund for the payment (pending
|
||||
// AND completed, any amount), not just an exact-amount COMPLETED one:
|
||||
// - a still-PENDING Square refund means money may still land — re-issuing
|
||||
// under a fresh amount-derived key would mint a SECOND refund that lands
|
||||
// on top of the first (double refund);
|
||||
// - a COMPLETED refund at a DIFFERENT amount than the pending row claims
|
||||
// still counts toward the entitlement — the exact-amount reconcile misses
|
||||
// it and would re-issue money that has already moved.
|
||||
// =============================================================================
|
||||
|
||||
// TestRound10_CancelGiftCard_Resume_PriorSquareRefundPending_NoReissue pins
|
||||
// the in-flight guard: the prior attempt is still PENDING at Square and the
|
||||
// entitlement has dropped (a £5 till spend between attempts). The resume must
|
||||
// NOT re-issue a second Square refund — the row stays 'pending' for the sweep
|
||||
// and the card stays live. Exploit before the fix: the exact-amount reconcile
|
||||
// sees no COMPLETED £50 refund, re-issues £45 under a fresh key while the £50
|
||||
// is still in flight, and the customer is refunded £95 total.
|
||||
func TestRound10_CancelGiftCard_Resume_PriorSquareRefundPending_NoReissue(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
counting := &countingRefundClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 5.00, "pending")
|
||||
|
||||
// The prior attempt is STILL in flight at Square (PENDING). Seed it via
|
||||
// the raw mock so it bypasses the counting wrapper's refund-call log.
|
||||
mock.ForceRefundPending = true
|
||||
_, err = counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||||
PaymentID: squarePaymentID,
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "pending-inflight-" + paymentID,
|
||||
Reason: giftCardCancelRefundReason,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
mock.ForceRefundPending = false
|
||||
|
||||
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
|
||||
require.Equal(t, http.StatusConflict, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
// No second Square refund may be issued while a prior one is in flight:
|
||||
// the handler minted none, and the mock ledger still holds exactly the one
|
||||
// pre-seeded refund.
|
||||
require.Empty(t, counting.refundCalls(), "no re-issue while a prior Square refund is still PENDING")
|
||||
require.Equal(t, 1, mock.RefundKeyCount(), "exactly one Square refund minted for the payment — the pending one")
|
||||
|
||||
// The refund row stays 'pending' (this request made no writes) and the
|
||||
// card stays live at its spend-verified balance.
|
||||
var status string
|
||||
var amount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status, amount FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &amount))
|
||||
assert.Equal(t, "pending", status, "the row must remain pending for sweep reconciliation")
|
||||
assert.Equal(t, 50.00, amount, "the row must not be rewritten with a new amount or key")
|
||||
|
||||
var rem float64
|
||||
var expiry sql.NullTime
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
|
||||
assert.Equal(t, 45.00, rem, "the card must stay live with its spend-verified remaining balance")
|
||||
require.True(t, expiry.Valid)
|
||||
assert.True(t, expiry.Time.After(clock.Now()), "the card must not be expired")
|
||||
}
|
||||
|
||||
// TestRound10_CancelGiftCard_Resume_CompletedDifferentAmount_ResolvesAndNeutralizes
|
||||
// pins the sum-based terminal reconcile: the prior attempt landed at Square at
|
||||
// a DIFFERENT amount than the pending row claims (£45 of a £50 card after a £5
|
||||
// till spend — the pending row still says £50). The exact-amount reconcile
|
||||
// (expecting £50) would miss the landed £45 and re-issue it. The resume must
|
||||
// instead recognise the entitlement is already covered, resolve the row
|
||||
// completed with the landed Square refund id, neutralise the card, and issue no
|
||||
// new refund.
|
||||
func TestRound10_CancelGiftCard_Resume_CompletedDifferentAmount_ResolvesAndNeutralizes(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
counting := &countingRefundClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 5.00, "pending")
|
||||
|
||||
prelanded, err := counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||||
PaymentID: squarePaymentID,
|
||||
Amount: 4500,
|
||||
IdempotencyKey: "prelanded-diff-amount-" + paymentID,
|
||||
Reason: giftCardCancelRefundReason,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "already been refunded")
|
||||
|
||||
require.Empty(t, counting.refundCalls(), "no new Square refund when the full entitlement already completed")
|
||||
|
||||
var status string
|
||||
var sqRefundID sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID))
|
||||
assert.Equal(t, "completed", status, "the pending row must resolve to completed")
|
||||
assert.Equal(t, prelanded.ID, sqRefundID.String, "the row records the landed Square refund id")
|
||||
|
||||
var rem float64
|
||||
var expiry sql.NullTime
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
|
||||
assert.Equal(t, 0.00, rem, "the card must be neutralised")
|
||||
require.True(t, expiry.Valid)
|
||||
assert.False(t, expiry.Time.After(clock.Now()), "the card must be expired")
|
||||
}
|
||||
|
||||
// TestRound10_CancelGiftCard_Resume_CompletedPartial_DifferenceOnlyReissued pins
|
||||
// the difference-only re-issue: £30 of the £50 card already completed at
|
||||
// Square, so the resume must issue ONLY the £20 remainder under a fresh
|
||||
// deterministic key for that difference — never the full £50.
|
||||
func TestRound10_CancelGiftCard_Resume_CompletedPartial_DifferenceOnlyReissued(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
counting := &countingRefundClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 0, "pending")
|
||||
|
||||
prelanded, err := counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||||
PaymentID: squarePaymentID,
|
||||
Amount: 3000,
|
||||
IdempotencyKey: "prelanded-partial-" + paymentID,
|
||||
Reason: giftCardCancelRefundReason,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
calls := counting.refundCalls()
|
||||
require.Len(t, calls, 1, "exactly one Square refund: the outstanding difference")
|
||||
assert.Equal(t, int64(2000), calls[0].Amount, "only the £20 difference may be re-issued")
|
||||
assert.Equal(t, paymentID+"-gccancel-diff-2000", calls[0].IdempotencyKey, "a fresh deterministic key for the difference")
|
||||
|
||||
var amount float64
|
||||
var status, key string
|
||||
var sqRefundID sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT amount, status, idempotency_key, square_refund_id FROM refunds WHERE payment_id = $1
|
||||
`, paymentID).Scan(&amount, &status, &key, &sqRefundID))
|
||||
assert.Equal(t, 20.00, amount, "the refund row records the re-issued £20 difference")
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Equal(t, paymentID+"-gccancel-diff-2000", key, "the row carries the fresh difference key")
|
||||
require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the new Square refund id must be recorded")
|
||||
assert.NotEqual(t, prelanded.ID, sqRefundID.String, "the row must carry the NEW refund id, not the pre-landed one")
|
||||
|
||||
var rem float64
|
||||
var expiry sql.NullTime
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
|
||||
assert.Equal(t, 0.00, rem, "the card must be zeroed once the balance of the entitlement is refunded")
|
||||
require.True(t, expiry.Valid)
|
||||
assert.False(t, expiry.Time.After(clock.Now()), "the card must be expired")
|
||||
}
|
||||
|
||||
// TestRound10_CancelGiftCard_HappyPath_NoRegression pins the fresh-cancellation
|
||||
// path (no prior refund row): the resume rewrite must not disturb the happy
|
||||
// path — one Square refund for the full value, the row completed, the card
|
||||
// neutralised.
|
||||
func TestRound10_CancelGiftCard_HappyPath_NoRegression(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
counting := &countingRefundClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 5000)
|
||||
require.Equal(t, http.StatusCreated, code, "buy must succeed")
|
||||
require.NotEmpty(t, cardID)
|
||||
|
||||
var payID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL
|
||||
`, userID).Scan(&payID))
|
||||
|
||||
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, payID)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "success")
|
||||
|
||||
calls := counting.refundCalls()
|
||||
require.Len(t, calls, 1, "exactly one Square refund for the fresh cancellation")
|
||||
assert.Equal(t, int64(5000), calls[0].Amount, "the full purchase value must be refunded in pence")
|
||||
|
||||
var status string
|
||||
var amount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status, amount FROM refunds WHERE payment_id = $1`, payID).Scan(&status, &amount))
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Equal(t, 50.00, amount)
|
||||
|
||||
var rem float64
|
||||
var expiry sql.NullTime
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
|
||||
assert.Equal(t, 0.00, rem, "the card must be zeroed after cancellation")
|
||||
require.True(t, expiry.Valid)
|
||||
assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// withNonGuest wraps a payment handler in the RequireNonGuest middleware, the
|
||||
// same chain main.go mounts for user-facing money routes (after RequireAuth).
|
||||
func withNonGuest(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return mw.RequireNonGuest(handler).ServeHTTP
|
||||
}
|
||||
|
||||
// createGuestBooking sets up a guest user with an in_progress booking so a
|
||||
// charge would otherwise succeed if the guest guard did not block it.
|
||||
func createGuestBooking(t *testing.T, ctx context.Context, q db.Querier) (string, string) {
|
||||
guestID, err := fixtures.CreateTestGuestUser(q)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(q)
|
||||
require.NoError(t, err)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(q, guestID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = q.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return guestID, bookingID
|
||||
}
|
||||
|
||||
func TestGuestBlocked_CreateBookingPayment(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
guestID, bookingID := createGuestBooking(t, ctx, tx)
|
||||
guestToken := jwt.GenerateTestToken(guestID, "guest")
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "guest-block-payment-key",
|
||||
}
|
||||
|
||||
handler := withNonGuest(CreateBookingPayment)
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, guestToken, ctx)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for guest payment, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count); err != nil {
|
||||
t.Fatalf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 payment rows for blocked guest, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBlocked_CreateTipPayment(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
guestID, bookingID := createGuestBooking(t, ctx, tx)
|
||||
guestToken := jwt.GenerateTestToken(guestID, "guest")
|
||||
|
||||
cardToken := "cnon:tip-card"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := withNonGuest(CreateTipPayment)
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, guestToken, ctx)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for guest tip, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBlocked_BuyGiftCard(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
guestID, err := fixtures.CreateTestGuestUser(tx)
|
||||
require.NoError(t, err)
|
||||
guestToken := jwt.GenerateTestToken(guestID, "guest")
|
||||
|
||||
newToken := "cnon:test-card"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
IdempotencyKey: "guest-block-gc-key",
|
||||
}
|
||||
|
||||
handler := withNonGuest(BuyGiftCard)
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/giftcards/buy", req, guestToken, ctx)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for guest gift card purchase, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBlocked_VerifiedUserStillPays(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "guest-block-verified-key",
|
||||
}
|
||||
|
||||
handler := withNonGuest(CreateBookingPayment)
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for verified user through RequireNonGuest, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count); err != nil {
|
||||
t.Fatalf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 payment for verified user, got %d", count)
|
||||
}
|
||||
}
|
||||
+4519
-775
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// Round-10 adversarial tests: the synchronous charge path vs the Square
|
||||
// payment.completed webhook race. The webhook can win the booking FOR UPDATE
|
||||
// lock between the Square call returning and the sync path's post-charge
|
||||
// completion flip, resolving the payment row to 'completed' (and running the
|
||||
// booking-completion side-effects) first. These tests simulate the webhook
|
||||
// landing DURING the sync CreatePayment call and verify the sync path's
|
||||
// guarded flips (R10) no-op instead of re-flipping the row, overwriting the
|
||||
// ledger, or re-inserting split records. They follow the existing sequential
|
||||
// (non-parallel) conventions of the money-path tests.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// webhookRaceClient wraps the dev Square client to simulate the
|
||||
// payment.completed webhook landing DURING the synchronous CreatePayment call
|
||||
// — the exact race window the R10 guards close. On a COMPLETED charge it
|
||||
// resolves the just-created pending payment row for this booking to
|
||||
// 'completed' BEFORE the sync handler's own post-charge completion flip runs.
|
||||
// When simulateBookingCompletion is set it also completes the booking and
|
||||
// awards the loyalty stamp, mirroring the webhook's booking-completion
|
||||
// side-effects.
|
||||
type webhookRaceClient struct {
|
||||
square.SquareClient
|
||||
bookingID string
|
||||
userID string
|
||||
simulateBookingCompletion bool
|
||||
}
|
||||
|
||||
func (c *webhookRaceClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
res, err := c.SquareClient.CreatePayment(ctx, req)
|
||||
if err != nil || res == nil || res.Status != "COMPLETED" {
|
||||
return res, err
|
||||
}
|
||||
// The webhook completes the pending row for this charge (same booking +
|
||||
// idempotency key) before the sync handler's flip runs.
|
||||
if _, uErr := db.Conn.Exec(ctx, `
|
||||
UPDATE payments
|
||||
SET status = 'completed', square_payment_id = $1
|
||||
WHERE booking_id = $2 AND idempotency_key = $3 AND status = 'pending'
|
||||
`, res.SquarePayID, c.bookingID, req.IdempotencyKey); uErr != nil {
|
||||
log.Printf("webhookRaceClient: failed to simulate payment.completed webhook: %v", uErr)
|
||||
}
|
||||
if c.simulateBookingCompletion {
|
||||
if _, uErr := db.Conn.Exec(ctx, `
|
||||
UPDATE bookings SET status = 'completed', loyalty_stamp_awarded_at = NOW()
|
||||
WHERE id = $1
|
||||
`, c.bookingID); uErr != nil {
|
||||
log.Printf("webhookRaceClient: failed to simulate booking completion: %v", uErr)
|
||||
}
|
||||
if _, uErr := db.Conn.Exec(ctx, `UPDATE users SET loyalty_stamps = loyalty_stamps + 1 WHERE id = $1`, c.userID); uErr != nil {
|
||||
log.Printf("webhookRaceClient: failed to simulate loyalty award: %v", uErr)
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_WebhookCompletedFirst_NoLedgerOverwrite locks the
|
||||
// R10 fix on the synchronous online booking path: when the Square
|
||||
// payment.completed webhook wins the race and completes the pending payment
|
||||
// row (plus the booking side-effects) before the sync completion flip, the
|
||||
// sync path must NOT re-flip the already-completed row, OVERWRITE the primary
|
||||
// row's amount/payment_type/VAT with buildSplitRecords' carved values, or
|
||||
// re-insert split tip/balance rows. The response stays 200 (the payment IS
|
||||
// completed) and the booking side-effects are not double-run.
|
||||
func TestCreateBookingPayment_WebhookCompletedFirst_NoLedgerOverwrite(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// VAT-registered so a buggy split/VAT overwrite would visibly clear or
|
||||
// recompute the row's VAT fields.
|
||||
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Past-start booking (total £50, per the round-9 fixture), in_progress.
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &webhookRaceClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
bookingID: bookingID,
|
||||
userID: userID,
|
||||
simulateBookingCompletion: true,
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
// £55 'full' on a £50 past-start booking: buildSplitRecords would carve
|
||||
// [£50 booking, £5 tip]. The webhook completing the row FIRST must make
|
||||
// the sync flip a no-op — no tip split inserted, primary not overwritten.
|
||||
cardToken := "cnon:round10-webhook-first"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5500,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "round10-webhook-first-" + bookingID,
|
||||
ConfirmOverflowTip: true, // B12: post-start overflow requires explicit confirmation
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a webhook-resolved payment must still report success, body: %s", w.Body.String())
|
||||
|
||||
// Exactly ONE completed payment row — no duplicate split tip/balance rows.
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
||||
require.Equal(t, 1, payCount, "the webhook-completed row must NOT be re-split into duplicate tip/balance rows")
|
||||
|
||||
// The primary row keeps the charged (webhook) values — the sync flip
|
||||
// must not overwrite amount/payment_type with the split-carved values.
|
||||
var amount float64
|
||||
var paymentType string
|
||||
var sqPayID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT amount, payment_type, COALESCE(square_payment_id, '') FROM payments WHERE booking_id = $1
|
||||
`, bookingID).Scan(&amount, &paymentType, &sqPayID))
|
||||
require.InDelta(t, 55.0, amount, 0.001, "the primary row must keep the charged amount (webhook's value), not the split-carved £50")
|
||||
require.Equal(t, "full", paymentType, "the primary row must keep its original payment_type")
|
||||
require.NotEmpty(t, sqPayID, "the webhook-completed row must carry the Square payment id")
|
||||
|
||||
// Booking side-effects not double-run: the webhook completed the booking
|
||||
// and awarded one stamp; the sync path must not re-run completion.
|
||||
var bookingStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
||||
require.Equal(t, "completed", bookingStatus, "the booking must stay completed — the sync path must not touch it")
|
||||
|
||||
var stamps int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps))
|
||||
require.Equal(t, 1, stamps, "the loyalty stamp must be awarded exactly once (webhook's), not re-awarded by the sync path")
|
||||
|
||||
var discountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
require.Equal(t, 0, discountCount, "no campaign discount rows may be created by a no-op completion")
|
||||
}
|
||||
|
||||
// TestCreateTipPayment_WebhookCompletedFirst_NoDuplicateTip locks the R10 fix
|
||||
// on the tip path: when the webhook completes the pending tip row before the
|
||||
// sync completion flip, the sync path must not re-flip it or double-record the
|
||||
// tip — exactly one completed tip row exists and the response is 200.
|
||||
func TestCreateTipPayment_WebhookCompletedFirst_NoDuplicateTip(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
// The tip flow requires an existing completed payment on the booking.
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'online_square', 'completed', 50.00, $2, $3, NOW(), NOW())
|
||||
`, bookingID, "round10-tip-primary-"+bookingID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &webhookRaceClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
bookingID: bookingID,
|
||||
userID: userID,
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: strPtr("cnon:round10-tip-webhook-first"),
|
||||
IdempotencyKey: "round10-tip-webhook-first-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a webhook-resolved tip must still report success, body: %s", w.Body.String())
|
||||
|
||||
// Exactly ONE completed tip row — the sync flip must not create a second.
|
||||
var tipCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'
|
||||
`, bookingID).Scan(&tipCount))
|
||||
require.Equal(t, 1, tipCount, "the webhook-completed tip row must not be duplicated by the sync path")
|
||||
|
||||
var tipAmount float64
|
||||
var sqPayID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT amount, COALESCE(square_payment_id, '') FROM payments
|
||||
WHERE booking_id = $1 AND payment_type = 'tip'
|
||||
`, bookingID).Scan(&tipAmount, &sqPayID))
|
||||
require.InDelta(t, 5.0, tipAmount, 0.001, "the tip row must keep the charged amount")
|
||||
require.NotEmpty(t, sqPayID, "the webhook-completed tip row must carry the Square payment id")
|
||||
|
||||
// Total ledger: the pre-existing full payment + exactly one tip.
|
||||
var totalCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&totalCount))
|
||||
require.Equal(t, 2, totalCount, "the ledger must hold the full payment + exactly one tip row")
|
||||
}
|
||||
|
||||
// TestPostChargeRecheck_WebhookCompletedRow_NotClobberedWithFailed locks the
|
||||
// R10 fix in postChargeRecheck (charge_helpers.go): a charge landing on a
|
||||
// cancelled booking must not mark 'failed' a payment row the webhook already
|
||||
// resolved to 'completed'. The guarded failed-mark UPDATE no-ops on the
|
||||
// completed row, the 409 conflict is still returned, and the completed row
|
||||
// stays completed so the cancellation refund path (which computes refunds
|
||||
// from completed payments) can reverse the money.
|
||||
func TestPostChargeRecheck_WebhookCompletedRow_NotClobberedWithFailed(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
|
||||
// The booking was cancelled between the Square call and the recheck.
|
||||
_, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The webhook already completed the payment row.
|
||||
var paymentID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'online_square', 'completed', 55.00, 'pay_round10_webhook_resolved', $2, $3, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, bookingID, "round10-clobber-key", userID).Scan(&paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
recheckTx, bErr := db.Conn.Begin(ctx)
|
||||
require.NoError(t, bErr)
|
||||
defer func() { _ = recheckTx.Rollback(ctx) }()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
payable, pErr := postChargeRecheck(ctx, w, recheckTx, bookingID, paymentID, "COMPLETED", "pay_round10_webhook_resolved", "payment", "This booking is no longer accepting payments")
|
||||
require.NoError(t, pErr)
|
||||
require.False(t, payable, "a cancelled booking must not accept the completed payment")
|
||||
require.Equal(t, http.StatusConflict, w.Code, "the recheck must surface the 409 conflict")
|
||||
|
||||
// The completed row must survive the recheck — never clobbered to 'failed'.
|
||||
var status string
|
||||
var sqPayID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1`, paymentID).Scan(&status, &sqPayID))
|
||||
require.Equal(t, "completed", status, "a webhook-completed row must not be marked failed by the cancelled-booking recheck")
|
||||
require.Equal(t, "pay_round10_webhook_resolved", sqPayID, "the webhook's square_payment_id must survive untouched")
|
||||
|
||||
var bookingStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
||||
require.Equal(t, "client_cancelled", bookingStatus, "the booking must stay cancelled")
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// Round-9 money-fix tests: tip-record VAT exclusion (R11), hashed split
|
||||
// idempotency keys, cross-booking idempotency-key collisions, refund-then-repay
|
||||
// on the same payment type, the SCA token-like save-gate exemption, and the
|
||||
// non-COMPLETED Square payment classification. These tests exercise the shared
|
||||
// package mock and the handler integration paths, so they follow the existing
|
||||
// sequential (non-parallel) conventions of the money-path tests they guard.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCreateBookingPayment_VATApplied_OverflowTipSkipped locks the R11 fix: an
|
||||
// online full payment whose overflow carves a payment_type='tip' split record
|
||||
// must NOT have VAT applied to the tip portion. vat.go's single-source policy
|
||||
// excludes tip payments entirely (ApplyVATToBookingPayment), and every other
|
||||
// tip path (CreateTipPayment, the terminal sweep rescue) applies VAT to the
|
||||
// booking portion only — the post-charge split loop must match. A £55 charge on
|
||||
// a £50 past-start booking splits into [£50 booking portion, £5 tip]; the tip
|
||||
// record keeps vat_amount NULL while the booking portion gets VAT, and the two
|
||||
// records still partition the charged amount exactly.
|
||||
func TestCreateBookingPayment_VATApplied_OverflowTipSkipped(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// Enable VAT for this test's transaction (the handler routes through the
|
||||
// same per-test tx via the context savepoint, so the change is visible).
|
||||
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
|
||||
require.NoError(t, err)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:round9-vat-tip-overflow"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5500, // £55 on a £50 booking — £5 overflows into a tip record
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "round9-vat-tip-overflow-" + bookingID,
|
||||
ConfirmOverflowTip: true, // B12: a post-start overflow always requires explicit confirmation
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "overflow payment must succeed, body: %s", w.Body.String())
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT payment_type, amount, is_vat_applicable, vat_amount, net_amount
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
ORDER BY payment_type
|
||||
`, bookingID)
|
||||
require.NoError(t, err)
|
||||
defer rows.Close()
|
||||
|
||||
var bookingVAT, tipVAT sql.NullFloat64
|
||||
var bookingNet, tipNet sql.NullFloat64
|
||||
var bookingVATFlag, tipVATFlag bool
|
||||
var bookingAmount, tipAmount float64
|
||||
var gotBooking, gotTip bool
|
||||
for rows.Next() {
|
||||
var pt string
|
||||
var amt float64
|
||||
var applicable bool
|
||||
var vat, net sql.NullFloat64
|
||||
require.NoError(t, rows.Scan(&pt, &amt, &applicable, &vat, &net))
|
||||
switch pt {
|
||||
case "tip":
|
||||
gotTip = true
|
||||
tipAmount = amt
|
||||
tipVAT = vat
|
||||
tipVATFlag = applicable
|
||||
tipNet = net
|
||||
case "full":
|
||||
gotBooking = true
|
||||
bookingAmount = amt
|
||||
bookingVAT = vat
|
||||
bookingVATFlag = applicable
|
||||
bookingNet = net
|
||||
}
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
require.True(t, gotBooking, "the booking portion record must exist")
|
||||
require.True(t, gotTip, "the tip split record must exist")
|
||||
|
||||
// The tip portion is gratuity, never VAT-applicable.
|
||||
require.False(t, tipVATFlag, "tip record must never be VAT-applicable")
|
||||
require.False(t, tipVAT.Valid, "tip record must have vat_amount NULL — the fix skips it in the apply_vat_to_payment loop")
|
||||
require.False(t, tipNet.Valid, "tip record must have net_amount NULL")
|
||||
|
||||
// The booking portion carries VAT at the 20% rate (net = 50/1.2 = 41.67,
|
||||
// vat = 50 − 41.67 = 8.33).
|
||||
require.True(t, bookingVATFlag, "the booking portion must be VAT-applicable")
|
||||
require.True(t, bookingVAT.Valid, "booking portion vat_amount must be set")
|
||||
require.InDelta(t, 8.33, bookingVAT.Float64, 0.01, "£50 at 20% → £8.33 VAT")
|
||||
require.True(t, bookingNet.Valid, "booking portion net_amount must be set")
|
||||
|
||||
// The split records still partition the charged amount exactly.
|
||||
require.InDelta(t, 5500, int64((bookingAmount+tipAmount)*100), 0.5, "booking portion + tip must equal the £55 charge")
|
||||
require.InDelta(t, 50.0, bookingAmount, 0.001, "booking portion = the £50 booking value")
|
||||
require.InDelta(t, 5.0, tipAmount, 0.001, "tip portion = the £5 overflow")
|
||||
}
|
||||
|
||||
// TestSplitIdempotencyKey_DistinctBases_DistinctKeys locks the R12 fix for
|
||||
// splitIdempotencyKey: two distinct terminal base keys that share a long common
|
||||
// prefix (e.g. Square payment IDs differing only in their tail) must derive
|
||||
// DISTINCT keys. The old raw `base[:maxBase]` prefix cut collapsed such bases
|
||||
// onto the same key, 500-ing the second insert on the UNIQUE(idempotency_key)
|
||||
// index. The keys must also stay within the payments.idempotency_key VARCHAR(64)
|
||||
// column (and, via truncateIdempotencyKey, within Square's 45-char cap).
|
||||
func TestSplitIdempotencyKey_DistinctBases_DistinctKeys(t *testing.T) {
|
||||
// Both bases are long enough that a raw prefix cut would keep only the
|
||||
// identical leading bytes — the OLD code returned the same key for both.
|
||||
base1 := "pay-booking-000000000001-deposit-2500-pay_mock_12345678901234567890"
|
||||
base2 := "pay-booking-000000000001-deposit-2500-pay_mock_12345678901234567899"
|
||||
|
||||
k1 := splitIdempotencyKey(base1, "-split-tip")
|
||||
k2 := splitIdempotencyKey(base2, "-split-tip")
|
||||
|
||||
assert.NotEqual(t, k1, k2,
|
||||
"two distinct base keys differing only in their tail must derive distinct split keys — the raw prefix cut collapses them")
|
||||
// The split keys are DB-only (the Square charge already used the primary
|
||||
// key) — the binding constraint is the payments.idempotency_key VARCHAR(64)
|
||||
// column, and the hashed base keeps the whole key well under it.
|
||||
assert.LessOrEqual(t, len(k1), 64, "split key must fit the payments.idempotency_key VARCHAR(64) column")
|
||||
assert.LessOrEqual(t, len(k2), 64, "split key must fit the payments.idempotency_key VARCHAR(64) column")
|
||||
|
||||
// The derivation stays deterministic — a retry of the same base must
|
||||
// reproduce the same key (same-key dedup relies on it).
|
||||
assert.Equal(t, k1, splitIdempotencyKey(base1, "-split-tip"), "split key derivation must be deterministic")
|
||||
|
||||
// A short candidate is preserved verbatim (≤45 chars), so the existing
|
||||
// terminal-split tests that assert the "-split-tip" suffix still pass.
|
||||
short := splitIdempotencyKey("test-key", "-split-tip")
|
||||
assert.Equal(t, "test-key-split-tip", short, "a short candidate is returned verbatim")
|
||||
|
||||
// Distinct suffixes on the same base must stay distinct (the suffix is
|
||||
// part of the hashed candidate).
|
||||
tipped := splitIdempotencyKey(base1, "-split-tip")
|
||||
splitted := splitIdempotencyKey(base1, "-split-2")
|
||||
assert.NotEqual(t, tipped, splitted, "distinct split suffixes must derive distinct keys")
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_CrossBookingIdempotencyKey_CollisionRecovers locks
|
||||
// the R12 fix: a client-supplied idempotency_key that already belongs to a
|
||||
// payment on a DIFFERENT booking must NOT die on the global
|
||||
// UNIQUE(idempotency_key) index (500 → frontend same-key retry → forever 500).
|
||||
// Mirroring the A6 cross-user pattern (giftcards.go), the handler derives a
|
||||
// fresh deterministic key for the colliding booking and the charge succeeds.
|
||||
func TestCreateBookingPayment_CrossBookingIdempotencyKey_CollisionRecovers(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// Booking A pays first and burns the key.
|
||||
userA, bookingA, _ := setupTestData(t, ctx, tx)
|
||||
tokenA := jwt.GenerateUserToken(userA)
|
||||
const sharedKey = "client-key-reused-across-bookings"
|
||||
reqA := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: strPtr("cnon:round9-cross-booking-a"),
|
||||
IdempotencyKey: sharedKey,
|
||||
}
|
||||
wA := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingA+"/payment", reqA, tokenA, ctx)
|
||||
require.Equal(t, http.StatusOK, wA.Code, "booking A must charge, body: %s", wA.Body.String())
|
||||
|
||||
// Booking B (different user) reuses the SAME key. Before the fix this
|
||||
// 500'd on the UNIQUE constraint; after the fix it derives a fresh key and
|
||||
// charges cleanly.
|
||||
userB, bookingB, _ := setupTestData(t, ctx, tx)
|
||||
tokenB := jwt.GenerateUserToken(userB)
|
||||
reqB := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: strPtr("cnon:round9-cross-booking-b"),
|
||||
IdempotencyKey: sharedKey,
|
||||
}
|
||||
wB := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingB+"/payment", reqB, tokenB, ctx)
|
||||
require.Equal(t, http.StatusOK, wB.Code, "the cross-booking key reuse must recover with a fresh key, body: %s", wB.Body.String())
|
||||
|
||||
// Booking B got its own payment under a key distinct from the shared one.
|
||||
var bID string
|
||||
var bKey string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT id, idempotency_key FROM payments WHERE booking_id = $1 AND status = 'completed'
|
||||
`, bookingB).Scan(&bID, &bKey))
|
||||
require.NotEqual(t, sharedKey, bKey, "booking B must have been charged under a freshly derived key, not the collided one")
|
||||
assert.Contains(t, bKey, bookingB, "the fresh key must be derived from booking B's identity")
|
||||
|
||||
// Booking A's original payment is untouched and still keyed by the shared key.
|
||||
var aCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'`, bookingA, sharedKey).Scan(&aCount))
|
||||
require.Equal(t, 1, aCount, "booking A's payment must remain intact under the shared key")
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_RefundedDeposit_RepaySucceeds locks the R11 fix for
|
||||
// the existingCount check: a completed payment of the same non-partial type
|
||||
// whose money is no longer live (it has a completed/pending refund) must NOT
|
||||
// block a fresh equal-type charge — refund-then-repay is a legitimate flow
|
||||
// (deriveBookingPaymentIdempotencyKey already rotates past refunded rows).
|
||||
func TestCreateBookingPayment_RefundedDeposit_RepaySucceeds(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:round9-refund-repay"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "round9-refund-repay-first",
|
||||
}
|
||||
w1 := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w1.Code, "the initial deposit must succeed, body: %s", w1.Body.String())
|
||||
|
||||
var paymentID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'`, bookingID, req.IdempotencyKey).Scan(&paymentID))
|
||||
|
||||
// The deposit is refunded in full — a live (completed) refund row means its
|
||||
// money is no longer collectable, so the type slot must re-open.
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
||||
VALUES ($1, $2, 25.00, 'completed', 'admin refund', NOW())
|
||||
`, paymentID, bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Same type, fresh key: before the fix the existingCount check counted the
|
||||
// refunded completed deposit and 409'd; after the fix the refunded row is
|
||||
// excluded and the repay succeeds.
|
||||
req2 := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "round9-refund-repay-second",
|
||||
}
|
||||
w2 := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w2.Code, "a refunded deposit must re-open the type slot, body: %s", w2.Body.String())
|
||||
|
||||
var secondKeyCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'`, bookingID, req2.IdempotencyKey).Scan(&secondKeyCount))
|
||||
require.Equal(t, 1, secondKeyCount, "the refund-then-repay deposit must be recorded as completed")
|
||||
|
||||
// An UN-refunded completed deposit still blocks a second charge — the
|
||||
// double-charge protection must survive the change.
|
||||
req3 := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "round9-refund-repay-third",
|
||||
}
|
||||
w3 := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req3, userToken, ctx)
|
||||
require.Equal(t, http.StatusConflict, w3.Code, "an un-refunded completed deposit must still block a second equal-type charge")
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_SaveCard_SCATokenizeResult_Enforced2FA_PersistsCard
|
||||
// locks the R13 fix: in an enforced-2FA deployment, a genuine SCA tokenize-result
|
||||
// charge with save_card=true must skip the SAVE gate (the token was minted by a
|
||||
// tokenization flow that ran the STORE-intent SCA — the same token-like
|
||||
// exemption CreatePaymentMethod applies) and persist the card. Before the fix
|
||||
// the tokenForwardedToSquare=false gate refused 402, making save_card
|
||||
// unreachable in production.
|
||||
func TestCreateBookingPayment_SaveCard_SCATokenizeResult_Enforced2FA_PersistsCard(t *testing.T) {
|
||||
// Any non-mock SQUARE_ENVIRONMENT enforces 2FA (fail-closed).
|
||||
t.Setenv("REQUIRE_2FA", "true")
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
// A genuine tokenize-result token (cnon:sca-...) for a NEW card — no saved
|
||||
// card reference, so scaTokenizedSavedCard is false and the SAVE gate runs.
|
||||
cardToken := "cnon:sca-round9-save-new-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "round9-sca-save-enforced-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a token-like SCA save must skip the 2FA gate in an enforced deployment, body: %s", w.Body.String())
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
||||
require.Equal(t, 1, cardCount, "the SCA tokenize-result save_card=true charge must persist the card in an enforced env")
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_NonCompletedSquareStatus_StaysPending locks the R11
|
||||
// fix: a CreatePayment that returns nil error but a non-COMPLETED status must
|
||||
// NOT be recorded as completed. APPROVED/PENDING are non-terminal (mirroring
|
||||
// the sweep's staleReconcileLeavePending — the row stays pending for a later
|
||||
// sweep run to re-poll); the handler returns 5xx and no split/completed
|
||||
// side-effects run.
|
||||
func TestCreateBookingPayment_NonCompletedSquareStatus_StaysPending(t *testing.T) {
|
||||
for _, status := range []string{"PENDING", "APPROVED"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
mock.ForcePaymentStatus = status
|
||||
SquareClient = mock
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: strPtr("cnon:round9-nonterminal-" + status),
|
||||
IdempotencyKey: "round9-nonterminal-" + status + "-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code, "%q must NOT be reported as success, body: %s", status, w.Body.String())
|
||||
|
||||
var rowStatus string
|
||||
var rowCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&rowStatus))
|
||||
require.Equal(t, "pending", rowStatus, "a non-terminal %q payment must stay pending (staleReconcileLeavePending semantics)", status)
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&rowCount))
|
||||
require.Equal(t, 1, rowCount, "no split records may be created for a non-completed payment")
|
||||
|
||||
// The booking is untouched — no promotion/completion side-effects.
|
||||
var bookingStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
||||
require.Equal(t, "in_progress", bookingStatus, "a non-terminal payment must not promote or complete the booking")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_TerminalNonSuccessSquareStatus_MarksFailed locks the
|
||||
// R11 fix for terminal non-success statuses: a CANCELED/FAILED payment never
|
||||
// landed at Square, so the row is marked failed (a same-key retry can never
|
||||
// issue a second charge under the key) and the handler surfaces the failure.
|
||||
func TestCreateBookingPayment_TerminalNonSuccessSquareStatus_MarksFailed(t *testing.T) {
|
||||
for _, status := range []string{"CANCELED", "FAILED"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
mock.ForcePaymentStatus = status
|
||||
SquareClient = mock
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: strPtr("cnon:round9-terminal-" + status),
|
||||
IdempotencyKey: "round9-terminal-" + status + "-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code, "%q must surface as a charge failure, body: %s", status, w.Body.String())
|
||||
|
||||
var rowStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&rowStatus))
|
||||
require.Equal(t, "failed", rowStatus, "a terminal non-success %q payment must be marked failed", status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTipPayment_NonCompletedSquareStatus_StaysPending locks the R11 fix
|
||||
// on the tip path too: a tip charge that returns non-COMPLETED must stay pending
|
||||
// (never flipped to completed), so the sweep can reconcile it.
|
||||
func TestCreateTipPayment_NonCompletedSquareStatus_StaysPending(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
mock.ForcePaymentStatus = "PENDING"
|
||||
SquareClient = mock
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
// The tip flow requires an existing completed payment on the booking.
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'online_square', 'completed', 50.00, $2, $3, NOW(), NOW())
|
||||
`, bookingID, "round9-tip-primary-"+bookingID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: strPtr("cnon:round9-tip-nonterminal"),
|
||||
IdempotencyKey: "round9-tip-nonterminal-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code, "a non-COMPLETED tip must not be reported as success, body: %s", w.Body.String())
|
||||
|
||||
var rowStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&rowStatus))
|
||||
require.Equal(t, "pending", rowStatus, "a non-terminal tip payment must stay pending for the sweep to reconcile")
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// Tests for the C2 till-vs-online advisory-lock serialization fix and the
|
||||
// SCA tokenize-result wire contract (new_card_token + saved_card_id on
|
||||
// CreateBookingPayment).
|
||||
//
|
||||
// These tests COMMIT their setup so the advisory locks work across independent
|
||||
// pool connections (see cleanupConcurrentTestRows in concurrency_test.go for
|
||||
// the leak-free deletion order). They swap the SquareClient global and run
|
||||
// lock-bound waits (~3s each), so they are sequential — never t.Parallel.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// enteredCreatePaymentClient blocks inside CreatePayment until the caller
|
||||
// confirms entry (closing entered), then delays `delay` before forwarding.
|
||||
// Because the online handler holds the crussell:payment:<bookingID> advisory
|
||||
// lock from BEFORE the pending insert through to AFTER CreatePayment returns,
|
||||
// a closed entered channel proves the lock is provably held — letting the test
|
||||
// deterministically fire a concurrent till cash payment into that window.
|
||||
type enteredCreatePaymentClient struct {
|
||||
square.SquareClient
|
||||
entered chan struct{}
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (c *enteredCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
select {
|
||||
case <-c.entered:
|
||||
default:
|
||||
close(c.entered)
|
||||
}
|
||||
time.Sleep(c.delay)
|
||||
return c.SquareClient.CreatePayment(ctx, req)
|
||||
}
|
||||
|
||||
// commitSetupTx commits the per-test transaction so the test operates at pool
|
||||
// level on independent connections (advisory locks only serialize across
|
||||
// independent connections; a shared per-test tx would mask the race).
|
||||
func commitSetupTx(t *testing.T, ctx context.Context) {
|
||||
t.Helper()
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, innerTx, "no transaction in context")
|
||||
require.NoError(t, innerTx.Commit(ctx))
|
||||
}
|
||||
|
||||
// TestTerminalCash_BlockedByConcurrentOnlineCharge pins the C2 fix: while an
|
||||
// online CreateBookingPayment charge holds the crussell:payment:<bookingID>
|
||||
// advisory lock across its Square round-trip, a till CASH payment on the SAME
|
||||
// booking must fail fast with 409 — never pass its remaining-balance check and
|
||||
// record a second amount (which buildSplitRecords would carve into a
|
||||
// non-refundable tip). Before the fix the till branch serialized only on the
|
||||
// bookings-row FOR UPDATE (a DIFFERENT primitive the online path never takes),
|
||||
// so both charges would land and the overflow became a silent tip.
|
||||
func TestTerminalCash_BlockedByConcurrentOnlineCharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
commitSetupTx(t, ctx)
|
||||
|
||||
// The online charge holds the lock for ~4s (longer than the ~3s bounded
|
||||
// try-lock), so the concurrent till cash request is guaranteed to hit a
|
||||
// contended lock and give up with 409.
|
||||
origClient := SquareClient
|
||||
slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
cardToken := "cnon:c2-online-inflight"
|
||||
onlineReq := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "c2-online-inflight-" + bookingID,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var onlineRec *httptest.ResponseRecorder
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool)
|
||||
}()
|
||||
|
||||
// Block until the online charge is inside the Square call — the advisory
|
||||
// lock is provably held from that point until the handler returns.
|
||||
<-slow.entered
|
||||
|
||||
cashReq := CreateTerminalPaymentRequest{
|
||||
Amount: 2000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("cash"),
|
||||
}
|
||||
cashRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", cashReq, adminToken, pool)
|
||||
require.Equal(t, http.StatusConflict, cashRec.Code,
|
||||
"till cash payment must 409 while an online charge holds the payment lock, body: %s", cashRec.Body.String())
|
||||
|
||||
wg.Wait()
|
||||
require.Equal(t, http.StatusOK, onlineRec.Code, "the online charge must succeed once the till payment was blocked: %s", onlineRec.Body.String())
|
||||
|
||||
// No cash payment row, no tip row (the till payment was never recorded),
|
||||
// and the booking's real-money ledger is exactly the online charge.
|
||||
assertNoTillMoneyRecorded(t, pool, bookingID, 5000)
|
||||
}
|
||||
|
||||
// TestTerminalGiftCard_BlockedByConcurrentOnlineCharge is the gift-card half
|
||||
// of the C2 fix: the till GIFT-CARD branch takes the same advisory lock, so a
|
||||
// concurrent online charge blocks it too (the pre-fix FOR UPDATE only
|
||||
// serialized against other row-lock holders).
|
||||
func TestTerminalGiftCard_BlockedByConcurrentOnlineCharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
commitSetupTx(t, ctx)
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
cardToken := "cnon:c2-online-inflight-gc"
|
||||
onlineReq := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "c2-online-inflight-gc-" + bookingID,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var onlineRec *httptest.ResponseRecorder
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool)
|
||||
}()
|
||||
|
||||
<-slow.entered
|
||||
|
||||
gcReq := CreateTerminalPaymentRequest{
|
||||
Amount: 2000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("giftcard"),
|
||||
GiftCardID: strPtr("GC-0000-0000-0000"),
|
||||
}
|
||||
gcRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", gcReq, adminToken, pool)
|
||||
require.Equal(t, http.StatusConflict, gcRec.Code,
|
||||
"till gift-card payment must 409 while an online charge holds the payment lock, body: %s", gcRec.Body.String())
|
||||
|
||||
wg.Wait()
|
||||
require.Equal(t, http.StatusOK, onlineRec.Code, onlineRec.Body.String())
|
||||
|
||||
assertNoTillMoneyRecorded(t, pool, bookingID, 5000)
|
||||
}
|
||||
|
||||
// TestOnlineCharge_BlockedWhileTillHoldsLock is the reverse direction of the
|
||||
// C2 fix: while the till cash/giftcard branch holds the SAME advisory lock
|
||||
// (here held directly to deterministically simulate a till charge in flight),
|
||||
// an online CreateBookingPayment charge must fail fast with 409 — the two
|
||||
// paths now contend on one primitive instead of passing their independent
|
||||
// remaining-balance checks.
|
||||
func TestOnlineCharge_BlockedWhileTillHoldsLock(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
commitSetupTx(t, ctx)
|
||||
|
||||
pool := context.Background()
|
||||
key := "crussell:payment:" + bookingID
|
||||
|
||||
// Simulate a till cash/giftcard charge in flight: hold the same advisory
|
||||
// lock on a dedicated connection for longer than the ~3s try-lock bound.
|
||||
holder, err := db.Conn.Acquire(pool)
|
||||
require.NoError(t, err)
|
||||
defer holder.Release()
|
||||
_, err = holder.Exec(pool, `SELECT pg_advisory_lock(hashtext($1))`, key)
|
||||
require.NoError(t, err)
|
||||
defer func() { _, _ = holder.Exec(pool, `SELECT pg_advisory_unlock(hashtext($1))`, key) }()
|
||||
|
||||
cardToken := "cnon:c2-online-blocked"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2000,
|
||||
PaymentType: "partial",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "c2-online-blocked-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, pool)
|
||||
require.Equal(t, http.StatusConflict, w.Code,
|
||||
"online charge must 409 while the till holds the payment lock, body: %s", w.Body.String())
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
||||
require.Zero(t, payCount, "no payment record may exist when the online charge was blocked")
|
||||
}
|
||||
|
||||
// assertNoTillMoneyRecorded pins the "not silently double-record" half of C2:
|
||||
// after a till cash/giftcard payment was blocked, the booking must carry no
|
||||
// till-originated row, no tip row (the pre-fix double-charge carved the
|
||||
// overflow into a non-refundable tip), and exactly wantPence of real-money
|
||||
// completed payments.
|
||||
func assertNoTillMoneyRecorded(t *testing.T, ctx context.Context, bookingID string, wantPence int64) {
|
||||
t.Helper()
|
||||
var cashRows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&cashRows))
|
||||
require.Zero(t, cashRows, "the blocked till cash payment must not be recorded")
|
||||
var gcRows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&gcRows))
|
||||
require.Zero(t, gcRows, "the blocked till gift-card payment must not be recorded")
|
||||
var tipRows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipRows))
|
||||
require.Zero(t, tipRows, "no non-refundable tip row may be minted by the blocked till payment")
|
||||
var paidPence int64
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `
|
||||
SELECT COALESCE(ROUND(SUM(amount) * 100), 0) FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'
|
||||
AND payment_method NOT IN ('discount', 'on_the_house')
|
||||
`, bookingID).Scan(&paidPence))
|
||||
require.Equal(t, wantPence, paidPence, "the booking ledger must reflect exactly the online charge")
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_SCATokenizeResult_UsedAsSource pins the SCA wire
|
||||
// contract: a saved-card charge carrying new_card_token (the SCA
|
||||
// tokenize-result from card.tokenize(verificationDetails, cardId)) plus
|
||||
// saved_card_id must call Square with source_id = the tokenize-result token —
|
||||
// NOT the stored ccof id — and with customer_id derived from the saved card,
|
||||
// and must NOT require a verification_token for the new path.
|
||||
func TestCreateBookingPayment_SCATokenizeResult_UsedAsSource(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-test", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
token := "cnon:sca-tokenize-result"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &token,
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "sca-tokenize-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
last := rec.lastReq
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, token, last.SourceID, "the SCA tokenize-result token must be the charge source_id")
|
||||
require.NotEqual(t, "ccof:sca-tokenize-test", last.SourceID, "the stored ccof id must NOT be the source for the tokenize-result flow")
|
||||
require.NotEmpty(t, last.CustomerID, "customer_id must derive from the saved card row")
|
||||
require.Empty(t, last.VerificationToken, "the tokenize-result flow must not require a legacy verification_token")
|
||||
|
||||
// The recorded payment row must carry the token as its square source and
|
||||
// reference the saved card row.
|
||||
var squareSource, uscID sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT square_source_id, user_saved_card_id FROM payments WHERE booking_id = $1`, bookingID).Scan(&squareSource, &uscID))
|
||||
require.Equal(t, token, squareSource.String, "the payment row must record the tokenize-result token as its square source")
|
||||
require.Equal(t, cardID, uscID.String, "the payment row must reference the saved card")
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate pins that the SCA
|
||||
// tokenize-result flow is SCA-primary: with 2FA enforced, a charge carrying
|
||||
// new_card_token + saved_card_id (no verification_code, no verification_token)
|
||||
// succeeds — the tokenize-result token itself proves buyer verification, so no
|
||||
// homegrown fallback authorization is demanded.
|
||||
func TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-2fa", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
installRecordingClient(t)
|
||||
|
||||
token := "cnon:sca-tokenize-2fa"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &token,
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "sca-tokenize-2fa-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code,
|
||||
"an SCA tokenize-result charge must skip the 2FA gate, body: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog locks the M8 fix
|
||||
// for the SYNC path: when the RefundPayment handler's FIRST Square attempt
|
||||
// returns COMPLETED immediately (no pending row for the sweep to re-issue), the
|
||||
// synchronous terminal-success path must write the SAME admin_audit_log row the
|
||||
// sweep's re-issue writes — action 'admin_refund', admin actor, payment id,
|
||||
// pence amount and reason, via the shared insertManualRefundAudit helper.
|
||||
// The row is marked completed so the sweep (which only processes still-pending
|
||||
// rows) can never re-process this refund, guaranteeing exactly one audit row.
|
||||
// Mirrors TestManualRefund_IssuesAdminAuditLog (refunds_test.go) but drives
|
||||
// the handler end-to-end instead of the sweep.
|
||||
func TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog(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)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
// The mock refunds any non-"pay_mock_" id leniently and returns COMPLETED
|
||||
// synchronously, so the handler's FIRST attempt completes immediately.
|
||||
const chargeID = "sqp_m8_sync_audit"
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The audit insert runs in its OWN transaction via InsertAdminAuditCharge
|
||||
// (never the per-test tx), and the refund's advisory-lock serialization
|
||||
// needs pool-level rows — commit the setup so the sync path executes at
|
||||
// pool level, exactly like the sweep test.
|
||||
commitSetupTx(t, ctx)
|
||||
freshCtx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_audit_log WHERE action_type = 'admin_refund' AND admin_id = $1`, adminID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
|
||||
})
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
req := RefundRequest{Amount: 5000, Reason: "customer request"}
|
||||
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, freshCtx)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "synchronous refund should complete 200, body: %s", rec.Body.String())
|
||||
|
||||
var resp RefundResponse
|
||||
require.NoError(t, parsePaymentResponseBody(rec, &resp))
|
||||
require.Equal(t, "completed", resp.Status, "the synchronous COMPLETED refund must resolve to 'completed'")
|
||||
|
||||
// The refund row must be completed so the sweep can never re-process it
|
||||
// (and duplicate the audit).
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, resp.ID).Scan(&status))
|
||||
require.Equal(t, "completed", status)
|
||||
|
||||
// Exactly one admin_refund audit row, carrying payment id, pence amount and
|
||||
// reason.
|
||||
var auditCount int
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `
|
||||
SELECT COUNT(*) FROM admin_audit_log
|
||||
WHERE admin_id = $1 AND action_type = 'admin_refund'
|
||||
`, adminID).Scan(&auditCount))
|
||||
require.Equal(t, 1, auditCount, "expected exactly 1 'admin_refund' audit row for the synchronous refund")
|
||||
var details string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `
|
||||
SELECT details::text FROM admin_audit_log
|
||||
WHERE admin_id = $1 AND action_type = 'admin_refund'
|
||||
`, adminID).Scan(&details))
|
||||
for _, want := range []string{paymentID, "5000", "customer request"} {
|
||||
require.True(t, strings.Contains(details, want), "expected audit details to carry %q, got %s", want, details)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds
|
||||
// pins the M11 fix: in an ENFORCED (SCA-only) deployment a card save that
|
||||
// carries a GENUINE SCA tokenize-result as card_token succeeds — the
|
||||
// STORE-intent SCA performed at tokenization (SquareCardInput.tokenizeForStore)
|
||||
// IS the verification (PSR 2017 reg 100), so the save skips the 2FA gate at the
|
||||
// call site exactly like the charge surfaces' scaTokenizedSavedCard path, and
|
||||
// the card is persisted. Before the fix the handler demanded the (removed) 2FA
|
||||
// fallback and refused every save 402 verification_required, so add-card could
|
||||
// not complete in an enforced deployment.
|
||||
func TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := CreatePaymentMethodRequest{CardToken: "cnon:sca-tokenize-store"}
|
||||
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code,
|
||||
"a genuine SCA tokenize-result card save must succeed in an enforced deployment, body: %s", w.Body.String())
|
||||
|
||||
var card SavedCard
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
require.NotEmpty(t, card.ID, "the saved card must be returned")
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
||||
require.Equal(t, 1, cardCount, "the SCA-compliant save must persist exactly one card")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402 pins
|
||||
// auth-F1 on the add-card surface: a verification_token is CLIENT-ASSERTED and
|
||||
// never forwarded to Square on a SAVE surface (CreateCardOnFile takes no token),
|
||||
// so it must NOT skip the gate — enforced + a NON-token-like card_token (a raw
|
||||
// PAN — the only shape the gate still refuses) + a (forged) non-empty
|
||||
// verification_token is refused 402 verification_required and no card is
|
||||
// persisted. The M11 fix cannot be a client-asserted token bypass.
|
||||
func TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
forged := "forged-verification-token"
|
||||
req := CreatePaymentMethodRequest{
|
||||
CardToken: "4111111111111111",
|
||||
VerificationToken: &forged,
|
||||
}
|
||||
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code,
|
||||
"a forged verification_token must not skip the save gate, 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"])
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
||||
require.Zero(t, cardCount, "a refused forged-token save must not persist a card")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402 pins the M11
|
||||
// refusal half in the SCA suite's own staging helper: a NON-token-like save
|
||||
// source (a raw PAN — the gate's only remaining refusal shape) is refused 402
|
||||
// verification_required in an enforced deployment and no card is persisted. A
|
||||
// GENUINE token-like source (cnon:/ccof:) is SCA-proven — the STORE-intent SCA
|
||||
// ran at tokenization — and skips the gate (see
|
||||
// TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds).
|
||||
// Companion to TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_402
|
||||
// (errors_test.go).
|
||||
func TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
req := CreatePaymentMethodRequest{CardToken: "4111111111111111"}
|
||||
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code,
|
||||
"a token-less save must be refused 402 in an enforced deployment, 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"])
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
||||
require.Zero(t, cardCount, "a refused token-less save must not persist a card")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"crussell/internal/square"
|
||||
)
|
||||
|
||||
// maxIdempotencyKeyLength caps idempotency keys at Square's /v2/payments limit
|
||||
// (45 chars). The same key is replayed to CreatePayment, so the stricter
|
||||
// 45-char cap applies even where a destination (e.g. CreateCheckout) allows 64.
|
||||
// Client-supplied keys are validated against it ("omitempty,max=45") and
|
||||
// server-derived keys are truncated to it via truncateIdempotencyKey.
|
||||
// Aliased from the square package — the client to Square, whose limit this is —
|
||||
// so there is a single source of the constant, not a per-package drift surface.
|
||||
const maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength
|
||||
|
||||
// truncateIdempotencyKey applies the deterministic >45-char sha256 truncation
|
||||
// shared by the derive* idempotency-key helpers: a candidate longer than
|
||||
// maxIdempotencyKeyLength is hashed with SHA-256 and returned as
|
||||
// "<prefix>-<hex of the first 16 hash bytes>", which stays within Square's
|
||||
// 45-char /v2/payments limit. The hash is deterministic, so identical
|
||||
// candidates always truncate to the same key — a lost-response retry re-derives
|
||||
// the same truncated key and Square dedups the charge. Candidates at or under
|
||||
// the limit are returned verbatim.
|
||||
func truncateIdempotencyKey(prefix, candidate string) string {
|
||||
if len(candidate) <= maxIdempotencyKeyLength {
|
||||
return candidate
|
||||
}
|
||||
sum := sha256.Sum256([]byte(candidate))
|
||||
return prefix + "-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// deriveRefundIdempotencyKey returns the deterministic SERVER-SIDE idempotency
|
||||
// key for a refund issued WITHOUT a client-supplied key (M1): a retry of the
|
||||
// same logical refund re-derives the SAME key, so Square's idempotency dedup
|
||||
// returns the original refund instead of minting a SECOND Square refund — even
|
||||
// after the sweep has resolved the first attempt (a client keyed to
|
||||
// (payment_id, amount, refund type) can never regenerate the fresh random
|
||||
// suffix the old no-key fallback used). The key is derived ONLY from stable
|
||||
// request fields — never a random value — and routed through
|
||||
// truncateIdempotencyKey so an over-length candidate stays deterministic and
|
||||
// inside Square's 45-char /v2/refunds limit (preserving its semantics). The
|
||||
// distinct refundType (e.g. "manual" vs "cancellation") keeps a partial refund
|
||||
// of the same payment+amount distinct from a cancellation refund of the same
|
||||
// size, and the paymentID prefix prevents cross-payment collisions.
|
||||
func deriveRefundIdempotencyKey(paymentID string, amountPence int64, refundType string) string {
|
||||
candidate := paymentID + "-refund-" + strconv.FormatInt(amountPence, 10) + "-" + refundType
|
||||
return truncateIdempotencyKey("refund", candidate)
|
||||
}
|
||||
|
||||
// nextIdempotencyCandidate returns the idempotency-key candidate for slot
|
||||
// sequence seq: the base key itself at seq 0, or "base-seq" at seq >= 1, then
|
||||
// truncated via truncateIdempotencyKey so the final key stays inside Square's
|
||||
// 45-char /v2/payments limit. The truncation prefix is derived from the base
|
||||
// key ("gc-..." -> "gc", "till-..." -> "till") so the truncated form keeps the
|
||||
// caller's namespace prefix. The slot-scan callers (scanTillIdempotencyKeySlot,
|
||||
// deriveGiftCardIdempotencyKey) use this under their advisory lock so the
|
||||
// scan-and-insert sequence is stable across retries.
|
||||
func nextIdempotencyCandidate(base string, seq int) string {
|
||||
candidate := base
|
||||
if seq > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", base, seq)
|
||||
}
|
||||
prefix := base
|
||||
if i := strings.IndexByte(base, '-'); i > 0 {
|
||||
prefix = base[:i]
|
||||
}
|
||||
return truncateIdempotencyKey(prefix, candidate)
|
||||
}
|
||||
|
||||
// scanIdempotencySlot iterates the candidate sequence for baseKey (seq 0, 1,
|
||||
// 2, ...) until it finds a slot NOT occupied by a terminal row, returning the
|
||||
// first free candidate. occupied reports whether the candidate is taken; the
|
||||
// caller supplies the table-specific occupancy check. Shared by the gift-card
|
||||
// purchase key derivation (deriveGiftCardIdempotencyKey) and the till-sale key
|
||||
// derivation (scanTillIdempotencyKeySlot), which must agree on the
|
||||
// completed/failed-occupies, pending-never-occupies rule so a lost-response
|
||||
// retry reuses the same key instead of minting a second charge. Must be called
|
||||
// under the caller's advisory lock so the scan-and-insert races no concurrent
|
||||
// identical request.
|
||||
func scanIdempotencySlot(ctx context.Context, baseKey string, occupied func(candidate string) (bool, error)) (string, error) {
|
||||
for seq := 0; ; seq++ {
|
||||
candidate := nextIdempotencyCandidate(baseKey, seq)
|
||||
isOccupied, err := occupied(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isOccupied {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects
|
||||
// the dev/mock Square stack. Only these exact values are treated as dev; an
|
||||
// empty or unknown value is NOT dev (fail-closed), because in production an
|
||||
// unset/mistyped env var must never bypass the 2FA gate or decrypt/encrypt
|
||||
// snapshot expectations (A9). It lives here — the neutral idempotency helper
|
||||
// file — because it gates far more than 2FA: snapshot encryption
|
||||
// (charge_helpers.go), the sweep's replay checks and snapshot decryption
|
||||
// (sweep.go), the till snapshot refresh (till.go), the gift-card reuse
|
||||
// snapshot handling (giftcards.go), and main.go's startup warnings. The
|
||||
// exported name is stable for main.go; in-package callers use it directly.
|
||||
func IsExplicitDevOrMockEnv() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT"))) {
|
||||
case "mock", "dev", "development", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"crussell/internal/square"
|
||||
)
|
||||
|
||||
// TestDeriveRefundIdempotencyKey_Deterministic locks the M1 property: the
|
||||
// server-side refund key derived from (payment_id, amount pence, refund type)
|
||||
// is DETERMINISTIC — a retry of the same logical refund re-derives the SAME
|
||||
// key, so Square's idempotency dedup returns the original refund instead of
|
||||
// minting a second one.
|
||||
func TestDeriveRefundIdempotencyKey_Deterministic(t *testing.T) {
|
||||
key1 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
|
||||
key2 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
|
||||
if key1 != key2 {
|
||||
t.Errorf("expected the derived refund key to be deterministic, got %q vs %q", key1, key2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveRefundIdempotencyKey_DistinguishesInputs locks the M1 discriminator
|
||||
// fields: the key MUST change when the payment, the amount, or the refund type
|
||||
// changes (a distinct partial refund of the same amount must never collide with
|
||||
// a cancellation refund of the same size).
|
||||
func TestDeriveRefundIdempotencyKey_DistinguishesInputs(t *testing.T) {
|
||||
base := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
|
||||
|
||||
otherPayment := deriveRefundIdempotencyKey("pay9999999999", 5000, "manual")
|
||||
if otherPayment == base {
|
||||
t.Errorf("expected a different payment id to produce a different refund key")
|
||||
}
|
||||
|
||||
otherAmount := deriveRefundIdempotencyKey("pay1234567890", 5001, "manual")
|
||||
if otherAmount == base {
|
||||
t.Errorf("expected a different amount to produce a different refund key")
|
||||
}
|
||||
|
||||
otherType := deriveRefundIdempotencyKey("pay1234567890", 5000, "cancellation")
|
||||
if otherType == base {
|
||||
t.Errorf("expected a different refund type to produce a different refund key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveRefundIdempotencyKey_RespectsSquareLimit locks the
|
||||
// truncateIdempotencyKey semantics: every derived key stays within Square's
|
||||
// 45-char /v2/refunds idempotency-key limit, even for an over-length candidate
|
||||
// (a long payment id / large amount), and the truncation stays deterministic.
|
||||
func TestDeriveRefundIdempotencyKey_RespectsSquareLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
paymentID string
|
||||
amount int64
|
||||
refundType string
|
||||
}{
|
||||
{"pay1234567890", 5000, "manual"},
|
||||
{"pay1234567890", 5000, "cancellation"},
|
||||
{"a-very-long-payment-id-that-exceeds-the-45-char-limit-when-combined", 999999999, "manual"},
|
||||
{"pay1234567890", 999999999, "a-refund-type-that-is-itself-quite-long"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
key := deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType)
|
||||
if len(key) > maxIdempotencyKeyLength {
|
||||
t.Errorf("derived refund key %q (%d chars) exceeds Square's %d-char limit", key, len(key), maxIdempotencyKeyLength)
|
||||
}
|
||||
if key != deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType) {
|
||||
t.Errorf("derived refund key %q is not deterministic across calls", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund locks
|
||||
// the M1 end-to-end dedup: a refund issued with the deterministic server-side
|
||||
// key, resolved by a sweep pass, and then RETRIED maps to the SAME Square refund
|
||||
// (the mock's refundByKey dedup returns the original) — a second Square refund
|
||||
// is never minted. This is the mechanism the refund issuance uses so a
|
||||
// no-client-key retry after sweep resolution dedups onto the first refund.
|
||||
func TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund(t *testing.T) {
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
payment, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "seed-refund-dedup-payment",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed the payment at the mock: %v", err)
|
||||
}
|
||||
|
||||
paymentID := "payrefund000001"
|
||||
amountPence := int64(5000)
|
||||
// The no-client-key refund issues with the deterministic server-side key.
|
||||
key := deriveRefundIdempotencyKey(paymentID, amountPence, "manual")
|
||||
|
||||
first, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
|
||||
PaymentID: payment.SquarePayID,
|
||||
Amount: amountPence,
|
||||
IdempotencyKey: key,
|
||||
Reason: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first refund failed: %v", err)
|
||||
}
|
||||
|
||||
// After the sweep resolves the first attempt, the client retries the SAME
|
||||
// logical refund. The retry re-derives the SAME deterministic key, so the
|
||||
// mock's Square-style dedup returns the ORIGINAL refund — RefundKeyCount
|
||||
// stays 1 and no second Square refund is minted.
|
||||
retry, retryErr := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
|
||||
PaymentID: payment.SquarePayID,
|
||||
Amount: amountPence,
|
||||
IdempotencyKey: deriveRefundIdempotencyKey(paymentID, amountPence, "manual"),
|
||||
Reason: "test",
|
||||
})
|
||||
if retryErr != nil {
|
||||
t.Fatalf("retry after sweep resolution failed: %v", retryErr)
|
||||
}
|
||||
if first.ID != retry.ID {
|
||||
t.Errorf("expected the retry to dedup onto the original refund %s, got a different refund %s", first.ID, retry.ID)
|
||||
}
|
||||
if got := mock.RefundKeyCount(); got != 1 {
|
||||
t.Errorf("expected exactly ONE Square refund after the retry, got %d distinct refund keys", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsExplicitDevOrMockEnv_NormalizedPins the FIX 5 normalization: the env
|
||||
// value is lowercased and trimmed before comparison, so "Mock", " MOCK ",
|
||||
// "Production " (space), and "PROD" all map correctly. Empty/unknown stays
|
||||
// fail-closed (false).
|
||||
func TestIsExplicitDevOrMockEnv_Normalized(t *testing.T) {
|
||||
cases := []struct {
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
// Exact matches (unchanged behavior)
|
||||
{"mock", true},
|
||||
{"dev", true},
|
||||
{"development", true},
|
||||
{"test", true},
|
||||
// Case normalization
|
||||
{"Mock", true},
|
||||
{"MOCK", true},
|
||||
{"Dev", true},
|
||||
{"DEVELOPMENT", true},
|
||||
// Trailing/leading whitespace
|
||||
{" mock ", true},
|
||||
{" mock ", true},
|
||||
{"mock ", true},
|
||||
{"", false},
|
||||
{"production", false},
|
||||
{"PROD", false},
|
||||
{"Production ", false},
|
||||
{" PRODUCTION ", false},
|
||||
{"sandbox", false},
|
||||
{"staging", false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.env, func(t *testing.T) {
|
||||
t.Setenv("SQUARE_ENVIRONMENT", tc.env)
|
||||
got := IsExplicitDevOrMockEnv()
|
||||
if got != tc.want {
|
||||
t.Errorf("IsExplicitDevOrMockEnv(%q) = %v, want %v", tc.env, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// advisoryLockAttempts and advisoryLockRetryDelay bound the total time a
|
||||
// try-lock retry loop waits for a contended advisory lock (~3s). This is the
|
||||
// core defence against pool exhaustion: every payment handler pins a pool
|
||||
// connection and would otherwise block on `pg_advisory_lock` for the FULL
|
||||
// Square round-trip (up to ~30s) of whichever request holds the lock, so a
|
||||
// handful of concurrent same-key requests can exhaust the whole pool
|
||||
// (max(4, numCPU)) and hang the app. With a bounded try-lock loop the waiter
|
||||
// gives up after ~3s and surfaces "operation in progress" instead of holding a
|
||||
// pool connection hostage.
|
||||
const (
|
||||
advisoryLockAttempts = 30
|
||||
advisoryLockRetryDelay = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// queryRower is satisfied by both *pgxpool.Conn (session-level locks) and
|
||||
// pgx.Tx (transaction-scoped locks) so the try-lock helpers work on pinned
|
||||
// pool connections and inside transactions alike.
|
||||
type queryRower interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
// acquireAdvisoryLock acquires a session advisory lock on the pinned conn with
|
||||
// bounded retries (defends the pool against concurrent same-key lock waiters).
|
||||
// Returns (true, nil) once the lock is held; (false, nil) if the lock could
|
||||
// not be acquired within the bound — the caller must surface a 409/503
|
||||
// "operation in progress, try again" instead of blocking. The unlock is still
|
||||
// the caller's responsibility (pg_advisory_unlock on the same conn via defer).
|
||||
func acquireAdvisoryLock(ctx context.Context, conn *pgxpool.Conn, key string) (bool, error) {
|
||||
return tryAdvisoryLock(ctx, conn, key, "pg_try_advisory_lock")
|
||||
}
|
||||
|
||||
// releasePaymentLock releases a session advisory lock acquired by
|
||||
// acquireAdvisoryLock on the SAME pinned pool connection (pg_advisory_unlock
|
||||
// only releases locks held by the calling session). It is the generic release
|
||||
// counterpart to acquireAdvisoryLock, and callers defer it immediately after a
|
||||
// successful acquire so the unlock runs before the deferred pinConn.Release().
|
||||
// Errors are logged and otherwise ignored — exactly what the inline
|
||||
// `pg_advisory_unlock(hashtext('crussell:...:' || $1))` blocks it replaces did
|
||||
// — and the key must be the FULL "crussell:..." string that was hashed at
|
||||
// acquire time so the two hashtext() calls produce the same lock bigint.
|
||||
func releasePaymentLock(pinConn *pgxpool.Conn, lockKey string) {
|
||||
if _, err := pinConn.Exec(context.Background(), `
|
||||
SELECT pg_advisory_unlock(hashtext($1))
|
||||
`, lockKey); err != nil {
|
||||
log.Printf("Failed to release payment serialization lock %s: %v", lockKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// acquireAdvisoryXactLockBlocking is the transaction-scoped BLOCKING variant
|
||||
// of acquireAdvisoryLock: it issues `SELECT pg_advisory_xact_lock(...)`
|
||||
// ONCE and waits for as long as the key is contended — there is no 3s bound.
|
||||
// The lock is transaction-scoped, so it is auto-released at the caller's
|
||||
// commit/rollback (never unlocked explicitly).
|
||||
//
|
||||
// This stays deliberately blocking for ONE site only: the admin cancellation
|
||||
// path (lockCancellationPayments). Admin cancellations are rare and there is
|
||||
// only ONE such transaction at a time, so the pool-exhaustion rationale that
|
||||
// justifies the bounded try-lock everywhere else does not apply here. A bound
|
||||
// would be actively harmful: if a manual RefundPayment holds the same
|
||||
// "crussell:refund:" key across its up-to-30s Square round-trip, a timed-out
|
||||
// cancellation would abort, and the caller (manage.go) would commit the
|
||||
// cancellation with ZERO refund rows created — no sweep retry is possible
|
||||
// because the rows never existed, so the refund would be silently lost. The
|
||||
// blocking acquire guarantees the cancellation refund runs, waiting for the
|
||||
// manual refund to finish rather than dropping the money.
|
||||
func acquireAdvisoryXactLockBlocking(ctx context.Context, tx pgx.Tx, key string) error {
|
||||
// Exec, not QueryRow.Scan: pg_advisory_xact_lock returns void, and Exec
|
||||
// discards the result set (the same pattern the payment handlers use for
|
||||
// their blocking pg_advisory_lock) while still blocking server-side until
|
||||
// the lock is granted.
|
||||
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, key)
|
||||
return err
|
||||
}
|
||||
|
||||
// tryAdvisoryLock is the shared try-lock retry loop. fn is the Postgres
|
||||
// advisory-lock function to call (pg_try_advisory_lock or
|
||||
// pg_try_advisory_xact_lock). The key is passed to hashtext() so it is hashed
|
||||
// to a bigint exactly like the blocking `pg_advisory_lock(hashtext($1))` calls
|
||||
// it replaces — the two acquire the same locks.
|
||||
func tryAdvisoryLock(ctx context.Context, q queryRower, key, fn string) (bool, error) {
|
||||
// ONE reusable timer for the whole retry loop instead of time.After per
|
||||
// attempt: a contended lock spins ~30 times, and allocating a fresh timer
|
||||
// (with its own goroutine) on every attempt is wasteful. The timer is
|
||||
// Stop+drained before each Reset so a previously-fired tick can never make
|
||||
// the next wait return early (missed-tick semantics). Timing is preserved:
|
||||
// 100ms between attempts, ~3s total bound.
|
||||
timer := time.NewTimer(advisoryLockRetryDelay)
|
||||
defer timer.Stop()
|
||||
for attempt := 0; attempt < advisoryLockAttempts; attempt++ { // ~3s total
|
||||
var acquired bool
|
||||
if err := q.QueryRow(ctx, `SELECT `+fn+`(hashtext($1))`, key).Scan(&acquired); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if acquired {
|
||||
return true, nil
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(advisoryLockRetryDelay)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
)
|
||||
|
||||
// TestAdvisoryLock_Uncontended_Acquires verifies the happy path: an unlocked
|
||||
// key is acquired immediately with a bounded try-lock.
|
||||
func TestAdvisoryLock_Uncontended_Acquires(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
conn, err := db.Conn.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire pool connection: %v", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
key := "crussell:payment:locktest-uncontended"
|
||||
acquired, err := acquireAdvisoryLock(ctx, conn, key)
|
||||
if err != nil {
|
||||
t.Fatalf("acquireAdvisoryLock returned error: %v", err)
|
||||
}
|
||||
if !acquired {
|
||||
t.Fatal("expected uncontended lock to be acquired")
|
||||
}
|
||||
if _, err := conn.Exec(ctx, `SELECT pg_advisory_unlock(hashtext($1))`, key); err != nil {
|
||||
t.Fatalf("failed to release lock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvisoryLock_Contended_TimesOutWithinBound verifies the bounded try-lock
|
||||
// timeout branch: when another connection holds the same key, acquireAdvisoryLock
|
||||
// returns (false, nil) after the ~3s retry bound instead of blocking forever
|
||||
// (the pool-exhaustion defence).
|
||||
func TestAdvisoryLock_Contended_TimesOutWithinBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
holder, err := db.Conn.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire holder connection: %v", err)
|
||||
}
|
||||
defer holder.Release()
|
||||
|
||||
key := "crussell:payment:locktest-contended"
|
||||
// Hold the advisory lock on a dedicated connection so every try-lock
|
||||
// attempt from the second connection fails.
|
||||
if _, err := holder.Exec(ctx, `SELECT pg_advisory_lock(hashtext($1))`, key); err != nil {
|
||||
t.Fatalf("failed to acquire holder lock: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = holder.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, key)
|
||||
}()
|
||||
|
||||
waiter, err := db.Conn.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire waiter connection: %v", err)
|
||||
}
|
||||
defer waiter.Release()
|
||||
|
||||
start := time.Now()
|
||||
acquired, err := acquireAdvisoryLock(ctx, waiter, key)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("acquireAdvisoryLock returned error (expected false, nil): %v", err)
|
||||
}
|
||||
if acquired {
|
||||
t.Fatal("expected contended lock NOT to be acquired")
|
||||
}
|
||||
// The bound is 30 attempts × 100ms ≈ 3s. Assert it gave up within a sane
|
||||
// window (did not hang) and did not return prematurely.
|
||||
if elapsed < 2*time.Second {
|
||||
t.Errorf("expected the timeout bound (~3s) to elapse before giving up, returned after %v", elapsed)
|
||||
}
|
||||
if elapsed > 10*time.Second {
|
||||
t.Errorf("expected to give up within the ~3s bound, took %v", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// =============================================================================
|
||||
// LOOP A — fresh-review money findings (HIGH-1, HIGH-2, MEDIUM-3, MEDIUM-5,
|
||||
// LOW-6). Each test pins the fixed behaviour and would fail on the old code.
|
||||
// =============================================================================
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// HIGH-1 — the overflow→tip guard compares chargeAmount (what Square will
|
||||
// actually charge and buildSplitRecords will split), not req.Amount against an
|
||||
// inflated remaining+discount threshold. A pending campaign credit previously
|
||||
// let a full payment exceed the REAL remaining and silently mint a pre-start
|
||||
// tip.
|
||||
// =============================================================================
|
||||
|
||||
// TestLoopA_PreStartFullWithPendingDiscount_RequiresConfirmation locks the HIGH-1
|
||||
// bypass: a full £60 payment on the £50 fixture booking with a 100% campaign
|
||||
// eligible (£50 credit) would have passed the old guard (60 < 50+50) and
|
||||
// silently charged £60, carving a £10 pre-start tip with no confirmation.
|
||||
// chargeAmount == req.Amount for a 'full' payment, so it exceeds the real £50
|
||||
// remaining and MUST require confirmation.
|
||||
func TestLoopA_PreStartFullWithPendingDiscount_RequiresConfirmation(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedActiveCampaign(t, ctx, tx, 100)
|
||||
|
||||
cardToken := "cnon:loop-a-overflow-full"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 6000, // £60 on a £50 booking
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "loop-a-overflow-full-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "a full payment beyond the real remaining must require confirmation even with a discount pending, body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required")
|
||||
|
||||
// No payment record may be written for the rejected overflow.
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
||||
assert.Zero(t, payCount, "the unconfirmed overflow must not create any payment record")
|
||||
}
|
||||
|
||||
// TestLoopA_PreStartDepositWithDiscount_OverflowRequiresConfirmation locks the
|
||||
// Loop-B A6 finding on the HIGH-1 deposit side: a deposit-with-discount charge
|
||||
// is clamped DOWN to the discounted obligation (remaining − discount), and the
|
||||
// overflow guard compares the RAW request against that obligation. A £60
|
||||
// deposit on the £50 booking with a 20% campaign (£10 credit) requests £60
|
||||
// against a £40 discounted obligation — it MUST require confirmation (the old
|
||||
// guard compared the discounted charge against the real £50 remaining, accepted
|
||||
// it and silently truncated the discount). On confirmation the full £60 is
|
||||
// charged and the £10 excess (beyond the real remaining) is carved out as a tip
|
||||
// record — never absorbed as service revenue.
|
||||
func TestLoopA_PreStartDepositWithDiscount_OverflowRequiresConfirmation(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedActiveCampaign(t, ctx, tx, 20)
|
||||
|
||||
cardToken := "cnon:loop-a-deposit-overflow"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 6000, // £60 deposit; discounted obligation is £40
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "loop-a-deposit-overflow-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "a deposit beyond the discounted obligation must require confirmation, body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required")
|
||||
|
||||
// No payment record may be written for the unconfirmed overflow.
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
||||
assert.Zero(t, payCount, "the unconfirmed overflow must not create any payment record")
|
||||
|
||||
// Confirmed: the full £60 is charged and the £10 excess (60 − 50 real
|
||||
// remaining) is carved out as a tip record, never absorbed as service
|
||||
// revenue.
|
||||
req.ConfirmOverflowTip = true
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w2.Code, "a confirmed deposit overflow must proceed, body: %s", w2.Body.String())
|
||||
|
||||
var bookingPortion float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip'`, bookingID).Scan(&bookingPortion))
|
||||
assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must total the £50 obligation")
|
||||
|
||||
var tipCount int
|
||||
var tipAmount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount))
|
||||
assert.Equal(t, 1, tipCount, "the £10 excess must be carved out as a tip record")
|
||||
assert.InDelta(t, 10.0, tipAmount, 0.001, "the tip must equal the £10 excess")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HIGH-2 — a deposit-with-discount pending-reuse retry must compare against the
|
||||
// CHARGE amount stored on the pending row (the discounted amount), not the raw
|
||||
// req.Amount the frontend resends. Previously every such retry 400'd
|
||||
// "amount_mismatch" forever.
|
||||
// =============================================================================
|
||||
|
||||
// TestLoopA_DepositWithDiscount_PendingReuseRetry_Succeeds seeds the pending
|
||||
// row at the DISCOUNTED charge (£15 = £25 deposit − £10 campaign credit) and
|
||||
// retries with the RAW £25 deposit — exactly what the frontend resends. The
|
||||
// retry must be accepted (chargeAmount recomputes to £15 and matches) and the
|
||||
// charge completed, not rejected with amount_mismatch.
|
||||
func TestLoopA_DepositWithDiscount_PendingReuseRetry_Succeeds(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
// 20% campaign on the £50 fixture booking = £10 credit → a £25 raw deposit
|
||||
// charges £15.
|
||||
seedActiveCampaign(t, ctx, tx, 20)
|
||||
|
||||
key := "loop-a-deposit-retry-" + bookingID
|
||||
// Seed the pending row exactly as the handler's first attempt stored it:
|
||||
// the CHARGE amount (£15), not the requested £25.
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_at, updated_at, created_by)
|
||||
VALUES ($1, 'deposit', 'online_square', 'pending', 15.00, $2, 'cnon:first-attempt', NOW(), NOW(), $3)
|
||||
`, bookingID, key, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
cardToken := "cnon:loop-a-deposit-retry"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500, // raw £25 deposit — the frontend resends this
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a deposit-with-discount pending-reuse retry must succeed, body: %s", w.Body.String())
|
||||
|
||||
var status, sqPayID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE idempotency_key = $1`, key).Scan(&status, &sqPayID))
|
||||
assert.Equal(t, "completed", status, "the reused pending row must complete")
|
||||
assert.NotEmpty(t, sqPayID, "the completed row must carry the Square payment id")
|
||||
|
||||
// Exactly one row for the key — the pending row was reused, not duplicated.
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE idempotency_key = $1`, key).Scan(&payCount))
|
||||
assert.Equal(t, 1, payCount, "the retry must reuse the pending row, not mint a second one")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MEDIUM-3 — the stale-pending sweep rescue must mirror the live-path split: an
|
||||
// overflow beyond the booking's remaining obligation is carved out as a tip
|
||||
// record (never mis-booked as service revenue) and the fully-paid completion
|
||||
// check runs.
|
||||
// =============================================================================
|
||||
|
||||
// TestLoopA_SweepRescue_CarvesTipAndCompletes rescues a keyed lost-response
|
||||
// payment of £60 on the £50 fixture booking via the sweep. The rescue must:
|
||||
// complete the row, split it into deposit £25 + balance £25 + a carved tip £10,
|
||||
// and complete the booking (fully paid).
|
||||
func TestLoopA_SweepRescue_CarvesTipAndCompletes(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
|
||||
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 60.00, "online_square", "full", "pending")
|
||||
require.NoError(t, err)
|
||||
const key = "loop-a-sweep-rescue"
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:loop-a' WHERE id = $2", key, staleID)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 6000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:loop-a",
|
||||
IdempotencyKey: key,
|
||||
})
|
||||
require.NoError(t, err, "failed to seed the completed Square payment")
|
||||
SquareClient = mock
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx, "no transaction in context")
|
||||
require.NoError(t, pgxTx.Commit(ctx), "failed to commit test tx")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
freshCtx := context.Background()
|
||||
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var status, sqPayID string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID))
|
||||
assert.Equal(t, "completed", status, "the rescued row must complete")
|
||||
assert.Equal(t, pay.SquarePayID, sqPayID, "the rescued row must carry the replayed square_payment_id")
|
||||
|
||||
// The primary row is the deposit portion (£25); the balance and tip are
|
||||
// separate split rows.
|
||||
var bookingPortion float64
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip'`, bookingID).Scan(&bookingPortion))
|
||||
assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must total the £50 obligation (no overflow mis-booked as service revenue)")
|
||||
|
||||
var tipCount int
|
||||
var tipAmount float64
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount))
|
||||
assert.Equal(t, 1, tipCount, "the £10 overflow must be carved out as a tip record")
|
||||
assert.InDelta(t, 10.0, tipAmount, 0.001, "the tip must equal the £10 overflow")
|
||||
|
||||
// The booking was fully paid by the rescue → completed.
|
||||
var bookingStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
||||
assert.Equal(t, "completed", bookingStatus, "the fully-paid rescue must run the completion side-effects")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MEDIUM-5 — the till gift-card create/top-up must go through the same £5,000/
|
||||
// day admin cap as the admin API surface. The till's own same-day value counts.
|
||||
// =============================================================================
|
||||
|
||||
// TestLoopA_TillGiftCard_DailyCap_Enforced seeds a till_sales row of £4,800
|
||||
// created by the admin today and verifies a £250 till create is rejected 400
|
||||
// (would land the day on £5,050 — over the cap; £250 is at the per-transaction
|
||||
// limit so the daily check is what fires) while a £200 create lands exactly on
|
||||
// the £5,000 cap and succeeds — pinning the cap as inclusive and the till's own
|
||||
// value as counted.
|
||||
func TestLoopA_TillGiftCard_DailyCap_Enforced(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// A gift card created BEFORE today that the admin topped up at the till
|
||||
// today for £4,800 — the seeded till_sales row is the day's issued value.
|
||||
var cardID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, created_at)
|
||||
VALUES (4800.00, 4800.00, $1, NOW() - INTERVAL '1 day') RETURNING id
|
||||
`, adminID).Scan(&cardID))
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card topup', 1, 4800.00, 4800.00, 'cash', 'completed',
|
||||
'loop-a-till-seed', $2, NOW(), NOW())
|
||||
`, cardID, adminID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// £250 (the per-transaction maximum) would land the day on £5,050 — over
|
||||
// the £5,000 daily cap.
|
||||
over := makeTillSaleRequest(t, TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 250.00,
|
||||
PaymentMethod: "cash",
|
||||
IdempotencyKey: "loop-a-till-over",
|
||||
}, adminToken, ctx, tx.(pgx.Tx))
|
||||
require.Equal(t, http.StatusBadRequest, over.Code, "body: %s", over.Body.String())
|
||||
assert.Contains(t, over.Body.String(), "£5,000", "the rejection must cite the daily cap")
|
||||
assert.Contains(t, over.Body.String(), "daily", "the rejection must be the daily-limit message")
|
||||
|
||||
// £200 lands the day on EXACTLY £5,000 — inside the cap (inclusive).
|
||||
ok := makeTillSaleRequest(t, TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 200.00,
|
||||
PaymentMethod: "cash",
|
||||
IdempotencyKey: "loop-a-till-ok",
|
||||
}, adminToken, ctx, tx.(pgx.Tx))
|
||||
require.Equal(t, http.StatusCreated, ok.Code, "boundary body: %s", ok.Body.String())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LOW-6 — expiry is enforced at redemption, not just by the nightly cleanup
|
||||
// job: a card whose expiry_date has passed cannot be redeemed even before the
|
||||
// next CleanupExpiredGiftCards run.
|
||||
// =============================================================================
|
||||
|
||||
// TestLoopA_RedeemExpiredCard_Rejected redeems a card whose expiry_date is in
|
||||
// the past but whose amount_remaining is still live (the nightly job has not
|
||||
// run yet). The redemption must be rejected 400 and the card left untouched.
|
||||
func TestLoopA_RedeemExpiredCard_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
var cardID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, expiry_date)
|
||||
VALUES (20.00, 20.00, NOW() - INTERVAL '1 day') RETURNING id
|
||||
`).Scan(&cardID))
|
||||
|
||||
w := redeemCodeRequest(t, token, tx.(pgx.Tx), cardID)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "an expired card must not be redeemable, body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "expired")
|
||||
|
||||
// The card is untouched: balance live, not redeemed, no balance credited.
|
||||
var remaining float64
|
||||
var redeemedBy interface{}
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &redeemedBy))
|
||||
assert.Equal(t, 20.0, remaining, "the expired card's balance must be left untouched")
|
||||
assert.Nil(t, redeemedBy, "the expired card must not be marked redeemed")
|
||||
|
||||
var balanceCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balanceCount))
|
||||
assert.Zero(t, balanceCount, "no balance may be credited from an expired card")
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// B3 — admin overcharge double-loss: server-side clamp of terminal charges to
|
||||
// the booking's remaining obligation + tip only when explicitly requested.
|
||||
// =============================================================================
|
||||
|
||||
// seedPriorPayment records a completed real payment on a booking so the
|
||||
// remaining obligation is total - paid.
|
||||
func seedPriorPayment(t *testing.T, ctx context.Context, q db.Querier, bookingID string, amountPounds float64) {
|
||||
t.Helper()
|
||||
_, err := q.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'cash', 'completed', $2, NOW(), NOW())
|
||||
`, bookingID, amountPounds)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestTerminalCash_ClampsToRemainingObligation locks B3(a) for the cash branch:
|
||||
// a PaymentModal sending £45 (subtotal - discounts - campaignPreview) on a
|
||||
// booking with £30 already paid must be recorded at the £20 remaining
|
||||
// obligation, never the verbatim £45 (which would overcharge the customer).
|
||||
func TestTerminalCash_ClampsToRemainingObligation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
seedPriorPayment(t, ctx, tx, bookingID, 30.00)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500, // £45 = subtotal - campaign preview, IGNORING the £30 already paid
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("cash"),
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var paid float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') AND payment_type != 'tip'`, bookingID).Scan(&paid))
|
||||
assert.InDelta(t, 50.00, paid, 0.001, "£30 prior + £20 clamped = £50 obligation, never £75")
|
||||
|
||||
var resp CheckoutResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), "cash response must carry the payment id")
|
||||
var clamped float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE id = $1`, resp.CheckoutID).Scan(&clamped))
|
||||
assert.InDelta(t, 20.00, clamped, 0.001, "the cash payment must be clamped to the £20 remaining obligation")
|
||||
}
|
||||
|
||||
// TestTerminalCash_FullyPaid_RejectsOvercharge locks the fully-paid edge of
|
||||
// B3(a): when the booking has no remaining obligation, a no-tip charge is
|
||||
// rejected with 400 — recording the requested amount verbatim would overcharge
|
||||
// a customer who already paid in full (overpayment is handled manually at the
|
||||
// counter, not minted into the ledger).
|
||||
func TestTerminalCash_FullyPaid_RejectsOvercharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
seedPriorPayment(t, ctx, tx, bookingID, 50.00)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("cash"),
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "a charge on a fully-paid booking must be rejected, body: %s", w.Body.String())
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&payCount))
|
||||
assert.Equal(t, 1, payCount, "only the £50 prior payment may exist — the overcharge must not be recorded")
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_ClampsToRemainingObligation locks B3(a) for the
|
||||
// saved-card branch of CreateTerminalPayment: the pending record and the
|
||||
// Square charge use the clamped remaining obligation, not the verbatim
|
||||
// PaymentModal amount that ignored prior payments.
|
||||
func TestTerminalSavedCard_ClampsToRemainingObligation(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
seedPriorPayment(t, ctx, tx, bookingID, 30.00)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "sc-b3-clamp-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var paid float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') AND payment_type != 'tip'`, bookingID).Scan(&paid))
|
||||
assert.InDelta(t, 50.00, paid, 0.001, "£30 prior + £20 clamped = £50 obligation, never £75")
|
||||
|
||||
var charged float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' AND status = 'completed'`, bookingID).Scan(&charged))
|
||||
assert.InDelta(t, 20.00, charged, 0.001, "the saved-card charge must be clamped to the £20 remaining obligation")
|
||||
}
|
||||
|
||||
// TestTerminalCheckout_NoTip_ClampsToRemaining locks B3(b): a card-reader
|
||||
// checkout for more than the remaining obligation is clamped down to the
|
||||
// remaining value UNLESS the customer explicitly requested a tip. The
|
||||
// recorded terminal_checkouts row amount must reflect the clamp.
|
||||
func TestTerminalCheckout_NoTip_ClampsToRemaining(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
seedPriorPayment(t, ctx, tx, bookingID, 30.00)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500, // over the £20 remaining, no tip requested
|
||||
PaymentType: "full",
|
||||
TipEnabled: false,
|
||||
IdempotencyKey: "chk-b3-clamp-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var checkoutAmount float64
|
||||
var tipEnabled bool
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount, tip_enabled FROM terminal_checkouts WHERE booking_id = $1 AND status = 'PENDING'`, bookingID).Scan(&checkoutAmount, &tipEnabled))
|
||||
assert.InDelta(t, 20.00, checkoutAmount, 0.001, "a no-tip checkout must present only the £20 remaining obligation")
|
||||
assert.False(t, tipEnabled, "tip_enabled must be persisted as false")
|
||||
}
|
||||
|
||||
// TestTerminalCheckout_TipEnabled_NotClamped locks the tip side of B3(b): when
|
||||
// the customer explicitly requested a tip, the checkout amount (booking
|
||||
// portion + tip) is NOT clamped — the overflow is gratuity.
|
||||
func TestTerminalCheckout_TipEnabled_NotClamped(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
seedPriorPayment(t, ctx, tx, bookingID, 30.00)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500, // £20 booking portion + £25 explicit tip
|
||||
PaymentType: "full",
|
||||
TipEnabled: true,
|
||||
IdempotencyKey: "chk-b3-tip-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var checkoutAmount float64
|
||||
var tipEnabled bool
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount, tip_enabled FROM terminal_checkouts WHERE booking_id = $1 AND status = 'PENDING'`, bookingID).Scan(&checkoutAmount, &tipEnabled))
|
||||
assert.InDelta(t, 45.00, checkoutAmount, 0.001, "an explicit tip must not be clamped away")
|
||||
assert.True(t, tipEnabled, "tip_enabled must be persisted as true")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// B14 — terminal saved-card charges must apply VAT (booking/cash paths do).
|
||||
// =============================================================================
|
||||
|
||||
// TestTerminalSavedCard_AppliesVAT locks B14: a saved-card charge through the
|
||||
// terminal handler must apply apply_vat_to_payment after the completed flip,
|
||||
// exactly like the booking/cash paths — otherwise a VAT-registered business
|
||||
// silently loses the VAT fields on every saved-card terminal charge.
|
||||
func TestTerminalSavedCard_AppliesVAT(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
|
||||
require.NoError(t, err)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "sc-b14-vat-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
paymentID, ok := resp["payment_id"].(string)
|
||||
require.True(t, ok, "response must carry payment_id")
|
||||
|
||||
var isVATApplicable bool
|
||||
var vatAmount, netAmount *float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, paymentID).Scan(&isVATApplicable, &vatAmount, &netAmount))
|
||||
require.True(t, isVATApplicable, "a saved-card terminal charge must be VAT-applicable")
|
||||
require.NotNil(t, vatAmount)
|
||||
assert.InDelta(t, 7.50, *vatAmount, 0.001, "£45 at 20%% VAT = £7.50")
|
||||
require.NotNil(t, netAmount)
|
||||
assert.InDelta(t, 37.50, *netAmount, 0.001, "net = £37.50")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// B13 — max_redemptions race: the apply-time re-check must surface a
|
||||
// campaign exhausted by a concurrent redemption instead of silently charging
|
||||
// full price.
|
||||
// =============================================================================
|
||||
|
||||
// TestApplyEligibleCampaignsAtPayment_CampaignExhausted_ReturnsError locks the
|
||||
// B13 error path directly: a campaign that was eligible at preview time but
|
||||
// exhausted (times_redeemed reached max_redemptions) by a concurrent
|
||||
// redemption before the apply-time re-check must surface a
|
||||
// campaignExhaustedAtApplyError with the promised discount value.
|
||||
func TestApplyEligibleCampaignsAtPayment_CampaignExhausted_ReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
var bookingTotal float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal))
|
||||
|
||||
now := clock.Now()
|
||||
var campaignID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
||||
VALUES ($1, 'time_based', 10, 'active', $2, $3, 2, 0)
|
||||
RETURNING id
|
||||
`, "B13 Summer Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID))
|
||||
|
||||
// Preview-time computation: the campaign is eligible (£5 on a £50 booking).
|
||||
expected := ComputeEligibleDiscounts(ctx, tx, bookingID, userID, bookingTotal)
|
||||
require.Len(t, expected, 1, "the campaign must be eligible at preview time")
|
||||
require.Equal(t, campaignID, expected[0].SourceID)
|
||||
|
||||
// A CONCURRENT redemption on another booking exhausts the campaign.
|
||||
_, err := tx.Exec(ctx, `UPDATE discount_campaigns SET times_redeemed = 2 WHERE id = $1`, campaignID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, expected)
|
||||
require.Error(t, err, "an exhausted-at-apply campaign must surface an error, not silently charge full price")
|
||||
var exErr *campaignExhaustedAtApplyError
|
||||
require.ErrorAs(t, err, &exErr)
|
||||
require.Equal(t, campaignID, exErr.campaignID)
|
||||
require.Equal(t, int64(500), exErr.lostPence, "the lost discount is £5 on the £50 booking")
|
||||
|
||||
// No discount row may have been created.
|
||||
var discountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
require.Zero(t, discountCount)
|
||||
}
|
||||
|
||||
// TestApplyEligibleCampaignsAtPayment_CampaignAvailable_NoError locks the B13
|
||||
// control: when the campaign is still available at apply time, the discount is
|
||||
// applied and no error is returned.
|
||||
func TestApplyEligibleCampaignsAtPayment_CampaignAvailable_NoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
var bookingTotal float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal))
|
||||
|
||||
now := clock.Now()
|
||||
var campaignID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
||||
VALUES ($1, 'time_based', 10, 'active', $2, $3, 10, 0)
|
||||
RETURNING id
|
||||
`, "B13 Still Active", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID))
|
||||
|
||||
expected := ComputeEligibleDiscounts(ctx, tx, bookingID, userID, bookingTotal)
|
||||
require.Len(t, expected, 1)
|
||||
|
||||
err := applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, expected)
|
||||
require.NoError(t, err, "an available campaign must apply cleanly")
|
||||
|
||||
var discountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
require.Equal(t, 1, discountCount, "the available campaign discount must be applied")
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_AppliesCampaignAtChargeTime locks the B13 fix for the
|
||||
// saved-card terminal path: an eligible campaign must be applied AT CHARGE TIME
|
||||
// (inside the completed-flip transaction), not deferred to the completion
|
||||
// side-effects — those only run when bookingIsFullyPaid, by which point
|
||||
// capDiscountToRemainingObligation sees zero headroom and the discount would be
|
||||
// lost. Charging the discounted amount (£45 on a £50 booking) mints the £5
|
||||
// discount row so real money + discount == total and the booking completes.
|
||||
func TestTerminalSavedCard_AppliesCampaignAtChargeTime(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
now := clock.Now()
|
||||
var campaignID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
||||
VALUES ($1, 'time_based', 10, 'active', $2, $3, 10, 0)
|
||||
RETURNING id
|
||||
`, "Terminal Saved-Card Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID))
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500, // the discounted amount the frontend preview showed
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "sc-b13-apply-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var discountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
assert.Equal(t, 1, discountCount, "the eligible campaign must be applied at saved-card charge time")
|
||||
var discountAmount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount))
|
||||
assert.InDelta(t, 5.00, discountAmount, 0.001, "the £50 booking at 10%% = £5 discount")
|
||||
|
||||
var discountPay float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' AND status = 'completed'`, bookingID).Scan(&discountPay))
|
||||
assert.InDelta(t, 5.00, discountPay, 0.001, "the discount payment record must exist")
|
||||
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status))
|
||||
assert.Equal(t, "completed", status, "real money + discount row must complete the booking")
|
||||
|
||||
var redeemed int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed))
|
||||
assert.Equal(t, 1, redeemed, "the campaign redemption counter must be incremented exactly once")
|
||||
}
|
||||
|
||||
// exhaustCampaignOnChargeClient simulates the B13 max_redemptions race: it
|
||||
// exhausts the campaign (times_redeemed = max_redemptions) at the moment the
|
||||
// Square charge is made — i.e. BETWEEN the pre-charge eligibility snapshot and
|
||||
// the apply-time re-check inside the saved-card terminal path.
|
||||
type exhaustCampaignOnChargeClient struct {
|
||||
square.SquareClient
|
||||
campaignID string
|
||||
}
|
||||
|
||||
func (c *exhaustCampaignOnChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
_, _ = db.Conn.Exec(ctx, `UPDATE discount_campaigns SET times_redeemed = max_redemptions WHERE id = $1`, c.campaignID)
|
||||
return c.SquareClient.CreatePayment(ctx, req)
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_CampaignExhaustedAtApply_ReturnsCampaignFullyRedeemed
|
||||
// locks the B13 saved-card terminal path: a campaign exhausted by a concurrent
|
||||
// redemption between the frontend's preview and the apply-time re-check must
|
||||
// surface the same campaign_fully_redeemed 400 the online booking path returns,
|
||||
// instead of silently skipping the discount and leaving the booking underpaid.
|
||||
// The charge still completes at Square and the payment is recorded; the
|
||||
// frontend learns the campaign ended so it can prompt for the difference.
|
||||
func TestTerminalSavedCard_CampaignExhaustedAtApply_ReturnsCampaignFullyRedeemed(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
now := clock.Now()
|
||||
var campaignID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
||||
VALUES ($1, 'time_based', 10, 'active', $2, $3, 2, 0)
|
||||
RETURNING id
|
||||
`, "B13 Terminal Race", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID))
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &exhaustCampaignOnChargeClient{SquareClient: square.NewDevClient(), campaignID: campaignID}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4500,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "sc-b13-exhaust-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "an exhausted-at-apply campaign must surface 400 campaign_fully_redeemed, body: %s", w.Body.String())
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, "campaign_fully_redeemed", body["code"])
|
||||
|
||||
var discountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
assert.Zero(t, discountCount, "an exhausted campaign must not mint a discount row")
|
||||
|
||||
// The charge still succeeded at Square and the payment was recorded as
|
||||
// completed (mirroring the online path: the payment is committed, then the
|
||||
// 400 is returned so the frontend can prompt for the difference).
|
||||
var payCount int
|
||||
var payStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount))
|
||||
assert.Equal(t, 1, payCount)
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' LIMIT 1`, bookingID).Scan(&payStatus))
|
||||
assert.Equal(t, "completed", payStatus)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// =============================================================================
|
||||
// LOOP B — Round-2 money findings. Each test pins a fixed behaviour and would
|
||||
// fail on the pre-fix code.
|
||||
// =============================================================================
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Finding 1 — B1 re-poll race: a webhook-promoted 'completed' sweepdup refund
|
||||
// whose PARENT payment row is still pending must still be resolved by the B1
|
||||
// re-poll pass, and the sweep's in-flight guard must keep treating such a
|
||||
// completed refund as in-flight (never re-replaying the expired key).
|
||||
// =============================================================================
|
||||
|
||||
// seedCompletedB1RefundAndPendingParent seeds a payments-table B1 sweep
|
||||
// auto-refund row with status 'completed' (as the webhook's COMPLETED
|
||||
// promotion leaves it) on a still-pending parent payment — the stranded
|
||||
// state the re-poll pass must resolve.
|
||||
func seedCompletedB1RefundAndPendingParent(t *testing.T, ctx context.Context, tx db.Querier, userID, bookingID string, amount float64, squareRefundID, refundKey string) (paymentID, refundID string) {
|
||||
t.Helper()
|
||||
pid, err := fixtures.CreateTestPayment(tx, bookingID, amount, "online_square", "full", "pending")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create pending parent payment: %v", err)
|
||||
}
|
||||
var rid string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'completed', 'manual', $5, $6, $7, NOW())
|
||||
RETURNING id
|
||||
`, pid, bookingID, amount, squareRefundID, sweepDuplicateRefundReason, refundKey, userID).Scan(&rid)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert webhook-completed B1 refund row: %v", err)
|
||||
}
|
||||
return pid, rid
|
||||
}
|
||||
|
||||
// TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight locks the
|
||||
// in-flight guard widening (sweep.go hasInFlightSweepDuplicateRefund): a B1
|
||||
// sweepdup refund a webhook promoted to 'completed' — while the parent payment
|
||||
// row is still pending — must STILL count as in-flight. Before the fix the
|
||||
// guard matched only 'pending', so the next sweep re-replayed the expired key
|
||||
// and minted ANOTHER charge before the re-poll pass resolved the parent. The
|
||||
// failed-refund guard must stay false for a completed (not failed) refund so
|
||||
// the row is never blind-failed on the replay path.
|
||||
func TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
paymentID, _ := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, "ref_b1_wbhk_inflight", "sweepdup-pay_dup_inflight")
|
||||
|
||||
assert.True(t, hasInFlightSweepDuplicateRefund(ctx, "payments", paymentID),
|
||||
"a webhook-completed sweepdup refund on a pending parent must still count as in-flight")
|
||||
assert.False(t, hasFailedSweepDuplicateRefund(ctx, "payments", paymentID),
|
||||
"a completed (not failed) sweepdup refund must NOT trip the failed-refund guard")
|
||||
}
|
||||
|
||||
// TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment locks
|
||||
// the re-poll query widening (refunds.go sweepPendingB1Refunds): a sweepdup
|
||||
// refund the webhook promoted to 'completed' — whose PARENT payment row is
|
||||
// still pending — is re-polled and, when Square confirms the refund COMPLETED,
|
||||
// the parent is finally marked failed. Before the fix the query matched only
|
||||
// 'pending' refunds, so the completed refund was never resolved and the parent
|
||||
// stayed pending forever (feeding the sweep replay loop).
|
||||
func TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
|
||||
const squareRefundID = "ref_b1_wbhk_completed"
|
||||
const refundKey = "sweepdup-pay_dup_wbhk"
|
||||
paymentID, refundID := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey)
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx, "no transaction in context")
|
||||
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "COMPLETED"}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
freshCtx := context.Background()
|
||||
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
||||
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
||||
}
|
||||
|
||||
var refundStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus))
|
||||
assert.Equal(t, "completed", refundStatus, "the webhook-completed refund stays completed")
|
||||
|
||||
var parentStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&parentStatus))
|
||||
assert.Equal(t, "failed", parentStatus, "the pending parent must be resolved to failed once the refund settled")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Finding 3 — a Square APPROVED refund is NON-terminal: the call sites must
|
||||
// keep the refunds row pending (with the square_refund_id recorded) so a later
|
||||
// FAILED/CANCELED can demote it, instead of resolving it to 'completed' and
|
||||
// stranding it.
|
||||
// =============================================================================
|
||||
|
||||
// approvedRefundClient answers RefundPayment with Square status APPROVED —
|
||||
// the ambiguous authorization-only state that must stay pending locally.
|
||||
type approvedRefundClient struct {
|
||||
square.SquareClient
|
||||
}
|
||||
|
||||
func (c *approvedRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
||||
return &square.RefundResult{ID: "ref_approved_test", Status: "APPROVED", Amount: req.Amount, PaymentID: req.PaymentID}, nil
|
||||
}
|
||||
|
||||
// TestRefundPayment_ApprovedStatus_LeavesRowPending pins the MED-HIGH finding
|
||||
// at the RefundPayment handler call site: a Square APPROVED refund is NOT
|
||||
// terminal — resolving it to 'completed' would strand the row (the FAILED
|
||||
// demotion only demotes 'pending'). The row must stay 'pending' with the
|
||||
// square_refund_id recorded, and the response must report 'pending'.
|
||||
func TestRefundPayment_ApprovedStatus_LeavesRowPending(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_approved' WHERE id = $1", paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &approvedRefundClient{SquareClient: square.NewDevClient()}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
req := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "approved-refund-" + bookingID}
|
||||
w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "an APPROVED refund is non-terminal but the handler must still respond 200, body: %s", w.Body.String())
|
||||
|
||||
var body RefundResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, "pending", body.Status, "the response must report pending for an APPROVED refund")
|
||||
|
||||
var status, sqRefundID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_refund_id, '') FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID))
|
||||
assert.Equal(t, "pending", status, "an APPROVED refund must leave the row pending, never completed")
|
||||
assert.Equal(t, "ref_approved_test", sqRefundID, "the square_refund_id must be recorded on the pending row so the re-poll can settle it")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Finding 6 — guest-booking cash/gift-card terminal charges must write an
|
||||
// admin_audit_log row (target_user_id NULL) AFTER the money commits.
|
||||
// =============================================================================
|
||||
|
||||
// TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget pins the MEDIUM
|
||||
// finding: a CASH terminal charge on a GUEST booking (user_id NULL) previously
|
||||
// wrote NO audit row (the `if customerID.Valid` guard skipped it). The audit
|
||||
// must run with a NULL target_user_id — matching the till flow — after the
|
||||
// money transaction commits.
|
||||
func TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
// Guest booking: no account behind it.
|
||||
_, err = tx.Exec(ctx, `UPDATE bookings SET user_id = NULL, status = 'in_progress' WHERE id = $1`, bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
pm := "cash"
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: &pm,
|
||||
IdempotencyKey: "guest-cash-audit-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a guest cash terminal charge must complete, body: %s", w.Body.String())
|
||||
|
||||
var auditCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_audit_log
|
||||
WHERE action_type = 'admin_cash_charge' AND target_user_id IS NULL
|
||||
AND details->>'booking_id' = $1
|
||||
`, bookingID).Scan(&auditCount))
|
||||
assert.Equal(t, 1, auditCount, "a guest cash charge must audit with a NULL target_user_id")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Finding 8 — the A6 skip path (deposit fully covered by discount) must bind
|
||||
// the request's idempotency key to a row so a same-key retry dedups instead of
|
||||
// re-running and potentially charging the full deposit.
|
||||
// =============================================================================
|
||||
|
||||
// TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups pins the
|
||||
// idempotency fix: after a discount-covered deposit skips the Square charge,
|
||||
// the discount row carries the request's idempotency key. A lost-response
|
||||
// same-key retry then short-circuits on the completed row — no second skip, no
|
||||
// Square call, no second discount redemption — where before it re-ran the
|
||||
// handler and could charge the full deposit once the campaign had exhausted.
|
||||
func TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedActiveCampaign(t, ctx, tx, 100)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &failOnChargeClient{SquareClient: square.NewDevClient(), t: t}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:deposit-covered-dedup"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "deposit-covered-dedup-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "the discount-covered deposit must complete, body: %s", w.Body.String())
|
||||
|
||||
// The skip path bound the request key to the applied discount row.
|
||||
var keyedDiscountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM payments
|
||||
WHERE booking_id = $1 AND payment_method = 'discount' AND idempotency_key = $2
|
||||
`, bookingID, req.IdempotencyKey).Scan(&keyedDiscountCount))
|
||||
assert.Equal(t, 1, keyedDiscountCount, "the request's idempotency key must be bound to the discount row")
|
||||
|
||||
// Same-key retry: short-circuits on the completed row — no Square charge,
|
||||
// no additional discount redemption.
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w2.Code, "a same-key retry must dedup to the completed result, body: %s", w2.Body.String())
|
||||
|
||||
var discountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
assert.Equal(t, 1, discountCount, "the retry must not re-apply (double-redeem) the campaign discount")
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Finding 2 — the A6 deposit clamp can produce chargeAmount=0 with no guard:
|
||||
// the handler then charged £0 at Square (invalid in prod, minted a completed
|
||||
// £0 deposit in the dev mock that consumed the discount). A deposit whose
|
||||
// eligible campaign credit covers the ENTIRE remaining obligation must skip the
|
||||
// Square call and report deposit_covered_by_discount.
|
||||
// =============================================================================
|
||||
|
||||
// seedActiveCampaign inserts an active time-based campaign with the given
|
||||
// discount percent and returns its id.
|
||||
func seedActiveCampaign(t *testing.T, ctx context.Context, q db.Querier, percent int) string {
|
||||
t.Helper()
|
||||
now := clock.Now()
|
||||
var id string
|
||||
err := q.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
||||
VALUES ($1, 'time_based', $2, 'active', $3, $4, 0)
|
||||
RETURNING id
|
||||
`, "Money-Fix Campaign", percent, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// failOnChargeClient fails the test if a Square charge is attempted. Proves a
|
||||
// discount-covered deposit skips the Square call entirely (finding 2).
|
||||
type failOnChargeClient struct {
|
||||
square.SquareClient
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (c *failOnChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
c.t.Fatalf("Square CreatePayment must NOT be called for a discount-covered deposit (amount=%d)", req.Amount)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestBookingPayment_DepositFullyCoveredByDiscount_SkipsSquareCharge pins the
|
||||
// A6 skip path: a deposit whose eligible campaign credit covers the ENTIRE
|
||||
// remaining obligation skips the Square charge (never charges £0) AND applies
|
||||
// the eligible campaign discount rows IMMEDIATELY (finding: deferring the
|
||||
// discount to the next real charge let the booking complete at full price with
|
||||
// no discount row — the customer overpaid the promised discount). The
|
||||
// discount row is a completed payments row (payment_method='discount'), the
|
||||
// response reports deposit_covered_by_discount only because a discount was
|
||||
// actually applied, and a fully-covered booking completes like the real charge
|
||||
// path.
|
||||
func TestBookingPayment_DepositFullyCoveredByDiscount_SkipsSquareCharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
// 100% time-based campaign = £50 discount on the £50 fixture booking, which
|
||||
// fully covers the £25 deposit request (chargeAmount clamps to £0).
|
||||
seedActiveCampaign(t, ctx, tx, 100)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &failOnChargeClient{SquareClient: square.NewDevClient(), t: t}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:deposit-covered"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500, // £25 deposit
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "deposit-covered-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a discount-covered deposit must complete without a Square charge, body: %s", w.Body.String())
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, true, body["deposit_covered_by_discount"], "the response must signal the discount-covered deposit")
|
||||
|
||||
// The discount rows are now applied AT the skip path (never deferred to a
|
||||
// later charge that F1-skips them — the overcharge bug). The single
|
||||
// payments row IS the completed discount row.
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
||||
assert.Equal(t, 1, payCount, "exactly one discount payment row must be recorded for the discount-covered deposit")
|
||||
|
||||
var discountCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
||||
assert.Equal(t, 1, discountCount, "the eligible campaign discount must be recorded at the skip path")
|
||||
|
||||
var discountAmount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountAmount))
|
||||
assert.Equal(t, 50.00, discountAmount, "the £50 campaign discount must be recorded in full")
|
||||
|
||||
// The discount fully covers the booking — it completes exactly like a fully
|
||||
// paid booking on the real charge path.
|
||||
var bookingStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
||||
assert.Equal(t, "completed", bookingStatus, "a booking whose entire obligation is discount-covered must complete")
|
||||
}
|
||||
|
||||
// TestBookingPayment_DepositCoveredDiscount_RecordsDiscount_NoOvercharge is the
|
||||
// exact A6 scenario from the finding: £100 booking, 50% campaign, post-start
|
||||
// partial £50 already paid, then a £50 deposit whose eligible credit covers the
|
||||
// ENTIRE remaining obligation. The skip path must record the £50 discount row
|
||||
// IMMEDIATELY so the promised discount is never lost — before the fix the
|
||||
// deposit returned deposit_covered_by_discount with NO discount row, the later
|
||||
// balance charge F1-skipped the discount (headroom already spent by the real
|
||||
// money) and the booking completed at the full £100 with the customer overpaying
|
||||
// the promised £50.
|
||||
func TestBookingPayment_DepositCoveredDiscount_RecordsDiscount_NoOvercharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
_, err := tx.Exec(ctx, `UPDATE bookings SET total_amount = 100.00 WHERE id = $1`, bookingID)
|
||||
require.NoError(t, err)
|
||||
// 50% time-based campaign = £50 eligible credit on the £100 booking.
|
||||
seedActiveCampaign(t, ctx, tx, 50)
|
||||
// Post-start partial £50 already paid — remaining obligation is £50.
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "partial", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &failOnChargeClient{SquareClient: square.NewDevClient(), t: t}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
// £50 deposit — the eligible £50 credit covers the ENTIRE remaining £50,
|
||||
// so chargeAmount clamps to £0 and the skip path runs.
|
||||
cardToken := "cnon:deposit-covered-partial"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "deposit-covered-partial-" + bookingID,
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a discount-covered deposit must complete without a Square charge, body: %s", w.Body.String())
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, true, body["deposit_covered_by_discount"], "the response must signal the discount-covered deposit")
|
||||
|
||||
// The £50 campaign discount row is recorded AT the skip path.
|
||||
var discountAmount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountAmount))
|
||||
assert.Equal(t, 50.00, discountAmount, "the promised £50 discount must be recorded at the skip path")
|
||||
|
||||
// Ledger: real £50 + discount £50 = £100 = total. The customer pays the
|
||||
// discounted £50, never the full £100. GetBookingRemainingBalancePence
|
||||
// counts only REAL money — a discount row is a ledger entry, not a payment
|
||||
// toward the balance (the real-money convention every other paid
|
||||
// computation applies) — so the remaining balance reports the full £50 even
|
||||
// though the booking auto-completed (bookingIsFullyPaid counts the discount
|
||||
// row toward completion).
|
||||
var remainingPence int64
|
||||
remainingPence, err = NewPaymentService().GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5000), remainingPence, "the remaining balance counts real money only — the discount row is not 'paid'")
|
||||
|
||||
// A later unconfirmed balance charge of £50 must be REJECTED — the booking
|
||||
// auto-completed when the deposit + discount settled it, so the completed-
|
||||
// booking guard refuses the charge outright. The booking can never be
|
||||
// silently overcharged the remaining £50 (the old bug: the balance charge
|
||||
// completed at full price with no discount row).
|
||||
w = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "balance",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "balance-after-covered-deposit-" + bookingID,
|
||||
}, userToken, ctx)
|
||||
require.Equal(t, http.StatusConflict, w.Code, "a balance charge on a completed discounted booking must be rejected, body: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// TestGetBookingPaymentSummary_ExcludesTipsFromRemaining pins finding 4: the
|
||||
// payment summary must not count tip rows as "paid" — a tip is gratuity paid
|
||||
// beyond the booking total and must not reduce the balance owed. Before the
|
||||
// fix PaidAmount included the tip and RemainingAmount (total - paid + refunded)
|
||||
// understated the authoritative tip-excluded balance, so an admin relying on
|
||||
// the summary could under-collect.
|
||||
func TestGetBookingPaymentSummary_ExcludesTipsFromRemaining(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewPaymentService()
|
||||
// Pay the full £50 booking + a £20 tip.
|
||||
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: 50.00,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: 20.00,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
summary, err := svc.GetBookingPaymentSummary(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 50.00, summary.PaidAmount, "PaidAmount must exclude the £20 tip row")
|
||||
require.Equal(t, 0.00, summary.RemainingAmount, "RemainingAmount must exclude the £20 tip row (the £50 booking is fully paid)")
|
||||
|
||||
// Cross-check against the authoritative charge-guard balance.
|
||||
remaining, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, remaining, int64(summary.RemainingAmount*100), "RemainingAmount must match GetBookingRemainingBalancePence")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Finding 7 — remaining-balance capacity ignored PENDING refunds: service.go
|
||||
// counted only completed refunds in GetBookingRemainingBalancePence while
|
||||
// GetBookingPaymentInfo counts completed + pending. An in-flight refund
|
||||
// understated the remaining balance and blocked a legitimate retry.
|
||||
// =============================================================================
|
||||
|
||||
func TestGetBookingRemainingBalancePence_PendingRefundsReopenCapacity(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var bookingTotal int64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal))
|
||||
|
||||
svc := NewPaymentService()
|
||||
|
||||
// Pay the full booking amount.
|
||||
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: float64(bookingTotal) / 100.0,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining")
|
||||
|
||||
// A PENDING refund is money in flight that will come back — it must re-open
|
||||
// capacity by its amount exactly like a completed refund.
|
||||
var payRowID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND payment_type = 'full' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&payRowID))
|
||||
|
||||
refundAmount := bookingTotal / 2
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
|
||||
VALUES ($1, $2, $3, 'pending', 'in-flight test refund', 'manual')
|
||||
`, payRowID, bookingID, float64(refundAmount)/100.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err = svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, refundAmount, remaining, "a pending refund must re-open the remaining balance by its amount")
|
||||
}
|
||||
@@ -57,7 +57,14 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if bookingUserID != userID {
|
||||
// Ownership: the booking's own customer may redeem their pending
|
||||
// redemption, and an admin acting on ANY booking may too (the admin
|
||||
// "Take Payment" PaymentModal applies the customer's redemption on their
|
||||
// behalf — the admin route /admin/bookings/{id}/apply-redemption mounts
|
||||
// this same handler under RequireAdmin). All the writes below target the
|
||||
// BOOKING's user (bookingUserID), never the acting admin.
|
||||
userRole, _ := r.Context().Value(mw.UserRoleKey).(string)
|
||||
if userRole != "admin" && bookingUserID != userID {
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -83,6 +90,37 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize redemption per booking: two concurrent redemptions could both
|
||||
// pass the checks above and both insert a discount (double-apply). Reuse
|
||||
// the booking-payment advisory lock so redemption is mutually exclusive
|
||||
// with payments and other redemptions on the same booking (N-6).
|
||||
pinConn, err := db.Conn.Acquire(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for loyalty redemption lock: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer pinConn.Release()
|
||||
// Bounded try-lock instead of a blocking pg_advisory_lock: the SAME
|
||||
// "crussell:payment:" key is held by the payment handlers across their full
|
||||
// Square round-trip (~30s), so a blocking acquire here would pin this pool
|
||||
// connection for that long — a handful of concurrent redemption requests
|
||||
// during an in-flight payment would exhaust the pool (max(4, numCPU)) and
|
||||
// hang the app. Give up after ~3s and surface 409 instead.
|
||||
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:payment:"+bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire loyalty redemption serialization lock for %s: %v", bookingID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !lockOK {
|
||||
http.Error(w, "Another payment operation is in progress, try again", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
defer releasePaymentLock(pinConn, "crussell:payment:"+bookingID)
|
||||
|
||||
// Re-check inside the lock (the checks above ran before acquiring it) so a
|
||||
// concurrent redemption that completed while we waited is caught.
|
||||
var existingDiscount int
|
||||
if err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'
|
||||
@@ -98,10 +136,10 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var bookingTotal float64
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
@@ -117,7 +155,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'loyalty', $3, NULL, NULL, $6, $4, $5)
|
||||
`, bookingID, userID, redemptionID, bookingTotal, discountAmount, LoyaltyDiscountPercent); err != nil {
|
||||
`, bookingID, bookingUserID, redemptionID, bookingTotal, discountAmount, LoyaltyDiscountPercent); err != nil {
|
||||
log.Printf("Failed to insert booking discount: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -126,7 +164,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
`, bookingID, discountAmount, bookingUserID); err != nil {
|
||||
log.Printf("Failed to insert payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -143,7 +181,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - $1) WHERE id = $2
|
||||
`, LoyaltyStampCost, userID); err != nil {
|
||||
`, LoyaltyStampCost, bookingUserID); err != nil {
|
||||
log.Printf("Failed to update loyalty stamps: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -155,8 +193,10 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"discount_amount": discountAmount,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,17 +249,19 @@ func TestCampaignAutoApply_TimeBased(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// Insert a deposit payment to trigger campaign auto-apply
|
||||
// Insert a deposit payment to trigger campaign auto-apply (£25 on the £50
|
||||
// booking — leaves headroom so the F1 over-credit cap still lets the 10%
|
||||
// discount through).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
// Call applyEligibleCampaignsAtPayment
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
// Verify booking_discounts was created
|
||||
var discountCount int
|
||||
@@ -302,16 +304,16 @@ func TestCampaignAutoApply_UserMilestone(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// Insert a payment
|
||||
// Insert a payment (£25 deposit leaves F1 cap headroom for the 15% discount)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
var discountCount int
|
||||
tx.QueryRow(ctx,
|
||||
@@ -354,7 +356,7 @@ func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
// Verify NO discount was applied (global milestone skipped for online payment)
|
||||
var discountCount int
|
||||
@@ -386,10 +388,11 @@ func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// Insert an IN-PERSON payment
|
||||
// Insert an IN-PERSON payment (£40 on the £50 booking — leaves the £10
|
||||
// headroom the 20% global-milestone discount needs to pass the F1 cap).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'full', 'in_person_card', 40.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
@@ -399,7 +402,7 @@ func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) {
|
||||
tx.Exec(ctx,
|
||||
"UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1", bookingID)
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
var discountCount int
|
||||
tx.QueryRow(ctx,
|
||||
@@ -444,7 +447,7 @@ func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) {
|
||||
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
// Verify still only 1 discount
|
||||
var discountCount int
|
||||
@@ -485,16 +488,16 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) {
|
||||
t.Fatalf("failed to insert referral discount: %v", err)
|
||||
}
|
||||
|
||||
// Insert payment to trigger auto-apply
|
||||
// Insert payment to trigger auto-apply (£25 deposit leaves F1 cap headroom)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
// Verify referral discount was applied
|
||||
var discountCount int
|
||||
@@ -561,7 +564,7 @@ func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) {
|
||||
t.Fatalf("failed to insert existing booking discount: %v", err)
|
||||
}
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
// Verify no second referral discount was applied
|
||||
var discountCount int
|
||||
@@ -606,7 +609,7 @@ func TestCampaignAutoApply_ReferralDiscount_AlreadyUsed(t *testing.T) {
|
||||
t.Fatalf("failed to insert used referral discount: %v", err)
|
||||
}
|
||||
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
|
||||
|
||||
var discountCount int
|
||||
tx.QueryRow(ctx,
|
||||
@@ -707,3 +710,63 @@ func TestApplyLoyaltyRedemption_NoPendingRedemption(t *testing.T) {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyLoyaltyRedemption_LockContended_Returns409 verifies the bounded
|
||||
// try-lock defence (loyalty.go): while another connection holds the
|
||||
// "crussell:payment:<booking>" advisory lock (e.g. an in-flight payment), a
|
||||
// redemption attempt must NOT block the pool connection — it gives up after
|
||||
// the ~3s bound and surfaces a 409 instead.
|
||||
func TestApplyLoyaltyRedemption_LockContended_Returns409(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
|
||||
|
||||
// Commit the setup tx so the handler sees committed rows.
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
if innerTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := innerTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit setup tx: %v", err)
|
||||
}
|
||||
|
||||
// Hold the booking-payment advisory lock on a dedicated pinned connection
|
||||
// so every try-lock attempt from the handler's connection fails.
|
||||
holder, err := db.Conn.Acquire(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire holder connection: %v", err)
|
||||
}
|
||||
defer holder.Release()
|
||||
if _, err := holder.Exec(context.Background(),
|
||||
`SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))`, bookingID); err != nil {
|
||||
t.Fatalf("failed to acquire holder lock: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = holder.Exec(context.Background(),
|
||||
`SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))`, bookingID)
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
w := makeApplyRedemptionRequest(bookingID, userToken, context.Background())
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 on contended lock, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// The bound is 30×100ms ≈ 3s. It must give up within a sane window (did
|
||||
// not block forever on the pool) — allow generous CI headroom.
|
||||
if elapsed > 15*time.Second {
|
||||
t.Errorf("lock contention should give up after ~3s, took %v", elapsed)
|
||||
}
|
||||
|
||||
// Redemption must NOT have been applied.
|
||||
var discountCount int
|
||||
err = db.Conn.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking_discounts: %v", err)
|
||||
}
|
||||
if discountCount != 0 {
|
||||
t.Errorf("expected 0 discounts (redemption rejected), got %d", discountCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// M4-1: Tips only after booking start
|
||||
// =============================================================================
|
||||
|
||||
func TestTipPayment_RejectedBeforeStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// setupTestData creates the booking with a far-future start time but status
|
||||
// in_progress — a payable status, so the start-time guard is the ONLY
|
||||
// rejection that can fire.
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:tip-before-start"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "after the booking has started")
|
||||
|
||||
var tipCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, tipCount, "no tip record may be created before the booking starts")
|
||||
}
|
||||
|
||||
func TestTipPayment_AcceptedAfterStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:tip-after-start"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var resp PaymentResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
assert.Equal(t, "tip", resp.PaymentType)
|
||||
assert.Equal(t, "completed", resp.Status)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M4-2: Cap pay-early at 100% — reject overpayment instead of silent tip
|
||||
// =============================================================================
|
||||
|
||||
// TestBookingPayment_OverflowRejected_NoSilentTip verifies the M4/M7 overflow
|
||||
// gate: a pre-start 'full' payment that exceeds the booking total is rejected
|
||||
// with 400 overflow_tip_confirmation_required (the tip conversion needs the
|
||||
// client's explicit confirmation) and creates no payment record.
|
||||
func TestBookingPayment_OverflowRejected_NoSilentTip(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// Fixture booking total is £50 (5000 pence); a £60 'full' payment exceeds it.
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:overflow-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 6000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "overflow-reject-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "The extra amount will be recorded as a tip")
|
||||
assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required")
|
||||
|
||||
// No payment records may be created (the rejection happens before any
|
||||
// pending record insert or Square charge), and no tip may be silently carved.
|
||||
var count int
|
||||
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, count, "unconfirmed overpayment must not create any payment record")
|
||||
}
|
||||
|
||||
// TestBookingPayment_Overflow_Confirmed_RecordsTip pins the M7 confirmed path:
|
||||
// the same pre-start overpayment WITH confirm_overflow_tip=true proceeds and
|
||||
// the overflow beyond the booking total is recorded as a tip record (not
|
||||
// silently dropped or double-counted).
|
||||
func TestBookingPayment_Overflow_Confirmed_RecordsTip(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
// £60 on a £50 booking with deposit room: deposit £25 + balance £25 + £10
|
||||
// tip overflow.
|
||||
cardToken := "cnon:overflow-confirmed-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 6000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "overflow-confirmed-" + bookingID,
|
||||
ConfirmOverflowTip: true,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var tipCount int
|
||||
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'`, bookingID).Scan(&tipCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, tipCount, "a confirmed overpayment must be recorded as a tip record")
|
||||
}
|
||||
|
||||
func TestBookingPayment_FullRemainingBalance_Accepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// Paying exactly the remaining balance is still allowed.
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:exact-balance-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "exact-balance-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M4-3: Cancellation refunds exclude tips
|
||||
// =============================================================================
|
||||
|
||||
func TestProcessCancellationRefund_ExcludesTips(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
|
||||
// £50 real payment plus a £5 tip on a £50 booking.
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
tipID, err := fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_refund_full' WHERE id = $1", payID)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_refund_tip' WHERE id = $1", tipID)
|
||||
require.NoError(t, err)
|
||||
|
||||
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour)
|
||||
|
||||
innerTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, innerTx)
|
||||
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, farFuture, now, "client_cancelled", &userID, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 50.0, result.RefundableAmount, "totalPrePaid must exclude the £5 tip")
|
||||
|
||||
// The tip payment must NOT be refunded.
|
||||
var tipRefundCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, tipID).Scan(&tipRefundCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, tipRefundCount, "tip payments are not refundable as part of a cancellation")
|
||||
|
||||
// The real payment IS refunded.
|
||||
var fullRefundCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, payID).Scan(&fullRefundCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, fullRefundCount)
|
||||
}
|
||||
|
||||
func TestGetBookingPaymentInfo_ExcludesTips(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewPaymentService()
|
||||
info, err := svc.GetBookingPaymentInfo(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50.00, info.TotalPaid, "tips must not count toward TotalPaid")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M4-4: Terminal tip split in GetCheckoutStatus
|
||||
// =============================================================================
|
||||
|
||||
func TestGetCheckoutStatus_TerminalTipSplit(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5500,
|
||||
PaymentType: "full",
|
||||
TipEnabled: true,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var createResp CheckoutResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp))
|
||||
require.NotEmpty(t, createResp.CheckoutID)
|
||||
|
||||
resp := pollCheckoutStatus(t, ctx, createResp.CheckoutID, bookingID, adminToken)
|
||||
require.Equal(t, "COMPLETED", resp.Status)
|
||||
require.NotEmpty(t, resp.PaymentID)
|
||||
|
||||
// A £55 charge on a £50 booking (£5 above the booking value) is a tip →
|
||||
// split into deposit £25 + balance £25 + tip £5.
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT payment_type, amount, idempotency_key FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
ORDER BY payment_type
|
||||
`, bookingID)
|
||||
require.NoError(t, err)
|
||||
defer rows.Close()
|
||||
|
||||
amounts := map[string]float64{}
|
||||
keys := map[string]string{}
|
||||
for rows.Next() {
|
||||
var pt string
|
||||
var amt float64
|
||||
var key *string
|
||||
require.NoError(t, rows.Scan(&pt, &amt, &key))
|
||||
amounts[pt] = amt
|
||||
if key != nil {
|
||||
keys[pt] = *key
|
||||
}
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
|
||||
require.Len(t, amounts, 3, "terminal tip charge must split into deposit + balance + tip records")
|
||||
assert.Equal(t, 25.0, amounts["deposit"], "deposit = 50% of the £50 booking total")
|
||||
assert.Equal(t, 25.0, amounts["balance"], "balance = remaining booking total")
|
||||
assert.Equal(t, 5.0, amounts["tip"], "tip = the Square tip amount")
|
||||
|
||||
// The tip record gets its own derived idempotency key.
|
||||
assert.Contains(t, keys["tip"], "-split-tip")
|
||||
|
||||
// The tip record shares the Square payment id with the split records and is
|
||||
// NOT refundable (deposit + balance only).
|
||||
var tipSquareID, primarySquareID string
|
||||
err = tx.QueryRow(ctx, `SELECT square_payment_id FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipSquareID)
|
||||
require.NoError(t, err)
|
||||
err = tx.QueryRow(ctx, `SELECT square_payment_id FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'`, bookingID).Scan(&primarySquareID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, primarySquareID, tipSquareID, "one Square charge, three ledger rows")
|
||||
|
||||
// Refundable total must be £50 (the booking portion), not £55.
|
||||
svc := NewPaymentService()
|
||||
refundable, err := svc.GetBookingRefundableAmountPence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5000), refundable, "tips must not be part of the refundable total")
|
||||
}
|
||||
|
||||
// TestGetCheckoutStatus_TerminalTipSplit_DiscountedBooking locks the M4 fix
|
||||
// for a discounted charge: a terminal charge priced to the DISCOUNTED amount
|
||||
// plus an explicit tip must carve the tip against the DISCOUNTED obligation
|
||||
// (total − pending campaign discount − paid), NOT the full total — otherwise
|
||||
// the whole charge is booked as the booking portion and the tip is silently
|
||||
// absorbed into deposit/balance as service revenue. £50 booking with a pending
|
||||
// £5 (10%) campaign, charge £49.50 = £45 service + £4.50 tip → split into
|
||||
// deposit £25 + balance £20 + tip £4.50; the £5 discount then mints and the
|
||||
// booking completes fully paid.
|
||||
func TestGetCheckoutStatus_TerminalTipSplit_DiscountedBooking(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
now := clock.Now()
|
||||
var campaignID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
||||
VALUES ($1, 'time_based', 10, 'active', $2, $3, 10, 0)
|
||||
RETURNING id
|
||||
`, "M4 Terminal Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID))
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 4950, // £45 discounted service + £4.50 explicit tip
|
||||
PaymentType: "full",
|
||||
TipEnabled: true,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var createResp CheckoutResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp))
|
||||
require.NotEmpty(t, createResp.CheckoutID)
|
||||
|
||||
resp := pollCheckoutStatus(t, ctx, createResp.CheckoutID, bookingID, adminToken)
|
||||
require.Equal(t, "COMPLETED", resp.Status)
|
||||
|
||||
// £49.50 charge on a £50 booking with a pending £5 discount → booking
|
||||
// portion £45 (deposit £25 + balance £20) and tip £4.50 — the tip must NOT
|
||||
// be absorbed into the booking portion. (The discount row is a separate
|
||||
// payment_method='discount' ledger row and is excluded here.)
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT payment_type, amount FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_method NOT IN ('discount', 'on_the_house')
|
||||
ORDER BY payment_type
|
||||
`, bookingID)
|
||||
require.NoError(t, err)
|
||||
defer rows.Close()
|
||||
amounts := map[string]float64{}
|
||||
for rows.Next() {
|
||||
var pt string
|
||||
var amt float64
|
||||
require.NoError(t, rows.Scan(&pt, &amt))
|
||||
amounts[pt] = amt
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
require.Len(t, amounts, 3, "the discounted tip charge must split into deposit + balance + tip records")
|
||||
assert.InDelta(t, 25.0, amounts["deposit"], 0.001, "deposit = 50% of the £50 booking total")
|
||||
assert.InDelta(t, 20.0, amounts["balance"], 0.001, "balance = booking portion (£45) minus deposit")
|
||||
assert.InDelta(t, 4.5, amounts["tip"], 0.001, "the £4.50 explicit tip must be carved out, not absorbed")
|
||||
|
||||
// The pending £5 campaign discount must still mint (real money £45 +
|
||||
// discount £5 = £50) and the booking completes fully paid.
|
||||
var discountPay float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' AND status = 'completed'`, bookingID).Scan(&discountPay))
|
||||
assert.InDelta(t, 5.0, discountPay, 0.001, "the pending £5 campaign discount must mint")
|
||||
|
||||
var bookingStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
||||
assert.Equal(t, "completed", bookingStatus, "real money + discount must complete the booking")
|
||||
|
||||
var redeemed int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed))
|
||||
assert.Equal(t, 1, redeemed, "the campaign redemption counter must be incremented exactly once")
|
||||
}
|
||||
|
||||
func TestBuildTerminalSplitRecords_SplitsDepositBalanceTip(t *testing.T) {
|
||||
record := makeTestRecord("b-t-term", "full", 55)
|
||||
info := &BookingPaymentInfo{
|
||||
StartTime: clock.Now().Add(-1 * time.Hour),
|
||||
TotalAmount: 50,
|
||||
TotalPaid: 0,
|
||||
}
|
||||
records := buildTerminalSplitRecords(record, info, 50, 5)
|
||||
|
||||
require.Len(t, records, 3)
|
||||
assert.Equal(t, "deposit", records[0].PaymentType)
|
||||
assert.Equal(t, 25.0, records[0].Amount)
|
||||
assert.Equal(t, "balance", records[1].PaymentType)
|
||||
assert.Equal(t, 25.0, records[1].Amount)
|
||||
assert.Equal(t, "tip", records[2].PaymentType)
|
||||
assert.Equal(t, 5.0, records[2].Amount)
|
||||
assert.Equal(t, 0.0, records[2].Fees, "tip split record carries no fees")
|
||||
assert.Equal(t, *record.IdempotencyKey+"-split-tip", *records[2].IdempotencyKey)
|
||||
|
||||
var sum float64
|
||||
for _, r := range records {
|
||||
sum += r.Amount
|
||||
}
|
||||
assert.Equal(t, 55.0, sum, "records must partition the charged amount exactly")
|
||||
}
|
||||
|
||||
func TestBuildTerminalSplitRecords_NoDepositRoom_BalanceAndTip(t *testing.T) {
|
||||
// £50 deposit already paid on a £50 booking → no deposit room left; a
|
||||
// terminal charge of £55 (£50 booking + £5 tip) records balance + tip only.
|
||||
record := makeTestRecord("b-t-term2", "full", 55)
|
||||
info := &BookingPaymentInfo{
|
||||
StartTime: clock.Now().Add(-1 * time.Hour),
|
||||
TotalAmount: 50,
|
||||
TotalPaid: 50,
|
||||
}
|
||||
records := buildTerminalSplitRecords(record, info, 50, 5)
|
||||
|
||||
require.Len(t, records, 2)
|
||||
assert.Equal(t, "balance", records[0].PaymentType)
|
||||
assert.Equal(t, 50.0, records[0].Amount)
|
||||
assert.Equal(t, "tip", records[1].PaymentType)
|
||||
assert.Equal(t, 5.0, records[1].Amount)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M4-5: Admin manual refund endpoint
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminRefundBooking_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_refund' WHERE id = $1", payID)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := AdminBookingRefundRequest{Amount: 2000, Reason: "bad application"}
|
||||
handler := AdminRefundBooking
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/refund", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var resp AdminBookingRefundResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
require.Equal(t, int64(2000), resp.RefundedAmount)
|
||||
require.Len(t, resp.Refunds, 1)
|
||||
assert.Equal(t, payID, resp.Refunds[0].PaymentID)
|
||||
assert.Equal(t, int64(2000), resp.Refunds[0].Amount)
|
||||
assert.Equal(t, "completed", resp.Refunds[0].Status)
|
||||
|
||||
var status, origin string
|
||||
err = tx.QueryRow(ctx, `SELECT status, origin FROM refunds WHERE id = $1`, resp.Refunds[0].ID).Scan(&status, &origin)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Equal(t, "manual", origin)
|
||||
}
|
||||
|
||||
func TestAdminRefundBooking_OverRefundRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_refund' WHERE id = $1", payID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// £60 refund on a £50 booking — must be rejected.
|
||||
req := AdminBookingRefundRequest{Amount: 6000, Reason: "too much"}
|
||||
handler := AdminRefundBooking
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/refund", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "refundable")
|
||||
|
||||
var refundCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE booking_id = $1`, bookingID).Scan(&refundCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, refundCount, "no refund record may be created for an over-refund")
|
||||
}
|
||||
|
||||
func TestAdminRefundBooking_ExcludesTipsFromRefundable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_refund' WHERE id = $1", payID)
|
||||
require.NoError(t, err)
|
||||
tipID, err := fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_tip' WHERE id = $1", tipID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Refundable total is £50 (tip excluded) — refunding £55 must be rejected.
|
||||
req := AdminBookingRefundRequest{Amount: 5500, Reason: "including tip"}
|
||||
handler := AdminRefundBooking
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/refund", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var refundCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE booking_id = $1`, bookingID).Scan(&refundCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, refundCount, "tip money must not be refundable")
|
||||
}
|
||||
|
||||
func TestGetBookingRefundableAmountPence_ExcludesTips(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewPaymentService()
|
||||
refundable, err := svc.GetBookingRefundableAmountPence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(5000), refundable, "tips must not count toward the refundable amount")
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// M5: Fully-paid bookings auto-complete
|
||||
// =============================================================================
|
||||
|
||||
// TestBookingPayment_FullyPaid_CompletesBooking verifies that an online
|
||||
// payment covering 100% of the booking total transitions an active booking to
|
||||
// 'completed' so it leaves the admin's Current Appointment view.
|
||||
func TestBookingPayment_FullyPaid_CompletesBooking(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// setupTestData creates a £50 (5000 pence) booking with status in_progress.
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:fully-paid-complete"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "fully-paid-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", status, "a fully-paid booking must auto-complete")
|
||||
}
|
||||
|
||||
// TestBookingPayment_PartialPayment_DoesNotComplete verifies that a partial
|
||||
// payment (below 100%) leaves the booking in its active status.
|
||||
func TestBookingPayment_PartialPayment_DoesNotComplete(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
// £25 = 50% of the £50 booking total.
|
||||
cardToken := "cnon:partial-no-complete"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "partial",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "partial-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "in_progress", status, "a partial payment must not complete the booking")
|
||||
}
|
||||
|
||||
// TestBookingPayment_FullPaymentPlusTip_Completes verifies that a full payment
|
||||
// plus a later tip both succeed — the booking completes on the full payment
|
||||
// and the tip is still accepted on the completed booking.
|
||||
func TestBookingPayment_FullPaymentPlusTip_Completes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// setupTestDataPast: booking started 1 hour ago, status in_progress.
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:full-plus-tip"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "full-plus-tip-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", status)
|
||||
|
||||
// The tip is gratuity for a service already rendered — still accepted on a
|
||||
// completed booking (bookingStatusAllowsCompletedPayment includes it).
|
||||
tipToken := "cnon:tip-on-completed"
|
||||
tipReq := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &tipToken,
|
||||
}
|
||||
handler = CreateTipPayment
|
||||
w = makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", tipReq, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var tipCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, tipCount, "the tip must be recorded on the completed booking")
|
||||
|
||||
err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", status, "a tip must not revert the completed status")
|
||||
}
|
||||
|
||||
// TestTerminalPayment_FullyPaid_CompletesBooking verifies that a Square
|
||||
// Terminal (card-machine) charge covering 100% of the booking total also
|
||||
// auto-completes the booking.
|
||||
func TestTerminalPayment_FullyPaid_CompletesBooking(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
TipEnabled: false,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var createResp CheckoutResponse
|
||||
if err := parsePaymentResponseBody(w, &createResp); err != nil {
|
||||
t.Fatalf("failed to parse create response: %v", err)
|
||||
}
|
||||
require.NotEmpty(t, createResp.CheckoutID)
|
||||
|
||||
resp := pollCheckoutStatus(t, ctx, createResp.CheckoutID, bookingID, adminToken)
|
||||
require.Equal(t, "COMPLETED", resp.Status)
|
||||
require.NotEmpty(t, resp.PaymentID)
|
||||
|
||||
var status string
|
||||
err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", status, "a fully-paid terminal charge must auto-complete the booking")
|
||||
}
|
||||
|
||||
// TestFullyPaid_CancelledBooking_StaysCancelled verifies that a cancelled
|
||||
// booking can never be auto-completed by a payment: the payment is rejected
|
||||
// and the status is unchanged.
|
||||
func TestFullyPaid_CancelledBooking_StaysCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
_, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:cancelled-booking"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "cancelled-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusConflict, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "client_cancelled", status, "a cancelled booking must never be completed by a payment")
|
||||
|
||||
var payCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, payCount, "no payment may be recorded on a cancelled booking")
|
||||
}
|
||||
|
||||
// TestBookingPayment_FullyPaid_AwardsLoyaltyStamp verifies that the loyalty
|
||||
// stamp is awarded by the payment-driven completion, mirroring the admin
|
||||
// progress endpoint.
|
||||
func TestBookingPayment_FullyPaid_AwardsLoyaltyStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:loyalty-stamp"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "loyalty-stamp-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "completed", status)
|
||||
|
||||
var stamps int
|
||||
err = tx.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, stamps, "a payment-completed booking must award one loyalty stamp")
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// maxOnlineTipPence: online tip business bound (CreateTipPayment)
|
||||
// =============================================================================
|
||||
|
||||
// TestTipPayment_AtBound_Accepted pins the online tip bound's inclusive edge: a
|
||||
// tip exactly at maxOnlineTipPence (£250) is a legitimate business amount and
|
||||
// must flow through the normal happy path (charge, completed tip record).
|
||||
func TestTipPayment_AtBound_Accepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:tip-at-bound"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: maxOnlineTipPence,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
var resp PaymentResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
assert.Equal(t, "tip", resp.PaymentType)
|
||||
assert.Equal(t, "completed", resp.Status)
|
||||
assert.Equal(t, maxOnlineTipPence, resp.Amount, "the at-bound tip amount must be charged verbatim")
|
||||
}
|
||||
|
||||
// TestTipPayment_OverBound_Rejected pins the online tip bound's exclusive edge:
|
||||
// any tip above maxOnlineTipPence (£250) is rejected with 400 and a clear
|
||||
// message, and the rejection fires before any pending record insert or Square
|
||||
// charge — no tip payment row may be created.
|
||||
func TestTipPayment_OverBound_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cardToken := "cnon:tip-over-bound"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: maxOnlineTipPence + 1,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "Tip exceeds the maximum allowed amount")
|
||||
|
||||
var tipCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, tipCount, "an over-bound tip must not create any payment record")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// maxOnlineTipPence applies to the CreateBookingPayment OVERFLOW carve too:
|
||||
// the B12 gate (confirm_overflow_tip) must cap the tip portion it mints at
|
||||
// £250, mirroring the dedicated tip endpoint. A confirmed £10,000 payment on a
|
||||
// booking with £50 remaining would otherwise carve a £9,950 tip row.
|
||||
// =============================================================================
|
||||
|
||||
// TestBookingPayment_OverflowTip_OverCap_Rejected_PostStart is the confirmed
|
||||
// bypass regression: a POST-START booking (the carve at handlers.go's
|
||||
// buildSplitRecords post-start branch) with £50 remaining and a confirmed
|
||||
// £10,000 payment must be rejected 400 with the tip-cap message — even though
|
||||
// confirm_overflow_tip=true — and must not create any payment row.
|
||||
func TestBookingPayment_OverflowTip_OverCap_Rejected_PostStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:overflow-over-cap-post"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 1000000, // £10,000 on a £50-remaining booking → £9,950 tip portion
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "overflow-over-cap-post-" + bookingID,
|
||||
ConfirmOverflowTip: true,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "an over-cap overflow must be rejected even when confirmed, body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "Tip exceeds the maximum allowed amount")
|
||||
|
||||
var payCount int
|
||||
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, payCount, "the rejected over-cap overflow must not create any payment record")
|
||||
}
|
||||
|
||||
// TestBookingPayment_OverflowTip_OverCap_Rejected_PreStart is the same bypass
|
||||
// regression for the PRE-START carve (deposit/balance/tip split): the
|
||||
// confirmed £10,000 payment must be rejected before any pending row or Square
|
||||
// charge, so no tip can be minted over the cap.
|
||||
func TestBookingPayment_OverflowTip_OverCap_Rejected_PreStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:overflow-over-cap-pre"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 1000000, // £10,000 on a £50-remaining booking → £9,950 tip portion
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "overflow-over-cap-pre-" + bookingID,
|
||||
ConfirmOverflowTip: true,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "an over-cap overflow must be rejected even when confirmed, body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "Tip exceeds the maximum allowed amount")
|
||||
|
||||
var payCount int
|
||||
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, payCount, "the rejected over-cap overflow must not create any payment record")
|
||||
}
|
||||
|
||||
// TestBookingPayment_OverflowTip_WithinCap_CarvesTip pins the inclusive edge:
|
||||
// a confirmed £250 payment on a £50-remaining booking (tip portion £200 — under
|
||||
// the £250 cap) proceeds and buildSplitRecords carves a tip row of EXACTLY
|
||||
// £200 alongside a £50 booking portion.
|
||||
func TestBookingPayment_OverflowTip_WithinCap_CarvesTip(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:overflow-within-cap"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 25000, // £250 on a £50-remaining booking → tip portion £200
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "overflow-within-cap-" + bookingID,
|
||||
ConfirmOverflowTip: true,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a within-cap overflow must proceed, body: %s", w.Body.String())
|
||||
|
||||
var tipCount int
|
||||
var tipAmount float64
|
||||
err := tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, tipCount, "a within-cap overflow must carve exactly one tip record")
|
||||
assert.InDelta(t, 200.0, tipAmount, 0.001, "the carved tip must equal the £200 overflow, not more")
|
||||
|
||||
var bookingPortion float64
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'full'`, bookingID).Scan(&bookingPortion)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must remain the £50 obligation")
|
||||
}
|
||||
|
||||
// TestBuildSplitRecords_OverCapTip_Rejected proves the belt-and-braces cap in
|
||||
// buildSplitRecords itself: even a caller that skips the B12 gate cannot mint a
|
||||
// tip row over maxOnlineTipPence — both the post-start and pre-start carve
|
||||
// return an error instead of minting the over-cap tip. A £10,000 charge on a
|
||||
// £50 booking would otherwise carve a £9,950 tip row.
|
||||
func TestBuildSplitRecords_OverCapTip_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := makeTestRecord("b-cap-reject", "full", 10000)
|
||||
info := &BookingPaymentInfo{
|
||||
StartTime: clock.Now().Add(-2 * time.Hour),
|
||||
TotalAmount: 50,
|
||||
TotalPaid: 0,
|
||||
}
|
||||
|
||||
// Post-start carve: booking portion £50, tip portion £9,950 — over the cap.
|
||||
records, err := buildSplitRecords(record, "full", info, 10000)
|
||||
require.Error(t, err, "the post-start carve must reject an over-cap tip portion")
|
||||
assert.Contains(t, err.Error(), "exceeding the £250 online tip cap")
|
||||
assert.Nil(t, records)
|
||||
|
||||
// Pre-start carve: deposit £25 + balance £25, tip portion £9,950 — over the cap.
|
||||
preInfo := &BookingPaymentInfo{
|
||||
StartTime: clock.Now().Add(48 * time.Hour),
|
||||
TotalAmount: 50,
|
||||
TotalPaid: 0,
|
||||
}
|
||||
records, err = buildSplitRecords(record, "full", preInfo, 10000)
|
||||
require.Error(t, err, "the pre-start carve must reject an over-cap tip portion")
|
||||
assert.Contains(t, err.Error(), "exceeding the £250 online tip cap")
|
||||
assert.Nil(t, records)
|
||||
|
||||
// Inclusive boundary: a £250 charge on the £50 booking (tip portion £200)
|
||||
// stays below the cap and splits normally (single post-start record pair).
|
||||
withinRecord := makeTestRecord("b-cap-within", "full", 250)
|
||||
withinInfo := &BookingPaymentInfo{
|
||||
StartTime: clock.Now().Add(-2 * time.Hour),
|
||||
TotalAmount: 50,
|
||||
TotalPaid: 0,
|
||||
}
|
||||
within, err := buildSplitRecords(withinRecord, "full", withinInfo, 250)
|
||||
require.NoError(t, err, "a within-cap tip portion must split normally")
|
||||
require.Len(t, within, 2)
|
||||
assert.Equal(t, "full", within[0].PaymentType)
|
||||
assert.InDelta(t, 50.0, within[0].Amount, 0.001)
|
||||
assert.Equal(t, "tip", within[1].PaymentType)
|
||||
assert.InDelta(t, 200.0, within[1].Amount, 0.001)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPenceLess_RoundingBoundary pins the pence rounding that decides whether a
|
||||
// gift-card clawback was PARTIAL: penceLess compares pound-float balances by
|
||||
// rounding to integer pence, so sub-penny residue around roundingEpsilon
|
||||
// (0.004) must not flip the comparison. Amounts at or below the epsilon (0.4
|
||||
// pence) round to zero pence; 0.5+ pence rounds up.
|
||||
func TestPenceLess_RoundingBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b float64
|
||||
less bool
|
||||
}{
|
||||
{"both at the epsilon round to 0 pence — not less", 0.004, 0.004, false},
|
||||
{"0.004 vs 0.0041 both round to 0 pence", 0.004, 0.0041, false},
|
||||
{"0.0039 vs 0.004 both round to 0 pence", 0.0039, 0.004, false},
|
||||
{"0.0041 vs 0.0039 both round to 0 pence", 0.0041, 0.0039, false},
|
||||
{"0.004 (0 pence) is less than 0.005 (1 pence)", 0.004, 0.005, true},
|
||||
{"0.005 (1 pence) is NOT less than 0.004 (0 pence)", 0.005, 0.004, false},
|
||||
{"0.004 is less than 0.01 (1 pence)", 0.004, 0.01, true},
|
||||
{"equal amounts are never less", 12.34, 12.34, false},
|
||||
{"£12.34 is less than £12.35", 12.34, 12.35, true},
|
||||
{"£12.35 is NOT less than £12.34", 12.35, 12.34, false},
|
||||
{"a full pound difference", 0.0, 1.0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.less, penceLess(tt.a, tt.b), "penceLess(%v, %v)", tt.a, tt.b)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPenceLess_FloatToPenceRoundingEdges pins the float64→pence rounding the
|
||||
// comparison is built on: Go's math.Round rounds half away from zero, so
|
||||
// exactly 0.5 pence rounds UP (0.005 → 1 pence) while anything below rounds
|
||||
// down. The clawback's partial-detection must agree with this everywhere a
|
||||
// pound-denominated balance is compared.
|
||||
func TestPenceLess_FloatToPenceRoundingEdges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Exactly 0.5 pence rounds up: 0.005 → 1p, so 0.005 < 0.006 is false
|
||||
// (both round to 1) and 0.004999999 < 0.005000001 is true (0p vs 1p).
|
||||
require.Equal(t, int64(1), int64(math.Round(0.005*100)), "0.005 pounds must round to 1 pence")
|
||||
require.Equal(t, int64(0), int64(math.Round(0.004999999*100)), "0.004999999 pounds must round to 0 pence")
|
||||
require.False(t, penceLess(0.005, 0.006), "0.005 and 0.006 both round to 1 pence")
|
||||
require.True(t, penceLess(0.004999999, 0.005000001), "0p vs 1p — the partial boundary")
|
||||
|
||||
// A whole-pence gap always compares by rounded pence, never raw floats.
|
||||
require.False(t, penceLess(1.009, 1.01), "1.009 rounds to 1.01 → equal pence")
|
||||
require.False(t, penceLess(1.009, 1.011), "1.009 and 1.011 both round to 1.01 → equal pence")
|
||||
require.True(t, penceLess(1.009, 1.021), "1.009 rounds to 1.01, 1.021 rounds to 1.02 → less")
|
||||
}
|
||||
|
||||
// TestRoundingEpsilon_Value pins the "effectively zero" threshold shared by the
|
||||
// split builders and the cash/gift-card terminal branches. It must stay 0.004
|
||||
// (0.4 pence): sub-penny float residue from dividing pence by 100 must round to
|
||||
// zero pence, so a phantom payment row is never created, while a real 0.5-penny
|
||||
// value still rounds to 1 pence.
|
||||
func TestRoundingEpsilon_Value(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, 0.004, roundingEpsilon, "roundingEpsilon must be 0.004 (0.4 pence)")
|
||||
|
||||
// The epsilon itself rounds to zero pence; the first rounding-up value is
|
||||
// 0.005. Any epsilon above 0.004 would silently drop legitimate half-pence
|
||||
// amounts; anything below would over-report residue.
|
||||
require.Equal(t, int64(0), int64(math.Round(roundingEpsilon*100)))
|
||||
require.Equal(t, int64(1), int64(math.Round(0.005*100)))
|
||||
require.Equal(t, int64(0), int64(math.Round(0.0039*100)))
|
||||
require.Equal(t, int64(0), int64(math.Round(0.0041*100)))
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// M7 — a manual refund of a gift-card purchase must never create money
|
||||
// =============================================================================
|
||||
|
||||
// TestRefundPayment_GiftCardPurchase_Rejected locks the direct-handler guard:
|
||||
// a completed gift-card purchase payment (payments row with NO booking_id)
|
||||
// must be rejected with a clear message BEFORE any Square call or refund row —
|
||||
// refunding it at Square would return the cash while the issued card + balance
|
||||
// credit stay live (money created from nothing).
|
||||
func TestRefundPayment_GiftCardPurchase_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Seed a completed gift-card purchase payment: NO booking_id, Square
|
||||
// payment id set — exactly what BuyGiftCard inserts.
|
||||
var paymentID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, created_by, created_at, updated_at)
|
||||
VALUES ('full', 'online_square', 'completed', 10.00, 'pay_gc_purchase_refund_1', $1, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, userID).Scan(&paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
req := RefundRequest{Amount: 1000, Reason: "customer changed their mind"}
|
||||
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "a gift-card purchase must be rejected before any Square call, body: %s", rec.Body.String())
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "gift-card") {
|
||||
t.Errorf("expected the rejection message to direct the admin to the gift-card section, got %q", rec.Body.String())
|
||||
}
|
||||
|
||||
// No refund row may be created for the blocked refund.
|
||||
var refundCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount))
|
||||
require.Equal(t, 0, refundCount, "no refund row may exist for a rejected gift-card purchase refund")
|
||||
}
|
||||
|
||||
// TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued locks the sweep
|
||||
// resume path: a pending manual refund for a gift-card purchase payment must
|
||||
// NEVER be re-issued at Square (the sweep is a bypass of the handler guard).
|
||||
// The row is reconciled only — a COMPLETED refund at Square resolves it to
|
||||
// completed; a genuine no-match fails it + admin-notifies.
|
||||
func TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
const sqPayID = "pay_gc_purchase_sweep_1"
|
||||
var paymentID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('full', 'online_square', 'completed', 10.00, $1, 'gc-purchase-sweep-key', $2, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, sqPayID, userID).Scan(&paymentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var refundID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
|
||||
VALUES ($1, NULL, 10.00, 'pending', 'manual refund of gift-card purchase', 'refund-key-1', $2, NOW() - INTERVAL '1 minute', 'manual')
|
||||
RETURNING id
|
||||
`, paymentID, adminID).Scan(&refundID)
|
||||
require.NoError(t, err)
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
freshCtx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL AND created_at > NOW() - INTERVAL '1 hour'`)
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE id = $1`, refundID)
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE id = $1`, paymentID)
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
t.Run("no_completed_refund_at_square_fails_without_reissue", func(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
counting := &countingRefundClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, n, "a blocked gift-card-purchase refund must not count as money-moved")
|
||||
|
||||
if calls := counting.refundCalls(); len(calls) != 0 {
|
||||
t.Fatalf("expected NO Square refund re-issue for a gift-card purchase, got %d refund call(s)", len(calls))
|
||||
}
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
|
||||
require.Equal(t, "failed", status, "a gift-card-purchase manual refund with no Square refund must be failed, never re-issued")
|
||||
|
||||
var notifCount int
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`).Scan(¬ifCount))
|
||||
require.Equal(t, 1, notifCount, "the blocked refund must surface an admin notification")
|
||||
})
|
||||
|
||||
t.Run("completed_refund_at_square_resolves_completed", func(t *testing.T) {
|
||||
// Put the row back to pending (the previous sub-run failed it).
|
||||
_, err := db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', refund_attempts = 0 WHERE id = $1`, refundID)
|
||||
require.NoError(t, err)
|
||||
_, err = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
// Seed a COMPLETED refund at Square directly on the mock (this is the
|
||||
// historical attempt's money already having moved — NOT a re-issue).
|
||||
_, err = mock.RefundPayment(freshCtx, square.RefundPaymentReq{
|
||||
PaymentID: sqPayID,
|
||||
Amount: 1000,
|
||||
IdempotencyKey: "seed-refund-gc-purchase",
|
||||
Reason: "historical",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
counting := &countingRefundClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n, "the reconciled COMPLETED refund resolves the row")
|
||||
|
||||
if calls := counting.refundCalls(); len(calls) != 0 {
|
||||
t.Fatalf("expected NO Square refund re-issue even with a completed refund at Square, got %d refund call(s)", len(calls))
|
||||
}
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
|
||||
require.Equal(t, "completed", status, "an exact COMPLETED refund at Square resolves the row to completed")
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M7 — BuyGiftCard no-client-key fallback: retry dedup without collapse
|
||||
// =============================================================================
|
||||
|
||||
// buyGiftCardNoKey issues a BuyGiftCard request without a client-supplied
|
||||
// idempotency key and returns the response code + body.
|
||||
func buyGiftCardNoKey(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int, recipientType string) (int, string) {
|
||||
t.Helper()
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": amount,
|
||||
"recipient_type": recipientType,
|
||||
"new_card_token": "cnon:card-nonce-ok",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx))
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/user/giftcards/buy", BuyGiftCard)
|
||||
r.ServeHTTP(w, req)
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge locks the M7 fix:
|
||||
// a no-client-key purchase whose Square response is lost (charge COMMITTED at
|
||||
// Square, handler saw a 503, payment row left pending) must, when retried with
|
||||
// the identical body, re-derive the SAME deterministic fallback key, reuse the
|
||||
// pending row, and land ONE Square charge — the old fresh-random-suffix
|
||||
// fallback generated a new key per retry and charged twice.
|
||||
func TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
mock.FailAfterCommit = true
|
||||
counting := &countingPaymentClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
// Attempt 1: Square commits the charge under the derived key but the
|
||||
// response is lost — the payment row stays pending.
|
||||
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code == http.StatusCreated {
|
||||
t.Fatalf("expected the lost-response attempt to fail (charge committed, response lost), got 201: %s", body)
|
||||
}
|
||||
|
||||
mock.FailAfterCommit = false
|
||||
|
||||
// Retry: deterministic key reuse + Square dedup → the retry succeeds.
|
||||
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
|
||||
t.Fatalf("expected the retry to succeed, got %d: %s", code, body)
|
||||
}
|
||||
|
||||
// Exactly ONE payment row, ONE idempotency key, ONE issued card.
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
|
||||
require.Equal(t, 1, payCount, "exactly one payment row for a lost-response retry")
|
||||
|
||||
var keyCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
|
||||
require.Equal(t, 1, keyCount, "both attempts must share ONE deterministic fallback key")
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
|
||||
require.Equal(t, 1, cardCount, "exactly one gift card issued")
|
||||
|
||||
// Both Square calls reused the SAME key; only ONE successful payment exists.
|
||||
keys := counting.keys
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("expected 2 CreatePayment calls (attempt + retry), got %d", len(keys))
|
||||
}
|
||||
if keys[0] != keys[1] {
|
||||
t.Errorf("expected the retry to reuse the fallback key %q, got %q — a fresh key per retry is the double-charge bug", keys[0], keys[1])
|
||||
}
|
||||
if len(counting.payments) != 1 {
|
||||
t.Errorf("expected exactly ONE successful Square charge, got %d", len(counting.payments))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges locks the other
|
||||
// half of the tradeoff: two genuinely DISTINCT no-key purchases (different
|
||||
// amounts) must diverge onto distinct deterministic keys and issue two charges.
|
||||
func TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
for _, amount := range []int{2000, 5000} {
|
||||
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, amount, "self"); code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 for £%d purchase, got %d: %s", amount/100, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
var keyCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
|
||||
require.Equal(t, 2, keyCount, "two different-amount no-key purchases must get distinct keys")
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
|
||||
require.Equal(t, 2, payCount)
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
|
||||
require.Equal(t, 2, cardCount)
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges locks the
|
||||
// distinct-recipient branch of the tradeoff.
|
||||
func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
for _, recipient := range []string{"self", "friend"} {
|
||||
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, recipient); code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 for %s purchase, got %d: %s", recipient, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
var keyCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
|
||||
require.Equal(t, 2, keyCount, "different-recipient no-key purchases must get distinct keys")
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
|
||||
require.Equal(t, 2, payCount)
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
|
||||
require.Equal(t, 2, cardCount)
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey locks the A10 fix:
|
||||
// a no-key purchase whose payment row was swept/declined to 'failed' must NOT
|
||||
// permanently block the identical repurchase. The slot scan advances past BOTH
|
||||
// COMPLETED and FAILED rows (mirroring the till's scanTillIdempotencyKeySlot),
|
||||
// so the repurchase derives a FRESH key and charges again instead of
|
||||
// 409-rejecting forever on the failed row.
|
||||
func TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
// The deterministic fallback key a no-key £20 self purchase would derive.
|
||||
failedKey := fmt.Sprintf("gc-%s-2000-self-new", userID)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('full', 'online_square', 'failed', 20.00, $1, $2, NOW(), NOW())
|
||||
`, failedKey, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The identical repurchase must SUCCEED on a fresh key — not 409 forever.
|
||||
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
|
||||
t.Fatalf("expected the repurchase after a failed slot to succeed, got %d: %s", code, body)
|
||||
}
|
||||
|
||||
// Two distinct keys: the failed slot key + the fresh advance key.
|
||||
var keyCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
|
||||
require.Equal(t, 2, keyCount, "the repurchase must diverge onto a fresh key, not reuse the failed slot")
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_ForeignIdempotencyKey_NotReused locks the A6 fix: a
|
||||
// client-supplied idempotency key that matches ANOTHER user's payment row must
|
||||
// never be returned (completed), reused (pending), or rejected on (failed) —
|
||||
// cross-user hijack. The purchase proceeds as a fresh request on a fresh key.
|
||||
func TestBuyGiftCard_ForeignIdempotencyKey_NotReused(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
victimID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
attackerID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(attackerID, "verified_email")
|
||||
|
||||
// The victim's COMPLETED payment under a deterministic/guessable key.
|
||||
victimKey := fmt.Sprintf("gc-%s-2000-self-new", victimID)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('full', 'online_square', 'completed', 20.00, $1, $2, NOW(), NOW())
|
||||
`, victimKey, victimID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The attacker supplies the victim's key: must NOT get the victim's
|
||||
// completed payment back (which would be a false success leaking the
|
||||
// victim's row) — it must proceed as a fresh charge.
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 2000,
|
||||
"recipient_type": "self",
|
||||
"new_card_token": "cnon:card-nonce-ok",
|
||||
"idempotency_key": victimKey,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/user/giftcards/buy", BuyGiftCard)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code, "a foreign-key purchase must proceed as a fresh charge, body: %s", w.Body.String())
|
||||
|
||||
// A gift card was issued to the ATTACKER, not the victim.
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", attackerID).Scan(&cardCount))
|
||||
require.Equal(t, 1, cardCount, "the attacker must receive their own gift card")
|
||||
// The victim's payment row is untouched and the attacker got their own row.
|
||||
var victimPayCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", victimKey).Scan(&victimPayCount))
|
||||
require.Equal(t, 1, victimPayCount, "the victim's payment row must not be reused or duplicated")
|
||||
var attackerPayCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1 AND status = 'completed'", attackerID).Scan(&attackerPayCount))
|
||||
require.Equal(t, 1, attackerPayCount, "the attacker must have exactly one completed payment of their own")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fix 1 — tip 2FA gate asymmetry: saved-card tip with SCA tokenize-result
|
||||
// must skip the gate in an enforced deployment
|
||||
// =============================================================================
|
||||
|
||||
// TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds locks the
|
||||
// Fix 1 gate skip: a saved-card tip carrying an SCA tokenize-result token
|
||||
// (new_card_token alongside card_id) must skip the 2FA gate and complete,
|
||||
// matching the CreateBookingPayment scaTokenizedSavedCard pattern. Without the
|
||||
// fix, the tip path gates on card_id alone and refuses 402
|
||||
// verification_required because the legacy verification_token field is empty.
|
||||
func TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
// A completed payment is required before a tip can be added.
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_sca_ok", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
mc.SimulateSavedCardVerificationRequired = true
|
||||
SquareClient = mc
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
scaToken := "cnon:sca-4242_500_ok"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardID: &cardID,
|
||||
NewCardToken: &scaToken,
|
||||
IdempotencyKey: "enforced-tip-scatokenized",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result tip must skip the enforced gate and complete, body: %s", w.Body.String())
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&payCount))
|
||||
require.Equal(t, 1, payCount, "the SCA-tokenized tip must record exactly one completed tip payment")
|
||||
}
|
||||
|
||||
// TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_SaveCard_Succeeds
|
||||
// locks the Fix 1 SAVE gate skip: a save-card tip carrying an SCA tokenize-result
|
||||
// token must skip the SAVE gate and persist the card, matching the
|
||||
// CreateBookingPayment isSCATokenizeResultShape pattern.
|
||||
func TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_SaveCard_Succeeds(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(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)
|
||||
|
||||
// A cnon:sca-... token with save_card=true and no card_id is the
|
||||
// NEW-card SCA tokenize-result save shape — isSCATokenizeResultShape
|
||||
// must recognise it and skip the SAVE gate. Use the regular mock
|
||||
// (no SimulateSavedCardVerificationRequired) so the ccof charge from
|
||||
// CreateCardOnFile succeeds.
|
||||
scaToken := "cnon:sca-round9-save-tip"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &scaToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "enforced-tip-scasave",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result tip with save_card=true must skip the enforced SAVE gate and complete, body: %s", w.Body.String())
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&payCount))
|
||||
require.Equal(t, 1, payCount, "the SCA-tokenized save-card tip must record exactly one completed tip payment")
|
||||
}
|
||||
|
||||
// TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment locks the
|
||||
// H4 fix: when activeTerminalCheckoutID discovers a provisional (tmp-)
|
||||
// checkout COMPLETED at Square, it must RECORD the payment (mirroring the
|
||||
// sweep's recordUntrackedTerminalPayment) instead of only marking the row
|
||||
// COMPLETED and relying on a poll that may never come — a never-polled
|
||||
// checkout would otherwise leave the charge permanently unrecorded and
|
||||
// unrefundable via the app.
|
||||
func TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set booking in_progress: %v", err)
|
||||
}
|
||||
|
||||
const tmpID = "tmp-completed-no-poll"
|
||||
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
|
||||
|
||||
const sqPayID = "pay_provisional_completed_1"
|
||||
origClient := SquareClient
|
||||
SquareClient = &provisionalCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
checkoutID: tmpID,
|
||||
result: &square.PaymentResult{
|
||||
Status: "COMPLETED",
|
||||
SquarePayID: sqPayID,
|
||||
Amount: 5000,
|
||||
Fees: 88,
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
ReferenceID: bookingID,
|
||||
},
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
freshCtx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID)
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID)
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
// A recorded COMPLETED provisional checkout must release the in-flight
|
||||
// guard ("" returned), not wedge the booking.
|
||||
got := activeTerminalCheckoutID(freshCtx, bookingID)
|
||||
require.Equal(t, "", got, "a recorded COMPLETED provisional checkout must release the in-flight guard")
|
||||
|
||||
// The charge must be recorded as a payment row.
|
||||
var payCount int
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = $2`, bookingID, sqPayID).Scan(&payCount))
|
||||
require.Equal(t, 1, payCount, "the never-polled COMPLETED provisional charge must be recorded as a payment")
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, tmpID).Scan(&status))
|
||||
require.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED")
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test clients
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fixedCardClient returns the same Square card id for every CreateCardOnFile
|
||||
// call, simulating a card token that tokenizes to the same Square card for two
|
||||
// different users (the cross-user saved-card collision scenario).
|
||||
type fixedCardClient struct {
|
||||
square.SquareClient
|
||||
fixedCardID string
|
||||
}
|
||||
|
||||
func (c *fixedCardClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
|
||||
return &square.CardOnFile{
|
||||
ID: c.fixedCardID,
|
||||
CardID: c.fixedCardID,
|
||||
Brand: "VISA",
|
||||
Last4: "4242",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Fingerprint: "sqfp_shared",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// recordingCustomerClient counts CreateCustomer calls per email and returns a
|
||||
// deterministic customer id, so tests can assert provisioning happens exactly
|
||||
// once and the stored id is reused.
|
||||
type recordingCustomerClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
createCalls []string
|
||||
customerSeq int
|
||||
customerByID map[string]*square.CustomerResult
|
||||
}
|
||||
|
||||
func (c *recordingCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.customerByID == nil {
|
||||
c.customerByID = map[string]*square.CustomerResult{}
|
||||
}
|
||||
if existing, ok := c.customerByID[email]; ok {
|
||||
return existing, nil
|
||||
}
|
||||
c.customerSeq++
|
||||
res := &square.CustomerResult{ID: fmt.Sprintf("cus_mock_%d", c.customerSeq), Email: email}
|
||||
c.customerByID[email] = res
|
||||
c.createCalls = append(c.createCalls, email)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *recordingCustomerClient) customerCalls() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.createCalls...)
|
||||
}
|
||||
|
||||
// definitiveChargeClient simulates a Square charge rejection that can never
|
||||
// succeed (declined) — a definitive failure. createErr carries the structured
|
||||
// CARD_DECLINED error the real client produces (see the construction sites), so
|
||||
// chargeFailureStatus classifies it as 402 and isDefinitiveChargeFailure claws
|
||||
// the funded card back.
|
||||
type definitiveChargeClient struct {
|
||||
square.SquareClient
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (c *definitiveChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
return nil, c.createErr
|
||||
}
|
||||
|
||||
// ambiguousChargeClient simulates a transport-level charge failure where Square
|
||||
// may or may not have processed the payment — an ambiguous failure.
|
||||
type ambiguousChargeClient struct {
|
||||
square.SquareClient
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (c *ambiguousChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
return nil, c.createErr
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-user saved-card collision (schema + upsert fix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreatePaymentMethodFromToken_CrossUserSameCard_DoesNotMutateOtherUserRow(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userA, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
userB, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &fixedCardClient{SquareClient: square.NewDevClient(), fixedCardID: "ccof:shared_card"}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
svc := NewPaymentService()
|
||||
|
||||
// User A saves the card, then deletes it (soft delete + retention).
|
||||
cardA, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.DeletePaymentMethod(ctx, cardA.ID, userA))
|
||||
|
||||
var aDeletedAt string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&aDeletedAt))
|
||||
require.NotEqual(t, "", aDeletedAt, "user A's card must be soft-deleted")
|
||||
|
||||
// User B tokenizes the SAME card. With the old global UNIQUE(square_card_id)
|
||||
// this upsert targeted A's row — reviving A's deleted card, clearing its
|
||||
// retention, and returning A's card id to B.
|
||||
cardB, err := svc.CreatePaymentMethodFromToken(ctx, userB, "cnon:shared")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, cardA.ID, cardB.ID, "user B must get their own saved-card row, not user A's")
|
||||
|
||||
// Exactly two rows for the shared Square card (one per user).
|
||||
var rows int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE square_card_id = 'ccof:shared_card'`).Scan(&rows))
|
||||
require.Equal(t, 2, rows)
|
||||
|
||||
// A's row is still owned by A and still deleted — never mutated by B.
|
||||
var ownerA, deletedA string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&ownerA, &deletedA))
|
||||
require.Equal(t, userA, ownerA)
|
||||
require.NotEqual(t, "", deletedA, "user A's deleted card must not be revived by user B")
|
||||
|
||||
// B's row is active and owned by B.
|
||||
var ownerB, deletedB string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardB.ID).Scan(&ownerB, &deletedB))
|
||||
require.Equal(t, userB, ownerB)
|
||||
require.Equal(t, "", deletedB)
|
||||
|
||||
// Same-user retry still revives the deleted row (N-8 preserved): user A
|
||||
// re-tokenizes the same card → the existing row comes back, not a new one.
|
||||
cardARetry, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cardA.ID, cardARetry.ID, "same-user re-tokenize must revive the existing row")
|
||||
var revivedDeleted string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&revivedDeleted))
|
||||
require.Equal(t, "", revivedDeleted)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Till sale gift-card clawback on definitive charge failure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreateTillSale_DefinitiveFailure_ClawsBackCreatedGiftCard(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:test-card",
|
||||
IdempotencyKey: "till-clawback-create",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||
|
||||
// Definitive rejection — the sale is marked failed immediately (not left
|
||||
// pending for the sweep), so a same-key retry cannot re-complete against a
|
||||
// gift card that no longer exists.
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-create").Scan(&status))
|
||||
require.Equal(t, "failed", status)
|
||||
|
||||
// The created gift card was clawed back (deleted).
|
||||
var gcCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM gift_cards gc
|
||||
JOIN till_sales ts ON gc.id = ts.item_id
|
||||
WHERE ts.idempotency_key = $1
|
||||
`, "till-clawback-create").Scan(&gcCount))
|
||||
require.Equal(t, 0, gcCount)
|
||||
|
||||
// And its purchase transaction is gone too.
|
||||
var txCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM gift_card_transactions gct
|
||||
JOIN till_sales ts ON gct.reference_id = ts.id
|
||||
WHERE ts.idempotency_key = $1
|
||||
`, "till-clawback-create").Scan(&txCount))
|
||||
require.Equal(t, 0, txCount)
|
||||
}
|
||||
|
||||
func TestCreateTillSale_DefinitiveFailure_ClawsBackTopUp(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
var gcID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id
|
||||
`, adminID).Scan(&gcID))
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "topup",
|
||||
Amount: 25.00,
|
||||
GiftCardID: &gcID,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:test-card",
|
||||
IdempotencyKey: "till-clawback-topup",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-topup").Scan(&status))
|
||||
require.Equal(t, "failed", status)
|
||||
|
||||
// The top-up was reversed: the card is back to its pre-sale £50.
|
||||
var remaining float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, gcID).Scan(&remaining))
|
||||
require.Equal(t, 50.00, remaining)
|
||||
|
||||
// This request's top-up transaction is gone (prior accounting untouched).
|
||||
var txCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM gift_card_transactions
|
||||
WHERE gift_card_id = $1 AND reference_type = 'till_sale'
|
||||
`, gcID).Scan(&txCount))
|
||||
require.Equal(t, 0, txCount)
|
||||
}
|
||||
|
||||
func TestCreateTillSale_DefinitiveFailure_ClawsBackRedeemedCard(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
redeemUserID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:test-card",
|
||||
RedeemToUserID: &redeemUserID,
|
||||
IdempotencyKey: "till-clawback-redeem",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||
|
||||
// The redeemed-to-account credit was reversed.
|
||||
var balance float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1`, redeemUserID).Scan(&balance))
|
||||
require.Equal(t, 0.00, balance)
|
||||
}
|
||||
|
||||
func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &ambiguousChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:test-card",
|
||||
IdempotencyKey: "till-ambiguous",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code, "an ambiguous charge failure must classify as 503 (not a definitive 402)")
|
||||
|
||||
// Ambiguous failure — the sale stays pending for the sweep, NOT failed.
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-ambiguous").Scan(&status))
|
||||
require.Equal(t, "pending", status)
|
||||
|
||||
// The gift card stays funded so a late retry can complete the sale.
|
||||
var remaining float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT amount_remaining FROM gift_cards gc
|
||||
JOIN till_sales ts ON gc.id = ts.item_id
|
||||
WHERE ts.idempotency_key = $1
|
||||
`, "till-ambiguous").Scan(&remaining))
|
||||
require.Equal(t, 50.00, remaining)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remaining balance excludes tips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetBookingRemainingBalancePence_ExcludesTips(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewPaymentService()
|
||||
initial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, initial)
|
||||
|
||||
// £20 partial payment reduces the remaining balance.
|
||||
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "partial",
|
||||
PaymentMethod: "cash",
|
||||
Status: "completed",
|
||||
Amount: 20.00,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
afterPartial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, initial-2000, afterPartial)
|
||||
|
||||
// A £5 tip must NOT reduce the remaining balance — it is not payment toward
|
||||
// the booking total.
|
||||
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: 5.00,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
afterTip, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, afterPartial, afterTip, "a tip must not count toward the paid balance")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Customer provisioning on save (P14)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreatePaymentMethodFromToken_ProvisionsCustomerOnceAndReuses(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
svc := NewPaymentService()
|
||||
card1, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:visa")
|
||||
require.NoError(t, err)
|
||||
card2, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:mastercard")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, rec.customerCalls(), 1, "customer must be created exactly once and then reused from the stored id")
|
||||
|
||||
var cid1, cid2 string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card1.ID).Scan(&cid1))
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card2.ID).Scan(&cid2))
|
||||
require.NotEmpty(t, cid1)
|
||||
require.Equal(t, cid1, cid2, "both saved cards must share the user's Square customer id")
|
||||
}
|
||||
|
||||
// alwaysNewCustomerClient counts every CreateCustomer call and always returns a
|
||||
// fresh customer id (unlike recordingCustomerClient, which dedups by email and
|
||||
// would mask a second mint for the same user).
|
||||
type alwaysNewCustomerClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
createCalls int
|
||||
}
|
||||
|
||||
func (c *alwaysNewCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
|
||||
c.mu.Lock()
|
||||
c.createCalls++
|
||||
id := fmt.Sprintf("cus_cache_%d", c.createCalls)
|
||||
c.mu.Unlock()
|
||||
return &square.CustomerResult{ID: id, Email: email}, nil
|
||||
}
|
||||
|
||||
func (c *alwaysNewCustomerClient) customerCallCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.createCalls
|
||||
}
|
||||
|
||||
// TestInvalidateSquareCustomerCache_DropsCachedID proves the exported cache
|
||||
// invalidation: after GDPR erasure NULLs the DB square_customer_id and the
|
||||
// customer is deleted at Square, the process-local cache must not keep serving
|
||||
// the erased user's stale customer id. Without invalidation a later
|
||||
// ensureSquareCustomer would return the cached id without re-minting; after
|
||||
// invalidation it re-queries the (NULLed) DB and mints a fresh customer.
|
||||
func TestInvalidateSquareCustomerCache_DropsCachedID(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A saved-card row is the persistence point for the customer id; start
|
||||
// with a NULL square_customer_id so ensureSquareCustomer must mint one.
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
||||
VALUES ($1, 'sq_card_cache_test', 'Visa', '4242', 12, 2030, 'fp_cache', true)
|
||||
`, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &alwaysNewCustomerClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
|
||||
|
||||
svc := NewPaymentService()
|
||||
|
||||
// 1. First ensure mints a customer and caches it.
|
||||
c1, err := svc.EnsureSquareCustomer(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, c1)
|
||||
require.Equal(t, 1, rec.customerCallCount())
|
||||
|
||||
// Simulate GDPR erasure NULLing the saved-card square_customer_id.
|
||||
_, err = tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = NULL WHERE user_id = $1`, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 2. WITHOUT invalidation the stale cached id is still served (no re-mint).
|
||||
c2, err := svc.EnsureSquareCustomer(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c1, c2, "stale cached customer id must be served when the cache is NOT invalidated")
|
||||
require.Equal(t, 1, rec.customerCallCount())
|
||||
|
||||
// 3. Invalidate, then re-ensure: the entry is gone, so the NULLed DB is
|
||||
// re-queried and a fresh customer is minted — the stale identity must not
|
||||
// resurface.
|
||||
InvalidateSquareCustomerCache(userID)
|
||||
c3, err := svc.EnsureSquareCustomer(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, c1, c3, "after invalidation a fresh customer must be minted, not the stale cached id")
|
||||
require.Equal(t, 2, rec.customerCallCount())
|
||||
}
|
||||
|
||||
func TestBuyGiftCard_NoSaveCard_NoCustomerProvisioned(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
newToken := "cnon:test-card"
|
||||
reqBody := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
SaveCard: false,
|
||||
IdempotencyKey: "buy-gc-nosave-key",
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", reqBody, token, ctx)
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
|
||||
require.Empty(t, rec.customerCalls(), "one-off buy must not provision a Square customer")
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
||||
require.Zero(t, cardCount, "one-off buy must not persist a saved card")
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
@@ -644,35 +645,29 @@ func TestGetCheckoutStatus_Completed(t *testing.T) {
|
||||
t.Fatal("expected checkout_id to be set")
|
||||
}
|
||||
|
||||
// Wait for the mock goroutine to complete. The mock's goroutine sleeps 3s
|
||||
// by default (mockSleep is only skipped when isTesting is set before the
|
||||
// square package initializes, which depends on init ordering with db).
|
||||
time.Sleep(3500 * time.Millisecond)
|
||||
|
||||
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+createResp.CheckoutID+"/status?booking_id="+bookingID, nil)
|
||||
statusRCtx := chi.NewRouteContext()
|
||||
statusRCtx.URLParams.Add("checkout_id", createResp.CheckoutID)
|
||||
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
|
||||
if info := extractUserFromTestJWT(adminToken); info != nil {
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
|
||||
}
|
||||
statusReq = statusReq.WithContext(statusCtx)
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w2, statusReq)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// Poll for the mock goroutine to complete using assert.Eventually
|
||||
var resp PaymentStatusResponse
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to parse status response: %v", err)
|
||||
}
|
||||
if resp.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED, got %s", resp.Status)
|
||||
}
|
||||
assert.Eventually(t, func() bool {
|
||||
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+createResp.CheckoutID+"/status?booking_id="+bookingID, nil)
|
||||
statusRCtx := chi.NewRouteContext()
|
||||
statusRCtx.URLParams.Add("checkout_id", createResp.CheckoutID)
|
||||
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
|
||||
if info := extractUserFromTestJWT(adminToken); info != nil {
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
|
||||
}
|
||||
statusReq = statusReq.WithContext(statusCtx)
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w2, statusReq)
|
||||
if w2.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
return resp.Status == "COMPLETED"
|
||||
}, 10*time.Second, 200*time.Millisecond, "expected checkout to complete")
|
||||
if resp.PaymentID == "" {
|
||||
t.Error("expected payment_id to be set")
|
||||
}
|
||||
@@ -683,3 +678,241 @@ func TestGetCheckoutStatus_Completed(t *testing.T) {
|
||||
t.Error("expected card_last4 to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// pollCheckoutStatus polls GetCheckoutStatus until the checkout reports
|
||||
// COMPLETED, returning the decoded response.
|
||||
func pollCheckoutStatus(t *testing.T, ctx context.Context, checkoutID, bookingID, adminToken string) PaymentStatusResponse {
|
||||
t.Helper()
|
||||
var resp PaymentStatusResponse
|
||||
assert.Eventually(t, func() bool {
|
||||
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
|
||||
statusRCtx := chi.NewRouteContext()
|
||||
statusRCtx.URLParams.Add("checkout_id", checkoutID)
|
||||
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
|
||||
if info := extractUserFromTestJWT(adminToken); info != nil {
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
|
||||
}
|
||||
statusReq = statusReq.WithContext(statusCtx)
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w2, statusReq)
|
||||
if w2.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
return resp.Status == "COMPLETED"
|
||||
}, 10*time.Second, 200*time.Millisecond, "expected checkout to complete")
|
||||
return resp
|
||||
}
|
||||
|
||||
// createTerminalCheckout creates a terminal checkout via CreateTerminalPayment
|
||||
// and returns the checkout ID from the response.
|
||||
func createTerminalCheckout(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64) string {
|
||||
t.Helper()
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: amount,
|
||||
PaymentType: "full",
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var createResp CheckoutResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
|
||||
t.Fatalf("failed to decode create response: %v", err)
|
||||
}
|
||||
if createResp.CheckoutID == "" {
|
||||
t.Fatal("expected checkout_id to be set")
|
||||
}
|
||||
return createResp.CheckoutID
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_DoublePoll_SinglePaymentRow(t *testing.T) {
|
||||
// A double poll of the same terminal checkout must return the existing
|
||||
// payment row instead of inserting a duplicate (which previously 500'd on
|
||||
// the idempotency-key UNIQUE violation after the customer had paid).
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: origClient,
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
||||
|
||||
first := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
|
||||
if first.PaymentID == "" {
|
||||
t.Fatal("expected payment_id from first poll")
|
||||
}
|
||||
|
||||
// Second poll of the same checkout — deduped against the existing row.
|
||||
second := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
|
||||
if second.PaymentID == "" {
|
||||
t.Fatal("expected payment_id from second poll")
|
||||
}
|
||||
if second.PaymentID != first.PaymentID {
|
||||
t.Errorf("expected same payment_id on re-poll, got %q then %q", first.PaymentID, second.PaymentID)
|
||||
}
|
||||
|
||||
var rowCount int
|
||||
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payment rows: %v", err)
|
||||
}
|
||||
if rowCount != 1 {
|
||||
t.Errorf("expected exactly 1 payment row after double poll, got %d", rowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_TwoEqualAmountCharges_NoCollision(t *testing.T) {
|
||||
// Two distinct terminal charges on the same booking with the same final
|
||||
// amount must each create their own payment row (the deposit + equal-amount
|
||||
// balance case) — no 500 on the idempotency-key UNIQUE collision.
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: origClient,
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
||||
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
||||
|
||||
respA := pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
|
||||
respB := pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken)
|
||||
|
||||
if respA.PaymentID == "" || respB.PaymentID == "" {
|
||||
t.Fatal("expected payment_ids for both checkouts")
|
||||
}
|
||||
if respA.PaymentID == respB.PaymentID {
|
||||
t.Error("expected two distinct payment rows for two distinct Square charges")
|
||||
}
|
||||
|
||||
var rowCount int
|
||||
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payment rows: %v", err)
|
||||
}
|
||||
if rowCount != 2 {
|
||||
t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount)
|
||||
}
|
||||
}
|
||||
|
||||
// mismatchedRefCheckoutClient forces GetCheckout to return a fixed COMPLETED
|
||||
// payment whose reference_id points at a DIFFERENT booking, deterministically
|
||||
// exercising GetCheckoutStatus's ownership check.
|
||||
type mismatchedRefCheckoutClient struct {
|
||||
square.SquareClient
|
||||
result *square.PaymentResult
|
||||
}
|
||||
|
||||
func (c *mismatchedRefCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_EmptyReferenceID_Returns400(t *testing.T) {
|
||||
// A checkout with an EMPTY reference_id was created outside this app (no
|
||||
// booking was attached at creation time) — it must NOT be attachable to a
|
||||
// booking via polling. Fail closed with 400, exactly like a mismatched
|
||||
// reference, so a mis-scoped charge is never recorded.
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &mismatchedRefCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
result: &square.PaymentResult{
|
||||
ID: "pay_empty_ref",
|
||||
Status: "COMPLETED",
|
||||
Amount: 5000,
|
||||
SquarePayID: "pay_empty_ref",
|
||||
ReferenceID: "", // created outside this app — no booking reference
|
||||
CreatedAt: "2026-07-31T00:00:00Z",
|
||||
UpdatedAt: "2026-07-31T00:00:00Z",
|
||||
},
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation
|
||||
req := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("checkout_id", checkoutID)
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001")
|
||||
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for empty reference_id, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// No payment may be recorded for the unreferenced checkout.
|
||||
var rowCount int
|
||||
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount); err != nil {
|
||||
t.Fatalf("failed to count payment rows: %v", err)
|
||||
}
|
||||
if rowCount != 0 {
|
||||
t.Errorf("expected no payment rows after empty reference, got %d", rowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_ReferenceIDMismatch_Returns400(t *testing.T) {
|
||||
// The terminal checkout's reference_id must match the booking being
|
||||
// polled; a checkout that references a different booking is refused with
|
||||
// 400 so its payment can never be recorded against the wrong booking.
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &mismatchedRefCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
result: &square.PaymentResult{
|
||||
ID: "pay_mismatch",
|
||||
Status: "COMPLETED",
|
||||
Amount: 5000,
|
||||
SquarePayID: "pay_mismatch",
|
||||
ReferenceID: "00000000dead", // a DIFFERENT booking
|
||||
CreatedAt: "2026-07-31T00:00:00Z",
|
||||
UpdatedAt: "2026-07-31T00:00:00Z",
|
||||
},
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation
|
||||
req := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("checkout_id", checkoutID)
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001")
|
||||
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for reference_id mismatch, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// No payment may be recorded for the mismatched checkout.
|
||||
var rowCount int
|
||||
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount); err != nil {
|
||||
t.Fatalf("failed to count payment rows: %v", err)
|
||||
}
|
||||
if rowCount != 0 {
|
||||
t.Errorf("expected no payment rows after reference mismatch, got %d", rowCount)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,541 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// R6: ccof charges must ALWAYS carry a CustomerID; one-off non-save charges
|
||||
// use the cnon: nonce directly (no card-on-file, no customer).
|
||||
// =============================================================================
|
||||
|
||||
// TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges
|
||||
// covers the lazy Square-customer provisioning for saved-card rows that
|
||||
// predate P14 (square_customer_id empty): the row must be provisioned AND the
|
||||
// provisioned id persisted on the row BEFORE the ccof: charge goes out.
|
||||
func TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_legacy", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
// Row is seeded with square_customer_id empty (legacy).
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "legacy-cust-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.CustomerID
|
||||
rec.mu.Unlock()
|
||||
require.NotEmpty(t, got, "a legacy ccof: charge must be provisioned a Square customer before charging")
|
||||
|
||||
var persisted string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&persisted))
|
||||
require.Equal(t, got, persisted, "the provisioned customer id must be persisted on the saved-card row")
|
||||
}
|
||||
|
||||
// TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID covers the save
|
||||
// path of the new-card flow: the ccof: charge MUST carry the same provisioned
|
||||
// customer id that CreateCardOnFile used.
|
||||
func TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
origClient := SquareClient
|
||||
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
||||
rec := &recordingPaymentClient{SquareClient: cof}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:save-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "save-charge-cust-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
|
||||
require.NotEmpty(t, cof.lastCustomerID(), "a save-card flow must provision a Square customer")
|
||||
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.CustomerID
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) charge must carry the provisioned customer id")
|
||||
require.NotEmpty(t, got)
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID is the BuyGiftCard
|
||||
// counterpart of the above: the gift-card purchase's ccof: charge must carry
|
||||
// the provisioned customer id.
|
||||
func TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
||||
rec := &recordingPaymentClient{SquareClient: cof}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
newToken := "cnon:save-gift-card-nonce"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "buy-gc-save-charge-cust",
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
|
||||
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.CustomerID
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) gift-card purchase must carry the provisioned customer id")
|
||||
require.NotEmpty(t, got)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R2: BuyGiftCard requires an idempotency key
|
||||
// =============================================================================
|
||||
|
||||
// TestBuyGiftCard_MissingIdempotencyKey_Accepted verifies that M1: a missing
|
||||
// idempotency key is accepted and a deterministic key is generated server-side.
|
||||
// The purchase should succeed, not be rejected.
|
||||
func TestBuyGiftCard_MissingIdempotencyKey_Accepted(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
newToken := "cnon:test-card"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
SaveCard: false,
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
||||
require.Equal(t, http.StatusCreated, w.Code, "M1: missing idempotency key should be accepted (server generates deterministic key)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R3: provisional (pre-Square) terminal checkout rows
|
||||
// =============================================================================
|
||||
|
||||
// TestActiveTerminalCheckoutID_ResolvesProvisionalRow covers the crash-window
|
||||
// guard: a PENDING row with a synthetic "tmp-" checkout_id never had a checkout
|
||||
// created at Square, so it is provably not live — the guard must mark it failed
|
||||
// and allow a fresh checkout instead of wedging the booking.
|
||||
func TestActiveTerminalCheckoutID_ResolvesProvisionalRow(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
provisionalID := "tmp-crash-window-1"
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
|
||||
VALUES ($1, $2, 'full', 'PENDING', 50.00)
|
||||
`, provisionalID, bookingID); err != nil {
|
||||
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
|
||||
}
|
||||
|
||||
got := activeTerminalCheckoutID(ctx, bookingID)
|
||||
require.Equal(t, "", got, "a provisional (pre-Square) row must not block a new checkout")
|
||||
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
|
||||
require.Equal(t, "failed", status, "the provisional row must be marked failed")
|
||||
}
|
||||
|
||||
// TestSweepStaleTerminalCheckouts_TmpProvisional_ResolvesAgainstSquare locks
|
||||
// the H4 fix: a provisional "tmp-" terminal_checkouts row is resolved against
|
||||
// Square FIRST, never blind-failed. A hard crash between the row insert and
|
||||
// the provisional→real UPDATE can leave a LIVE checkout at Square (created
|
||||
// under the idempotency key embedded in the tmp id) while the row still
|
||||
// carries the synthetic id — so a tmp- row is NO LONGER provably not live.
|
||||
// A tmp- id Square has never seen (NOT_FOUND — the crash happened before the
|
||||
// Square call, or the id cannot be resolved) is the expected outcome and is
|
||||
// safely resolved to failed.
|
||||
func TestSweepStaleTerminalCheckouts_TmpProvisional_ResolvesAgainstSquare(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
|
||||
provisionalID := "tmp-sweep-provisional-1"
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
|
||||
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
|
||||
`, provisionalID, bookingID); err != nil {
|
||||
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
|
||||
}
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
freshCtx := context.Background()
|
||||
// Drop stale rows left by other sweep tests so the count is deterministic.
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, provisionalID); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
||||
}
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
||||
}
|
||||
|
||||
// A fresh mock holds no checkout under the tmp id → GetCheckout returns
|
||||
// the mock's plain "checkout not found" error (NOT_FOUND), the expected
|
||||
// outcome for a tmp- id that never reached Square → the row is resolved
|
||||
// to failed.
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n, "the stale provisional terminal checkout must be resolved by the sweep")
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
|
||||
require.Equal(t, "failed", status)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R5: GetDiscountPreviewHandler ownership (IDOR)
|
||||
// =============================================================================
|
||||
|
||||
// TestGetDiscountPreviewHandler_WrongOwner_Forbidden covers the IDOR fix: a
|
||||
// non-admin user must not read another user's booking discount preview.
|
||||
func TestGetDiscountPreviewHandler_WrongOwner_Forbidden(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
|
||||
|
||||
otherUserID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
otherToken := jwt.GenerateUserToken(otherUserID)
|
||||
|
||||
w := serveDiscountPreviewHandler(bookingID, otherUserID, otherToken, ctx)
|
||||
require.Equal(t, http.StatusForbidden, w.Code, "a non-owner must not read another user's discount preview")
|
||||
}
|
||||
|
||||
// TestGetDiscountPreviewHandler_NoUserContext_Unauthorized covers the fail-closed
|
||||
// 401 for requests that carry no user id.
|
||||
func TestGetDiscountPreviewHandler_NoUserContext_Unauthorized(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", bookingID)
|
||||
req = req.WithContext(ctx)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetDiscountPreviewHandler(w, req)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R8: DeletePaymentMethod disables the Square card before the local soft-delete
|
||||
// =============================================================================
|
||||
|
||||
type deletingCardClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (c *deletingCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
c.mu.Lock()
|
||||
c.deleted = append(c.deleted, cardID)
|
||||
c.mu.Unlock()
|
||||
return c.SquareClient.DeleteCardOnFile(ctx, cardID)
|
||||
}
|
||||
|
||||
func (c *deletingCardClient) deletedIDs() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.deleted...)
|
||||
}
|
||||
|
||||
func TestDeletePaymentMethod_DisablesSquareCard(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_disable", "VISA", "9999")
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &deletingCardClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
svc := NewPaymentService()
|
||||
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
|
||||
|
||||
require.Equal(t, []string{"ccof:sq_del_disable"}, rec.deletedIDs(), "the Square card must be disabled on local delete (R8)")
|
||||
|
||||
var deletedAt string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
|
||||
require.NotEqual(t, "", deletedAt, "the card must still be soft-deleted locally")
|
||||
}
|
||||
|
||||
// TestDeletePaymentMethod_SquareFailure_StillDeletesLocally covers the
|
||||
// best-effort contract: a Square disable failure must never block the local
|
||||
// soft-delete.
|
||||
func TestDeletePaymentMethod_SquareFailure_StillDeletesLocally(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_fail", "VISA", "8888")
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &failingDeleteClient{SquareClient: square.NewDevClient()}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
svc := NewPaymentService()
|
||||
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
|
||||
|
||||
var deletedAt string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
|
||||
require.NotEqual(t, "", deletedAt, "the local soft-delete must proceed even when the Square call fails")
|
||||
}
|
||||
|
||||
type failingDeleteClient struct {
|
||||
square.SquareClient
|
||||
}
|
||||
|
||||
func (c *failingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return fmt.Errorf("square: network error disabling card %s", cardID)
|
||||
}
|
||||
|
||||
// syncBuffer is a mutex-guarded slog writer so log records can be read safely
|
||||
// under -race.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// tokenSafeFailingDeleteClient fails with a token-free error so the
|
||||
// log-redaction test isolates redaction of the square_card_id attribute rather
|
||||
// than the error string.
|
||||
type tokenSafeFailingDeleteClient struct {
|
||||
square.SquareClient
|
||||
}
|
||||
|
||||
func (c *tokenSafeFailingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return fmt.Errorf("square: network error disabling card at Square")
|
||||
}
|
||||
|
||||
// TestDeletePaymentMethod_LogsRedactCardToken verifies the local card-delete
|
||||
// warning logs the redacted tokenPrefix form of the square_card_id (a ccof:
|
||||
// token), never the full value (SECURITY: full ccof tokens must not reach
|
||||
// server logs).
|
||||
func TestDeletePaymentMethod_LogsRedactCardToken(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
fullToken := "ccof:secret_token_123456"
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, fullToken, "VISA", "9999")
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &tokenSafeFailingDeleteClient{SquareClient: square.NewDevClient()}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
var sb syncBuffer
|
||||
origLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&sb, nil)))
|
||||
defer slog.SetDefault(origLogger)
|
||||
|
||||
svc := NewPaymentService()
|
||||
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
|
||||
|
||||
logs := sb.String()
|
||||
if strings.Contains(logs, fullToken) {
|
||||
t.Errorf("full ccof token %q leaked into logs: %q", fullToken, logs)
|
||||
}
|
||||
if !strings.Contains(logs, "ccof:sec...") {
|
||||
t.Errorf("expected redacted token prefix in logs, got %q", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveCardForUser_RevivesSoftDeletedCard verifies the SaveCardForUser
|
||||
// upsert: a user who soft-deleted a card (DeletePaymentMethod sets deleted_at,
|
||||
// but the row still occupies the UNIQUE (user_id, square_card_id) slot) and
|
||||
// then re-saves the SAME physical card via a save_card=true charge must get
|
||||
// the existing row revived — NOT a pgx.ErrNoRows 500 from the old
|
||||
// DO NOTHING + deleted_at IS NULL fallback.
|
||||
func TestSaveCardForUser_RevivesSoftDeletedCard(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &deletingCardClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_revive", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewPaymentService()
|
||||
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID), "soft-delete must succeed")
|
||||
|
||||
var deletedAt string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
|
||||
require.NotEqual(t, "", deletedAt, "precondition: card must be soft-deleted")
|
||||
|
||||
// Re-save the same physical card (same square_card_id → same UNIQUE slot).
|
||||
revivedID, err := svc.SaveCardForUser(ctx, userID, "cus_sq_revive", "ccof:sq_revive", "VISA", "4242", 12, 2030, "revive_fp")
|
||||
require.NoError(t, err, "re-saving a soft-deleted card must not error")
|
||||
require.Equal(t, cardID, revivedID, "the revived card must be the existing row, not a new insert")
|
||||
|
||||
var revivedDeletedAt sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT deleted_at FROM user_saved_cards WHERE id = $1`, revivedID).Scan(&revivedDeletedAt))
|
||||
require.False(t, revivedDeletedAt.Valid, "the revived card must have deleted_at cleared")
|
||||
}
|
||||
|
||||
// TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers proves the
|
||||
// validate:"max=45/64" caps on client-supplied idempotency keys: a key longer
|
||||
// than Square's per-endpoint limit would otherwise 400 at Square (misclassified
|
||||
// as a definitive 402 by chargeFailureStatus) with a confusingly worded error.
|
||||
func TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
_, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 46 chars — exceeds Square's 45-char /v2/payments, /v2/cards, /v2/refunds cap.
|
||||
tooLong := strings.Repeat("k", 46)
|
||||
// 65 chars — exceeds Square's 64-char terminal-checkout cap.
|
||||
tooLongCheckout := strings.Repeat("c", 65)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
token string
|
||||
}{
|
||||
{
|
||||
name: "terminal-saved-card",
|
||||
handler: CreateTerminalPayment,
|
||||
method: "POST",
|
||||
path: "/api/admin/bookings/" + bookingID + "/payment",
|
||||
body: CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", UserSavedCardID: strPtr("000000000001"), IdempotencyKey: tooLong},
|
||||
token: adminToken,
|
||||
},
|
||||
{
|
||||
name: "booking-payment",
|
||||
handler: CreateBookingPayment,
|
||||
method: "POST",
|
||||
path: "/api/bookings/" + bookingID + "/payment",
|
||||
body: CreateBookingPaymentRequest{Amount: 5000, PaymentType: "deposit", IdempotencyKey: tooLong},
|
||||
token: userToken,
|
||||
},
|
||||
{
|
||||
name: "tip",
|
||||
handler: CreateTipPayment,
|
||||
method: "POST",
|
||||
path: "/api/bookings/" + bookingID + "/tip",
|
||||
body: CreateTipPaymentRequest{Amount: 500, IdempotencyKey: tooLong},
|
||||
token: userToken,
|
||||
},
|
||||
{
|
||||
name: "gift-card-buy",
|
||||
handler: BuyGiftCard,
|
||||
method: "POST",
|
||||
path: "/api/gift-cards/buy",
|
||||
body: BuyGiftCardRequest{Amount: 1000, RecipientType: "self", NewCardToken: strPtr("cnon:test-card"), IdempotencyKey: tooLong},
|
||||
token: userToken,
|
||||
},
|
||||
{
|
||||
name: "till-terminal",
|
||||
handler: CreateTillSale,
|
||||
method: "POST",
|
||||
path: "/api/admin/till/sale",
|
||||
body: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "cash", IdempotencyKey: tooLongCheckout},
|
||||
token: adminToken,
|
||||
},
|
||||
{
|
||||
name: "refund",
|
||||
handler: RefundPayment,
|
||||
method: "POST",
|
||||
path: "/api/admin/payments/" + paymentID + "/refund",
|
||||
body: RefundRequest{Amount: 1000, Reason: "customer request", IdempotencyKey: tooLong},
|
||||
token: adminToken,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := makePaymentRequest(tc.handler, tc.method, tc.path, tc.body, tc.token, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code,
|
||||
"an over-length idempotency key must be rejected before reaching Square (got %d: %s)", w.Code, w.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// BuyGiftCard / CreateTillSale verification_token wiring
|
||||
// =============================================================================
|
||||
|
||||
// recordingCardOnFileClient records the customerID passed to
|
||||
// CreateCardOnFile (the new P14 4th parameter) so tests can assert save-card
|
||||
// flows provision the Square customer before tokenizing, while one-off flows
|
||||
// pass "".
|
||||
type recordingCardOnFileClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
customerID string
|
||||
cofCalls int
|
||||
}
|
||||
|
||||
func (c *recordingCardOnFileClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
|
||||
c.mu.Lock()
|
||||
c.customerID = customerID
|
||||
c.cofCalls++
|
||||
c.mu.Unlock()
|
||||
return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID)
|
||||
}
|
||||
|
||||
func (c *recordingCardOnFileClient) lastCustomerID() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.customerID
|
||||
}
|
||||
|
||||
func (c *recordingCardOnFileClient) callCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.cofCalls
|
||||
}
|
||||
|
||||
func TestBuyGiftCard_VerificationTokenPassthrough(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
vrf := "vrf_gc_token_789"
|
||||
newToken := "cnon:test-card"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
SaveCard: false,
|
||||
IdempotencyKey: "buy-gc-vrf-key",
|
||||
VerificationToken: &vrf,
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.VerificationToken
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square")
|
||||
}
|
||||
|
||||
func TestBuyGiftCard_VerificationTokenTooLongRejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
big := strings.Repeat("a", 600)
|
||||
newToken := "cnon:test-card"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
IdempotencyKey: "buy-gc-vrf-long-key",
|
||||
VerificationToken: &big,
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestBuyGiftCard_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
newToken := "cnon:test-card"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "buy-gc-save-cust-key",
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
|
||||
require.Equal(t, 1, rec.callCount())
|
||||
require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile")
|
||||
|
||||
var cid string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid))
|
||||
require.Equal(t, rec.lastCustomerID(), cid, "the stored square_customer_id must match the id passed to CreateCardOnFile")
|
||||
}
|
||||
|
||||
func TestBuyGiftCard_NoSaveCard_ChargesNonceDirectly(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
||||
rec := &recordingPaymentClient{SquareClient: cof}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
newToken := "cnon:test-card"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 1000,
|
||||
RecipientType: "self",
|
||||
NewCardToken: &newToken,
|
||||
SaveCard: false,
|
||||
IdempotencyKey: "buy-gc-nosave-cust-key",
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
|
||||
// R6: a one-off non-save charge uses the cnon: nonce DIRECTLY — no
|
||||
// card-on-file is created (nothing to orphan) and no customer is involved.
|
||||
require.Equal(t, 0, cof.callCount(), "a one-off non-save charge must NOT tokenize via CreateCardOnFile")
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.SourceID
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, newToken, got, "the nonce itself must be charged directly")
|
||||
require.Equal(t, "", rec.lastReq.CustomerID, "a cnon: nonce charge carries no customer")
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
||||
require.Zero(t, cardCount, "a one-off buy must not persist a saved card")
|
||||
}
|
||||
|
||||
func TestCreateTillSale_VerificationTokenPassthrough(t *testing.T) {
|
||||
_, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
vrf := "vrf_till_token_012"
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:visa",
|
||||
IdempotencyKey: "till-vrf-key",
|
||||
VerificationToken: &vrf,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.VerificationToken
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square")
|
||||
}
|
||||
|
||||
func TestCreateTillSale_VerificationTokenTooLongRejected(t *testing.T) {
|
||||
_, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
big := strings.Repeat("a", 600)
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:visa",
|
||||
IdempotencyKey: "till-vrf-long-key",
|
||||
VerificationToken: &big,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestCreateTillSale_OnlineSquare_NoCustomerProvisioned(t *testing.T) {
|
||||
_, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:visa",
|
||||
IdempotencyKey: "till-nosave-cust-key",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
require.Equal(t, 0, rec.callCount())
|
||||
require.Equal(t, "", rec.lastCustomerID(), "the ephemeral till card is a one-off cnon: charge — no CreateCardOnFile, no Square customer")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INSUFFICIENT_FUNDS and other definitive decline codes
|
||||
// =============================================================================
|
||||
|
||||
func TestIsDefinitiveChargeFailure_CoversInsufficientFunds(t *testing.T) {
|
||||
errs := []error{
|
||||
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/INSUFFICIENT_FUNDS] insufficient funds"),
|
||||
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/ADDRESS_VERIFICATION_FAILURE] avs mismatch"),
|
||||
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/TRANSACTION_LIMIT] limit reached"),
|
||||
}
|
||||
for _, err := range errs {
|
||||
if !isDefinitiveChargeFailure(err) {
|
||||
t.Errorf("expected %v to be classified as a definitive charge failure", err)
|
||||
}
|
||||
}
|
||||
if isDefinitiveChargeFailure(fmt.Errorf("network error: connection reset by peer")) {
|
||||
t.Error("expected an ambiguous transport error to NOT be definitive")
|
||||
}
|
||||
if isDefinitiveChargeFailure(nil) {
|
||||
t.Error("expected nil to not be a definitive charge failure")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GetCheckoutStatus — cancellation recheck before recording a completed payment
|
||||
// =============================================================================
|
||||
|
||||
func TestGetCheckoutStatus_CancelledBooking_RejectsRecord(t *testing.T) {
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
||||
|
||||
// Cancel the booking after the checkout was created but before it is polled —
|
||||
// a terminal payment landing on a cancelled booking must NOT be recorded.
|
||||
if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID); err != nil {
|
||||
t.Fatalf("failed to cancel booking: %v", err)
|
||||
}
|
||||
|
||||
var w *httptest.ResponseRecorder
|
||||
assert.Eventually(t, func() bool {
|
||||
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
|
||||
statusRCtx := chi.NewRouteContext()
|
||||
statusRCtx.URLParams.Add("checkout_id", checkoutID)
|
||||
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
|
||||
if info := extractUserFromTestJWT(adminToken); info != nil {
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
|
||||
}
|
||||
statusReq = statusReq.WithContext(statusCtx)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
GetCheckoutStatus(w, statusReq)
|
||||
return w.Code == http.StatusConflict
|
||||
}, 10*time.Second, 100*time.Millisecond, "expected the cancelled-booking checkout to be rejected with 409")
|
||||
|
||||
var completedCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount))
|
||||
require.Zero(t, completedCount, "no completed payment may be recorded on a cancelled booking")
|
||||
|
||||
var rowStatus string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&rowStatus))
|
||||
require.Equal(t, "failed", rowStatus, "the terminal_checkouts row must be marked failed so a fresh charge is possible")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// activeTerminalCheckoutID — definitively cancelled checkout must not wedge
|
||||
// =============================================================================
|
||||
|
||||
// canceledCheckoutClient makes one checkout report CANCELED at Square (the
|
||||
// error the real HTTP client produces for a non-COMPLETED, non-PENDING status)
|
||||
// while delegating everything else to the real mock.
|
||||
type canceledCheckoutClient struct {
|
||||
square.SquareClient
|
||||
checkoutID string
|
||||
}
|
||||
|
||||
func (c *canceledCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
||||
if checkoutID == c.checkoutID {
|
||||
return nil, fmt.Errorf("square: checkout %s is CANCELED (not COMPLETED)", checkoutID)
|
||||
}
|
||||
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
||||
}
|
||||
|
||||
func TestActiveTerminalCheckoutID_ResolvesCanceledCheckout(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
checkoutID := "chk_canceled_12345"
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
|
||||
VALUES ($1, $2, 'full', 'PENDING', 50.00)
|
||||
`, checkoutID, bookingID); err != nil {
|
||||
t.Fatalf("failed to seed terminal checkout: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &canceledCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
got := activeTerminalCheckoutID(ctx, bookingID)
|
||||
require.Equal(t, "", got, "a definitively CANCELED checkout must resolve to \"\" so a new checkout can be created")
|
||||
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&status))
|
||||
require.Equal(t, "failed", status, "the canceled checkout row must be marked failed")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SweepStaleTerminalCheckouts — terminal_checkouts (booking) coverage
|
||||
// =============================================================================
|
||||
|
||||
func TestSweepStaleTerminalCheckouts_CoversTerminalCheckoutsTable(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
mock.HoldCheckouts = true
|
||||
checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "chk-stale-terminal-booking",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
SquareClient = mock
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
|
||||
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
|
||||
`, checkout.ID, bookingID); err != nil {
|
||||
t.Fatalf("failed to seed stale terminal checkout row: %v", err)
|
||||
}
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
freshCtx := context.Background()
|
||||
// Drop stale rows left by other sweep tests so the count is deterministic.
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkout.ID); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
||||
}
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
||||
}
|
||||
|
||||
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n, "the stale booking terminal checkout must be resolved by the sweep")
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID).Scan(&status))
|
||||
require.Equal(t, "failed", status)
|
||||
|
||||
// The checkout must no longer be PENDING at Square (it was cancelled).
|
||||
if _, gErr := mock.GetCheckout(freshCtx, checkout.ID); gErr == nil || errors.Is(gErr, square.ErrCheckoutPending) {
|
||||
t.Errorf("expected checkout %s to be cancelled at Square (no longer pending), GetCheckout err=%v", checkout.ID, gErr)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Till-sale clawback on pending-retry definitive failure
|
||||
// =============================================================================
|
||||
|
||||
func TestCreateTillSale_PendingRetry_DefinitiveFailure_ClawsBack(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:sq_test_card_id", "VISA", "1234")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Seed a PENDING till_sale whose gift card was already funded by a prior
|
||||
// attempt of this same sale (the prior charge failed ambiguously). The
|
||||
// retry's definitive failure must claw the funding back.
|
||||
key := "till-pending-definitive-clawback-key"
|
||||
var giftCardID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id
|
||||
`, adminID).Scan(&giftCardID))
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
||||
$2, $3, $4, $5, NOW(), NOW())
|
||||
`, giftCardID, userID, cardID, key, adminID)
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "saved_card",
|
||||
UserSavedCardID: &cardID,
|
||||
UserID: &userID,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||
|
||||
// The reused sale row must be 'failed' and the previously funded gift card
|
||||
// clawed back — a definitive failure on retry means the charge can never
|
||||
// complete, so the funded card must not be left behind (free gift card).
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&status))
|
||||
require.Equal(t, "failed", status)
|
||||
|
||||
var gcCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&gcCount))
|
||||
require.Zero(t, gcCount, "the funded gift card must be clawed back after a definitive failure on retry")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// customer_id on saved-card (ccof:) charges
|
||||
// =============================================================================
|
||||
|
||||
func TestCreateBookingPayment_SavedCard_ForwardsCustomerID(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_saved", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
if _, err := tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = 'cus_test_123' WHERE id = $1`, cardID); err != nil {
|
||||
t.Fatalf("failed to set square_customer_id: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "saved-cust-" + bookingID,
|
||||
}
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.CustomerID
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, "cus_test_123", got, "a saved-card (ccof:) charge must carry the saved-card row's Square customer id")
|
||||
}
|
||||
|
||||
func TestCreateBookingPayment_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "save-cust-" + bookingID,
|
||||
}
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
require.Equal(t, 1, rec.callCount())
|
||||
require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile")
|
||||
|
||||
var cid string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid))
|
||||
require.Equal(t, rec.lastCustomerID(), cid)
|
||||
}
|
||||
|
||||
func TestCreateBookingPayment_NoSaveCard_ChargesNonceDirectly(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
origClient := SquareClient
|
||||
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
||||
rec := &recordingPaymentClient{SquareClient: cof}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: false,
|
||||
IdempotencyKey: "nosave-cust-" + bookingID,
|
||||
}
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
// R6: a one-off non-save charge uses the cnon: nonce DIRECTLY — no
|
||||
// card-on-file is created (nothing to orphan) and no customer is involved.
|
||||
require.Equal(t, 0, cof.callCount(), "a one-off non-save charge must NOT tokenize via CreateCardOnFile")
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.SourceID
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, cardToken, got, "the nonce itself must be charged directly")
|
||||
require.Equal(t, "", rec.lastReq.CustomerID, "a cnon: nonce charge carries no customer")
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// =============================================================================
|
||||
// ROUND 10 — gift-card value limits & the admin cancellation surface
|
||||
// =============================================================================
|
||||
//
|
||||
// This file pins the money-safety behaviours added in round 10:
|
||||
//
|
||||
// 1. Per-transaction £250 cap on the admin-funded gift-card entry points
|
||||
// (CreateGiftCard, TopUpGiftCard, TransferGiftCard): an amount of £251
|
||||
// (25,100 pence) is rejected with 400 before any row is written, while
|
||||
// exactly £250 (25,000 pence) stays inside the cap.
|
||||
//
|
||||
// 2. User daily cap of £500 on online gift-card purchases (BuyGiftCard): the
|
||||
// day's spend is the sum of the caller's gift_card_transactions 'purchase'
|
||||
// rows (reference_type 'api' — the signal BuyGiftCard itself writes, see
|
||||
// giftcard_limits.go userGiftCardSpentToday); an attempt that would cross
|
||||
// £500 is rejected 400, and the cap is inclusive (exactly £500 is
|
||||
// allowed). The cap is calendar-day (created_at >= CURRENT_DATE): rolling
|
||||
// yesterday's signal rows forward resets it.
|
||||
//
|
||||
// 3. Admin daily cap of £5,000 on gift-card value created/top-up'd: the day's
|
||||
// issued value is the sum of the cards the admin created today
|
||||
// (total_funds_added) plus the admin's same-day 'purchase'/'topup'
|
||||
// gift_card_transactions audit rows on cards created before today (see
|
||||
// giftcard_limits.go adminGiftCardValueToday); an operation that would
|
||||
// cross £5,000 is rejected 400, and the cap is inclusive.
|
||||
//
|
||||
// 4. AdminCancelGiftCard (POST /api/admin/gift-cards/cancel, body
|
||||
// {code, payment_id?}) reuses the 14-day partial-spend cancellation core:
|
||||
// for a card whose shortfall is verified till spend it refunds ONLY the
|
||||
// unspent remainder to the original payment method, zeroes + expires the
|
||||
// card, and records a 'giftcard_cancel' refunds row. Cards outside the
|
||||
// 14-day window are rejected 400 with no Square call.
|
||||
//
|
||||
// MONEY-SAFETY CONTRACT under test: a rejected operation must never write a
|
||||
// card/transaction/payment row and never call Square; an accepted cancellation
|
||||
// must issue EXACTLY ONE Square refund and must never leave the card's balance
|
||||
// spendable on top of the returned money (amount_remaining zeroed + expiry in
|
||||
// the past, atomically with the refund row resolution).
|
||||
//
|
||||
// BUILD DEPENDENCY: main.go already routes POST /admin/gift-cards/cancel to
|
||||
// AdminCancelGiftCard, so until that handler (and the round-10 limit checks)
|
||||
// are defined in this package the package cannot compile.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Round 10 helpers
|
||||
// =============================================================================
|
||||
|
||||
// round10CreateAdmin creates an admin user the way the existing admin gift-card
|
||||
// tests do (CreateTestUser + account_role update) and returns the user id and a
|
||||
// role-claim 'admin' token, matching main.go's admin group (mw.RequireAuth +
|
||||
// mw.RequireAdmin).
|
||||
func round10CreateAdmin(t *testing.T, ctx context.Context, q db.Querier) (adminID, token string) {
|
||||
t.Helper()
|
||||
adminID, err := fixtures.CreateTestUser(q)
|
||||
require.NoError(t, err)
|
||||
_, err = q.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
||||
require.NoError(t, err)
|
||||
return adminID, jwt.GenerateTestToken(adminID, "admin")
|
||||
}
|
||||
|
||||
// round10AdminCreateGiftCard POSTs a CreateGiftCard request through the real
|
||||
// router with mw.RequireAuth + mw.RequireAdmin (mirroring main.go's admin
|
||||
// group) and the test transaction embedded in the request context.
|
||||
func round10AdminCreateGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount float64) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(CreateGiftCardRequest{Amount: amount})
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards", bytes.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r = r.WithContext(db.ContextWithTx(r.Context(), tx))
|
||||
w := httptest.NewRecorder()
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.RequireAuth)
|
||||
router.With(mw.RequireAdmin).Post("/api/admin/gift-cards", CreateGiftCard)
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// round10AdminTopUpGiftCard PUTs a TopUpGiftCard request through the real
|
||||
// router with the admin middleware stack and the test transaction embedded in
|
||||
// the request context.
|
||||
func round10AdminTopUpGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, cardID string, amount float64) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(TopUpGiftCardRequest{Amount: amount, PaymentMethod: "cash"})
|
||||
r := httptest.NewRequest(http.MethodPut, "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r = r.WithContext(db.ContextWithTx(r.Context(), tx))
|
||||
w := httptest.NewRecorder()
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.RequireAuth)
|
||||
router.With(mw.RequireAdmin).Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// round10AdminTransferGiftCard POSTs a TransferGiftCard request through the
|
||||
// real router with the admin middleware stack and the test transaction
|
||||
// embedded in the request context.
|
||||
func round10AdminTransferGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, fromCardID, toCardID string, amount float64) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(TransferGiftCardRequest{ToCardID: toCardID, Amount: amount})
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards/"+fromCardID+"/transfer", bytes.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r = r.WithContext(db.ContextWithTx(r.Context(), tx))
|
||||
w := httptest.NewRecorder()
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.RequireAuth)
|
||||
router.With(mw.RequireAdmin).Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// round10AdminCancelGiftCard POSTs a gift-card cancellation through the ADMIN
|
||||
// endpoint (POST /api/admin/gift-cards/cancel) with the admin middleware stack
|
||||
// (mw.RequireAuth + mw.RequireAdmin, matching main.go) and the test transaction
|
||||
// embedded in the request context.
|
||||
func round10AdminCancelGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, code string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(CancelGiftCardRequest{Code: code})
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards/cancel", bytes.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r = r.WithContext(db.ContextWithTx(r.Context(), tx))
|
||||
w := httptest.NewRecorder()
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.RequireAuth)
|
||||
router.With(mw.RequireAdmin).Post("/api/admin/gift-cards/cancel", AdminCancelGiftCard)
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// round10BuyGiftCard POSTs an online gift-card purchase for a friend through
|
||||
// the real BuyGiftCard handler and returns the full response recorder so the
|
||||
// daily-limit message can be asserted. Mirrors round9BuyGiftCardForFriend but
|
||||
// keeps the body (that helper returns only the card id and status).
|
||||
func round10BuyGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": amount,
|
||||
"recipient_type": "friend",
|
||||
"new_card_token": "cnon:card-nonce-ok",
|
||||
"idempotency_key": fmt.Sprintf("round10-buy-%d-%d", amount, time.Now().UnixNano()),
|
||||
})
|
||||
r := httptest.NewRequest(http.MethodPost, "/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r = r.WithContext(db.ContextWithTx(r.Context(), tx))
|
||||
w := httptest.NewRecorder()
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.RequireAuth)
|
||||
router.With(mw.RequireNonGuest).Post("/user/giftcards/buy", BuyGiftCard)
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. £250 per-transaction cap on admin gift-card value entry points
|
||||
// =============================================================================
|
||||
|
||||
// TestRound10_AdminGiftCardTransaction_250Cap_Rejected pins the per-transaction
|
||||
// £250 cap on the three admin-funded gift-card entry points. For each of
|
||||
// CreateGiftCard, TopUpGiftCard and TransferGiftCard an amount of £251
|
||||
// (25,100 pence) must be rejected 400 with a message citing the cap BEFORE any
|
||||
// value moves (no card created, no top-up applied, no transfer executed), while
|
||||
// exactly £250 (25,000 pence) stays INSIDE the cap and succeeds. The cap is the
|
||||
// money-safety ceiling for a single admin-funded gift-card operation; without
|
||||
// it a mis-keyed admin entry could fund a card beyond the value the salon can
|
||||
// justify, so the boundary is pinned exactly.
|
||||
func TestRound10_AdminGiftCardTransaction_250Cap_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
adminID, adminToken := round10CreateAdmin(t, ctx, tx)
|
||||
|
||||
// Source card funds the top-up and transfer cases; destination receives
|
||||
// the transfer. Both are plain unredeemed non-inventory cards.
|
||||
var sourceID, destID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (300.00, 300.00, $1) RETURNING id`, adminID).Scan(&sourceID))
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (0, 0, $1) RETURNING id`, adminID).Scan(&destID))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
at func(t *testing.T, amount float64) *httptest.ResponseRecorder
|
||||
wantSuccess int
|
||||
}{
|
||||
{
|
||||
name: "CreateGiftCard",
|
||||
at: func(t *testing.T, amount float64) *httptest.ResponseRecorder {
|
||||
return round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, amount)
|
||||
},
|
||||
wantSuccess: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "TopUpGiftCard",
|
||||
at: func(t *testing.T, amount float64) *httptest.ResponseRecorder {
|
||||
return round10AdminTopUpGiftCard(t, ctx, tx.(pgx.Tx), adminToken, sourceID, amount)
|
||||
},
|
||||
wantSuccess: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "TransferGiftCard",
|
||||
at: func(t *testing.T, amount float64) *httptest.ResponseRecorder {
|
||||
return round10AdminTransferGiftCard(t, ctx, tx.(pgx.Tx), adminToken, sourceID, destID, amount)
|
||||
},
|
||||
wantSuccess: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// £251 (25,100 pence) — one penny over the £250 per-transaction cap.
|
||||
w := tc.at(t, 251.00)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "over-cap body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "£250", "the rejection must cite the £250 per-transaction cap")
|
||||
|
||||
// Boundary: exactly £250 (25,000 pence) is INSIDE the cap.
|
||||
wb := tc.at(t, 250.00)
|
||||
require.Equal(t, tc.wantSuccess, wb.Code, "boundary body: %s", wb.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 2. User daily cap of £500 on online gift-card purchases (BuyGiftCard)
|
||||
// =============================================================================
|
||||
|
||||
// TestRound10_UserGiftCardDailyLimit_500 pins the user-facing daily cap: a
|
||||
// user who has already purchased £500 of online gift cards today cannot buy any
|
||||
// more — a £50 purchase that would land the day on £550 is rejected 400 with
|
||||
// the daily-limit message ("You have reached your £500 daily gift-card purchase
|
||||
// limit"). A user at £450 today can still buy £50, landing the day on EXACTLY
|
||||
// £500 — pinning the cap as inclusive. (Per-purchase amounts are fixed at
|
||||
// £10/£20/£50, and the daily gate sits after that amount validation, so the
|
||||
// over-cap purchase is exercised at the maximum valid amount rather than a
|
||||
// £100 request, which the amount validation rejects first.) The day's spend
|
||||
// signal is the caller's gift_card_transactions 'purchase' rows written by
|
||||
// BuyGiftCard (reference_type 'api'), seeded here via round9SeedGiftCardPurchase.
|
||||
func TestRound10_UserGiftCardDailyLimit_500(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// --- Over-cap rejection: £500 already purchased today ---
|
||||
overUserID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
overToken := jwt.GenerateTestToken(overUserID, "verified_email")
|
||||
for i := 0; i < 10; i++ {
|
||||
round9SeedGiftCardPurchase(t, ctx, tx, overUserID, 50.00, 0)
|
||||
}
|
||||
|
||||
// A £50 purchase would take the day to £550 — over the £500 cap.
|
||||
w := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), overToken, 5000)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "£500", "the rejection must cite the £500 daily cap")
|
||||
assert.Contains(t, w.Body.String(), "daily", "the rejection must be the daily-limit message")
|
||||
|
||||
// --- Inclusive boundary: £450 purchased today, £50 still allowed ---
|
||||
boundaryUserID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
boundaryToken := jwt.GenerateTestToken(boundaryUserID, "verified_email")
|
||||
for i := 0; i < 9; i++ {
|
||||
round9SeedGiftCardPurchase(t, ctx, tx, boundaryUserID, 50.00, 0)
|
||||
}
|
||||
|
||||
// A £50 purchase takes the day to exactly £500 — inside the cap.
|
||||
wb := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), boundaryToken, 5000)
|
||||
require.Equal(t, http.StatusCreated, wb.Code, "boundary body: %s", wb.Body.String())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 3. Admin daily cap of £5,000 on gift-card value created/top-up'd
|
||||
// =============================================================================
|
||||
|
||||
// TestRound10_AdminGiftCardDailyLimit_5000 pins the admin daily cap: an admin
|
||||
// who has issued £4,900 of gift-card value today (CreateGiftCard/TopUpGiftCard
|
||||
// audit rows — reference_type 'api', user_id = the admin) cannot issue another
|
||||
// £200 (that would land the day on £5,100 — over the £5,000 cap) and is
|
||||
// rejected 400 with a message citing the cap, while a £100 issue that lands the
|
||||
// day on EXACTLY £5,000 is accepted, pinning the cap as inclusive. The day's
|
||||
// issued-value signal is seeded as both the gift-card row and its 'purchase'/
|
||||
// 'topup' gift_card_transactions rows so whichever query the limit code uses
|
||||
// sees £4,900.
|
||||
func TestRound10_AdminGiftCardDailyLimit_5000(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
adminID, adminToken := round10CreateAdmin(t, ctx, tx)
|
||||
|
||||
// £4,900 of admin-issued gift-card value today: one card plus the audit
|
||||
// rows CreateGiftCard/TopUpGiftCard write (transaction_type 'purchase'/
|
||||
// 'topup', reference_type 'api', user_id = the admin), both created today.
|
||||
var cardID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES (4900.00, 4900.00, $1) RETURNING id`, adminID).Scan(&cardID))
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes, created_at)
|
||||
VALUES ($1, 'purchase', 2400.00, 'api', NULL, $2, 'seeded daily signal', NOW())`, cardID, adminID)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes, created_at)
|
||||
VALUES ($1, 'topup', 2500.00, 'api', NULL, $2, 'seeded daily signal', NOW())`, cardID, adminID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A £200 creation would take the day to £5,100 — over the £5,000 cap.
|
||||
// (£200 is also inside the £250 per-transaction cap, isolating the daily gate.)
|
||||
w := round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, 200.00)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "£5,000", "the rejection must cite the £5,000 daily cap")
|
||||
|
||||
// A £100 creation takes the day to exactly £5,000 — inside the cap.
|
||||
wb := round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, 100.00)
|
||||
require.Equal(t, http.StatusCreated, wb.Code, "boundary body: %s", wb.Body.String())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 4. Admin cancellation reuses the 14-day partial-spend core
|
||||
// =============================================================================
|
||||
|
||||
// TestRound10_AdminCancelGiftCard_PartiallySpent_RefundsRemaining pins the
|
||||
// admin cancellation surface's handling of partial spend (CCR 2013 reg 34(9)):
|
||||
// a £50 online purchase whose balance was genuinely spent down to £30 at the
|
||||
// till (a completed giftcard payment row carrying the card id) is cancelled via
|
||||
// POST /api/admin/gift-cards/cancel as an admin → 200, Square refunds EXACTLY
|
||||
// once for the unspent remainder (3,000 pence), the card is neutralized (zeroed
|
||||
// + expired so the refunded value can never be spent on top of the returned
|
||||
// money), and the refunds row carries the 'giftcard_cancel' origin at the
|
||||
// unspent amount. This proves the admin surface exercises the same
|
||||
// partial-spend money path as the customer-facing flow.
|
||||
func TestRound10_AdminCancelGiftCard_PartiallySpent_RefundsRemaining(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
_, adminToken := round10CreateAdmin(t, ctx, tx)
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
counting := &countingRefundClient{SquareClient: mock}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
// £50 online purchase, £20 genuinely spent at the till (a completed
|
||||
// giftcard payment row carrying the card id), £30 remaining.
|
||||
cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0)
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id)
|
||||
VALUES (NULL, 'full', 'giftcard', 'completed', 20.00, 'r10-spend-' || $1::text, $2, NOW(), NOW(), $1)`, cardID, userID)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE idempotency_key = 'r10-spend-' || $1`, cardID)
|
||||
})
|
||||
|
||||
w := round10AdminCancelGiftCard(t, ctx, tx.(pgx.Tx), adminToken, cardID)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "£30.00", "the message must state the refunded unspent portion")
|
||||
assert.Contains(t, w.Body.String(), "£20.00", "the message must state the non-refundable spent portion")
|
||||
|
||||
// Exactly ONE Square refund, for the UNSPENT remainder (3000 pence).
|
||||
calls := counting.refundCalls()
|
||||
require.Len(t, calls, 1, "exactly one Square refund for the admin cancellation")
|
||||
assert.Equal(t, int64(3000), calls[0].Amount, "the unspent remainder must be refunded in pence")
|
||||
|
||||
// Card neutralized: zero balance, expired (cannot be spent).
|
||||
var rem float64
|
||||
var expiry sql.NullTime
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
|
||||
assert.Equal(t, 0.00, rem, "card balance must be zero after the admin cancellation")
|
||||
require.True(t, expiry.Valid, "the card must still carry an expiry date")
|
||||
assert.False(t, expiry.Time.After(clock.Now()), "card expiry must be in the past (neutralized)")
|
||||
|
||||
// Refund row recorded at the partial amount with the giftcard_cancel origin.
|
||||
var refundAmount float64
|
||||
var refundOrigin string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT amount, origin FROM refunds WHERE payment_id = $1`, paymentID).
|
||||
Scan(&refundAmount, &refundOrigin))
|
||||
assert.Equal(t, 30.00, refundAmount, "the refunds row must record the unspent remainder")
|
||||
assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund must carry the gift-card-cancel origin")
|
||||
}
|
||||
|
||||
// TestRound10_AdminCancelGiftCard_NotCancellable_Rejected pins the statutory
|
||||
// timing gate on the ADMIN cancellation surface: a card purchased outside the
|
||||
// 14-day cooling-off window (seeded 15 days ago) is rejected 400 with the
|
||||
// 14-day message and NO Square refund is issued — the cooling-off right is
|
||||
// time-limited regardless of who invokes it.
|
||||
func TestRound10_AdminCancelGiftCard_NotCancellable_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
_, adminToken := round10CreateAdmin(t, ctx, tx)
|
||||
|
||||
cardID, _ := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 15*24*time.Hour)
|
||||
|
||||
origClient := SquareClient
|
||||
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
w := round10AdminCancelGiftCard(t, ctx, tx.(pgx.Tx), adminToken, cardID)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "14-day", "the rejection must cite the 14-day cooling-off window")
|
||||
require.Empty(t, counting.refundCalls(), "no Square refund for a card outside the 14-day window")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 5. The user daily cap resets on the next calendar day
|
||||
// =============================================================================
|
||||
|
||||
// TestRound10_UserDailyLimit_ClearsNextDay pins the daily boundary of the user
|
||||
// purchase cap: after a user has purchased £500 today (exactly at the cap) a
|
||||
// further £50 purchase is rejected, but once the seeded purchases' timestamps
|
||||
// are rolled back to YESTERDAY the same £50 purchase succeeds — proving the cap
|
||||
// is calendar-day scoped and never counts spend from a previous day. Without
|
||||
// this, a single heavy day would permanently suppress future purchases (or, if
|
||||
// the boundary were a rolling window, a purchase at 23:59 would bleed into the
|
||||
// next day's allowance).
|
||||
func TestRound10_UserDailyLimit_ClearsNextDay(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
// £500 of purchases today — exactly at the cap.
|
||||
for i := 0; i < 10; i++ {
|
||||
round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0)
|
||||
}
|
||||
|
||||
// Any further purchase today is over the cap.
|
||||
w := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), token, 5000)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "£500", "the rejection must cite the £500 daily cap")
|
||||
|
||||
// Roll the seeded purchases back to yesterday across every table that
|
||||
// could carry the daily-spend signal (payments, gift_card_transactions,
|
||||
// gift_cards) so the day boundary resets regardless of which signal the
|
||||
// limit code queries.
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE payments SET created_at = created_at - INTERVAL '1 day'
|
||||
WHERE created_by = $1 AND booking_id IS NULL AND payment_method = 'online_square'`, userID)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_card_transactions SET created_at = created_at - INTERVAL '1 day'
|
||||
WHERE user_id = $1 AND reference_type = 'api' AND transaction_type = 'purchase'`, userID)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards SET created_at = created_at - INTERVAL '1 day'
|
||||
WHERE created_by = $1`, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The same £50 purchase now succeeds — yesterday's spend does not count
|
||||
// toward today's cap.
|
||||
wb := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), token, 5000)
|
||||
require.Equal(t, http.StatusCreated, wb.Code, "next-day body: %s", wb.Body.String())
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// R7 — CreateBookingPayment refunded-dedup 409 guard (paymentHasLiveRefund)
|
||||
// =============================================================================
|
||||
|
||||
// TestCreateBookingPayment_RefundedDedup_409 locks the money-safety guard on
|
||||
// the idempotent dedup path: a client-supplied idempotency_key that matches a
|
||||
// COMPLETED payment which has since been refunded must NOT be reported as
|
||||
// success (that would silently swallow a new equal-amount charge — the booking
|
||||
// shows paid with no money collected). The replay is rejected with 409 and no
|
||||
// new payment row is created.
|
||||
func TestCreateBookingPayment_RefundedDedup_409(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
const key = "client-uuid-refunded-dedup"
|
||||
cardToken := "cnon:refunded-dedup-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
handler := CreateBookingPayment
|
||||
|
||||
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w1.Code, "initial charge must succeed, body: %s", w1.Body.String())
|
||||
|
||||
// A £25 deposit on the £50 fixture booking leaves the booking confirmed
|
||||
// (not fully paid), so the retry reaches the GENERAL dedup path (with the
|
||||
// refund re-validation) rather than the completed-booking short-circuit.
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status))
|
||||
require.Equal(t, "confirmed", status, "the replay must hit the general dedup path")
|
||||
|
||||
var paymentID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'`, bookingID, key).Scan(&paymentID))
|
||||
|
||||
// The admin refunds the payment — a live (completed) refund row now makes
|
||||
// the payment's money no longer collectable.
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
||||
VALUES ($1, $2, 25.00, 'completed', 'admin refund', NOW())
|
||||
`, paymentID, bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Same key + same amount retry: MUST NOT return the refunded payment as
|
||||
// success.
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusConflict, w2.Code, "a refunded-payment replay must 409, body: %s", w2.Body.String())
|
||||
require.Contains(t, w2.Body.String(), "refunded and can no longer be replayed")
|
||||
|
||||
// No new payment row may be created by the rejected replay.
|
||||
var keyCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&keyCount))
|
||||
require.Equal(t, 1, keyCount, "the rejected replay must not create a second payment row")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R7 — sweep keyed blind-fail for rows past Square's key-retention window
|
||||
// =============================================================================
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedPastRetention_BlindFails locks the
|
||||
// payments-table blind-fail: a stale pending row with a stored idempotency_key
|
||||
// but no square_payment_id that is ALREADY older than Square's ~24h key
|
||||
// retention window when swept is marked 'failed' WITHOUT a replay reconcile
|
||||
// (replaying an expired key would misread the probe rejection as "never
|
||||
// charged"), and it counts toward the unverifiable WARN accounting.
|
||||
func TestSweepStalePendingPayments_KeyedPastRetention_BlindFails(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
|
||||
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||
require.NoError(t, err)
|
||||
// 25h old: past stalePendingPaymentAge (24h), so replayExpired (now-24h) is
|
||||
// already in the past even though the replay mock is FRESH — the row must be
|
||||
// blind-failed, never replayed.
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-past-retention-pay', square_source_id = 'cnon:test-card' WHERE id = $1", staleID); err != nil {
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
// Fresh mock: has no payment under the key, but the blind-fail must happen
|
||||
// WITHOUT any replay because the retention window already closed.
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
// The past-retention blind-fail must be counted in the unverifiable
|
||||
// accounting (resolved, NOT completed).
|
||||
resolved, completed, unverifiable, err := sweepKeyedStaleRows(context.Background(), "payments", clock.Now().Add(-stalePendingKeyedAge))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, resolved, "the past-retention keyed row must be resolved by the sweep")
|
||||
require.Equal(t, 0, completed, "a past-retention keyed row can never be rescued to completed")
|
||||
require.Equal(t, 1, unverifiable, "the past-retention blind-fail must count toward the unverifiable WARN accounting")
|
||||
|
||||
var rowStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
|
||||
require.Equal(t, "failed", rowStatus, "a keyed row past Square's retention window must be blind-failed")
|
||||
|
||||
// No reconcile happened: the row must not carry a square_payment_id.
|
||||
var sqPayID *string
|
||||
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&sqPayID))
|
||||
require.Nil(t, sqPayID, "a blind-failed row must never receive a square_payment_id")
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback
|
||||
// locks the same blind-fail for a KEYED till sale: the sale is marked 'failed'
|
||||
// and its funded gift card is NOT clawed back — unlike the proven-failure path,
|
||||
// the blind-fail's charge outcome is unknown, so the funding must stay put.
|
||||
func TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
pool := context.Background()
|
||||
|
||||
// seedStaleTillSaleWithCard already ages both the sale and its created gift
|
||||
// card to 25h (create) — past Square's 24h retention window. Add the stored
|
||||
// idempotency key to put it on the keyed pass.
|
||||
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
|
||||
if _, err := tx.Exec(ctx, "UPDATE till_sales SET idempotency_key = 'key-past-retention-till' WHERE id = $1", saleID); err != nil {
|
||||
t.Fatalf("failed to set the till sale idempotency key: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = square.NewDevClient()
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
if _, err := SweepStalePendingPayments(pool); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status))
|
||||
require.Equal(t, "failed", status, "a keyed till sale past retention must be blind-failed")
|
||||
|
||||
var cardCount int
|
||||
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount))
|
||||
require.Equal(t, 1, cardCount, "the blind-failed till sale's funded gift card must NOT be clawed back (charge outcome unknown)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R7 — reconcile tri-state status outcomes (by key AND by square_payment_id)
|
||||
// =============================================================================
|
||||
|
||||
// TestSweepStalePendingPayments_ReconcileByKey_TriState locks the status
|
||||
// switch in reconcileStalePaymentByKey: after a successful keyed replay,
|
||||
// CANCELED/FAILED are definitive failures (row marked failed), APPROVED/PENDING
|
||||
// are non-terminal (row left pending), and any unknown status is failed.
|
||||
func TestSweepStalePendingPayments_ReconcileByKey_TriState(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
wantFinal string // "failed" or "pending"
|
||||
}{
|
||||
{name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"},
|
||||
{name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"},
|
||||
{name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"},
|
||||
{name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"},
|
||||
{name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
|
||||
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||
require.NoError(t, err)
|
||||
key := "key-replay-tri-" + tc.name
|
||||
// 23h old: past the 22h keyed cutoff (so the keyed pass picks it up)
|
||||
// but still inside Square's 24h retention window (so the replay runs).
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, staleID); err != nil {
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status}}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
if _, err := SweepStalePendingPayments(context.Background()); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var rowStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
|
||||
require.Equal(t, tc.wantFinal, rowStatus, "replay-by-key status %q must leave the row %q", tc.status, tc.wantFinal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses locks the
|
||||
// same status switch in reconcileStalePaymentAtSquare (rows reconciled by their
|
||||
// stored square_payment_id).
|
||||
func TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
wantFinal string
|
||||
}{
|
||||
{name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"},
|
||||
{name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"},
|
||||
{name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"},
|
||||
{name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"},
|
||||
{name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
|
||||
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||
require.NoError(t, err)
|
||||
sqPayID := "sqp_reconcile_tri_" + tc.name
|
||||
// 25h old: past the 24h pass-2 cutoff, with a square_payment_id so
|
||||
// the reconcile runs by payment id (not by key).
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = $1 WHERE id = $2", sqPayID, staleID); err != nil {
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status, SquarePayID: sqPayID}}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx)
|
||||
require.NoError(t, pgxTx.Commit(ctx))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
if _, err := SweepStalePendingPayments(context.Background()); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var rowStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
|
||||
require.Equal(t, tc.wantFinal, rowStatus, "by-payment-id status %q must leave the row %q", tc.status, tc.wantFinal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R7 — resolveChargeSource Square-failure branches (all 500 + ok=false)
|
||||
// =============================================================================
|
||||
|
||||
// round7SquareFailureClient forces CreateCustomer / CreateCardOnFile to return
|
||||
// errors so the resolveChargeSource error branches can be exercised
|
||||
// deterministically (the embedding pattern mirrors staleGetPaymentClient /
|
||||
// staleReplayClient).
|
||||
type round7SquareFailureClient struct {
|
||||
square.SquareClient
|
||||
failCreateCustomer bool
|
||||
failCreateCard bool
|
||||
}
|
||||
|
||||
func (c *round7SquareFailureClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
|
||||
if c.failCreateCustomer {
|
||||
return nil, fmt.Errorf("square: network error creating customer")
|
||||
}
|
||||
return c.SquareClient.CreateCustomer(ctx, name, email)
|
||||
}
|
||||
|
||||
func (c *round7SquareFailureClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
|
||||
if c.failCreateCard {
|
||||
return nil, fmt.Errorf("square: network error creating card-on-file")
|
||||
}
|
||||
return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID)
|
||||
}
|
||||
|
||||
func TestResolveChargeSource_SquareFailureBranches_500NoOrphan(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ensure_square_customer_failure", func(t *testing.T) {
|
||||
userID, err := fixtures.CreateTestUser(db.Conn)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
InvalidateSquareCustomerCache(userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
}()
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
token := "cnon:r7-customer-fail"
|
||||
w := httptest.NewRecorder()
|
||||
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "")
|
||||
require.False(t, ok, "an EnsureSquareCustomer failure must fail source resolution")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.Empty(t, sourceID)
|
||||
require.Nil(t, savedCardID)
|
||||
require.Empty(t, sqCustID)
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
|
||||
require.Zero(t, rows, "no orphan saved-card row may be created when customer provisioning fails")
|
||||
})
|
||||
|
||||
t.Run("create_card_on_file_failure", func(t *testing.T) {
|
||||
userID, err := fixtures.CreateTestUser(db.Conn)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
InvalidateSquareCustomerCache(userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
}()
|
||||
// Provisioning succeeds from the cache; the card-on-file creation fails.
|
||||
squareCustomerCache.Store(userID, "cus_r7_createcard")
|
||||
defer InvalidateSquareCustomerCache(userID)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCard: true}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
token := "cnon:r7-createcard-fail"
|
||||
w := httptest.NewRecorder()
|
||||
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "")
|
||||
require.False(t, ok, "a CreateCardOnFile failure must fail source resolution")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.Empty(t, sourceID)
|
||||
require.Nil(t, savedCardID)
|
||||
require.Empty(t, sqCustID)
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
|
||||
require.Zero(t, rows, "no orphan saved-card row may be created when the Square card creation fails")
|
||||
})
|
||||
|
||||
t.Run("saved_card_lazy_provisioning_failure", func(t *testing.T) {
|
||||
userID, err := fixtures.CreateTestUser(db.Conn)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
InvalidateSquareCustomerCache(userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
}()
|
||||
// A pre-P14 saved-card row with an EMPTY square_customer_id: charging it
|
||||
// requires lazy provisioning, which fails here.
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(db.Conn, userID, "ccof:r7-legacy-card", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found")
|
||||
require.False(t, ok, "a lazy-provisioning failure must fail source resolution")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.Empty(t, sourceID)
|
||||
require.Nil(t, savedCardID)
|
||||
require.Empty(t, sqCustID)
|
||||
|
||||
// The pre-existing saved card must remain, still un-provisioned.
|
||||
var rows int
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND user_id = $2`, cardID, userID).Scan(&rows))
|
||||
require.Equal(t, 1, rows, "the existing saved card must not be deleted by the failed provisioning")
|
||||
var custID *string
|
||||
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, cardID).Scan(&custID))
|
||||
require.Nil(t, custID, "the failed provisioning must not persist a Square customer id")
|
||||
})
|
||||
|
||||
t.Run("get_card_non_404_failure", func(t *testing.T) {
|
||||
userID, err := fixtures.CreateTestUser(db.Conn)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
}()
|
||||
|
||||
// A cancelled context makes the GetCardByID query fail with a non-404
|
||||
// error (context canceled), exercising the 500 branch (NOT the 404
|
||||
// no-rows branch).
|
||||
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
cardID := "nonexistent-card-id"
|
||||
w := httptest.NewRecorder()
|
||||
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(cancelCtx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found")
|
||||
require.False(t, ok, "a non-404 GetCardByID failure must fail source resolution")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.Empty(t, sourceID)
|
||||
require.Nil(t, savedCardID)
|
||||
require.Empty(t, sqCustID)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R7 — CreateBookingPayment end-to-end charge-failure classification
|
||||
// =============================================================================
|
||||
|
||||
// round7ChargeClient forces CreatePayment to return a fixed error so the
|
||||
// charge-failure status mapping can be exercised end to end through the
|
||||
// handler (the pending record must stay pending on every failure).
|
||||
type round7ChargeClient struct {
|
||||
square.SquareClient
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (c *round7ChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
if c.createErr != nil {
|
||||
return nil, c.createErr
|
||||
}
|
||||
return c.SquareClient.CreatePayment(ctx, req)
|
||||
}
|
||||
|
||||
// round7CancelAwareChargeClient blocks inside CreatePayment until the request
|
||||
// context is cancelled and then returns ctx.Err() — simulating Square hanging
|
||||
// until the client gives up mid-charge.
|
||||
type round7CancelAwareChargeClient struct {
|
||||
square.SquareClient
|
||||
entered chan struct{}
|
||||
}
|
||||
|
||||
func (c *round7CancelAwareChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
close(c.entered)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// structuredSquareErrorWithCode builds a structured *square.squareAPIError of
|
||||
// the same concrete type the real client produces, re-stamped with the given
|
||||
// HTTP status and Square error code. The type is not nameable outside
|
||||
// internal/square, so the clone-through-reflection technique mirrors
|
||||
// errors_test.go's structuredSquareAPIError (which rewrites only the status);
|
||||
// here the code is also rewritten so a CARD_DECLINED decline can be produced.
|
||||
func structuredSquareErrorWithCode(t *testing.T, status int, code string) error {
|
||||
t.Helper()
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 1000,
|
||||
Currency: "GBP",
|
||||
SourceID: "ccof:card_1",
|
||||
})
|
||||
require.Error(t, err, "expected the mock to reject a ccof charge without a customer")
|
||||
v := reflect.ValueOf(err)
|
||||
require.Equal(t, reflect.Ptr, v.Kind(), "expected the structured error to be a pointer")
|
||||
clone := reflect.New(v.Elem().Type())
|
||||
clone.Elem().Set(v.Elem())
|
||||
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
|
||||
clone.Elem().FieldByName("Code").SetString(code)
|
||||
return clone.Interface().(error)
|
||||
}
|
||||
|
||||
func TestCreateBookingPayment_ChargeFailureStatuses_KeepPending(t *testing.T) {
|
||||
t.Run("structured_500_returns_503_keeps_pending", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:r7-structured-500"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "r7-500-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code, "a structured Square 500 must classify as 503, body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
|
||||
require.Equal(t, "pending", status, "an ambiguous charge failure must leave the row pending for a same-key retry")
|
||||
})
|
||||
|
||||
t.Run("card_declined_returns_402_keeps_pending", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:r7-card-declined"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "r7-declined-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code, "a definitive decline (CARD_DECLINED 4xx) must classify as 402, body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
|
||||
require.Equal(t, "pending", status, "a declined charge must leave the row pending")
|
||||
})
|
||||
|
||||
t.Run("cancelled_context_returns_503_keeps_pending", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
|
||||
entered := make(chan struct{})
|
||||
origClient := SquareClient
|
||||
SquareClient = &round7CancelAwareChargeClient{SquareClient: square.NewDevClient(), entered: entered}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
reqCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
cardToken := "cnon:r7-cancel-ctx"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "r7-cancel-" + bookingID,
|
||||
}
|
||||
done := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
done <- makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, reqCtx)
|
||||
}()
|
||||
select {
|
||||
case <-entered:
|
||||
// The charge reached Square with the DB pending record already
|
||||
// committed — cancel now.
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the charge never reached Square")
|
||||
}
|
||||
cancel()
|
||||
w := <-done
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code, "a cancelled charge context must classify as 503, body: %s", w.Body.String())
|
||||
|
||||
var status string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
|
||||
require.Equal(t, "pending", status, "a cancelled-context charge must leave the row pending")
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// R7 — deriveBookingPaymentIdempotencyKey >45-char sha256 truncation
|
||||
// =============================================================================
|
||||
|
||||
// TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic
|
||||
// locks the >45-char truncation: a candidate key built from a very long
|
||||
// bookingID + card must be hashed down to a deterministic ≤45-char key, and
|
||||
// the truncation must be input-sensitive (the seq-0 truncated key is distinct
|
||||
// from the seq-1 candidate's key — distinct inputs never collapse).
|
||||
func TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// A bookingID far longer than Square's 45-char idempotency-key limit.
|
||||
longBookingID := "pay-" + strings.Repeat("b", 60)
|
||||
cardPart := "ccof:some-long-card-id"
|
||||
base := fmt.Sprintf("pay-%s-%s-%d-%s", longBookingID, "deposit", 2500, cardPart)
|
||||
|
||||
key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart)
|
||||
require.NoError(t, err)
|
||||
if len(key1) > 45 {
|
||||
t.Errorf("the derived key must stay within Square's 45-char limit, got %d chars: %q", len(key1), key1)
|
||||
}
|
||||
require.True(t, strings.HasPrefix(key1, "pay-"), "the truncated key must keep the pay- prefix, got %q", key1)
|
||||
|
||||
// Deterministic: an identical re-derivation returns the same key.
|
||||
key2, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, key1, key2, "the truncated key must be deterministic")
|
||||
|
||||
// The truncation hashes the FULL candidate: verify the seq-0 key against
|
||||
// the sha256[:16] derivation and confirm it differs from the seq-1 key.
|
||||
h0 := sha256.Sum256([]byte(base))
|
||||
wantSeq0 := fmt.Sprintf("pay-%x", h0[:16])
|
||||
require.Equal(t, wantSeq0, key1, "the seq-0 truncated key must be sha256[:16] of the full candidate")
|
||||
|
||||
h1 := sha256.Sum256([]byte(base + "-1"))
|
||||
wantSeq1 := fmt.Sprintf("pay-%x", h1[:16])
|
||||
require.NotEqual(t, wantSeq1, key1, "the seq-0 truncated key must be distinct from the seq-1 truncated key")
|
||||
}
|
||||
|
||||
// TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct locks the
|
||||
// sequence-advance path under truncation: when the seq-0 truncated key is
|
||||
// occupied by a REFUNDED completed payment (so it can no longer be replayed),
|
||||
// the derive advances to a seq-1 truncated key that is distinct from seq-0 —
|
||||
// two different inputs must never collide in the truncated namespace.
|
||||
func TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
// A real CHAR(12) bookingID plus a long card ID pushes the candidate over
|
||||
// 45 chars while the booking_id stays insertable in the payments table.
|
||||
cardPart := "ccof:super-long-card-on-file-id-00000001"
|
||||
|
||||
key0, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart)
|
||||
require.NoError(t, err)
|
||||
if len(key0) > 45 {
|
||||
t.Errorf("the seq-0 truncated key must stay within 45 chars, got %d: %q", len(key0), key0)
|
||||
}
|
||||
|
||||
// Occupy the seq-0 slot with a REFUNDED completed payment so the derive
|
||||
// must advance to seq 1 (a refunded completed row never blocks a new
|
||||
// equal-amount charge).
|
||||
var paymentID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 'completed', 25.00, $2, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, bookingID, key0).Scan(&paymentID))
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
||||
VALUES ($1, $2, 25.00, 'completed', 'test refund', NOW())
|
||||
`, paymentID, bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, key0, key1, "the seq-1 truncated key must be distinct from the seq-0 key")
|
||||
if len(key1) > 45 {
|
||||
t.Errorf("the seq-1 truncated key must stay within 45 chars, got %d: %q", len(key1), key1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// ROUND 8 — money-safety testing gaps
|
||||
// =============================================================================
|
||||
//
|
||||
// This file pins four behaviors that keep money movements safe:
|
||||
//
|
||||
// 1. RefundPayment's manual guard rejects discount/on-the-house ledger rows
|
||||
// (a discount row is not real money, so refunding it would pay money out
|
||||
// of nothing) BEFORE any Square refund call or refund row is created.
|
||||
//
|
||||
// 2. The sweep's recordUntrackedTerminalPayment splits a COMPLETED terminal
|
||||
// charge that exceeds the remaining booking balance into deposit/balance/
|
||||
// tip records, applies per-record VAT via ApplyVATToBookingPayment, and
|
||||
// completes the now-fully-paid booking.
|
||||
//
|
||||
// 3. CreateTerminalPayment always sends AllowTipping: false in the Square
|
||||
// CreateCheckoutReq — the third leg of the tip double-count fix (the
|
||||
// frontend embeds the tip in the charge amount, so the terminal must not
|
||||
// prompt for a second one).
|
||||
//
|
||||
// 4. acquireAdvisoryXactLockBlocking (the deliberately-unbounded refund lock)
|
||||
// blocks a second waiter until the holder's transaction commits — the
|
||||
// "a refund must never be dropped" rationale for the unbounded wait.
|
||||
|
||||
// =============================================================================
|
||||
// T5 — RefundPayment manual guard rejects discount / on-the-house payments
|
||||
// =============================================================================
|
||||
|
||||
// TestRound8_RefundPayment_DiscountOrOnTheHouse_Rejected pins the T5 manual
|
||||
// guard: RefundPayment rejects a completed discount/on-the-house payment row
|
||||
// with 400 and a "Cannot refund a discount or complimentary payment" message,
|
||||
// BEFORE issuing any Square refund call and BEFORE creating any refund row. A
|
||||
// discount/on-the-house row is a ledger entry, not real money — the customer
|
||||
// never paid it, so refunding it would pay money out of nothing. The row is
|
||||
// seeded WITHOUT a square_payment_id so the rejection can only come from the
|
||||
// discount guard (the later "Payment has no Square reference" guard would
|
||||
// fire with a different message if the discount guard were ever removed).
|
||||
func TestRound8_RefundPayment_DiscountOrOnTheHouse_Rejected(t *testing.T) {
|
||||
for _, method := range []string{"discount", "on_the_house"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, method, "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Swap in a client that records every Square refund call so the
|
||||
// test can prove the guard fires before any money would move.
|
||||
origClient := SquareClient
|
||||
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = counting
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
req := RefundRequest{Amount: 1000, Reason: "round8 guard test"}
|
||||
w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+payID+"/refund", req, adminToken, ctx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "Cannot refund a discount or complimentary payment",
|
||||
"the message must identify the discount/complimentary rejection")
|
||||
|
||||
require.Empty(t, counting.refundCalls(),
|
||||
"no Square refund call may be issued for a discount/on-the-house payment")
|
||||
|
||||
var refundCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, payID).Scan(&refundCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, refundCount,
|
||||
"no refund row may be created for a discount/on-the-house payment")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// T6 — recordUntrackedTerminalPayment tip-split / VAT / completion branches
|
||||
// =============================================================================
|
||||
|
||||
// TestRound8_SweepUntrackedTerminal_OverBalance_TipSplit_VAT_CompletesBooking
|
||||
// pins the T6 money-safety contract of recordUntrackedTerminalPayment: a stale
|
||||
// "tmp-" terminal checkout that COMPLETED at Square with an amount ABOVE the
|
||||
// remaining booking balance (£55 on a £50 booking) must be recorded as THREE
|
||||
// ledger rows (deposit £25 + balance £25 + tip £5), each booking row must get
|
||||
// its VAT applied through ApplyVATToBookingPayment (the tip record must never
|
||||
// carry VAT), and the now-fully-paid booking must be transitioned to
|
||||
// 'completed' via completeFullyPaidBooking. This mirrors the existing
|
||||
// TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment but
|
||||
// exercises the over-balance split that its tipAmount=0 charge never reaches.
|
||||
func TestRound8_SweepUntrackedTerminal_OverBalance_TipSplit_VAT_CompletesBooking(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
// The booking must be in a payable state for the untracked charge to be
|
||||
// recorded (bookingStatusAllowsCompletedPayment) and completable by
|
||||
// completeFullyPaidBooking.
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set booking in_progress: %v", err)
|
||||
}
|
||||
// VAT-registered so the sweep's per-record ApplyVATToBookingPayment writes
|
||||
// vat_amount/net_amount on the split booking rows. The update is part of the
|
||||
// setup tx that is committed below, so the sweep sees it at pool level.
|
||||
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`); err != nil {
|
||||
t.Fatalf("failed to enable VAT registration: %v", err)
|
||||
}
|
||||
|
||||
const tmpID = "tmp-round8-tip-split"
|
||||
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
|
||||
// B3: the £5 overflow is only carved into a tip record when the customer
|
||||
// EXPLICITLY requested a tip (tip_enabled) — mark it so the split is
|
||||
// exercised here.
|
||||
if _, err := tx.Exec(ctx, `UPDATE terminal_checkouts SET tip_enabled = TRUE WHERE checkout_id = $1`, tmpID); err != nil {
|
||||
t.Fatalf("failed to mark the checkout tip-enabled: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
const sqPayID = "sqp_round8_tip_split"
|
||||
SquareClient = &provisionalCheckoutClient{
|
||||
SquareClient: square.NewDevClient(),
|
||||
checkoutID: tmpID,
|
||||
// £55 charged on a £50 booking: deposit £25 + balance £25 + tip £5.
|
||||
result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: sqPayID, Amount: 5500, Fees: 88, CardBrand: "VISA", CardLast4: "4242"},
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
require.NotNil(t, pgxTx, "no transaction in context")
|
||||
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
// Restore the shared business_settings row to the VAT-unregistered
|
||||
// baseline so parallel tests keep their own VAT expectations.
|
||||
_, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, default_vat_rate = 20.00`)
|
||||
})
|
||||
|
||||
// Drop any other stale terminal rows left by sequential tests so the count
|
||||
// is deterministic.
|
||||
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
||||
}
|
||||
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
||||
}
|
||||
|
||||
n, err := SweepStaleTerminalCheckouts(pool)
|
||||
require.NoError(t, err, "sweep failed")
|
||||
assert.Equal(t, 1, n, "the COMPLETED over-balance provisional checkout must be resolved by the sweep")
|
||||
|
||||
var status string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status))
|
||||
assert.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED")
|
||||
|
||||
// The untracked charge must be split: one £55 Square charge → deposit £25 +
|
||||
// balance £25 + tip £5 (three ledger rows sharing the square_payment_id).
|
||||
rows, err := db.Conn.Query(pool, `
|
||||
SELECT payment_type, amount, is_vat_applicable, vat_amount, net_amount
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND square_payment_id = $2
|
||||
ORDER BY payment_type
|
||||
`, bookingID, sqPayID)
|
||||
require.NoError(t, err, "failed to query recorded split payments")
|
||||
defer rows.Close()
|
||||
|
||||
type splitRow struct {
|
||||
paymentType string
|
||||
amount float64
|
||||
vatApplied bool
|
||||
vatAmount sql.NullFloat64
|
||||
netAmount sql.NullFloat64
|
||||
}
|
||||
splits := map[string]splitRow{}
|
||||
for rows.Next() {
|
||||
var r splitRow
|
||||
require.NoError(t, rows.Scan(&r.paymentType, &r.amount, &r.vatApplied, &r.vatAmount, &r.netAmount))
|
||||
splits[r.paymentType] = r
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
|
||||
require.Len(t, splits, 3, "the over-balance terminal charge must split into deposit + balance + tip records")
|
||||
assert.InDelta(t, 25.0, splits["deposit"].amount, 0.001, "deposit = 50%% of the £50 booking total")
|
||||
assert.InDelta(t, 25.0, splits["balance"].amount, 0.001, "balance = the remaining booking total")
|
||||
assert.InDelta(t, 5.0, splits["tip"].amount, 0.001, "tip = the charged amount above the booking value")
|
||||
|
||||
// Per-record VAT (ApplyVATToBookingPayment): the deposit and balance rows
|
||||
// carry 20% VAT of the £25 gross (£4.17 VAT, £20.83 net); the tip record
|
||||
// must never have VAT applied.
|
||||
for _, pt := range []string{"deposit", "balance"} {
|
||||
r := splits[pt]
|
||||
assert.True(t, r.vatApplied, "%s record must have VAT applied", pt)
|
||||
require.True(t, r.vatAmount.Valid, "%s record must have vat_amount set", pt)
|
||||
assert.InDelta(t, 4.17, r.vatAmount.Float64, 0.001, "%s record VAT (20%% of £25 gross)", pt)
|
||||
require.True(t, r.netAmount.Valid, "%s record must have net_amount set", pt)
|
||||
assert.InDelta(t, 20.83, r.netAmount.Float64, 0.001, "%s record net of 20%% VAT", pt)
|
||||
}
|
||||
assert.False(t, splits["tip"].vatApplied, "tip record must never have VAT applied")
|
||||
assert.False(t, splits["tip"].vatAmount.Valid, "tip record must have NULL vat_amount")
|
||||
assert.False(t, splits["tip"].netAmount.Valid, "tip record must have NULL net_amount")
|
||||
|
||||
// The £55 charge covers the full £50 booking (deposit + balance), so
|
||||
// completeFullyPaidBooking must have transitioned the booking to
|
||||
// 'completed' — the same completion the poll handler performs.
|
||||
var bookingStatus string
|
||||
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus))
|
||||
assert.Equal(t, "completed", bookingStatus, "a fully-paid booking must be completed by the sweep")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// T8 — CreateTerminalPayment sends AllowTipping: false to Square
|
||||
// =============================================================================
|
||||
|
||||
// recordingCheckoutClient records every CreateCheckoutReq so a test can assert
|
||||
// exactly what the handler sends to Square while delegating the actual call to
|
||||
// the underlying client (the same recording-client pattern as
|
||||
// recordingPaymentClient / countingRefundClient in the sibling files).
|
||||
type recordingCheckoutClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
reqs []square.CreateCheckoutReq
|
||||
}
|
||||
|
||||
func (c *recordingCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) {
|
||||
c.mu.Lock()
|
||||
c.reqs = append(c.reqs, req)
|
||||
c.mu.Unlock()
|
||||
return c.SquareClient.CreateCheckout(ctx, req)
|
||||
}
|
||||
|
||||
func (c *recordingCheckoutClient) checkoutReqs() []square.CreateCheckoutReq {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]square.CreateCheckoutReq(nil), c.reqs...)
|
||||
}
|
||||
|
||||
// TestRound8_CreateTerminalPayment_AllowTippingFalse pins the T8 leg of the
|
||||
// tip double-count fix: the frontend embeds the tip in the charge amount
|
||||
// (totalWithTip), so CreateTerminalPayment must pass AllowTipping: false in
|
||||
// the Square CreateCheckoutReq even when the client requests TipEnabled —
|
||||
// otherwise the terminal would prompt for a second tip and the tip would be
|
||||
// double-counted in production. The request is captured with a recording
|
||||
// client and asserted verbatim.
|
||||
func TestRound8_CreateTerminalPayment_AllowTippingFalse(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
origClient := SquareClient
|
||||
rec := &recordingCheckoutClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5500,
|
||||
PaymentType: "full",
|
||||
TipEnabled: true,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
reqs := rec.checkoutReqs()
|
||||
require.Len(t, reqs, 1, "exactly one CreateCheckoutReq must be sent to Square")
|
||||
assert.False(t, reqs[0].AllowTipping,
|
||||
"AllowTipping must be false even with TipEnabled — the tip is already embedded in the amount")
|
||||
assert.Equal(t, int64(5500), reqs[0].Amount, "the charge amount (tip embedded) must reach Square verbatim")
|
||||
assert.Equal(t, bookingID, reqs[0].ReferenceID, "the checkout must be scoped to the booking")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// T9 — acquireAdvisoryXactLockBlocking blocks waiters until the holder commits
|
||||
// =============================================================================
|
||||
|
||||
// TestRound8_AdvisoryXactLock_BlocksWaiterUntilCommit pins the T9 contract of
|
||||
// the deliberately-unbounded transaction-scoped refund lock: a second waiter
|
||||
// on the same "crussell:refund:" key must BLOCK (not time out, not proceed)
|
||||
// while the holder's transaction is open, and must acquire the lock — returning
|
||||
// nil — only after the holder commits. This is the "a refund must never be
|
||||
// dropped" rationale: if the manual RefundPayment holds the key across its
|
||||
// up-to-30s Square round-trip, a timed-out cancellation would abort and the
|
||||
// caller would commit a cancellation with ZERO refund rows created (no sweep
|
||||
// retry is possible because the rows never existed). Uses channels + timeouts
|
||||
// so the assertion never depends on a sleep; both transactions are rolled back
|
||||
// when the lock is not acquired.
|
||||
func TestRound8_AdvisoryXactLock_BlocksWaiterUntilCommit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
key := "crussell:refund:round8-locktest"
|
||||
|
||||
// Goroutine A: the holder. Its transaction stays OPEN until we commit it,
|
||||
// so the lock it holds is never released early.
|
||||
holderTx, err := db.Conn.Begin(ctx)
|
||||
require.NoError(t, err, "failed to begin holder tx")
|
||||
defer func() { _ = holderTx.Rollback(ctx) }()
|
||||
|
||||
require.NoError(t, acquireAdvisoryXactLockBlocking(ctx, holderTx, key),
|
||||
"the uncontended blocking xact lock must be acquired immediately")
|
||||
|
||||
// Goroutine B: the waiter. It signals that it has STARTED (its tx is open
|
||||
// and it is about to issue the blocking acquire) and then reports the
|
||||
// acquire result on a buffered channel.
|
||||
started := make(chan struct{})
|
||||
acquired := make(chan error, 1)
|
||||
go func() {
|
||||
waiterTx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
acquired <- err
|
||||
return
|
||||
}
|
||||
defer func() { _ = waiterTx.Rollback(ctx) }()
|
||||
close(started)
|
||||
acquired <- acquireAdvisoryXactLockBlocking(ctx, waiterTx, key)
|
||||
}()
|
||||
|
||||
<-started
|
||||
|
||||
// While the holder's tx is open, the waiter must NOT have returned.
|
||||
select {
|
||||
case err := <-acquired:
|
||||
t.Fatalf("waiter returned %v while the holder tx was still open — the blocking xact lock did not block", err)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
// Expected: the waiter is blocked on the holder's lock.
|
||||
}
|
||||
|
||||
// Release the lock by committing the holder's transaction; the waiter must
|
||||
// then acquire it and return nil.
|
||||
require.NoError(t, holderTx.Commit(ctx), "failed to commit holder tx")
|
||||
|
||||
select {
|
||||
case err := <-acquired:
|
||||
require.NoError(t, err, "the waiter must acquire the lock once the holder commits")
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("waiter never acquired the lock after the holder committed — the blocking xact lock did not release")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// policyTSValue extracts the numeric value of a `NAME: value,` entry from the
|
||||
// frontend's POLICY object literal (policy.ts), tolerating the tab-indented
|
||||
// formatting the file uses. Returns "" when the name is absent.
|
||||
func policyTSValue(t *testing.T, src, name string) string {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(name) + `:\s*(\d+(?:\.\d+)?),`)
|
||||
m := re.FindStringSubmatch(src)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// TestPolicyTS_CrossCheck pins the frontend's single-source policy constants
|
||||
// (frontend/src/lib/constants/policy.ts) to THIS package's refund_policy.go
|
||||
// values by REACTING to drift: the test reads the .ts file and asserts each
|
||||
// entry equals the backend constant, so a one-sided change on either side fails
|
||||
// CI. The frontend's own vitest suite (policy.test.ts) pins the same values in
|
||||
// the other direction, closing the drift loop both ways.
|
||||
func TestPolicyTS_CrossCheck(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "..", "frontend", "src", "lib", "constants", "policy.ts")
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "policy.ts not found at %s (tests run from the package dir; repo layout is <repo>/backend/handlers/payments + <repo>/frontend)", path)
|
||||
src := string(data)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"REQUIRED_DEPOSIT_PCT", "0.2"},
|
||||
{"PROTECTED_DEPOSIT_MAX_PCT", "0.5"},
|
||||
{"FULL_REFUND_THRESHOLD_HOURS", "72"},
|
||||
{"PARTIAL_REFUND_THRESHOLD_HOURS", "24"},
|
||||
{"NO_SHOW_THRESHOLD_HOURS", "24"},
|
||||
{"DEPOSIT_ADVANCE_HOURS", "36"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := policyTSValue(t, src, tc.name)
|
||||
require.NotEmpty(t, got, "policy.ts no longer declares %q — the constant may have been renamed or removed", tc.name)
|
||||
require.Equal(t, tc.want, got, "%s drifted from backend/handlers/payments/refund_policy.go", tc.name)
|
||||
})
|
||||
}
|
||||
|
||||
// The backend has no named reschedule constants; the frontend values must
|
||||
// track the refund tiers they were derived from.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS", strconv.Itoa(int(FullRefundThreshold.Hours()))},
|
||||
{"RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS", strconv.Itoa(int(PartialRefundThreshold.Hours()))},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := policyTSValue(t, src, tc.name)
|
||||
require.NotEmpty(t, got, "policy.ts no longer declares %q", tc.name)
|
||||
require.Equal(t, tc.want, got, "%s must track the backend refund threshold", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,16 @@ const (
|
||||
ProtectedDepositMaxPct = 0.50
|
||||
RequiredDepositPct = 0.20
|
||||
|
||||
// depositPromotionMinPct is the share of the booking total a payment must
|
||||
// cover before a pending_release booking is promoted back to 'confirmed'
|
||||
// (A10) — the deposit-promotion threshold in the CreateBookingPayment
|
||||
// deposit-promotion query. Named separately from RequiredDepositPct (the
|
||||
// deposit REQUIRED at booking time, used by bookings.go): the promotion
|
||||
// threshold is about already-paid money, not the amount to demand up front,
|
||||
// even though both are 20% today.
|
||||
// Currently equals RequiredDepositPct, but intentionally independent for future divergence.
|
||||
depositPromotionMinPct = 0.20
|
||||
|
||||
LoyaltyStampCost = 10
|
||||
LoyaltyDiscountPercent = 10.0
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user