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)
123 lines
5.6 KiB
Go
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
|
|
}
|