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.
152 lines
6.9 KiB
Go
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)
|
|
}
|