- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete - giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed - sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT - till: suffixed-key slot scan lock held across Square round-trip Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
4651 lines
197 KiB
Go
4651 lines
197 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/adminnotify"
|
|
"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', square_source_id = 'cnon:test-card' WHERE id = $1", staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
// A fresh mock has no payment under the key → ReplayPaymentByKey returns
|
|
// ErrReplayKeyNotRetained → the charge provably never happened → failed.
|
|
origClient := SquareClient
|
|
SquareClient = square.NewDevClient()
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected keyed payment with no charge at Square marked 'failed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedLostResponse_Ambiguous_LeavesPending locks
|
|
// the conservative keyed-reconcile rule: an ambiguous replay (transport error)
|
|
// leaves the keyed row pending — the charge may still be in flight at Square.
|
|
func TestSweepStalePendingPayments_KeyedLostResponse_Ambiguous_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-lost-response-ambiguous' WHERE id = $1", staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), err: fmt.Errorf("network error: connection reset by peer")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected ambiguous keyed replay to leave the payment pending, got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued locks the
|
|
// keyed lost-response rescue for till_sales: a stale pending till sale with a
|
|
// stored idempotency key but no square_payment_id whose charge completed at
|
|
// Square is rescued to 'completed' with the square_payment_id written back.
|
|
func TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 5000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: "key-lost-till-completed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
|
}
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
var saleID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, idempotency_key, square_source_id, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, 'cnon:test-card', $2, NOW() - INTERVAL '23 hours', NOW())
|
|
RETURNING id
|
|
`, "key-lost-till-completed", adminID).Scan(&saleID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed stale pending till sale: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status, sqPayID string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected lost-response till sale with a completed Square charge rescued to 'completed', got %q", status)
|
|
}
|
|
if sqPayID != pay.SquarePayID {
|
|
t.Errorf("expected square_payment_id %s written back on the till sale rescue, got %q", pay.SquarePayID, sqPayID)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedGiftCardPurchase_Completed_LeavesPending
|
|
// locks C6: a gift-card purchase payment row (payments table, NO booking) whose
|
|
// charge COMPLETED at Square must NOT be rescued to 'completed' — completing it
|
|
// would permanently block the same-key retry that delivers the card (customer
|
|
// charged, no card). The row stays pending, a critical-payment admin
|
|
// notification is inserted, and a same-key retry remains possible.
|
|
func TestSweepStalePendingPayments_KeyedGiftCardPurchase_Completed_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
const key = "key-gc-purchase-completed"
|
|
var payID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_by, created_at, updated_at)
|
|
VALUES ('full', 'online_square', 'pending', 50.00, $1, 'cnon:test-card', $2, NOW() - INTERVAL '23 hours', NOW())
|
|
RETURNING id
|
|
`, key, userID).Scan(&payID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed gift-card purchase payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 5000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: key,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
|
}
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, payID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status, sqPayID string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", payID).Scan(&status, &sqPayID); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected the gift-card purchase row left 'pending' (a same-key retry must still deliver the card), got %q", status)
|
|
}
|
|
if sqPayID != "" {
|
|
t.Errorf("expected no square_payment_id written on the gift-card purchase row, got %q", sqPayID)
|
|
}
|
|
// The charge landed at Square (the mock still holds the payment).
|
|
if _, err := mock.GetPayment(freshCtx, pay.SquarePayID); err != nil {
|
|
t.Errorf("expected the Square payment to still exist (customer was charged): %v", err)
|
|
}
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the unresolved gift-card purchase, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedSourceMismatch_LeavesPending locks C1: a
|
|
// replay that hits IDEMPOTENCY_KEY_REUSED (the stored square_source_id differs
|
|
// from the original charge's source — a data bug) must NEVER fail the row. The
|
|
// original charge may well have landed at Square, so the row is left pending
|
|
// for manual reconciliation instead of being marked failed (which would claw
|
|
// back funding / block a same-key retry).
|
|
func TestSweepStalePendingPayments_KeyedSourceMismatch_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-source-mismatch"
|
|
// The stored source differs from what the charge actually used at Square —
|
|
// the data-bug condition the identical-body replay surfaces as
|
|
// IDEMPOTENCY_KEY_REUSED.
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:wrong-source' WHERE id = $2", key, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
if _, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 200000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:original-source",
|
|
IdempotencyKey: key,
|
|
}); err != nil {
|
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
|
}
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected IDEMPOTENCY_KEY_REUSED to leave the row pending (never proof of no charge), got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplayNewCharge_AutoRefunded locks the B1
|
|
// money-safety behaviour for the A1 cross-check: a replayed COMPLETED payment
|
|
// created long AFTER the pending row is a NEW charge Square made with an
|
|
// EXPIRED idempotency key against the still-valid ccof source (the ~24h key
|
|
// retention is unverified) — money the customer never authorized. Instead of
|
|
// only leaving the row pending for a human (the pre-B1 outcome), the sweep now
|
|
// AUTO-REFUNDS the duplicate at Square with a fresh key, records the refunds
|
|
// row, raises the critical notification and marks the row FAILED (the original
|
|
// charge was never found). The cross-check runs only in a non-dev/mock env, so
|
|
// this test flips SQUARE_ENVIRONMENT to production (sequential, like the 2FA
|
|
// tests).
|
|
func TestSweepStalePendingPayments_KeyedReplayNewCharge_AutoRefunded(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
// 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but
|
|
// still inside Square's ~24h retention window (so the replay still runs —
|
|
// a row past 24h is blind-failed without a replay). NO stored snapshot →
|
|
// the minimal fallback body is rebuilt, which skips the production
|
|
// snapshot-decryption gate. created_by carries the payer so the admin
|
|
// notification is attributable and assertable.
|
|
const key = "key-expired-replay-new-charge"
|
|
const dupPayID = "pay_expired_key_new_charge"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
var rowCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
|
}
|
|
|
|
// The replayed COMPLETED payment's created_at is 25h AFTER the pending row
|
|
// — at/after the sweep's own replay moment (the discriminator is now the
|
|
// sweep's replayAt, not a fixed window), so it is provably a NEW expired-key
|
|
// replay charge, not a same-key retry.
|
|
// The cross-check runs only in a non-dev/mock env, so the env is flipped to
|
|
// production for the sweep (sequential, like the 2FA tests). The dev mock
|
|
// is constructed BEFORE the flip (NewDevClient refuses production without
|
|
// SQUARE_ALLOW_REAL_API); the mock itself never re-reads the env.
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
counting := &countingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: dupPayID,
|
|
SquarePayID: dupPayID,
|
|
Amount: 200000,
|
|
CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339),
|
|
}}}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
var sqPayID *string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status, square_payment_id FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected the auto-refunded new-charge replay to mark the row failed, got %q", status)
|
|
}
|
|
if sqPayID != nil {
|
|
t.Errorf("expected NO square_payment_id written on a new-charge replay, got %q", *sqPayID)
|
|
}
|
|
|
|
// Exactly one auto-refund must have been issued for the duplicate charge,
|
|
// for the row's amount, with the sweep reason.
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly one auto-refund of the duplicate charge, got %d", len(calls))
|
|
}
|
|
if calls[0].PaymentID != dupPayID {
|
|
t.Errorf("expected the refund to target the duplicate charge %s, got %s", dupPayID, calls[0].PaymentID)
|
|
}
|
|
if calls[0].Amount != 200000 {
|
|
t.Errorf("expected the refund amount to be the row's 200000 pence, got %d", calls[0].Amount)
|
|
}
|
|
if calls[0].Reason != "duplicate charge — sweep replay" {
|
|
t.Errorf("expected the sweep duplicate reason, got %q", calls[0].Reason)
|
|
}
|
|
|
|
// The refunds row records the auto-refund against the pending payment row.
|
|
var refundCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, staleID).Scan(&refundCount); err != nil {
|
|
t.Fatalf("failed to count refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected one completed refunds row for the auto-refund, got %d", refundCount)
|
|
}
|
|
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification recording the auto-refund, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// pendingRefundClient forces RefundPayment to return a PENDING result so the
|
|
// B1 auto-refund branch is exercised: Square accepted the refund but left it
|
|
// non-terminal.
|
|
type pendingRefundClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *pendingRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
|
res, err := c.SquareClient.RefundPayment(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Status = "PENDING"
|
|
return res, nil
|
|
}
|
|
|
|
// settledRefundClient reports every refund Square holds as COMPLETED — as if a
|
|
// PENDING refund settled — so the B1 re-poll pass resolves the parent row.
|
|
type settledRefundClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *settledRefundClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
|
refunds, err := c.SquareClient.ListPaymentRefunds(ctx, paymentID, beginTime)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range refunds {
|
|
refunds[i].Status = "COMPLETED"
|
|
}
|
|
return refunds, nil
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowPending
|
|
// locks the B1 PENDING fix: when Square accepts the auto-refund of a
|
|
// replay-induced duplicate charge but leaves it PENDING (non-terminal), the
|
|
// sweep must NOT mark the parent payment failed and must record the refunds
|
|
// row with status 'pending' + square_refund_id. The re-poll pass
|
|
// (SweepPendingSquareRefunds) then resolves the parent row ONLY once Square
|
|
// reports the refund COMPLETED. Sequential (flips SQUARE_ENVIRONMENT), like the
|
|
// sibling B1 tests.
|
|
func TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-expired-replay-pending-refund"
|
|
const dupPayID = "pay_expired_key_pending_refund"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
var rowCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
SquareClient = &pendingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: dupPayID,
|
|
SquarePayID: dupPayID,
|
|
Amount: 200000,
|
|
CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339),
|
|
}}}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// PENDING refund → NON-terminal: the parent row must NOT be marked failed.
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected a PENDING auto-refund to leave the row pending (never failed), got %q", status)
|
|
}
|
|
|
|
// The in-flight refund must be recorded with status 'pending' + square_refund_id.
|
|
var refundStatus string
|
|
var sqRefundID *string
|
|
var reason string
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id, reason FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundStatus, &sqRefundID, &reason); err != nil {
|
|
t.Fatalf("failed to query refunds row: %v", err)
|
|
}
|
|
if refundStatus != "pending" {
|
|
t.Errorf("expected the refunds row to be pending, got %q", refundStatus)
|
|
}
|
|
if sqRefundID == nil || *sqRefundID == "" {
|
|
t.Error("expected the refunds row to carry the Square refund id")
|
|
}
|
|
if reason != sweepDuplicateRefundReason {
|
|
t.Errorf("expected reason %q, got %q", sweepDuplicateRefundReason, reason)
|
|
}
|
|
|
|
// Now the refund settles at Square: the re-poll pass resolves the parent.
|
|
SquareClient = &settledRefundClient{SquareClient: mock}
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("refund re-poll sweep failed: %v", err)
|
|
}
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to re-query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected a COMPLETED refund to resolve the parent row to failed, got %q", status)
|
|
}
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundStatus); err != nil {
|
|
t.Fatalf("failed to re-query refunds row: %v", err)
|
|
}
|
|
if refundStatus != "completed" {
|
|
t.Errorf("expected the refunds row to complete once Square settles, got %q", refundStatus)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_NoClawback
|
|
// locks the B1 PENDING fix for till sales: a PENDING auto-refund of a
|
|
// replay-induced duplicate charge must leave the till sale pending with its
|
|
// funded gift card intact (NO clawback, NO fail), and the refunds row must be
|
|
// recorded (attached to a synthetic payments row for the duplicate charge)
|
|
// with the parent sale encoded in the reason. Once Square settles the refund
|
|
// COMPLETED, the re-poll pass claws back the funding and marks the sale failed.
|
|
// Sequential (flips SQUARE_ENVIRONMENT), like the sibling B1 tests.
|
|
func TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_NoClawback(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
pool := context.Background()
|
|
|
|
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
|
|
// Age the sale AND its created gift card inside the key window (23h,
|
|
// created_at equality preserved → is_create stays true) and add the key.
|
|
const dupPayID = "pay_expired_key_till_pending_refund"
|
|
if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-till-expired-pending-refund', square_source_id = 'ccof:test-saved-card' WHERE id = $1", saleID); err != nil {
|
|
t.Fatalf("failed to age the till sale: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
|
|
t.Fatalf("failed to age the gift card: %v", err)
|
|
}
|
|
var saleCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM till_sales WHERE id = $1", saleID).Scan(&saleCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged till sale created_at: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
SquareClient = &pendingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: dupPayID,
|
|
SquarePayID: dupPayID,
|
|
Amount: 5000,
|
|
CreatedAt: saleCreatedAt.Add(25 * time.Hour).Format(time.RFC3339),
|
|
}}}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit setup tx: %v", err)
|
|
}
|
|
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected a PENDING auto-refund to leave the till sale pending (never failed, no clawback), got %q", status)
|
|
}
|
|
var cardCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
|
|
t.Fatalf("failed to count gift cards: %v", err)
|
|
}
|
|
if cardCount != 1 {
|
|
t.Errorf("expected the funded gift card NOT clawed back while the refund is pending, got %d cards", cardCount)
|
|
}
|
|
|
|
// The refund row exists with the parent till sale encoded in the reason.
|
|
var reason, refundStatus string
|
|
if err := db.Conn.QueryRow(pool, `SELECT reason, status FROM refunds WHERE reason = $1`, sweepDuplicateRefundReasonFor(saleID)).Scan(&reason, &refundStatus); err != nil {
|
|
t.Fatalf("failed to query B1 refund row: %v", err)
|
|
}
|
|
if refundStatus != "pending" {
|
|
t.Errorf("expected the B1 refunds row to be pending, got %q", refundStatus)
|
|
}
|
|
|
|
// Refund settles COMPLETED → re-poll claws back the funding and fails the sale.
|
|
SquareClient = &settledRefundClient{SquareClient: mock}
|
|
if _, err := SweepPendingSquareRefunds(pool); err != nil {
|
|
t.Fatalf("refund re-poll sweep failed: %v", err)
|
|
}
|
|
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to re-query till sale: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected a COMPLETED refund to resolve the till sale to failed, got %q", status)
|
|
}
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
|
|
t.Fatalf("failed to count gift cards: %v", err)
|
|
}
|
|
if cardCount != 0 {
|
|
t.Errorf("expected the created gift card clawed back after the refund settled COMPLETED, got %d cards", cardCount)
|
|
}
|
|
}
|
|
|
|
// rejectedRefundClient forces RefundPayment to return a REJECTED result so the
|
|
// B1 auto-refund's definitive-rejection branch is exercised: Square rejects the
|
|
// refund of the replay-induced duplicate charge (the duplicate stands).
|
|
type rejectedRefundClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *rejectedRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
|
res, err := c.SquareClient.RefundPayment(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Status = "REJECTED"
|
|
return res, nil
|
|
}
|
|
|
|
// failOnReplayClient fails the test if the sweep attempts to re-replay the
|
|
// SPECIFIC expired idempotency key it was constructed with — proving the B1
|
|
// guard never re-replays a key whose auto-refund of a replay-induced duplicate
|
|
// has failed or hit the attempt cap (each replay would mint ANOTHER charge at
|
|
// Square). The sweep processes the whole shared test database, so replays of
|
|
// OTHER tests' rows are delegated to the embedded client instead of failing.
|
|
type failOnReplayClient struct {
|
|
square.SquareClient
|
|
t *testing.T
|
|
key string
|
|
}
|
|
|
|
func (c *failOnReplayClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*square.PaymentResult, error) {
|
|
if bytes.Contains(snapshotJSON, []byte(c.key)) {
|
|
c.t.Fatalf("the sweep must NEVER re-replay the expired key %q whose B1 refund failed / hit the attempt cap", c.key)
|
|
}
|
|
return c.SquareClient.ReplayPaymentByKey(ctx, snapshotJSON)
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundRejected_FailsRowNoReplay
|
|
// locks the CRITICAL-HIGH B1 fix for a refund Square REJECTS at call time: the
|
|
// auto-refund of the replay-induced duplicate is definitively rejected (the
|
|
// duplicate stands at Square). The parent row must be marked failed (the
|
|
// original charge was never found), the CRITICAL notification raised, and
|
|
// b1_attempts pinned to the cap so the expired key is NEVER replayed — a
|
|
// rejected refund writes NO refunds row, so without the cap the next sweep
|
|
// would re-replay the key and mint ANOTHER charge (the stacking-unauthorized-
|
|
// charges loop the finding describes). No refunds row exists (the refund was
|
|
// never accepted). Sequential (flips SQUARE_ENVIRONMENT), like the sibling B1
|
|
// tests.
|
|
func TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundRejected_FailsRowNoReplay(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-expired-replay-rejected-refund"
|
|
const dupPayID = "pay_expired_key_rejected_refund"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
var rowCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
counting := &countingRefundClient{SquareClient: &rejectedRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: dupPayID,
|
|
SquarePayID: dupPayID,
|
|
Amount: 200000,
|
|
CreatedAt: rowCreatedAt.Add(25 * time.Hour).Format(time.RFC3339),
|
|
}}}}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// The REJECTED auto-refund marks the parent failed (the original charge was
|
|
// never found) — with the CRITICAL notification and the b1_attempts cap set.
|
|
var status string
|
|
var b1Attempts int
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status, b1_attempts FROM payments WHERE id = $1", staleID).Scan(&status, &b1Attempts); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected a REJECTED auto-refund to mark the row failed, got %q", status)
|
|
}
|
|
if b1Attempts != b1DuplicateRefundAttemptCap {
|
|
t.Errorf("expected b1_attempts pinned to the cap %d after a REJECTED refund, got %d", b1DuplicateRefundAttemptCap, b1Attempts)
|
|
}
|
|
|
|
// Exactly one auto-refund was ATTEMPTED (and rejected) — and, crucially, NO
|
|
// refunds row was written (the refund was never accepted), which is exactly
|
|
// the state that used to re-arm the replay loop.
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly one auto-refund attempt of the duplicate charge, got %d", len(calls))
|
|
}
|
|
var refundCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil {
|
|
t.Fatalf("failed to count refunds: %v", err)
|
|
}
|
|
if refundCount != 0 {
|
|
t.Errorf("expected NO refunds row for a REJECTED auto-refund, got %d", refundCount)
|
|
}
|
|
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the rejected auto-refund, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplay_NoReplayAtAttemptCap locks the B1
|
|
// cap guard: a stale pending row whose b1_attempts already reached the cap must
|
|
// be failed with a CRITICAL notification and its expired key NEVER replayed
|
|
// (a replay would mint ANOTHER charge at Square). The failOnReplayClient
|
|
// proves the replay never happens.
|
|
func TestSweepStalePendingPayments_KeyedReplay_NoReplayAtAttemptCap(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-expired-replay-at-cap"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2, b1_attempts = $3 WHERE id = $4", key, userID, b1DuplicateRefundAttemptCap, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment and set the attempt cap: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &failOnReplayClient{SquareClient: square.NewDevClient(), t: t, key: key}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected a row at the b1_attempts cap to be failed without a replay, got %q", status)
|
|
}
|
|
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the attempt-capped row, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplay_NoReplayWithFailedRefundRow locks
|
|
// the FAILED-webhook demotion path (finding d): a B1 auto-refund that was
|
|
// accepted PENDING and later demoted to 'failed' by the refund.updated webhook
|
|
// clears the in-flight guard — the sweep must then see the FAILED refunds row
|
|
// and fail the parent WITHOUT ever re-replaying the expired key. The
|
|
// failOnReplayClient proves the replay never happens.
|
|
func TestSweepStalePendingPayments_KeyedReplay_NoReplayWithFailedRefundRow(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-expired-replay-failed-refund"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2, b1_attempts = 1 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
// The webhook-demoted B1 refund row: accepted PENDING, then FAILED at Square.
|
|
const dupPayID = "pay_demoted_failed_duplicate"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at)
|
|
VALUES ($1, $2, 2000.00, 'ref_sweep_demoted_failed', 'failed', 'manual', $3, 'sweepdup-' || $4, $5, NOW())
|
|
`, staleID, bookingID, sweepDuplicateRefundReason, dupPayID, userID); err != nil {
|
|
t.Fatalf("failed to insert the demoted failed refund row: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &failOnReplayClient{SquareClient: square.NewDevClient(), t: t, key: key}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected a row with a FAILED B1 refund to be failed without a replay, got %q", status)
|
|
}
|
|
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the failed-B1-refund row, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplayUnparseableCreatedAt_LeavesPending
|
|
// locks the B1 caveat: a replayed COMPLETED payment whose created_at CANNOT be
|
|
// parsed must NOT be auto-refunded. An unparseable created_at does not prove
|
|
// the payment is a duplicate — it could be the ORIGINAL charge a retained key
|
|
// returned, whose created_at was lost/corrupt — so the sweep restores the
|
|
// pre-B1 outcome: leave the row PENDING with a CRITICAL notification and issue
|
|
// NO refund (never auto-reverse a possibly-legitimate authorized charge).
|
|
// Sequential (flips SQUARE_ENVIRONMENT), like the sibling B1/A1 tests.
|
|
func TestSweepStalePendingPayments_KeyedReplayUnparseableCreatedAt_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-expired-replay-unparseable-createdat"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
counting := &countingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: "pay_expired_key_unparseable_createdat",
|
|
SquarePayID: "pay_expired_key_unparseable_createdat",
|
|
CreatedAt: "not-a-real-timestamp",
|
|
}}}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected an unparseable-created_at replay to leave the row pending (pre-B1), got %q", status)
|
|
}
|
|
|
|
// NO auto-refund may have been issued — the payment is not provably a duplicate.
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO auto-refund for an unparseable-created_at replay, got %d refund call(s)", len(calls))
|
|
}
|
|
var refundCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil {
|
|
t.Fatalf("failed to count refunds: %v", err)
|
|
}
|
|
if refundCount != 0 {
|
|
t.Errorf("expected no refunds row for an unparseable-created_at replay, got %d", refundCount)
|
|
}
|
|
|
|
// The leave-pending-CRITICAL path still raises the admin notification.
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the unparseable-created_at replay, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues locks the
|
|
// A1 cross-check control: a replayed COMPLETED payment created at the SAME
|
|
// instant as the pending row (the retained-key dedup returning the original
|
|
// charge) is rescued to 'completed' — the cross-check must never block a
|
|
// legitimate lost-response rescue. Production env so the cross-check runs.
|
|
func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-retained-original"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
// The replayed payment is the ORIGINAL — created at the SAME instant as the
|
|
// pending row, as a retained-key dedup returns. Seeding CreatedAt from the
|
|
// row's own timestamp makes the F2 lag ~0 deterministically (a fixed
|
|
// clock.Now()-relative offset would race the DB NOW() microsecond
|
|
// truncation). The env is flipped to production for the sweep so the A1
|
|
// cross-check runs; the dev mock is constructed BEFORE the flip
|
|
// (NewDevClient refuses production without SQUARE_ALLOW_REAL_API).
|
|
var rowCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
|
}
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: "pay_original_under_key",
|
|
SquarePayID: "pay_original_under_key",
|
|
CreatedAt: rowCreatedAt.Format(time.RFC3339Nano),
|
|
}}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status, sqPayID string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected the original-payment replay rescued to 'completed', got %q", status)
|
|
}
|
|
if sqPayID != "pay_original_under_key" {
|
|
t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_under_key", sqPayID)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending locks
|
|
// the B1 lower-bound clock-skew tolerance: a replayed COMPLETED payment created
|
|
// BEFORE the pending row must NOT be auto-refunded as a "new charge". Rows are
|
|
// inserted pending-first (row.CreatedAt precedes Square's created_at by
|
|
// ~0.5-1s), and a DB clock running AHEAD of Square's (independent NTP drift, VM
|
|
// pause/resume) makes a retained-key dedup return the ORIGINAL charge with
|
|
// created < row.CreatedAt. A payment within replayRescueLowerBoundSkew of the
|
|
// row is that clock-skewed ORIGINAL and is rescued (locked by
|
|
// TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues); this
|
|
// fixture sits BEYOND the 5-minute tolerance, where no clock skew can explain
|
|
// it — the row is left PENDING with a CRITICAL notification instead. Sequential
|
|
// (flips SQUARE_ENVIRONMENT), like the sibling B1/A1 tests.
|
|
func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-retained-clock-skew"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
// The replayed COMPLETED payment is created 10 minutes BEFORE the row —
|
|
// beyond the 5-minute clock-skew tolerance, so it cannot be a clock-skewed
|
|
// retained-key original and must remain ambiguous. Seeding from the row's
|
|
// own timestamp keeps the lag deterministic.
|
|
var rowCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
|
}
|
|
skewedCreated := rowCreatedAt.Add(-10 * time.Minute)
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: "pay_original_skewed",
|
|
SquarePayID: "pay_original_skewed",
|
|
CreatedAt: skewedCreated.Format(time.RFC3339Nano),
|
|
}}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// Ambiguous (created before the row): the row must stay PENDING — never
|
|
// auto-refunded, never rescued.
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected a before-row replay to leave the payment pending, got %q", status)
|
|
}
|
|
|
|
// No auto-refund may have been issued for the ambiguous payment.
|
|
var refundCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil {
|
|
t.Fatalf("failed to count refunds: %v", err)
|
|
}
|
|
if refundCount != 0 {
|
|
t.Errorf("expected NO auto-refund of an ambiguous before-row payment, got %d refunds rows", refundCount)
|
|
}
|
|
|
|
// A critical-payment admin notification must surface the manual reconciliation.
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the ambiguous replay, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues locks the
|
|
// Loop B MEDIUM lower-bound fix: a replayed COMPLETED payment created slightly
|
|
// BEFORE the pending row — within the 5-minute replayRescueLowerBoundSkew — is
|
|
// the clock-skewed ORIGINAL charge under a retained key (a DB clock ahead of
|
|
// Square's makes a retained-key dedup return the original with created <
|
|
// row.CreatedAt), NOT a provably-new duplicate. It must be RESCUED to
|
|
// 'completed'; stranding it pending (the pre-fix behavior for any before-row
|
|
// payment) would leave the customer's legitimately-authorized charge to resolve
|
|
// only via the 24h blind-fail as a WARN'd failed payment. Sequential (flips
|
|
// SQUARE_ENVIRONMENT), like the sibling B1/A1 tests.
|
|
func TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-retained-within-skew"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
// The replayed COMPLETED payment is the ORIGINAL charge under a retained
|
|
// key whose created_at lags the DB row's by 2 minutes — inside the 5-minute
|
|
// clock-skew tolerance. Seeding from the row's own timestamp keeps the lag
|
|
// deterministic.
|
|
var rowCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
|
}
|
|
skewedCreated := rowCreatedAt.Add(-2 * time.Minute)
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: "pay_original_within_skew",
|
|
SquarePayID: "pay_original_within_skew",
|
|
CreatedAt: skewedCreated.Format(time.RFC3339Nano),
|
|
}}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// Within-tolerance before-row: the clock-skewed ORIGINAL is rescued, never
|
|
// auto-refunded and never left pending.
|
|
var status, sqPayID string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected a within-tolerance before-row replay rescued to 'completed', got %q", status)
|
|
}
|
|
if sqPayID != "pay_original_within_skew" {
|
|
t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_within_skew", sqPayID)
|
|
}
|
|
|
|
var refundCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil {
|
|
t.Fatalf("failed to count refunds: %v", err)
|
|
}
|
|
if refundCount != 0 {
|
|
t.Errorf("expected NO auto-refund of a within-tolerance before-row original, got %d refund rows", refundCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues locks the B2
|
|
// dead-zone fix: a same-key retry whose charge landed 21.5h after the pending
|
|
// row is the REAL charge under a legitimately replayed key and MUST be rescued.
|
|
// The discriminator is the sweep's OWN replay instant (replayAt): a payment
|
|
// created before the sweep replayed the key is the original or a legit retry,
|
|
// while a payment created at/after the replay is the sweep's own expired-key
|
|
// creation. A fixed row-age window (21h, then 22h, then 24h across reviews)
|
|
// was wrong in every direction — comparing against replayAt is correct
|
|
// regardless of Square's unverified ~24h key-retention window.
|
|
func TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
const key = "key-retained-retry-215h"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
// The replayed COMPLETED payment is a legitimate same-key retry created
|
|
// 21.5h after the row — inside the 22h legitimate window, so it is the real
|
|
// charge and must be rescued, not refused as an expired-key duplicate.
|
|
var rowCreatedAt time.Time
|
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
|
}
|
|
retryCreated := rowCreatedAt.Add(21*time.Hour + 30*time.Minute)
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
ID: "pay_retry_at_215h",
|
|
SquarePayID: "pay_retry_at_215h",
|
|
CreatedAt: retryCreated.Format(time.RFC3339Nano),
|
|
}}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status, sqPayID string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected the 21.5h same-key retry rescued to 'completed', got %q", status)
|
|
}
|
|
if sqPayID != "pay_retry_at_215h" {
|
|
t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_retry_at_215h", sqPayID)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback locks
|
|
// the A1 ccof-blind-fail rule: a replay rejection (ErrReplayKeyNotRetained)
|
|
// against a SAVED-CARD (ccof:) source is NOT proof the original charge never
|
|
// happened — the same key-retention race that lets the replay land a NEW charge
|
|
// can leave that charge even when the probe is rejected. A till sale with a
|
|
// funded gift card must be left PENDING (never failed) and the gift card must
|
|
// NOT be clawed back. A cnon-source (spent-nonce) rejection remains definitive
|
|
// and keeps the proven-failed clawback (locked by
|
|
// TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks).
|
|
func TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
pool := context.Background()
|
|
|
|
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
|
|
// 23h old: past the 22h keyed cutoff, still inside the retention window so
|
|
// the keyed replay runs (not the past-retention blind-fail). ccof source.
|
|
if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-ccof-rejected', square_source_id = 'ccof:test-saved-card' WHERE id = $1", saleID); err != nil {
|
|
t.Fatalf("failed to age the till sale: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
|
|
t.Fatalf("failed to age the gift card: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), err: square.ErrReplayKeyNotRetained}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit setup tx: %v", err)
|
|
}
|
|
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected the ccof-source replay rejection to leave the till sale pending (never blindly failed), got %q", status)
|
|
}
|
|
|
|
// The funded card must be untouched — the blind rejection could mean a
|
|
// replay-created charge landed, so the funding must stay put.
|
|
var cardCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
|
|
t.Fatalf("failed to count gift cards: %v", err)
|
|
}
|
|
if cardCount != 1 {
|
|
t.Errorf("expected the ccof rejection to leave the funded gift card in place (no clawback), got %d cards", cardCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedBlindFail_Notifies locks A5a: a keyed
|
|
// pending row already past Square's key retention window is blind-failed (the
|
|
// charge outcome is unknown — the lost response may have landed at Square), so
|
|
// a critical-payment admin notification must be raised alongside the failed
|
|
// mark. The notification is deduped per payer.
|
|
func TestSweepStalePendingPayments_KeyedBlindFail_Notifies(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
// 25h old: past both the 22h keyed cutoff and Square's 24h retention window,
|
|
// so the keyed pass blind-fails it without a replay.
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-blindfail-notify', square_source_id = 'cnon:test-card', created_by = $1 WHERE id = $2", userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = square.NewDevClient()
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected the past-retention keyed row blind-failed, got %q", status)
|
|
}
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the keyed blind-fail, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks
|
|
// locks the keyed clawback: a stale pending till sale with a stored idempotency
|
|
// key whose charge Square PROVES never happened (no payment under the key) is
|
|
// marked failed AND its funded gift card is clawed back — unlike the blind-fail
|
|
// path, the reconcile proved the funding has no charge behind it.
|
|
func TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
pool := context.Background()
|
|
|
|
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
|
|
// Move both the sale and its created gift card inside the key window (23h,
|
|
// created_at equality preserved → is_create stays true) and add the key.
|
|
if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-lost-till-never', square_source_id = 'cnon:test-card' WHERE id = $1", saleID); err != nil {
|
|
t.Fatalf("failed to age the till sale: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
|
|
t.Fatalf("failed to age the gift card: %v", err)
|
|
}
|
|
|
|
// 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_CancelThenNoClawback
|
|
// 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 — is NOT provably
|
|
// dead. money-F3: because the cancel did NOT provably take effect (the charge
|
|
// may still complete at Square after the cancel), the sale is NOT marked
|
|
// failed — it is left PENDING for manual reconciliation, a CRITICAL admin
|
|
// notification is raised, and the funded gift card is NOT clawed back. The
|
|
// separate CANCEL_REQUESTED-only "not provably dead, no clawback" FIRST-check
|
|
// classification of isCheckoutDefinitivelyDead is locked by
|
|
// TestSweepStaleTerminalCheckouts_TillCancelRequested_NoClawback, and the
|
|
// definitively-dead cancel-recheck path (re-check proves CANCELED) is locked
|
|
// by TestSweepStaleTerminalCheckouts_CancelsStalePending.
|
|
func TestSweepStaleTerminalCheckouts_MockCancelRequested_CancelThenNoClawback(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)
|
|
}
|
|
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND user_id IS NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover critical notifications: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'awaiting_reversal'`, giftCardID); err != nil {
|
|
t.Fatalf("failed to clean leftover reconciliation traces: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(pool)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected a cancel-recheck that is still live / not provably dead to be left UNRESOLVED for reconciliation, got %d resolutions", 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 != "pending" {
|
|
t.Errorf("expected a not-provably-dead cancel-recheck till sale left 'pending' (money-F3: never marked failed), got %q", status)
|
|
}
|
|
|
|
// C3 / money-F3: the re-check is still live / cancel-requested, so the
|
|
// charge may still complete at Square — the funded gift card must NOT be
|
|
// clawed back (the pre-fix code deleted it, taking the customer's money AND
|
|
// the card value if the charge later landed).
|
|
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 the funded gift card KEPT after a not-provably-dead cancel-recheck, got count=%d remaining=%.2f", cardCount, remaining)
|
|
}
|
|
|
|
// money-F3: a CRITICAL admin notification must be raised so an operator
|
|
// verifies the Square checkout state manually.
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND user_id IS NULL`).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count critical notifications: %v", err)
|
|
}
|
|
if notifCount != 1 {
|
|
t.Errorf("expected exactly 1 CRITICAL admin notification for the still-live cancel-recheck, got %d", notifCount)
|
|
}
|
|
|
|
// money-F3: the outstanding gift-card funding must be surfaced as an
|
|
// 'awaiting_reversal' trace row for the operator's manual resolution.
|
|
var traceCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'awaiting_reversal' AND reference_id = $2`, giftCardID, saleID).Scan(&traceCount); err != nil {
|
|
t.Fatalf("failed to count reconciliation traces: %v", err)
|
|
}
|
|
if traceCount != 1 {
|
|
t.Errorf("expected exactly 1 outstanding-funding trace row for the still-live cancel-recheck, got %d", traceCount)
|
|
}
|
|
}
|
|
|
|
// stillLiveAfterCancelClient reports ErrCheckoutPending on EVERY GetCheckout for
|
|
// the target checkout — the cancel did not provably take effect at Square (the
|
|
// cancel request was accepted but the checkout stays live; a customer could
|
|
// still complete the payment on the terminal). Everything else delegates to the
|
|
// underlying mock.
|
|
type stillLiveAfterCancelClient struct {
|
|
square.SquareClient
|
|
checkoutID string
|
|
calls int
|
|
}
|
|
|
|
func (c *stillLiveAfterCancelClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
|
if checkoutID == c.checkoutID {
|
|
c.calls++
|
|
return nil, square.ErrCheckoutPending
|
|
}
|
|
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_StillLiveAfterCancel_TillSale locks the
|
|
// money-F3 fix for a TILL-SALE checkout whose cancel did not provably take
|
|
// effect: the sweep cancels the stale checkout but the re-check STILL reports
|
|
// it live (ErrCheckoutPending), so the card charge can still complete at
|
|
// Square. The sale must NOT be marked failed (a failed mark would drop the live
|
|
// checkout — a late completion becomes a permanently untracked charge with no
|
|
// alert); instead the sale stays PENDING for manual reconciliation, a CRITICAL
|
|
// admin notification is raised, and the outstanding gift-card funding is
|
|
// surfaced as an 'awaiting_reversal' trace row. The funded gift card is not
|
|
// clawed back.
|
|
func TestSweepStaleTerminalCheckouts_StillLiveAfterCancel_TillSale(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_still_live_after_cancel_till"
|
|
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 = &stillLiveAfterCancelClient{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 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 := db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND user_id IS NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover critical notifications: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'awaiting_reversal'`, giftCardID); err != nil {
|
|
t.Fatalf("failed to clean leftover reconciliation traces: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(pool)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected a still-live-after-cancel till sale left UNRESOLVED for reconciliation, got %d resolutions", n)
|
|
}
|
|
|
|
// NOT marked failed — the sale stays pending so the live checkout is never
|
|
// dropped from tracking.
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected a still-live-after-cancel till sale left 'pending' (never marked failed), got %q", status)
|
|
}
|
|
|
|
// The funded gift card must NOT be 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 the funded gift card KEPT after a still-live cancel-recheck, got count=%d remaining=%.2f", cardCount, remaining)
|
|
}
|
|
|
|
// A CRITICAL admin notification must be raised so an operator verifies the
|
|
// Square checkout state manually.
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND user_id IS NULL`).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count critical notifications: %v", err)
|
|
}
|
|
if notifCount != 1 {
|
|
t.Errorf("expected exactly 1 CRITICAL admin notification for the still-live-after-cancel till sale, got %d", notifCount)
|
|
}
|
|
|
|
// The outstanding gift-card funding must be surfaced as an
|
|
// 'awaiting_reversal' trace row for the operator's manual resolution.
|
|
var traceCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'awaiting_reversal' AND reference_id = $2`, giftCardID, saleID).Scan(&traceCount); err != nil {
|
|
t.Fatalf("failed to count reconciliation traces: %v", err)
|
|
}
|
|
if traceCount != 1 {
|
|
t.Errorf("expected exactly 1 outstanding-funding trace row for the still-live-after-cancel till sale, got %d", traceCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_StillLiveAfterCancel_Booking locks the
|
|
// money-F3 fix for a BOOKING terminal checkout whose cancel did not provably
|
|
// take effect: the re-check still reports the checkout live, so the terminal
|
|
// can still charge the card. The terminal_checkouts row must NOT be marked
|
|
// failed (the money-F3 bug: a failed mark drops the live checkout with no alert
|
|
// and a late completion becomes permanently untracked); instead it stays
|
|
// PENDING for manual reconciliation and a CRITICAL admin notification is raised
|
|
// against the booking.
|
|
func TestSweepStaleTerminalCheckouts_StillLiveAfterCancel_Booking(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
|
|
|
const checkoutID = "chk_still_live_after_cancel_booking"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
|
|
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
|
|
`, checkoutID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed stale terminal checkout row: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &stillLiveAfterCancelClient{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 setup tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1`, bookingID)
|
|
_, _ = 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()
|
|
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)
|
|
}
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1`, bookingID); err != nil {
|
|
t.Fatalf("failed to clean leftover critical notifications: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected a still-live-after-cancel booking checkout left UNRESOLVED for reconciliation, got %d resolutions", n)
|
|
}
|
|
|
|
// NOT marked failed — the row stays PENDING so the live checkout is never
|
|
// dropped from tracking.
|
|
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 != "PENDING" {
|
|
t.Errorf("expected a still-live-after-cancel booking checkout left 'PENDING' (never marked failed), got %q", status)
|
|
}
|
|
|
|
// A CRITICAL admin notification against the booking must be raised so an
|
|
// operator verifies the Square checkout state manually.
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1 AND user_id IS NULL`, bookingID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count critical notifications: %v", err)
|
|
}
|
|
if notifCount != 1 {
|
|
t.Errorf("expected exactly 1 CRITICAL admin notification for the still-live-after-cancel booking checkout, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SweepStaleTerminalCheckouts — provisional "tmp-" rows are resolved against
|
|
// Square first (H4), never blind-failed
|
|
// =============================================================================
|
|
|
|
// provisionalCheckoutClient forces GetCheckout for one target checkout id to a
|
|
// fixed result/error so the sweep's provisional "tmp-" row resolution
|
|
// (mirroring handlers.go activeTerminalCheckoutID's H4 classification) can be
|
|
// exercised deterministically, while delegating everything else to the mock.
|
|
type provisionalCheckoutClient struct {
|
|
square.SquareClient
|
|
checkoutID string
|
|
result *square.PaymentResult
|
|
err error
|
|
}
|
|
|
|
func (c *provisionalCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
|
if checkoutID == c.checkoutID {
|
|
if c.err != nil {
|
|
return nil, c.err
|
|
}
|
|
return c.result, nil
|
|
}
|
|
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
// seedStaleProvisionalTerminalCheckout seeds a stale (2h old) PENDING
|
|
// terminal_checkouts row carrying a synthetic "tmp-" checkout_id for the
|
|
// caller's booking inside the setup transaction. The caller commits the setup
|
|
// tx; pool-level cleanup of the row is registered.
|
|
func seedStaleProvisionalTerminalCheckout(t *testing.T, ctx context.Context, q db.Querier, bookingID, tmpID string) {
|
|
t.Helper()
|
|
pool := context.Background()
|
|
if _, err := q.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
|
|
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
|
|
`, tmpID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed stale provisional terminal checkout: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID)
|
|
})
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment locks
|
|
// the H4 fix: a provisional "tmp-" terminal_checkouts row whose checkout
|
|
// actually COMPLETED at Square (a hard crash between the row insert and the
|
|
// provisional→real UPDATE leaves the real checkout live while the row still
|
|
// carries the synthetic id) is resolved against Square and the untracked charge
|
|
// RECORDED — never blind-failed. Blind-failing would release the in-flight
|
|
// guard while the customer was still charged, leaving an invisible untracked
|
|
// payment (a second checkout could then be created for the same booking).
|
|
func TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
// The booking must be in a payable state for the untracked charge to be
|
|
// recorded.
|
|
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
|
|
t.Fatalf("failed to set booking in_progress: %v", err)
|
|
}
|
|
|
|
const tmpID = "tmp-crash-completed-key"
|
|
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &provisionalCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
checkoutID: tmpID,
|
|
result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_tmp_completed", Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242"},
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE square_payment_id = 'sqp_tmp_completed'`)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
// Drop any other stale terminal rows left by sequential tests so the count
|
|
// is deterministic.
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
|
|
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(pool)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected the COMPLETED provisional checkout resolved by the sweep, got %d resolutions", n)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query terminal checkout: %v", err)
|
|
}
|
|
if status != "COMPLETED" {
|
|
t.Errorf("expected a COMPLETED provisional checkout marked 'COMPLETED', got %q", status)
|
|
}
|
|
|
|
// The untracked charge must have been recorded as a payment row (H4).
|
|
var payCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_tmp_completed'`, bookingID).Scan(&payCount); err != nil {
|
|
t.Fatalf("failed to count recorded payments: %v", err)
|
|
}
|
|
if payCount != 1 {
|
|
t.Errorf("expected the COMPLETED provisional charge recorded as a payment, got %d rows", payCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_TmpProvisional_NotFound_Fails locks the H4
|
|
// NOT_FOUND direction: a provisional "tmp-" row that Square has never seen (the
|
|
// crash happened before the Square call, so no checkout was ever created — or
|
|
// the tmp id cannot be resolved) resolves to the expected NOT_FOUND and is
|
|
// safely marked failed.
|
|
func TestSweepStaleTerminalCheckouts_TmpProvisional_NotFound_Fails(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
const tmpID = "tmp-never-reached-square-key"
|
|
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
|
|
|
|
// A fresh mock holds no checkout under the tmp id → GetCheckout returns the
|
|
// mock's plain "checkout not found" error, which isTerminalCheckoutError
|
|
// classifies as terminal — the expected outcome for an unresolvable tmp id.
|
|
origClient := SquareClient
|
|
SquareClient = square.NewDevClient()
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
|
|
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(pool)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected the not-found provisional checkout resolved to failed, got %d resolutions", n)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query terminal checkout: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected a not-found provisional checkout marked 'failed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_TmpProvisional_Ambiguous_LeavesPending locks
|
|
// the H4 ambiguous direction: a provisional "tmp-" row whose Square status is
|
|
// unknown (transport error) must NOT be blind-failed — the checkout may still
|
|
// be live or may have completed, so the row stays pending for a later run.
|
|
func TestSweepStaleTerminalCheckouts_TmpProvisional_Ambiguous_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
const tmpID = "tmp-ambiguous-key"
|
|
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &provisionalCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
checkoutID: tmpID,
|
|
err: fmt.Errorf("network error: connection reset by peer"),
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
|
|
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(pool)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected an ambiguous provisional checkout left unresolved, got %d resolutions", n)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query terminal checkout: %v", err)
|
|
}
|
|
if status != "PENDING" {
|
|
t.Errorf("expected an ambiguous provisional checkout left 'PENDING', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_TmpProvisional_StillLive_LeavesPending locks
|
|
// the H4 ErrCheckoutPending direction: a provisional "tmp-" row whose checkout
|
|
// is still live at Square (PENDING / IN_PROGRESS) must keep the in-flight guard
|
|
// — the sweep must never resolve it failed (releasing the guard) while the
|
|
// customer can still complete the payment at the terminal into an untracked
|
|
// charge.
|
|
func TestSweepStaleTerminalCheckouts_TmpProvisional_StillLive_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
const tmpID = "tmp-still-live-key"
|
|
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &provisionalCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
checkoutID: tmpID,
|
|
err: square.ErrCheckoutPending,
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
|
|
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(pool)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected a still-live provisional checkout left unresolved, got %d resolutions", n)
|
|
}
|
|
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query terminal checkout: %v", err)
|
|
}
|
|
if status != "PENDING" {
|
|
t.Errorf("expected a still-live provisional checkout left 'PENDING' (guard held), got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_CancelledBooking_NoPaymentRecorded locks the
|
|
// sweep's booking re-check (mirroring GetCheckoutStatus): a terminal checkout
|
|
// that COMPLETED at Square while the booking is we_cancelled must NOT be
|
|
// recorded as a completed payment — the cancellation refund path computes
|
|
// refunds from completed payments and would silently exclude this charge.
|
|
// Instead the checkout is marked failed, no payment row is created, and a
|
|
// critical-payment admin notification tells the owner a charge landed on a
|
|
// cancelled booking and a manual refund is required.
|
|
func TestSweepStaleTerminalCheckouts_CancelledBooking_NoPaymentRecorded(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
// The charge completes at Square AFTER the booking was cancelled.
|
|
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil {
|
|
t.Fatalf("failed to set booking we_cancelled: %v", err)
|
|
}
|
|
|
|
const checkoutID = "chk_completed_on_cancelled_booking"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
|
|
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
|
|
`, checkoutID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed stale terminal checkout: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &provisionalCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
checkoutID: checkoutID,
|
|
result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_cancelled_booking", Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242"},
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_cancelled_booking'`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkoutID); err != nil {
|
|
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(pool)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected the COMPLETED checkout on a cancelled booking resolved (to failed) by the sweep, got %d resolutions", n)
|
|
}
|
|
|
|
// The checkout must be marked failed (guard released, sweep does not retry
|
|
// forever), never COMPLETED and never left with a recorded payment.
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", checkoutID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query terminal checkout: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected the cancelled-booking checkout marked 'failed', got %q", status)
|
|
}
|
|
|
|
// No payment row may exist for the charge.
|
|
var payCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_cancelled_booking'`, bookingID).Scan(&payCount); err != nil {
|
|
t.Fatalf("failed to count recorded payments: %v", err)
|
|
}
|
|
if payCount != 0 {
|
|
t.Errorf("expected NO payment recorded on the cancelled booking, got %d rows", payCount)
|
|
}
|
|
|
|
// The owner must be told a charge landed on a cancelled booking.
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1 AND user_id IS NULL`, bookingID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount != 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the cancelled booking, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Bug fixes: C3 cancel-recheck clawback, M2 stranded-charge auto-refund, M5
|
|
// sweep-rescue VAT, M3 age-guard clock-source boundary, MINOR replay upper-bound
|
|
// skew
|
|
// =============================================================================
|
|
|
|
// terminalRecheckClient simulates the C3 "cancel then charge completes" race: a
|
|
// customer completes the payment at the terminal between the sweep's first
|
|
// GetCheckout (still live) and its post-cancel re-check (now COMPLETED). Every
|
|
// other call delegates to the real mock.
|
|
type terminalRecheckClient struct {
|
|
square.SquareClient
|
|
checkoutID string
|
|
calls int
|
|
}
|
|
|
|
func (c *terminalRecheckClient) 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_cancel_then_completed", Amount: 5000}, nil
|
|
}
|
|
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_CancelThenChargeCompletes_FundingKept locks
|
|
// the C3 money-safety end-state: the sweep cancels a stale till-sale checkout
|
|
// but the customer completes the payment in the cancel window. The charge
|
|
// landed, so the sweep must RECONCILE (record the sale completed, keep the
|
|
// funded gift card) — never claw the funding back. The pre-fix code clawed the
|
|
// funding back on any cancel-recheck that was not COMPLETED, taking the
|
|
// customer's card money AND the gift-card value (silent money-taken).
|
|
func TestSweepStaleTerminalCheckouts_CancelThenChargeCompletes_FundingKept(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_cancel_then_completes"
|
|
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 = &terminalRecheckClient{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 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 (completed during cancel), got %d", n)
|
|
}
|
|
|
|
// The sale must be RECONCILED to completed with the real charge recorded.
|
|
var status, sqPayID string
|
|
if err := db.Conn.QueryRow(pool, `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" || sqPayID != "sqp_cancel_then_completed" {
|
|
t.Errorf("expected the cancel-then-completed sale recorded completed with the payment id, got status=%q sq_pay_id=%q", status, sqPayID)
|
|
}
|
|
|
|
// 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 the funded gift card kept after the completed charge, got count=%d remaining=%.2f", cardCount, remaining)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_CancelledBooking_StrandedCharge_AutoRefundRow
|
|
// locks the M2 fix: a stale pending payment whose charge COMPLETED at Square on
|
|
// a booking that was cancelled during the pending window must not just be
|
|
// failed + admin-notified — an automatic cancellation refund row must be
|
|
// created for the full stranded charge so the pending-refund sweep issues the
|
|
// Square refund. The pre-fix code left the customer charged with NO automatic
|
|
// refund row.
|
|
func TestSweepStalePendingPayments_CancelledBooking_StrandedCharge_AutoRefundRow(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
// The booking was cancelled during the pending window.
|
|
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil {
|
|
t.Fatalf("failed to set booking we_cancelled: %v", err)
|
|
}
|
|
|
|
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', created_by = $1 WHERE id = $2", userID, staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
|
Amount: 200000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: "seed-stale-cancelled-booking",
|
|
})
|
|
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)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM refunds WHERE payment_id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, staleID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// The payment must be marked FAILED (never completed on a cancelled booking).
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query payment: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected the stranded charge's payment marked failed, got %q", status)
|
|
}
|
|
|
|
// M2: an automatic cancellation refund row must exist for the full charge.
|
|
var refundCount int
|
|
var refundAmount float64
|
|
var refundStatus, refundOrigin string
|
|
if err := db.Conn.QueryRow(pool, `
|
|
SELECT COUNT(*), COALESCE(MAX(amount), 0), COALESCE(MAX(status)::text, ''), COALESCE(MAX(origin), '')
|
|
FROM refunds WHERE payment_id = $1
|
|
`, staleID).Scan(&refundCount, &refundAmount, &refundStatus, &refundOrigin); err != nil {
|
|
t.Fatalf("failed to query refund row: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Fatalf("expected exactly 1 automatic refund row for the stranded charge, got %d", refundCount)
|
|
}
|
|
if refundStatus != "pending" || refundOrigin != "cancellation" {
|
|
t.Errorf("expected a pending origin='cancellation' refund row (picked up by the refund sweep), got status=%q origin=%q", refundStatus, refundOrigin)
|
|
}
|
|
if refundAmount != 2000.00 {
|
|
t.Errorf("expected the refund row to cover the full stranded charge £2000.00, got £%.2f", refundAmount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_TillRescued_VATApplied locks the M5 fix: a stale
|
|
// pending till sale whose charge COMPLETED at Square is rescued to 'completed'
|
|
// by the sweep, and the sweep applies the SAME VAT the synchronous
|
|
// till-completion path (GetTillCheckoutStatus → ApplyVATToTillSale) would. The
|
|
// pre-fix sweep rescued the sale with its VAT fields untouched (NULL), silently
|
|
// dropping the sale from VAT reporting.
|
|
func TestSweepStalePendingPayments_TillRescued_VATApplied(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)
|
|
}
|
|
// VAT-registered, SPV vouchers — the config the synchronous path uses.
|
|
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
|
|
t.Fatalf("failed to enable VAT in business_settings: %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-vat",
|
|
})
|
|
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)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
|
|
_, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
|
|
})
|
|
|
|
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 != "completed" {
|
|
t.Fatalf("expected genuinely-charged stale till sale rescued to 'completed', got %q", status)
|
|
}
|
|
|
|
// M5: the rescued sale must carry the SAME VAT fields the synchronous path
|
|
// computes — £50.00 at 20% → £8.33 VAT, £41.67 net.
|
|
var isVATApplicable bool
|
|
var vatAmount, netAmount, vatRate sql.NullFloat64
|
|
if err := db.Conn.QueryRow(pool, `SELECT is_vat_applicable, vat_amount, net_amount, vat_rate FROM till_sales WHERE id = $1`, saleID).Scan(&isVATApplicable, &vatAmount, &netAmount, &vatRate); err != nil {
|
|
t.Fatalf("failed to query till sale VAT fields: %v", err)
|
|
}
|
|
if !isVATApplicable {
|
|
t.Errorf("expected is_vat_applicable=TRUE on the sweep-rescued till sale, got false")
|
|
}
|
|
if !vatAmount.Valid || vatAmount.Float64 != 8.33 {
|
|
t.Errorf("expected vat_amount 8.33 on the sweep-rescued till sale, got %v", vatAmount)
|
|
}
|
|
if !netAmount.Valid || netAmount.Float64 != 41.67 {
|
|
t.Errorf("expected net_amount 41.67 on the sweep-rescued till sale, got %v", netAmount)
|
|
}
|
|
if !vatRate.Valid || vatRate.Float64 != 20.00 {
|
|
t.Errorf("expected vat_rate 20.00 on the sweep-rescued till sale, got %v", vatRate)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_AgeBoundary_23h_24h locks the M3 clock-source
|
|
// contract at the sweep's stale-age boundary: every age-guard cutoff in this
|
|
// package is computed from clock.Now() (never a DB NOW()-derived comparison),
|
|
// so a row aged just UNDER stalePendingPaymentAge (24h) stays pending and a row
|
|
// aged just OVER it is swept — with a comfortable margin so the two clocks
|
|
// (DB NOW() at write, clock.Now() at cutoff) can never flip the decision at the
|
|
// boundary.
|
|
func TestSweepStalePendingPayments_AgeBoundary_23h_24h(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)
|
|
}
|
|
|
|
youngID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create young pending payment: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours 50 minutes', square_payment_id = 'sqp_age_young' WHERE id = $1", youngID); err != nil {
|
|
t.Fatalf("failed to age the young payment: %v", err)
|
|
}
|
|
oldID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create old pending payment: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '24 hours 10 minutes', square_payment_id = 'sqp_age_old' WHERE id = $1", oldID); err != nil {
|
|
t.Fatalf("failed to age the old payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient()
|
|
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)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = ANY($1)`, []string{youngID, oldID})
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// Under the 24h cutoff: not stale, stays pending.
|
|
var youngStatus string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", youngID).Scan(&youngStatus); err != nil {
|
|
t.Fatalf("failed to query young payment: %v", err)
|
|
}
|
|
if youngStatus != "pending" {
|
|
t.Errorf("expected a 23h50m-old payment (under the 24h cutoff) to stay pending, got %q", youngStatus)
|
|
}
|
|
|
|
// Over the 24h cutoff: stale, swept to failed (Square has no such payment).
|
|
var oldStatus string
|
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", oldID).Scan(&oldStatus); err != nil {
|
|
t.Fatalf("failed to query old payment: %v", err)
|
|
}
|
|
if oldStatus != "failed" {
|
|
t.Errorf("expected a 24h10m-old payment (over the 24h cutoff) swept to failed, got %q", oldStatus)
|
|
}
|
|
}
|
|
|
|
// TestReplayWithinLegitimateWindow_UpperBoundSkew locks the MINOR fix: the
|
|
// upper-bound discriminator ("same charge vs the sweep's own replay-created new
|
|
// charge") tolerates a small clock skew — a replayed COMPLETED payment created
|
|
// within replayRescueUpperBoundSkew (5s) AFTER the sweep's replay instant is
|
|
// still a legit original/retry and must be rescued, while one created beyond
|
|
// the skew is a provably-new expired-key duplicate. Exercised directly because
|
|
// replayRevealsNewCharge is gated off in the dev/mock test env.
|
|
func TestReplayWithinLegitimateWindow_UpperBoundSkew(t *testing.T) {
|
|
rowCreated := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
|
|
replayAt := rowCreated.Add(22 * time.Hour)
|
|
base := staleRow{CreatedAt: rowCreated, AmountPence: 5000}
|
|
|
|
// A payment created just inside the 5s upper-bound skew AFTER the replay is
|
|
// a legit same-key retry whose Square timestamp reads slightly ahead of the
|
|
// app clock — must be rescued, never auto-refunded.
|
|
within := &square.PaymentResult{Status: "COMPLETED", Amount: 5000, CreatedAt: replayAt.Add(4 * time.Second).Format(time.RFC3339Nano)}
|
|
if !replayWithinLegitimateWindow(base, replayAt, within) {
|
|
t.Errorf("expected a payment created %s after the replay (within the %s skew) to be legitimate", replayRescueUpperBoundSkew, replayRescueUpperBoundSkew)
|
|
}
|
|
|
|
// A payment created just beyond the skew is the sweep's own replay-created
|
|
// expired-key duplicate — must NOT be rescued.
|
|
beyond := &square.PaymentResult{Status: "COMPLETED", Amount: 5000, CreatedAt: replayAt.Add(6 * time.Second).Format(time.RFC3339Nano)}
|
|
if replayWithinLegitimateWindow(base, replayAt, beyond) {
|
|
t.Errorf("expected a payment created 6s after the replay (beyond the %s skew) to be a NEW charge, got legitimate", replayRescueUpperBoundSkew)
|
|
}
|
|
|
|
// A payment created just BEFORE the replay is the original/retry — rescued.
|
|
before := &square.PaymentResult{Status: "COMPLETED", Amount: 5000, CreatedAt: replayAt.Add(-1 * time.Second).Format(time.RFC3339Nano)}
|
|
if !replayWithinLegitimateWindow(base, replayAt, before) {
|
|
t.Errorf("expected a payment created 1s before the replay to be legitimate")
|
|
}
|
|
|
|
// A DIFFERENT amount can never be the charge this row is waiting on.
|
|
wrongAmount := &square.PaymentResult{Status: "COMPLETED", Amount: 4999, CreatedAt: replayAt.Add(-1 * time.Second).Format(time.RFC3339Nano)}
|
|
if replayWithinLegitimateWindow(base, replayAt, wrongAmount) {
|
|
t.Errorf("expected a replayed payment with a different amount to be refused")
|
|
}
|
|
}
|
|
|
|
// TestInsertCriticalPaymentNotification_FloodCap_ConcurrentSerialized locks the
|
|
// F6 serialization of the flood cap: the count-then-insert critical section in
|
|
// insertCriticalPaymentNotification runs inside a transaction scoped by
|
|
// pg_advisory_xact_lock, so even when many inserts fire concurrently at the cap
|
|
// boundary the unacknowledged critical_payment_log queue never exceeds
|
|
// adminnotify.MaxUnacknowledgedCriticalLogs. Under READ COMMITTED a bare
|
|
// count-then-insert would let every concurrent insert evaluate the COUNT before
|
|
// any of them commits and overshoot by the concurrency.
|
|
func TestInsertCriticalPaymentNotification_FloodCap_ConcurrentSerialized(t *testing.T) {
|
|
pool := context.Background()
|
|
|
|
userID, err := fixtures.CreateTestUser(db.Conn)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
// One committed booking per notification so the per-issue NOT EXISTS dedup
|
|
// does not collapse distinct inserts (and the booking_id FK resolves — the
|
|
// helper's insert runs in its own pool transaction, so the rows must be
|
|
// committed first).
|
|
const concurrent = 6
|
|
total := adminnotify.MaxUnacknowledgedCriticalLogs - 1 + concurrent
|
|
var bookingIDs []string
|
|
for i := 0; i < total; i++ {
|
|
var bookingID string
|
|
if err := db.Conn.QueryRow(pool, `
|
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
|
VALUES ($1, NOW() + INTERVAL '1 day', 'pending', 'flood-cap serialization test')
|
|
RETURNING id
|
|
`, userID).Scan(&bookingID); err != nil {
|
|
t.Fatalf("failed to create booking %d: %v", i, err)
|
|
}
|
|
bookingIDs = append(bookingIDs, bookingID)
|
|
}
|
|
// This test runs in the sequential phase (it never calls t.Parallel), so no
|
|
// other test is active while it runs: clearing the whole queue at setup and
|
|
// in cleanup is safe and makes the cap-boundary math deterministic. Without
|
|
// the clear, a leftover notification from an earlier test would trip the
|
|
// helper's global pre-check and suppress every concurrent insert.
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`)
|
|
for _, b := range bookingIDs {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, b)
|
|
}
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
if _, err := db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`); err != nil {
|
|
t.Fatalf("failed to clear the critical notification queue: %v", err)
|
|
}
|
|
|
|
// Seed the queue to one below the cap, each with its own booking.
|
|
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs-1; i++ {
|
|
if _, err := db.Conn.Exec(pool, `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id, created_at)
|
|
VALUES ('critical_payment_log', $1, NULL, NOW())
|
|
`, bookingIDs[i]); err != nil {
|
|
t.Fatalf("failed to seed notification %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Fire `concurrent` inserts at the boundary simultaneously. With the
|
|
// advisory xact lock exactly ONE of them lands (the first to hold the lock
|
|
// sees the count still below the cap); every waiter's COUNT runs after the
|
|
// previous insert committed, so the rest see the cap reached and are
|
|
// suppressed.
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < concurrent; i++ {
|
|
b := bookingIDs[adminnotify.MaxUnacknowledgedCriticalLogs-1+i]
|
|
wg.Add(1)
|
|
go func(booking string) {
|
|
defer wg.Done()
|
|
<-start
|
|
insertCriticalPaymentNotification(pool, &booking, nil)
|
|
}(b)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
var totalUnacked int
|
|
if err := db.Conn.QueryRow(pool, `
|
|
SELECT COUNT(*) FROM admin_notifications
|
|
WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL
|
|
`).Scan(&totalUnacked); err != nil {
|
|
t.Fatalf("failed to count notifications: %v", err)
|
|
}
|
|
if totalUnacked != adminnotify.MaxUnacknowledgedCriticalLogs {
|
|
t.Errorf("expected the unacknowledged queue capped at exactly %d under concurrent inserts (serialized), got %d", adminnotify.MaxUnacknowledgedCriticalLogs, totalUnacked)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_KeyedTillLockContended_DefersClawback locks the
|
|
// FIX 1 sweep side: the keyed stale-pending pass must acquire the SAME
|
|
// advisory lock a same-key retry holds while its Square charge is mid-flight
|
|
// ("crussell:till:<idempotency_key>") BEFORE failing a till_sale / clawing back
|
|
// its funded gift card. When the retry holds the lock (simulated here), the
|
|
// sweep's "no payment under the key" probe is premature — the charge may still
|
|
// land — so the sweep must DEFER the row (leave it pending, funding intact)
|
|
// instead of failing + clawing back. Without the lock it would mark the sale
|
|
// failed and delete the funded card, and the retry's charge would then land on
|
|
// a row that no longer accepts it (0 rows → CRITICAL): customer charged AND
|
|
// funding clawed back.
|
|
func TestSweepStalePendingPayments_KeyedTillLockContended_DefersClawback(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 key = "key-lock-contended-retry"
|
|
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
|
|
// plus a chargeable source so the replay-by-key reconcile is valid under
|
|
// the mock's identical-body contract.
|
|
if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, 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 → the sweep would normally
|
|
// claw back. The retry holds the till-sale lock, so the sweep must defer.
|
|
origClient := SquareClient
|
|
SquareClient = square.NewDevClient()
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
// Simulate a same-key retry mid-flight at Square: hold the SAME advisory
|
|
// lock the retry path (CreateTillSale) pins across its Square round-trip.
|
|
retryConn, err := db.Conn.Acquire(pool)
|
|
if err != nil {
|
|
t.Fatalf("failed to acquire retry connection: %v", err)
|
|
}
|
|
defer retryConn.Release()
|
|
if _, err := retryConn.Exec(pool, `SELECT pg_advisory_lock(hashtext($1))`, "crussell:till:"+key); err != nil {
|
|
t.Fatalf("failed to hold the retry advisory lock: %v", err)
|
|
}
|
|
defer func() {
|
|
_, _ = retryConn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, "crussell:till:"+key)
|
|
}()
|
|
|
|
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)
|
|
}
|
|
|
|
start := time.Now()
|
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// The sweep must have DEFERRED the row (bounded try-lock contended for ~3s)
|
|
// rather than failing/clawing-back while the retry may still land its charge.
|
|
var status string
|
|
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected the lock-contended till sale left 'pending' (deferred), got %q", status)
|
|
}
|
|
|
|
// The funded gift card must NOT 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 != 1 {
|
|
t.Errorf("expected the funded gift card untouched while the till-sale lock is contended, got %d cards", cardCount)
|
|
}
|
|
|
|
if elapsed := time.Since(start); elapsed < 2*time.Second {
|
|
t.Errorf("expected the sweep to wait out the ~3s bounded try-lock before deferring, returned after %v", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_UntrackedTillSale_VATApplied locks the FIX 2
|
|
// gap: a stale card-machine till sale whose checkout COMPLETED at Square and
|
|
// was never polled is recorded by recordUntrackedTillSalePayment — and that
|
|
// rescue must apply VAT exactly like the synchronous till-completion path
|
|
// (GetTillCheckoutStatus) and the stale-pending rescue, or the rescued sale
|
|
// silently drops out of VAT reporting.
|
|
func TestSweepStaleTerminalCheckouts_UntrackedTillSale_VATApplied(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)
|
|
}
|
|
// VAT-registered, SPV vouchers — the config the synchronous path uses.
|
|
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
|
|
t.Fatalf("failed to enable VAT in business_settings: %v", err)
|
|
}
|
|
|
|
const checkoutID = "chk_untracked_vat_terminal"
|
|
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
|
|
`, checkoutID, adminID).Scan(&saleID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed stale terminal sale: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &completedTerminalClient{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)
|
|
}
|
|
|
|
pool := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
|
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
|
|
_, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
|
|
})
|
|
|
|
// 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 the COMPLETED till-sale checkout recorded by the sweep, got %d resolutions", 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 sale: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected a COMPLETED till-sale checkout's sale marked 'completed', got %q", status)
|
|
}
|
|
|
|
// FIX 2: the untracked-terminal rescue must carry the SAME VAT fields the
|
|
// synchronous path computes — £50.00 at 20% → £8.33 VAT, £41.67 net.
|
|
var isVATApplicable bool
|
|
var vatAmount, netAmount, vatRate sql.NullFloat64
|
|
if err := db.Conn.QueryRow(pool, `SELECT is_vat_applicable, vat_amount, net_amount, vat_rate FROM till_sales WHERE id = $1`, saleID).Scan(&isVATApplicable, &vatAmount, &netAmount, &vatRate); err != nil {
|
|
t.Fatalf("failed to query till sale VAT fields: %v", err)
|
|
}
|
|
if !isVATApplicable {
|
|
t.Errorf("expected is_vat_applicable=TRUE on the untracked-terminal-rescued till sale, got false")
|
|
}
|
|
if !vatAmount.Valid || vatAmount.Float64 != 8.33 {
|
|
t.Errorf("expected vat_amount 8.33 on the untracked-terminal-rescued till sale, got %v", vatAmount)
|
|
}
|
|
if !netAmount.Valid || netAmount.Float64 != 41.67 {
|
|
t.Errorf("expected net_amount 41.67 on the untracked-terminal-rescued till sale, got %v", netAmount)
|
|
}
|
|
if !vatRate.Valid || vatRate.Float64 != 20.00 {
|
|
t.Errorf("expected vat_rate 20.00 on the untracked-terminal-rescued till sale, got %v", vatRate)
|
|
}
|
|
}
|