fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round

Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:

MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance

SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue

DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)

Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent b7122be3a0
commit 36887167c6
32 changed files with 1565 additions and 590 deletions
@@ -7,6 +7,7 @@ import (
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -175,6 +176,33 @@ func releaseBookingPaymentLock(pinConn *pgxpool.Conn, lockKey string) {
pinConn.Release()
}
// writeChargeSnapshot stores the verbatim request JSON so the sweep can replay
// the charge with an IDENTICAL body under the same key (M1). The write is
// immutability-guarded: it records the FIRST attempt's body and stays immutable
// so a nonce-changing retry can never redirect the sweep's replay away from the
// original charge. Reuse paths that legitimately refresh the snapshot (a new
// source on a pending-reuse retry) do so via the Go-reencrypt refresh
// (refreshTillSnapshotSource / the gift-card reuse refresh), not by overwriting
// the guard. table is 'payments' or 'till_sales'; label names the flow for log
// messages (e.g. "payment", "tip payment", "gift-card payment", "till sale").
// Best-effort: failures are logged and the row stays snapshot-less — the sweep
// overrides the replay source from the live square_source_id column.
func writeChargeSnapshot(ctx context.Context, q db.Querier, table, rowID string, body any, label string) {
snap, mErr := json.Marshal(body)
if mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for %s %s: %v", label, rowID, mErr)
return
}
stored, eErr := encryptSnapshot(snap)
if eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for %s %s: %v", label, rowID, eErr)
return
}
if _, sErr := q.Exec(ctx, `UPDATE `+table+` SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), rowID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for %s %s: %v", label, rowID, sErr)
}
}
// recheckBookingPayable re-reads the booking status after a Square charge
// succeeded (R9): a concurrent cancellation/eviction can move the booking out
// of a payable state between the pre-charge status check and the charge
@@ -197,6 +225,40 @@ func recheckBookingPayable(ctx context.Context, q db.Querier, bookingID string)
return status, bookingStatusAllowsCompletedPayment(status), nil
}
// postChargeRecheck re-reads the booking status after a Square charge
// succeeded and, when the booking is no longer payable, marks the payment row
// failed, commits the caller's transaction, writes the 409 conflict response
// and returns false — the caller must abort. The recheck and the failed mark
// run in ONE transaction so the FOR UPDATE row lock taken inside
// recheckBookingPayable persists to commit (C5). Shared by the booking, tip
// and terminal saved-card paths so the R9 recheck cannot drift between them.
// On the not-payable branch the caller's deferred rollback becomes a harmless
// no-op (the commit already closed the transaction). chargeNoun labels the
// CRITICAL log (e.g. "payment", "tip"); conflictMsg is the 409 body.
func postChargeRecheck(ctx context.Context, w http.ResponseWriter, tx pgx.Tx, bookingID, paymentID, sqStatus, sqPayID, chargeNoun, conflictMsg string) (bool, error) {
recheckStatus, payable, err := recheckBookingPayable(ctx, tx, bookingID)
if err != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but re-reading booking %s status failed: %v — manual reconciliation required", sqStatus, sqPayID, bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return false, err
}
if !payable {
log.Printf("CRITICAL: Square payment %s (ID=%s) for booking %s was processed but booking is now %q — marking %s failed; money taken at Square MUST be refunded manually",
sqStatus, sqPayID, bookingID, recheckStatus, chargeNoun)
if _, upErr := tx.Exec(ctx, `UPDATE payments SET status = 'failed' WHERE id = $1`, paymentID); upErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s but marking %s failed errored: %v — manual reconciliation required",
sqStatus, sqPayID, recheckStatus, bookingID, chargeNoun, upErr)
}
if cErr := tx.Commit(ctx); cErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s and committing the failed mark errored: %v — manual reconciliation required",
sqStatus, sqPayID, recheckStatus, bookingID, cErr)
}
http.Error(w, conflictMsg, http.StatusConflict)
return false, nil
}
return true, nil
}
// snapshotEncMarker prefixes the at-rest encrypted form of a stored
// square_request_snapshot (PII: buyer email + ccof tokens) so decryptSnapshot
// can distinguish encrypted values from plaintext (dev/mock environments and