Files
Crussell/backend/handlers/payments/locks.go
T
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

123 lines
5.6 KiB
Go

package payments
import (
"context"
"log"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// advisoryLockAttempts and advisoryLockRetryDelay bound the total time a
// try-lock retry loop waits for a contended advisory lock (~3s). This is the
// core defence against pool exhaustion: every payment handler pins a pool
// connection and would otherwise block on `pg_advisory_lock` for the FULL
// Square round-trip (up to ~30s) of whichever request holds the lock, so a
// handful of concurrent same-key requests can exhaust the whole pool
// (max(4, numCPU)) and hang the app. With a bounded try-lock loop the waiter
// gives up after ~3s and surfaces "operation in progress" instead of holding a
// pool connection hostage.
const (
advisoryLockAttempts = 30
advisoryLockRetryDelay = 100 * time.Millisecond
)
// queryRower is satisfied by both *pgxpool.Conn (session-level locks) and
// pgx.Tx (transaction-scoped locks) so the try-lock helpers work on pinned
// pool connections and inside transactions alike.
type queryRower interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// acquireAdvisoryLock acquires a session advisory lock on the pinned conn with
// bounded retries (defends the pool against concurrent same-key lock waiters).
// Returns (true, nil) once the lock is held; (false, nil) if the lock could
// not be acquired within the bound — the caller must surface a 409/503
// "operation in progress, try again" instead of blocking. The unlock is still
// the caller's responsibility (pg_advisory_unlock on the same conn via defer).
func acquireAdvisoryLock(ctx context.Context, conn *pgxpool.Conn, key string) (bool, error) {
return tryAdvisoryLock(ctx, conn, key, "pg_try_advisory_lock")
}
// releasePaymentLock releases a session advisory lock acquired by
// acquireAdvisoryLock on the SAME pinned pool connection (pg_advisory_unlock
// only releases locks held by the calling session). It is the generic release
// counterpart to acquireAdvisoryLock, and callers defer it immediately after a
// successful acquire so the unlock runs before the deferred pinConn.Release().
// Errors are logged and otherwise ignored — exactly what the inline
// `pg_advisory_unlock(hashtext('crussell:...:' || $1))` blocks it replaces did
// — and the key must be the FULL "crussell:..." string that was hashed at
// acquire time so the two hashtext() calls produce the same lock bigint.
func releasePaymentLock(pinConn *pgxpool.Conn, lockKey string) {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext($1))
`, lockKey); err != nil {
log.Printf("Failed to release payment serialization lock %s: %v", lockKey, err)
}
}
// acquireAdvisoryXactLockBlocking is the transaction-scoped BLOCKING variant
// of acquireAdvisoryLock: it issues `SELECT pg_advisory_xact_lock(...)`
// ONCE and waits for as long as the key is contended — there is no 3s bound.
// The lock is transaction-scoped, so it is auto-released at the caller's
// commit/rollback (never unlocked explicitly).
//
// This stays deliberately blocking for ONE site only: the admin cancellation
// path (lockCancellationPayments). Admin cancellations are rare and there is
// only ONE such transaction at a time, so the pool-exhaustion rationale that
// justifies the bounded try-lock everywhere else does not apply here. A bound
// would be actively harmful: if a manual RefundPayment holds the same
// "crussell:refund:" key across its up-to-30s Square round-trip, a timed-out
// cancellation would abort, and the caller (manage.go) would commit the
// cancellation with ZERO refund rows created — no sweep retry is possible
// because the rows never existed, so the refund would be silently lost. The
// blocking acquire guarantees the cancellation refund runs, waiting for the
// manual refund to finish rather than dropping the money.
func acquireAdvisoryXactLockBlocking(ctx context.Context, tx pgx.Tx, key string) error {
// Exec, not QueryRow.Scan: pg_advisory_xact_lock returns void, and Exec
// discards the result set (the same pattern the payment handlers use for
// their blocking pg_advisory_lock) while still blocking server-side until
// the lock is granted.
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, key)
return err
}
// tryAdvisoryLock is the shared try-lock retry loop. fn is the Postgres
// advisory-lock function to call (pg_try_advisory_lock or
// pg_try_advisory_xact_lock). The key is passed to hashtext() so it is hashed
// to a bigint exactly like the blocking `pg_advisory_lock(hashtext($1))` calls
// it replaces — the two acquire the same locks.
func tryAdvisoryLock(ctx context.Context, q queryRower, key, fn string) (bool, error) {
// ONE reusable timer for the whole retry loop instead of time.After per
// attempt: a contended lock spins ~30 times, and allocating a fresh timer
// (with its own goroutine) on every attempt is wasteful. The timer is
// Stop+drained before each Reset so a previously-fired tick can never make
// the next wait return early (missed-tick semantics). Timing is preserved:
// 100ms between attempts, ~3s total bound.
timer := time.NewTimer(advisoryLockRetryDelay)
defer timer.Stop()
for attempt := 0; attempt < advisoryLockAttempts; attempt++ { // ~3s total
var acquired bool
if err := q.QueryRow(ctx, `SELECT `+fn+`(hashtext($1))`, key).Scan(&acquired); err != nil {
return false, err
}
if acquired {
return true, nil
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(advisoryLockRetryDelay)
select {
case <-timer.C:
case <-ctx.Done():
return false, ctx.Err()
}
}
return false, nil
}