- M2: a stale pending payment COMPLETED at Square on a cancelled/lapsed/no-show booking no longer just fails the row + admin-notifies: an automatic pending refund row for the full stranded charge is created (same shape/origin as ProcessCancellationRefundTx, deterministic idempotency key, square_payment_id written when missing) so the pending-refund sweep issues it at Square. - M5: sweep rescues re-apply VAT — rescued till sales run ApplyVATToTillSale and rescued payments apply ApplyVATToBookingPayment per record after the align UPDATE (which no longer NULLs the VAT fields), keeping rescued charges in VAT reporting. Both SQL functions are idempotent (guarded on vat_amount IS NULL). - M3: every age-guard cutoff in the sweep is computed from clock.Now() and passed into SQL as parameters (never a DB NOW()-derived comparison) so the 23h/24h Square idempotency-key retention decision cannot flip on clock skew; replayRescueUpperBoundSkew (5s) stops a legit same-key retry that raced the sweep from being misclassified as the sweep's own replay-created duplicate. - C2: till cash/giftcard charges now serialize under the same crussell:payment:<bookingID> advisory lock as the online path (bounded try-lock) so remaining-balance checks can never both pass. - webhooks_completion_asymmetry_test: webhook-first completion + sweep rescue double-complete race locked end-to-end through the real handler.
335 lines
14 KiB
Go
335 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/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.
|
|
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 = NOW() - INTERVAL '21 hours 55 minutes', idempotency_key = 'key-boundary-inside', square_source_id = 'cnon:test-card' WHERE id = $1", 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 = NOW() - INTERVAL '22 hours 5 minutes', idempotency_key = 'key-boundary-outside', square_source_id = 'cnon:test-card' WHERE id = $1", 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, NOW() - INTERVAL '59 minutes')
|
|
`, inside.ID, bookingID); 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, NOW() - INTERVAL '61 minutes')
|
|
`, outside.ID, bookingID); 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', NOW() - INTERVAL '22 hours 55 minutes')
|
|
RETURNING id
|
|
`, insidePay, insideBooking, insidePay+"-square-2500").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', NOW() - INTERVAL '23 hours 5 minutes')
|
|
RETURNING id
|
|
`, outsidePay, outsideBooking, outsidePay+"-square-2500").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)
|
|
}
|
|
}
|