//go:build test && dev package payments import ( "bytes" "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) } // noPaymentIDCompletedClient makes one checkout look COMPLETED at Square with // NO Square payment id — the record-impossible condition the till-sale sweep // must leave pending (never blind-fail) — while delegating everything else to // the real mock. type noPaymentIDCompletedClient struct { square.SquareClient checkoutID string } func (c *noPaymentIDCompletedClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { if checkoutID == c.checkoutID { return &square.PaymentResult{Status: "COMPLETED", Amount: 5000}, nil } return c.SquareClient.GetCheckout(ctx, checkoutID) } // ============================================================================= // SweepStalePendingPayments — lost-response reconcile by idempotency key // ============================================================================= // staleReplayClient forces ReplayPaymentByKey to return a fixed result/error so // the keyed-reconcile branches can be exercised deterministically. type staleReplayClient struct { square.SquareClient result *square.PaymentResult err error } func (c *staleReplayClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*square.PaymentResult, error) { if c.err != nil { return nil, c.err } if c.result != nil { return c.result, nil } return c.SquareClient.ReplayPaymentByKey(ctx, snapshotJSON) } // TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued locks the // lost-response gap: a pending payment with a stored idempotency key but no // square_payment_id whose charge actually COMPLETED at Square (the response // was lost) is rescued to 'completed' with the real square_payment_id written // back by replaying the key, instead of being blind-failed. func TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued(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) } // 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but // still inside Square's 24h idempotency-key retention window (so the replay // returns the original payment instead of being blind-failed). const key = "key-lost-response-completed" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, 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 under the SAME idempotency key the // pending row stores — the lost-response state the sweep must recover from. pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 200000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: key, }) if err != nil { t.Fatalf("failed to seed completed Square payment: %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) } 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, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "completed" { t.Errorf("expected lost-response payment with a completed Square charge rescued to 'completed', got %q", status) } if sqPayID != pay.SquarePayID { t.Errorf("expected square_payment_id %s written back on the rescue, got %q", pay.SquarePayID, sqPayID) } } // TestSweepStalePendingPayments_KeyedLostResponse_NoPayment_Failed locks the // mirror case: a pending payment with a stored idempotency key but no // square_payment_id whose charge Square proves NEVER happened (no payment under // the key) is marked failed — the keyed reconcile runs before the fail, so no // row with a key is ever failed without checking Square first. func TestSweepStalePendingPayments_KeyedLostResponse_NoPayment_Failed(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 '23 hours', idempotency_key = 'key-lost-response-never' WHERE id = $1", staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // A fresh mock has no payment under the key → ReplayPaymentByKey returns // ErrReplayKeyNotRetained → the charge provably never happened → failed. 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 keyed payment with no charge at Square marked 'failed', got %q", status) } } // TestSweepStalePendingPayments_KeyedLostResponse_Ambiguous_LeavesPending locks // the conservative keyed-reconcile rule: an ambiguous replay (transport error) // leaves the keyed row pending — the charge may still be in flight at Square. func TestSweepStalePendingPayments_KeyedLostResponse_Ambiguous_LeavesPending(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 '23 hours', idempotency_key = 'key-lost-response-ambiguous' WHERE id = $1", staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } origClient := SquareClient SquareClient = &staleReplayClient{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 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 != "pending" { t.Errorf("expected ambiguous keyed replay to leave the payment pending, got %q", status) } } // TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued locks the // keyed lost-response rescue for till_sales: a stale pending till sale with a // stored idempotency key but no square_payment_id whose charge completed at // Square is rescued to 'completed' with the square_payment_id written back. func TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued(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: "key-lost-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, idempotency_key, square_source_id, created_by, created_at, updated_at) VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, 'cnon:test-card', $2, NOW() - INTERVAL '23 hours', NOW()) RETURNING id `, "key-lost-till-completed", 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, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query till sale: %v", err) } if status != "completed" { t.Errorf("expected lost-response till sale with a completed Square charge rescued to 'completed', got %q", status) } if sqPayID != pay.SquarePayID { t.Errorf("expected square_payment_id %s written back on the till sale rescue, got %q", pay.SquarePayID, sqPayID) } } // TestSweepStalePendingPayments_KeyedGiftCardPurchase_Completed_LeavesPending // locks C6: a gift-card purchase payment row (payments table, NO booking) whose // charge COMPLETED at Square must NOT be rescued to 'completed' — completing it // would permanently block the same-key retry that delivers the card (customer // charged, no card). The row stays pending, a critical-payment admin // notification is inserted, and a same-key retry remains possible. func TestSweepStalePendingPayments_KeyedGiftCardPurchase_Completed_LeavesPending(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } const key = "key-gc-purchase-completed" var payID string err = tx.QueryRow(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_by, created_at, updated_at) VALUES ('full', 'online_square', 'pending', 50.00, $1, 'cnon:test-card', $2, NOW() - INTERVAL '23 hours', NOW()) RETURNING id `, key, userID).Scan(&payID) if err != nil { t.Fatalf("failed to seed gift-card purchase payment: %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: key, }) if err != nil { t.Fatalf("failed to seed completed Square payment: %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) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, payID) _, _ = 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, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", payID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "pending" { t.Errorf("expected the gift-card purchase row left 'pending' (a same-key retry must still deliver the card), got %q", status) } if sqPayID != "" { t.Errorf("expected no square_payment_id written on the gift-card purchase row, got %q", sqPayID) } // The charge landed at Square (the mock still holds the payment). if _, err := mock.GetPayment(freshCtx, pay.SquarePayID); err != nil { t.Errorf("expected the Square payment to still exist (customer was charged): %v", err) } var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).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 unresolved gift-card purchase, got %d", notifCount) } } // TestSweepStalePendingPayments_KeyedSourceMismatch_LeavesPending locks C1: a // replay that hits IDEMPOTENCY_KEY_REUSED (the stored square_source_id differs // from the original charge's source — a data bug) must NEVER fail the row. The // original charge may well have landed at Square, so the row is left pending // for manual reconciliation instead of being marked failed (which would claw // back funding / block a same-key retry). func TestSweepStalePendingPayments_KeyedSourceMismatch_LeavesPending(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) } const key = "key-source-mismatch" // The stored source differs from what the charge actually used at Square — // the data-bug condition the identical-body replay surfaces as // IDEMPOTENCY_KEY_REUSED. if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:wrong-source' WHERE id = $2", key, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) if _, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 200000, Currency: "GBP", SourceID: "cnon:original-source", IdempotencyKey: key, }); err != nil { t.Fatalf("failed to seed completed Square payment: %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) } 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 != "pending" { t.Errorf("expected IDEMPOTENCY_KEY_REUSED to leave the row pending (never proof of no charge), got %q", status) } } // TestSweepStalePendingPayments_KeyedReplayNewCharge_AutoRefunded locks the B1 // money-safety behaviour for the A1 cross-check: a replayed COMPLETED payment // created long AFTER the pending row is a NEW charge Square made with an // EXPIRED idempotency key against the still-valid ccof source (the ~24h key // retention is unverified) — money the customer never authorized. Instead of // only leaving the row pending for a human (the pre-B1 outcome), the sweep now // AUTO-REFUNDS the duplicate at Square with a fresh key, records the refunds // row, raises the critical notification and marks the row FAILED (the original // charge was never found). The cross-check runs only in a non-dev/mock env, so // this test flips SQUARE_ENVIRONMENT to production (sequential, like the 2FA // tests). func TestSweepStalePendingPayments_KeyedReplayNewCharge_AutoRefunded(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) } // 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but // still inside Square's ~24h retention window (so the replay still runs — // a row past 24h is blind-failed without a replay). NO stored snapshot → // the minimal fallback body is rebuilt, which skips the production // snapshot-decryption gate. created_by carries the payer so the admin // notification is attributable and assertable. const key = "key-expired-replay-new-charge" const dupPayID = "pay_expired_key_new_charge" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } var rowCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { t.Fatalf("failed to read aged payment created_at: %v", err) } // The replayed COMPLETED payment's created_at is 25h AFTER the pending row // — at/after the sweep's own replay moment (the discriminator is now the // sweep's replayAt, not a fixed window), so it is provably a NEW expired-key // replay charge, not a same-key retry. // The cross-check runs only in a non-dev/mock env, so the env is flipped to // production for the sweep (sequential, like the 2FA tests). The dev mock // is constructed BEFORE the flip (NewDevClient refuses production without // SQUARE_ALLOW_REAL_API); the mock itself never re-reads the env. origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") counting := &countingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: dupPayID, SquarePayID: dupPayID, Amount: 200000, CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339), }}} SquareClient = counting 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 refunds WHERE payment_id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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 var sqPayID *string if err := db.Conn.QueryRow(freshCtx, "SELECT status, square_payment_id FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "failed" { t.Errorf("expected the auto-refunded new-charge replay to mark the row failed, got %q", status) } if sqPayID != nil { t.Errorf("expected NO square_payment_id written on a new-charge replay, got %q", *sqPayID) } // Exactly one auto-refund must have been issued for the duplicate charge, // for the row's amount, with the sweep reason. calls := counting.refundCalls() if len(calls) != 1 { t.Fatalf("expected exactly one auto-refund of the duplicate charge, got %d", len(calls)) } if calls[0].PaymentID != dupPayID { t.Errorf("expected the refund to target the duplicate charge %s, got %s", dupPayID, calls[0].PaymentID) } if calls[0].Amount != 200000 { t.Errorf("expected the refund amount to be the row's 200000 pence, got %d", calls[0].Amount) } if calls[0].Reason != "duplicate charge — sweep replay" { t.Errorf("expected the sweep duplicate reason, got %q", calls[0].Reason) } // The refunds row records the auto-refund against the pending payment row. var refundCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, staleID).Scan(&refundCount); err != nil { t.Fatalf("failed to count refunds: %v", err) } if refundCount != 1 { t.Errorf("expected one completed refunds row for the auto-refund, got %d", refundCount) } var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil { t.Fatalf("failed to count admin notifications: %v", err) } if notifCount < 1 { t.Errorf("expected a critical-payment admin notification recording the auto-refund, got %d", notifCount) } } // pendingRefundClient forces RefundPayment to return a PENDING result so the // B1 auto-refund branch is exercised: Square accepted the refund but left it // non-terminal. type pendingRefundClient struct { square.SquareClient } func (c *pendingRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) { res, err := c.SquareClient.RefundPayment(ctx, req) if err != nil { return nil, err } res.Status = "PENDING" return res, nil } // settledRefundClient reports every refund Square holds as COMPLETED — as if a // PENDING refund settled — so the B1 re-poll pass resolves the parent row. type settledRefundClient struct { square.SquareClient } func (c *settledRefundClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) { refunds, err := c.SquareClient.ListPaymentRefunds(ctx, paymentID, beginTime) if err != nil { return nil, err } for i := range refunds { refunds[i].Status = "COMPLETED" } return refunds, nil } // TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowPending // locks the B1 PENDING fix: when Square accepts the auto-refund of a // replay-induced duplicate charge but leaves it PENDING (non-terminal), the // sweep must NOT mark the parent payment failed and must record the refunds // row with status 'pending' + square_refund_id. The re-poll pass // (SweepPendingSquareRefunds) then resolves the parent row ONLY once Square // reports the refund COMPLETED. Sequential (flips SQUARE_ENVIRONMENT), like the // sibling B1 tests. func TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowPending(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) } const key = "key-expired-replay-pending-refund" const dupPayID = "pay_expired_key_pending_refund" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } var rowCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { t.Fatalf("failed to read aged payment created_at: %v", err) } origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") SquareClient = &pendingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: dupPayID, SquarePayID: dupPayID, Amount: 200000, CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339), }}} 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 refunds WHERE payment_id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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) } // PENDING refund → NON-terminal: the parent row must NOT be marked failed. 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 != "pending" { t.Errorf("expected a PENDING auto-refund to leave the row pending (never failed), got %q", status) } // The in-flight refund must be recorded with status 'pending' + square_refund_id. var refundStatus string var sqRefundID *string var reason string if err := db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id, reason FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundStatus, &sqRefundID, &reason); err != nil { t.Fatalf("failed to query refunds row: %v", err) } if refundStatus != "pending" { t.Errorf("expected the refunds row to be pending, got %q", refundStatus) } if sqRefundID == nil || *sqRefundID == "" { t.Error("expected the refunds row to carry the Square refund id") } if reason != sweepDuplicateRefundReason { t.Errorf("expected reason %q, got %q", sweepDuplicateRefundReason, reason) } // Now the refund settles at Square: the re-poll pass resolves the parent. SquareClient = &settledRefundClient{SquareClient: mock} if _, err := SweepPendingSquareRefunds(freshCtx); err != nil { t.Fatalf("refund re-poll sweep failed: %v", err) } if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil { t.Fatalf("failed to re-query payment: %v", err) } if status != "failed" { t.Errorf("expected a COMPLETED refund to resolve the parent row to failed, got %q", status) } if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundStatus); err != nil { t.Fatalf("failed to re-query refunds row: %v", err) } if refundStatus != "completed" { t.Errorf("expected the refunds row to complete once Square settles, got %q", refundStatus) } } // TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_NoClawback // locks the B1 PENDING fix for till sales: a PENDING auto-refund of a // replay-induced duplicate charge must leave the till sale pending with its // funded gift card intact (NO clawback, NO fail), and the refunds row must be // recorded (attached to a synthetic payments row for the duplicate charge) // with the parent sale encoded in the reason. Once Square settles the refund // COMPLETED, the re-poll pass claws back the funding and marks the sale failed. // Sequential (flips SQUARE_ENVIRONMENT), like the sibling B1 tests. func TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_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) // Age the sale AND its created gift card inside the key window (23h, // created_at equality preserved → is_create stays true) and add the key. const dupPayID = "pay_expired_key_till_pending_refund" if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-till-expired-pending-refund', square_source_id = 'ccof:test-saved-card' WHERE id = $1", saleID); err != nil { t.Fatalf("failed to age the till sale: %v", err) } if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil { t.Fatalf("failed to age the gift card: %v", err) } var saleCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM till_sales WHERE id = $1", saleID).Scan(&saleCreatedAt); err != nil { t.Fatalf("failed to read aged till sale created_at: %v", err) } origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") SquareClient = &pendingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: dupPayID, SquarePayID: dupPayID, Amount: 5000, CreatedAt: saleCreatedAt.Add(25 * time.Hour).Format(time.RFC3339), }}} 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 a PENDING auto-refund to leave the till sale pending (never failed, no clawback), got %q", status) } 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 the funded gift card NOT clawed back while the refund is pending, got %d cards", cardCount) } // The refund row exists with the parent till sale encoded in the reason. var reason, refundStatus string if err := db.Conn.QueryRow(pool, `SELECT reason, status FROM refunds WHERE reason = $1`, sweepDuplicateRefundReasonFor(saleID)).Scan(&reason, &refundStatus); err != nil { t.Fatalf("failed to query B1 refund row: %v", err) } if refundStatus != "pending" { t.Errorf("expected the B1 refunds row to be pending, got %q", refundStatus) } // Refund settles COMPLETED → re-poll claws back the funding and fails the sale. SquareClient = &settledRefundClient{SquareClient: mock} if _, err := SweepPendingSquareRefunds(pool); err != nil { t.Fatalf("refund re-poll sweep failed: %v", err) } if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil { t.Fatalf("failed to re-query till sale: %v", err) } if status != "failed" { t.Errorf("expected a COMPLETED refund to resolve the till sale to failed, got %q", status) } 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 created gift card clawed back after the refund settled COMPLETED, got %d cards", cardCount) } } // rejectedRefundClient forces RefundPayment to return a REJECTED result so the // B1 auto-refund's definitive-rejection branch is exercised: Square rejects the // refund of the replay-induced duplicate charge (the duplicate stands). type rejectedRefundClient struct { square.SquareClient } func (c *rejectedRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) { res, err := c.SquareClient.RefundPayment(ctx, req) if err != nil { return nil, err } res.Status = "REJECTED" return res, nil } // failOnReplayClient fails the test if the sweep attempts to re-replay the // SPECIFIC expired idempotency key it was constructed with — proving the B1 // guard never re-replays a key whose auto-refund of a replay-induced duplicate // has failed or hit the attempt cap (each replay would mint ANOTHER charge at // Square). The sweep processes the whole shared test database, so replays of // OTHER tests' rows are delegated to the embedded client instead of failing. type failOnReplayClient struct { square.SquareClient t *testing.T key string } func (c *failOnReplayClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*square.PaymentResult, error) { if bytes.Contains(snapshotJSON, []byte(c.key)) { c.t.Fatalf("the sweep must NEVER re-replay the expired key %q whose B1 refund failed / hit the attempt cap", c.key) } return c.SquareClient.ReplayPaymentByKey(ctx, snapshotJSON) } // TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundRejected_FailsRowNoReplay // locks the CRITICAL-HIGH B1 fix for a refund Square REJECTS at call time: the // auto-refund of the replay-induced duplicate is definitively rejected (the // duplicate stands at Square). The parent row must be marked failed (the // original charge was never found), the CRITICAL notification raised, and // b1_attempts pinned to the cap so the expired key is NEVER replayed — a // rejected refund writes NO refunds row, so without the cap the next sweep // would re-replay the key and mint ANOTHER charge (the stacking-unauthorized- // charges loop the finding describes). No refunds row exists (the refund was // never accepted). Sequential (flips SQUARE_ENVIRONMENT), like the sibling B1 // tests. func TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundRejected_FailsRowNoReplay(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) } const key = "key-expired-replay-rejected-refund" const dupPayID = "pay_expired_key_rejected_refund" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } var rowCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { t.Fatalf("failed to read aged payment created_at: %v", err) } origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") counting := &countingRefundClient{SquareClient: &rejectedRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: dupPayID, SquarePayID: dupPayID, Amount: 200000, CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339), }}}} SquareClient = counting 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 refunds WHERE payment_id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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) } // The REJECTED auto-refund marks the parent failed (the original charge was // never found) — with the CRITICAL notification and the b1_attempts cap set. var status string var b1Attempts int if err := db.Conn.QueryRow(freshCtx, "SELECT status, b1_attempts FROM payments WHERE id = $1", staleID).Scan(&status, &b1Attempts); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "failed" { t.Errorf("expected a REJECTED auto-refund to mark the row failed, got %q", status) } if b1Attempts != b1DuplicateRefundAttemptCap { t.Errorf("expected b1_attempts pinned to the cap %d after a REJECTED refund, got %d", b1DuplicateRefundAttemptCap, b1Attempts) } // Exactly one auto-refund was ATTEMPTED (and rejected) — and, crucially, NO // refunds row was written (the refund was never accepted), which is exactly // the state that used to re-arm the replay loop. calls := counting.refundCalls() if len(calls) != 1 { t.Fatalf("expected exactly one auto-refund attempt of the duplicate charge, got %d", len(calls)) } var refundCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil { t.Fatalf("failed to count refunds: %v", err) } if refundCount != 0 { t.Errorf("expected NO refunds row for a REJECTED auto-refund, got %d", refundCount) } var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).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 rejected auto-refund, got %d", notifCount) } } // TestSweepStalePendingPayments_KeyedReplay_NoReplayAtAttemptCap locks the B1 // cap guard: a stale pending row whose b1_attempts already reached the cap must // be failed with a CRITICAL notification and its expired key NEVER replayed // (a replay would mint ANOTHER charge at Square). The failOnReplayClient // proves the replay never happens. func TestSweepStalePendingPayments_KeyedReplay_NoReplayAtAttemptCap(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) } const key = "key-expired-replay-at-cap" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2, b1_attempts = $3 WHERE id = $4", key, userID, b1DuplicateRefundAttemptCap, staleID); err != nil { t.Fatalf("failed to age the stale payment and set the attempt cap: %v", err) } origClient := SquareClient SquareClient = &failOnReplayClient{SquareClient: square.NewDevClient(), t: t, key: key} 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 admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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 a row at the b1_attempts cap to be failed without a replay, got %q", status) } var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).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 attempt-capped row, got %d", notifCount) } } // TestSweepStalePendingPayments_KeyedReplay_NoReplayWithFailedRefundRow locks // the FAILED-webhook demotion path (finding d): a B1 auto-refund that was // accepted PENDING and later demoted to 'failed' by the refund.updated webhook // clears the in-flight guard — the sweep must then see the FAILED refunds row // and fail the parent WITHOUT ever re-replaying the expired key. The // failOnReplayClient proves the replay never happens. func TestSweepStalePendingPayments_KeyedReplay_NoReplayWithFailedRefundRow(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) } const key = "key-expired-replay-failed-refund" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2, b1_attempts = 1 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // The webhook-demoted B1 refund row: accepted PENDING, then FAILED at Square. const dupPayID = "pay_demoted_failed_duplicate" if _, err := tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at) VALUES ($1, $2, 2000.00, 'ref_sweep_demoted_failed', 'failed', 'manual', $3, 'sweepdup-' || $4, $5, NOW()) `, staleID, bookingID, sweepDuplicateRefundReason, dupPayID, userID); err != nil { t.Fatalf("failed to insert the demoted failed refund row: %v", err) } origClient := SquareClient SquareClient = &failOnReplayClient{SquareClient: square.NewDevClient(), t: t, key: key} 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 refunds WHERE payment_id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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 a row with a FAILED B1 refund to be failed without a replay, got %q", status) } var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).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 failed-B1-refund row, got %d", notifCount) } } // TestSweepStalePendingPayments_KeyedReplayUnparseableCreatedAt_LeavesPending // locks the B1 caveat: a replayed COMPLETED payment whose created_at CANNOT be // parsed must NOT be auto-refunded. An unparseable created_at does not prove // the payment is a duplicate — it could be the ORIGINAL charge a retained key // returned, whose created_at was lost/corrupt — so the sweep restores the // pre-B1 outcome: leave the row PENDING with a CRITICAL notification and issue // NO refund (never auto-reverse a possibly-legitimate authorized charge). // Sequential (flips SQUARE_ENVIRONMENT), like the sibling B1/A1 tests. func TestSweepStalePendingPayments_KeyedReplayUnparseableCreatedAt_LeavesPending(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) } const key = "key-expired-replay-unparseable-createdat" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") counting := &countingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: "pay_expired_key_unparseable_createdat", SquarePayID: "pay_expired_key_unparseable_createdat", CreatedAt: "not-a-real-timestamp", }}} SquareClient = counting 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 refunds WHERE payment_id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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 != "pending" { t.Errorf("expected an unparseable-created_at replay to leave the row pending (pre-B1), got %q", status) } // NO auto-refund may have been issued — the payment is not provably a duplicate. if calls := counting.refundCalls(); len(calls) != 0 { t.Errorf("expected NO auto-refund for an unparseable-created_at replay, got %d refund call(s)", len(calls)) } var refundCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil { t.Fatalf("failed to count refunds: %v", err) } if refundCount != 0 { t.Errorf("expected no refunds row for an unparseable-created_at replay, got %d", refundCount) } // The leave-pending-CRITICAL path still raises the admin notification. var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).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 unparseable-created_at replay, got %d", notifCount) } } // TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues locks the // A1 cross-check control: a replayed COMPLETED payment created at the SAME // instant as the pending row (the retained-key dedup returning the original // charge) is rescued to 'completed' — the cross-check must never block a // legitimate lost-response rescue. Production env so the cross-check runs. func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(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) } const key = "key-retained-original" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // The replayed payment is the ORIGINAL — created at the SAME instant as the // pending row, as a retained-key dedup returns. Seeding CreatedAt from the // row's own timestamp makes the F2 lag ~0 deterministically (a fixed // clock.Now()-relative offset would race the DB NOW() microsecond // truncation). The env is flipped to production for the sweep so the A1 // cross-check runs; the dev mock is constructed BEFORE the flip // (NewDevClient refuses production without SQUARE_ALLOW_REAL_API). var rowCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { t.Fatalf("failed to read aged payment created_at: %v", err) } origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: "pay_original_under_key", SquarePayID: "pay_original_under_key", CreatedAt: rowCreatedAt.Format(time.RFC3339Nano), }} 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 admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "completed" { t.Errorf("expected the original-payment replay rescued to 'completed', got %q", status) } if sqPayID != "pay_original_under_key" { t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_under_key", sqPayID) } } // TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending locks // the B1 lower-bound clock-skew tolerance: a replayed COMPLETED payment created // BEFORE the pending row must NOT be auto-refunded as a "new charge". Rows are // inserted pending-first (row.CreatedAt precedes Square's created_at by // ~0.5-1s), and a DB clock running AHEAD of Square's (independent NTP drift, VM // pause/resume) makes a retained-key dedup return the ORIGINAL charge with // created < row.CreatedAt. A payment within replayRescueLowerBoundSkew of the // row is that clock-skewed ORIGINAL and is rescued (locked by // TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues); this // fixture sits BEYOND the 5-minute tolerance, where no clock skew can explain // it — the row is left PENDING with a CRITICAL notification instead. Sequential // (flips SQUARE_ENVIRONMENT), like the sibling B1/A1 tests. func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(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) } const key = "key-retained-clock-skew" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // The replayed COMPLETED payment is created 10 minutes BEFORE the row — // beyond the 5-minute clock-skew tolerance, so it cannot be a clock-skewed // retained-key original and must remain ambiguous. Seeding from the row's // own timestamp keeps the lag deterministic. var rowCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { t.Fatalf("failed to read aged payment created_at: %v", err) } skewedCreated := rowCreatedAt.Add(-10 * time.Minute) origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: "pay_original_skewed", SquarePayID: "pay_original_skewed", CreatedAt: skewedCreated.Format(time.RFC3339Nano), }} 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 refunds WHERE payment_id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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) } // Ambiguous (created before the row): the row must stay PENDING — never // auto-refunded, never rescued. 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 != "pending" { t.Errorf("expected a before-row replay to leave the payment pending, got %q", status) } // No auto-refund may have been issued for the ambiguous payment. var refundCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil { t.Fatalf("failed to count refunds: %v", err) } if refundCount != 0 { t.Errorf("expected NO auto-refund of an ambiguous before-row payment, got %d refunds rows", refundCount) } // A critical-payment admin notification must surface the manual reconciliation. var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).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 ambiguous replay, got %d", notifCount) } } // TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues locks the // Loop B MEDIUM lower-bound fix: a replayed COMPLETED payment created slightly // BEFORE the pending row — within the 5-minute replayRescueLowerBoundSkew — is // the clock-skewed ORIGINAL charge under a retained key (a DB clock ahead of // Square's makes a retained-key dedup return the original with created < // row.CreatedAt), NOT a provably-new duplicate. It must be RESCUED to // 'completed'; stranding it pending (the pre-fix behavior for any before-row // payment) would leave the customer's legitimately-authorized charge to resolve // only via the 24h blind-fail as a WARN'd failed payment. Sequential (flips // SQUARE_ENVIRONMENT), like the sibling B1/A1 tests. func TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues(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) } const key = "key-retained-within-skew" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // The replayed COMPLETED payment is the ORIGINAL charge under a retained // key whose created_at lags the DB row's by 2 minutes — inside the 5-minute // clock-skew tolerance. Seeding from the row's own timestamp keeps the lag // deterministic. var rowCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { t.Fatalf("failed to read aged payment created_at: %v", err) } skewedCreated := rowCreatedAt.Add(-2 * time.Minute) origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: "pay_original_within_skew", SquarePayID: "pay_original_within_skew", CreatedAt: skewedCreated.Format(time.RFC3339Nano), }} 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 refunds WHERE payment_id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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) } // Within-tolerance before-row: the clock-skewed ORIGINAL is rescued, never // auto-refunded and never left pending. var status, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "completed" { t.Errorf("expected a within-tolerance before-row replay rescued to 'completed', got %q", status) } if sqPayID != "pay_original_within_skew" { t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_within_skew", sqPayID) } var refundCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil { t.Fatalf("failed to count refunds: %v", err) } if refundCount != 0 { t.Errorf("expected NO auto-refund of a within-tolerance before-row original, got %d refund rows", refundCount) } } // TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues locks the B2 // dead-zone fix: a same-key retry whose charge landed 21.5h after the pending // row is the REAL charge under a legitimately replayed key and MUST be rescued. // The discriminator is the sweep's OWN replay instant (replayAt): a payment // created before the sweep replayed the key is the original or a legit retry, // while a payment created at/after the replay is the sweep's own expired-key // creation. A fixed row-age window (21h, then 22h, then 24h across reviews) // was wrong in every direction — comparing against replayAt is correct // regardless of Square's unverified ~24h key-retention window. func TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues(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) } const key = "key-retained-retry-215h" if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // The replayed COMPLETED payment is a legitimate same-key retry created // 21.5h after the row — inside the 22h legitimate window, so it is the real // charge and must be rescued, not refused as an expired-key duplicate. var rowCreatedAt time.Time if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil { t.Fatalf("failed to read aged payment created_at: %v", err) } retryCreated := rowCreatedAt.Add(21*time.Hour + 30*time.Minute) origClient := SquareClient mock := square.NewDevClient() t.Setenv("SQUARE_ENVIRONMENT", "production") SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ Status: "COMPLETED", ID: "pay_retry_at_215h", SquarePayID: "pay_retry_at_215h", CreatedAt: retryCreated.Format(time.RFC3339Nano), }} 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 admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "completed" { t.Errorf("expected the 21.5h same-key retry rescued to 'completed', got %q", status) } if sqPayID != "pay_retry_at_215h" { t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_retry_at_215h", sqPayID) } } // TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback locks // the A1 ccof-blind-fail rule: a replay rejection (ErrReplayKeyNotRetained) // against a SAVED-CARD (ccof:) source is NOT proof the original charge never // happened — the same key-retention race that lets the replay land a NEW charge // can leave that charge even when the probe is rejected. A till sale with a // funded gift card must be left PENDING (never failed) and the gift card must // NOT be clawed back. A cnon-source (spent-nonce) rejection remains definitive // and keeps the proven-failed clawback (locked by // TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks). func TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback(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) // 23h old: past the 22h keyed cutoff, still inside the retention window so // the keyed replay runs (not the past-retention blind-fail). ccof source. if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-ccof-rejected', square_source_id = 'ccof:test-saved-card' WHERE id = $1", saleID); err != nil { t.Fatalf("failed to age the till sale: %v", err) } if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil { t.Fatalf("failed to age the gift card: %v", err) } origClient := SquareClient SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), err: square.ErrReplayKeyNotRetained} 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 the ccof-source replay rejection to leave the till sale pending (never blindly failed), got %q", status) } // The funded card must be untouched — the blind rejection could mean a // replay-created charge landed, so the funding must stay put. 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 the ccof rejection to leave the funded gift card in place (no clawback), got %d cards", cardCount) } } // TestSweepStalePendingPayments_KeyedBlindFail_Notifies locks A5a: a keyed // pending row already past Square's key retention window is blind-failed (the // charge outcome is unknown — the lost response may have landed at Square), so // a critical-payment admin notification must be raised alongside the failed // mark. The notification is deduped per payer. func TestSweepStalePendingPayments_KeyedBlindFail_Notifies(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) } // 25h old: past both the 22h keyed cutoff and Square's 24h retention window, // so the keyed pass blind-fails it without a replay. if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-blindfail-notify', square_source_id = 'cnon:test-card', created_by = $1 WHERE id = $2", userID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } 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 admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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 the past-retention keyed row blind-failed, got %q", status) } var notifCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).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 keyed blind-fail, got %d", notifCount) } } // TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks // locks the keyed clawback: a stale pending till sale with a stored idempotency // key whose charge Square PROVES never happened (no payment under the key) is // marked failed AND its funded gift card is clawed back — unlike the blind-fail // path, the reconcile proved the funding has no charge behind it. func TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_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() saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true) // Move both the sale and its created gift card inside the key window (23h, // created_at equality preserved → is_create stays true) and add the key. if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-lost-till-never' WHERE id = $1", saleID); err != nil { t.Fatalf("failed to age the till sale: %v", err) } if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil { t.Fatalf("failed to age the gift card: %v", err) } // A fresh mock has no payment under the key → ReplayPaymentByKey returns // ErrReplayKeyNotRetained → definitively failed → clawback. 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 keyed till sale with no charge at Square marked failed, got %q", status) } // The reconcile PROVED the charge never happened, so 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 after keyed proof, got %d cards", cardCount) } } // 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_CompletedTillSale_Recorded locks the finding-3 // fix: a stale card-machine till sale whose checkout has COMPLETED at Square // (never polled by the frontend) is RECORDED by the sweep — the sale is marked // 'completed' with the returned square_payment_id — instead of being left // pending for a poll handler that never runs (the 24h blind-fail would then // hide the real charge from till reporting). A COMPLETED checkout is never // cancelled. func TestSweepStaleTerminalCheckouts_CompletedTillSale_Recorded(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() // 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 the COMPLETED till-sale checkout recorded by the sweep, got %d resolutions", n) } var status, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query sale: %v", err) } if status != "completed" { t.Errorf("expected a COMPLETED till-sale checkout's sale marked 'completed', got %q", status) } if sqPayID != "sqp_terminal_completed" { t.Errorf("expected square_payment_id written back on the recorded till sale, got %q", sqPayID) } } // TestSweepStaleTerminalCheckouts_CompletedTillSale_NoPaymentID_LeavesPending // locks the finding-3 safety rule: a COMPLETED till-sale checkout whose payment // result carries NO Square payment id cannot be recorded, so the sale is left // PENDING with a CRITICAL log — never blind-failed (the charge may be real and // the blind-fail would hide it from till reporting). func TestSweepStaleTerminalCheckouts_CompletedTillSale_NoPaymentID_LeavesPending(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_no_payment_id', $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 = &noPaymentIDCompletedClient{SquareClient: square.NewDevClient(), checkoutID: "chk_completed_no_payment_id"} 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() 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 != 0 { t.Errorf("expected a COMPLETED till-sale checkout with no Square payment id left unresolved, got %d resolutions", n) } var status, sqPayID string if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query sale: %v", err) } if status != "pending" { t.Errorf("expected the unrecordable COMPLETED till-sale checkout left 'pending' (never blind-failed), got %q", status) } if sqPayID != "" { t.Errorf("expected no square_payment_id written on the unrecordable till sale, got %q", sqPayID) } } // ============================================================================= // 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", Amount: 5000}, 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() { // The sweep records the untracked COMPLETED charge as a payments row // (H4) — clean it up before the booking so the FK delete order holds. _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE square_payment_id = 'sqp_completed_during_cancel'`) _, _ = 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) } } // ============================================================================= // SweepStaleTerminalCheckouts — intermediate states via the dev mock's // ForceCheckoutState (mirrors the real Square API, not a fake client) // ============================================================================= // TestSweepStaleTerminalCheckouts_MockCanceled_Clawbacks exercises the // isCheckoutDefinitivelyDead CANCELED direction through the DEV MOCK's own // forced state: a CANCELED checkout is provably dead, so the stale till-sale // is failed AND its funded gift card is clawed back. Before ForceCheckoutState // the mock could only auto-complete or stay PENDING, so this sweep branch was // only reachable via a fake client. func TestSweepStaleTerminalCheckouts_MockCanceled_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() // Force the checkout into the terminal CANCELED state — the mock's // GetCheckout then emits the same plain "is CANCELED (not COMPLETED)" // error the real client surfaces, which the sweep classifies as dead. mock := square.NewDevClient().(*square.MockClient) mock.ForceCheckoutState = "CANCELED" checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-mock-canceled", }) if err != nil { t.Fatalf("failed to create forced-CANCELED checkout: %v", err) } if checkout.Status != "CANCELED" { t.Fatalf("expected forced CANCELED checkout, got %q", checkout.Status) } saleID, giftCardID := seedStaleTerminalTillSale(t, ctx, tx, adminID, true) if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_checkout_id = $1 WHERE id = $2`, checkout.ID, 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 = 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 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_MockNotFound_Clawbacks exercises the // isCheckoutDefinitivelyDead NOT_FOUND direction through the dev mock: a // square_checkout_id that references a checkout Square has never seen (expired // checkout, e.g.) resolves to the mock's plain "checkout not found" error, // which the sweep classifies as definitively dead and claws back. func TestSweepStaleTerminalCheckouts_MockNotFound_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_never_created_mock" 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) } // A fresh mock holds no checkout under checkoutID → GetCheckout returns // "checkout not found", which isTerminalCheckoutError / isCheckoutDefinitivelyDead // classify as terminal + definitively dead. 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 := 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 not-found till sale marked failed, got %q", status) } 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_MockCancelRequested_CancelThenClawback // exercises the cancel-then-recheck path for a CANCEL_REQUESTED checkout via // the dev mock: GetCheckout folds CANCEL_REQUESTED into ErrCheckoutPending // (mirroring the real client, which treats it as still-live), the sweep calls // CancelCheckout (a no-op — Square returns 404 for an already-canceling // checkout), and the re-check — still ErrCheckoutPending — resolves the sale // to failed with the funding clawed back. The separate // CANCEL_REQUESTED-only "not provably dead, no clawback" classification of // isCheckoutDefinitivelyDead is locked by // TestSweepStaleTerminalCheckouts_TillCancelRequested_NoClawback, which // injects a non-pending CANCEL_REQUESTED error the real client never emits. func TestSweepStaleTerminalCheckouts_MockCancelRequested_CancelThenClawback(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() mock := square.NewDevClient().(*square.MockClient) mock.ForceCheckoutState = "CANCEL_REQUESTED" checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-mock-cancel-requested", }) if err != nil { t.Fatalf("failed to create forced-CANCEL_REQUESTED checkout: %v", err) } if checkout.Status != "CANCEL_REQUESTED" { t.Fatalf("expected forced CANCEL_REQUESTED checkout, got %q", checkout.Status) } saleID, giftCardID := seedStaleTerminalTillSale(t, ctx, tx, adminID, true) if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_checkout_id = $1 WHERE id = $2`, checkout.ID, 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 = 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 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) } 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 CANCEL_REQUESTED (cancel-recheck) till sale marked failed, got %q", status) } // The sweep believed the cancel landed, so the created gift card is clawed // back (deleted) — the same path the real Square API produces. 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) } } // ============================================================================= // SweepStaleTerminalCheckouts — provisional "tmp-" rows are resolved against // Square first (H4), never blind-failed // ============================================================================= // provisionalCheckoutClient forces GetCheckout for one target checkout id to a // fixed result/error so the sweep's provisional "tmp-" row resolution // (mirroring handlers.go activeTerminalCheckoutID's H4 classification) can be // exercised deterministically, while delegating everything else to the mock. type provisionalCheckoutClient struct { square.SquareClient checkoutID string result *square.PaymentResult err error } func (c *provisionalCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { if checkoutID == c.checkoutID { if c.err != nil { return nil, c.err } return c.result, nil } return c.SquareClient.GetCheckout(ctx, checkoutID) } // seedStaleProvisionalTerminalCheckout seeds a stale (2h old) PENDING // terminal_checkouts row carrying a synthetic "tmp-" checkout_id for the // caller's booking inside the setup transaction. The caller commits the setup // tx; pool-level cleanup of the row is registered. func seedStaleProvisionalTerminalCheckout(t *testing.T, ctx context.Context, q db.Querier, bookingID, tmpID string) { t.Helper() pool := context.Background() if _, err := q.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') `, tmpID, bookingID); err != nil { t.Fatalf("failed to seed stale provisional terminal checkout: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID) }) } // TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment locks // the H4 fix: a provisional "tmp-" terminal_checkouts row whose checkout // actually COMPLETED at Square (a hard crash between the row insert and the // provisional→real UPDATE leaves the real checkout live while the row still // carries the synthetic id) is resolved against Square and the untracked charge // RECORDED — never blind-failed. Blind-failing would release the in-flight // guard while the customer was still charged, leaving an invisible untracked // payment (a second checkout could then be created for the same booking). func TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment(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) } // The booking must be in a payable state for the untracked charge to be // recorded. if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking in_progress: %v", err) } const tmpID = "tmp-crash-completed-key" seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID) origClient := SquareClient SquareClient = &provisionalCheckoutClient{ SquareClient: square.NewDevClient(), checkoutID: tmpID, result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_tmp_completed", Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242"}, } 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) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE square_payment_id = 'sqp_tmp_completed'`) _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) // Drop any other stale terminal rows left by sequential tests so the count // is deterministic. if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(pool, `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(pool) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 1 { t.Errorf("expected the COMPLETED provisional checkout resolved by the sweep, got %d resolutions", n) } var status string if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil { t.Fatalf("failed to query terminal checkout: %v", err) } if status != "COMPLETED" { t.Errorf("expected a COMPLETED provisional checkout marked 'COMPLETED', got %q", status) } // The untracked charge must have been recorded as a payment row (H4). var payCount int if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_tmp_completed'`, bookingID).Scan(&payCount); err != nil { t.Fatalf("failed to count recorded payments: %v", err) } if payCount != 1 { t.Errorf("expected the COMPLETED provisional charge recorded as a payment, got %d rows", payCount) } } // TestSweepStaleTerminalCheckouts_TmpProvisional_NotFound_Fails locks the H4 // NOT_FOUND direction: a provisional "tmp-" row that Square has never seen (the // crash happened before the Square call, so no checkout was ever created — or // the tmp id cannot be resolved) resolves to the expected NOT_FOUND and is // safely marked failed. func TestSweepStaleTerminalCheckouts_TmpProvisional_NotFound_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) } const tmpID = "tmp-never-reached-square-key" seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID) // A fresh mock holds no checkout under the tmp id → GetCheckout returns the // mock's plain "checkout not found" error, which isTerminalCheckoutError // classifies as terminal — the expected outcome for an unresolvable tmp id. 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) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(pool, `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(pool) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 1 { t.Errorf("expected the not-found provisional checkout resolved to failed, got %d resolutions", n) } var status string if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil { t.Fatalf("failed to query terminal checkout: %v", err) } if status != "failed" { t.Errorf("expected a not-found provisional checkout marked 'failed', got %q", status) } } // TestSweepStaleTerminalCheckouts_TmpProvisional_Ambiguous_LeavesPending locks // the H4 ambiguous direction: a provisional "tmp-" row whose Square status is // unknown (transport error) must NOT be blind-failed — the checkout may still // be live or may have completed, so the row stays pending for a later run. func TestSweepStaleTerminalCheckouts_TmpProvisional_Ambiguous_LeavesPending(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) } const tmpID = "tmp-ambiguous-key" seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID) origClient := SquareClient SquareClient = &provisionalCheckoutClient{ SquareClient: square.NewDevClient(), checkoutID: tmpID, 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 test tx: %v", err) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(pool, `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(pool) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 0 { t.Errorf("expected an ambiguous provisional checkout left unresolved, got %d resolutions", n) } var status string if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil { t.Fatalf("failed to query terminal checkout: %v", err) } if status != "PENDING" { t.Errorf("expected an ambiguous provisional checkout left 'PENDING', got %q", status) } } // TestSweepStaleTerminalCheckouts_TmpProvisional_StillLive_LeavesPending locks // the H4 ErrCheckoutPending direction: a provisional "tmp-" row whose checkout // is still live at Square (PENDING / IN_PROGRESS) must keep the in-flight guard // — the sweep must never resolve it failed (releasing the guard) while the // customer can still complete the payment at the terminal into an untracked // charge. func TestSweepStaleTerminalCheckouts_TmpProvisional_StillLive_LeavesPending(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) } const tmpID = "tmp-still-live-key" seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID) origClient := SquareClient SquareClient = &provisionalCheckoutClient{ SquareClient: square.NewDevClient(), checkoutID: tmpID, err: square.ErrCheckoutPending, } 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) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(pool, `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(pool) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 0 { t.Errorf("expected a still-live provisional checkout left unresolved, got %d resolutions", n) } var status string if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil { t.Fatalf("failed to query terminal checkout: %v", err) } if status != "PENDING" { t.Errorf("expected a still-live provisional checkout left 'PENDING' (guard held), got %q", status) } } // TestSweepStaleTerminalCheckouts_CancelledBooking_NoPaymentRecorded locks the // sweep's booking re-check (mirroring GetCheckoutStatus): a terminal checkout // that COMPLETED at Square while the booking is we_cancelled must NOT be // recorded as a completed payment — the cancellation refund path computes // refunds from completed payments and would silently exclude this charge. // Instead the checkout is marked failed, no payment row is created, and a // critical-payment admin notification tells the owner a charge landed on a // cancelled booking and a manual refund is required. func TestSweepStaleTerminalCheckouts_CancelledBooking_NoPaymentRecorded(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) } // The charge completes at Square AFTER the booking was cancelled. if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking we_cancelled: %v", err) } const checkoutID = "chk_completed_on_cancelled_booking" 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: %v", err) } origClient := SquareClient SquareClient = &provisionalCheckoutClient{ SquareClient: square.NewDevClient(), checkoutID: checkoutID, result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_cancelled_booking", Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242"}, } 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) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_cancelled_booking'`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID) _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) if _, err := db.Conn.Exec(pool, `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(pool, `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(pool) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 1 { t.Errorf("expected the COMPLETED checkout on a cancelled booking resolved (to failed) by the sweep, got %d resolutions", n) } // The checkout must be marked failed (guard released, sweep does not retry // forever), never COMPLETED and never left with a recorded payment. var status string if err := db.Conn.QueryRow(pool, "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 != "failed" { t.Errorf("expected the cancelled-booking checkout marked 'failed', got %q", status) } // No payment row may exist for the charge. var payCount int if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_cancelled_booking'`, bookingID).Scan(&payCount); err != nil { t.Fatalf("failed to count recorded payments: %v", err) } if payCount != 0 { t.Errorf("expected NO payment recorded on the cancelled booking, got %d rows", payCount) } // The owner must be told a charge landed on a cancelled booking. var notifCount int if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1 AND user_id IS NULL`, bookingID).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 cancelled booking, got %d", notifCount) } }