Commit Graph
813 Commits
Author SHA1 Message Date
popertotsandSisyphus 049c361e16 fix: adminnotify observability — money-critical rows sort first, flood-cap suppression surfaced to operator, stale coordination doc fixed
- notifications priority ordering: money-critical reasons (webhooks, sweeps, refunds, gift-card, manual-refund failures) above routine
- admin notifications page exposes the flood-cap suppressed count
- adminnotify.go contract doc: removed stale 2FA reissue-fail site, current insert-site list

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00
popertotsandSisyphus e9b34d0ad6 fix: dev-mock/prod parity + fail-closed config gates — 402 verification, cnon:sca binding validation, refund reconcile parity, SNAPSHOT_ENC_KEY fail-closed, webhook config gates, access-token startup validation, ClientIP validation
- mock: 400->402 for CARD_DECLINED_VERIFICATION_REQUIRED, cnon:sca- tokenize-result binding validated (prefix/amount/deny), RefundPayment exact-amount reconcile parity, ReplayPaymentByKey snapshot sanity, verify_mock_ legacy widening removed, listRefunds zero-time omits begin_time
- main.go: SNAPSHOT_ENC_KEY log.Fatalf in non-mock, webhook key-set-URL-unset log.Fatalf, SQUARE_ACCESS_TOKEN/LOCATION startup validation, empty-env base URL matches 2FA production interpretation
- mw: ClientIP rejects garbage/comma/port XFF values, documented trusted-proxy requirement

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00
popertotsandSisyphus 62dca184df fix: account security + GDPR erasure — current-password lockout budgets (atomic 15/30/60 escalation, uniform 401), DAV + S3 deletion durable in-tx, batch erasure outbox
- ChangePassword/DeleteAccount: failed-attempt lockout matching login escalation, atomic check-increment (no burst), uniform 401 with distinct bodies, passwordless accounts require 2FA unconditionally to delete, NULL-password change-password clear error
- erasure: CardDAV dav_cards rows deleted inside the erasure transaction (was fire-and-forget goroutine); S3 profile-pic deletion via pending_s3_deletions outbox + retry job; stale-guest/idle-account batch paths write the outbox in-tx and skip the guessed-bucket fallback
- S3_PROFILE_PICS_BUCKET unset -> fail-closed warning (once per process)
- scheduler test: 27 jobs (retry-s3-deletions)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00
popertotsandSisyphus 1d6d3e2f8d test: round-9/10 adversarial suites — sync-path vs webhook completion, gift-card double-refund, sweep VAT, till lock, account lockout + erasure
- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows
- giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund)
- sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge
- webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync
- account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00
popertotsandSisyphus 985b114c8b test: payments round-2 — webhook gate/M2 refund, gift-card cancel re-issue, till lock contention, sweep VAT rescue coverage
- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete
- giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed
- sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT
- till: suffixed-key slot scan lock held across Square round-trip

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00
popertotsandSisyphus 77317e4a45 fix: payments money-safety round-2 — webhook booking gate + M2 stranded-charge refund, sync-path status guards, gift-card cancel re-issue reconcile, till-sweep clawback lock, sweep VAT rescue, refund credit routing
- webhook payment.updated now re-reads the booking FOR UPDATE: non-payable booking -> payment failed + stranded-charge refund row + flood-capped alert; payable booking -> full completion side-effects; gift-card rows stay pending (C6 same-key retry); unknown events -> 503 so Square retries
- sync-path completion flips (saved-card, tip, online) guarded AND status='pending' + post-flip re-read; postChargeRecheck failed-mark guarded — no webhook-first double-processing, no phantom split rows
- CancelGiftCard resume reconciles ALL Square refunds (pending blocks re-issue; COMPLETED sum >= entitlement resolves+neutralizes; else re-issues only the difference under a fresh key) — closes double-refund
- sweep till_sales fail/clawback takes crussell:till:<key> lock + post-lock status re-read; recordUntrackedTillSalePayment applies VAT; rescue align clears VAT fields before re-apply (split-accurate VAT, tip rows stay VAT-free)
- refunds: chargeAggKey widened, redeemed-card refunds route to user_giftcard_balances, guest cash refunds recorded failed + notification
- handlers: tip split-records excluded from VAT loop, splitIdempotencyKey hashed, cross-booking key reuse 409, refunded payments excluded from existingCount, SCA-mint exemption for save_card, non-COMPLETED results routed

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00
popertots 01b20b4420 cleanup 2026-08-22 00:34:50 +01:00
popertots 95d2ee3ccf docs: consolidated payments review session report (findings -> fixes, full verification matrix) 2026-08-22 00:34:50 +01:00
popertots 6bd952238e docs: README + obsidian parity — 2FA/verification-code delivery is dev/test-only stdout log, production fails closed until email/SMS (P6); fixed posture, counts, tiers
- 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
2026-08-22 00:34:50 +01:00
popertots 01e7cc575d fix: frontend payment surfaces — SCA wire shapes (explicit token precedence), mock token parity, infinite-loop guard, money display, delete-account re-auth, admin progress UI, mobile touch targets
- new_card_token uses explicit newCardToken ?? verificationToken precedence on every charge surface (BookingFlow, UserPaymentModal, TipPayment, PaymentModal, TillPurchases, account gift-card buy); dead verification_code/consent fields + ScaFallbackConsentDialog removed from payment flows
- mock mints cnon:sca-... tokenize-results and tokenizeWithVerification returns verificationToken:null for new cards (real-SDK parity so save-card works in dev)
- UserPaymentModal infinite /payment-methods fetch loop guarded; formatCurrency(totalPaid) no longer 100x too small
- delete-account dialog collects current_password + fresh 2FA code; admin 'Begin appointment'/'Complete' wired to /admin/bookings/{id}/progress
- mobile: 44px touch targets, active: feedback, TimeSlotPicker 50dvh, dialog close sizing, .no-scrollbar utility, CSP meta, receipt fields escaped
- vitest: policy.ts cross-check + ScaFallbackConsentDialog component tests (svelte project via happy-dom)
2026-08-22 00:34:50 +01:00
popertots f9e8385d5a fix: auth/2FA security — stdout-log code delivery is dev/test-only, production fails closed until email/SMS; verification-code hashing, lockout recovery, sabredav fail-closed
- 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)
2026-08-22 00:34:50 +01:00
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00
popertots 1d9c87d6d6 docs: README + obsidian parity with SCA-only posture, flood caps, lockout tiers; dev-script secret bootstrap, stale-backend kill, patch-test backdate
- 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.
2026-08-22 00:34:50 +01:00
popertots af685df40c test: rate-limit proxy-header trust (TRUST_PROXY_HEADERS) coverage
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).
2026-08-22 00:34:50 +01:00
popertots d0d72d8caf fix: refresh-token reuse grace 60s -> 20s (cross-tab coordinated rotation)
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.
2026-08-22 00:34:50 +01:00
popertots 8d9f72b9f0 feat: CardDAV profile sync writes through dav.Service (photo included, DAV_BASE_URL retired)
- 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.
2026-08-22 00:34:50 +01:00
popertots 8dcfe52ac8 fix: till saved-card SCA/consent + ownership invariant (F1/F5), money-F4 top-up expiry gate, 2FA-enforced 402 tests
- 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.
2026-08-22 00:34:50 +01:00
popertots 8ba76aa958 docs: legal pages — SCA/3-D Secure disclosure, gift-card terms route, footer links, GDPR verification-code scrub
- 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).
2026-08-22 00:34:50 +01:00
popertots 0fdb2f02cd feat: frontend SCA-only posture — tokenize-result as charge source (C1), C6 refusal dialog, no 2FA fallback
- 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.
2026-08-22 00:34:50 +01:00
popertots 9a75ebc794 fix: booking hardening — notification flood caps (C5) + pending-release eviction refunds (C4)
- 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.
2026-08-22 00:34:50 +01:00
popertots 69a854d857 fix: admin notification flood caps (C5) at every remaining insert site; gift-card expiry-sweep TOCTOU (M4)
- 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.
2026-08-22 00:34:50 +01:00
popertots 16304bd295 fix: refund sweep — manual-refund audit rows (admin_refund) + refund_failed flood cap
- 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.
2026-08-22 00:34:50 +01:00
popertots 42130865f4 fix: gift-card money fixes — partial clawback surfaces CRITICAL (M6), per-admin daily-cap lock (M7), DB-clock expiry
- 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.
2026-08-22 00:34:50 +01:00
popertots 2a47021673 fix: stale-pending sweep hardening — auto-refund stranded charges (M2), VAT re-apply (M5), single clock source (M3)
- 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.
2026-08-22 00:34:50 +01:00
popertots d25ba16aa7 feat: Square SCA tokenize-result wire contract — token as source_id, byte-identical dev mock
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).
2026-08-22 00:34:50 +01:00
popertots 2cdbad0cea feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
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.
2026-08-22 00:34:50 +01:00
popertots 1179293777 fix: 2FA single-use consume is DB-atomic; login lockout escalates 15/30/60m with uniform 401
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.
2026-08-22 00:34:50 +01:00
popertots 3130b39c77 docs: round-2 loop-B changes — B1 webhook parent-resolve, APPROVED refund semantics, 2FA cooldown/StateFor, adminnotify flood cap, admin audit coverage, deposit POLICY consts
Payments doc + Technical Manual + README + Overview + Feature Catalog updated to
the post-79b9ffb state, each claim verified against the code:
- Ch7 sweep: webhook COMPLETED promotion resolves the B1 parent (re-poll parity);
  b1_attempts cap + fail-immediate-on-refund-error; till-sale funding trace
