Files
Crussell/backend/handlers/payments/giftcard_clawback.go
T
popertots 42130865f4 fix: gift-card money fixes — partial clawback surfaces CRITICAL (M6), per-admin daily-cap lock (M7), DB-clock expiry
- M6: RevertGiftCardFunding no longer silently drops unreclaimable money. A
  partially-spent create/top-up claws back everything still on the card/balance
  (GREATEST(0, ...) clamp instead of the old guarded 0-row block), inserts a
  CRITICAL admin notification, and returns errClawbackPartiallyReversed so every
  caller (till handler, stale-pending sweep, webhook) surfaces the residual
  without forking the money logic; balance comparisons use pence (penceLess).
- M7: the £5,000/day admin gift-card value cap (create/top-up/transfer) is now
  serialized per-admin under a bounded advisory try-lock
  (acquireGiftCardDailyCapLock) so two concurrent operations cannot both read
  the day's value before either writes and over-issue value.
- M4/M3: every gift-card expiry comparison now reads the DATABASE clock
  (giftCardExpired -> SELECT NOW()), the same clock that wrote expiry_date, so
  app-clock drift can neither extend nor shorten card life; applied on redeem,
  cancellation assessment, cancel-for-user and the reversal re-verification.
- C6 consent fields carried on BuyGiftCardRequest and enforced on the
  (now unreachable) 2FA fallback audit path; fallback audit row captures the
  versioned consent.
2026-08-22 00:34:50 +01:00

212 lines
10 KiB
Go

package payments
import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"math"
"crussell/db"
"github.com/jackc/pgx/v5"
)
// errClawbackPartiallyReversed is returned by RevertGiftCardFunding when some
// of the funding it was asked to revert had already been SPENT before the
// charge failed — the clawback reverts everything still on the card/balance
// but the spent portion cannot be reclaimed. The CRITICAL admin notification
// for reconciliation is inserted inside RevertGiftCardFunding itself, so every
// caller (till handler, stale-pending sweep, Square webhook) surfaces the
// residual without forking the money logic.
var errClawbackPartiallyReversed = errors.New("gift-card funding clawback partially reversed — funding was already spent")
// penceLess reports whether a < b comparing two pound-float balances in pence,
// the only float-safe way to compare money.
func penceLess(a, b float64) bool {
return int64(math.Round(a*100)) < int64(math.Round(b*100))
}
// 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
}
// M6: partial flags that some of the funding was already spent before the
// charge failed — the clawback reverts everything still on the card/balance
// but the spent portion is unrecoverable. Set by the branches below and
// resolved into a CRITICAL admin notification + errClawbackPartiallyReversed
// after the commit.
partial := false
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. M6: the reversal CLAMPS to zero instead
// of the old guarded `balance >= amount` 0-row block — a partially-spent
// redemption must still give back everything that remains on the
// balance; the unreclaimable spent portion is surfaced as a CRITICAL
// admin notification + errClawbackPartiallyReversed below.
if redeemToUserID != nil && *redeemToUserID != "" {
var balanceBefore float64
err := tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, *redeemToUserID).Scan(&balanceBefore)
if err != nil {
// Balance row missing (the credit was never recorded / already
// fully spent — nothing to reverse) or unreadable. A
// non-ErrNoRows read failure means the debit could not be
// verified — flag the sale as partially reversed so the owner
// reconciles instead of silently keeping the credit.
if !errors.Is(err, pgx.ErrNoRows) {
partial = true
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not read the balance credited to user %s (%v) — MANUAL RECONCILIATION REQUIRED", giftCardID, *redeemToUserID, err)
}
} else {
if _, bErr := tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = GREATEST(0, balance - $1), updated_at = NOW()
WHERE user_id = $2
`, amount, *redeemToUserID); bErr != nil {
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", bErr)
}
if penceLess(balanceBefore, amount) {
partial = true
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 (some was already spent — remaining balance clamped to zero)", giftCardID, amount, *redeemToUserID)
}
}
}
} else {
// Top-up: subtract the amount back out of the card. M6: the reversal
// CLAMPS amount_remaining and total_funds_added to zero instead of the
// old guarded `amount_remaining >= amount` 0-row block — a partially-
// spent top-up must still give back everything still on the card; the
// unreclaimable spent portion is surfaced as a CRITICAL admin
// notification + errClawbackPartiallyReversed below.
var remainingBefore float64
if err := tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&remainingBefore); err != nil {
// The card is gone (no FK — a concurrent gift-card cancellation can
// remove it) or unreadable: nothing can be reverted, but the sale
// must STILL be marked failed. Flag the funding as unrecovered so
// the owner reconciles instead of the sale staying pending forever.
partial = true
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be reversed (card missing/unreadable: %v)", amount, giftCardID, err)
} else {
if _, err := tx.Exec(ctx, `
UPDATE gift_cards
SET total_funds_added = GREATEST(0, total_funds_added - $1),
amount_remaining = GREATEST(0, amount_remaining - $1)
WHERE id = $2
`, amount, giftCardID); err != nil {
return fmt.Errorf("failed to reverse gift card top-up: %w", err)
}
if penceLess(remainingBefore, amount) {
partial = true
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (some of it was already spent — card balance clamped to zero)", 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 — the PARTIAL clawback included (it reverted the balance to
// zero and must leave the same trail). 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)
if partial {
// M6: the funding was partially spent before the charge failed — the
// clawback reverted everything still on the card/balance but the spent
// portion is gone. Surface a CRITICAL admin notification so the owner
// reconciles the residual (the card/balance were clamped to zero and
// the sale is failed; the spent money is unrecoverable and must be
// reviewed) and return the sentinel so every caller knows the reversal
// was not complete.
insertCriticalPaymentNotification(ctx, nil, redeemToUserID)
log.Printf("CRITICAL: till sale %s's gift-card funding clawback was PARTIAL (funding had already been spent) — card/balance reverted to zero; the spent portion is unrecoverable — MANUAL RECONCILIATION REQUIRED", tillSaleID)
return errClawbackPartiallyReversed
}
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)
}