//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) } }