Addresses the payment review (all 10 blocking + 2 minor findings): Till money-integrity (CreateTillSale): - Add pg_advisory_lock on the idempotency key (concurrent same-key double-funding race) - Guard amount on pending-reuse retry (mirrors tip/gift-card guards) - Explicitly complete the row for cash/on_the_house pending-reuse - Reject method-switch on a live card-machine checkout (double-charge guard) - 3 regression tests (amount-mismatch, cash-completes-row, method-switch) BookingFlow: - Fetch saved cards at the deposit step (was dead code) - Charge the server-computed deposit_amount, not the client estimate HTTP client tests (was untested): doJSON error parsing, refund sentinel classification, payment/refund/card wire shapes, checkout polling states, list-refunds pagination + 20-page guard, sha256 card idempotency key Concurrency regression tests: real two-goroutine races for BuyGiftCard, tip, and booking-payment locks asserting exactly-one record each Frontend: - Fix CRIT-1: zero-saved-card users blocked (all flows now handle it) - Consolidate tip/deposit/Buy-Gift-Card card UI onto CardSelection - Explicit save-card consent checkbox (was silent/inconsistent) - Fix stale saved-card field names in BookingFlow (last4 -> last_4) - Unique instance ids (crypto.randomUUID) in CardSelection/SquareCardInput - UserPaymentModal: keep card form mounted on error + Try Again button Health/docs: /api/health reports square state (mock/ok, was not_implemented), close P1 backlog, correct stale webhook and env-var claims
311 lines
10 KiB
Go
311 lines
10 KiB
Go
//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"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
}
|