VITE_SQUARE_ENVIRONMENT=mock renders a plain HTML card form (MockCardForm)
instead of the Square Web Payments SDK iframe, minting the same cnon: tokens
the backend dev mock accepts — all 8 payment flows run end-to-end locally with
zero credentials.
- isSquareMock() gated on import.meta.env.DEV: structurally impossible in a
production build even if the env var is mis-set
- MockCardForm: Luhn/brand/expiry/CVC validation, Amex 15-digit + 4-digit CVC,
error states, disabled propagation — mirrors the real form's onReady contract
so CardSelection.isCardValid and submit guards behave identically
- tokenize() maps typed card -> deterministic cnon: token matching backend
detectCardInfo (4242->test-card, 4111->visa, 5555->mastercard, 3782->amex)
- lazy-loaded via dynamic import: mock code ships in its own chunk, referenced
only from the mock branch, never statically imported into the main bundle
- docs: .env.example (mock pairing with SQUARE_ENVIRONMENT=mock), P11 plan
(mock opt-in + canonical-last4 caveat), Feature Catalog (2.1, 2.5)
- prettier formatting fixes in 10 unrelated files (line wrapping only)
R1/R4: saved_card branch in CreateTerminalPayment now mirrors CreateTipPayment
- advisory lock (crussell:payment:<bookingID>) serializes concurrent double-clicks
- deterministic key bookingID-sc-type-amount-cardID (<=45 chars) so a lost-response
retry derives the same key and dedups instead of double-charging
- idempotency switch inside the lock: completed -> dedup, pending -> reuse with
pence amount-guard, failed -> clean 409
- success response includes card_brand/card_last4 (frontend already reads them)
R2: add 'failed' case to all four retry switches (tip, booking, gift card, till)
- a swept/definitively-rejected record returns 409 instead of 500-ing on the
idempotency_key UNIQUE constraint
R3: extend SweepStalePendingPayments to till_sales card rows
- sweeps pending till_sales (online_square/in_person_card) past Square's ~24h
key retention, closing the double-charge window for till sales
- swept rows logged with the same CRITICAL manual-reconciliation marker as the
refund sweep
Webhook fail-closed: reject 503 when SQUARE_WEBHOOK_SIGNATURE_KEY unset, 403 on
bad signature (was: skip verification in dev)
Refund status resolution: refunds now resolve by Square status
(COMPLETED/PENDING/FAILED/REJECTED) instead of assuming completed; real error
codes (REFUND_AMOUNT_INVALID, PAYMENT_NOT_REFUNDABLE, REFUND_ALREADY_PENDING)
added to the definitive/processed classification
HTTP client: CreateCard key truncated to <=45 chars, device_options always sent
(env SQUARE_TERMINAL_DEVICE_ID fallback), processing_fee reads amount_money,
ListCards cursor loop, refund keys hashed to <=45 chars
Other fixes: payment/till/gift-card advisory-lock + FOR UPDATE asymmetries,
GetPaymentByID NULL scans, loyalty redemption lock, card upsert on conflict,
mock ccof: prefix parity, IsValidSquareCheckoutID for real Square IDs,
isAdminRequest defense-in-depth on all 6 admin payment handlers, webhook
signature docs, M8/L5 debug markers removed
Docs: README/FC/TM/Overview updated (22 jobs, 20 CRITICAL sites, 23-section
GDPR export, sweep jobs, webhook fail-closed); P11 plan marks remaining items
(sandbox smoke test, M-8 customer_id, saved-card key dedup trade-off) as
deferred with rationale; gap backlog pruned of completed items
Refund idempotency (P2):
- RefundRequest gains an optional client idempotency_key: two DISTINCT equal
partial refunds of one payment no longer collide on the amount-derived key
(the second was silently swallowed as a dedup)
- Extract resumeManualPendingRefund: resumes a pending refund with the row's
OWN stored key, so Square's key dedup returns the original refund if the
prior attempt completed — never issues a second
- (payment, amount) pending fallback: when the exact-key lookup misses (admin
reopened the modal, new UUID), resume the matching pending row instead of
creating a second pending row the sweep would double-process
- 409 in-flight guard: if a pending refund exists for the payment but no
same-amount row matches, reject a different-amount refund (money state at
Square is unknown — no new refund is safe until it resolves)
- Frontend (EditBookingModal): UUID per refund attempt, reused on retry,
mirroring the tip flow
Terminal completion (P3):
- GetCheckoutStatus serializes on pg_advisory_lock('crussell:terminal:' ||
SquarePayID) on a pinned connection — concurrent polls of the same checkout
can no longer both pass the dedup SELECT and race the UNIQUE constraint
Card-on-file / doc-only:
- Document why CreateCardOnFile is NOT rolled back on payment failure
(deterministic sha256 retry returns the same card; deletion breaks it)
- Document HasCompletedPayment's deliberate 'tip' exclusion
Regression tests:
- TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates
- TestRefund_PendingResume_NewKeyAfterModalReopen (proves stored-key resume)
- TestRefund_PendingResume_DifferentAmountRejected (409 + no second row)
- TestRefund_GuardCountsPendingRefunds updated: 400 -> 409 (in-flight guard
fires first — strictly safer, blocks before any Square attempt)
- TestGetCheckoutStatus_ConcurrentPolls_SingleRecord (real two-goroutine race)
Re-enable new-card entry across all 8 flows via Square Web Payments SDK
cnon: nonces (backend was already P11-ready):
- Add square.ts SDK loader (env-gated on VITE_SQUARE_APPLICATION_ID/LOCATION_ID,
sandbox vs prod URL auto-derived from app-ID prefix) + SquareCardInput.svelte
(tokenize() via bind:this, onReady state, CardEntryUnavailable fallback)
- CardSelection.svelte: replace newCardDisabled gate with new-card toggle +
SquareCardInput; expose tokenize() for parent flows
- Wire new-card mode into tip x3, booking payment (UserPaymentModal), deposit
(BookingFlow incl. guest), Buy a Gift Card + Add a Card (account), and admin
till online_square (GiftCardsManagement create/topup)
- Retry-safe: each flow caches the one-shot nonce and reuses it on retry so the
backend idempotency key dedups instead of re-tokenizing
- Docs: README, Gap Backlog P11, Feature Catalog, Technical Manual, P11 plan
Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
per-payment advisory locks taken before the prior-refunds read
(pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
charge (stable charge-level -square-agg key); atomic group UPDATE
keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
refund_attempts cap; sweep retries stale manual pending refunds with
each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
terminal failed transition: tri-state result leaves rows pending on
reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
(excluding failed); ErrRefundDeclined distinguishes definitive vs
ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
(refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
DO NOTHING without consuming refundRemaining
Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
in request bodies; gate new-card entry behind CardEntryUnavailable
notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording
Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
(single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
goroutines), reconcile error vs no-match branches, stale manual retry,
forgive-fees real refund row + reason, double-cancel dedup, mock refund
key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
P0 — float truncation: applied math.Round to all remaining int64(x*100)
sites (till penceAmount, refund over-refund guard, GetAlreadyRefundedAmount,
payment summary conversions). A £1.14 till sale previously charged 113p.
P0 — raw PAN stopped at the API edge:
- Deleted CardNumber/CardExpMonth/CardExpYear/CardCVC from TillSaleRequest
and CardNumber/Expiry/CVC from CreatePaymentMethodRequest. Both now accept
card_token (Square nonce) and return 400 when absent. PAN+CVV no longer
transit the application server (PCI-DSS SAQ-A scope).
- Deleted CreateCardOnFileRaw from the SquareClient interface and all
implementations (MockClient, ProdClient, devProdClient).
- Added idempotency_key column to refunds table (UNIQUE).
P0 — RefundPayment hardened: advisory lock on payment ID (prevents two
concurrent refunds passing the over-refund guard), pending-refund-record-
then-Square pattern (scheduler reprocesses on failure), same-key dedup.
P1 — till sale pending-retry now re-attempts the Square charge instead of
returning the stale 'pending' status (gift card was already funded in the
committed tx — silent money loss otherwise). Sale row reused, not duplicated.
P1 — idempotency key caching in frontend: BuyGiftCard and
UserPaymentModal/BookingFlow now cache the key per amount+card, regenerated
on change and cleared on success — matches the tip-flow pattern so a
lost-response retry dedups instead of double-charging.
P1 — CreateTerminalPayment cash/giftcard INSERTs now persist idempotency_key.
Key is unique per payment (booking+type+amount would wrongly dedup two
legitimate identical payments, e.g. two £50 cash receipts).
P1 — gift-card codes no longer logged (spendable credential; value+recipient
only).
Tests: till pending-retry re-attempt, refund same-key dedup, mock CreatePayment
idempotency dedup, CreatePaymentMethod nonce happy path + raw-PAN rejection,
till online_square card_token required/valid.
Move the saved-card auto-select effect INTO CardSelection, where it owns both
cards and showNewCardForm. The effect is guarded by !showNewCardForm so the
'Use a new card' click (selectedCardId = '') is not immediately re-set to the
default card — previously the parent's unguarded effect (moved from the OLD
showNewCardForm guard during the CardSelection refactor) silently charged the
saved default card instead of the newly entered card.
CardSelection mounts fresh each time the modal opens (conditional {#if}
mounting in UserBookingModal and BookingFlow), so the auto-select fires once
on load, exactly like the tip flows' one-shot load-time selection.
Create CardSelection.svelte reusable component encapsulating the standard
saved-card list + 'Use a new card' + CardInput pattern with blur-based
validation (Luhn, expiry, CVC) — identical to the tip flows and account page.
Refactor UserPaymentModal (Make a Payment submodal) to use CardSelection:
- Removed its bespoke 'Use a different card' expand/collapse UI and inline
validation derivations (parseExpiryParts, isValidLuhn, touched state)
- Bound selectedCardId + new card fields to the component
- payButtonDisabled now driven by component's onValidityChange callback
- Removed now-unused CardInput import, SvelteDate import, formatCardExpiry
Fix account 'Buy a Gift Card' bug: 'Use a new card' click did nothing because
the auto-select effect immediately re-set buySelectedCard back to the default
card. Added buyShowNewCard flag so the effect only auto-selects on initial load;
reset after successful new-card purchase so the next purchase re-defaults.
Money-moving fixes:
- Tip idempotency key regenerates when the tip amount changes after a failed
attempt (all 3 tip flows). Cached key still reused on same-amount retry
(dedup intact) and cleared on success/modal reset. Prevents silent
under-charge when a user retries at a different amount.
- Till replay path returns actual till_sales.status (may be 'pending') instead
of hardcoded 'completed' — no more misreported successful charge.
- BuyerEmail wired for CreateBookingPayment, gift card purchases, and till
sales (saved_card + online_square), matching the tip flow. Email lookup
errors logged, non-fatal.
- Till buyer-email errors now logged (was silently swallowed).
- on_the_house till top-up uses cached getIdempotencyKey() for retry-safe dedup
(was fresh crypto.randomUUID()).
Test/validation fixes:
- Add TestPaymentFromSquare_* unit tests (else-branch + nil card details),
build tag relaxed to 'test' so they run in the standard dev suite.
- Add TestValidateCardInfo table test (7 cases: both/either/neither/empty).
- Add TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed regression test.
- Remove dead mock pre-registration in TestTipPayment_WithSavedCard.
- Correct misleading till regression-test comment.
ESLint cleanup (12 errors -> 0):
- Remove unused loadingCards in tip + pay-tip pages (dead assignments in
loadSavedCards).
- Scoped eslint-disable for {@html} in CardBrandIcon (hardcoded brand SVGs).
- Remove dead confirmSaveDefaultHours + unused rescheduleVersion prop in
WeeklySchedule (and its parent pass-through).
- Replace new Date() with SvelteDate in WeeklySchedule + BusinessHours.
- Fix each-block key in BusinessHours skeleton loader.
- Use void expression for reactivity-tracker reads in effects.
Homepage: restored original v0 design, replaced lorem ipsum with real service descriptions, added BusinessHours (Opening Hours) section, alternating bg-gray-50 section backgrounds. Layout: added global sticky footer (hidden on /admin/schedule, /account, and ?format=pdf pages), wrapped content in min-h-screen flex layout. PortfolioCarousel: set heading to Playfair Display.
All other pages (contact, admin, account, today, book) use border-based card styling without drop shadows. Removes shadow-sm, hover:shadow-md, and transition classes from all Card.Root instances on the prices page.
Adds GET /api/services/popular endpoint that returns services sorted by booking count (desc) then price (desc) for ties. Prices page now fetches from this endpoint instead of the default alphabetical sort.
bits-ui blocks onValueChange for unavailable dates, leaving stale selection. Adds native click detection on [data-unavailable] elements to call onchange(undefined) so the time picker hides and the user knows nothing was picked.
Replace unreactive @const with for the changed-days filter. Normalize PostgreSQL microsecond times before comparing (TIME::text produces '17:00:00.000000' but staged hours are '17:00'). Change header text to 'These opening hours will change from' when fewer than 7 days differ.
Re-check conflicts immediately before finalizing holiday hours and time blocker saves to prevent race conditions. Add Refresh button to conflict banners. Fix prettier formatting in login page.
Convert WeeklySchedule to schedule staged changes with an effective date picker and conflict detection UI. Show pending scheduled changes in BusinessHours component. Update admin page to pass through props.
Add extractErrorMessage helper for JSON error body parsing and apply sanitizeText across all toast displays. Add time_blockers test coverage for new holiday placeholder cleanup and overlapping scenarios.
Fix isFormComplete derived always returning true for login mode. Now requires email and password to be non-empty before enabling Sign In button. Add early-return guard in handleSubmit to prevent sending empty credentials.
Add conflict detection to the Create Exception Schedule modal with auto-checking, amber warning display, and View Booking/View Client buttons. Wire openUserModal and openBookingModal props from admin page. Fix TimeBlockers placeholder duration from hardcoded 60 to booking.duration_minutes. Remove dead placeholder creation code (isFormValid prevents save while conflicts exist). Fix formatTime overwriting raw hour data with display strings.
Restore processImage (images.go) and nonDepositPaymentType (handlers.go) with //nolint:unused — used in test files.
Fix 97 tx.Rollback defers to silently discard expected "tx is closed" error after commit.
Frontend: remove 44 unused shadcn-svelte files, 2 dead components, 9 stale npm deps, prune unused exports.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
- Remove -timeout 180s/240s from go test and race commands
- Add concurrency group to cancel stale pipelines on new pushes
- Run npm update to bump Vite 7.3.5 -> 7.3.6, fixing nested esbuild vuln
- npm audit now at 0 vulnerabilities
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Centralizes auth token management into a reusable apiFetch() helper and getAuthHeaders() utility, eliminating inline Bearer token logic across all frontend files.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
- Release reservation on service change, date change, and step-back navigation
- Add synchronous double-click payment guard (isProcessingPaymentSync)
- Immutable update for confirmedBooking to prevent race-condition overcharge
- Generate fresh idempotency key per submission attempt (was reused across
component lifetime, causing stale-booking illusion on re-submit)
- Release reservation after successful booking submission
- Extract releaseReservation() helper for DRY reservation cleanup
- Race-condition guard in selectTimeWithValidation: pass clicked time
explicitly so stale validation can't clobber a newer selection
Pre-commit hook now runs eslint on all frontend files regardless of what's staged, catching pre-existing issues. Restore the necessary eslint-disable in gdpr page for the async goto call.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>