The replayLegitimateRetryWindow constant (22h -> 24h -> 22h across three reviews)
was the wrong discriminator for 'original/retry vs expired-key duplicate' in the
stale-pending sweep: it is a row-age PROXY for 'when did THIS sweep replay the
key'. Each review flipped it because the true cutoff is the sweep's own replay
moment — a payment created at/after the sweep's ReplayPaymentByKey call can only
be the sweep's expired-key creation, and a payment created before it is the
original or a legit same-key retry, INDEPENDENT of Square's unverified ~24h key
retention (square_http_client.go:626).
- reconcileStalePaymentByKey now captures replayAt := clock.Now() immediately
before the replay call and threads it through
- replayRevealsNewCharge / replayWithinLegitimateWindow compare the replayed
payment's created_at against replayAt (upper bound) instead of
row.CreatedAt + a fixed constant; the 5m lower-bound clock-skew tolerance is
unchanged
- replayLegitimateRetryWindow constant and its rationale block removed (dead);
replayRescueLowerBoundSkew docstring updated to reference replayAt
- sweep_test comments updated to document the new discriminator + why the
constant approach flip-flopped (21h/22h/24h) and is now unnecessary
The keyed-replay tests (retry at 21.5h rescued; 25h-after-row auto-refunded)
still pass and now pin the correct, retention-window-independent behavior.
26/26 backend packages.
Loop B full-scope red-team (money/security/dup-mod) findings:
- CRITICAL: booking detail handlers (GetBookingHandler/GetAdminBookingHandler) now exclude payment_type='tip' from amount_paid — a tip before the final balance no longer undercharges the booking (bookings.go x3 sites)
- HIGH: A6 deposit clamp adds a zero-guard — when the eligible discount covers the entire deposit, the flow returns deposit_covered_by_discount instead of charging £0 at Square (real Square rejects £0; the mock accepted it); square_dev CreatePayment + CreateRefund now reject Amount <= 0 (mock/prod parity)
- HIGH: replayLegitimateRetryWindow restored to 22h (== stalePendingKeyedAge) so sweep-produced duplicate charges are still auto-refunded, not rescued-and-hidden
- HIGH: 2FA single-use consume-at-gate applied to ALL saved-card charge gates (booking 2263, admin saved-card 960, tip 4483, till 967, gift-card purchase 1482) with re-issue-on-failed-charge on each; pending-reuse retries keep their code
- MEDIUM: 2FA re-issue now fires only when the gate actually consumed a code (fresh saved-card path) — new-card failures no longer silently burn a standing code
- MEDIUM: pre_start tip-exclusion consistent across admin lists + detail handlers (bookings.go)
- MEDIUM: remaining-balance counts pending refunds (service.go) — capacity consistent with GetBookingPaymentInfo
- Mock CreatePayment/CreateRefund reject £0 amounts (INVALID_REQUEST_ERROR) for dev/prod parity
26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
Full-scope Loop A restart review (18 findings across money/security/dup-mod):
MONEY:
- HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking
- MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount
- MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit
- MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx)
- LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded
SECURITY:
- 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure)
- Admin 2FA mint now writes admin_audit_log + logs code reuse
- Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account
- Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts)
- family-alive cache invalidated on password change / GDPR erasure
- Login lockout keyed per user+IP with a capped ceiling
FRONTEND/DUP-MOD:
- OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware)
- PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard)
- requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode)
- BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently
26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
Loop B dup/mod attack findings:
- TillPurchases admin 2FA gate now mirrors PaymentModal and the backend: gateActive = twoFactorEnforced && customerTwoFactorEnabled && paymentMethod='saved_card'; the customer's setup flag is fetched from GET /api/admin/users/{id} on selection. A 2FA-disabled customer in an enforced env no longer hits a dead-end blocked input — the charge 403 surfaces the actionable message via the existing self-heal.
- Extracted twoFAMintThrottled helper shared by SetupTwoFAHandler and ensurePendingTwoFACode — mint-cooldown rule can no longer drift between setup and disable-flow paths
- Notification-helper drift documented: sweep copy states the intentional booking+user scoping vs the canonical webhook copy (cross-referenced); auth refresh_token_reuse insert verified to carry the same NOT EXISTS acknowledged_at IS NULL guard; no import cycle (webhooks→payments one-way)
- Pence convention: 'rounded to the cent' corrected to 'pence' (handlers.go:2726)
26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
Loop B restart (money/security/dup-mod adversarial) fixes:
- CRITICAL: CreateTerminalPayment rejects payment_type='tip' (mirrors CreateBookingPayment) — a tip-typed admin charge no longer records the FULL amount as a tip and double-collects (all is-paid computations exclude tip rows)
- HIGH: tip refunds can no longer re-open booking capacity — refunded_total subqueries filter payment_type <> 'tip' (service.go) and RefundPayment rejects tip rows
- MEDIUM: loyalty-stamp farming closed — stamp award once-per-booking via loyalty_stamp_awarded_at column (init-script.sql) + existing same-day guard
- MEDIUM: CreateTipPayment/CreateBookingPayment 2FA gates moved AFTER the idempotency completed-dedup (code consumed only on new money paths; terminal path already correct) — lost-response retries return the completed payment instead of 400
- MEDIUM: replayRescueLowerBoundSkew widened to 5m (DB-clock-skew stranded originals now rescued)
- MEDIUM-1: verifyFamilyAlive DB amplification reduced via 30s bounded family-alive cache; admin route group rate-limited
- MEDIUM-3: admin saved-card charges now write admin_audit_log (handlers.go helper + till); [2FA] log line decoupled from user identity
- LOW-1: logout scoped to the presented token's family (no cross-session kill)
- LOW-2: refresh-reuse grace widened for same-IP replays
- LOW-4: squareEnvironmentMismatch enforced for empty env
- LOW-5: uuid.ts hard-fails on Math.random fallback (crypto.randomUUID)
- Cash/giftcard tip-enabled overflow mirrors the card-terminal carve
26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
The till and admin payment modal 'Request a new code' buttons previously called
the session-scoped POST /api/user/2fa/code, which mints a code for the ADMIN's
session — a code that can never satisfy the card-owner gate and is delivered to
the admin's log line, not the customer.
- New POST /api/admin/users/{id}/2fa/code (AdminSendVerificationCodeHandler,
RequireAdmin + per-user limiter): mints/reuses a code for the TARGET user
(the card owner/customer), keyed to the CUSTOMER's userID so the [2FA]
delivery log carries the customer's ID — the customer, never the admin, is
the authentication subject for their card
- Shared useTwoFactorCodeForSavedCard composable gains an optional mint()
option; admin surfaces (PaymentModal, TillPurchases) pass the customer-scoped
mint, customer surfaces keep the session default
- Frontend: adminRequestNewTwoFactorCode(userID) in square.ts; PaymentModal
mints for booking.user_id, TillPurchases for selectedCustomer.id
- Tests: admin mint keys the code to the customer's userID (log line contains
customer ID, NOT the admin ID) + pending hash persisted for the customer;
unknown target user 404s
Backend 26/26 packages; frontend 72/72 + build clean.
The verification of 62adccd found the 2FA composable conversion was incomplete:
PaymentModal.svelte (admin Take Payment on the today page — a live saved-card
charge surface) still re-implemented the 2FA gate inline while the composable's
own doc listed it as one of the six surfaces. This completes the refactor:
- Removed inline twoFactorCode/reveal2FACodeInput/show2FACodeInput/
missing2FACode/requesting2FACode/handleRequestNew2FACode state (74 -> 21 net
lines) and the now-unused requestNewTwoFactorCode import
- Composable call mirrors the TillPurchases admin reference: enabled() => true
(admin supplies the CUSTOMER's code), gateActive() => twoFactorEnforced &&
customerTwoFactorEnabled (byte-identical semantics)
- Rewired request body, success handler, 403 self-heal, focus effect, and the
TwoFactorCodeInput/request-button/Pay-button bindings to the composable
- Zero inline gate patterns remain in the payments components dir
Frontend 72/72 tests + build + eslint clean; backend 26/26 packages.
Restart-loop dup/mod review findings:
- Account page gift-card buy flow now passes the 2FA verification-code gate end-to-end: TwoFactorCodeInput + Request-a-new-code wired for saved-card/save-card charges, verification_code in the /api/user/giftcards/buy body, 403/429 gate-failure self-heal, buy button gated on missing code (backend BuyGiftCard gate at giftcards.go:1471 already required it — the frontend never sent it)
- Removed dead requires2FACodeForSavedCard export from square.ts (zero consumers; all surfaces use authStore.savedCardChargeRequires2FACode) + its test; auth store getter documented as THE single source of truth
- Login page now delegates token persistence to authStore.setToken instead of direct localStorage writes (drift-risk closed; setToken persists both tokens identically so the full-reload init still works)
- Pence comment corrected (GBP minor unit)
- account/+page.svelte:84+6; square.ts -16; square.test.ts -26; auth.svelte.ts comment; login/+page.svelte delegation
Frontend 72/72 tests + build clean; backend builds.
Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
The disable flow was broken in enforced (production) mode: the account page
posted {code:''} to /api/user/2fa/disable, but the backend mints + validates a
code when 2FA is enforced, so the empty code always failed with 400 and a user
could never disable 2FA through the UI. In unenforced (dev/mock) mode the
backend short-circuits and no code is needed — which is why the user only saw
the confirmation dialog and no code prompt.
Backend:
- New POST /api/user/2fa/disable/code (SendDisableCodeHandler): mints +
delivers a fresh disable-flow code via the existing ensurePendingTwoFACode
machinery (per-user 1-min mint cooldown, 429 when throttled, 5-attempt
lockout preserved on the disable call itself). This is the disable-flow
equivalent of /api/user/2fa/setup. Runs unconditionally (no dev
short-circuit) so the step is exercisable in dev too. Route mounted in
main.go beside the other 2FA routes.
- Tests: mints fresh code, reuses valid pending code (hash unchanged),
mint-throttled 429 (after the pending code is dropped, as a lockout does),
unauthorized 401, unenforced still mints.
Frontend (account page):
- The disable confirmation now branches on twoFactorRequired: enforced →
POST /api/user/2fa/disable/code to mint, then a 6-digit code-entry input +
'Confirm Disable' button that posts the code to /api/user/2fa/disable;
unenforced (dev) → unchanged direct disable. Code entry mirrors the enable
flow's input styling; mint-throttle 429 / wrong-code 400 / lockout 429 all
surface as toasts with the entry kept open for retry.
Verification: go test -tags test,dev -count=1 -parallel 8 ./... (all 20
packages ok, 0 failures incl. 5 new 2FA tests), go build ./... and -tags dev,
go vet clean, svelte-check 0 errors 0 warnings, env-docs gate OK.
Rate limiting (backend):
- RateLimit/ProgressiveRateLimit now derive the per-client key from
CF-Connecting-IP, then chi's GetClientIP (the X-Real-IP value nginx sets at
main.go:323), then RemoteAddr. Previously only CF-Connecting-IP/RemoteAddr
were used, so behind the Docker nginx every client shared ONE bucket per
limiter — 10 logins/min site-wide blocked all users (the reported
'Error: Rate limit exceeded' after seeding was the login 10/min bucket
tripped by the seed's 11 logins, all keyed 127.0.0.1 in dev).
- Real implementation is now //go:build !dev || test; new
mw/ratelimit_dev.go (//go:build dev && !test) is a no-op passthrough, so
'go run -tags dev' (the dev harness) never rate-limits dev/seeding traffic,
while production and tests (-tags test,dev) keep the real limiter. The docs
(Technical Manual) already claimed dev no-op behaviour — the code now
matches. NewProgressiveRateLimiter is provided in the no-op build because
tag-free ratelimit_shared.go:104 initializes the global at package init.
Admin 2FA management (backend):
- users.two_factor_last_used_at TIMESTAMPTZ column (init-script, fresh-DB).
- AdminUserDetail now returns twoFactorEnabled/twoFactorMethod/
twoFactorLastUsedAt.
- New POST /api/admin/users/{id}/2fa/remove (admin-only): clears all 5 2FA
columns + drops the user's in-memory attempt/lockout state — an admin
recovery path when a user loses 2FA access.
- two_factor_last_used_at updated on every successful 2FA verification.
Account page (/account):
- 2FA section moved under the Notifications heading, visible to all roles;
Email/SMS toggles (Notifications styling) acting as a radio group with
'none' state; Apply button only when the selection differs from saved;
unselecting shows a payment-rules warning dialog; the dev-comment
'2FA is optional right now (REQUIRE_2FA is off)' and the 'Dev code:' debug
line are removed.
- Cards tab hidden from admin role.
Admin modals:
- User Details modal: new 'Two-Factor Authentication' section above Patch
Tests showing Enabled/Disabled, method, last-used timestamp, and a Remove
2FA button with a confirmation dialog (POST to the admin endpoint, refetch
on success).
- Booking Details modal: the customer's name now links to their User Details
modal (optional openUserModal prop threaded through admin/+page and
today/+page; other call sites unaffected).
Take Payment + /today:
- PaymentModal shows pre-tip (netTotal) and post-tip (totalWithTip) totals
with a tip-amount delta row only when a tip is selected; zero-tip flow
unchanged.
- The /today Payment button is hidden unless the booking is in_progress or
completed, matching the backend gate (was shown for confirmed/pending
bookings, producing the 'Booking must be in_progress or completed' error).
Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok
incl. new admin 2FA tests + mw tests), go build ./... and -tags dev both
compile, go vet clean, svelte-check 0 errors 0 warnings, env-docs gate OK,
docker compose config valid.
The round-7 port pre-flight ran BEFORE , so a
healthy postgres container from a previous run (which owns host port 5432) was
mistaken for a squatter and the script exited. Down the container first, then
check for any remaining non-Docker process on 5432 and fail loudly with the
offending pid.
Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.
Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
no-booking (gift-card purchase) payment are rejected with a clear message
in the direct handler AND never re-issued by the sweep-resume path
(processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
advisory lock (pending-row reuse fixes lost-response double-charge;
completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
(COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
ambiguous → leave pending) instead of blind-failing a possibly-live
checkout. recordUntrackedTerminalPayment re-checks the booking status
(FOR UPDATE) and refuses to record on a cancelled booking, inserting a
critical_payment_log admin notification instead. Till-sale post-charge
UPDATE now requires status='pending' (no resurrection of a clawed-back sale).
Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
ref + Square iframe survive the loyalty/tokenize awaits) — new-card
payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
refetches the booking and reconciles depositPaid so the confirmation gate
opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.
Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
completed terminal checkout's payment resolvable by id.
Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
fails at config time when missing.
Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
in both by-key and by-id paths), resolveChargeSource Square-failure branches,
structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
dispute.evidence / terminal.checkout dispatch.
Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
(previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
gained the missing build tag.
Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
Sixth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). QA FAILED the deposit-required new-card flow; the P0 root
cause was backend + frontend, now fixed. All 20 packages green.
P0 money-safety:
- Deposit-required bookings now actually charge the deposit on new-card
payment. Two-part fix: (1) CreateBookingHandler re-reads the
trigger-maintained total_amount/total_duration_minutes from the DB after the
booking_services insert (the INSERT..RETURNING row predates the recalc
trigger, so TotalAmount serialized as 0 and DepositPaid computed TRUE on an
unpaid booking — the frontend gate trusted deposit_paid:true, never charged,
and confirmed the booking with zero payment rows); (2) BookingFlow.svelte
gates the confirmation view on depositPaid and guards against re-creating a
booking on retry. Regression test
TestBookings_Create_DepositPaidFalseOnUnpaidBooking.
Payments (idempotency + money):
- deriveBookingPaymentIdempotencyKey: no-client-key fallback now advances a
sequence for repeatable types (partial) and rotates past refunded completed
rows, so refund-then-repay and equal-amount partials diverge onto distinct
keys; an un-refunded completed row keeps its key (double-charge protection
holds). Dedup hits on refunded rows now 409, never stale success.
- chargeFailureStatus default is 503 (ambiguous), never 402; table test.
- Flaky TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance fixed
(ORDER BY payment_type).
- resolveChargeSource: orphaned card-on-file disabled via DeleteCardOnFile
when SaveCardForUser fails (best-effort, redacted log); retry path preserved.
Square client:
- Dev builds HARD-FAIL (panic) on SQUARE_ENVIRONMENT=production without
SQUARE_ALLOW_REAL_API=1; sandbox routes with a loud banner.
- Mock fault-injection FailAfterCommit (commit-then-5xx) exercises the exact
lost-response same-key retry; SimulateCardTokenUsed; 45-char idempotency-key
cap parity; SquareEnvironment/SquareLocationID shared env helpers used by
the sweep (env contract no longer comment-only).
- listRefunds truncation now errors (money-sensitive reconcile retries
instead of over-refunding); getCardsOnFile truncation loudly logged.
Webhooks + 2FA:
- square-environment header checked fail-closed (403) when configured env is
production/sandbox; dispatch DB work bounded by 30s timeout contexts.
- 2FA codes HMAC-SHA256 pepper'd (TWO_FACTOR_PEPPER) with legacy-hash
migration + upgrade-on-verify; disable-flow mint cooldown (1/min, 429) caps
the brute-force loop; in-lockout records never LRU-evicted.
Repo hygiene:
- env-docs CI gate green again (FRONTEND_ORIGIN + SQUARE_ALLOW_REAL_API +
TWO_FACTOR_PEPPER documented; Vite DEV built-in allowlisted).
- Dead square_deposits schema dropped; obsidian/README/legal-page drift fixed
(consumeradvice.scot signposting, CORS allowlist, p11 R3/P13, T1).
- 2FA disable residual documented; P6 email/SMS delivery and P12 sandbox
smoke test remain the pre-go-live gates.
Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs
gate OK, live deposit-required flow re-verified end-to-end (deposit £11
charged, square_payment_id recorded).
Fifth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). Code verdict: PASS across all five. The findings this round
were documentation-only — the code changes from round 4 (per-dispute chargeback
alerting, single-source clawback, 2FA lockout coherence) were verified correct.
Docs:
- Test counts corrected everywhere to the ACTUAL compiled number under
-tags test,dev: 2,137 (README, Technical Manual, Testing Architecture doc).
Prior docs claimed 2,151 / 2,133 — neither matched the compiled count, and
the raw func-Test grep (2,154) includes 17 build-tag-excluded tests.
- Technical Manual 2FA Gate: corrected the false 'fresh verification code is
generated ... when disabling' claim. The disable flow REUSES a still-valid
pending code and only mints fresh when none exists; the 'always-fresh on
disable' alternative was deliberately NOT adopted (an out-of-band [2FA]-log
code cannot be submitted within the same request that generates it). This
now matches the code and the round-4 commit's own rationale.
- Technical Manual: documented the accepted residual 2FA brute-force exposure:
a fresh-code delivery resets the shared 5-attempt counter, so an attacker
who already holds the victim's password can loop disable to obtain unlimited
fresh codes (6-digit guessing bounded only by the per-IP rate limit and
10-min TTL). Documented rather than fixed because a hard lockout would strand
a legitimate code-lost user with no email/SMS recovery (P6).
- README: corrected the -count=10 verification claim — handlers/payments and
handlers/webhooks share package-global state (Square mock ledger, in-memory
webhook dedup cache, fixed-ID test rows) that leaks across in-process
iterations, so -count>1 is unreliable there; use -count=1 for those two.
Verification: build clean, user/webhooks/payments packages green via
run-tests.sh, frontend builds, svelte-check 0 errors.
Fourth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). All PASS on the money-safety core; this round closes the
remaining MAJOR/MINOR items they surfaced.
Webhooks:
- Untracked disputes now raise ONE admin notification PER distinct chargeback:
the notification id is derived deterministically from the square_dispute_id
(SHA-256 truncated into the CHAR(12) slot) so a second untracked dispute is
no longer silently suppressed by the first's dedup row. ON CONFLICT (id)
keeps same-dispute replays idempotent; the booking-scoped NOT EXISTS guard
is retained for the tracked path. Verified: distinct disputes -> distinct
rows; re-delivered dispute -> one row.
- The gift-card clawback SQL now lives in exactly ONE place:
payments.RevertGiftCardFunding (new giftcard_clawback.go). till.go and the
webhook path both call it — eliminating the byte-for-byte copy whose
divergence would be a money-loss drift trap (the same two-sources-of-truth
pattern this commit eliminated for GDPR scrubbing).
2FA:
- Applied the lockout-coherence fix from the review: when a disable request
must mint a fresh code (no valid pending one), the held attempt counter is
reset so the locked-out user can use the freshly delivered code in the SAME
request (no wasted round-trip). The reuse path keeps accumulating wrong
attempts toward the 5-attempt lockout — the two behaviors no longer
conflict. (The 'always-fresh on disable' suggestion was NOT adopted: it
would break the out-of-band [2FA]-log delivery model, since a code generated
by a request can never be submitted within that same request.)
- New test pins the shared verify/disable lockout: 5 wrong verifies 429 and
destroy the code; a stale code then 400s on disable while the freshly
delivered code succeeds in the same request.
- Startup now warns that 2FA codes travel in PLAINTEXT via the server log in
enforced mode (operator must restrict log access + relay out-of-band until
email/SMS lands).
Docs:
- Test counts updated to the current 2,154 across README + Technical Manual.
- User Manual 2FA nav corrected: the settings live on the Account page, not an
'Admin' area.
Tests: 2,154 (up from 2,151). Backend 25/26 packages green (crussell/db fails
only in this environment: local postgres auth for the test role; package
byte-identical to HEAD). Frontend builds; svelte-check 0 errors.
Second fresh-eyes review pass (7 agents: goal, security, code-quality,
context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers).
Money-safety core verified sound (identical-body replay byte-lossless, clawback
gated on definitive proof, no double-charge window). This round fixes the
issues the fresh pass surfaced:
2FA:
- Setup now DELIVERS the code via the [2FA] server log in ALL modes (was:
nothing in enforced mode -> production 2FA was an unbreakable dead-end and
saved-card charges were permanently 403). Enforced mode still withholds the
code from the API response; the log line is the fake delivery channel until
email/SMS lands (P6).
- Disabling 2FA now requires a fresh verification code when enforcement is ON
(previously ignored the code -> a password-only attacker could lift the gate).
Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained.
- REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive);
startup warning extended to the empty-env/mock-client/enforced-2FA confusion.
GDPR:
- anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the
idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just
the user-initiated delete path.
Webhooks:
- dispute.created for an untracked Square payment now raises a
critical_payment_log admin notification (chargeback the app can't reconcile
is never silent). Reason strings truncated on rune boundaries (valid UTF-8).
Stale at-most-once comment corrected; revertTillSaleGiftCardFunding
duplication noted.
Sweep/mock parity:
- Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on
source mismatch) matching ReplayPaymentByKey and real Square.
- COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by
the sweep (previously only booking checkouts were; till charges were
invisible until the 24h blind-fail WARN).
- Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and
in-memory-mock-restart limitations documented.
Docs:
- Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square
- a deployer following the old path would 404 and silently lose all webhook
reconciliation).
- 2FA enforcement semantics + code-delivery mechanism documented accurately
(fail-closed default; log-delivery channel; disable re-verification).
- README/User Manual note the 2FA requirement on online saved-card payments.
Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails
only in this environment: local postgres doesn't offer scram-sha-256 for the
test role; package is byte-identical to HEAD and untouched here). Frontend
builds; svelte-check 0 errors.
Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.
Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
square_request_snapshot, so a retained idempotency key returns the original
payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
sources are charged and rescued; spent cnon: nonces surface
ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].
2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.
Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
packages green, 2,142 tests, svelte-check clean.
Tip double-count (root cause of £33.75 vs £28.75 display):
- Remove mock's fixed +500p auto-tip when AllowTipping is true (square_dev.go) —
real Square only enables a terminal prompt, it never adds a tip to the amount
- Set AllowTipping=false in CreateTerminalPayment: the frontend already embeds
the tip in the amount, so the terminal must not prompt for a second tip
- M4 tip split now derives the tip as charged amount minus remaining booking
value ('after 100% is tips'), not from Square's TipAmount field
- Success screens divide paymentResult.amount by 100 (pence -> pounds) in both
PaymentModal and UserPaymentModal
Fully-paid bookings auto-complete:
- Extract ApplyBookingCompletionSideEffects into payments package (shared by
admin progress endpoint and payment paths; avoids circular import)
- Add bookingIsFullyPaid + completeFullyPaidBooking: when completed non-tip
payments reach 100% of the booking total, an active booking transitions to
'completed' so it leaves the admin Current Appointment view
- Wired into CreateBookingPayment (inside tx) and GetCheckoutStatus (terminal,
after commit); completion side-effects (loyalty, campaigns, deposits_required)
fire identically to the manual progress endpoint
- Add /admin/bookings/{id}/refund route (AdminRefundBooking)
Discount-refund hardening:
- RefundPayment explicitly rejects discount/on_the_house payments (was relying
on the incidental NULL-square_payment_id guard)
- Hide the Refund button for discount/on_the_house payments in EditBookingModal
- Cancel-refund estimate in BookingModal also excludes on_the_house
- Cancellation refund loop + GetBookingPaymentInfo + GetBookingRefundableAmountCents
exclude payment_type='tip' from refundable totals
Tip flow (start-time guard) fixes tests:
- Tip tests updated to use past-dated bookings (tips now require booking started)
Tests:
- m4_tip_refund_redesign_test.go (tip split, refund exclusion, admin refund cap)
- m5_fully_paid_completion_test.go (online + terminal full-payment completion,
partial stays active, tip excluded, cancelled stays cancelled)
- Full suite passes with -race (25 packages)
The project is pre-launch: there is no production database and all dev starts
from a fresh volume recreated from init-scripts/init-script.sql. The obsolete
'Database migrations' section (ALTER statements and apply-before-deploy notes
for existing deployments) is replaced with the no-ALTER policy — schema
changes are edited directly into the CREATE statements, no migration-managed
delta exists, and the diff on the next recreate IS the migration.
Staff may charge a customer's saved card at the till but cannot add or save
one. A customer picker (reusing the admin user-search pattern, excluding
admin/guest/affiliate) loads the customer's cards via
GET /admin/users/{id}/payment-methods; the 'Saved card' payment option is
hidden outright when the customer has no currently-valid cards, computed
client-side with the Square convention (valid through the end of
exp_month/exp_year). The saved-card charge sends payment_method saved_card
plus user_id/user_saved_card_id with no card_token or verification_token, and
the per-line idempotency keys also key on the selected card so switching cards
yields fresh keys. No Square Dashboard hint, no new-card form, no save
checkbox — the till can never persist a card.
The rule pushes SvelteDate (svelte/reactivity), which this codebase
deliberately removed in 55b2c8c because it caused real bugs. All Date usage is
local wall-clock computation via parseWallClockDate helpers, not reactive
$state Date mutations, and the rule has no options to allowlist Date — so
disable it outright in the svelte block with a rationale comment. Restores a
clean eslint gate for the pre-commit hook and CI.
canSaveCardsForRole(role) in square.ts is the single source of truth for the
save-card product rule (verified_email, admin — never affiliate). All four
predicate sites (account page, UserBookingModal, BookingFlow, TipPayment) were
wrong before, excluding admin and including affiliate. The worst gap was
BookingFlow passing canSaveCards={authStore.isAuthenticated} to the Pay-Early
modal, which let unverified users save cards — it now passes the derived value.
isNonceStale() + NONCE_STALENESS_MS replace the 240s staleness check duplicated
five times, keeping the amount-bound re-tokenization semantics identical.
The project is pre-launch with no production DB — all dev starts from a fresh
volume recreated from init-scripts/init-script.sql. All ALTER statements are
removed and their effects expressed directly in the CREATE statements:
name_history now lives after bookings with its booking_id FK inlined (no
ALTER TABLE ADD CONSTRAINT), the critical_payment_log enum value is in the
CREATE TYPE (no ALTER TYPE ADD VALUE), and voucher_type_at_purchase is in the
CREATE TABLE gift_cards (no ADD COLUMN).
gift_card_expired_balances.account_id is now nullable: CleanupExpiredGiftCards
inserts NULL for unredeemed gift cards (bought for non-account-holders, never
claimed) — a NOT NULL column made that expiry fail at runtime. The three tests
that papered over this with ALTER TABLE DROP NOT NULL now rely on the schema
directly.
The transaction-scoped TRY variant had no production callers — only the
conn-level try-lock (acquireAdvisoryLock) and the blocking xact variant
(acquireAdvisoryXactLockBlocking, used by the admin cancellation path) are in
use. Dropping the dead code removes a foot-gun: a try-lock that silently
fails to acquire inside a transaction would otherwise look like a safe option.
Guests (account_role='guest') have no login flow and never receive a JWT
normally, so this is defense-in-depth: any token whose role claim is 'guest'
(forged/minted guest tokens or future changes) is refused 403 before the money
handlers run. The check reads role from context ONLY — real guests are seeded
with account_type='email', so account_type is never the discriminator. A
missing role passes through (RequireAuth guarantees presence; same trust model
as isVerifiedRole).
Wired onto all user-facing money routes: booking payment, apply-redemption,
payment-lock POST/DELETE, tip, gift-card redeem and gift-card buy. Admin and
till routes (RequireAdmin) are untouched — admin can never be guest. The
payment-methods routes gain RequireVerified (verified_email, admin) alongside,
so only verified accounts can manage saved cards.
Tests: 6 middleware tests (reject guest, allow verified/unverified/admin/
affiliate/missing-role) + 4 integration tests (guest 403 on booking payment
with zero side effects, tip, gift-card buy; verified user still pays 200).
Only verified accounts (account_role in 'verified_email','admin') may save
cards. CreateBookingPayment, CreateTipPayment and BuyGiftCard now reject
save_card=true for guests, unverified accounts and affiliates with 403 BEFORE
charge-source resolution, the pending-payment insert, or any Square call —
failing closed with zero side effects. Unverified users may still pay; only
card persistence is blocked. The dedicated save endpoints are additionally
protected by mw.RequireVerified middleware on the routes (main.go).
isVerifiedRole / rejectSaveCardForUnverified mirror the existing isAdminRequest
defense-in-depth pattern. Tests cover: unverified save-card 403 with no payment
row, verified save-card succeeds, and unverified pay-without-save succeeds.
A lost-response retry carrying a fresh or absent idempotency key (an old
frontend, or a key regenerated for a changed cart) now reuses the pending
till_sale row by resolving the gift card itself and adopts the row's STORED
key — so Square dedups the retry against the original charge and the card is
never funded twice. An amount mismatch proves a genuinely different sale (same
card, new amount) and leaves the pending row untouched. 'create' actions carry
no gift-card id and continue to rely on the client-supplied key cached per
cart line in the till frontend.
The stale-pending sweep now runs two passes. Pass 1 (22h cutoff — deliberately
2h earlier than the 24h legacy cutoff) targets pending rows with a STORED
idempotency key but NO square_payment_id: the lost-response case, where the
charge may have completed at Square with the response never received. Each row
is reconciled at Square by replaying the key (ReplayPaymentByKey): a COMPLETED
charge is rescued to 'completed' with the real square_payment_id written back,
a charge Square proves never happened is failed (till-sale gift-card clawback
included), an ambiguous answer leaves the row pending for the next run. The
earlier cutoff keeps the replay inside Square's ~24h key-retention window;
replaying at exactly 24h risks an expired key misreading as 'never charged'.
Pass 2 (legacy 24h cutoff) reconciles rows WITH a square_payment_id by payment
id; rows with neither payment id nor stored key (no reconcile possible) are
failed with a WARN that the charge outcome is unknown. Late retries on all
swept rows are rejected (409), preventing a second Square charge.
ReplayPaymentByKey (POST /v2/payments re-issue with the same idempotency key
and a synthetic probe source token that can never process a real charge):
Square returns the ORIGINAL payment for a retained key and definitively
rejects an unknown/expired one, so the stale-pending sweep can rescue
lost-response charges without ever issuing a second payment. ErrReplayKeyNotRetained
marks a probe rejection as proof the charge never happened.
Refund classification: zero-amount refunds are now rejected (Square requires
amount_money) instead of lenient full-refund; REFUND_ALREADY_PENDING is
classified as already-processed to match the real contract.
Dev mock parity: SquarePayID == payment ID (was fabricated 'sqp_' prefix),
ForceCheckoutState for IN_PROGRESS/CANCEL_REQUESTED terminal states,
replay-by-key support, aligned refund error codes.
Gift-card rolling expiry (setting-driven, was dead config):
- GetGiftCardExpiryMonths(): single source of truth (business_settings
gift_card_expiry_months, fallback 24) shared by payment handlers and the
CleanupExpiredGiftCards job (was hardcoded 24).
- expiry_date now maintained on ALL 9 gift-card write sites (buy, topup,
transfer, redeem, terminal payment, refund credit, till) so the refund-time
guard at refunds.go actually fires. Schema default 12->24 + migration note;
test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new
expired-card-rejected regression test.
Frontend SvelteDate purge (docs' stated convention, wide):
- All 180+ raw `new SvelteDate(...)` uses across routes/components replaced
with parseWallClockDate (backend UTC ISO) or new Date (wall-clock
constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal
fixed. Zero SvelteDate references remain; svelte-check clean.
Strict timezone/DST testing + QA fixes:
- 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds),
closing-hours GMT vs BST, booking date-window midnight, refund-tier
elapsed-time independence, deposit-window UTC-instant, scheduling
LondonDateString midnight, today AT TIME ZONE window + UTC round-trip.
- today.go summary date labels fixed to London wall-clock (were showing the
previous UTC day during BST) + regression test.
- pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON
offsets depended on deployment TZ, contradicting the documented UTC
invariant) + regression test. Registered as a new *Type to avoid a data
race on the shared type map (caught by -race).
Admin Business Settings (setting now functional => legal floor):
- gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/
Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI,
with rolling-expiry semantics shown in both display and edit form.
- 3 new expiry validation tests; 2 pre-existing message assertions updated.
Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0
errors/warnings; production build succeeds.
Money-safety idempotency hardening (I1, wide):
- validate:"max=45" on CreateTerminalPayment/BookingPayment/Refund/Tip/
BuyGiftCard idempotency keys (all feed Square's 45-char /v2/payments,
/v2/refunds, /v2/cards caps); BuyGiftCard corrected from a wrongly-loose
max=64. Till keeps max=64 (its key also feeds the 64-char terminal-checkout
endpoint).
- Explicit 45-char guard in RefundPayment: the one handler that decodes
RefundRequest without running the struct validator, so the tag alone was
inert; a longer key would 400 at Square and be misclassified as a
definitive refund decline.
- New TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers covers all
six endpoints (terminal saved-card, booking, tip, gift-card, till, refund).
Stable-sentinel card identity in idempotency keys (C1, wide):
- BookingFlow deposit key now uses the 'new-card' sentinel instead of
embedding the cnon: nonce (matches UserPaymentModal/account). A re-tokenize
after a spent nonce no longer regenerates the key, closing a lost-response
double-charge window.
- TipPayment + UserBookingModal tip keys now include card identity
(selectedCardId || 'new-card'); previously keyed on amount only, so a
same-amount tip on a DIFFERENT card reused the key and deduped a distinct
charge. Resets cleared in every success/close path.
Test isolation (R1): TestRefund_PendingResume_NewKeyAfterModalReopen no
longer t.Parallel — it swaps the package-level SquareClient mid-test and a
concurrent parallel test could observe the swapped instance.
Naming/quality (M1/M2/M4): resolveChargeSource local renamed savedRowID (was
shadowing the cardID *string parameter); BuyGiftCard fallback prefix
"till-" -> "gc-"; saved-card terminal response key "checkout_id" -> "payment_id"
(it holds a DB payment row, not a Square checkout) with matching frontend
fallback. README maintenance-job count corrected 24 -> 25.
Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0
errors/warnings; production build succeeds.
A soft-deleted card row (DeletePaymentMethod sets deleted_at) still occupies
the non-partial UNIQUE (user_id, square_card_id) slot. Re-saving the same
physical card via a save_card=true charge hit ON CONFLICT DO NOTHING, got
pgx.ErrNoRows, and the deleted_at IS NULL fallback SELECT also missed the
row → 500. DO UPDATE now revives it (deleted_at/retained_until = NULL),
matching CreatePaymentMethodFromToken's revival semantics; the ErrNoRows
fallback is removed. Regression test: soft-delete then re-save same card id
→ existing row returned, deleted_at cleared.
Money-safety idempotency fixes (external review bugs 1-3):
- processChargeGroup: aggregated refund key now hashes the sorted pending-row
set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark
a new row completed against an old smaller refund; >45-char chargeIDs use a
hashed prefix instead of verbatim truncation (which would collide charges on
Square's global key dedup). Same-set crash-retry keeps Square's dedup.
- CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied
per-attempt UUID preferred (distinct identical charges no longer collapse),
deterministic booking+type+amount+card fallback for no-key retry safety.
PaymentModal sends a per-charge UUID cleared after success.
- ensureRefundKey: legacy NULL-key manual refunds persist a generated key to
the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard),
so a lost-response retry reuses the key and never double-refunds. Wired into
resumeManualPendingRefund and the sweep's manual-retry loop.
Classification + money-safety hardening:
- till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative
when present; message-substring matching only for non-structured errors
(dev mock, client-side status errors). Fixes fragile string-matching driving
sweep retries and gift-card clawbacks.
- SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select
(was a latent UNIQUE-violation 500 on save-card retry).
- CreateBookingPayment: partial payments re-validated against remaining balance
inside the advisory lock (closes concurrent-overpayment race).
- InvalidateSquareCustomerCache on GDPR erasure paths (account.go,
time-blockers.go stale-guest anonymization).
- GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth).
- getCheckoutHTTP: warn on multi-payment checkouts instead of dropping
payments[1:].
- Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" ->
"till-" prefix.
- UserPaymentModal: removed vestigial polling state; proper interval cleanup.
- account/+page.svelte: gift-card redeem dialog links /terms.
- nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web
Payments SDK + card iframe can tokenize behind the proxy.
Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key
single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and
cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
Re-review (2 Oracle + security + QA + librarian + context-miner) surfaced fixes, all applied: (1) UserPaymentModal and the account-page gift-card buy now key the cached idempotency key on a stable 'new-card' sentinel instead of the cnon: nonce, so clearing the nonce on a failed charge no longer regenerates the key — a lost-response retry now dedups at Square instead of double-charging (the tip flows already keyed on amount only). (2) The create-with-redeem clawback now logs CRITICAL when the guarded balance reversal is blocked (previously silent), and its transaction DELETE is scoped to this sale instead of deleting every transaction on the card. (3) reconcileStalePaymentAtSquare now treats APPROVED/PENDING as non-terminal (leave pending) instead of definitively failed, matching Square's documented state machine. (4) GetTillCheckoutStatus returns 404 for a sale already swept to failed instead of reporting a live state. (5) The critical-payment scan job skips candidates whose booking was hard-deleted, so one orphan can no longer silence all critical alerts.
Adds critical_payment_log to the admin_notification_reason enum (fresh installs + ALTER TYPE for existing deploys); corrects the README's false cash-with-change claim; updates Gap Backlog T14 with the scan job stopgap; documents the till flow's clawback/cash-reconciliation model in the Technical Manual; records the frontend's re-tokenize-on-failure design in P11; and marks the P14 customer_id assumption VERIFIED (Square runtime enforces it per its SDK maintainer; only the OpenAPI schema stays ambiguous, so the P12 sandbox test remains the definitive live check).
A cnon: nonce and its SCA verification token are consumed by a definitive charge failure (e.g. declined card) and can never succeed again, but TipPayment, UserBookingModal, UserPaymentModal and the account-page Buy-a-Gift-Card cached them and resubmitted the dead nonce on every retry — a non-retryable failure loop. The nonce/verification-token/amount/timestamp cache is now cleared in each error branch so retries re-tokenize fresh, while the idempotency key is kept for network-timeout dedup.
Adds the scan-critical-payment-logs job (daily 2:45am) that surfaces stale pending payments/till-sales and refunds at the retry cap as admin_notifications with reason='critical_payment_log' — the app has no log/alert pipeline (Gap Backlog T14), so money events that would otherwise sit in un-watched CRITICAL log lines now reach the owner's in-app notification centre. Dedup is NULL-safe (IS NOT DISTINCT FROM) and re-surfaces acknowledged-but-still-unresolved rows. Stopgap until a real alerting pipeline lands.
Square rejects POST /v2/cards without card.customer_id at runtime (confirmed by Square's SDK maintainer); the production client omits an empty id via omitempty and every caller provisions a customer first, so the mock now enforces the same structured 400 INVALID_REQUEST_ERROR to keep sandbox/dev parity with the gate the production code depends on.
HIGH-1: a till sale whose card-machine checkout is provably dead, or whose Square reconcile proves the charge never landed, now claws back the funded gift card atomically with the failed mark (claim-first gating UPDATE serializes against the admin retry; blind-fail and ambiguous/lost-response rows never claw back, and a bare CANCEL_REQUESTED is not treated as proof of non-completion). HIGH-2: a card-machine checkout created at Square but not committed is cancelled on any pre-commit failure. HIGH-3: a cash/on_the_house retry of a pending card sale is reconciled at Square first (COMPLETED rescues + refuses cash; NOT_FOUND/FAILED/CANCELED allows cash; lost-response forces the card-method retry; ambiguous rejects). GetTillCheckoutStatus no longer resurrects a swept-failed sale, and the cash-completion UPDATE checks RowsAffected so the admin is never told to take cash against an already-resolved sale. 23 new tests covering the clawback matrix, the reconcile-or-reject matrix, cancel-on-error, and a till concurrency test.
TransferGiftCard locked source-then-destination in caller-chosen order, so concurrent cross-transfers (A→B and B→A) acquired row locks in opposite orders and deadlocked (SQLSTATE 40P01), aborting one transfer with a generic 500. Both rows are now locked in lexicographically sorted ID order (case-insensitive, matching the 12-hex mixed-case card codes), with the scanned values mapped back to source/destination roles afterward. Adds a deterministic concurrency regression test that holds both row locks on dedicated connections to force the AB-BA cycle, proving the old code deadlocks and the new code serializes cleanly.
README test count corrected to 1,934 (4 skipped) with the square_webhook_events migration entry; Technical Manual fixed to match the bounded try-lock, terminal flow, till idempotency, and refund sweep behaviour, and records the RespondError de-scope for the payments package; Feature Catalog and P11 plan corrected to match the actual UserBookingModal/CardSelection wiring.
A load that resolved the script tag but failed to expose window.Square (or timed out) permanently cached a rejected promise, bricking card entry until reload. sdkPromise now resets and the injected script element is removed on every failure path so later calls retry fresh.
square_webhook_events is now CREATE TABLE IF NOT EXISTS, registered as the 24th maintenance job (sweep-square-webhook-events, daily 2:30am) pruning rows older than 90 days, and testdb gives a clear docker compose hint when the admin DB connection fails.
The in-memory dedup is now only a fast path; the square_webhook_events INSERT ... ON CONFLICT DO NOTHING is the source of truth, so replays across restarts and after FIFO eviction are skipped. A DB failure fails closed with 503 so Square retries. Empty event_ids are rejected with 400 (no dispatch, no dedup row). Ordering trade-off (insert-before-dispatch) documented for when handlers mutate state.
golang.org/x/text/cases.Caser is not safe for concurrent use, but UpdateProfileHandler shared one package-level instance. A fresh caser is now created per call via titleCase().
Account deletion now snapshots card and customer IDs before the local anonymize transaction and dispatches the Square cleanup goroutine only after the tx commits, deleting each distinct Square customer once and skipping any customer still referenced by another user's card. AnonymizeStaleGuestAccounts also disables cards and deletes the guest's Square customer profile (PII) before NULLing references locally. Token redaction applied to all error logs.
Till sales and gift-card purchases now use uniqueChargeKey and resolveChargeSource/chargeFailureStatus instead of duplicated inline logic. The saved-card delete path logs the square_card_id through square.TokenPrefix so full ccof references never reach logs.
Downgrades routine sweep bookkeeping from CRITICAL to WARN (genuine post-charge manual-reconciliation branches keep CRITICAL) and replaces the per-attempt time.After in tryAdvisoryLock with a single reusable timer.
Extracts resolveChargeSource (new-card vs saved-card vs one-off nonce, with Square customer provisioning) and shared advisory-lock + post-charge recheck helpers into charge_helpers.go. Adds errors.go with chargeFailureStatus: transport/5xx/context and 429/408/425 map to 503 (retryable), structured 4xx declines map to 402, used across all four charge paths. Also fixes the no-client-key refund fallback to append a crypto/rand suffix (distinct same-amount partial refunds no longer collide) and adds refundResumeKey so legacy NULL idempotency_key rows resume with a derived key instead of an empty one.
Adds SquareClient.DeleteCustomer for GDPR erasure (DELETE /v2/customers/{id}, NOT_FOUND as no-op), bounds doJSON response reads to 1 MiB with rune-safe 500-byte error snippets, adds validCardID guard to the disable-card URL, makes paymentFromSquare card fields consistent when the card ID is empty, redacts ccof/cnon tokens in all log paths, and fixes the idempotency-key-length comment (45 chars for payments/refunds/cards, 64 only for terminal checkouts).
Refresh README and obsidian docs to the post-review state: 1,902 tests passed (4 skipped), 23 jobs / three sweeps, nonce-direct one-off charges, save-only card-on-file, GDPR square-reference scrubbing, /terms and /privacy-policy routes, webhook fail-closed wording. Mark Email, S3/R2, Mettle/FreeAgent accounting, and user notification delivery as planned upcoming bodies of work (including new backlog item P15) so references no longer read as dead features.
compose.yml backend service now reads the root ./.env (which README instructs users to create) instead of the nonexistent backend/.env. Promote R2_ACCESS_KEY/R2_SECRET_KEY/R2_BUCKET/R2_PUBLIC_URL to active vars (prod S3 reads all four via getEnv) and document the webhook URL/signature-key exact-match requirement with fail-closed (503/403) wording.
Fix the P0 infinite refetch in BookingFlow (payment-methods fetched once via a guard flag, was looping on empty saved-card arrays and DoS-ing the rate limiter). Extract the shared TipPayment component so tip and pay-tip routes no longer drift; reconcile formatTimeRange override_duration_minutes and subtotal/tipsPaid. CardBrandIcon gains the correct Square enum keys (DISCOVER_DINERS, CHINA_UNIONPAY). PaymentModal reads card_last4. Login links resolve to the new /terms and /privacy-policy routes. Add frontend/.env.example.
Close the coverage-gap round: terminal CreateCheckout-failure marks the provisional row failed, GetCheckoutStatus reference_id mismatch 400, deadline wire shape, concurrent loyalty redemption 409, delete_guest_user + stale-guest saved-card scrubbing, ValidateAmount and isTokenLike direct units, bounded try-lock timeout, buildSplitRecords tip-overflow, and concurrent same-key dedup for gift card / booking / tip / checkout.
anonymize_user, delete_guest_user, and AnonymizeStaleGuestAccounts now NULL square_card_id and square_customer_id on user_saved_cards (7-year retained_until soft-delete kept for financial records). Make square_card_id nullable in the schema. GDPR export refunds join fixed to include gift-card-purchase refunds. Add scrub assertions to the GDPR and stale-guest test suites.
payment.updated/refund.updated handlers log only the Square object id and payload length instead of the raw JSON body (which contained buyer email, card brand/last4, cardholder name). Add a test asserting no raw payload reaches the log.
BuyGiftCard and till online_square charge the cnon: nonce directly (no synthetic card-on-file); BuyGiftCard.IdempotencyKey is now validate-required (empty key previously collided on the UNIQUE constraint). Saved-card branches forward customer_id and lazily provision legacy cards. lockCancellationPayments uses the deliberately-blocking xact lock so a cancellation never silently drops a refund under contention (admins see the full manual refund round-trip).
One-off new-card charges now pass the cnon: nonce directly as source_id (no card-on-file, no customer). Save-card charges forward customer_id; legacy saved cards lazily provision a Square customer before charging (EnsureSquareCustomerForSavedCard). Terminal checkouts insert the terminal_checkouts row FIRST with a provisional tmp- id, then update with the real checkout_id, closing the crash window. GetDiscountPreviewHandler gains the fail-closed ownership check (IDOR). DeletePaymentMethod disables the card at Square before soft-delete. Sweep resolves provisional/tmp- terminal rows without a Square round-trip. GetCheckoutStatus rejects tmp- ids.
Introduce acquireAdvisoryLock (pg_try_advisory_lock with a ~3s bounded retry) and acquireAdvisoryXactLockBlocking (deliberately blocking for the cancellation-refund path where silently dropping a refund is worse than waiting). Convert ApplyLoyaltyRedemption to the bounded variant: concurrent redemptions during an in-flight payment return 409 instead of pinning a pool connection. Add uncontended + contended-timeout unit tests and a lock-contended 409 redemption test.
Add StatusCode/Category/Field to squareAPIError and an IsNotFound helper so 400/401/404/429/5xx are distinguishable structurally instead of by substring. Validate cnon:/ccof: token prefixes in createPayment/createCardOnFile (PCI parity with the mock). Reject ccof charges without customer_id in the mock so dev parity catches the production bug. Emit Deadline as the RFC 3339 duration (PT5M) and correct the deprecated-comment.
P14 plan: lazily provision Square customers only when a user saves a
card, charge one-off new-card payments via cnon: nonce directly (no
card/customer minted for non-savers or guests), and add a policy
pop-over on the consent checkbox linking to a /privacy-policy route
(mirroring the existing /cancellation-policy pattern).
Draft the Square processor disclosure into the Privacy Policy (2.2
Saved Cards & Square: lawful basis Art 6(1)(b), one-off statement,
retention, removal, Square privacy link) and cross-reference it in
Terms 3.2. Both remain placeholders pending owner review.
Add P14 row to the gap backlog.
VITE_SQUARE_ENVIRONMENT=mock renders a plain HTML card form (MockCardForm)
instead of the Square Web Payments SDK iframe, minting the same cnon: tokens
the backend dev mock accepts — all 8 payment flows run end-to-end locally with
zero credentials.
- isSquareMock() gated on import.meta.env.DEV: structurally impossible in a
production build even if the env var is mis-set
- MockCardForm: Luhn/brand/expiry/CVC validation, Amex 15-digit + 4-digit CVC,
error states, disabled propagation — mirrors the real form's onReady contract
so CardSelection.isCardValid and submit guards behave identically
- tokenize() maps typed card -> deterministic cnon: token matching backend
detectCardInfo (4242->test-card, 4111->visa, 5555->mastercard, 3782->amex)
- lazy-loaded via dynamic import: mock code ships in its own chunk, referenced
only from the mock branch, never statically imported into the main bundle
- docs: .env.example (mock pairing with SQUARE_ENVIRONMENT=mock), P11 plan
(mock opt-in + canonical-last4 caveat), Feature Catalog (2.1, 2.5)
- prettier formatting fixes in 10 unrelated files (line wrapping only)
R1/R4: saved_card branch in CreateTerminalPayment now mirrors CreateTipPayment
- advisory lock (crussell:payment:<bookingID>) serializes concurrent double-clicks
- deterministic key bookingID-sc-type-amount-cardID (<=45 chars) so a lost-response
retry derives the same key and dedups instead of double-charging
- idempotency switch inside the lock: completed -> dedup, pending -> reuse with
pence amount-guard, failed -> clean 409
- success response includes card_brand/card_last4 (frontend already reads them)
R2: add 'failed' case to all four retry switches (tip, booking, gift card, till)
- a swept/definitively-rejected record returns 409 instead of 500-ing on the
idempotency_key UNIQUE constraint
R3: extend SweepStalePendingPayments to till_sales card rows
- sweeps pending till_sales (online_square/in_person_card) past Square's ~24h
key retention, closing the double-charge window for till sales
- swept rows logged with the same CRITICAL manual-reconciliation marker as the
refund sweep
Webhook fail-closed: reject 503 when SQUARE_WEBHOOK_SIGNATURE_KEY unset, 403 on
bad signature (was: skip verification in dev)
Refund status resolution: refunds now resolve by Square status
(COMPLETED/PENDING/FAILED/REJECTED) instead of assuming completed; real error
codes (REFUND_AMOUNT_INVALID, PAYMENT_NOT_REFUNDABLE, REFUND_ALREADY_PENDING)
added to the definitive/processed classification
HTTP client: CreateCard key truncated to <=45 chars, device_options always sent
(env SQUARE_TERMINAL_DEVICE_ID fallback), processing_fee reads amount_money,
ListCards cursor loop, refund keys hashed to <=45 chars
Other fixes: payment/till/gift-card advisory-lock + FOR UPDATE asymmetries,
GetPaymentByID NULL scans, loyalty redemption lock, card upsert on conflict,
mock ccof: prefix parity, IsValidSquareCheckoutID for real Square IDs,
isAdminRequest defense-in-depth on all 6 admin payment handlers, webhook
signature docs, M8/L5 debug markers removed
Docs: README/FC/TM/Overview updated (22 jobs, 20 CRITICAL sites, 23-section
GDPR export, sweep jobs, webhook fail-closed); P11 plan marks remaining items
(sandbox smoke test, M-8 customer_id, saved-card key dedup trade-off) as
deferred with rationale; gap backlog pruned of completed items
The customer_id-required question (Card schema says Required, spec says
nullable) can only be resolved by a real sandbox Create Card call. The smoke
test remains the gate before any production flip: new-card tokenization ->
payment -> saved card -> refund -> reconcile against a real endpoint.
Refund idempotency (P2):
- RefundRequest gains an optional client idempotency_key: two DISTINCT equal
partial refunds of one payment no longer collide on the amount-derived key
(the second was silently swallowed as a dedup)
- Extract resumeManualPendingRefund: resumes a pending refund with the row's
OWN stored key, so Square's key dedup returns the original refund if the
prior attempt completed — never issues a second
- (payment, amount) pending fallback: when the exact-key lookup misses (admin
reopened the modal, new UUID), resume the matching pending row instead of
creating a second pending row the sweep would double-process
- 409 in-flight guard: if a pending refund exists for the payment but no
same-amount row matches, reject a different-amount refund (money state at
Square is unknown — no new refund is safe until it resolves)
- Frontend (EditBookingModal): UUID per refund attempt, reused on retry,
mirroring the tip flow
Terminal completion (P3):
- GetCheckoutStatus serializes on pg_advisory_lock('crussell:terminal:' ||
SquarePayID) on a pinned connection — concurrent polls of the same checkout
can no longer both pass the dedup SELECT and race the UNIQUE constraint
Card-on-file / doc-only:
- Document why CreateCardOnFile is NOT rolled back on payment failure
(deterministic sha256 retry returns the same card; deletion breaks it)
- Document HasCompletedPayment's deliberate 'tip' exclusion
Regression tests:
- TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates
- TestRefund_PendingResume_NewKeyAfterModalReopen (proves stored-key resume)
- TestRefund_PendingResume_DifferentAmountRejected (409 + no second row)
- TestRefund_GuardCountsPendingRefunds updated: 400 -> 409 (in-flight guard
fires first — strictly safer, blocks before any Square attempt)
- TestGetCheckoutStatus_ConcurrentPolls_SingleRecord (real two-goroutine race)
The app does not provision Square customers, so sending the local user ID as
customer_id in Create Card was rejected with CUSTOMER_NOT_FOUND, and filtering
List Cards by it returned nothing. reference_id is Square's free-form client
reference — max 128 chars, no uniqueness constraint — and is echoed in both
Create and List responses.
- Create Card payload: reference_id = local user ID (customer_id absent)
- List Cards: native ?reference_id=<userID> filter (no limit/customer_id,
no client-side filter, no cursor handling needed)
- Mock parity: CreateCardOnFile stores ReferenceID; GetCardsOnFile unchanged
- Regression guards: TestCreateCardOnFileHTTP_IdempotencyKey asserts
reference_id=user_1 and customer_id ABSENT; new
TestGetCardsOnFileHTTP_ReferenceIDFilter asserts the query shape
Re-enable new-card entry across all 8 flows via Square Web Payments SDK
cnon: nonces (backend was already P11-ready):
- Add square.ts SDK loader (env-gated on VITE_SQUARE_APPLICATION_ID/LOCATION_ID,
sandbox vs prod URL auto-derived from app-ID prefix) + SquareCardInput.svelte
(tokenize() via bind:this, onReady state, CardEntryUnavailable fallback)
- CardSelection.svelte: replace newCardDisabled gate with new-card toggle +
SquareCardInput; expose tokenize() for parent flows
- Wire new-card mode into tip x3, booking payment (UserPaymentModal), deposit
(BookingFlow incl. guest), Buy a Gift Card + Add a Card (account), and admin
till online_square (GiftCardsManagement create/topup)
- Retry-safe: each flow caches the one-shot nonce and reuses it on retry so the
backend idempotency key dedups instead of re-tokenizing
- Docs: README, Gap Backlog P11, Feature Catalog, Technical Manual, P11 plan
The P11 plan was written when the frontend sent raw PANs and the backend
had CreateCardOnFileRaw blocked. Four rounds of payment-safety work
changed the reality: the backend is fully P11-ready (CreateCardOnFileRaw
deleted; all card-creation paths accept cnon:/ccof: tokens via
CreateCardOnFile) and new-card entry is gated behind CardEntryUnavailable.
- Rewrite plans/p11-square-web-payments-sdk.md: accurate current state
(8 gated flows), backend-readiness section with verified line evidence,
Steps 4/5 marked done, revised to a frontend-only 2-3 day integration
(SquareCardInput replaces the deleted CardInput.svelte)
- README: online payments via saved cards; new-card entry gated until
nonces land; backend accepts tokens only (PCI-DSS parity)
- Gap Backlog P11: backend groundwork DONE, remaining work is
frontend-only re-enable of the 8 gated flows
- Feature Catalog 2.1/2.5 and Technical Manual: gated-not-dead-end;
Add Card posts card_token to CreatePaymentMethodFromToken
Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
per-payment advisory locks taken before the prior-refunds read
(pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
charge (stable charge-level -square-agg key); atomic group UPDATE
keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
refund_attempts cap; sweep retries stale manual pending refunds with
each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
terminal failed transition: tri-state result leaves rows pending on
reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
(excluding failed); ErrRefundDeclined distinguishes definitive vs
ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
(refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
DO NOTHING without consuming refundRemaining
Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
in request bodies; gate new-card entry behind CardEntryUnavailable
notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording
Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
(single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
goroutines), reconcile error vs no-match branches, stale manual retry,
forgive-fees real refund row + reason, double-cancel dedup, mock refund
key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
P0 — float truncation: applied math.Round to all remaining int64(x*100)
sites (till penceAmount, refund over-refund guard, GetAlreadyRefundedAmount,
payment summary conversions). A £1.14 till sale previously charged 113p.
P0 — raw PAN stopped at the API edge:
- Deleted CardNumber/CardExpMonth/CardExpYear/CardCVC from TillSaleRequest
and CardNumber/Expiry/CVC from CreatePaymentMethodRequest. Both now accept
card_token (Square nonce) and return 400 when absent. PAN+CVV no longer
transit the application server (PCI-DSS SAQ-A scope).
- Deleted CreateCardOnFileRaw from the SquareClient interface and all
implementations (MockClient, ProdClient, devProdClient).
- Added idempotency_key column to refunds table (UNIQUE).
P0 — RefundPayment hardened: advisory lock on payment ID (prevents two
concurrent refunds passing the over-refund guard), pending-refund-record-
then-Square pattern (scheduler reprocesses on failure), same-key dedup.
P1 — till sale pending-retry now re-attempts the Square charge instead of
returning the stale 'pending' status (gift card was already funded in the
committed tx — silent money loss otherwise). Sale row reused, not duplicated.
P1 — idempotency key caching in frontend: BuyGiftCard and
UserPaymentModal/BookingFlow now cache the key per amount+card, regenerated
on change and cleared on success — matches the tip-flow pattern so a
lost-response retry dedups instead of double-charging.
P1 — CreateTerminalPayment cash/giftcard INSERTs now persist idempotency_key.
Key is unique per payment (booking+type+amount would wrongly dedup two
legitimate identical payments, e.g. two £50 cash receipts).
P1 — gift-card codes no longer logged (spendable credential; value+recipient
only).
Tests: till pending-retry re-attempt, refund same-key dedup, mock CreatePayment
idempotency dedup, CreatePaymentMethod nonce happy path + raw-PAN rejection,
till online_square card_token required/valid.
The pending-retry amount guard compared pence via int64(pounds*100),
which truncates instead of rounding. For non-exact pound values (e.g.
£1.14 stored as the float64 1.1399999999999999) the truncation yields
113 != 114, falsely rejecting a legitimate same-amount retry with 400.
Since the frontend reuses the idempotency key on same-amount retries,
every retry was rejected, permanently stranding the pending record
(and any orphaned Square charge) with no recovery path.
Fix: compare in pence via math.Round — the existing pattern already
used in refunds.go — so non-exact values round to the true pence.
Also applied the same correction to the sibling lossy conversions:
- CreateTipPayment / CreateBookingPayment completed-dedup responses
(would have reported 113p for a 114p payment)
- BuyGiftCard retry amount guard (latent: £10/£20/£50 are float-exact
so it never bit, but the identical trap is now closed)
Tests:
- TestTipPayment_RetryPending_NonExactAmountSucceeds: 114p pending
record + same-amount retry completes and charges 114p (failed on the
old truncation with 400)
- TestTipPayment_RetryPending_AmountMismatchRejected: a same-key retry
at a different amount is still rejected with 400 and the pending
record is left untouched
Verified: full backend suite green (25/25 packages, 0 failures),
-race clean on handlers/payments, go build + go vet clean.
N1 (HIGH) — BuyGiftCard concurrent same-key retry could double-issue gift
cards (2× value for 1 charge). Added pg_advisory_lock on the idempotency key
(mirroring the tip pattern) acquired before the idempotency check, so
concurrent same-key retries serialize and only one executes gift-card
creation.
N2 — Amount-equality guards in both reuse branches (CreateTipPayment and
BuyGiftCard). A same-key retry with a different amount now returns 400
instead of silently mutating the pending record's books/VAT/refund caps.
N3 — test coverage:
- TestBuyGiftCard_RetryPending_ReattemptsCharge: pending record + same-key
retry re-attempts, reuses the record (count=1), completes, and issues the
gift card exactly once.
- TestCreateCheckoutHTTP_DeviceOptionsWireShape: httptest.Server asserts
device_id is under checkout.device_options (not top-level). Extracted
createCheckoutHTTPWithClient for injectable base URL.
- MockClient.CreatePayment now dedups on idempotency key (paymentByKey map),
matching real Square behaviour.
N4 — Corrected the savepoint comments in handlers.go and giftcards.go: the
savepoint only exists in the test harness; in production db.Conn.Begin is a
plain tx and the status UPDATE runs on a separate pooled connection. Commit
is a harmless no-op in prod but required in tests.
Bonus bug fixed: CheckIdempotencyByKey scanned NULL booking_id/gift_card_id
(gift-card purchases) into plain string, failing with 'cannot scan NULL'.
Now uses sql.NullString.
Docs: Technical Manual.md:53 and Feature Catalog.md (2.1, 2.5) corrected —
no longer claim Web Payments SDK is live; new-card entry is documented as
pending P11, saved-card flow works via ccof tokens, dev mock rejects raw PANs.
CRITICAL — same-amount tip retry silently never charged:
- CreateTipPayment idempotency check now only short-circuits when the
existing record is 'completed'. A 'pending' record (previous Square call
failed) is REUSED and the charge re-attempted with the same key (Square
dedups safely), instead of returning the stale pending record as 200 with
a success toast and no charge.
- Same fix in BuyGiftCard: pending records trigger a re-attempt, not a
false-success response. Unique idempotency_key constraint means the
pending record must be reused, not re-inserted.
- Fixes the savepoint/rollback interaction: the nested tx (savepoint) is
now committed in the reuse path so the deferred rollback doesn't undo the
later status UPDATE on the same connection.
- Regression test: TestTipPayment_RetryPending_ReattemptsCharge verifies a
pending record + same-key retry re-attempts and completes, reusing the
record (count stays 1).
MAJOR — terminal checkout wire contract:
- device_id now sent as checkout.device_options.device_id (Square's required
shape), not a top-level field which Square rejects with 400.
- 'checkout pending' detection now uses typed sentinel ErrCheckoutPending
with errors.Is in both handlers, matching mock and real HTTP client.
MAJOR — exp_month/exp_year omitted from card creation payload when unset
(now *int with omitempty) — Square would 400 on 0/0; expiry comes from the
tokenized source.
Docs:
- README payments/infrastructure sections corrected (Web Payments SDK claim
replaced with accurate P11-backlog note; dev mock parity described)
- Future Work P11 updated to reflect raw-PAN rejection is now enforced in
both mock and prod (new-card flows are a documented dead end)
- Added plans/p11-square-web-payments-sdk.md: full implementation plan +
handoff prompt for the agent picking up P11 (Web Payments SDK nonces)
Previously the dev MockClient was more permissive than production:
- MockClient.CreateCardOnFileRaw processed raw PANs and stored mock cards,
while ProdClient and devProdClient both block raw PANs. A dev testing the
raw-card flow saw it succeed, masking a production failure.
- MockClient.CreateCardOnFile accepted raw PANs as source_id via an
isAllDigits branch. Real Square only accepts cnon:xxx/ccof:xxx tokens.
Now the mock behaves identically to production:
- CreateCardOnFileRaw returns the same PCI error as ProdClient
- CreateCardOnFile validates source_id is token-like (cnon:/ccof:) and
rejects raw PANs
- Removed dead isAllDigits helper
Tests updated to assert the parity behavior:
- TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity (table-driven,
replaces 5 brand-specific raw-PAN tests)
- TestDevClient_CreateCardOnFile_RejectsRawPAN (replaces RawNumber)
- TestCreatePaymentMethod_HappyPath / SecondCardNotDefault now expect
500 instead of 200, documenting the prod block
Move the saved-card auto-select effect INTO CardSelection, where it owns both
cards and showNewCardForm. The effect is guarded by !showNewCardForm so the
'Use a new card' click (selectedCardId = '') is not immediately re-set to the
default card — previously the parent's unguarded effect (moved from the OLD
showNewCardForm guard during the CardSelection refactor) silently charged the
saved default card instead of the newly entered card.
CardSelection mounts fresh each time the modal opens (conditional {#if}
mounting in UserBookingModal and BookingFlow), so the auto-select fires once
on load, exactly like the tip flows' one-shot load-time selection.
Create CardSelection.svelte reusable component encapsulating the standard
saved-card list + 'Use a new card' + CardInput pattern with blur-based
validation (Luhn, expiry, CVC) — identical to the tip flows and account page.
Refactor UserPaymentModal (Make a Payment submodal) to use CardSelection:
- Removed its bespoke 'Use a different card' expand/collapse UI and inline
validation derivations (parseExpiryParts, isValidLuhn, touched state)
- Bound selectedCardId + new card fields to the component
- payButtonDisabled now driven by component's onValidityChange callback
- Removed now-unused CardInput import, SvelteDate import, formatCardExpiry
Fix account 'Buy a Gift Card' bug: 'Use a new card' click did nothing because
the auto-select effect immediately re-set buySelectedCard back to the default
card. Added buyShowNewCard flag so the effect only auto-selects on initial load;
reset after successful new-card purchase so the next purchase re-defaults.
Money-moving fixes:
- Tip idempotency key regenerates when the tip amount changes after a failed
attempt (all 3 tip flows). Cached key still reused on same-amount retry
(dedup intact) and cleared on success/modal reset. Prevents silent
under-charge when a user retries at a different amount.
- Till replay path returns actual till_sales.status (may be 'pending') instead
of hardcoded 'completed' — no more misreported successful charge.
- BuyerEmail wired for CreateBookingPayment, gift card purchases, and till
sales (saved_card + online_square), matching the tip flow. Email lookup
errors logged, non-fatal.
- Till buyer-email errors now logged (was silently swallowed).
- on_the_house till top-up uses cached getIdempotencyKey() for retry-safe dedup
(was fresh crypto.randomUUID()).
Test/validation fixes:
- Add TestPaymentFromSquare_* unit tests (else-branch + nil card details),
build tag relaxed to 'test' so they run in the standard dev suite.
- Add TestValidateCardInfo table test (7 cases: both/either/neither/empty).
- Add TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed regression test.
- Remove dead mock pre-registration in TestTipPayment_WithSavedCard.
- Correct misleading till regression-test comment.
ESLint cleanup (12 errors -> 0):
- Remove unused loadingCards in tip + pay-tip pages (dead assignments in
loadSavedCards).
- Scoped eslint-disable for {@html} in CardBrandIcon (hardcoded brand SVGs).
- Remove dead confirmSaveDefaultHours + unused rescheduleVersion prop in
WeeklySchedule (and its parent pass-through).
- Replace new Date() with SvelteDate in WeeklySchedule + BusinessHours.
- Fix each-block key in BusinessHours skeleton loader.
- Use void expression for reactivity-tracker reads in effects.
Homepage: restored original v0 design, replaced lorem ipsum with real service descriptions, added BusinessHours (Opening Hours) section, alternating bg-gray-50 section backgrounds. Layout: added global sticky footer (hidden on /admin/schedule, /account, and ?format=pdf pages), wrapped content in min-h-screen flex layout. PortfolioCarousel: set heading to Playfair Display.
All other pages (contact, admin, account, today, book) use border-based card styling without drop shadows. Removes shadow-sm, hover:shadow-md, and transition classes from all Card.Root instances on the prices page.
Feature Catalog: comprehensive audit of all 15 feature areas with cross-references, verified against source code (18 parallel deep-dive agents). Gap Backlog: rewritten with dev-to-prod integration framing, 40 items across Pre-Launch/MVP/Stretch/Tech Debt. Gitleaks: whitelist obsidian/ docs containing curl examples.
Admin no longer receives a notification when someone buys a gift card for a friend. The recipient email and gift code are logged for future SMTP delivery instead.
PostgreSQL ORDER BY ... DESC puts NULLs first by default, so services with no bookings appeared at the top instead of the bottom. Also removes the booking count column from SELECT entirely — the sort is done purely in the ORDER BY.
Adds GET /api/services/popular endpoint that returns services sorted by booking count (desc) then price (desc) for ties. Prices page now fetches from this endpoint instead of the default alphabetical sort.
bits-ui blocks onValueChange for unavailable dates, leaving stale selection. Adds native click detection on [data-unavailable] elements to call onchange(undefined) so the time picker hides and the user knows nothing was picked.
Add TestRequestEditHandler_PastClosing_Blocked (19:30 extends past 20:00 closing), TestRequestEditHandler_StagedHoursClosed_Blocked (day closed under staged change), and TestRequestEditHandler_ValidTime_Succeeds (10:00 within open hours). Removed the closed-day test that was incompatible with the bookings test DB seed (all 7 days open 08:00-20:00) — that case is covered by the staged-hours test.