Files
Crussell/backend/handlers/payments/giftcard_clawback_test.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

181 lines
7.1 KiB
Go

//go:build test && dev
package payments
import (
"context"
"errors"
"testing"
"crussell/db"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// TestRevertGiftCardFunding_TopupPartiallySpent_InsertsCriticalNotification
// locks the M6 neither-path: when the top-up funding was already partially spent
// before the charge failed, RevertGiftCardFunding clamps the card to zero,
// returns errClawbackPartiallyReversed AND inserts a critical-payment admin
// notification so the residual is surfaced in the admin notification centre
// (a bare log line is not enough — an operator must see the reconciliation
// item).
func TestRevertGiftCardFunding_TopupPartiallySpent_InsertsCriticalNotification(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var cardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES (50.00, 10.00, $1, FALSE)
RETURNING id
`, adminID).Scan(&cardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
var saleID string
if err := tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed till sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, cardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
})
err = revertGiftCardFunding(pool, "topup", cardID, 50.00, nil, saleID)
if !errors.Is(err, errClawbackPartiallyReversed) {
t.Fatalf("expected errClawbackPartiallyReversed for the partially-spent top-up, got %v", err)
}
// M6: the CRITICAL admin notification must exist for the residual (a till
// sale has no booking or user attribution, so the notification dedup key is
// (nil, nil)).
var notifCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND user_id IS NULL`).Scan(&notifCount); err != nil {
t.Fatalf("failed to count admin notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected a critical-payment admin notification for the partial clawback, got %d", notifCount)
}
}
// TestRevertGiftCardFunding_CreateWithRedeemPartiallySpent_ClampsAndFlags locks
// the M6 create-with-redeem neither-path: a created card immediately redeemed to
// a user balance that was partially spent before the charge failed is deleted,
// the balance is clamped to zero, errClawbackPartiallyReversed is returned and
// a critical admin notification is inserted for the unrecoverable spent
// portion.
func TestRevertGiftCardFunding_CreateWithRedeemPartiallySpent_ClampsAndFlags(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
customerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create customer user: %v", err)
}
var cardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES (50.00, 0.00, $1, FALSE)
RETURNING id
`, adminID).Scan(&cardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
// The redeem credited £50 to the customer, who spent £40 before the charge
// failed.
if _, err := tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, 10.00, NOW())
`, customerID); err != nil {
t.Fatalf("failed to seed user balance: %v", err)
}
var saleID string
if err := tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed till sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`)
_, _ = db.Conn.Exec(pool, `DELETE FROM user_giftcard_balances WHERE user_id = $1`, customerID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, cardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = ANY($1)`, []string{adminID, customerID})
})
err = revertGiftCardFunding(pool, "create", cardID, 50.00, &customerID, saleID)
if !errors.Is(err, errClawbackPartiallyReversed) {
t.Fatalf("expected errClawbackPartiallyReversed for the partially-spent redeemed balance, got %v", err)
}
// The created card is deleted and the balance clamped to zero.
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, cardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 0 {
t.Errorf("expected the created card deleted by the clawback, got %d cards", cardCount)
}
var balance float64
if err := db.Conn.QueryRow(pool, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, customerID).Scan(&balance); err != nil {
t.Fatalf("failed to query user balance: %v", err)
}
if balance != 0.00 {
t.Errorf("expected the partially-spent redeemed balance clamped to zero, got £%.2f", balance)
}
// The sale is failed and a CRITICAL notification surfaced.
var saleStatus string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "failed" {
t.Errorf("expected till sale marked failed, got %q", saleStatus)
}
var notifCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, customerID).Scan(&notifCount); err != nil {
t.Fatalf("failed to count admin notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected a critical-payment admin notification for the partial clawback, got %d", notifCount)
}
}