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
+60 -11
View File
@@ -434,8 +434,8 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r
}
case staleReconcileDefinitivelyFailedNoClawback:
// B1 (CRITICAL-HIGH): the auto-refund of the replay-induced
// duplicate charge was definitively REJECTED at Square — the
// duplicate charge stands and the money is REAL at Square, so a
// duplicate charge was definitively REJECTED (or errored) at Square —
// the duplicate charge stands and the money is REAL at Square, so a
// till sale's funded gift card is NOT clawed back. Mark the parent
// row failed, raise the CRITICAL notification, and set b1_attempts
// to the cap so the expired key is never replayed (each replay
@@ -445,6 +445,15 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r
}
notifyStaleRowCritical(ctx, r)
setB1AttemptsToCap(ctx, table, r.ID)
// Loop-B finding (MED): a capped-fail on a till_sale leaves its
// funded gift card outstanding — the duplicate charge at Square is
// REAL and a manual Square refund is the only reversal. Surface the
// outstanding funding with a gift_card_transactions trace so the
// operator sees it (see insertOutstandingGiftCardFundingTrace for
// the manual-resolution path).
if table == "till_sales" && r.HasGiftCard {
insertOutstandingGiftCardFundingTrace(ctx, r)
}
}
}
return resolved, completed, unverifiable, nil
@@ -853,6 +862,27 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
return true
}
// insertOutstandingGiftCardFundingTrace surfaces a till sale's gift-card funding
// that remains outstanding after a B1 capped-fail (the auto-refund of the
// replay-induced duplicate charge was REJECTED/errored — the duplicate charge
// stands at Square, so the funding is NOT auto-clawed back: reversing money the
// merchant still holds would lose it). The trace row lets the operator see the
// outstanding funding and drives the manual-resolution path:
//
// 1. refund the duplicate charge at Square FIRST (it is real money — the
// customer never authorized it),
// 2. then run clawbackTillSaleFunding (revertGiftCardFunding with the sale's
// action, gift_card_id and amount) to reverse the funding atomically once
// the duplicate is gone.
func insertOutstandingGiftCardFundingTrace(ctx context.Context, r staleRow) {
if _, err := db.Conn.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'awaiting_reversal', $2, 'till_sale', $3, $4, $5)
`, r.ItemID, r.TotalAmount, r.ID, r.RedeemToUserID, "B1 capped-fail: duplicate charge stands at Square — refund it there FIRST, then run the till-sale funding clawback manually"); err != nil {
log.Printf("Failed to insert outstanding gift-card funding trace for till sale %s: %v", r.ID, err)
}
}
// replayRescueLowerBoundSkew is the lower-bound tolerance for a replayed
// COMPLETED payment to still be treated as the ORIGINAL charge under a retained
// key rather than a provably-new duplicate. Rows are inserted pending-first, so
@@ -1221,7 +1251,21 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
log.Printf("stale pending %s row %s: the auto-refund of the replay-induced duplicate charge %s was DEFINITIVELY REJECTED at Square (%v) — marking the row failed with a CRITICAL notification; the expired key will never be replayed", table, r.ID, pr.ID, refundErr)
return staleReconcileDefinitivelyFailedNoClawback, ""
} else {
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key; auto-refund FAILED (%v) — leaving the row PENDING — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag, refundErr)
// Loop-B finding (MED): the auto-refund ERRORED (transport /
// any non-pending, non-rejected error) — the money state at
// Square is unknown, NO refunds row was written, and the row's
// b1_attempts was already incremented. The OLD code left the
// row pending, so the next run re-replayed the SAME expired key
// and minted ANOTHER charge — the b1_attempts cap of 3 allowed
// up to 3 stacked unauthorized charges. Do NOT re-replay a key
// whose refund attempt errored: fail the parent row + CRITICAL
// immediately (the NoClawback branch — the duplicate charge may
// stand at Square, so a till sale's funding is NOT clawed back;
// the b1_attempts cap is pinned so the key is never replayed).
// A genuinely-ambiguous refund (accepted by Square, left PENDING)
// is already handled by the B1 re-poll pass.
log.Printf("stale pending %s row %s: the auto-refund of the replay-induced duplicate charge %s ERRORED (%v) — marking the row failed with a CRITICAL notification; the expired key will never be replayed", table, r.ID, pr.ID, refundErr)
return staleReconcileDefinitivelyFailedNoClawback, ""
}
}
return staleReconcileCompleted, pr.ID
@@ -1422,13 +1466,18 @@ func recordSweepDuplicateRefundRow(ctx context.Context, table string, r staleRow
// hasInFlightSweepDuplicateRefund reports whether a stale pending row carries a
// sweep auto-refund of a replay-induced duplicate charge (B1) that Square left
// PENDING. While the refund is in flight the row must NOT be replayed (a replay
// PENDING — or that a webhook promoted to 'completed' while the parent is still
// pending. While the refund is in flight the row must NOT be replayed (a replay
// of the expired-key row could land ANOTHER charge), blind-failed or clawed
// back (the refund is non-terminal — money may still reverse). The B1 re-poll
// pass (sweepPendingB1Refunds, refunds.go) resolves the parent row when Square
// settles. For a till_sale the refund row's reason carries the sale id (see
// sweepDuplicateRefundReasonFor); a payments-table refund's payment_id IS the
// parent row.
// back (the refund is non-terminal — money may still reverse). Loop-B finding:
// matching only 'pending' let a webhook-promoted 'completed' refund fall off
// the guard, so the next sweep re-replayed the expired key and minted a second
// charge before the re-poll pass could resolve the parent. The parent being
// still pending is implied: the sweep only processes pending rows. The B1
// re-poll pass (sweepPendingB1Refunds, refunds.go) resolves the parent row when
// Square settles. For a till_sale the refund row's reason carries the sale id
// (see sweepDuplicateRefundReasonFor); a payments-table refund's payment_id IS
// the parent row.
func hasInFlightSweepDuplicateRefund(ctx context.Context, table, id string) bool {
var exists bool
var err error
@@ -1436,7 +1485,7 @@ func hasInFlightSweepDuplicateRefund(ctx context.Context, table, id string) bool
err = db.Conn.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM refunds
WHERE status = 'pending' AND square_refund_id IS NOT NULL
WHERE status IN ('pending', 'completed') AND square_refund_id IS NOT NULL
AND reason = $1
)
`, sweepDuplicateRefundReasonFor(id)).Scan(&exists)
@@ -1444,7 +1493,7 @@ func hasInFlightSweepDuplicateRefund(ctx context.Context, table, id string) bool
err = db.Conn.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM refunds
WHERE payment_id = $1 AND status = 'pending' AND square_refund_id IS NOT NULL
WHERE payment_id = $1 AND status IN ('pending', 'completed') AND square_refund_id IS NOT NULL
AND reason = $2
)
`, id, sweepDuplicateRefundReason).Scan(&exists)