//go:build test && dev package payments import ( "context" "errors" "fmt" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" ) // TestSweepStalePendingPayments_ReconcileCompleted locks the F3 fix: a stale // pending payment whose square_payment_id resolves to a COMPLETED charge at // Square (the DB row was genuinely charged, the post-charge DB write failed) // is rescued to 'completed' instead of being swept to 'failed' with no // automatic resolution. func TestSweepStalePendingPayments_ReconcileCompleted(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") if err != nil { t.Fatalf("failed to create stale pending payment: %v", err) } if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) // Seed the completed charge at Square with the same idempotency semantics // the charge would have used in production. pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 200000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "seed-stale-completed", }) if err != nil { t.Fatalf("failed to seed completed Square payment: %v", err) } if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, staleID); err != nil { t.Fatalf("failed to set square_payment_id: %v", err) } SquareClient = mock defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } // The committed rows live in the SHARED test pool, so clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) freshCtx := context.Background() if _, err := SweepStalePendingPayments(freshCtx); err != nil { t.Fatalf("sweep failed: %v", err) } var status string if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "completed" { t.Errorf("expected genuinely-charged stale pending payment rescued to 'completed', got %q", status) } } // TestSweepStalePendingPayments_ReconcileNotFound_Fails locks the F3 fallback: // a stale pending payment whose square_payment_id does NOT resolve to a // COMPLETED charge at Square (payment not found / not completed) is marked // failed exactly as the legacy bulk sweep did — the double-charge window must // stay closed. func TestSweepStalePendingPayments_ReconcileNotFound_Fails(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") if err != nil { t.Fatalf("failed to create stale pending payment: %v", err) } if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_not_in_mock' WHERE id = $1", staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // The default mock has no payment under 'sqp_not_in_mock' → GetPayment // returns not-found → the row must be failed, not left pending. origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) freshCtx := context.Background() if _, err := SweepStalePendingPayments(freshCtx); err != nil { t.Fatalf("sweep failed: %v", err) } var status string if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != "failed" { t.Errorf("expected stale pending payment with no COMPLETED charge at Square marked 'failed', got %q", status) } } // TestSweepStalePendingPayments_ReconcileTillSale_Completed locks the F3 fix // for till_sales: a stale pending till sale whose square_payment_id resolves // to a COMPLETED charge at Square is rescued to 'completed' like payments. func TestSweepStalePendingPayments_ReconcileTillSale_Completed(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "seed-stale-till-completed", }) if err != nil { t.Fatalf("failed to seed completed Square payment: %v", err) } SquareClient = mock defer func() { SquareClient = origClient }() var saleID string err = tx.QueryRow(ctx, ` INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_payment_id, created_by, created_at, updated_at) VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, $2, NOW() - INTERVAL '25 hours', NOW()) RETURNING id `, pay.SquarePayID, adminID).Scan(&saleID) if err != nil { t.Fatalf("failed to seed stale pending till sale: %v", err) } pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID) }) freshCtx := context.Background() if _, err := SweepStalePendingPayments(freshCtx); err != nil { t.Fatalf("sweep failed: %v", err) } var status string if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil { t.Fatalf("failed to query till sale: %v", err) } if status != "completed" { t.Errorf("expected genuinely-charged stale till sale rescued to 'completed', got %q", status) } } // completedTerminalClient makes one checkout look COMPLETED at Square while // delegating everything else to the real mock — used to prove the terminal // sweep never cancels a checkout that may have completed. type completedTerminalClient struct { square.SquareClient checkoutID string } func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { if checkoutID == c.checkoutID { return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_terminal_completed"}, nil } return c.SquareClient.GetCheckout(ctx, checkoutID) } // TestSweepStaleTerminalCheckouts_CancelsStalePending locks the F4 fix: a // terminal checkout still PENDING at Square after an hour is cancelled and its // till_sales row moved to the terminal 'failed' state (the payment_status enum // has no 'cancelled' value). func TestSweepStaleTerminalCheckouts_CancelsStalePending(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) mock.HoldCheckouts = true checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-stale-terminal", }) if err != nil { t.Fatalf("failed to create pending Square checkout: %v", err) } SquareClient = mock defer func() { SquareClient = origClient }() var saleID string err = tx.QueryRow(ctx, ` INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at) VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', $1, $2, NOW() - INTERVAL '2 hours', NOW()) RETURNING id `, checkout.ID, adminID).Scan(&saleID) if err != nil { t.Fatalf("failed to seed stale terminal sale: %v", err) } pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID) }) freshCtx := context.Background() // Drop any other stale terminal rows left by parallel tests so the count is // deterministic. if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil { t.Fatalf("failed to clean leftover stale terminal sales: %v", err) } if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil { t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err) } n, err := SweepStaleTerminalCheckouts(freshCtx) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 1 { t.Errorf("expected exactly 1 cancelled stale terminal checkout, got %d", n) } var status string if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil { t.Fatalf("failed to query sale: %v", err) } if status != "failed" { t.Errorf("expected cancelled stale terminal sale marked 'failed', got %q", status) } // The checkout must no longer be PENDING at Square (it was cancelled). if _, gErr := mock.GetCheckout(freshCtx, checkout.ID); gErr == nil || errors.Is(gErr, square.ErrCheckoutPending) { t.Errorf("expected checkout %s to be cancelled at Square (no longer pending), GetCheckout err=%v", checkout.ID, gErr) } } // TestSweepStaleTerminalCheckouts_LeavesCompletedAlone locks the conservative // F4 rule: a checkout that has COMPLETED at Square is never cancelled — the // poll handler records it; cancelling a completed checkout would orphan the // charge. func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } var saleID string err = tx.QueryRow(ctx, ` INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at) VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', 'chk_completed_terminal', $1, NOW() - INTERVAL '2 hours', NOW()) RETURNING id `, adminID).Scan(&saleID) if err != nil { t.Fatalf("failed to seed stale terminal sale: %v", err) } origClient := SquareClient SquareClient = &completedTerminalClient{SquareClient: square.NewDevClient(), checkoutID: "chk_completed_terminal"} defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID) }) freshCtx := context.Background() n, err := SweepStaleTerminalCheckouts(freshCtx) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 0 { t.Errorf("expected a completed terminal checkout to be left alone, got %d cancellations", n) } var status string if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil { t.Fatalf("failed to query sale: %v", err) } if status != "pending" { t.Errorf("expected completed terminal checkout's sale left 'pending' (poll handler records it), got %q", status) } } // ============================================================================= // SweepStalePendingPayments — tri-state reconcile (LOW money-integrity) // ============================================================================= // staleGetPaymentClient forces GetPayment to return a fixed result/error so the // reconcile tri-state branches can be exercised deterministically. type staleGetPaymentClient struct { square.SquareClient result *square.PaymentResult err error } func (c *staleGetPaymentClient) GetPayment(ctx context.Context, paymentID string) (*square.PaymentResult, error) { if c.err != nil { return nil, c.err } if c.result != nil { return c.result, nil } return c.SquareClient.GetPayment(ctx, paymentID) } func TestSweepStalePendingPayments_ReconcileTriState(t *testing.T) { cases := []struct { name string result *square.PaymentResult getErr error wantFinal string // "completed", "failed", or "pending" }{ { name: "completed_rescues_row", result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_tri_completed"}, wantFinal: "completed", }, { name: "not_found_marks_failed", getErr: fmt.Errorf("square: GET /v2/payments/sqp_x: [PAYMENT_NOT_FOUND/NOT_FOUND] payment does not exist"), wantFinal: "failed", }, { name: "ambiguous_error_leaves_pending", getErr: fmt.Errorf("network error: connection reset by peer"), wantFinal: "pending", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") if err != nil { t.Fatalf("failed to create stale pending payment: %v", err) } if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_tri_state' WHERE id = $1", staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } origClient := SquareClient SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: tc.result, err: tc.getErr} defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) freshCtx := context.Background() if _, err := SweepStalePendingPayments(freshCtx); err != nil { t.Fatalf("sweep failed: %v", err) } var status string if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil { t.Fatalf("failed to query payment: %v", err) } if status != tc.wantFinal { t.Errorf("expected stale pending payment %q after reconcile, got %q", tc.wantFinal, status) } }) } } // ============================================================================= // SweepStaleTerminalCheckouts — completed-during-cancel re-check (TOCTOU) // ============================================================================= // completingDuringCancelClient reports ErrCheckoutPending on the FIRST // GetCheckout for the target checkout and COMPLETED on later calls — simulating // a customer completing the payment between the sweep's status check and its // CancelCheckout. type completingDuringCancelClient struct { square.SquareClient checkoutID string calls int } func (c *completingDuringCancelClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { if checkoutID == c.checkoutID { c.calls++ if c.calls == 1 { return nil, square.ErrCheckoutPending } return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_completed_during_cancel"}, nil } return c.SquareClient.GetCheckout(ctx, checkoutID) } func TestSweepStaleTerminalCheckouts_CompletedDuringCancel_MarkedCompleted(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, serviceID := setupTestData(t, ctx, tx) const checkoutID = "chk_completes_during_cancel" if _, err := tx.Exec(ctx, ` INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at) VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours') `, checkoutID, bookingID); err != nil { t.Fatalf("failed to seed stale terminal checkout row: %v", err) } origClient := SquareClient SquareClient = &completingDuringCancelClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID} defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) freshCtx := context.Background() // Drop any other stale terminal rows left by parallel tests so the count is // deterministic. if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkoutID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil { t.Fatalf("failed to clean leftover stale till sales: %v", err) } n, err := SweepStaleTerminalCheckouts(freshCtx) if err != nil { t.Fatalf("sweep failed: %v", err) } if n != 1 { t.Errorf("expected exactly 1 resolved terminal checkout (completed during cancel), got %d", n) } var status string if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", checkoutID).Scan(&status); err != nil { t.Fatalf("failed to query terminal checkout: %v", err) } if status != "COMPLETED" { t.Errorf("expected a checkout that completed during the cancel window marked 'COMPLETED', got %q", status) } }