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) }