- 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.
4466 lines
188 KiB
Go
4466 lines
188 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' 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' 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)
|
|
}
|
|
}
|