- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in removed from every doc: log delivery reframed as a local DEV ONLY feature while email/SMS is implemented
- Technical Manual: 2FA state + delivery, /api/verify/generate route table, future-work item
- Feature Catalog: SCA posture + verification-code feature
- payments and money processes: relay model + env var reference (removed)
- Overview, User Manual: delivery posture
- test counts, refresh-token grace, session lifetime, deposit advance, gift-card expiry, patch-test notice kept accurate
- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak
- verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle
- sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD
- delete-account re-auth (current_password + fresh 2FA code when enforced)
- prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check
- startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals
- env-docs parity updated (flag removed, 38 vars)
- README: payments/2FA sections rewritten for the SCA-only posture (no
TWO_FACTOR_FALLBACK, tokenize-result wire contract, 402 refusal), deposit
carve-out clarified, gift-card 12-hex codes + 14-day cancellation, flood-cap
insert sites enumerated, escalating lockout tiers documented, ICO
registration note, updated test counts (2,555 backend + 129 frontend).
- local-dev-2.sh: fail-closed dev secret bootstrap (auto-generates
JWT_SECRET_KEY / TWO_FACTOR_PEPPER into the gitignored .env), kills stale
backends holding :8080 before the tmux reset, passes
RUSTFS_ENDPOINT/GO_TESTING=1 to the dev backend, and backdates seeded patch
tests 60 days so past gel bookings pass the 24h notice gate.
- Obsidian manuals (Technical/Admin/User/Feature Catalog/Overview/Gift Card
T&C/Privacy/T&C/Testing Architecture/payments and money processes + p14 plan
+ workspace state) updated to the post-round-2 state.
Tests lock the TRUST_PROXY_HEADERS behavior: a trusted CF-Connecting-IP
becomes the rate-limit key (per-IP and per-user+IP), an origin-exposed header
on an untrusted proxy is ignored, and the unauthenticated per-user limiter
honors header mode. Mirrors the middleware contract (finding 4a / Loop B).
The frontend's cross-tab coordination (auth.svelte.ts REFRESH_LOCK_TTL_MS=15s +
20s wait-for-timeout) guarantees only ONE tab rotates and every sibling adopts
the rotated pair, so the only legitimately-arriving replays are same-tick
races (sub-second). The old 60s window handed a stolen refresh token a full
minute of freshness before reuse detection fired; 20s keeps comfortable margin
over the coordination bound while cutting the undetected-theft window to a
third. The ideal fix (kill only when the replay's IP/UA differs) still needs
rotation-origin persistence the locked schema cannot express.
- profile.go updateCardDAV now writes directly to the shared dav_cards table via
dav.Service (mirroring registration) instead of PUTting a vCard to
DAV_BASE_URL over HTTP — the Go backend no longer needs the SabreDAV URL,
only the sabredav PHP container uses the server-side credential.
- dav/types.go: ContactInput gains PhotoURL; GenerateVCard emits the
PHOTO;VALUE=URI line when set, so the profile photo syncs into the address
book. DAV_BASE_URL is retained in .env.example for reference only.
- till.go: the saved-card till charge carries the C6 consent fields and enforces
them on the (unreachable) 2FA fallback path; the card ownership SELECT became
owner-agnostic with the owner read at the gate (F1 — no charge surface can
act on a card it does not own); a till sale's gift-card creation/top-up now
runs under the SAME per-admin daily-cap advisory lock as the admin API
surfaces (F5) so two concurrent distinct sales cannot overshoot the £5,000
day ceiling; money-F4: an expired gift card can never be topped up (expiry
gate mirrors RedeemGiftCard's DB-clock comparison) — the top-up would
otherwise resurrect a card the nightly cleanup already forfeited.
- charge_helpers_test.go: TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource
pins the SCA tokenize-result wire contract (token as source, card row for the
customer).
- errors_test.go: token-less saved-card charges are refused 402
verification_required under 2FA enforcement (create-payment, gift-card buy +
save-card), even for a user with 2FA enabled — the homegrown gate can never
substitute for SCA.
- Terms & Conditions: DRAFT badge removed, updated to August 2026; SCA / 3-D
Secure disclosure (online + till refusal behaviour), chargeback section,
non-refundable-gift-card position, Liability and Acceptable Use sections.
- New /gift-card-terms route with the full gift-card position (14-day online
cancellation, non-refundable except as the law requires, SPV VAT treatment);
linked from the Terms page and the new footer.
- Footer now links Privacy Policy / Terms / Cancellation Policy / Gift Card
Terms / Your Data instead of a bare copyright line.
- GDPR export page: verification-codes card removed — verification codes are
authentication tokens excluded from the export, so the card could never
populate (parity with the backend scrub).
- square.ts: shouldFallbackTo2FA replaced by shouldShowSCARefusal — a genuine
'sca-unavailable' now drives the REFUSAL path (the customer is told the
payment cannot complete and to pay online later), never the 2FA code fallback
(PSR 2017 SCA is non-waivable; merchant liability is not cured by consent).
SCA_REFUSAL_MESSAGE_ONLINE/TILL copy added; SCA_FALLBACK_CONSENT_VERSION 'v1'
+ scaFallbackConsentFields() carry the versioned consent on the explicit
opt-in path only (shipped surfaces send none). SquareTokenizeResult docs
updated: tokenize-result token is the charge source, tokenless OK proceeds
token-less under the backend's SCA-only gate.
- C1 wire contract on every saved-card surface (booking, tip, gift-card buy,
till, account): the proactive SCA tokenize-result is sent as new_card_token
(the charge SOURCE alongside the saved-card ref), never the legacy
verification_token; 402 verification-required now means the tokenize-result
was consumed/expired between tokenize and charge.
- New ScaFallbackConsentDialog surfaces the refusal notice; the code input
(useTwoFactorCodeForSavedCard scaAvailable: () => true) only ever appears via
a backend gate rejection (defensive/opt-in).
- Till (M10): proactive saved-card SCA runs per sale line BEFORE the first
charge; sca-unavailable aborts the whole sale before any charge.
- Card save (M11/M12): STORE-intent tokenizeForStore with SCA at tokenization;
402 verification-required on save surfaces SCA-first guidance instead of a
generic failure.
- C5: new_booking, pending_booking, cancelled_booking and edit_requested admin
notifications are flood-capped per reason (pre-check logs suppression; atomic
fold inside the INSERT), so a booking/cancellation flood cannot bury the
operator's notification centre.
- C4: EvictPendingReleaseOverlapping no longer re-sells a slot over a
customer's money. Evicted pending_release bookings that carry a paid deposit
are refunded FIRST — through payments.ProcessCancellationRefundTx (exported
cancellation-refund machinery) inside the same transaction, full-refund
override (business is re-selling the slot) — and only THEN flipped to
'deposit_lapsed'. Rows are SELECTed FOR UPDATE first so the guard predicate
stays true; a refund failure aborts the eviction so the caller rolls the
whole transaction back. Card refunds record 'pending' and settle via the
pending-refund sweep post-commit.
- Reschedule-fee audit payload whitespace alignment fix.
- adminnotify: MaxUnacknowledgedCriticalLogs global cap exposed as
CriticalLogsCapExceeded — a pre-check helper every insert site pairs with the
atomic fold inside its INSERT (count-then-insert is atomic, closing the
TOCTOU where concurrent inserts could both read a below-cap count).
- jobs/cleanup.go ScanCriticalPaymentLogs: capped at the shared cap, pre-check
skips the scan and logs the suppression.
- scheduling: 1_week_no_pay, 1_month_no_pay, default_hours_changed,
deposit_not_paid_by_deadline and the Square-erasure critical notification all
flood-capped with pre-check + atomic fold (per-booking/per-user dedup kept).
- time-blockers.go CleanupExpiredGiftCards (M4): the expiry SELECT now runs
under FOR UPDATE row locks so the read-expired-then-zero window is atomic —
a concurrent top-up either commits before the SELECT (refreshed last_used_at
drops the card out of the predicate) or blocks until the sweep's tx ends and
revives the zeroed card via its own expiry refresh; the top-up value can
never be destroyed by the sweep.
- flood-cap tests added for 1_week_no_pay; adminnotify unit coverage added.
- MEDIUM-3a audit coverage: the refund sweep's re-issue of manual refund rows
now records the admin actor, payment, pence amount and reason under
action_type 'admin_refund' via the shared InsertAdminAuditCharge helper
(best-effort own-transaction, non-fatal; distinct from the booking-level
'admin_booking_refund'); legacy rows with NULL created_by fail harmlessly.
- C5 flood cap: the unacknowledged 'refund_failed' notification queue is capped
at adminnotify.MaxUnacknowledgedCriticalLogs — pre-check logs the suppression,
the fold inside the INSERT enforces it atomically, and the (reason,
booking_id) NOT EXISTS dedup is preserved.
- M6: RevertGiftCardFunding no longer silently drops unreclaimable money. A
partially-spent create/top-up claws back everything still on the card/balance
(GREATEST(0, ...) clamp instead of the old guarded 0-row block), inserts a
CRITICAL admin notification, and returns errClawbackPartiallyReversed so every
caller (till handler, stale-pending sweep, webhook) surfaces the residual
without forking the money logic; balance comparisons use pence (penceLess).
- M7: the £5,000/day admin gift-card value cap (create/top-up/transfer) is now
serialized per-admin under a bounded advisory try-lock
(acquireGiftCardDailyCapLock) so two concurrent operations cannot both read
the day's value before either writes and over-issue value.
- M4/M3: every gift-card expiry comparison now reads the DATABASE clock
(giftCardExpired -> SELECT NOW()), the same clock that wrote expiry_date, so
app-clock drift can neither extend nor shorten card life; applied on redeem,
cancellation assessment, cancel-for-user and the reversal re-verification.
- C6 consent fields carried on BuyGiftCardRequest and enforced on the
(now unreachable) 2FA fallback audit path; fallback audit row captures the
versioned consent.
- M2: a stale pending payment COMPLETED at Square on a cancelled/lapsed/no-show
booking no longer just fails the row + admin-notifies: an automatic pending
refund row for the full stranded charge is created (same shape/origin as
ProcessCancellationRefundTx, deterministic idempotency key, square_payment_id
written when missing) so the pending-refund sweep issues it at Square.
- M5: sweep rescues re-apply VAT — rescued till sales run ApplyVATToTillSale and
rescued payments apply ApplyVATToBookingPayment per record after the align
UPDATE (which no longer NULLs the VAT fields), keeping rescued charges in VAT
reporting. Both SQL functions are idempotent (guarded on vat_amount IS NULL).
- M3: every age-guard cutoff in the sweep is computed from clock.Now() and
passed into SQL as parameters (never a DB NOW()-derived comparison) so the
23h/24h Square idempotency-key retention decision cannot flip on clock skew;
replayRescueUpperBoundSkew (5s) stops a legit same-key retry that raced the
sweep from being misclassified as the sweep's own replay-created duplicate.
- C2: till cash/giftcard charges now serialize under the same
crussell:payment:<bookingID> advisory lock as the online path (bounded
try-lock) so remaining-balance checks can never both pass.
- webhooks_completion_asymmetry_test: webhook-first completion + sweep rescue
double-complete race locked end-to-end through the real handler.
The CURRENT saved-card SCA contract (Square card.tokenize(verificationDetails,
cardId)) returns a one-time tokenize-result that must be sent as the charge
SOURCE (source_id), not a separate verification_token.
- square_dev.go: the mock validates the WIRE BODY (mockPaymentWireBody — an
independently assembled copy of buildCreatePaymentBody) so it accepts exactly
the request shape the real client emits. SimulateSavedCardVerificationRequired
now demands SCA on every saved-card charge in both wire shapes: (a) a genuine
tokenize-result (cnon:sca-... — isSCATokenizeResultSource) as source_id +
customer_id is ACCEPTED (the token IS the buyer verification); a RAW
card.tokenize() nonce in the tokenize-result slot is REJECTED
CARD_DECLINED_VERIFICATION_REQUIRED (money-F2 — the mock is the enforcement
point that stops the forged shape); (b) legacy ccof: + verification_token is
kept for backward-compat.
- square_http_client.go: byte-identical body assembly shared with the mock, so
TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical pins the mock and the
real client emit identical CreatePayment bodies (a wire drift fails the test
before reaching prod).
PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).
- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
a non-empty Square verification_token (charge surfaces, token forwarded to
Square) skips the gate; anything else is refused 402 verification_required.
enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
where the token IS forwarded to Square (charge — Square validates it) from
card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
one-time tokenize-result sends the token as the charge SOURCE (new_card_token
-> source_id) alongside saved_card_id, never a separate verification_token.
resolveChargeSource resolves the saved-card branch FIRST (customer from the
card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
tip/till/gift-card charge requests, enforced server-side before any fallback
charge could reach Square and recorded on the 2fa_fallback_charge audit row;
logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
(twoFAPepperConfigured / twoFADeliveryChannelConfigured /
twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
.env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
validation on save vs charge surfaces, completion idempotency, idempotency
key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
F5.5 brute-force hardening:
- internal/twofa.Check consume is now a conditional UPDATE (WHERE id AND
two_factor_pending_code_hash) reporting rows affected: two concurrent
verifications of the same code on different instances both match the digest,
but only the first conditional UPDATE can affect a row — the loser sees 0
rows and fails MissingOrExpired, so one code authorizes exactly ONE operation
across instances (the per-user mutex only serialized within one process).
- /login lockout is now indistinguishable from a wrong password: a locked
account returns the same uniform 401 'invalid credentials' and burns the same
constant-time bcrypt compare (via the shared semaphore), removing the
account-existence oracle and lockout-probing signal of the old 429.
- Lockout tiers escalate 15m (5+) / 30m (7+) / 60m (10+): an attacker who keeps
guessing past each unlock makes the lock LONGER, raising the repeat-DoS
effort while the response stays uniform.
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:
MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
a rejected auto-refund no longer re-replays the expired key every sweep run
(which minted a stacking unauthorized charge each time); FAILED-webhook
demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
campaign discount rows immediately (capped) instead of skipping with no
discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
now matches the authoritative tip-excluded balance)
SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
email-verification per-user attempt counter
DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
SquareRefundStatusToLocal single source (errors.go, all sites); admin
audit-log helper dedup; SCA retry model unified (proactive on all 6
surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
backend; generateUUID at all card-form sites; magic numbers named
(defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
terminal charges now audited; DAV_SKIP_INIT documented in manuals
Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
Quick wins from the Loop B close-out:
- backend/internal/dav/service_prod.go: package-level init() connected to
postgres and panicked when the DB was unreachable — breaking bare-shell
'go test -tags test,!dev' and any CI test-prod run without a live service.
init() now skips connecting under GO_TESTING (the pipeline sets it) or
DAV_SKIP_INIT, and defaults POSTGRES_HOST to 127.0.0.1 (the pipeline
postgres-service address, matching testutils/testdb) so real prod builds
still connect. The test suite wires its own pool in TestMain.
- README.md:21: corrected stale 'Gift card SPV/MPV VAT treatment configurable'
to the SPV-only posture (a stored MPV is overridden to SPV at read time).
Verified: 26/26 dev packages, both vet tags clean.
Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:
- CRITICAL-ish: IDEMPOTENCY_KEY_REUSED (409) no longer classified as a definitive
402 in chargeFailureStatus — it means the ORIGINAL charge may have landed with
a different body, so it is now AMBIGUOUS (503): the frontend keeps the same
idempotency key, the pending row stays rescuable by the sweep (which already
treated it as ambiguous), and the frontend no longer regenerates the key into
a possible double charge. SCA verification-required codes remain definitive 402.
- HIGH: reissueTwoFACodeAfterFailedCharge now writes a CRITICAL admin notification
(insertCriticalPaymentNotification) when issuance is refused (missing pepper /
unavailable delivery) instead of silently stranding the customer; documented that
a pepper CHANGE invalidates all pending codes.
- MEDIUM: family-alive cache invalidation crash window documented (invalidate-after-
commit leaves up to 30s warm on a crash; the near-TTL DB re-check bounds it).
- Consolidation regression checks (8b2fe3b helpers): writeChargeSnapshot guard
preserved at all sites, postChargeRecheck identical, squareRefundStatusToLocal
mappings verified, reissue fresh-only semantics confirmed at all 5 call sites.
Verified: 26/26 dev packages, both vet tags, frontend tests + build, env-docs 42/42.
Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:
MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance
SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue
DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)
Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
Square's card.tokenize(verificationDetails, squareCardId) determines the SCA
requirement UP FRONT and returns a fresh verification_token (or an explicit
outcome), so the customer-initiated saved-card flow now runs it before the
first charge attempt instead of the reactive 'attempt naked ccof -> 402
verification_required -> challenge + retry' round-trip.
- UserPaymentModal/TipPayment/BookingFlow/account gift-card buy: call
runSavedCardSCAProactively before charging; 'verified' carries the token on
attempt #1; 'sca-unavailable' demotes to the 2FA gate (the only tokenless
path); 'challenge-cancelled'/'sca-failed' never charge and keep the pending
row retryable with the same cached idempotency key
- The reactive re-challenge hook is removed; a defensive verification-required
402 (stale/consumed token) surfaces VERIFICATION_REQUIRED_MESSAGE and lets
the user retry
- Admin PaymentModal + till saved-card charges remain MERCHANT-INITIATED
(customer_initiated=false, SCA-exempt, no liability shift) — unchanged
- square.ts comments updated (saved-card charges now carry a token proactively;
SAVED_CARD_VERIFICATION_MESSAGE is the defensive path)
- Tests: 98 frontend tests (proactive decision coverage); build clean
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