- 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)
336 lines
14 KiB
Go
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)
|
|
}
|
|
}
|