//go:build test && dev package payments import ( "context" "net/http" "net/http/httptest" "sync" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" ) // slowCreatePaymentClient delays the Square charge so each handler holds its // advisory lock long enough that a concurrent same-key request would race it // without the lock (two goroutines both reading "no record", both charging). type slowCreatePaymentClient struct { square.SquareClient delay time.Duration } func (c *slowCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { time.Sleep(c.delay) return c.SquareClient.CreatePayment(ctx, req) } // cleanupConcurrentTestRows deletes the rows a concurrency test committed at // pool level. These tests must COMMIT their setup so the advisory locks work // across independent connections, which leaves committed rows in the shared // test DB — without cleanup they leak into parallel tests (e.g. GetGiftCards // counts gift_cards/user_giftcard_balances globally). FK-safe deletion order. func cleanupConcurrentTestRows(t *testing.T, pool context.Context, userID, bookingID string) { t.Helper() t.Cleanup(func() { var gcIDs []string rows, err := db.Conn.Query(pool, `SELECT id FROM gift_cards WHERE created_by = $1`, userID) if err == nil { for rows.Next() { var id string if rows.Scan(&id) == nil { gcIDs = append(gcIDs, id) } } rows.Close() } for _, gcID := range gcIDs { _, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, gcID) } _, _ = db.Conn.Exec(pool, `DELETE FROM user_giftcard_balances WHERE user_id = $1`, userID) if bookingID != "" { _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) } _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE created_by = $1 OR idempotency_key LIKE 'concurrent-%'`, userID) for _, gcID := range gcIDs { _, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, gcID) } _, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) } // TestBuyGiftCard_ConcurrentSameKey_SingleRecord proves the BuyGiftCard // advisory lock (giftcards.go): two goroutines POSTing the same idempotency key // must produce exactly ONE payment record and ONE funded gift card — never // 2× value for 1 charge. Without the lock, both goroutines pass the // idempotency check, both reuse/insert pending records, and both fund the card. func TestBuyGiftCard_ConcurrentSameKey_SingleRecord(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } token := jwt.GenerateUserToken(userID) cleanupConcurrentTestRows(t, context.Background(), userID, "") // Commit the setup so both goroutines operate at pool level — the advisory // locks only serialize across independent connections, and a per-test tx // would route both sides through a single shared connection. innerTx := db.TxFromContext(ctx) if innerTx == nil { t.Fatal("no transaction in context") } if err := innerTx.Commit(ctx); err != nil { t.Fatalf("failed to commit setup tx: %v", err) } origClient := SquareClient slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond} SquareClient = slow defer func() { SquareClient = origClient }() pool := context.Background() key := "buy-gc-concurrent-same-key" reqBody := map[string]interface{}{ "amount": 2000, "recipient_type": "self", "new_card_token": "cnon:concurrent-card", "idempotency_key": key, } var wg sync.WaitGroup startBoth := make(chan struct{}) recs := make([]*httptest.ResponseRecorder, 2) for i := 0; i < 2; i++ { wg.Add(1) go func(idx int) { defer wg.Done() <-startBoth recs[idx] = makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", reqBody, token, pool) }(i) } close(startBoth) wg.Wait() // Both requests must succeed — the lock serializes them and the second // finds the completed record (idempotent dedup, HTTP 200), so neither // double-charges nor errors. for i, rec := range recs { if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { t.Errorf("request %d expected 201 (create) or 200 (dedup), got %d. body: %s", i, rec.Code, rec.Body.String()) } } // Exactly one payment record for this key. var payCount int err = db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE idempotency_key = $1`, key).Scan(&payCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if payCount != 1 { t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount) } // Exactly one funded gift card for this user's self-purchase. var gcCount int err = db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 20.00 AND created_by = $1`, userID).Scan(&gcCount) if err != nil { t.Fatalf("failed to count gift cards: %v", err) } if gcCount != 1 { t.Errorf("expected exactly 1 funded gift card, got %d (2× value!)", gcCount) } // User balance credited exactly once. var balance float64 err = db.Conn.QueryRow(pool, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance) if err != nil { t.Fatalf("failed to query balance: %v", err) } if balance != 20.00 { t.Errorf("expected balance 20.00, got %.2f (double-credit!)", balance) } } // TestTipPayment_ConcurrentSameKey_SingleRecord proves the tip advisory lock // (handlers.go): two goroutines POSTing the same booking with the same key must // produce exactly ONE tip payment record — never two charges for one booking. func TestTipPayment_ConcurrentSameKey_SingleRecord(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) } start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start) if err != nil { t.Fatalf("failed to create booking: %v", err) } if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil { t.Fatalf("failed to create prior payment: %v", err) } token := jwt.GenerateUserToken(userID) cleanupConcurrentTestRows(t, context.Background(), userID, bookingID) innerTx := db.TxFromContext(ctx) if innerTx == nil { t.Fatal("no transaction in context") } if err := innerTx.Commit(ctx); err != nil { t.Fatalf("failed to commit setup tx: %v", err) } origClient := SquareClient slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond} SquareClient = slow defer func() { SquareClient = origClient }() pool := context.Background() key := "tip-concurrent-same-key" cardToken := "cnon:concurrent-tip-card" reqBody := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, IdempotencyKey: key, } var wg sync.WaitGroup startBoth := make(chan struct{}) recs := make([]*httptest.ResponseRecorder, 2) for i := 0; i < 2; i++ { wg.Add(1) go func(idx int) { defer wg.Done() <-startBoth recs[idx] = makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", reqBody, token, pool) }(i) } close(startBoth) wg.Wait() for i, rec := range recs { if rec.Code != http.StatusOK { t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String()) } } var tipCount int err = db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND idempotency_key = $2`, bookingID, key).Scan(&tipCount) if err != nil { t.Fatalf("failed to count tip payments: %v", err) } if tipCount != 1 { t.Errorf("expected exactly 1 tip payment record, got %d (double-charge!)", tipCount) } } // TestBookingPayment_ConcurrentSameKey_SingleRecord proves the booking-payment // advisory lock (handlers.go): two goroutines paying the same booking with the // same key must produce exactly ONE payment record. func TestBookingPayment_ConcurrentSameKey_SingleRecord(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) token := jwt.GenerateUserToken(userID) cleanupConcurrentTestRows(t, context.Background(), userID, bookingID) innerTx := db.TxFromContext(ctx) if innerTx == nil { t.Fatal("no transaction in context") } if err := innerTx.Commit(ctx); err != nil { t.Fatalf("failed to commit setup tx: %v", err) } origClient := SquareClient slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond} SquareClient = slow defer func() { SquareClient = origClient }() pool := context.Background() key := "booking-pay-concurrent-same-key" cardToken := "cnon:concurrent-booking-card" reqBody := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: key, } var wg sync.WaitGroup startBoth := make(chan struct{}) recs := make([]*httptest.ResponseRecorder, 2) for i := 0; i < 2; i++ { wg.Add(1) go func(idx int) { defer wg.Done() <-startBoth recs[idx] = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", reqBody, token, pool) }(i) } close(startBoth) wg.Wait() for i, rec := range recs { if rec.Code != http.StatusOK { t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String()) } } var payCount int err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&payCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if payCount != 1 { t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount) } } // completedCheckoutClient forces GetCheckout to return a fixed COMPLETED // payment for any checkout id, deterministically exercising the terminal // completion dedup+insert path (the mock's real async goroutine would be // non-deterministic in a race test). type completedCheckoutClient struct { square.SquareClient result *square.PaymentResult } func (c *completedCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { return c.result, nil } // TestGetCheckoutStatus_ConcurrentPolls_SingleRecord proves the P3 fix: the // terminal-completion path serializes on a per-payment advisory lock, so two // concurrent polls of the same completed checkout produce exactly ONE payment // record. Without the lock, both goroutines pass the dedup SELECT, both INSERT, // and the second dies on the idempotency_key UNIQUE constraint after the // customer already paid. func TestGetCheckoutStatus_ConcurrentPolls_SingleRecord(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) } start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start) if err != nil { t.Fatalf("failed to create booking: %v", err) } cleanupConcurrentTestRows(t, context.Background(), userID, bookingID) innerTx := db.TxFromContext(ctx) if innerTx == nil { t.Fatal("no transaction in context") } if err := innerTx.Commit(ctx); err != nil { t.Fatalf("failed to commit setup tx: %v", err) } origClient := SquareClient SquareClient = &completedCheckoutClient{ SquareClient: square.NewDevClient(), result: &square.PaymentResult{ ID: "pay_terminal_race", Status: "COMPLETED", Amount: 5000, Fees: 88, SquarePayID: "pay_terminal_race", CardBrand: "VISA", CardLast4: "4242", ReceiptURL: "https://receipt.example/pay_terminal_race", EntryMethod: "EMV", LocationID: "loc", ReferenceID: bookingID, CreatedAt: "2026-07-31T00:00:00Z", UpdatedAt: "2026-07-31T00:00:00Z", }, } defer func() { SquareClient = origClient }() pool := context.Background() checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation var wg sync.WaitGroup startBoth := make(chan struct{}) recs := make([]*httptest.ResponseRecorder, 2) for i := 0; i < 2; i++ { wg.Add(1) go func(idx int) { defer wg.Done() <-startBoth req := httptest.NewRequest("GET", "/api/checkout/"+checkoutID+"/status?booking_id="+bookingID, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", checkoutID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetCheckoutStatus(w, req) recs[idx] = w }(i) } close(startBoth) wg.Wait() for i, rec := range recs { if rec.Code != http.StatusOK { t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String()) } } // Exactly one completed terminal payment record for this booking. var payCount int err = db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card' AND square_payment_id = $2`, bookingID, "pay_terminal_race").Scan(&payCount) if err != nil { t.Fatalf("failed to count terminal payments: %v", err) } if payCount != 1 { t.Errorf("expected exactly 1 terminal payment record, got %d (double-record race!)", payCount) } }