- 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.
212 lines
10 KiB
Go
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)
|
|
}
|