//go:build test && dev package payments import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" ) // ============================================================================= // Unit tests — IsValidBookingStatusForPayment (pure function, no DB) // ============================================================================= func TestIsValidBookingStatusForPayment_Confirmed(t *testing.T) { t.Parallel() if !IsValidBookingStatusForPayment("confirmed") { t.Error("expected 'confirmed' to be valid for payment") } } func TestIsValidBookingStatusForPayment_Pending(t *testing.T) { t.Parallel() if !IsValidBookingStatusForPayment("pending") { t.Error("expected 'pending' to be valid for payment") } } func TestIsValidBookingStatusForPayment_PendingRelease(t *testing.T) { t.Parallel() if !IsValidBookingStatusForPayment("pending_release") { t.Error("expected 'pending_release' to be valid for payment") } } func TestIsValidBookingStatusForPayment_InProgress(t *testing.T) { t.Parallel() if !IsValidBookingStatusForPayment("in_progress") { t.Error("expected 'in_progress' to be valid for payment") } } func TestIsValidBookingStatusForPayment_RejectsDepositLapsed(t *testing.T) { t.Parallel() if IsValidBookingStatusForPayment("deposit_lapsed") { t.Error("expected 'deposit_lapsed' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsClientCancelled(t *testing.T) { t.Parallel() if IsValidBookingStatusForPayment("client_cancelled") { t.Error("expected 'client_cancelled' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsWeCancelled(t *testing.T) { t.Parallel() if IsValidBookingStatusForPayment("we_cancelled") { t.Error("expected 'we_cancelled' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsCompleted(t *testing.T) { t.Parallel() if IsValidBookingStatusForPayment("completed") { t.Error("expected 'completed' to be rejected for payment") } } func TestIsValidBookingStatusForPayment_RejectsNoShow(t *testing.T) { t.Parallel() if IsValidBookingStatusForPayment("no_show") { t.Error("expected 'no_show' to be rejected for payment") } } // ============================================================================= // Integration tests — CreateBookingPayment status guard // ============================================================================= // setupPaymentStatusTest creates a user, service, and booking with the given // status, returning the userID, bookingID, and user token. func setupPaymentStatusTest(t *testing.T, ctx context.Context, q db.Querier, status string) (string, string, string) { t.Helper() userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Use a far-future date so the booking is never in the cleanup window. bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = q.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", status, bookingID) if err != nil { t.Fatalf("failed to set booking status to %q: %v", status, err) } userToken := jwt.GenerateUserToken(userID) return userID, bookingID, userToken } func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "deposit-pending-release-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) } // Verify the booking was promoted back to confirmed. var status string err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "confirmed" { t.Errorf("expected booking promoted from pending_release to 'confirmed', got %q", status) } } func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") // Pay 15% (£7.50 on a £50 booking) — below 20% threshold. cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 750, // £7.50 = 15% of £50 PaymentType: "partial", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "below-threshold-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Payment below 20% threshold — booking should remain pending_release. var status string err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "pending_release" { t.Errorf("expected booking to remain pending_release, got %q", status) } } func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") // Pay £25 via "balance" type — still should meet the 20% threshold. cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, // £25.00 = 50% of £50 PaymentType: "balance", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "balance-promotes-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var status string err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "confirmed" { t.Errorf("expected booking promoted to 'confirmed', got %q", status) } } func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "deposit-confirmed-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateBookingPayment_RejectsPending(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "deposit-pending-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for pending booking (not yet confirmed), got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "deposit_lapsed") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "reject-lapsed-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "reject-cancelled-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Payment Lock Tests // ============================================================================= func paymentLockRequest(method, path string, token string, baseCtx ...context.Context) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+token) rctx := chi.NewRouteContext() bookingID, _ := extractPaymentIDFromPath(path) rctx.URLParams.Add("id", bookingID) ctx := context.Background() if len(baseCtx) > 0 { ctx = baseCtx[0] } ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) if token != "" { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } } AcquirePaymentLock(w, req.WithContext(ctx)) return w } func TestAcquirePaymentLock_Confirmed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_InProgress(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "in_progress") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200 for in_progress booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_PendingRelease(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsDepositLapsed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "deposit_lapsed") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsClientCancelled(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsWeCancelled(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "we_cancelled") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for we_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsNoShow(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "no_show") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for no_show booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsPending(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for pending booking, got %d. body: %s", w.Code, w.Body.String()) } } func TestAcquirePaymentLock_RejectsCompleted(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "completed") w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected 409 for completed booking, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // ReleasePaymentLock — DELETE /api/bookings/{id}/payment-lock // ============================================================================= func releasePaymentLockRequest(path string, token string, baseCtx ...context.Context) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest("DELETE", path, nil) req.Header.Set("Authorization", "Bearer "+token) rctx := chi.NewRouteContext() bookingID, _ := extractPaymentIDFromPath(path) rctx.URLParams.Add("id", bookingID) ctx := context.Background() if len(baseCtx) > 0 { ctx = baseCtx[0] } ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) if token != "" { if info := extractUserFromTestJWT(token); info != nil { ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) } } ReleasePaymentLock(w, req.WithContext(ctx)) return w } func TestReleasePaymentLock_HappyPath(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") // First, acquire the lock to create a PAYMENT_IN_FLIGHT time_blocker lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 when acquiring lock, got %d", lockW.Code) } // Verify the lock exists in the DB var lockCount int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if lockCount != 1 { t.Fatalf("expected 1 time_blocker before release, got %d", lockCount) } // Now release the lock w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content, got %d. body: %s", w.Code, w.Body.String()) } // Verify the lock was removed from the DB err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers after release: %v", err) } if lockCount != 0 { t.Errorf("expected 0 time_blockers after release, got %d", lockCount) } } func TestReleasePaymentLock_NoExistingLock(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") // Release without acquiring first — should be idempotent (204) w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content for idempotent release, got %d. body: %s", w.Code, w.Body.String()) } } func TestReleasePaymentLock_InvalidBookingID(t *testing.T) { w := releasePaymentLockRequest("/api/bookings/invalid/payment-lock", "") if w.Code != http.StatusNotFound { t.Errorf("expected 404 for invalid booking ID, got %d. body: %s", w.Code, w.Body.String()) } } func TestReleasePaymentLock_EmptyBookingID(t *testing.T) { w := releasePaymentLockRequest("/api/bookings//payment-lock", "") if w.Code != http.StatusNotFound { t.Errorf("expected 404 for empty booking ID, got %d. body: %s", w.Code, w.Body.String()) } } func TestReleasePaymentLock_AfterMultipleAcquires(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") // Acquire the lock twice — AcquirePaymentLock should be idempotent lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 on first acquire, got %d", lockW.Code) } lockW = paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 on second acquire, got %d", lockW.Code) } // Release should still succeed w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected 204 No Content after multiple acquires, got %d. body: %s", w.Code, w.Body.String()) } // Verify all locks are gone var lockCount int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if lockCount != 0 { t.Errorf("expected 0 time_blockers after release, got %d", lockCount) } } // ============================================================================= // GetCheckoutStatus — COMPLETED/Pending paths (via wrapper client) // ============================================================================= // testCheckoutClient wraps square.SquareClient to generate checkout IDs that // pass validators.IsValidID (12-char hex). The mock generates IDs like // "chk_mock_..." which fail that check, so we map valid hex IDs to mock IDs. type testCheckoutClient struct { square.SquareClient mu sync.Mutex hexIDs map[string]string idSeq int } func (c *testCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) { result, err := c.SquareClient.CreateCheckout(ctx, req) if err != nil { return nil, err } c.mu.Lock() c.idSeq++ hexID := fmt.Sprintf("%012x", c.idSeq) c.hexIDs[hexID] = result.ID c.mu.Unlock() result.ID = hexID return result, nil } func (c *testCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { c.mu.Lock() mockID, ok := c.hexIDs[checkoutID] c.mu.Unlock() if ok { checkoutID = mockID } return c.SquareClient.GetCheckout(ctx, checkoutID) } func TestGetCheckoutStatus_Pending(t *testing.T) { // NOTE: This test is skipped because the mock's goroutine completes instantly // when GO_TESTING=1 (mockSleep is a no-op). By the time we call // GetCheckoutStatus, the checkout is already COMPLETED. The PENDING state is // only observable with real Square (3s delay) or when mockSleep actually sleeps. // The underlying paths are exercised by TestGetCheckoutStatus_Completed and // the existing TestGetTillCheckoutStatus_Pending test. t.Skip("mock goroutine completes instantly in test mode; PENDING state not observable") } func TestGetCheckoutStatus_Completed(t *testing.T) { origClient := SquareClient SquareClient = &testCheckoutClient{ SquareClient: origClient, hexIDs: make(map[string]string), } defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var createResp CheckoutResponse if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil { t.Fatalf("failed to decode create response: %v", err) } if createResp.CheckoutID == "" { t.Fatal("expected checkout_id to be set") } // Poll for the mock goroutine to complete using assert.Eventually var resp PaymentStatusResponse assert.Eventually(t, func() bool { statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+createResp.CheckoutID+"/status?booking_id="+bookingID, nil) statusRCtx := chi.NewRouteContext() statusRCtx.URLParams.Add("checkout_id", createResp.CheckoutID) statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx) if info := extractUserFromTestJWT(adminToken); info != nil { statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID) statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role) } statusReq = statusReq.WithContext(statusCtx) w2 := httptest.NewRecorder() GetCheckoutStatus(w2, statusReq) if w2.Code != http.StatusOK { return false } if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil { return false } return resp.Status == "COMPLETED" }, 10*time.Second, 200*time.Millisecond, "expected checkout to complete") if resp.PaymentID == "" { t.Error("expected payment_id to be set") } if resp.CardBrand == "" { t.Error("expected card_brand to be set") } if resp.CardLast4 == "" { t.Error("expected card_last4 to be set") } } // pollCheckoutStatus polls GetCheckoutStatus until the checkout reports // COMPLETED, returning the decoded response. func pollCheckoutStatus(t *testing.T, ctx context.Context, checkoutID, bookingID, adminToken string) PaymentStatusResponse { t.Helper() var resp PaymentStatusResponse assert.Eventually(t, func() bool { statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil) statusRCtx := chi.NewRouteContext() statusRCtx.URLParams.Add("checkout_id", checkoutID) statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx) if info := extractUserFromTestJWT(adminToken); info != nil { statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID) statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role) } statusReq = statusReq.WithContext(statusCtx) w2 := httptest.NewRecorder() GetCheckoutStatus(w2, statusReq) if w2.Code != http.StatusOK { return false } if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil { return false } return resp.Status == "COMPLETED" }, 10*time.Second, 200*time.Millisecond, "expected checkout to complete") return resp } // createTerminalCheckout creates a terminal checkout via CreateTerminalPayment // and returns the checkout ID from the response. func createTerminalCheckout(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64) string { t.Helper() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: amount, PaymentType: "full", } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var createResp CheckoutResponse if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil { t.Fatalf("failed to decode create response: %v", err) } if createResp.CheckoutID == "" { t.Fatal("expected checkout_id to be set") } return createResp.CheckoutID } func TestGetCheckoutStatus_DoublePoll_SinglePaymentRow(t *testing.T) { // A double poll of the same terminal checkout must return the existing // payment row instead of inserting a duplicate (which previously 500'd on // the idempotency-key UNIQUE violation after the customer had paid). origClient := SquareClient SquareClient = &testCheckoutClient{ SquareClient: origClient, hexIDs: make(map[string]string), } defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000) first := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken) if first.PaymentID == "" { t.Fatal("expected payment_id from first poll") } // Second poll of the same checkout — deduped against the existing row. second := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken) if second.PaymentID == "" { t.Fatal("expected payment_id from second poll") } if second.PaymentID != first.PaymentID { t.Errorf("expected same payment_id on re-poll, got %q then %q", first.PaymentID, second.PaymentID) } var rowCount int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount) if err != nil { t.Fatalf("failed to count payment rows: %v", err) } if rowCount != 1 { t.Errorf("expected exactly 1 payment row after double poll, got %d", rowCount) } } func TestGetCheckoutStatus_TwoEqualAmountCharges_NoCollision(t *testing.T) { // Two distinct terminal charges on the same booking with the same final // amount must each create their own payment row (the deposit + equal-amount // balance case) — no 500 on the idempotency-key UNIQUE collision. origClient := SquareClient SquareClient = &testCheckoutClient{ SquareClient: origClient, hexIDs: make(map[string]string), } defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000) checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000) respA := pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken) respB := pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken) if respA.PaymentID == "" || respB.PaymentID == "" { t.Fatal("expected payment_ids for both checkouts") } if respA.PaymentID == respB.PaymentID { t.Error("expected two distinct payment rows for two distinct Square charges") } var rowCount int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount) if err != nil { t.Fatalf("failed to count payment rows: %v", err) } if rowCount != 2 { t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount) } } // mismatchedRefCheckoutClient forces GetCheckout to return a fixed COMPLETED // payment whose reference_id points at a DIFFERENT booking, deterministically // exercising GetCheckoutStatus's ownership check. type mismatchedRefCheckoutClient struct { square.SquareClient result *square.PaymentResult } func (c *mismatchedRefCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { return c.result, nil } func TestGetCheckoutStatus_EmptyReferenceID_Returns400(t *testing.T) { // A checkout with an EMPTY reference_id was created outside this app (no // booking was attached at creation time) — it must NOT be attachable to a // booking via polling. Fail closed with 400, exactly like a mismatched // reference, so a mis-scoped charge is never recorded. ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) origClient := SquareClient SquareClient = &mismatchedRefCheckoutClient{ SquareClient: square.NewDevClient(), result: &square.PaymentResult{ ID: "pay_empty_ref", Status: "COMPLETED", Amount: 5000, SquarePayID: "pay_empty_ref", ReferenceID: "", // created outside this app — no booking reference CreatedAt: "2026-07-31T00:00:00Z", UpdatedAt: "2026-07-31T00:00:00Z", }, } defer func() { SquareClient = origClient }() checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation req := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", checkoutID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") req = req.WithContext(reqCtx) w := httptest.NewRecorder() GetCheckoutStatus(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for empty reference_id, got %d: %s", w.Code, w.Body.String()) } // No payment may be recorded for the unreferenced checkout. var rowCount int if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount); err != nil { t.Fatalf("failed to count payment rows: %v", err) } if rowCount != 0 { t.Errorf("expected no payment rows after empty reference, got %d", rowCount) } } func TestGetCheckoutStatus_ReferenceIDMismatch_Returns400(t *testing.T) { // The terminal checkout's reference_id must match the booking being // polled; a checkout that references a different booking is refused with // 400 so its payment can never be recorded against the wrong booking. ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) origClient := SquareClient SquareClient = &mismatchedRefCheckoutClient{ SquareClient: square.NewDevClient(), result: &square.PaymentResult{ ID: "pay_mismatch", Status: "COMPLETED", Amount: 5000, SquarePayID: "pay_mismatch", ReferenceID: "00000000dead", // a DIFFERENT booking CreatedAt: "2026-07-31T00:00:00Z", UpdatedAt: "2026-07-31T00:00:00Z", }, } defer func() { SquareClient = origClient }() checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation req := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", checkoutID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") req = req.WithContext(reqCtx) w := httptest.NewRecorder() GetCheckoutStatus(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for reference_id mismatch, got %d: %s", w.Code, w.Body.String()) } // No payment may be recorded for the mismatched checkout. var rowCount int if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount); err != nil { t.Fatalf("failed to count payment rows: %v", err) } if rowCount != 0 { t.Errorf("expected no payment rows after reference mismatch, got %d", rowCount) } }