Implement every finding from the deep payment review (P0-P2, minors, nitpicks), then close the post-implementation re-review items, then align card-form typography and roll out the Square trust badge. Backend - Square API alignment: - tip_settings.allow_tipping nested under device_options (was top-level: terminal tips were silently lost in prod) - CreateCardOnFile now accepts customerID and sends card.customer_id; saved-card (ccof:) charges forward square_customer_id as CustomerID - New SquareClient methods GetPayment, CreateCustomer, CancelCheckout - SCA verification_token accepted + forwarded in all charge paths - ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/ ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts emails, ForceRefundPending hook Backend - money safety: - sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id instead of stranding them forever - SweepStalePendingPayments reconciles at Square before failing (tri-state: leave pending on transport error, rescue completed, fail definitively) - GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution; SweepStaleTerminalCheckouts covers terminal_checkouts table - till gift-card clawback on definitive failure incl. retry path + INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT - cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id)) - customer provisioning (lazy, save-only); one-off/guest mint no customer - discount preview/apply unified in discounts.go (global-milestone visible in preview, N+1 eliminated, redemption counter preserved on failures) - webhook event_id dedup; refund loop dedup; stale comment fixes - test-isolation t.Cleanup on committed sweep tests Frontend: - SCA tokenizeWithVerification across all charge flows (amount as major-units decimal), 5-min token-expiry re-tokenize, verification_token in request bodies - PaymentModal synchronous double-click + zero/negative-amount guards - till online-card UI wired to /api/admin/till/sale - policyPopover generalised; new /privacy-policy route; consent checkbox copy + Square privacy link - Square card iframe styled to app typography (Inter 14px, oklch tokens); mock form md:text-sm parity - 'Secure payment powered by Square' badge on all 8 card-payment flows Schema/docs: terminal_checkouts + square_customer_id + per-user card constraint in init-script.sql; README migrations; P14 plan + backlog + Technical Manual updated. Includes 39 modified/new test files; full backend suite (25 pkgs), -race on payments+square, and frontend build are green.
566 lines
21 KiB
Go
566 lines
21 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"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)
|
|
}
|
|
|
|
// 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_LeavesCompletedAlone locks the conservative
|
|
// F4 rule: a checkout that has COMPLETED at Square is never cancelled — the
|
|
// poll handler records it; cancelling a completed checkout would orphan the
|
|
// charge.
|
|
func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(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()
|
|
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected a completed terminal checkout to be left alone, got %d cancellations", 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 != "pending" {
|
|
t.Errorf("expected completed terminal checkout's sale left 'pending' (poll handler records it), got %q", status)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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"}, 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() {
|
|
_, _ = 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)
|
|
}
|
|
}
|