- Ch6 refunds: APPROVED is non-terminal on the event-driven webhook path (FAILED
  can still demote); COMPLETED is the only terminal-completed; synchronous
  blocking-APPROVED override; over-refund guard semantics
- Ch15 2FA: mint cooldown survives the gate verify (cleared on terminal success);
  StateFor saturation returns an immutable permanently-locked state (no-op
  LastMintAt writes, in-window counters never evicted)
- Technical Manual: adminnotify.MaxUnacknowledgedCriticalLogs=100 flood cap at all
  insert sites + operator acknowledge-to-re-arm action; 2FA reissue-fail alert
  per-issue capped; /login+/register shared 20-slot bcrypt semaphore; progressive
  429 only at top tier; admin audit coverage expanded (cash/gift-card/guest/
  cancellation/reschedule-fee/transfer/clawback)
- README: deposit POLICY constants single-source (0.5/0.2)
2026-08-22 00:34:50 +01:00
popertots 4e398a7a2b fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00
popertots 3866cc5963 fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
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.
2026-08-22 00:34:50 +01:00
popertots 4e64e32f09 fix: dav prod-tag test panic (DB init in package init), README SPV-only VAT claim
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.
2026-08-22 00:34:50 +01:00
popertots dfe856b181 fix: loop-B adversarial (503c326 baseline) — IDEMPOTENCY_KEY_REUSED reclassified ambiguous, 2FA reissue fail-closed alerts, family-cache crash window, consolidation regression checks
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.
2026-08-22 00:34:50 +01:00
popertots 36887167c6 fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round
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.
2026-08-22 00:34:50 +01:00
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

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

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

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

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

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

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00
popertots c4c65d9dd8 feat: proactive saved-card SCA — challenge runs BEFORE the first charge, never a naked ccof attempt
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
2026-08-22 00:34:50 +01:00
popertots 5dae0bba08 feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup
SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
2026-08-22 00:34:50 +01:00
popertots ecef5da516 fix: sweep duplicate detection keys off the sweep's own replay instant, not a flip-flopping row-age window
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.
2026-08-22 00:34:50 +01:00
popertots 1543160f6a fix: loop-B full-scope adversarial findings — tip-excluded detail endpoints, £0-charge guard, 24h window, 2FA single-use everywhere
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.
2026-08-22 00:34:50 +01:00
popertots 9a182db932 fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup
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.
2026-08-22 00:34:50 +01:00
popertots b46927336b fix: dup/mod secondary round — till 2FA gate parity, mint-cooldown single source, notification parity, pence comments
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.
2026-08-22 00:34:50 +01:00
popertots 7c424b28b8 fix: loop-B adversarial findings — tip-type double-charge, tip-refund capacity, loyalty stamp farming, gate ordering, auth amplification, admin audit log
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.
2026-08-22 00:34:50 +01:00
popertots 03d85c6d13 fix: admin-scoped 2FA mint targets the CUSTOMER — user authentication for saved cards, never the admin
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.
2026-08-22 00:34:50 +01:00
popertots 6d00c3004f fix: convert admin PaymentModal to shared useTwoFactorCodeForSavedCard composable (verification round FAIL)
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.
2026-08-22 00:34:50 +01:00
popertots a6a4683b74 fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment
Three fresh reviews (money/security/dup-mod) cross-validated findings:
- MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL
- MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks
- DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed'
- HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert)
- LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval)
- Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID
- Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race
- SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence)

26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
2026-08-22 00:34:50 +01:00
popertots a8bf24ee23 fix: dup/modularisation findings — account gift-card 2FA gate, single-source 2FA predicate, login store delegation
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.
2026-08-22 00:34:50 +01:00
popertots 4d5d2cd381 fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX
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
2026-08-22 00:34:50 +01:00
popertots fe88f2084d fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00
popertots faceb9809c fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming
Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00
popertots 6d82535780 fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
2026-08-22 00:34:50 +01:00
popertots 78e6d00dc5 fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking
Money-safety:
- Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation
- Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged
- CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard)
- Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse
- Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID

GDPR / security:
- Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010
- square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2
- Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel
- Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit

Frontend:
- Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen)
- Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh
- Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy

S3:
- Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific)

Tests/docs:
- 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
2026-08-22 00:34:50 +01:00