Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.
Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
no-booking (gift-card purchase) payment are rejected with a clear message
in the direct handler AND never re-issued by the sweep-resume path
(processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
advisory lock (pending-row reuse fixes lost-response double-charge;
completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
(COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
ambiguous → leave pending) instead of blind-failing a possibly-live
checkout. recordUntrackedTerminalPayment re-checks the booking status
(FOR UPDATE) and refuses to record on a cancelled booking, inserting a
critical_payment_log admin notification instead. Till-sale post-charge
UPDATE now requires status='pending' (no resurrection of a clawed-back sale).
Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
ref + Square iframe survive the loyalty/tokenize awaits) — new-card
payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
refetches the booking and reconciles depositPaid so the confirmation gate
opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.
Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
completed terminal checkout's payment resolvable by id.
Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
fails at config time when missing.
Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
in both by-key and by-id paths), resolveChargeSource Square-failure branches,
structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
dispute.evidence / terminal.checkout dispatch.
Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
(previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
gained the missing build tag.
Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
Fourth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). All PASS on the money-safety core; this round closes the
remaining MAJOR/MINOR items they surfaced.
Webhooks:
- Untracked disputes now raise ONE admin notification PER distinct chargeback:
the notification id is derived deterministically from the square_dispute_id
(SHA-256 truncated into the CHAR(12) slot) so a second untracked dispute is
no longer silently suppressed by the first's dedup row. ON CONFLICT (id)
keeps same-dispute replays idempotent; the booking-scoped NOT EXISTS guard
is retained for the tracked path. Verified: distinct disputes -> distinct
rows; re-delivered dispute -> one row.
- The gift-card clawback SQL now lives in exactly ONE place:
payments.RevertGiftCardFunding (new giftcard_clawback.go). till.go and the
webhook path both call it — eliminating the byte-for-byte copy whose
divergence would be a money-loss drift trap (the same two-sources-of-truth
pattern this commit eliminated for GDPR scrubbing).
2FA:
- Applied the lockout-coherence fix from the review: when a disable request
must mint a fresh code (no valid pending one), the held attempt counter is
reset so the locked-out user can use the freshly delivered code in the SAME
request (no wasted round-trip). The reuse path keeps accumulating wrong
attempts toward the 5-attempt lockout — the two behaviors no longer
conflict. (The 'always-fresh on disable' suggestion was NOT adopted: it
would break the out-of-band [2FA]-log delivery model, since a code generated
by a request can never be submitted within that same request.)
- New test pins the shared verify/disable lockout: 5 wrong verifies 429 and
destroy the code; a stale code then 400s on disable while the freshly
delivered code succeeds in the same request.
- Startup now warns that 2FA codes travel in PLAINTEXT via the server log in
enforced mode (operator must restrict log access + relay out-of-band until
email/SMS lands).
Docs:
- Test counts updated to the current 2,154 across README + Technical Manual.
- User Manual 2FA nav corrected: the settings live on the Account page, not an
'Admin' area.
Tests: 2,154 (up from 2,151). Backend 25/26 packages green (crussell/db fails
only in this environment: local postgres auth for the test role; package
byte-identical to HEAD). Frontend builds; svelte-check 0 errors.
Second fresh-eyes review pass (7 agents: goal, security, code-quality,
context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers).
Money-safety core verified sound (identical-body replay byte-lossless, clawback
gated on definitive proof, no double-charge window). This round fixes the
issues the fresh pass surfaced:
2FA:
- Setup now DELIVERS the code via the [2FA] server log in ALL modes (was:
nothing in enforced mode -> production 2FA was an unbreakable dead-end and
saved-card charges were permanently 403). Enforced mode still withholds the
code from the API response; the log line is the fake delivery channel until
email/SMS lands (P6).
- Disabling 2FA now requires a fresh verification code when enforcement is ON
(previously ignored the code -> a password-only attacker could lift the gate).
Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained.
- REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive);
startup warning extended to the empty-env/mock-client/enforced-2FA confusion.
GDPR:
- anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the
idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just
the user-initiated delete path.
Webhooks:
- dispute.created for an untracked Square payment now raises a
critical_payment_log admin notification (chargeback the app can't reconcile
is never silent). Reason strings truncated on rune boundaries (valid UTF-8).
Stale at-most-once comment corrected; revertTillSaleGiftCardFunding
duplication noted.
Sweep/mock parity:
- Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on
source mismatch) matching ReplayPaymentByKey and real Square.
- COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by
the sweep (previously only booking checkouts were; till charges were
invisible until the 24h blind-fail WARN).
- Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and
in-memory-mock-restart limitations documented.
Docs:
- Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square
- a deployer following the old path would 404 and silently lose all webhook
reconciliation).
- 2FA enforcement semantics + code-delivery mechanism documented accurately
(fail-closed default; log-delivery channel; disable re-verification).
- README/User Manual note the 2FA requirement on online saved-card payments.
Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails
only in this environment: local postgres doesn't offer scram-sha-256 for the
test role; package is byte-identical to HEAD and untouched here). Frontend
builds; svelte-check 0 errors.
Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.
Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
square_request_snapshot, so a retained idempotency key returns the original
payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
sources are charged and rescued; spent cnon: nonces surface
ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].
2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.
Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
packages green, 2,142 tests, svelte-check clean.
Tip double-count (root cause of £33.75 vs £28.75 display):
- Remove mock's fixed +500p auto-tip when AllowTipping is true (square_dev.go) —
real Square only enables a terminal prompt, it never adds a tip to the amount
- Set AllowTipping=false in CreateTerminalPayment: the frontend already embeds
the tip in the amount, so the terminal must not prompt for a second tip
- M4 tip split now derives the tip as charged amount minus remaining booking
value ('after 100% is tips'), not from Square's TipAmount field
- Success screens divide paymentResult.amount by 100 (pence -> pounds) in both
PaymentModal and UserPaymentModal
Fully-paid bookings auto-complete:
- Extract ApplyBookingCompletionSideEffects into payments package (shared by
admin progress endpoint and payment paths; avoids circular import)
- Add bookingIsFullyPaid + completeFullyPaidBooking: when completed non-tip
payments reach 100% of the booking total, an active booking transitions to
'completed' so it leaves the admin Current Appointment view
- Wired into CreateBookingPayment (inside tx) and GetCheckoutStatus (terminal,
after commit); completion side-effects (loyalty, campaigns, deposits_required)
fire identically to the manual progress endpoint
- Add /admin/bookings/{id}/refund route (AdminRefundBooking)
Discount-refund hardening:
- RefundPayment explicitly rejects discount/on_the_house payments (was relying
on the incidental NULL-square_payment_id guard)
- Hide the Refund button for discount/on_the_house payments in EditBookingModal
- Cancel-refund estimate in BookingModal also excludes on_the_house
- Cancellation refund loop + GetBookingPaymentInfo + GetBookingRefundableAmountCents
exclude payment_type='tip' from refundable totals
Tip flow (start-time guard) fixes tests:
- Tip tests updated to use past-dated bookings (tips now require booking started)
Tests:
- m4_tip_refund_redesign_test.go (tip split, refund exclusion, admin refund cap)
- m5_fully_paid_completion_test.go (online + terminal full-payment completion,
partial stays active, tip excluded, cancelled stays cancelled)
- Full suite passes with -race (25 packages)
Guests (account_role='guest') have no login flow and never receive a JWT
normally, so this is defense-in-depth: any token whose role claim is 'guest'
(forged/minted guest tokens or future changes) is refused 403 before the money
handlers run. The check reads role from context ONLY — real guests are seeded
with account_type='email', so account_type is never the discriminator. A
missing role passes through (RequireAuth guarantees presence; same trust model
as isVerifiedRole).
Wired onto all user-facing money routes: booking payment, apply-redemption,
payment-lock POST/DELETE, tip, gift-card redeem and gift-card buy. Admin and
till routes (RequireAdmin) are untouched — admin can never be guest. The
payment-methods routes gain RequireVerified (verified_email, admin) alongside,
so only verified accounts can manage saved cards.
Tests: 6 middleware tests (reject guest, allow verified/unverified/admin/
affiliate/missing-role) + 4 integration tests (guest 403 on booking payment
with zero side effects, tip, gift-card buy; verified user still pays 200).
Adds GET /api/services/popular endpoint that returns services sorted by booking count (desc) then price (desc) for ties. Prices page now fetches from this endpoint instead of the default alphabetical sort.
Add ScheduleDefaultHoursChange, GetScheduledDefaultHoursChange, CancelScheduledDefaultHoursChange, and GetDefaultHoursConflictingBookings handlers. Updates GetDefaultHours to return { current, scheduled_change }. Updates GetWorkingHours and computeAvailableHours to apply staged hours for dates on/after the effective_date.
Move GET /api/scheduling/preview-available-hours from public OptionalAuth group into the admin-only group (RequireAuth + RequireAdmin) since it's an admin simulation tool.
errcheck: add proper error handling with slog.Error for tx.Rollback, key generation, and s3/dav operations. Add nolint comments for intentionally discarded DB scan errors and HTTP write errors.
unused: remove dead code (svcRow type, processImage, nonDepositPaymentType, generateSecureCode, colorBold, nGreen, nRed)
gosimple S1021: merge var declaration with assignment in manage.go
ineffassign: remove dead assignments in settings.go, till.go, images.go
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Removes the inline Bearer token fallback in ServicesHandler — the OptionalAuth middleware (added to the public services route group in main.go) now handles auth context population. This eliminates duplicated token parsing logic and ensures consistent auth behavior across all routes.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add DELETE /api/admin/bookings/reserve to release admin walk-in/call-in reservations.
New handler AdminCancelReservationHandler targets only RESERVATION:admin:%
entries (partitioned from user RESERVATION:user:% by WHERE clause). Includes
12 tests covering walkin + callin success, isolation, no-op, unauth, empty ctx,
walkin+callin coexistence, anon untouched, response format parity, overlapping
reservations deleted, user reservations untouched, and idempotent double-cancel.
Inverse-isolation tests in cancel_reservation_test.go prove the user-side
DELETE /api/bookings/reserve does not touch admin or anon reservations.
Add CancelReservationHandler (DELETE /api/bookings/reserve) to release authenticated user's active reservation. Register route in main.go. Add background goroutine for periodic reservation cleanup using CleanupOldReservations. Add idx_time_blockers_created_at index and extend anon cleanup to cover edit_request reservations in init-script.sql.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Support out_of_hours query param in GetWorkingHours and GetAvailableHours (admin-only, returns 06:00-22:00 for all days). Replace ukLocation with time.Local for date boundaries. Add OptionalAuth middleware to scheduling routes for admin role detection. Add tests for admin/non-admin/no-auth scenarios, booking respect, and exceptional hours interaction.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Replace direct *pgxpool.Pool usage with PoolProxy wrapper across the entire backend:
- db.DB renamed to db.Conn (*pgxpool.Pool -> *PoolProxy)
- JWT functions now accept context.Context instead of using context.Background()
- Handler DB calls route through PoolProxy for per-test transaction support
- Fixture/helper/testdb functions accept Querier interface for decoupling
- Query ordering fixed in bookings handlers: COUNT after data query to avoid pgx conn busy
- Time truncation fixed: time.Date instead of Truncate(24*time.Hour) for week start calc
- testmain_test.go files updated with SeedBaseline and NewPoolProxy
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add CreateTestDatabase function for parallel isolated test databases per package.
- Add CreateTestDatabase() for isolated test DBs (parallel-safe)
- Move all TestMain functions to per-package testmain_test.go files
- Remove old TestMain from handlers_test.go, jwt_test.go, main_test.go
- Add JWT init guard in main.go to skip when -test.* flags detected
- Update testdb.go with admin DSN and proper cleanup
- Rename test database to crussell_test_db for consistency
- Replace testdb.NewPool + testdb.Migrate pattern with CreateTestDatabase
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add 6 admin endpoints for custom services: list (with search/popular/pagination), create, get, update, promote to regular service, and delete. Each handler validates admin role via middleware.
Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
- Added generic formatAndPreserveCursor() helper on frontend to track
and restore selection caret position during dynamic input sanitization
- Applied to all card inputs, gift card code inputs, and expiry inputs
- Added Luhn validation (isValidLuhn) for saved cards and gift cards
- Rebuilt payments test DB and got 100% green tests
Backend:
- Add enriched response types (EditSnapshot, EnrichedEditRequest) with original vs proposed snapshots
- Add 4 new GET endpoints for viewing edit requests (user and admin scoped)
- Remove github.com/lib/pq dependency — use native PostgreSQL array scanning
- Clean up edit requests, time blockers, and notifications on booking cancellation
- Validate exceptional closed hours on admin approve (409 Conflict)
- Notification upsert on edit request replace (no duplicate admin notifications)
Frontend:
- New user EditRequestModal with time/services/both modes and lunch protection
- New admin EditRequestModal with side-by-side diff (date/time, services, notes)
- Integrate edit requests into PendingApprovals card and notifications page
- Preload 3 months of availability to prevent calendar snap-back
- Apply lunch protection to isDateUnavailable in BookingFlow and BookingCreateModal
- Fix accessibility: card list items use <button> instead of <div>
Dev & Docs:
- Seed edit requests in local-dev-2.sh
- Update all Obsidian manuals with enriched edit request documentation
- 42 new tests (438/441 passing)
Replace placeholder BookingModal on the Next Appointment edit button with a
dedicated EditBookingModal that allows admins to add, remove, and override
services on an active booking. Includes backend PUT endpoint with overlap
detection and full test suite (20 tests).
- backend/handlers/user/account.go: Wire DELETE /api/user/account to call
anonymize_user() for registered users and delete_guest_user() for guests,
with CardDAV contact cleanup
- backend/handlers/user/profile_test.go: Add TestAccount_DeleteGuest and
enhance TestAccount_Delete to verify anonymization results
- backend/main.go: Add GET /api/health endpoint with DB ping and S3 status
check; add HSTS and Referrer-Policy security headers; replace
http.ListenAndServe with http.Server + graceful SIGTERM/SIGINT shutdown
- frontend/routes/+layout.svelte: Replace alert() with toast notifications
for email verification flow
- frontend/routes/login/+page.svelte: Replace alert() with toast.info for
social login prototype buttons
- frontend/booking/BookingFlow.svelte: Remove 2 console.log debug calls;
add cancellation policy note in Step 3; add timezone policy comment
- frontend/ImageUpload.svelte: Comment out debug console.log
- init-scripts/init-script.sql: Add delete_guest_user() SQL function
- docs: Update README.md and Obsidian notes to reflect completed items
- backend/main.go: Flatten /bookings/* sub-Route to explicit paths to prevent
RequireAuth middleware from bleeding into OptionalAuth POST /bookings
- backend/handlers/scheduling/time-blockers.go: Exclude RESERVATION:* entries
from GetTimeBlockersInRange so overlap checks dont reject the users own
reservation before CreateBookingHandler can delete it
- local-dev-2.sh: Fix open_day to skip Saturday (6) not Monday (1), matching
working_hours schema; move guest booking dates to +16/+20/+22 days beyond
the upcoming loop range; add reserve-then-book step mirroring frontend flow
Guest flow: CreateGuestUserHandler creates disposable guest accounts on-the-fly.
CreateBookingHandler uses OptionalAuth — accepts authenticated or guest (user_id
in body, validated as account_role='guest'). Guests bypass deposits, patch tests,
and the 24h deposit advance rule.
Admin reserve: AdminReserveSlotHandler supports walk-in (5min TTL) and call-in
(60min TTL) reservations with configurable TTL. Validates against bookings,
blockers, working hours.
Route restructuring: POST /bookings moved to OptionalAuth group. POST /bookings/reserve
added for public reservation. POST /admin/bookings/reserve added for admin.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>