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