fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore

Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 3866cc5963
commit 4e398a7a2b
31 changed files with 1703 additions and 342 deletions
+154 -8
View File
@@ -20,6 +20,7 @@ import (
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/adminnotify"
"github.com/jackc/pgx/v5"
)
@@ -670,13 +671,26 @@ func disputeNotificationID(squareDisputeID string) string {
// collapse every untracked dispute onto one unacknowledged NULL-booking row.
// The caller supplies a bounded context (webhookDBContext).
func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID string) {
// Round 2 Loop B finding 1: apply the global cap to this insert site too
// (the pre-check logs the suppression; the fold inside each INSERT enforces
// it atomically so concurrent events cannot overshoot together). The
// unacknowledged 'critical_payment_log' queue is shared by every insert
// site in the codebase (sweep, webhook, account-erasure, jobs), so a flood
// at any of them must not bury the single-operator notification centre.
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
log.Printf("[SQUARE-WEBHOOK] critical_payment_log admin notification suppressed (booking=%q dispute=%q) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", bookingID, disputeID, adminnotify.MaxUnacknowledgedCriticalLogs)
return
}
if disputeID != "" {
id := disputeNotificationID(disputeID)
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NOW()
WHERE (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $2
ON CONFLICT (id) DO NOTHING
`, id)
`, id, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
return
@@ -699,7 +713,10 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID
AND an.booking_id IS NOT DISTINCT FROM $1
AND an.acknowledged_at IS NULL
)
`, bid)
AND (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $2
`, bid, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
return
@@ -735,12 +752,22 @@ func unknownEventNotificationID(eventID string) string {
// failure is logged, never a dispatch error (the 501 is already the response).
// The caller supplies a bounded context (webhookDBContext).
func insertUnknownEventNotification(ctx context.Context, eventType, eventID string) {
// Round 2 Loop B finding 1: same atomic global cap as every other
// 'critical_payment_log' insert site — the pre-check logs the suppression,
// the INSERT fold enforces it atomically.
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
log.Printf("[SQUARE-WEBHOOK] critical_payment_log admin notification suppressed for unhandled event %q (event_id=%s) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", eventType, eventID, adminnotify.MaxUnacknowledgedCriticalLogs)
return
}
id := unknownEventNotificationID(eventID)
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NOW()
WHERE (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $2
ON CONFLICT (id) DO NOTHING
`, id)
`, id, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification for unhandled event %q (event_id=%s): %v", eventType, eventID, err)
return
@@ -874,6 +901,12 @@ func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookin
if paymentID == "" {
return
}
// Round 2 Loop B finding 1: same atomic global cap as every other
// 'critical_payment_log' insert site.
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
log.Printf("[SQUARE-WEBHOOK] orphaned-replay admin notification suppressed (origin payment=%s) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", paymentID, adminnotify.MaxUnacknowledgedCriticalLogs)
return
}
id := orphanReplayChargeNotificationID(paymentID)
var bid any
if bookingID != "" {
@@ -881,9 +914,12 @@ func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookin
}
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, $2, NOW())
SELECT $1, 'critical_payment_log'::admin_notification_reason, $2, NOW()
WHERE (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $3
ON CONFLICT (id) DO NOTHING
`, id, bid)
`, id, bid, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert orphaned-replay admin notification: %v", err)
return
@@ -1149,7 +1185,20 @@ func handleRefundUpdated(data json.RawMessage) error {
return fmt.Errorf("refund.updated payload for data.id=%q missing/invalid refund object (id=%q status=%q): %w", env.ID, refund.ID, refund.Status, errWebhookParseFailure)
}
// Single shared Square → local refund-status mapping (payments package) so
// the webhook and the synchronous refund handlers can never drift.
// the webhook and the synchronous refund handlers can never drift. One
// deliberate webhook-only override: APPROVED. The shared mapping maps
// APPROVED→('completed', true) for the SYNCHRONOUS refund handlers, whose
// blocking APPROVED result is final. The webhook is event-driven — an
// APPROVED refund is still in flight at Square and may settle COMPLETED or
// FAILED afterwards. Promoting on APPROVED would stick the row at
// 'completed', and the FAILED demotion below only demotes 'pending' rows
// (the over-refund guard counts completed refunds — demoting would exclude
// money that already moved). Leave the row 'pending'; the Square status is
// logged for the audit trail.
if refund.Status == "APPROVED" {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s status APPROVED is non-terminal for the webhook (Square may still settle COMPLETED or FAILED) — leaving the local row pending", refund.ID)
return nil
}
localStatus, terminal := payments.SquareRefundStatusToLocal(refund.Status)
if !terminal {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s status %q is non-terminal — no local state change", refund.ID, refund.Status)
@@ -1170,6 +1219,18 @@ func handleRefundUpdated(data json.RawMessage) error {
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus)
}
// B1 sweep-dup refunds: a COMPLETED promotion must ALSO resolve the
// parent payment/till_sale row. The B1 re-poll pass (sweepPendingB1Refunds,
// refunds.go) only queries refunds rows still 'pending' — once this
// promotion flips the row to 'completed' the re-poll can never see it
// again, so the parent would stay pending forever and the stale-pending
// sweep would re-replay the expired idempotency key each run, minting a
// new charge every 5 minutes (HIGH, B1). Runs on every COMPLETED event
// so a row stranded by an earlier path is healed too; idempotent — the
// parent UPDATE is status='pending'-guarded and the clawback is claim-first.
if err := resolveSweepDupRefundParent(ctx, refund.ID); err != nil {
return err
}
return nil
case "failed":
// A5d: this webhook demotes a 'pending' row to 'failed' BEFORE the sweep
@@ -1199,6 +1260,91 @@ func handleRefundUpdated(data json.RawMessage) error {
return nil
}
// resolveSweepDupRefundParent resolves the parent row of a B1 sweep auto-refund
// of a replay-induced duplicate charge when the webhook promotes the refund to
// COMPLETED. The B1 re-poll pass (sweepPendingB1Refunds, refunds.go) only
// processes refunds rows still 'pending', so once THIS handler promotes the
// row to 'completed' the re-poll can never resolve the parent again — and a
// still-pending parent lets the stale-pending sweep re-replay its expired
// idempotency key every 5-minute run, minting a NEW charge each time (HIGH).
// This replicates the re-poll's resolveB1ParentFailed semantics here:
//
// - a payments-table refund (reason exactly "duplicate charge — sweep replay")
// is attached to the still-pending parent payment row — payment_id IS that
// row, so it is marked failed;
// - a till_sale refund carries the parent sale id in its reason ("(till_sale
// <id>)"), so the sale's funded gift card is clawed back and the sale marked
// failed, exactly like clawbackFailedTillSales.
//
// Idempotent: the parent UPDATE is status='pending'-guarded and the clawback is
// claim-first (payments.RevertGiftCardFunding), so a concurrent resolution by
// the sweep's own re-poll is a no-op. A non-nil error means a DB failure left
// the parent unresolved — the caller rejects the webhook so Square retries.
func resolveSweepDupRefundParent(ctx context.Context, squareRefundID string) error {
var paymentID, reason string
err := db.Conn.QueryRow(ctx, `
SELECT payment_id, reason FROM refunds WHERE square_refund_id = $1
`, squareRefundID).Scan(&paymentID, &reason)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Refund row already gone (deleted) — nothing to resolve.
return nil
}
log.Printf("[SQUARE-WEBHOOK] Failed to read refund %s for parent resolution: %v", squareRefundID, err)
return err
}
if !strings.HasPrefix(reason, "duplicate charge — sweep replay") {
return nil
}
if reason == "duplicate charge — sweep replay" {
tag, err := db.Conn.Exec(ctx, `
UPDATE payments SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`, paymentID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to mark B1 parent payment %s failed: %v", paymentID, err)
return err
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] B1 sweep-dup refund %s COMPLETED — marked parent payment %s failed", squareRefundID, paymentID)
}
return nil
}
if idx := strings.Index(reason, "(till_sale "); idx >= 0 {
tillSaleID := strings.TrimSuffix(reason[idx+len("(till_sale "):], ")")
return clawbackOneTillSaleByID(ctx, tillSaleID)
}
log.Printf("[SQUARE-WEBHOOK] B1 sweep-dup refund %s COMPLETED but parent could not be identified from reason %q — MANUAL RECONCILIATION REQUIRED", squareRefundID, reason)
return nil
}
// clawbackOneTillSaleByID loads a pending till sale by id and resolves it like
// a definitively failed charge: a gift-card sale has its funding reverted
// atomically with the failed mark; a sale with no gift card is only marked
// failed. Shares clawbackOneTillSale with clawbackFailedTillSales so the B1
// parent resolution and the failed-payment clawback can never drift.
func clawbackOneTillSaleByID(ctx context.Context, saleID string) error {
var (
itemType string
itemID sql.NullString
totalAmount float64
redeemedBy sql.NullString
isCreate *bool
)
err := db.Conn.QueryRow(ctx, `
SELECT ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by,
(ts.created_at = gc.created_at) AS is_create
FROM till_sales ts
LEFT JOIN gift_cards gc ON gc.id = ts.item_id
WHERE ts.id = $1
`, saleID).Scan(&itemType, &itemID, &totalAmount, &redeemedBy, &isCreate)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to load till sale %s for B1 parent clawback: %v", saleID, err)
return err
}
return clawbackOneTillSale(ctx, saleID, itemType, itemID, totalAmount, redeemedBy, isCreate)
}
// truncateDisputeReason caps a Square dispute reason at the disputes.reason
// VARCHAR(192) column width. An over-long reason would fail the disputes
// INSERT; the handler treats that as a dispatch error (no dedup row, 5xx), so
@@ -38,13 +38,21 @@ func createWebhookTestPayment(t *testing.T, squarePaymentID, status string) stri
}
func createWebhookTestRefund(t *testing.T, paymentID, squareRefundID, status string) string {
t.Helper()
return createWebhookTestRefundWithReason(t, paymentID, squareRefundID, status, "webhook test refund")
}
// createWebhookTestRefundWithReason is createWebhookTestRefund with an explicit
// reason — the B1 sweep-dup parent-resolution tests need the
// "duplicate charge — sweep replay" reason to exercise finding 1.
func createWebhookTestRefundWithReason(t *testing.T, paymentID, squareRefundID, status, reason string) string {
t.Helper()
var id string
err := db.Conn.QueryRow(context.Background(), `
INSERT INTO refunds (payment_id, amount, reason, status, square_refund_id, created_at)
VALUES ($1, 5.00, 'webhook test refund', $3, $2, NOW())
VALUES ($1, 5.00, $4, $3, $2, NOW())
RETURNING id
`, paymentID, squareRefundID, status).Scan(&id)
`, paymentID, squareRefundID, status, reason).Scan(&id)
if err != nil {
t.Fatalf("failed to create webhook test refund: %v", err)
}
@@ -1289,6 +1297,176 @@ func TestWebhook_RefundUpdated_NonTerminal_LeavesPending(t *testing.T) {
}
}
// TestWebhook_RefundUpdated_Approved_IsNonTerminal locks the webhook-only
// APPROVED override (finding 2): an APPROVED Square refund is still in flight
// and may settle COMPLETED or FAILED afterwards, so the webhook must leave the
// local row 'pending' — NOT promote it to 'completed' the way the synchronous
// refund handlers resolve a blocking APPROVED result. A later FAILED event
// must then be able to demote it; promoting on APPROVED would make that
// demotion a no-op (the FAILED path only demotes 'pending' rows) and the
// over-refund guard would count money that never actually moved.
func TestWebhook_RefundUpdated_Approved_IsNonTerminal(t *testing.T) {
const (
squarePaymentID = "sqp_refund_pay_approved"
squareRefundID = "sqr_updated_approved"
)
payID := createWebhookTestPayment(t, squarePaymentID, "completed")
refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending")
approved := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_approved_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
"object": {
"refund": {
"id": "` + squareRefundID + `",
"status": "APPROVED",
"payment_id": "` + squarePaymentID + `"
}
}
}`),
}
w := deliverWebhook(t, approved)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 on APPROVED, got %d: %s", w.Code, w.Body.String())
}
if got := getRefundStatus(t, refundID); got != "pending" {
t.Fatalf("expected APPROVED to leave the refund 'pending', got %q", got)
}
// A later FAILED event (Square declined the refund) must demote the still-
// pending row — the exact transition promoting on APPROVED would have broken.
failed := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_approved_fail_1",
CreatedAt: "2025-01-01T00:00:01Z",
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
"object": {
"refund": {
"id": "` + squareRefundID + `",
"status": "FAILED",
"payment_id": "` + squarePaymentID + `"
}
}
}`),
}
w2 := deliverWebhook(t, failed)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 on FAILED after APPROVED, got %d: %s", w2.Code, w2.Body.String())
}
if got := getRefundStatus(t, refundID); got != "failed" {
t.Errorf("expected the APPROVED-then-FAILED refund to be demoted to 'failed', got %q", got)
}
}
// TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent locks finding 1:
// a COMPLETED webhook for a B1 sweep auto-refund of a replay-induced duplicate
// charge (reason "duplicate charge — sweep replay") must ALSO resolve the
// parent payment row — the B1 re-poll pass (sweepPendingB1Refunds, refunds.go)
// only queries refunds rows still 'pending', so once the webhook promotes this
// row to 'completed' the re-poll can never resolve the parent again. A
// still-pending parent would let the stale-pending sweep re-replay the expired
// idempotency key each run, minting a NEW charge (HIGH). The parent payment
// must be marked failed by the same webhook that promoted the refund.
func TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent(t *testing.T) {
const (
squarePaymentID = "sqp_refund_sweepdup_parent"
squareRefundID = "sqr_sweepdup_parent"
)
// The parent is a still-PENDING payment row (the sweep-failed row the
// duplicate was minted for). The refund is attached to it with the B1
// sweep-dup reason.
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
refundID := createWebhookTestRefundWithReason(t, payID, squareRefundID, "pending", "duplicate charge — sweep replay")
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_sweepdup_parent_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
"object": {
"refund": {
"id": "` + squareRefundID + `",
"status": "COMPLETED",
"payment_id": "` + squarePaymentID + `"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getRefundStatus(t, refundID); got != "completed" {
t.Errorf("expected sweep-dup refund 'completed', got %q", got)
}
if got := getPaymentStatus(t, payID); got != "failed" {
t.Errorf("expected the sweep-dup refund's parent payment to be resolved to 'failed', got %q", got)
}
}
// TestWebhook_RefundUpdated_Completed_SweepDupResolvesTillSale locks the
// till_sale branch of finding 1: a COMPLETED webhook for a B1 sweep-dup refund
// whose reason carries "(till_sale <id>)" must claw back the till sale's funded
// gift card and mark the sale failed — mirroring the sweep's re-poll resolution.
func TestWebhook_RefundUpdated_Completed_SweepDupResolvesTillSale(t *testing.T) {
const squareRefundID = "sqr_sweepdup_tillsale"
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, "sqp_sweepdup_tillsale_pay", "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z", 40.00)
// A synthetic completed payments row anchors the refund (mirrors
// recordSweepDuplicateRefundRow); the till_sale id lives in the reason.
adminID, err := fixtures.CreateTestAdminUser(db.Conn)
if err != nil {
t.Fatalf("failed to create admin for sweep-dup till sale: %v", err)
}
var payID string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, created_by, created_at)
VALUES ('full', 'in_person_card', 'completed', 40.00, $1, NOW())
RETURNING id
`, adminID).Scan(&payID); err != nil {
t.Fatalf("failed to create synthetic payment for sweep-dup till sale: %v", err)
}
refundID := createWebhookTestRefundWithReason(t, payID, squareRefundID, "pending", "duplicate charge — sweep replay (till_sale "+saleID+")")
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_sweepdup_tillsale_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
"object": {
"refund": {
"id": "` + squareRefundID + `",
"status": "COMPLETED",
"payment_id": "sqp_sweepdup_tillsale_pay"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getRefundStatus(t, refundID); got != "completed" {
t.Errorf("expected sweep-dup refund 'completed', got %q", got)
}
if got := getTillSaleStatus(t, saleID); got != "failed" {
t.Errorf("expected the sweep-dup refund's till sale to be marked 'failed', got %q", got)
}
if exists := giftCardExists(t, giftCardID); exists {
t.Error("expected the created gift card to be deleted by the till-sale clawback")
}
}
// TestWebhook_RefundUpdated_DoesNotDemoteCompleted guards the FAILED
// transition: a completed refund must never be demoted to 'failed' by a late
// webhook, since the over-refund guard counts 'completed' refunds — demoting