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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user