- 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.
181 lines
7.1 KiB
Go
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(¬ifCount); 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(¬ifCount); 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)
|
|
}
|
|
}
|