Files
Crussell/backend/handlers/payments/sweep_boundary_test.go
T
popertotsandSisyphus 985b114c8b test: payments round-2 — webhook gate/M2 refund, gift-card cancel re-issue, till lock contention, sweep VAT rescue coverage
- 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)
2026-08-22 00:34:51 +01:00

336 lines
14 KiB
Go

//go:build test && dev
package payments
// M14 sweep boundary tests. The three sweeps select rows with a strict
// `created_at < cutoff` comparison against cutoffs computed from clock.Now()
// (the single wall-clock source — the M3 contract). These tests pin the
// boundary behaviour with rows aged just INSIDE the window (left pending /
// untouched) and just OUTSIDE it (swept), for every cutoff the earlier
// reviews left without an explicit epsilon boundary test:
//
// - stalePendingKeyedAge (22h) — the keyed lost-response pass;
// - staleTerminalCheckoutAge (1h) — the terminal checkout sweep;
// - stalePendingRefundAge (23h) — the pending Square refund sweep.
//
// The 24h stalePendingPaymentAge boundary is already locked by
// TestSweepStalePendingPayments_AgeBoundary_23h_24h in sweep_test.go. All
// margin epsilons are comfortably above any Go-clock/DB-clock skew on the test
// host so the strict < comparison can never flip on clock drift. These tests
// are sequential (no t.Parallel): they swap the package-global SquareClient
// and mutate the shared test pool, exactly like the other sweep tests.
import (
"context"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// TestSweepStalePendingPayments_KeyedCutoffBoundary locks the 22h keyed-pass
// boundary: a keyed pending row aged just INSIDE stalePendingKeyedAge (22h)
// is NOT stale for the keyed pass AND not yet past the 24h stale-pending
// cutoff, so it stays pending; a row aged just OUTSIDE the 22h cutoff (but
// still inside Square's ~24h retention window) is picked up by the keyed
// pass, replayed, and — with a spent cnon source and no payment under the key
// at Square — definitively failed.
func TestSweepStalePendingPayments_KeyedCutoffBoundary(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)
}
// INSIDE the 22h keyed cutoff: 21h55m old → created_at >= keyedCutoff →
// the keyed pass must NOT fetch it, and it is under the 24h stale cutoff
// too → stays pending. Seeded from clock.Now(), the sweep's cutoff source.
insideID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create inside payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = $1, idempotency_key = 'key-boundary-inside', square_source_id = 'cnon:test-card' WHERE id = $2", clock.Now().Add(-21*time.Hour-55*time.Minute), insideID); err != nil {
t.Fatalf("failed to age the inside payment: %v", err)
}
// OUTSIDE the 22h keyed cutoff: 22h05m old → past the keyed cutoff, still
// inside Square's ~24h retention window → the keyed pass replays and fails
// it (spent cnon, no payment under the key).
outsideID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create outside payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = $1, idempotency_key = 'key-boundary-outside', square_source_id = 'cnon:test-card' WHERE id = $2", clock.Now().Add(-22*time.Hour-5*time.Minute), outsideID); err != nil {
t.Fatalf("failed to age the outside payment: %v", err)
}
origClient := SquareClient
// A fresh mock has no payment under either key → ReplayPaymentByKey
// returns ErrReplayKeyNotRetained; with a spent-cnon source that is
// definitive proof of no charge → the outside row is failed.
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 payments WHERE id = ANY($1)`, []string{insideID, outsideID})
_, _ = 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)
}
// Just INSIDE the 22h keyed cutoff: not stale → stays pending.
var insideStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", insideID).Scan(&insideStatus); err != nil {
t.Fatalf("failed to query inside payment: %v", err)
}
if insideStatus != "pending" {
t.Errorf("expected a keyed row 21h55m old (inside the 22h cutoff) to stay pending, got %q", insideStatus)
}
// Just OUTSIDE the 22h keyed cutoff: the keyed pass swept it to failed.
var outsideStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", outsideID).Scan(&outsideStatus); err != nil {
t.Fatalf("failed to query outside payment: %v", err)
}
if outsideStatus != "failed" {
t.Errorf("expected a keyed row 22h05m old (outside the 22h cutoff) swept to failed, got %q", outsideStatus)
}
}
// TestSweepStaleTerminalCheckouts_AgeCutoffBoundary locks the 1h terminal
// sweep boundary: a PENDING terminal checkout aged just INSIDE
// staleTerminalCheckoutAge stays live (the sweep must not touch it), while a
// checkout aged just OUTSIDE it is cancelled and its terminal_checkouts row
// marked failed.
func TestSweepStaleTerminalCheckouts_AgeCutoffBoundary(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.HoldCheckouts = true
inside, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "chk-boundary-inside",
})
if err != nil {
t.Fatalf("failed to create inside checkout: %v", err)
}
outside, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "chk-boundary-outside",
})
if err != nil {
t.Fatalf("failed to create outside checkout: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
// INSIDE: 59m old → not past the 1h cutoff → must be left alone.
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, $3)
`, inside.ID, bookingID, clock.Now().Add(-59*time.Minute)); err != nil {
t.Fatalf("failed to seed inside terminal checkout: %v", err)
}
// OUTSIDE: 61m old → past the 1h cutoff → cancelled + marked failed.
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, $3)
`, outside.ID, bookingID, clock.Now().Add(-61*time.Minute)); err != nil {
t.Fatalf("failed to seed outside terminal checkout: %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 terminal_checkouts WHERE checkout_id IN ($1, $2)`, inside.ID, outside.ID)
_, _ = 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 parallel 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 NOT IN ($1, $2)`, inside.ID, outside.ID); 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 exactly 1 resolved terminal checkout (only the 61m-old one), got %d", n)
}
var insideStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", inside.ID).Scan(&insideStatus); err != nil {
t.Fatalf("failed to query inside checkout: %v", err)
}
if insideStatus != "PENDING" {
t.Errorf("expected a 59m-old checkout (inside the 1h cutoff) left PENDING, got %q", insideStatus)
}
var outsideStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", outside.ID).Scan(&outsideStatus); err != nil {
t.Fatalf("failed to query outside checkout: %v", err)
}
if outsideStatus != "failed" {
t.Errorf("expected a 61m-old checkout (outside the 1h cutoff) marked failed, got %q", outsideStatus)
}
}
// TestSweepPendingSquareRefunds_AgeGuardBoundary locks the 23h refund-sweep
// boundary: a pending cancellation refund aged just INSIDE stalePendingRefundAge
// is re-issued at Square (money still provably unmoved under a retained key),
// while a refund aged just OUTSIDE it is reconciled first — and with no
// COMPLETED refund at Square, marked failed + admin-notified (never re-issued
// into a double-refund).
func TestSweepPendingSquareRefunds_AgeGuardBoundary(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)
}
insideBooking, 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 inside booking: %v", err)
}
outsideBooking, 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 outside booking: %v", err)
}
// Two completed online payments with Square charge ids — one per refund.
insidePay, err := fixtures.CreateTestPayment(tx, insideBooking, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create inside payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_refund_boundary_inside' WHERE id = $1", insidePay); err != nil {
t.Fatalf("failed to set inside square_payment_id: %v", err)
}
outsidePay, err := fixtures.CreateTestPayment(tx, outsideBooking, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create outside payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_refund_boundary_outside' WHERE id = $1", outsidePay); err != nil {
t.Fatalf("failed to set outside square_payment_id: %v", err)
}
// INSIDE: 22h55m old → not yet past the 23h guard → the sweep issues the
// Square refund (money provably unmoved under a retained key).
var insideRefundID string
if err := tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', $4)
RETURNING id
`, insidePay, insideBooking, insidePay+"-square-2500", clock.Now().Add(-22*time.Hour-55*time.Minute)).Scan(&insideRefundID); err != nil {
t.Fatalf("failed to insert inside refund: %v", err)
}
// OUTSIDE: 23h05m old → past the 23h guard → reconciled first; no
// COMPLETED refund at Square → marked failed.
var outsideRefundID string
if err := tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', $4)
RETURNING id
`, outsidePay, outsideBooking, outsidePay+"-square-2500", clock.Now().Add(-23*time.Hour-5*time.Minute)).Scan(&outsideRefundID); err != nil {
t.Fatalf("failed to insert outside refund: %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)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM refunds WHERE id IN ($1, $2)`, insideRefundID, outsideRefundID)
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id IN ($1, $2)`, insidePay, outsidePay)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id IN ($1, $2)`, insideBooking, outsideBooking)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := SweepPendingSquareRefunds(pool); err != nil {
t.Fatalf("refund sweep failed: %v", err)
}
// INSIDE the 23h guard: the refund is re-issued and completed at Square.
var insideStatus string
var insideSqRefundID *string
if err := db.Conn.QueryRow(pool, "SELECT status, square_refund_id FROM refunds WHERE id = $1", insideRefundID).Scan(&insideStatus, &insideSqRefundID); err != nil {
t.Fatalf("failed to query inside refund: %v", err)
}
if insideStatus != "completed" {
t.Errorf("expected a refund 22h55m old (inside the 23h guard) re-issued to 'completed', got %q", insideStatus)
}
if insideSqRefundID == nil || *insideSqRefundID == "" {
t.Error("expected the inside refund to carry a square_refund_id (Square was called)")
}
// OUTSIDE the 23h guard: reconciled, no COMPLETED refund → failed.
var outsideStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM refunds WHERE id = $1", outsideRefundID).Scan(&outsideStatus); err != nil {
t.Fatalf("failed to query outside refund: %v", err)
}
if outsideStatus != "failed" {
t.Errorf("expected a refund 23h05m old (outside the 23h guard) reconciled to 'failed', got %q", outsideStatus)
}
}