Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
1944 lines
77 KiB
Go
1944 lines
77 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
)
|
|
|
|
// TestSweepStalePendingPayments_ReconcileCompleted locks the F3 fix: a stale
|
|
// pending payment whose square_payment_id resolves to a COMPLETED charge at
|
|
// Square (the DB row was genuinely charged, the post-charge DB write failed)
|
|
// is rescued to 'completed' instead of being swept to 'failed' with no
|
|
// automatic resolution.
|
|
func TestSweepStalePendingPayments_ReconcileCompleted(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
// Seed the completed charge at Square with the same idempotency semantics
|
|
// the charge would have used in production.
|
|
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 200000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: "seed-stale-completed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, staleID); err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// The committed rows live in the SHARED test pool, so clean them up or
|
|
// parallel tests that count whole tables see them (test isolation).
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected genuinely-charged stale pending payment rescued to 'completed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_ReconcileNotFound_Fails locks the F3 fallback:
|
|
// a stale pending payment whose square_payment_id does NOT resolve to a
|
|
// COMPLETED charge at Square (payment not found / not completed) is marked
|
|
// failed exactly as the legacy bulk sweep did — the double-charge window must
|
|
// stay closed.
|
|
func TestSweepStalePendingPayments_ReconcileNotFound_Fails(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_not_in_mock' WHERE id = $1", staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
// The default mock has no payment under 'sqp_not_in_mock' → GetPayment
|
|
// returns not-found → the row must be failed, not left pending.
|
|
origClient := SquareClient
|
|
SquareClient = square.NewDevClient()
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected stale pending payment with no COMPLETED charge at Square marked 'failed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_ReconcileTillSale_Completed locks the F3 fix
|
|
// for till_sales: a stale pending till sale whose square_payment_id resolves
|
|
// to a COMPLETED charge at Square is rescued to 'completed' like payments.
|
|
func TestSweepStalePendingPayments_ReconcileTillSale_Completed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 5000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: "seed-stale-till-completed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
|
}
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
var saleID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_payment_id, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, $2, NOW() - INTERVAL '25 hours', NOW())
|
|
RETURNING id
|
|
`, pay.SquarePayID, adminID).Scan(&saleID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed stale pending till sale: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected genuinely-charged stale till sale rescued to 'completed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// completedTerminalClient makes one checkout look COMPLETED at Square while
|
|
// delegating everything else to the real mock — used to prove the terminal
|
|
// sweep never cancels a checkout that may have completed.
|
|
type completedTerminalClient struct {
|
|
square.SquareClient
|
|
checkoutID string
|
|
}
|
|
|
|
func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
|
if checkoutID == c.checkoutID {
|
|
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_terminal_completed"}, nil
|
|
}
|
|
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
// 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_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)
|
|
}
|
|
}
|