BuyGiftCard and till online_square charge the cnon: nonce directly (no synthetic card-on-file); BuyGiftCard.IdempotencyKey is now validate-required (empty key previously collided on the UNIQUE constraint). Saved-card branches forward customer_id and lazily provision legacy cards. lockCancellationPayments uses the deliberately-blocking xact lock so a cancellation never silently drops a refund under contention (admins see the full manual refund round-trip).
One-off new-card charges now pass the cnon: nonce directly as source_id (no card-on-file, no customer). Save-card charges forward customer_id; legacy saved cards lazily provision a Square customer before charging (EnsureSquareCustomerForSavedCard). Terminal checkouts insert the terminal_checkouts row FIRST with a provisional tmp- id, then update with the real checkout_id, closing the crash window. GetDiscountPreviewHandler gains the fail-closed ownership check (IDOR). DeletePaymentMethod disables the card at Square before soft-delete. Sweep resolves provisional/tmp- terminal rows without a Square round-trip. GetCheckoutStatus rejects tmp- ids.
Introduce acquireAdvisoryLock (pg_try_advisory_lock with a ~3s bounded retry) and acquireAdvisoryXactLockBlocking (deliberately blocking for the cancellation-refund path where silently dropping a refund is worse than waiting). Convert ApplyLoyaltyRedemption to the bounded variant: concurrent redemptions during an in-flight payment return 409 instead of pinning a pool connection. Add uncontended + contended-timeout unit tests and a lock-contended 409 redemption test.
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)
Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
per-payment advisory locks taken before the prior-refunds read
(pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
charge (stable charge-level -square-agg key); atomic group UPDATE
keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
refund_attempts cap; sweep retries stale manual pending refunds with
each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
terminal failed transition: tri-state result leaves rows pending on
reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
(excluding failed); ErrRefundDeclined distinguishes definitive vs
ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
(refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
DO NOTHING without consuming refundRemaining
Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
in request bodies; gate new-card entry behind CardEntryUnavailable
notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording
Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
(single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
goroutines), reconcile error vs no-match branches, stale manual retry,
forgive-fees real refund row + reason, double-cancel dedup, mock refund
key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
P0 — float truncation: applied math.Round to all remaining int64(x*100)
sites (till penceAmount, refund over-refund guard, GetAlreadyRefundedAmount,
payment summary conversions). A £1.14 till sale previously charged 113p.
P0 — raw PAN stopped at the API edge:
- Deleted CardNumber/CardExpMonth/CardExpYear/CardCVC from TillSaleRequest
and CardNumber/Expiry/CVC from CreatePaymentMethodRequest. Both now accept
card_token (Square nonce) and return 400 when absent. PAN+CVV no longer
transit the application server (PCI-DSS SAQ-A scope).
- Deleted CreateCardOnFileRaw from the SquareClient interface and all
implementations (MockClient, ProdClient, devProdClient).
- Added idempotency_key column to refunds table (UNIQUE).
P0 — RefundPayment hardened: advisory lock on payment ID (prevents two
concurrent refunds passing the over-refund guard), pending-refund-record-
then-Square pattern (scheduler reprocesses on failure), same-key dedup.
P1 — till sale pending-retry now re-attempts the Square charge instead of
returning the stale 'pending' status (gift card was already funded in the
committed tx — silent money loss otherwise). Sale row reused, not duplicated.
P1 — idempotency key caching in frontend: BuyGiftCard and
UserPaymentModal/BookingFlow now cache the key per amount+card, regenerated
on change and cleared on success — matches the tip-flow pattern so a
lost-response retry dedups instead of double-charging.
P1 — CreateTerminalPayment cash/giftcard INSERTs now persist idempotency_key.
Key is unique per payment (booking+type+amount would wrongly dedup two
legitimate identical payments, e.g. two £50 cash receipts).
P1 — gift-card codes no longer logged (spendable credential; value+recipient
only).
Tests: till pending-retry re-attempt, refund same-key dedup, mock CreatePayment
idempotency dedup, CreatePaymentMethod nonce happy path + raw-PAN rejection,
till online_square card_token required/valid.
The pending-retry amount guard compared pence via int64(pounds*100),
which truncates instead of rounding. For non-exact pound values (e.g.
£1.14 stored as the float64 1.1399999999999999) the truncation yields
113 != 114, falsely rejecting a legitimate same-amount retry with 400.
Since the frontend reuses the idempotency key on same-amount retries,
every retry was rejected, permanently stranding the pending record
(and any orphaned Square charge) with no recovery path.
Fix: compare in pence via math.Round — the existing pattern already
used in refunds.go — so non-exact values round to the true pence.
Also applied the same correction to the sibling lossy conversions:
- CreateTipPayment / CreateBookingPayment completed-dedup responses
(would have reported 113p for a 114p payment)
- BuyGiftCard retry amount guard (latent: £10/£20/£50 are float-exact
so it never bit, but the identical trap is now closed)
Tests:
- TestTipPayment_RetryPending_NonExactAmountSucceeds: 114p pending
record + same-amount retry completes and charges 114p (failed on the
old truncation with 400)
- TestTipPayment_RetryPending_AmountMismatchRejected: a same-key retry
at a different amount is still rejected with 400 and the pending
record is left untouched
Verified: full backend suite green (25/25 packages, 0 failures),
-race clean on handlers/payments, go build + go vet clean.
N1 (HIGH) — BuyGiftCard concurrent same-key retry could double-issue gift
cards (2× value for 1 charge). Added pg_advisory_lock on the idempotency key
(mirroring the tip pattern) acquired before the idempotency check, so
concurrent same-key retries serialize and only one executes gift-card
creation.
N2 — Amount-equality guards in both reuse branches (CreateTipPayment and
BuyGiftCard). A same-key retry with a different amount now returns 400
instead of silently mutating the pending record's books/VAT/refund caps.
N3 — test coverage:
- TestBuyGiftCard_RetryPending_ReattemptsCharge: pending record + same-key
retry re-attempts, reuses the record (count=1), completes, and issues the
gift card exactly once.
- TestCreateCheckoutHTTP_DeviceOptionsWireShape: httptest.Server asserts
device_id is under checkout.device_options (not top-level). Extracted
createCheckoutHTTPWithClient for injectable base URL.
- MockClient.CreatePayment now dedups on idempotency key (paymentByKey map),
matching real Square behaviour.
N4 — Corrected the savepoint comments in handlers.go and giftcards.go: the
savepoint only exists in the test harness; in production db.Conn.Begin is a
plain tx and the status UPDATE runs on a separate pooled connection. Commit
is a harmless no-op in prod but required in tests.
Bonus bug fixed: CheckIdempotencyByKey scanned NULL booking_id/gift_card_id
(gift-card purchases) into plain string, failing with 'cannot scan NULL'.
Now uses sql.NullString.
Docs: Technical Manual.md:53 and Feature Catalog.md (2.1, 2.5) corrected —
no longer claim Web Payments SDK is live; new-card entry is documented as
pending P11, saved-card flow works via ccof tokens, dev mock rejects raw PANs.
CRITICAL — same-amount tip retry silently never charged:
- CreateTipPayment idempotency check now only short-circuits when the
existing record is 'completed'. A 'pending' record (previous Square call
failed) is REUSED and the charge re-attempted with the same key (Square
dedups safely), instead of returning the stale pending record as 200 with
a success toast and no charge.
- Same fix in BuyGiftCard: pending records trigger a re-attempt, not a
false-success response. Unique idempotency_key constraint means the
pending record must be reused, not re-inserted.
- Fixes the savepoint/rollback interaction: the nested tx (savepoint) is
now committed in the reuse path so the deferred rollback doesn't undo the
later status UPDATE on the same connection.
- Regression test: TestTipPayment_RetryPending_ReattemptsCharge verifies a
pending record + same-key retry re-attempts and completes, reusing the
record (count stays 1).
MAJOR — terminal checkout wire contract:
- device_id now sent as checkout.device_options.device_id (Square's required
shape), not a top-level field which Square rejects with 400.
- 'checkout pending' detection now uses typed sentinel ErrCheckoutPending
with errors.Is in both handlers, matching mock and real HTTP client.
MAJOR — exp_month/exp_year omitted from card creation payload when unset
(now *int with omitempty) — Square would 400 on 0/0; expiry comes from the
tokenized source.
Docs:
- README payments/infrastructure sections corrected (Web Payments SDK claim
replaced with accurate P11-backlog note; dev mock parity described)
- Future Work P11 updated to reflect raw-PAN rejection is now enforced in
both mock and prod (new-card flows are a documented dead end)
- Added plans/p11-square-web-payments-sdk.md: full implementation plan +
handoff prompt for the agent picking up P11 (Web Payments SDK nonces)
Previously the dev MockClient was more permissive than production:
- MockClient.CreateCardOnFileRaw processed raw PANs and stored mock cards,
while ProdClient and devProdClient both block raw PANs. A dev testing the
raw-card flow saw it succeed, masking a production failure.
- MockClient.CreateCardOnFile accepted raw PANs as source_id via an
isAllDigits branch. Real Square only accepts cnon:xxx/ccof:xxx tokens.
Now the mock behaves identically to production:
- CreateCardOnFileRaw returns the same PCI error as ProdClient
- CreateCardOnFile validates source_id is token-like (cnon:/ccof:) and
rejects raw PANs
- Removed dead isAllDigits helper
Tests updated to assert the parity behavior:
- TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity (table-driven,
replaces 5 brand-specific raw-PAN tests)
- TestDevClient_CreateCardOnFile_RejectsRawPAN (replaces RawNumber)
- TestCreatePaymentMethod_HappyPath / SecondCardNotDefault now expect
500 instead of 200, documenting the prod block
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.
Admin no longer receives a notification when someone buys a gift card for a friend. The recipient email and gift code are logged for future SMTP delivery instead.
PostgreSQL ORDER BY ... DESC puts NULLs first by default, so services with no bookings appeared at the top instead of the bottom. Also removes the booking count column from SELECT entirely — the sort is done purely in the ORDER BY.
Adds GET /api/services/popular endpoint that returns services sorted by booking count (desc) then price (desc) for ties. Prices page now fetches from this endpoint instead of the default alphabetical sort.
Add TestRequestEditHandler_PastClosing_Blocked (19:30 extends past 20:00 closing), TestRequestEditHandler_StagedHoursClosed_Blocked (day closed under staged change), and TestRequestEditHandler_ValidTime_Succeeds (10:00 within open hours). Removed the closed-day test that was incompatible with the bookings test DB seed (all 7 days open 08:00-20:00) — that case is covered by the staged-hours test.
RequestEditHandler had empty M8/L5 placeholder comments where hours validation was planned. Implemented full validation: staged-hours-aware closing time check via getClosingTimeForDate, closed-day check, and closing-hours boundary check using the booking's total_duration_minutes. Removed placeholder comments. Updated test to propose an open-day time that doesn't extend past closing.
AdminApproveEditRequestHandler checked exceptional hours but not staged default hours changes. Added getClosingTimeForDate check for the proposed reschedule time, matching the pattern used in AdminRescheduleBookingHandler.
The else-if chain in GetWorkingHours/computeAvailableHours entered the staged change block for ALL days when a pending change existed, but only applied hours for future dates — leaving earlier days as zero-value (closed). Moved the effective date check into the else-if condition so early days fall through to default hours.
Replace clock.Now() with fixed London-timezone times in TestGetCurrentNext_WithMultipleDataPoints to prevent near-midnight UTC failures where bookings fall outside the London 'today' window.
Add ApplyScheduledDefaultHours which checks default_hours_scheduled_changes at midnight, bulk-updates working_hours with staged values, and inserts an admin_notification. Register as the 'apply-default-hours' job in the scheduler (daily at 00:05).
Add tests for ScheduleDefaultHoursChange, GetScheduledDefaultHoursChange, CancelScheduledDefaultHoursChange, GetDefaultHoursConflictingBookings, and GetWorkingHours/GetDefaultHours integration with staged changes.
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.
Integrate getClosingTimeForDate into AdminReserveSlotHandler, CreateBookingHandler, AdminRescheduleBookingHandler, and AdminCreateBookingForUserHandler. Rejects bookings when the staged schedule marks a day as closed (00:00).
Add getClosingTimeForDate that queries default_hours_scheduled_changes for a pending staged change whose effective_date <= the booking date. Falls back to current working_hours if no staged change applies. Also checks the staged schedule to see if a weekday is closed (returns 00:00).
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.
Replace err.Error() concatenation in JSON decode error responses with fixed 'invalid request body' message across 5 locations in custom_services.go, discount_campaigns.go, and services.go.
Add 12 new tests for GetConflictingBookingsForExceptionHandler (closed day, within hours, before-opening, after-closing, multiple weeks, excluded statuses, invalid payload, wrong method) and GetPreviewAvailableHours (no proposed, proposed override, invalid JSON, missing params). Fix pre-existing test with FK violation (non-existent admin_id) by using fixture-created admin user.
Extract computeAvailableHours as shared core between GetAvailableHours and GetPreviewAvailableHours, eliminating ~320 lines of duplication. Add GetConflictingBookingsForExceptionHandler (POST) for holiday hours conflict detection with batch service loading, int-based time comparison, and ActiveBookingStatuses constant. Add RESERVATION:placeholder cleanup (24h TTL). Fix scan error propagation in all scheduling row iterations. Add rows.Err() checks.
The PoolProxy.Begin routes to the outer test transaction, so PG NOW() returns the same value for both handler invocations. Comparing notification IDs correctly verifies the notification was deleted and recreated. Also removes unused testify/assert import.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>