Files
Crussell/backend/handlers/payments/giftcard_clawback.go
T
popertots 4e398a7a2b 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.
2026-08-22 00:34:50 +01:00

152 lines
6.9 KiB
Go

package payments
import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"crussell/db"
"github.com/jackc/pgx/v5"
)
// RevertGiftCardFunding undoes the gift-card funding performed earlier in the
// SAME till-sale request after a definitive Square charge rejection, matching
// the gift_card_transactions accounting: a created card is deleted (with its
// purchase transaction) and any immediate redeem-to-account credit reversed; a
// topped-up card has the amount subtracted back out and its top-up transaction
// removed. The clawback is claim-first: it atomically claims the till sale
// with a gating `status='pending'` UPDATE whose row lock serializes against
// the handler's completion UPDATE, then runs the card mutation + failed-mark
// in the same transaction so a late same-key retry cannot re-complete a sale
// whose gift card no longer exists.
//
// This is the SINGLE money-reversal implementation shared by the till handler
// (revertGiftCardFunding), the stale-pending sweep (sweep.go) and the Square
// webhook clawback (handlers/webhooks/square.go). Do not fork it: a divergent
// clawback is real money loss.
func RevertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin clawback transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback gift-card clawback transaction", "err", err)
}
}()
// Claim the sale first: the row lock serializes against the handler's
// completion UPDATE; a zero-row claim means the funding is not ours.
tag, err := tx.Exec(ctx, `
UPDATE till_sales SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'`, tillSaleID)
if err != nil {
return fmt.Errorf("failed to claim till sale for clawback: %w", err)
}
if tag.RowsAffected() == 0 {
return errTillSaleNotPending
}
if action == "create" {
// A newly created card's transactions are scoped to THIS sale's
// funding (reference_type='till_sale' AND reference_id=sale id) — never
// a wholesale delete, which would destroy the value of a different
// idempotency-keyed top-up sale that funded the same card before this
// create resolved. Then remove the card itself.
if _, err := tx.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card transaction: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil {
return fmt.Errorf("failed to delete gift card: %w", err)
}
// If the card was immediately redeemed to a user balance in this
// request, reverse that credit (guarded so it can never go negative).
if redeemToUserID != nil && *redeemToUserID != "" {
bTag, bErr := tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = user_giftcard_balances.balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
`, amount, *redeemToUserID)
if bErr != nil {
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", bErr)
}
if bTag.RowsAffected() == 0 {
// The guard blocked the reversal because some of the credited
// balance was already spent. The sale is still marked failed
// below — do not fail the whole clawback tx — but the
// un-reversed credit must be flagged for manual reconciliation
// (mirrors the top-up branch).
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not fully reverse the £%.2f balance credited to user %s (balance < amount)", giftCardID, amount, *redeemToUserID)
}
}
} else {
// Top-up: subtract the amount back out of the card. The guard keeps
// amount_remaining from ever going negative in the pathological case
// where some of the top-up was already spent before the charge failed.
tag, err := tx.Exec(ctx, `
UPDATE gift_cards
SET total_funds_added = total_funds_added - $1,
amount_remaining = amount_remaining - $1
WHERE id = $2 AND amount_remaining >= $1
`, amount, giftCardID)
if err != nil {
return fmt.Errorf("failed to reverse gift card top-up: %w", err)
}
if tag.RowsAffected() == 0 {
// The guard blocked the reversal because some of the top-up was
// already spent. The sale is still marked failed below — do not
// fail the whole clawback tx — but the unreversed money must be
// flagged for manual reconciliation.
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (amount_remaining < top-up)", amount, giftCardID)
}
// Remove only this request's top-up transaction (reference_id = till
// sale) so prior sales' accounting on the same card is untouched.
if _, err := tx.Exec(ctx, `
DELETE FROM gift_card_transactions
WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2
`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card top-up transaction: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit clawback transaction: %w", err)
}
// MEDIUM-3a coverage: every funding clawback is a money reversal that must
// be auditable. Record it in admin_audit_log (best-effort, own tx —
// InsertAdminAuditCharge's separate transaction keeps a write failure from
// aborting the committed clawback). The create branch DELETES the card, so
// target_gift_card_id must stay NULL (the FK would otherwise block the card
// deletion); the card id is carried in the details. admin_id is NULL too —
// this helper is the shared implementation for the till handler, the
// stale-pending sweep and the Square webhook, which carry no admin actor.
insertGiftCardClawbackAudit(ctx, giftCardID, tillSaleID, action, amount)
return nil
}
// insertGiftCardClawbackAudit records a funding clawback in admin_audit_log
// via the shared InsertAdminAuditCharge helper. Best-effort and non-fatal —
// a failed audit write can never abort the already-committed money reversal.
func insertGiftCardClawbackAudit(ctx context.Context, giftCardID, tillSaleID, action string, amount float64) {
InsertAdminAuditCharge(ctx, "", "", "giftcard_clawback", map[string]any{
"gift_card_id": giftCardID,
"till_sale_id": tillSaleID,
"action": action,
"amount": amount,
})
}
// IsTillSaleNotPending reports whether err is the claim-first sentinel
// (errTillSaleNotPending): the gating `status='pending'` UPDATE matched zero
// rows, so the till sale is no longer pending and its gift card must be left
// untouched. Exported so cross-package clawback callers (the Square webhook)
// can detect the sentinel without reaching into the unexported error value.
func IsTillSaleNotPending(err error) bool {
return errors.Is(err, errTillSaleNotPending)
}