Files
Crussell/backend/handlers/payments/sweep_test.go
T
popertots 352f9e50d4 Fix till-sale money safety: sweep gift-card clawback, orphaned-checkout cancel, cash-retry reconciliation
HIGH-1: a till sale whose card-machine checkout is provably dead, or whose Square reconcile proves the charge never landed, now claws back the funded gift card atomically with the failed mark (claim-first gating UPDATE serializes against the admin retry; blind-fail and ambiguous/lost-response rows never claw back, and a bare CANCEL_REQUESTED is not treated as proof of non-completion). HIGH-2: a card-machine checkout created at Square but not committed is cancelled on any pre-commit failure. HIGH-3: a cash/on_the_house retry of a pending card sale is reconciled at Square first (COMPLETED rescues + refuses cash; NOT_FOUND/FAILED/CANCELED allows cash; lost-response forces the card-method retry; ambiguous rejects). GetTillCheckoutStatus no longer resurrects a swept-failed sale, and the cash-completion UPDATE checks RowsAffected so the admin is never told to take cash against an already-resolved sale. 23 new tests covering the clawback matrix, the reconcile-or-reject matrix, cancel-on-error, and a till concurrency test.
2026-08-22 00:34:49 +01:00

1068 lines
41 KiB
Go

//go:build test && dev
package payments
import (
"context"
"errors"
"fmt"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// TestSweepStalePendingPayments_ReconcileCompleted locks the F3 fix: a stale
// pending payment whose square_payment_id resolves to a COMPLETED charge at
// Square (the DB row was genuinely charged, the post-charge DB write failed)
// is rescued to 'completed' instead of being swept to 'failed' with no
// automatic resolution.
func TestSweepStalePendingPayments_ReconcileCompleted(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
// Seed the completed charge at Square with the same idempotency semantics
// the charge would have used in production.
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 200000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "seed-stale-completed",
})
if err != nil {
t.Fatalf("failed to seed completed Square payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, staleID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
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 test tx: %v", err)
}
// The committed rows live in the SHARED test pool, so clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "completed" {
t.Errorf("expected genuinely-charged stale pending payment rescued to 'completed', got %q", status)
}
}
// TestSweepStalePendingPayments_ReconcileNotFound_Fails locks the F3 fallback:
// a stale pending payment whose square_payment_id does NOT resolve to a
// COMPLETED charge at Square (payment not found / not completed) is marked
// failed exactly as the legacy bulk sweep did — the double-charge window must
// stay closed.
func TestSweepStalePendingPayments_ReconcileNotFound_Fails(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_not_in_mock' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
// The default mock has no payment under 'sqp_not_in_mock' → GetPayment
// returns not-found → the row must be failed, not left pending.
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
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 test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "failed" {
t.Errorf("expected stale pending payment with no COMPLETED charge at Square marked 'failed', got %q", status)
}
}
// TestSweepStalePendingPayments_ReconcileTillSale_Completed locks the F3 fix
// for till_sales: a stale pending till sale whose square_payment_id resolves
// to a COMPLETED charge at Square is rescued to 'completed' like payments.
func TestSweepStalePendingPayments_ReconcileTillSale_Completed(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)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "seed-stale-till-completed",
})
if err != nil {
t.Fatalf("failed to seed completed Square payment: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_payment_id, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, $2, NOW() - INTERVAL '25 hours', NOW())
RETURNING id
`, pay.SquarePayID, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed stale pending 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 test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "completed" {
t.Errorf("expected genuinely-charged stale till sale rescued to 'completed', got %q", status)
}
}
// completedTerminalClient makes one checkout look COMPLETED at Square while
// delegating everything else to the real mock — used to prove the terminal
// sweep never cancels a checkout that may have completed.
type completedTerminalClient struct {
square.SquareClient
checkoutID string
}
func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_terminal_completed"}, nil
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
// TestSweepStaleTerminalCheckouts_CancelsStalePending locks the F4 fix: a
// terminal checkout still PENDING at Square after an hour is cancelled and its
// till_sales row moved to the terminal 'failed' state (the payment_status enum
// has no 'cancelled' value).
func TestSweepStaleTerminalCheckouts_CancelsStalePending(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)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.HoldCheckouts = true
checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "chk-stale-terminal",
})
if err != nil {
t.Fatalf("failed to create pending Square checkout: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', $1, $2, NOW() - INTERVAL '2 hours', NOW())
RETURNING id
`, checkout.ID, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed stale terminal 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 test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
freshCtx := context.Background()
// Drop any other stale terminal rows left by parallel tests so the count is
// deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
}
n, err := SweepStaleTerminalCheckouts(freshCtx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected exactly 1 cancelled stale terminal checkout, got %d", n)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
t.Fatalf("failed to query sale: %v", err)
}
if status != "failed" {
t.Errorf("expected cancelled stale terminal sale marked 'failed', got %q", status)
}
// The checkout must no longer be PENDING at Square (it was cancelled).
if _, gErr := mock.GetCheckout(freshCtx, checkout.ID); gErr == nil || errors.Is(gErr, square.ErrCheckoutPending) {
t.Errorf("expected checkout %s to be cancelled at Square (no longer pending), GetCheckout err=%v", checkout.ID, gErr)
}
}
// TestSweepStaleTerminalCheckouts_LeavesCompletedAlone locks the conservative
// F4 rule: a checkout that has COMPLETED at Square is never cancelled — the
// poll handler records it; cancelling a completed checkout would orphan the
// charge.
func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(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 saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', 'chk_completed_terminal', $1, NOW() - INTERVAL '2 hours', NOW())
RETURNING id
`, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed stale terminal sale: %v", err)
}
origClient := SquareClient
SquareClient = &completedTerminalClient{SquareClient: square.NewDevClient(), checkoutID: "chk_completed_terminal"}
defer func() { SquareClient = origClient }()
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 test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
freshCtx := context.Background()
n, err := SweepStaleTerminalCheckouts(freshCtx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 0 {
t.Errorf("expected a completed terminal checkout to be left alone, got %d cancellations", n)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
t.Fatalf("failed to query sale: %v", err)
}
if status != "pending" {
t.Errorf("expected completed terminal checkout's sale left 'pending' (poll handler records it), got %q", status)
}
}
// =============================================================================
// SweepStalePendingPayments — tri-state reconcile (LOW money-integrity)
// =============================================================================
// staleGetPaymentClient forces GetPayment to return a fixed result/error so the
// reconcile tri-state branches can be exercised deterministically.
type staleGetPaymentClient struct {
square.SquareClient
result *square.PaymentResult
err error
}
func (c *staleGetPaymentClient) GetPayment(ctx context.Context, paymentID string) (*square.PaymentResult, error) {
if c.err != nil {
return nil, c.err
}
if c.result != nil {
return c.result, nil
}
return c.SquareClient.GetPayment(ctx, paymentID)
}
func TestSweepStalePendingPayments_ReconcileTriState(t *testing.T) {
cases := []struct {
name string
result *square.PaymentResult
getErr error
wantFinal string // "completed", "failed", or "pending"
}{
{
name: "completed_rescues_row",
result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_tri_completed"},
wantFinal: "completed",
},
{
name: "not_found_marks_failed",
getErr: fmt.Errorf("square: GET /v2/payments/sqp_x: [PAYMENT_NOT_FOUND/NOT_FOUND] payment does not exist"),
wantFinal: "failed",
},
{
name: "ambiguous_error_leaves_pending",
getErr: fmt.Errorf("network error: connection reset by peer"),
wantFinal: "pending",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_tri_state' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: tc.result, err: tc.getErr}
defer func() { SquareClient = origClient }()
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 test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != tc.wantFinal {
t.Errorf("expected stale pending payment %q after reconcile, got %q", tc.wantFinal, status)
}
})
}
}
// =============================================================================
// SweepStaleTerminalCheckouts — completed-during-cancel re-check (TOCTOU)
// =============================================================================
// completingDuringCancelClient reports ErrCheckoutPending on the FIRST
// GetCheckout for the target checkout and COMPLETED on later calls — simulating
// a customer completing the payment between the sweep's status check and its
// CancelCheckout.
type completingDuringCancelClient struct {
square.SquareClient
checkoutID string
calls int
}
func (c *completingDuringCancelClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
c.calls++
if c.calls == 1 {
return nil, square.ErrCheckoutPending
}
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_completed_during_cancel"}, nil
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
func TestSweepStaleTerminalCheckouts_CompletedDuringCancel_MarkedCompleted(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
const checkoutID = "chk_completes_during_cancel"
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
`, checkoutID, bookingID); err != nil {
t.Fatalf("failed to seed stale terminal checkout row: %v", err)
}
origClient := SquareClient
SquareClient = &completingDuringCancelClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID}
defer func() { SquareClient = origClient }()
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 test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
// Drop any other stale terminal rows left by parallel tests so the count is
// deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkoutID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
n, err := SweepStaleTerminalCheckouts(freshCtx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected exactly 1 resolved terminal checkout (completed during cancel), got %d", n)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", checkoutID).Scan(&status); err != nil {
t.Fatalf("failed to query terminal checkout: %v", err)
}
if status != "COMPLETED" {
t.Errorf("expected a checkout that completed during the cancel window marked 'COMPLETED', got %q", status)
}
}
// =============================================================================
// Sweep clawback — funded gift cards are reverted when the sale is provably
// dead (HIGH-1)
// =============================================================================
// seedStaleTillSaleWithCard seeds a stale pending till_sale (created 25h ago,
// past the 24h stale cutoff) with its gift card inside the caller's setup
// transaction. isCreate=true seeds the gift card with the SAME created_at
// timestamp so the sweep's created_at-equality discriminates a create (a real
// create sets both timestamps to the transaction-start NOW()); isCreate=false
// predates the card so the sweep treats the sale as a topup. Returns the sale
// and gift-card ids plus a pool-level cleanup closure.
func seedStaleTillSaleWithCard(t *testing.T, ctx context.Context, q db.Querier, adminID string, saleAmount float64, squarePaymentID string, isCreate bool) (saleID, giftCardID string) {
t.Helper()
pool := context.Background()
gcAge := "NOW() - INTERVAL '30 hours'"
if isCreate {
// Identical to the sale's created_at — provably a create.
gcAge = "NOW() - INTERVAL '25 hours'"
}
if err := q.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, created_at)
VALUES ($1, $1, $2, `+gcAge+`)
RETURNING id
`, saleAmount, adminID).Scan(&giftCardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
sqParam := any(squarePaymentID)
if squarePaymentID == "" {
sqParam = nil
}
if err := q.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_payment_id, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card', 1, $2, $2, 'online_square', 'pending', $3, $4,
NOW() - INTERVAL '25 hours', NOW())
RETURNING id
`, giftCardID, saleAmount, sqParam, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed stale pending till sale: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
})
return saleID, giftCardID
}
// TestSweepStalePendingPayments_TillCreateWithRedeem_Clawbacks locks the
// HIGH-1 create-with-redeem clawback: a stale pending till sale whose Square
// charge provably never completed (NOT_FOUND reconcile) is marked failed and
// its created gift card is DELETED while the user's redeemed balance is
// reversed back to zero.
func TestSweepStalePendingPayments_TillCreateWithRedeem_Clawbacks(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)
}
redeemerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create redeemer user: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM user_giftcard_balances WHERE user_id = $1`, redeemerID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, redeemerID)
})
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "sqp_till_clawback_create", true)
// The card was redeemed to the user's account balance in the same sale.
if _, err := tx.Exec(ctx, `
UPDATE gift_cards SET redeemed_by = $1, amount_remaining = 0.00 WHERE id = $2
`, redeemerID, giftCardID); err != nil {
t.Fatalf("failed to mark gift card redeemed: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, 50.00, NOW())
`, redeemerID); err != nil {
t.Fatalf("failed to seed user gift card balance: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'purchase', 50.00, 'till_sale', $2)
`, giftCardID, saleID); err != nil {
t.Fatalf("failed to seed gift card transaction: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(),
err: fmt.Errorf("square: GET /v2/payments/sqp_till_clawback_create: [PAYMENT_NOT_FOUND/NOT_FOUND] payment does not exist")}
defer func() { SquareClient = origClient }()
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)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected stale pending till sale marked failed after clawback, got %q", status)
}
// The created card must be GONE.
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 0 {
t.Errorf("expected the clawed-back created gift card deleted, got %d cards", cardCount)
}
// The redeemed balance must be reversed to zero.
var balance float64
if err := db.Conn.QueryRow(pool, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, redeemerID).Scan(&balance); err != nil {
t.Fatalf("failed to query redeemed balance: %v", err)
}
if balance != 0.00 {
t.Errorf("expected redeemed balance reversed to 0.00, got %.2f", balance)
}
}
// TestSweepStalePendingPayments_TillTopup_ClawbacksAmount_KeepsCard locks the
// HIGH-1 top-up clawback AND the is_create discrimination: a stale pending
// top-up sale whose charge provably never completed is failed and its top-up
// amount subtracted back out of the PRE-EXISTING card (which must NOT be
// deleted — is_create is false because the card predates the sale), and only
// this sale's top-up transaction is removed.
func TestSweepStalePendingPayments_TillTopup_ClawbacksAmount_KeepsCard(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)
}
pool := context.Background()
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "sqp_till_clawback_topup", false)
// The pre-existing card holds £100 (was topped up £50 by this sale).
if _, err := tx.Exec(ctx, `
UPDATE gift_cards SET total_funds_added = 100.00, amount_remaining = 100.00 WHERE id = $1
`, giftCardID); err != nil {
t.Fatalf("failed to set card balance: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'topup', 50.00, 'till_sale', $2)
`, giftCardID, saleID); err != nil {
t.Fatalf("failed to seed gift card transaction: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(),
err: fmt.Errorf("square: GET /v2/payments/sqp_till_clawback_topup: [PAYMENT_NOT_FOUND/NOT_FOUND] payment does not exist")}
defer func() { SquareClient = origClient }()
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)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected stale pending till sale marked failed after top-up clawback, got %q", status)
}
// The PRE-EXISTING card must survive (is_create discrimination) with the
// £50 top-up subtracted back out.
var totalAdded, remaining float64
if err := db.Conn.QueryRow(pool, `SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&totalAdded, &remaining); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if totalAdded != 50.00 || remaining != 50.00 {
t.Errorf("expected top-up reversed out (100.00 -> 50.00), got total_funds_added=%.2f amount_remaining=%.2f", totalAdded, remaining)
}
// This sale's top-up transaction must be removed.
var txCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, giftCardID, saleID).Scan(&txCount); err != nil {
t.Fatalf("failed to count gift card transactions: %v", err)
}
if txCount != 0 {
t.Errorf("expected this sale's top-up transaction removed, got %d", txCount)
}
}
// TestSweepStalePendingPayments_TillAmbiguous_NoClawback locks the HIGH-1
// MUST-NOT: an ambiguous Square reconcile (transport error) leaves the sale
// pending AND the funded gift card untouched — the charge may still complete.
func TestSweepStalePendingPayments_TillAmbiguous_NoClawback(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)
}
pool := context.Background()
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "sqp_till_ambiguous", true)
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), err: fmt.Errorf("network error: connection reset by peer")}
defer func() { SquareClient = origClient }()
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)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "pending" {
t.Errorf("expected ambiguous reconcile to leave the sale pending, got %q", status)
}
// The funded card must be untouched (still exists, fully funded).
var cardCount int
var remaining float64
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*), COALESCE(MAX(amount_remaining), 0) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount, &remaining); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if cardCount != 1 || remaining != 50.00 {
t.Errorf("expected card untouched after ambiguous reconcile, got count=%d remaining=%.2f", cardCount, remaining)
}
}
// TestSweepStalePendingPayments_TillBlindFail_NoClawback locks the HIGH-1
// MUST-NOT: a stale pending till sale with NO square_payment_id (lost response)
// is marked failed WITHOUT clawing back — the charge may have landed at Square
// and the funding must stay put.
func TestSweepStalePendingPayments_TillBlindFail_NoClawback(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)
}
pool := context.Background()
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
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)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected blind-failed till sale marked failed, got %q", status)
}
// The funded card must be untouched (charge outcome unknown).
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected blind-fail to leave the funded card in place, got %d cards", cardCount)
}
}
// terminalErrorClient forces GetCheckout to return a fixed error for the target
// checkout while delegating everything else to the real mock — used to exercise
// the terminal sweep's definitively-dead vs CANCEL_REQUESTED-only branches.
type terminalErrorClient struct {
square.SquareClient
checkoutID string
err error
}
func (c *terminalErrorClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
return nil, c.err
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
// seedStaleTerminalTillSale seeds a stale pending card-machine till sale (with
// square_checkout_id, created 2h ago past the 1h terminal cutoff) and a funded
// gift card, returning ids and a pool-level cleanup closure.
func seedStaleTerminalTillSale(t *testing.T, ctx context.Context, q db.Querier, adminID string, isCreate bool) (saleID, giftCardID string) {
t.Helper()
pool := context.Background()
gcAge := "NOW() - INTERVAL '3 hours'"
if isCreate {
gcAge = "NOW() - INTERVAL '2 hours'"
}
if err := q.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, created_at)
VALUES (50.00, 50.00, $1, `+gcAge+`)
RETURNING id
`, adminID).Scan(&giftCardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
checkoutID := "chk_stale_terminal_till"
if err := q.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_checkout_id, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', $2, $3,
NOW() - INTERVAL '2 hours', NOW())
RETURNING id
`, giftCardID, checkoutID, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed stale terminal till sale: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
})
return saleID, giftCardID
}
// TestSweepStaleTerminalCheckouts_TillDefinitivelyCanceled_Clawbacks locks the
// HIGH-1 terminal clawback: a till-sale checkout that reports CANCELED at
// Square is provably dead, so the sale is failed AND the funded gift card is
// clawed back (deleted for a create).
func TestSweepStaleTerminalCheckouts_TillDefinitivelyCanceled_Clawbacks(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)
}
pool := context.Background()
const checkoutID = "chk_till_definitively_canceled"
saleID, giftCardID := seedStaleTerminalTillSale(t, ctx, tx, adminID, true)
if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_checkout_id = $1 WHERE id = $2`, checkoutID, saleID); err != nil {
t.Fatalf("failed to set checkout id: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'purchase', 50.00, 'till_sale', $2)
`, giftCardID, saleID); err != nil {
t.Fatalf("failed to seed gift card transaction: %v", err)
}
origClient := SquareClient
SquareClient = &terminalErrorClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID,
err: fmt.Errorf("square: checkout %s is CANCELED (not COMPLETED)", checkoutID)}
defer func() { SquareClient = origClient }()
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)
}
// Drop any other stale terminal rows left by parallel tests so the count is
// deterministic.
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
}
n, err := SweepStaleTerminalCheckouts(pool)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected exactly 1 resolved stale terminal checkout, got %d", n)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected definitively-cancelled till sale marked failed, got %q", status)
}
// The created gift card must have been clawed back (deleted).
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 0 {
t.Errorf("expected the clawed-back created gift card deleted, got %d cards", cardCount)
}
}
// TestSweepStaleTerminalCheckouts_TillCancelRequested_NoClawback locks the
// HIGH-1 MUST-NOT: a checkout that reports only CANCEL_REQUESTED is NOT
// provably dead (Square does not promise non-completion), so the sale is
// marked failed WITHOUT clawing back the funded gift card (CRITICAL logged for
// manual reconciliation).
func TestSweepStaleTerminalCheckouts_TillCancelRequested_NoClawback(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)
}
pool := context.Background()
const checkoutID = "chk_till_cancel_requested"
saleID, giftCardID := seedStaleTerminalTillSale(t, ctx, tx, adminID, true)
if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_checkout_id = $1 WHERE id = $2`, checkoutID, saleID); err != nil {
t.Fatalf("failed to set checkout id: %v", err)
}
origClient := SquareClient
SquareClient = &terminalErrorClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID,
err: fmt.Errorf("square: checkout %s is CANCEL_REQUESTED", checkoutID)}
defer func() { SquareClient = origClient }()
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)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
}
if _, err := SweepStaleTerminalCheckouts(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected CANCEL_REQUESTED-only till sale marked failed, got %q", status)
}
// The funded gift card must NOT have been clawed back.
var cardCount int
var remaining float64
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*), COALESCE(MAX(amount_remaining), 0) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount, &remaining); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if cardCount != 1 || remaining != 50.00 {
t.Errorf("expected card untouched after CANCEL_REQUESTED-only, got count=%d remaining=%.2f", cardCount, remaining)
}
}