//go:build test && dev package payments import ( "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "math" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder { return makePaymentAuthRequest(handler, method, path, body, token, "", ctx) } // adminRequestCtx wraps a request context with the admin role so handlers that // run a defense-in-depth isAdminRequest check (S-1) work when called directly // (bypassing the mw.RequireAdmin middleware that normally injects the role). func adminRequestCtx(r *http.Request) *http.Request { reqCtx := context.WithValue(r.Context(), mw.UserRoleKey, "admin") reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001") return r.WithContext(reqCtx) } func TestValidateCardInfo(t *testing.T) { empty := "" cardID := "card_123" token := "cnon:test" tests := []struct { name string cardID *string newToken *string wantError bool }{ {"both set rejected", &cardID, &token, true}, {"card_id only ok", &cardID, nil, false}, {"token only ok", nil, &token, false}, {"neither set rejected", nil, nil, true}, {"empty card_id rejected", &empty, nil, true}, {"empty token rejected", nil, &empty, true}, {"both empty rejected", &empty, &empty, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateCardInfo(tt.cardID, tt.newToken) if tt.wantError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rctx := chi.NewRouteContext() if id, paramName := extractPaymentIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) var userID, userRole string if userIDOverride != "" { userID = userIDOverride userRole = "verified_email" } else if token != "" { if info := extractUserFromTestJWT(token); info != nil { userID = info.userID userRole = info.role } } if userID != "" { ctx = context.WithValue(ctx, mw.UserIDKey, userID) ctx = context.WithValue(ctx, mw.UserRoleKey, userRole) } req = req.WithContext(ctx) w := httptest.NewRecorder() handler(w, req) return w } type paymentUserInfo struct { userID string role string } func extractUserFromTestJWT(token string) *paymentUserInfo { parts := splitToken(token) if len(parts) != 3 { return nil } decoded, err := base64URLDecode(parts[1]) if err != nil { return nil } var claims map[string]interface{} if err := json.Unmarshal(decoded, &claims); err != nil { return nil } userID, _ := claims["user_id"].(string) role, _ := claims["role"].(string) if userID == "" { return nil } return &paymentUserInfo{userID: userID, role: role} } func splitToken(token string) []string { var result []string var current []byte for _, c := range token { if c == '.' { result = append(result, string(current)) current = nil } else { current = append(current, byte(c)) } } if len(current) > 0 { result = append(result, string(current)) } return result } func base64URLDecode(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } func extractPaymentIDFromPath(path string) (string, string) { patterns := []struct { prefix string paramName string }{ {"/api/admin/payments/", "payment_id"}, {"/api/admin/bookings/", "id"}, {"/api/bookings/", "id"}, {"/api/user/payment-methods/", "id"}, } for _, p := range patterns { if idx := findPaymentLastSegment(path, p.prefix); idx >= 0 { endIdx := len(path) for i := idx; i < len(path); i++ { if path[i] == '/' { endIdx = i break } } return path[idx:endIdx], p.paramName } } return "", "" } func findPaymentLastSegment(path, prefix string) int { for i := len(path) - 1; i >= len(prefix); i-- { if len(path) > i && path[i-len(prefix):i] == prefix { return i } } return -1 } func parsePaymentResponseBody(w *httptest.ResponseRecorder, dest interface{}) error { return json.Unmarshal(w.Body.Bytes(), dest) } func TestTerminalPayment_HappyPath(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", TipEnabled: true, } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp CheckoutResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.CheckoutID == "" { t.Error("expected checkout ID to be set") } if resp.Status != "PENDING" { t.Errorf("expected status PENDING, got %s", resp.Status) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } if count != 0 { t.Errorf("expected 0 payments (created on completion), got %d", count) } } func setupTestData(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) { return setupTestDataAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) } // setupTestDataPast creates a booking with start_time in the past (1 hour ago) // to prevent payment-split logic from triggering. Used by tests that verify // payment sequencing or idempotency rather than deposit allocation. func setupTestDataPast(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) { return setupTestDataAtTime(t, ctx, q, clock.Now().Add(-1*time.Hour)) } func setupTestDataAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string, string) { 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) } bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = q.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } return userID, bookingID, serviceID } func TestTerminalPayment_PriceOverride(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() overrideAmount := int64(3000) req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", OverrideAmount: &overrideAmount, TipEnabled: false, } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp CheckoutResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } } func TestTerminalPayment_BookingNotInProgress(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } adminToken := jwt.GenerateAdminToken() req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } _ = serviceID } func TestTerminalPayment_BookingNotFound(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) adminToken := jwt.GenerateAdminToken() req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } func TestOnlinePayment_NewCard_Deposit(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "deposit-key-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.ID == "" { t.Error("expected payment ID to be set") } if resp.Status != "completed" { t.Errorf("expected status completed, got %s", resp.Status) } if resp.Amount != 2500 { t.Errorf("expected amount 2500, got %d", resp.Amount) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } if count != 1 { t.Errorf("expected 1 payment, got %d", count) } } func TestOnlinePayment_SavedCard(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") if err != nil { t.Fatalf("failed to create payment method: %v", err) } userToken := jwt.GenerateUserToken(userID) req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", CardID: &cardID, IdempotencyKey: "saved-card-key-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.Status != "completed" { t.Errorf("expected status completed, got %s", resp.Status) } } func TestOnlinePayment_BookingNotOwned(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } userToken := jwt.GenerateUserToken(otherUserID) cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "not-owned-key-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetUserPaymentMethods_HasCards(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_1", "VISA", "1111") if err != nil { t.Fatalf("failed to create payment method 1: %v", err) } _, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_2", "MASTERCARD", "2222") if err != nil { t.Fatalf("failed to create payment method 2: %v", err) } userToken := jwt.GenerateUserToken(userID) handler := GetUserPaymentMethods w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var cards []SavedCard if err := parsePaymentResponseBody(w, &cards); err != nil { t.Errorf("failed to parse response: %v", err) } if len(cards) != 2 { t.Errorf("expected 2 cards, got %d", len(cards)) } } func TestDeletePaymentMethod(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_delete", "VISA", "9999") if err != nil { t.Fatalf("failed to create payment method: %v", err) } userToken := jwt.GenerateUserToken(userID) handler := DeletePaymentMethod w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp map[string]string if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp["status"] != "deleted" { t.Errorf("expected status deleted, got %s", resp["status"]) } } func TestRefund_FullRefund(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_123" _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } req := RefundRequest{ Amount: 5000, Reason: "customer request", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp RefundResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.Amount != 5000 { t.Errorf("expected amount 5000, got %d", resp.Amount) } if resp.Status != "completed" { t.Errorf("expected status completed, got %s", resp.Status) } } func TestRefund_SameKeyRetry_Dedups(t *testing.T) { // A retry of the same refund (network timeout, double-click) must not // create a second Square refund or a second DB row. Retry dedup runs on // the CLIENT-supplied idempotency key — a UUID generated per refund // attempt and REUSED on retry (hashed+truncated into the stored key), so // the same key dedups. A NO-key refund now carries a fresh random fallback // key per attempt and never dedups against a prior no-key refund — that is // deliberate (see TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide). t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_dedup' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } req := RefundRequest{ Amount: 5000, Reason: "customer request", IdempotencyKey: "refund-same-key-retry-uuid", } handler := RefundPayment // First refund — completes. w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } // Same-key retry (identical client key, amount, reason) — must dedup, not // double-refund. w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w2.Code != http.StatusOK { t.Fatalf("retry refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } // Exactly one refund row for this payment. var refundCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) } if refundCount != 1 { t.Errorf("expected 1 refund row (deduped), got %d", refundCount) } } // TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide verifies the P2 // security fix for the DEFAULT (no client key) path: the fallback idempotency // key is now unique per refund attempt, so two DISTINCT partial refunds of the // SAME amount against the SAME payment each get their own key and both // complete. With the old amount-derived default key (paymentID + "-refund-" + // amount) the second would collide with the first and be silently swallowed by // the dedup lookup. func TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_no_key_equal_refunds' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } handler := RefundPayment // Two distinct £20 partial refunds of the same £50 payment, NO client // idempotency key on either — the exact case that collided on the // amount-derived default key before the fix. refund1 := RefundRequest{Amount: 2000, Reason: "partial one"} w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } refund2 := RefundRequest{Amount: 2000, Reason: "partial two"} w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund2, adminToken, ctx) if w2.Code != http.StatusOK { t.Fatalf("second equal partial refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } // Both refunds must exist as separate completed rows with DIFFERENT // fallback keys — the second was NOT swallowed by the first's dedup lookup. var completedCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&completedCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) } if completedCount != 2 { t.Errorf("expected 2 completed refund rows, got %d (second was swallowed!)", completedCount) } var distinctKeys int err = tx.QueryRow(ctx, `SELECT COUNT(DISTINCT idempotency_key) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&distinctKeys) if err != nil { t.Fatalf("failed to count distinct refund keys: %v", err) } if distinctKeys != 2 { t.Errorf("expected 2 DISTINCT fallback keys for the two same-amount refunds, got %d", distinctKeys) } } // TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates verifies the P2 fix: // two DISTINCT partial refunds of the same amount against the same payment // must both complete. With only the amount-derived key (paymentID-amount) the // second would collide with the first and be silently swallowed as a dedup. // A client-supplied idempotency key per attempt disambiguates them, while a // same-key retry still dedups. func TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_equal_refunds' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } handler := RefundPayment // Two distinct £20 partial refunds of the same £50 payment — the exact // case that collided on the amount-derived key before the fix. refund1 := RefundRequest{Amount: 2000, Reason: "partial one", IdempotencyKey: "refund-uuid-1"} w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } refund2 := RefundRequest{Amount: 2000, Reason: "partial two", IdempotencyKey: "refund-uuid-2"} w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund2, adminToken, ctx) if w2.Code != http.StatusOK { t.Fatalf("second equal partial refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } // Both refunds must exist as separate completed rows — the second was NOT // swallowed by the first's dedup lookup. var completedCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&completedCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) } if completedCount != 2 { t.Errorf("expected 2 completed refund rows, got %d (second was swallowed!)", completedCount) } // Same-key retry of refund1 must STILL dedup (retry semantics preserved). w3 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx) if w3.Code != http.StatusOK { t.Fatalf("retry of first refund: expected 200, got %d. body: %s", w3.Code, w3.Body.String()) } var totalCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&totalCount) if err != nil { t.Fatalf("failed to query refunds after retry: %v", err) } if totalCount != 2 { t.Errorf("expected still 2 completed refund rows after same-key retry, got %d", totalCount) } } // TestRefund_PendingResume_NewKeyAfterModalReopen verifies the P2 pending-resume // regression fix: when a refund attempt left a PENDING row (Square call failed // ambiguously) and the admin reopens the modal — generating a NEW idempotency // key — the retry must RESUME the pending row with its stored key, not create // a second pending row. Without the fallback, the sweep would process both // pending rows and move twice the intended money. func TestRefund_PendingResume_NewKeyAfterModalReopen(t *testing.T) { // NOT t.Parallel: it swaps the package-level SquareClient (the counting // client below), and a concurrent parallel test reading SquareClient would // observe the swapped instance mid-test. ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } // Seed the pending row from the first (failed) attempt. Its stored key is // the ORIGINAL attempt's UUID; the retry below uses a different one. origKey := paymentID + "-refund-uuid-A" var pendingRefundID string err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at) VALUES ($1, $2, 20, 'pending', 'customer request', $3, 'manual', NOW()) RETURNING id `, paymentID, bookingID, origKey).Scan(&pendingRefundID) if err != nil { t.Fatalf("failed to seed pending refund: %v", err) } // Swap in a counting client to verify Square is called with the STORED key. origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting defer func() { SquareClient = origClient }() handler := RefundPayment // Retry with a fresh key (modal reopened) — must resume, not duplicate. newKeyReq := RefundRequest{Amount: 2000, Reason: "customer request", IdempotencyKey: "refund-uuid-B"} w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", newKeyReq, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("retry with new key: expected 200, got %d. body: %s", w.Code, w.Body.String()) } // Still exactly ONE refund row for the payment — no second pending row. var refundCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount) if err != nil { t.Fatalf("failed to count refunds: %v", err) } if refundCount != 1 { t.Errorf("expected exactly 1 refund row (resumed, not duplicated), got %d — double-refund path!", refundCount) } // The single row was resumed to completed. var status string err = tx.QueryRow(ctx, `SELECT status FROM refunds WHERE id = $1`, pendingRefundID).Scan(&status) if err != nil { t.Fatalf("failed to query refund status: %v", err) } if status != "completed" { t.Errorf("expected the pending refund to be resumed to completed, got %q", status) } // Square was called with the STORED key (uuid-A), never the new key — so // Square's key dedup returns the original refund instead of issuing a second. calls := counting.refundCalls() if len(calls) != 1 { t.Fatalf("expected exactly 1 Square refund call, got %d", len(calls)) } if calls[0].IdempotencyKey != origKey { t.Errorf("expected Square call to use the stored key %q, got %q (fresh key would double-refund)", origKey, calls[0].IdempotencyKey) } } // TestRefund_PendingResume_DifferentAmountRejected verifies the P3 hardening: // when a pending refund exists for the payment but the retry is for a DIFFERENT // amount (admin changed it after reopening the modal), the request is rejected // with 409 instead of creating a second pending row — which the sweep would // otherwise process alongside the first, moving more money than intended. func TestRefund_PendingResume_DifferentAmountRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_diff_amount' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } // Seed a pending £20 refund (first attempt failed ambiguously). origKey := paymentID + "-refund-2000" _, err = tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at) VALUES ($1, $2, 20, 'pending', 'customer request', $3, 'manual', NOW()) `, paymentID, bookingID, origKey) if err != nil { t.Fatalf("failed to seed pending refund: %v", err) } handler := RefundPayment // Retry with a DIFFERENT amount (£30) and a fresh key — must be rejected // with 409, never creating a second pending row. req := RefundRequest{Amount: 3000, Reason: "customer request", IdempotencyKey: "refund-uuid-B-diff"} w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusConflict { t.Fatalf("different-amount retry while pending: expected 409, got %d. body: %s", w.Code, w.Body.String()) } // Still exactly ONE pending refund row — no second row was created. var pendingCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'pending'`, paymentID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to count pending refunds: %v", err) } if pendingCount != 1 { t.Errorf("expected exactly 1 pending refund row, got %d (second pending row would double-refund)", pendingCount) } } func TestRefund_PartialRefund(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_456" _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } req := RefundRequest{ Amount: 2500, Reason: "partial refund", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp RefundResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.Amount != 2500 { t.Errorf("expected amount 2500, got %d", resp.Amount) } } func TestRefund_OverRefundRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_789" _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } req := RefundRequest{ Amount: 6000, Reason: "over refund attempt", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestRefund_PaymentNotFound(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) adminToken := jwt.GenerateAdminToken() req := RefundRequest{ Amount: 1000, Reason: "test", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } func TestRefund_PendingPaymentRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "pending") if err != nil { t.Fatalf("failed to create payment: %v", err) } req := RefundRequest{ Amount: 5000, Reason: "test", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestRefund_PendingSameKeyRetry_Resumes(t *testing.T) { // A retry of an in-flight refund (DB row pending, Square call never // completed) must resume rather than insert a second row or issue a second // Square refund. The handler retries Square with the same idempotency key // and completes the pending row. t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } // Seed a pending refund with the key the handler derives from the client // key below (sha256-hashed + truncated to 24 hex chars). The retry sends // the SAME client key, so the exact-key dedup resumes the row. clientKey := "refund-pending-resume-uuid" ikHash := sha256.Sum256([]byte(clientKey)) key := paymentID + "-refund-" + fmt.Sprintf("%x", ikHash)[:24] _, err = tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_at) VALUES ($1, $2, 25, 'pending', 'customer request', $3, NOW()) `, paymentID, bookingID, key) if err != nil { t.Fatalf("failed to seed pending refund: %v", err) } req := RefundRequest{ Amount: 2500, Reason: "customer request", IdempotencyKey: clientKey, } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // The pending row must now be completed with a Square refund ID — and no // second refund row inserted. var refundCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) } if refundCount != 1 { t.Errorf("expected 1 refund row (resumed, not duplicated), got %d", refundCount) } var status string var squareRefundID *string err = tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE idempotency_key = $1`, key).Scan(&status, &squareRefundID) if err != nil { t.Fatalf("failed to query refund: %v", err) } if status != "completed" { t.Errorf("expected status completed, got %q", status) } if squareRefundID == nil || *squareRefundID == "" { t.Error("expected square_refund_id to be set after resume") } } func TestRefund_GuardCountsPendingRefunds(t *testing.T) { // A pending refund (in-flight Square call) blocks any further manual refund // of the payment: same amount resumes it, a different amount is rejected // with 409 (in-flight guard) — so the over-refund guard can never be // bypassed by piling a second pending row on top of an unresolved one. t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_guard_pending' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to update payment: %v", err) } // Seed a pending refund of £70 (as if a previous attempt's Square call is in flight). _, err = tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at) VALUES ($1, $2, 70, 'pending', 'in flight', NOW()) `, paymentID, bookingID) if err != nil { t.Fatalf("failed to seed pending refund: %v", err) } // A further £40 refund would total £110 > £100. The in-flight guard rejects // it with 409 before the over-refund guard is even reached — the pending // row's money state is unknown, so no new refund is issued. req := RefundRequest{ Amount: 4000, Reason: "over refund attempt", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusConflict { t.Errorf("expected status 409 (in-flight guard), got %d. body: %s", w.Code, w.Body.String()) } } func TestRefund_PaymentAlreadyRefunded_MarksCompleted(t *testing.T) { // Square reports PAYMENT_ALREADY_REFUNDED — the money has already moved. // The refund row must resolve to 'completed' (NOT 'failed', which would let // the over-refund guard re-issue money on top of it), square_refund_id // stays NULL, and the API returns 200. ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_already_refunded' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to set square_payment_id: %v", err) } origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) mock.FailRefundCode = "PAYMENT_ALREADY_REFUNDED" SquareClient = mock defer func() { SquareClient = origClient }() req := RefundRequest{ Amount: 5000, Reason: "customer request", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var resp RefundResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Status != "completed" { t.Errorf("expected response status 'completed', got %q", resp.Status) } var status string var squareRefundID *string err = tx.QueryRow(ctx, "SELECT status, square_refund_id FROM refunds WHERE payment_id = $1", paymentID).Scan(&status, &squareRefundID) if err != nil { t.Fatalf("failed to query refund: %v", err) } if status != "completed" { t.Errorf("expected refund status 'completed', got %q", status) } if squareRefundID != nil && *squareRefundID != "" { t.Errorf("expected square_refund_id NULL (no new refund issued), got %q", *squareRefundID) } } func TestTipPayment_HappyPath(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:tip-card" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.PaymentType != "tip" { t.Errorf("expected payment type tip, got %s", resp.PaymentType) } if resp.Amount != 500 { t.Errorf("expected amount 500, got %d", resp.Amount) } } func TestTipPayment_NoPriorPayment(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:tip-card" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:idempotent-card" idempotencyKey := "idempotent-same-key" req1 := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: idempotencyKey, } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) } var resp1 PaymentResponse if err := parsePaymentResponseBody(w1, &resp1); err != nil { t.Errorf("failed to parse first response: %v", err) } req2 := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: idempotencyKey, } w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var resp2 PaymentResponse if err := parsePaymentResponseBody(w2, &resp2); err != nil { t.Errorf("failed to parse second response: %v", err) } if resp1.ID != resp2.ID { t.Errorf("expected same payment ID, got %s and %s", resp1.ID, resp2.ID) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } if count != 1 { t.Errorf("expected 1 payment (idempotent), got %d", count) } } func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:different-key-card" req1 := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "key-1", } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) } req2 := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "key-2", } w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) // The second request is blocked because only one "full" payment is // allowed per booking (the payment-type duplicate guard prevents the // two-tab double-payment race even when idempotency keys differ). if w2.Code != http.StatusConflict { t.Errorf("second request expected status 409, got %d. body: %s", w2.Code, w2.Body.String()) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } if count != 1 { t.Errorf("expected 1 payment (second was blocked), got %d", count) } } // ============================================================================= // Payment-type duplicate guard — serialization lock prevents double payments // ============================================================================= func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") // First: a deposit payment should succeed. cardToken := "cnon:diff-type-card" depositReq := CreateBookingPaymentRequest{ Amount: 2000, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "diff-type-deposit-" + bookingID, } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", depositReq, userToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("deposit payment expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } // Second: a balance payment uses a different payment_type — should also succeed. balanceReq := CreateBookingPaymentRequest{ Amount: 3000, PaymentType: "balance", NewCardToken: &cardToken, IdempotencyKey: "diff-type-balance-" + bookingID, } w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", balanceReq, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("balance payment expected 200 (different type allowed), got %d. body: %s", w2.Code, w2.Body.String()) } // Verify at least one payment of each type exists. buildSplitRecords may // create extra records (e.g. a 'balance' portion alongside 'deposit'), so // we check DISTINCT types rather than a raw row count. var distinctTypes []string rows, err := tx.Query(ctx, "SELECT DISTINCT payment_type FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY payment_type", bookingID) if err != nil { t.Fatalf("failed to query payments: %v", err) } defer rows.Close() for rows.Next() { var pt string if err := rows.Scan(&pt); err == nil { distinctTypes = append(distinctTypes, pt) } } if len(distinctTypes) < 2 { t.Errorf("expected at least 2 distinct payment types, got %d: %v", len(distinctTypes), distinctTypes) } } func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") cardToken := "cnon:dup-type-card" // First 'full' payment succeeds. req1 := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "dup-type-first-" + bookingID, } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first payment expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } // Second 'full' payment with a different idempotency key should be blocked // by the payment-type duplicate guard. req2 := CreateBookingPaymentRequest{ Amount: 2000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "dup-type-second-" + bookingID, } w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusConflict { t.Errorf("duplicate 'full' payment expected 409, got %d. body: %s", w2.Code, w2.Body.String()) } // Verify only one real payment was created. buildSplitRecords converts the // first 'full' payment into 'deposit' + 'balance', so we count deposit records // rather than 'full' — the exact guard above confirmed the 409 rejection. var depositCount int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'deposit' AND payment_method NOT IN ('discount', 'on_the_house')", bookingID).Scan(&depositCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if depositCount != 1 { t.Errorf("expected 1 deposit record (split from first 'full' payment), got %d", depositCount) } } func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") cardToken := "cnon:partial-card" // First partial payment. req1 := CreateBookingPaymentRequest{ Amount: 1000, PaymentType: "partial", NewCardToken: &cardToken, IdempotencyKey: "partial-first-" + bookingID, } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first partial expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } // Second partial payment (different key, same type) — allowed because // the duplicate guard explicitly exempts 'partial'. req2 := CreateBookingPaymentRequest{ Amount: 1500, PaymentType: "partial", NewCardToken: &cardToken, IdempotencyKey: "partial-second-" + bookingID, } w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("second partial expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } // Count all real payments (buildSplitRecords converts partials to deposit // when within the 50% deposit cap). Both should have been created. var total int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')", bookingID).Scan(&total) if err != nil { t.Fatalf("failed to count payments: %v", err) } if total != 2 { t.Errorf("expected 2 payments (both created), got %d", total) } } // TestBookingPayment_NoClientKey_RefundThenRepaySameAmount_CreatesNewCharge is // the money-safety regression for the deterministic no-client-key fallback key // (finding a-i): pay £50, refund it, then pay £50 again with NO client // idempotency key. The second payment MUST be a NEW charge — the dedup lookup // must not return the refunded (but still status='completed') payment as // success, which would silently swallow the second payment while the booking // shows paid with no money collected. The deterministic derivation rotates the // key past the refunded payment's spent slot instead. func TestBookingPayment_NoClientKey_RefundThenRepaySameAmount_CreatesNewCharge(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // A past booking keeps the payment unsplit (single record) and 'partial' // is the repeatable payment type, so the same-type duplicate guard does // not interfere with the re-pay. A SECOND £50 service is linked so the // booking total is £100: the first £50 payment then does not auto-complete // the booking, and a £50 re-pay after the refund stays within the // remaining balance (refunds re-open booking capacity). userID, bookingID, _ := setupTestDataPast(t, ctx, tx) secondServiceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, bookingID, secondServiceID) require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") handler := CreateBookingPayment cardToken := "cnon:refund-repay-card" // NO IdempotencyKey — exercises the deterministic booking+type+amount+card fallback. req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "partial", NewCardToken: &cardToken, } // 1. Pay £50 — completes. w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w1.Code, "first payment: %s", w1.Body.String()) var resp1 PaymentResponse require.NoError(t, parsePaymentResponseBody(w1, &resp1)) // 2. Refund the full £50 via the admin refund handler (the dev mock // completes the Square refund synchronously). refundReq := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "refund-repay-" + bookingID} wRefund := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+resp1.ID+"/refund", refundReq, adminToken, ctx) require.Equal(t, http.StatusOK, wRefund.Code, "refund: %s", wRefund.Body.String()) // 3. Pay £50 again — same no-key derivation. Must be a NEW charge, not a // dedup to the refunded first payment. w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w2.Code, "repay: %s", w2.Body.String()) var resp2 PaymentResponse require.NoError(t, parsePaymentResponseBody(w2, &resp2)) require.NotEqual(t, resp1.ID, resp2.ID, "refund-then-repay must create a NEW payment, not return the refunded payment as success") // Exactly two completed real payments with distinct idempotency keys and // distinct Square charges. var payCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')`, bookingID).Scan(&payCount)) require.Equal(t, 2, payCount, "refund-then-repay must record two distinct payments") var keys, sqIDs []string rows, err := tx.Query(ctx, `SELECT idempotency_key, square_payment_id FROM payments WHERE booking_id = $1 AND status = 'completed' ORDER BY created_at ASC`, bookingID) require.NoError(t, err) for rows.Next() { var k, s string require.NoError(t, rows.Scan(&k, &s)) keys = append(keys, k) sqIDs = append(sqIDs, s) } rows.Close() require.Equal(t, 2, len(keys)) require.NotEqual(t, keys[0], keys[1], "the two payments must use distinct idempotency keys") require.NotEqual(t, sqIDs[0], sqIDs[1], "the second payment must be a new Square charge, not a dedup of the refunded one") } // TestBookingPayment_NoClientKey_TwoEqualPartials_DoNotCollapse is the // money-safety regression for the deterministic no-client-key fallback key // (finding a-ii): two genuine equal-amount partial payments on the same card // must both be recorded. The deterministic key derivation includes a sequence // that advances past the first completed equal partial, so the second derives // a DISTINCT key instead of silently collapsing onto the first. func TestBookingPayment_NoClientKey_TwoEqualPartials_DoNotCollapse(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) handler := CreateBookingPayment cardToken := "cnon:equal-partial-card" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "partial", NewCardToken: &cardToken, } for i := 0; i < 2; i++ { w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "partial %d: %s", i, w.Body.String()) } var keys []string rows, err := tx.Query(ctx, `SELECT idempotency_key FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC`, bookingID) require.NoError(t, err) for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) keys = append(keys, k) } rows.Close() require.Equal(t, 2, len(keys), "two genuine equal-amount partials must both be recorded") require.NotEqual(t, keys[0], keys[1], "equal-amount partials must not collapse onto one idempotency key") } // TestBookingPayment_NoClientKey_SameDepositTwice_StillDedups pins the // double-charge protection that MUST survive the sequence fix: paying the same // 50% deposit twice on an UN-refunded booking with no client key must dedup to // the existing completed payment — never a second Square charge. The // deterministic derivation advances its sequence only past refunded/partial // slots; an un-refunded 'deposit' slot keeps its key so the dedup lookup // returns the original payment. func TestBookingPayment_NoClientKey_SameDepositTwice_StillDedups(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) handler := CreateBookingPayment cardToken := "cnon:same-deposit-card" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, } w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w1.Code, "first deposit: %s", w1.Body.String()) var resp1 PaymentResponse require.NoError(t, parsePaymentResponseBody(w1, &resp1)) w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w2.Code, "second deposit: %s", w2.Body.String()) var resp2 PaymentResponse require.NoError(t, parsePaymentResponseBody(w2, &resp2)) require.Equal(t, resp1.ID, resp2.ID, "the same 50%% deposit charged twice on an un-refunded booking must DEDUP, not double-charge") var count int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')`, bookingID).Scan(&count)) require.Equal(t, 1, count, "only ONE deposit payment may be recorded") } // ============================================================ // User Booking Payment Tests — deposit, full, partial, balance // ============================================================ func setupDepositBooking(t *testing.T, ctx context.Context, q db.Querier) (string, string) { return setupDepositBookingAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) } // setupDepositBookingPast creates a confirmed booking with start_time in the past // (1 hour ago). This prevents the payment-split logic from triggering, which is // useful for tests that verify payment sequencing rather than deposit splitting. func setupDepositBookingPast(t *testing.T, ctx context.Context, q db.Querier) (string, string) { return setupDepositBookingAtTime(t, ctx, q, clock.Now().Add(-1*time.Hour)) } func setupDepositBookingAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string) { 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) } bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = q.Exec(ctx, "UPDATE bookings SET status = 'confirmed', deposit_required = TRUE WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking: %v", err) } return userID, bookingID } func TestBookingPayment_Deposit_HappyPath(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:deposit-card" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "deposit-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.PaymentType != "deposit" { t.Errorf("expected payment type deposit, got %s", resp.PaymentType) } if resp.Amount != 2500 { t.Errorf("expected amount 2500, got %d", resp.Amount) } if resp.Status != "completed" { t.Errorf("expected status completed, got %s", resp.Status) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } if count != 1 { t.Errorf("expected 1 deposit payment, got %d", count) } } func TestBookingPayment_FullPayment(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:full-card" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "full-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.PaymentType != "full" { t.Errorf("expected payment type full, got %s", resp.PaymentType) } if resp.Amount != 5000 { t.Errorf("expected amount 5000, got %d", resp.Amount) } } func TestBookingPayment_PartialPayment(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:partial-card" req := CreateBookingPaymentRequest{ Amount: 1500, PaymentType: "partial", NewCardToken: &cardToken, IdempotencyKey: "partial-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.PaymentType != "partial" { t.Errorf("expected payment type partial, got %s", resp.PaymentType) } if resp.Amount != 1500 { t.Errorf("expected amount 1500, got %d", resp.Amount) } } func TestBookingPayment_BalancePayment(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:balance-card" req := CreateBookingPaymentRequest{ Amount: 3500, PaymentType: "balance", NewCardToken: &cardToken, IdempotencyKey: "balance-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if resp.PaymentType != "balance" { t.Errorf("expected payment type balance, got %s", resp.PaymentType) } } // --------------------------------------------------------------------------- // Payment-split tests — verify that a single Square charge is recorded as // multiple payment rows when paid before the booking start time, and that // both records share the same square_payment_id. // --------------------------------------------------------------------------- func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) { // A full payment of £50 on a £50 booking (future-dated) should be split: // record 1: payment_type='deposit', amount=25.00 // record 2: payment_type='balance', amount=25.00 t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:split-full-card" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "split-full-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Should be exactly 2 payment records. Order by payment_type (the enum // defines 'deposit' before 'balance', so the deposit row is first) plus // created_at as a tiebreaker — ORDER BY amount was nondeterministic here // because both split amounts are equal (£25.00), which flaked ~1-in-10 // runs asserting records[0] is the deposit. rows, err := tx.Query(ctx, `SELECT payment_type, amount, square_payment_id FROM payments WHERE booking_id = $1 ORDER BY payment_type, created_at ASC`, bookingID) if err != nil { t.Fatalf("failed to query payments: %v", err) } defer rows.Close() var records []struct { ptype string amount float64 squarePaymentID *string } for rows.Next() { var r struct { ptype string amount float64 squarePaymentID *string } if err := rows.Scan(&r.ptype, &r.amount, &r.squarePaymentID); err != nil { t.Fatalf("failed to scan row: %v", err) } records = append(records, r) } if len(records) != 2 { t.Fatalf("expected 2 split records, got %d", len(records)) } // First record should be the deposit portion (larger or equal — deposit is 25, balance is 25). if records[0].ptype != "deposit" { t.Errorf("expected first record to be 'deposit', got %q", records[0].ptype) } // Second record should be balance. if records[1].ptype != "balance" { t.Errorf("expected second record to be 'balance', got %q", records[1].ptype) } // Both records must share the same square_payment_id. if records[0].squarePaymentID == nil || records[1].squarePaymentID == nil { t.Error("both records should have a square_payment_id") } else if *records[0].squarePaymentID != *records[1].squarePaymentID { t.Errorf("expected same square_payment_id, got %q and %q", *records[0].squarePaymentID, *records[1].squarePaymentID) } } func TestBookingPayment_FullPayment_PastBooking_DoesNotSplit(t *testing.T) { // A full payment on a PAST booking should NOT split (single record). t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBookingPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:nosplit-card" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "nosplit-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Fatalf("failed to query payments: %v", err) } if count != 1 { t.Errorf("expected 1 payment (no-split), got %d", count) } } func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) { // Verify that when the split-record insert fails, the entire group rolls // back atomically. We simulate a failure by causing the second INSERT to // violate a NOT NULL constraint (passing an invalid record). t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID := setupDepositBooking(t, ctx, tx) // Use a nil idempotency key on the split record — this works fine for both. // Instead we rely on the fact that the handler wraps both inserts in a // single transaction: if either fails, neither survives. // // Because we can't easily inject a DB error through the handler, we verify // the architecture at the service level instead: innerTx, err := db.Conn.Begin(ctx) if err != nil { t.Fatalf("failed to begin tx: %v", err) } defer innerTx.Rollback(ctx) svc := NewPaymentService() now := clock.Now() // First record — valid. pid1, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{ BookingID: bookingID, PaymentType: "deposit", PaymentMethod: "cash", Status: "completed", Amount: 25.00, CreatedAt: now, UpdatedAt: now, }, nil) if err != nil { t.Fatalf("failed to create first payment record: %v", err) } if pid1 == "" { t.Fatal("expected non-empty payment id") } // Second record — also valid. pid2, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{ BookingID: bookingID, PaymentType: "balance", PaymentMethod: "cash", Status: "completed", Amount: 25.00, CreatedAt: now, UpdatedAt: now, }, nil) if err != nil { t.Fatalf("failed to create second payment record: %v", err) } if pid2 == "" { t.Fatal("expected non-empty payment id") } if err := innerTx.Commit(ctx); err != nil { t.Fatalf("failed to commit tx: %v", err) } // Both records should exist. var count int tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1 OR id = $2", pid1, pid2).Scan(&count) if count != 2 { t.Errorf("expected 2 committed records, got %d", count) } // Now test rollback: start a new inner tx, insert, then rollback. tx2, err := db.Conn.Begin(ctx) if err != nil { t.Fatalf("failed to begin tx2: %v", err) } pid3, err := svc.CreatePaymentRecordTx(ctx, tx2, PaymentRecord{ BookingID: bookingID, PaymentType: "deposit", PaymentMethod: "cash", Status: "completed", Amount: 10.00, CreatedAt: now, UpdatedAt: now, }, nil) if err != nil { t.Fatalf("failed to create rolled-back record: %v", err) } tx2.Rollback(ctx) // Rolled-back record should NOT exist. var rollbackCount int tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", pid3).Scan(&rollbackCount) if rollbackCount != 0 { t.Errorf("expected 0 records after rollback, got %d", rollbackCount) } } // --------------------------------------------------------------------------- // buildSplitRecords unit tests — pure function, no DB needed. // --------------------------------------------------------------------------- func makeTestRecord(bookingID, ptype string, amount float64) PaymentRecord { now := clock.Now() key := "test-key" return PaymentRecord{ BookingID: bookingID, PaymentType: ptype, PaymentMethod: "online_square", Status: "completed", Amount: amount, SquarePaymentID: strPtr("sq_test"), IdempotencyKey: &key, Fees: 1.50, CreatedAt: now, UpdatedAt: now, } } func strPtr(s string) *string { return &s } func TestBuildSplitRecords_FutureBooking_FullPayment_Splits(t *testing.T) { // £50 payment on a £50 future booking → splits into deposit £25 + balance £25 record := makeTestRecord("b1", "full", 50) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 50, TotalPaid: 0, } records := buildSplitRecords(record, "full", info, 50) if len(records) != 2 { t.Fatalf("expected 2 records, got %d", len(records)) } if records[0].PaymentType != "deposit" { t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType) } if records[0].Amount != 25 { t.Errorf("expected first record amount 25, got %.2f", records[0].Amount) } if records[1].PaymentType != "balance" { t.Errorf("expected second record 'balance', got %q", records[1].PaymentType) } if records[1].Amount != 25 { t.Errorf("expected second record amount 25, got %.2f", records[1].Amount) } // Both share the same SquarePaymentID. if *records[0].SquarePaymentID != *records[1].SquarePaymentID { t.Error("split records must share square_payment_id") } // Split record has separate idempotency key. if *records[1].IdempotencyKey != *records[0].IdempotencyKey+"-split-1" { t.Errorf("split key should be derived, got %q", *records[1].IdempotencyKey) } // Split record has zero fees (all on primary). if records[1].Fees != 0 { t.Errorf("expected split fees=0, got %.2f", records[1].Fees) } } func TestBuildSplitRecords_PastBooking_NoSplit(t *testing.T) { // Same amount on a PAST booking → single record record := makeTestRecord("b2", "full", 50) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(-2 * time.Hour), TotalAmount: 50, TotalPaid: 0, } records := buildSplitRecords(record, "full", info, 50) if len(records) != 1 { t.Fatalf("expected 1 record (no split), got %d", len(records)) } if records[0].PaymentType != "full" { t.Errorf("expected 'full', got %q", records[0].PaymentType) } } func TestBuildSplitRecords_DepositWithinCap_NoSplit(t *testing.T) { // £20 deposit on a £50 total (40% < 50% cap) → single deposit record record := makeTestRecord("b3", "deposit", 20) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 50, TotalPaid: 0, } records := buildSplitRecords(record, "deposit", info, 20) if len(records) != 1 { t.Fatalf("expected 1 record (within cap), got %d", len(records)) } if records[0].PaymentType != "deposit" { t.Errorf("expected 'deposit', got %q", records[0].PaymentType) } } func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) { // £25 on a £100 total (25% < 50% cap) → single deposit record record := makeTestRecord("b4", "deposit", 25) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 100, TotalPaid: 0, } records := buildSplitRecords(record, "deposit", info, 25) if len(records) != 1 { t.Fatalf("expected 1 record (under 50%%), got %d", len(records)) } } func TestBuildSplitRecords_OverflowBeyondTotal_BecomesTip(t *testing.T) { // £60 on a £50 future booking: deposit caps at £25 (50%), balance covers // the remaining £25 owed, and the £10 overflow beyond the booking total // becomes a tip record. record := makeTestRecord("b5", "full", 60) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 50, TotalPaid: 0, } records := buildSplitRecords(record, "full", info, 60) if len(records) != 3 { t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records)) } if records[0].PaymentType != "deposit" { t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType) } if records[0].Amount != 25 { t.Errorf("expected deposit amount 25, got %.2f", records[0].Amount) } if records[1].PaymentType != "balance" { t.Errorf("expected second record 'balance', got %q", records[1].PaymentType) } if records[1].Amount != 25 { t.Errorf("expected balance amount 25, got %.2f", records[1].Amount) } if records[2].PaymentType != "tip" { t.Errorf("expected third record 'tip', got %q", records[2].PaymentType) } if records[2].Amount != 10 { t.Errorf("expected tip amount 10 (overflow beyond the booking total), got %.2f", records[2].Amount) } // The tip is a split: zero fees and a derived idempotency key. if records[2].Fees != 0 { t.Errorf("expected tip fees=0, got %.2f", records[2].Fees) } if *records[2].IdempotencyKey != *records[0].IdempotencyKey+"-split-3" { t.Errorf("expected tip key derived from primary, got %q", *records[2].IdempotencyKey) } // The splits share the Square payment id of the primary record. if *records[2].SquarePaymentID != *records[0].SquarePaymentID { t.Error("tip split must share square_payment_id with the primary record") } } // TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge // proves the money invariant documented on buildSplitRecords: the split // records (deposit + balance + tip) ALWAYS partition the charged amount // exactly, so their sum can never exceed what Square actually charged — even // when a discount is present. A discount reduces what the customer owes but is // booked as a SEPARATE ledger row (payment_method='discount') that // GetBookingPaymentInfo excludes from TotalPaid, so buildSplitRecords runs // against the full booking total and never inflates the split sum past the // charged pence. func TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge(t *testing.T) { // Discounted booking: £100 total with a £10 discount (the discount row // exists in the ledger but is excluded from TotalPaid). Tip overflow // charges over and above the booking total. record := makeTestRecord("b-disc-tip", "full", 120) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 100, TotalPaid: 0, // the £10 discount payment row is excluded here } records := buildSplitRecords(record, "full", info, 120) var sum float64 for _, r := range records { sum += r.Amount } // Partition invariant: the sum equals the charged amount exactly — it can // never exceed it, no matter how the tip overflows. if sum > 120 { t.Errorf("split records sum to %.2f, exceeding the charged amount £120", sum) } if math.Abs(sum-120) > 0.005 { t.Errorf("expected split records to partition the charged £120 exactly, got sum %.2f (records: %+v)", sum, records) } // Same invariant with real money already paid toward the booking: the // deposit room is consumed, but the sum still partitions the new charge. record2 := makeTestRecord("b-disc-tip-2", "full", 70) info2 := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 100, TotalPaid: 60, } records2 := buildSplitRecords(record2, "full", info2, 70) var sum2 float64 for _, r := range records2 { sum2 += r.Amount } if sum2 > 70 { t.Errorf("split records sum to %.2f, exceeding the charged amount £70", sum2) } if math.Abs(sum2-70) > 0.005 { t.Errorf("expected split records to partition the charged £70 exactly, got sum %.2f (records: %+v)", sum2, records2) } } // TestGetBookingPaymentInfo_ExcludesDiscountPayments proves the database half // of the invariant: GetBookingPaymentInfo.TotalPaid excludes discount payment // rows, so a discounted booking never inflates TotalPaid (which would shrink // the tip carve-out / grow the balance allocation into an over-recorded total). func TestGetBookingPaymentInfo_ExcludesDiscountPayments(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) // £60 of real money plus a £10 discount payment row (payment_method='discount'). _, err = fixtures.CreateTestPayment(tx, bookingID, 60.00, "online_square", "full", "completed") require.NoError(t, err) _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', 10.00, 'completed', $2) `, bookingID, userID) require.NoError(t, err) svc := NewPaymentService() info, err := svc.GetBookingPaymentInfo(ctx, bookingID) require.NoError(t, err) if info.TotalPaid != 60.00 { t.Errorf("expected TotalPaid to exclude the £10 discount row, got %.2f", info.TotalPaid) } // A £70 tip-overflow charge on top must partition exactly into £70 of split // records — the discount can never push the recorded sum past the charge. record := makeTestRecord(bookingID, "full", 70) records := buildSplitRecords(record, "full", info, 70) var sum float64 for _, r := range records { sum += r.Amount } if sum > 70 { t.Errorf("split records sum to %.2f, exceeding the charged amount £70", sum) } if math.Abs(sum-70) > 0.005 { t.Errorf("expected split records to partition the charged £70 exactly, got sum %.2f", sum) } } // TestBuildSplitRecords_DepositCarve_PenceExact pins the 50% deposit carve to // exact pence outcomes (money-safety audit): the carve rounds ONCE at the // pence boundary and the parts always partition the charged amount exactly, so // the recorded pence can never exceed what Square actually charged. The // tip-percentage math (tip% × subtotal) lives in the frontend — the backend // receives the charged total — so this test pins the backend's deposit carve, // the only percentage-derived math in the charge-splitting path. func TestBuildSplitRecords_DepositCarve_PenceExact(t *testing.T) { cases := []struct { name string total float64 // booking total, pounds paid float64 // already paid, pounds chargedPence int64 // the charge, int64 pence as Square reports it wantDeposit int64 // expected deposit carve, pence wantBalance int64 // expected balance portion, pence wantTip int64 // expected tip portion, pence }{ {"£50 charge on £50 booking → 25.00 + 25.00", 50, 0, 5000, 2500, 2500, 0}, {"£60 charge on £50 booking → deposit + balance + tip overflow", 50, 0, 6000, 2500, 2500, 1000}, {"£25 charge on £100 booking (under the 50% cap) → all deposit", 100, 0, 2500, 2500, 0, 0}, {"£60 charge on £100 booking → 50.00 deposit + 10.00 balance", 100, 0, 6000, 5000, 1000, 0}, {"£100 charge on £100 booking → 50.00 + 50.00", 100, 0, 10000, 5000, 5000, 0}, {"£50 charge on £100 booking with £30 already paid → deposit fills to cap", 100, 30, 5000, 2000, 3000, 0}, {"£12.34 charge on £25.00 booking → all deposit", 25, 0, 1234, 1234, 0, 0}, {"£25 charge on £25.50 booking → 12.75 deposit + 12.25 balance (half-penny carve)", 25.50, 0, 2500, 1275, 1225, 0}, {"£45.67 charge on £50 booking → 25.00 + 20.67", 50, 0, 4567, 2500, 2067, 0}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { chargedPounds := float64(tc.chargedPence) / 100.0 record := makeTestRecord("pence-booking", "full", chargedPounds) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: tc.total, TotalPaid: tc.paid, } records := buildSplitRecords(record, "full", info, chargedPounds) var depositPence, balancePence, tipPence, partitionPence int64 for _, r := range records { pence := int64(math.Round(r.Amount * 100)) partitionPence += pence switch r.PaymentType { case "deposit": depositPence = pence case "tip": tipPence = pence default: // The booking-portion remainder after the deposit carve. // Its TYPE is dynamic ('balance' when the payment reaches // the booking total, 'partial' when it does not); the // pence are what this table pins. balancePence = pence } } if depositPence != tc.wantDeposit { t.Errorf("deposit carve = %d pence, want %d", depositPence, tc.wantDeposit) } if balancePence != tc.wantBalance { t.Errorf("balance portion = %d pence, want %d", balancePence, tc.wantBalance) } if tipPence != tc.wantTip { t.Errorf("tip portion = %d pence, want %d", tipPence, tc.wantTip) } if partitionPence != tc.chargedPence { t.Errorf("split records partition to %d pence, want the charged %d pence", partitionPence, tc.chargedPence) } }) } } // --------------------------------------------------------------------------- // Handler-level atomicity — verify the full handler succeeds with split. // --------------------------------------------------------------------------- func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:atomic-card" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "atomic-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp PaymentResponse if err := parsePaymentResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.ID == "" { t.Fatal("expected non-empty payment ID") } if resp.Amount != 5000 { t.Errorf("expected amount 5000, got %d", resp.Amount) } if resp.PaymentType != "full" { t.Errorf("expected payment type 'full' in response, got %q", resp.PaymentType) } // Verify both split records exist and the total paid is correct. var recordCount int tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&recordCount) if recordCount != 2 { t.Errorf("expected 2 completed payment records from split, got %d", recordCount) } var totalPaid float64 tx.QueryRow(ctx, "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&totalPaid) if totalPaid != 50.00 { t.Errorf("expected total paid £50.00, got £%.2f", totalPaid) } // Deposit threshold should have been met — verify booking promoted from pending_release. var status string tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if status == "pending_release" { t.Error("expected booking to be promoted from pending_release after payment meets 20% threshold") } } func TestBookingPayment_ZeroAmountRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:zero-card" req := CreateBookingPaymentRequest{ Amount: 0, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "zero-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestBookingPayment_NegativeAmountRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:neg-card" req := CreateBookingPaymentRequest{ Amount: -100, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "neg-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:invalid-type-card" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "invalid_type", NewCardToken: &cardToken, IdempotencyKey: "invalid-type-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestBookingPayment_NoAuthRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID := setupDepositBooking(t, ctx, tx) cardToken := "cnon:no-auth-card" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "no-auth-test-1", } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } } func TestBookingPayment_DepositFollowedByBalance(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID := setupDepositBooking(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:deposit-balance-card" req1 := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "deposit-balance-1", } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("deposit: expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) } req2 := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "balance", NewCardToken: &cardToken, IdempotencyKey: "deposit-balance-2", } w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } if count != 2 { t.Errorf("expected 2 payments, got %d", count) } } func TestBookingPayment_PartialFollowedByBalance(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Past booking to avoid payment-split; we're testing sequence not deposit allocation. userID, bookingID := setupDepositBookingPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:partial-balance-card" req1 := CreateBookingPaymentRequest{ Amount: 1000, PaymentType: "partial", NewCardToken: &cardToken, IdempotencyKey: "partial-balance-1", } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx) if w1.Code != http.StatusOK { t.Errorf("partial: expected status 200, got %d. body: %s", w1.Code, w1.Body.String()) } req2 := CreateBookingPaymentRequest{ Amount: 4000, PaymentType: "balance", NewCardToken: &cardToken, IdempotencyKey: "partial-balance-2", } w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx) if w2.Code != http.StatusOK { t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var count int err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query payments: %v", err) } if count != 2 { t.Errorf("expected 2 payments, got %d", count) } } func TestTipPayment_WrongOwnerRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } otherToken := jwt.GenerateUserToken(otherUserID) cardToken := "cnon:wrong-owner-tip" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, otherToken, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } func TestTipPayment_MultipleTipsAllowed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } userToken := jwt.GenerateUserToken(userID) for i := 0; i < 3; i++ { cardToken := fmt.Sprintf("cnon:multi-tip-%d", i) req := CreateTipPaymentRequest{ Amount: int64(200 + i*100), NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("tip %d: expected status 200, got %d. body: %s", i, w.Code, w.Body.String()) } } var count int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query tip payments: %v", err) } if count != 3 { t.Errorf("expected 3 tip payments, got %d", count) } } func TestTipPayment_WithSavedCard(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // Create a completed payment so the tip is allowed. payID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) squarePayID := "sqp_test_saved_card" _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID) require.NoError(t, err) // Create a saved card for this user. var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) VALUES ($1, 'ccof:mock_saved', 'VISA', '1111', 12, 2030, 'sqfp_mock_saved') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) req := CreateTipPaymentRequest{ Amount: 1000, CardID: &savedCardID, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp PaymentResponse require.NoError(t, parsePaymentResponseBody(w, &resp)) assert.Equal(t, "tip", resp.PaymentType) assert.Equal(t, int64(1000), resp.Amount) assert.Equal(t, "completed", resp.Status) assert.NotEmpty(t, resp.CardBrand) assert.NotEmpty(t, resp.CardLast4) } func TestTipPayment_RetryPending_ReattemptsCharge(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // Create a completed payment so the tip is allowed. payID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) squarePayID := "sqp_test_retry" _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID) require.NoError(t, err) // Create a saved card for this user. var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) // Simulate a failed prior attempt: a PENDING tip record with the same // idempotency key the frontend will send on retry. The handler must NOT // short-circuit on this — it must re-attempt the Square charge. key := "tip-retry-key-123" _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by) VALUES ($1, 'tip', 'online_square', 'pending', 10.00, $2, NOW(), NOW(), $3) `, bookingID, key, userID) require.NoError(t, err) req := CreateTipPaymentRequest{ Amount: 1000, CardID: &savedCardID, IdempotencyKey: key, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp PaymentResponse require.NoError(t, parsePaymentResponseBody(w, &resp)) assert.Equal(t, "completed", resp.Status, "retry of a pending record must re-attempt and complete, not return the stale pending record") // Exactly one payment record for this key, now completed. var count int var status string err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM payments WHERE idempotency_key = $1`, key).Scan(&count, &status) require.NoError(t, err) assert.Equal(t, 1, count, "must reuse the pending record, not insert a duplicate") assert.Equal(t, "completed", status) } // TestTipPayment_RetryPending_NonExactAmountSucceeds verifies the pending-retry // amount guard compares pence exactly. £1.14 is stored as the float64 // 1.1399999999999999, so a naive int64(pounds*100) truncation yields 113 and // falsely rejects a legitimate same-amount retry of 114 pence. func TestTipPayment_RetryPending_NonExactAmountSucceeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // Create a completed payment so the tip is allowed. _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) // Create a saved card for this user. var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) // Failed prior attempt: a PENDING tip of £1.14 (114 pence) with the same key. key := "tip-retry-key-114" _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by) VALUES ($1, 'tip', 'online_square', 'pending', 1.14, $2, NOW(), NOW(), $3) `, bookingID, key, userID) require.NoError(t, err) req := CreateTipPaymentRequest{ Amount: 114, CardID: &savedCardID, IdempotencyKey: key, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp PaymentResponse require.NoError(t, parsePaymentResponseBody(w, &resp)) assert.Equal(t, "completed", resp.Status, "a same-amount retry of a non-exact pence value must re-attempt and complete") assert.Equal(t, int64(114), resp.Amount) // Exactly one payment record for this key, now completed. var count int var status string err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM payments WHERE idempotency_key = $1`, key).Scan(&count, &status) require.NoError(t, err) assert.Equal(t, 1, count, "must reuse the pending record, not insert a duplicate") assert.Equal(t, "completed", status) } // TestTipPayment_RetryPending_AmountMismatchRejected verifies the amount guard // still rejects a retry that changes the amount on the same idempotency key. func TestTipPayment_RetryPending_AmountMismatchRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) // Failed prior attempt: PENDING tip of £10.00 (1000 pence). key := "tip-retry-key-mismatch" _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by) VALUES ($1, 'tip', 'online_square', 'pending', 10.00, $2, NOW(), NOW(), $3) `, bookingID, key, userID) require.NoError(t, err) // Retry with a DIFFERENT amount but the same key → must 400, not charge. req := CreateTipPaymentRequest{ Amount: 2000, CardID: &savedCardID, IdempotencyKey: key, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "a same-key retry with a different amount must be rejected") // The pending record must be untouched. var status string err = tx.QueryRow(ctx, `SELECT status FROM payments WHERE idempotency_key = $1`, key).Scan(&status) require.NoError(t, err) assert.Equal(t, "pending", status) } func TestTipPayment_TransactionFailure_SkipsSquare(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } userToken := jwt.GenerateUserToken(userID) cancelCtx, cancel := context.WithCancel(ctx) cancel() cardToken := "cnon:tip-card" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, cancelCtx) if w.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String()) } var completedTipCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'", bookingID).Scan(&completedTipCount) if err != nil { t.Errorf("failed to query completed tip payments: %v", err) } if completedTipCount != 0 { t.Errorf("expected 0 completed tip payments, got %d", completedTipCount) } } // TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow verifies the H2 // fix: a no-client-key tip whose Square call failed ambiguously leaves a // pending row; a retry (still no client key) must derive the SAME deterministic // key from the booking+amount+card+sequence and reuse that pending row — never // mint a fresh random key, a fresh pending row, and a second Square charge. func TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow(t *testing.T) { // NOT t.Parallel: it swaps the package-global SquareClient (a ShouldFail // mock) mid-test, and a concurrent parallel charge test would observe the // swapped client and fail with a spurious 503. ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) origClient := SquareClient mc := square.NewDevClient().(*square.MockClient) mc.ShouldFail = true SquareClient = mc defer func() { SquareClient = origClient }() cardToken := "cnon:no-key-tip-retry" req := CreateTipPaymentRequest{ Amount: 1000, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusServiceUnavailable, w.Code, "first attempt must fail ambiguously: %s", w.Body.String()) // The pending row must exist with a deterministic key. var pendingKey string var pendingCount int require.NoError(t, tx.QueryRow(ctx, `SELECT idempotency_key, COUNT(*) OVER() FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&pendingKey, &pendingCount)) require.Equal(t, 1, pendingCount) require.NotEmpty(t, pendingKey) require.Contains(t, pendingKey, bookingID, "the no-client-key fallback must derive a deterministic key from the booking, not a random one") // Retry with Square healthy: no client key, same amount/card. mc.ShouldFail = false w = makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "retry must succeed: %s", w.Body.String()) // Exactly ONE tip payment row, completed, with the SAME key. var count int var status string var storedKey string require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), MAX(idempotency_key) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&count, &status, &storedKey)) require.Equal(t, 1, count, "a no-client-key retry must reuse the pending row, not insert a duplicate") require.Equal(t, "completed", status) require.Equal(t, pendingKey, storedKey, "the retry must derive the same deterministic key") } // TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse verifies the // H2 fix's other half: two genuinely distinct identical no-client-key tips on // the same booking get n=1 and n=2, so they never collapse onto one // idempotency key (which would silently swallow the second tip). func TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) handler := CreateTipPayment cardToken := "cnon:no-key-tip-dup" req := CreateTipPaymentRequest{ Amount: 1000, NewCardToken: &cardToken, } for i := 0; i < 2; i++ { w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "tip %d: %s", i, w.Body.String()) } var keys []string rows, err := tx.Query(ctx, `SELECT idempotency_key FROM payments WHERE booking_id = $1 AND payment_type = 'tip' ORDER BY created_at`, bookingID) require.NoError(t, err) for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) keys = append(keys, k) } rows.Close() require.Equal(t, 2, len(keys), "two distinct identical tips must both be recorded") require.NotEqual(t, keys[0], keys[1], "distinct identical tips must not collapse onto one idempotency key") } func TestGetUserPaymentMethods_NoCards(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } userToken := jwt.GenerateUserToken(userID) handler := GetUserPaymentMethods w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var cards []SavedCard if err := parsePaymentResponseBody(w, &cards); err != nil { t.Errorf("failed to parse response: %v", err) } if len(cards) != 0 { t.Errorf("expected 0 cards, got %d", len(cards)) } } func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_wrong_owner", "VISA", "0000") if err != nil { t.Fatalf("failed to create payment method: %v", err) } otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } otherToken := jwt.GenerateUserToken(otherUserID) handler := DeletePaymentMethod w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, otherToken, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var count int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL", cardID).Scan(&count) if err != nil { t.Errorf("failed to query card: %v", err) } if count != 1 { t.Error("expected card to still exist (not deleted by wrong owner)") } } func TestValidatePartialAmount(t *testing.T) { tests := []struct { name string amountPence int64 remainingPence int64 expectErr bool }{ {"valid partial", 500, 1000, false}, {"exact remaining", 1000, 1000, false}, {"exceeds remaining", 1500, 1000, true}, {"zero amount", 0, 1000, true}, {"negative amount", -100, 1000, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidatePartialAmount(tt.amountPence, tt.remainingPence) if tt.expectErr && err == nil { t.Error("expected error, got nil") } if !tt.expectErr && err != nil { t.Errorf("expected no error, got %v", err) } }) } } func TestGetBookingRemainingBalancePence(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } service := NewPaymentService() initialRemaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } if initialRemaining <= 0 { t.Fatalf("expected positive remaining balance, got %d", initialRemaining) } _, err = service.CreatePaymentRecord(ctx, PaymentRecord{ BookingID: bookingID, PaymentType: "partial", PaymentMethod: "cash", Status: "completed", Amount: 20.00, }, nil) if err != nil { t.Fatalf("failed to create payment: %v", err) } afterPartial, err := service.GetBookingRemainingBalancePence(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } if afterPartial != initialRemaining-2000 { t.Errorf("expected %d pence remaining after £20 payment, got %d", initialRemaining-2000, afterPartial) } _, err = service.CreatePaymentRecord(ctx, PaymentRecord{ BookingID: bookingID, PaymentType: "balance", PaymentMethod: "cash", Status: "completed", Amount: float64(afterPartial) / 100.0, }, nil) if err != nil { t.Fatalf("failed to create payment: %v", err) } afterFull, err := service.GetBookingRemainingBalancePence(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } if afterFull != 0 { t.Errorf("expected 0 pence remaining after full payment, got %d", afterFull) } } // TestGetBookingRemainingBalancePence_RefundsReopenCapacity verifies the M-cap // is refund-aware: a completed refund returns money, so it re-opens booking // capacity — remaining = total - paid + refunded — while the cap never exceeds // the booking total. func TestGetBookingRemainingBalancePence_RefundsReopenCapacity(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) require.NoError(t, err) var bookingTotal int64 require.NoError(t, tx.QueryRow(ctx, `SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal)) service := NewPaymentService() // Pay the full booking amount. _, err = service.CreatePaymentRecord(ctx, PaymentRecord{ BookingID: bookingID, PaymentType: "full", PaymentMethod: "online_square", Status: "completed", Amount: float64(bookingTotal) / 100.0, }, nil) require.NoError(t, err) remaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID) require.NoError(t, err) require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining") // Refund half the booking value — capacity must re-open by that amount. var payRowID string require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND payment_type = 'full' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&payRowID)) refundAmount := bookingTotal / 2 _, err = tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin) VALUES ($1, $2, $3, 'completed', 'test refund', 'manual') `, payRowID, bookingID, float64(refundAmount)/100.0) require.NoError(t, err) remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID) require.NoError(t, err) require.Equal(t, refundAmount, remaining, "a completed refund must re-open the remaining balance by its amount") // The cap must never exceed the booking total even if refunds exceed payments. _, err = tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin) VALUES ($1, $2, $3, 'completed', 'over-refund test', 'manual') `, payRowID, bookingID, bookingTotal) require.NoError(t, err) remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID) require.NoError(t, err) require.Equal(t, bookingTotal, remaining, "the remaining balance must never exceed the booking total") } func TestCreatePaymentMethod_HappyPath(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) handler := CreatePaymentMethod reqBody := CreatePaymentMethodRequest{ CardToken: "cnon:visa", } w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) return } var card SavedCard if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil { t.Fatalf("failed to parse response: %v", err) } if card.Brand != "VISA" { t.Errorf("expected brand VISA, got %s", card.Brand) } if card.Last4 != "1111" { t.Errorf("expected last4 1111, got %s", card.Last4) } if !card.IsDefault { t.Error("expected first card to be default") } } func TestCreatePaymentMethod_RawPANRejected(t *testing.T) { // PCI-DSS: raw PANs are never accepted at the API edge — the handler must // return 400 for a card_number body, since the field no longer exists and // card_token is required. Validates that a raw PAN never reaches the // Square client. t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) // Raw PAN sent as the old field name — should be ignored and rejected. handler := CreatePaymentMethod reqBody := map[string]string{ "card_number": "4111111111111111", "expiry": "12/30", "cvc": "123", } w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 (card_token required), got %d. body: %s", w.Code, w.Body.String()) } } func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) tests := []struct { name string body CreatePaymentMethodRequest }{ {"no card token", CreatePaymentMethodRequest{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := CreatePaymentMethod w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", tt.body, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } }) } } func TestCreatePaymentMethod_NoAuthRejected(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := CreatePaymentMethod w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{ CardToken: "cnon:visa", }, "", ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) // Create first card handler := CreatePaymentMethod reqBody := CreatePaymentMethodRequest{ CardToken: "cnon:visa", } w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusOK { t.Fatalf("failed to create first card: %d. body: %s", w.Code, w.Body.String()) } reqBody2 := CreatePaymentMethodRequest{ CardToken: "cnon:mastercard", } w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token, ctx) if w.Code != http.StatusOK { t.Fatalf("failed to create second card: %d. body: %s", w.Code, w.Body.String()) } var card SavedCard if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil { t.Fatalf("failed to parse response: %v", err) } if card.Brand != "MASTERCARD" { t.Errorf("expected brand MASTERCARD, got %s", card.Brand) } if card.IsDefault { t.Error("expected second card to NOT be default") } } // ============================================================================= // GetCheckoutStatus — GET /api/checkout/{checkout_id}/status?booking_id=... // ============================================================================= // GetCheckoutStatus cannot be fully tested with the dev Square mock because // the mock generates checkout IDs (like "chk_mock_...") that don't pass the // 12-char hex validation. These tests cover the validation guard paths. func TestGetCheckoutStatus_MissingCheckoutID(t *testing.T) { _, _ = testutils.SetupTestTx(t) req := httptest.NewRequest("GET", "/api/checkout//status?booking_id=abc", nil) rctx := chi.NewRouteContext() reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) req = adminRequestCtx(req) w := httptest.NewRecorder() GetCheckoutStatus(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetCheckoutStatus_InvalidCheckoutID(t *testing.T) { _, _ = testutils.SetupTestTx(t) // Must fail the Square-compatible checkout-ID check — the old 12-hex gate // rejected real Square IDs (UUIDs like "08YceKh7B3ZqO"), so an injection // attempt (path traversal) is the correct invalid case now. badID := "../etc/passwd" req := httptest.NewRequest("GET", "/api/checkout/"+badID+"/status?booking_id=abc", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", badID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() req = adminRequestCtx(req) GetCheckoutStatus(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetCheckoutStatus_RealSquareID_PassesValidation(t *testing.T) { // A real Square checkout ID (13-char UUID, not 12-hex) must pass the // checkout-ID gate — the C-4 fix. It then 404s at the Square client level // (mock has no such checkout), proving the gate no longer rejects it. _, _ = testutils.SetupTestTx(t) realID := "08YceKh7B3ZqO" req := httptest.NewRequest("GET", "/api/checkout/"+realID+"/status?booking_id=abc", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", realID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() req = adminRequestCtx(req) GetCheckoutStatus(w, req) // Not 404-from-validation: the gate accepted it. The mock returns 500 for // an unknown checkout (it panics on a missing ID), so assert NOT a 404 // from the gate — any non-404 is proof the gate passed. if w.Code == http.StatusNotFound { t.Errorf("real Square checkout ID %q was rejected by the validation gate — expected it to pass validation", realID) } } func TestGetCheckoutStatus_ValidCheckoutNotFound(t *testing.T) { _, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } svcID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID) if err != nil { t.Fatalf("failed to create booking: %v", err) } req := httptest.NewRequest("GET", "/api/checkout/aaaaaaaaaaaa/status?booking_id="+bookingID, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa") reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() req = adminRequestCtx(req) GetCheckoutStatus(w, req) if w.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // AdminGetUserPaymentMethods — GET /api/admin/users/{id}/payment-methods // ============================================================================= func TestAdminGetUserPaymentMethods_Success(t *testing.T) { _, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_admin_test", "VISA", "4321") if err != nil { t.Fatalf("failed to create payment method: %v", err) } _ = cardID req := httptest.NewRequest("GET", "/api/admin/users/"+userID+"/payment-methods", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", userID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx)) req = req.WithContext(reqCtx) w := httptest.NewRecorder() AdminGetUserPaymentMethods(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var cards []SavedCard if err := json.Unmarshal(w.Body.Bytes(), &cards); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(cards) != 1 { t.Errorf("expected 1 card, got %d", len(cards)) } } func TestAdminGetUserPaymentMethods_InvalidUserID(t *testing.T) { _, _ = testutils.SetupTestTx(t) req := httptest.NewRequest("GET", "/api/admin/users/$$$/payment-methods", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "$$$") reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() req = adminRequestCtx(req) AdminGetUserPaymentMethods(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestAdminGetUserPaymentMethods_NoCards(t *testing.T) { _, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } req := httptest.NewRequest("GET", "/api/admin/users/"+userID+"/payment-methods", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", userID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") req = req.WithContext(reqCtx) w := httptest.NewRecorder() AdminGetUserPaymentMethods(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var cards []SavedCard if err := json.Unmarshal(w.Body.Bytes(), &cards); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(cards) != 0 { t.Errorf("expected 0 cards, got %d", len(cards)) } } // ============================================================================= // GetBookingPaymentSummary — GET /api/bookings/{id}/payment-summary // ============================================================================= func TestGetBookingPaymentSummary_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID) reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx)) req = req.WithContext(reqCtx) w := httptest.NewRecorder() GetBookingPaymentSummary(w, req) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var summary PaymentSummaryResponse if err := json.Unmarshal(w.Body.Bytes(), &summary); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(summary.Payments) != 1 { t.Errorf("expected 1 payment, got %d", len(summary.Payments)) } if summary.Payments[0].Amount != 5000 { t.Errorf("expected amount 5000, got %d", summary.Payments[0].Amount) } } func TestGetBookingPaymentSummary_NotFound(t *testing.T) { _, _ = testutils.SetupTestTx(t) req := httptest.NewRequest("GET", "/api/bookings/aaaaaaaaaaaa/payment-summary", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "aaaaaaaaaaaa") reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "some-user") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") req = req.WithContext(reqCtx) w := httptest.NewRecorder() GetBookingPaymentSummary(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetBookingPaymentSummary_Unauthorized(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, otherUserID) reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx)) req = req.WithContext(reqCtx) _ = userID w := httptest.NewRecorder() GetBookingPaymentSummary(w, req) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetBookingPaymentSummary_AdminAccess(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx)) req = req.WithContext(reqCtx) w := httptest.NewRecorder() GetBookingPaymentSummary(w, req) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Service layer: GetPaymentByID // ============================================================================= func TestGetPaymentByID_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx svc := NewPaymentService() _, err := svc.GetPaymentByID(ctx, "000000000001") if err == nil { t.Error("expected error for non-existent payment ID") } } func TestGetPaymentByID_Found(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } svc := NewPaymentService() record, err := svc.GetPaymentByID(ctx, paymentID) if err != nil { t.Errorf("unexpected error: %v", err) } if record == nil || record.ID != paymentID { t.Errorf("expected payment ID %s, got %v", paymentID, record) } } // ============================================================================= // Service layer: GetBookingStatus // ============================================================================= func TestGetBookingStatus_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx svc := NewPaymentService() _, err := svc.GetBookingStatus(ctx, "000000000001") if err == nil { t.Error("expected error for non-existent booking ID") } } func TestGetBookingStatus_Found(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) svc := NewPaymentService() status, err := svc.GetBookingStatus(ctx, bookingID) if err != nil { t.Errorf("unexpected error: %v", err) } if status == "" { t.Error("expected non-empty status") } } // ============================================================================= // Service layer: GetBookingPaymentInfo // ============================================================================= func TestGetBookingPaymentInfo_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx svc := NewPaymentService() _, err := svc.GetBookingPaymentInfo(ctx, "000000000001") if err == nil { t.Error("expected error for non-existent booking ID") } } func TestGetBookingPaymentInfo_Found(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) svc := NewPaymentService() info, err := svc.GetBookingPaymentInfo(ctx, bookingID) if err != nil { t.Errorf("unexpected error: %v", err) } if info == nil { t.Fatal("expected non-nil info") } if info.TotalAmount <= 0 { t.Errorf("expected positive total amount, got %.2f", info.TotalAmount) } } // ============================================================================= // Service layer: GetBookingRemainingBalancePence // ============================================================================= func TestGetBookingRemainingBalancePence_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx svc := NewPaymentService() _, err := svc.GetBookingRemainingBalancePence(ctx, "000000000001") if err == nil { t.Error("expected error for non-existent booking ID") } } func TestGetBookingRemainingBalancePence_FullBalance(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) svc := NewPaymentService() pence, err := svc.GetBookingRemainingBalancePence(ctx, bookingID) if err != nil { t.Errorf("unexpected error: %v", err) } if pence <= 0 { t.Errorf("expected positive remaining balance for unpaid booking, got %d", pence) } } // ============================================================================= // Service layer: GetAlreadyRefundedAmount // ============================================================================= func TestGetAlreadyRefundedAmount_NoRefunds(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } svc := NewPaymentService() amount, err := svc.GetAlreadyRefundedAmount(ctx, paymentID) if err != nil { t.Errorf("unexpected error: %v", err) } if amount != 0 { t.Errorf("expected 0 refunded amount, got %d", amount) } } func TestGetAlreadyRefundedAmount_WithRefund(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } _, err = fixtures.CreateTestRefund(tx, paymentID, bookingID, 20.00) if err != nil { t.Fatalf("failed to create refund: %v", err) } svc := NewPaymentService() amount, err := svc.GetAlreadyRefundedAmount(ctx, paymentID) if err != nil { t.Errorf("unexpected error: %v", err) } if amount != 2000 { t.Errorf("expected 2000 (2000p = £20.00), got %d", amount) } } // ============================================================================= // Service layer: HasCompletedPayment // ============================================================================= func TestHasCompletedPayment_NoPayments(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) svc := NewPaymentService() hasPayments, err := svc.HasCompletedPayment(ctx, bookingID) if err != nil { t.Errorf("unexpected error: %v", err) } if hasPayments { t.Error("expected false for booking with no completed payments") } } func TestHasCompletedPayment_HasPayment(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } svc := NewPaymentService() hasPayments, err := svc.HasCompletedPayment(ctx, bookingID) if err != nil { t.Errorf("unexpected error: %v", err) } if !hasPayments { t.Error("expected true for booking with completed payment") } } // ============================================================================= // Service layer: SaveCardForUser // ============================================================================= func TestSaveCardForUser_Success(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } svc := NewPaymentService() cardID, err := svc.SaveCardForUser(ctx, userID, "cus_test_success", "cfa_test_success", "VISA", "4242", 12, 2030, "fp_success") if err != nil { t.Errorf("unexpected error: %v", err) } if cardID == "" { t.Error("expected non-empty card ID") } } func TestSaveCardForUser_InvalidUserID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx svc := NewPaymentService() _, err := svc.SaveCardForUser(ctx, "000000000001", "cus_test_invalid", "cfa_test", "VISA", "4242", 12, 2030, "fp_test") if err == nil { t.Error("expected error for non-existent user ID (FK violation)") } } // ============================================================================= // Service layer: CreateRefundRecord // ============================================================================= func TestCreateRefundRecord_InvalidPaymentID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx svc := NewPaymentService() now := clock.Now() _, err := svc.CreateRefundRecord(ctx, RefundRecord{ PaymentID: "000000000001", BookingID: "000000000002", Amount: 10.00, Status: "completed", Reason: "test", CreatedAt: now, }) if err == nil { t.Error("expected error for non-existent payment ID (FK violation)") } } // ============================================================================= // GetCheckoutStatus — GET /api/admin/payments/{checkout_id}/status // ============================================================================= func TestGetCheckoutStatus_MissingBookingID(t *testing.T) { _, _ = testutils.SetupTestTx(t) req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa") reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() req = adminRequestCtx(req) GetCheckoutStatus(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetCheckoutStatus_InvalidBookingID(t *testing.T) { _, _ = testutils.SetupTestTx(t) req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status?booking_id=invalid", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa") reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) req = adminRequestCtx(req) w := httptest.NewRecorder() GetCheckoutStatus(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestGetCheckoutStatus_BookingNotFound(t *testing.T) { _, _ = testutils.SetupTestTx(t) req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status?booking_id=bbbbbbbbbbbb", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa") reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() req = adminRequestCtx(req) GetCheckoutStatus(w, req) if w.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // CreateTerminalPayment — Validation gap tests // ============================================================================= func TestTerminalPayment_NoAuth(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", }, "", ctx) // No auth token → no role → the defense-in-depth isAdminRequest check // rejects with 403 before the adminID check (S-1). if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } func TestTerminalPayment_InvalidJSON(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) adminToken := jwt.GenerateAdminToken() body := bytes.NewReader([]byte(`{invalid}`)) req := httptest.NewRequest("POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+adminToken) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "aaaaaaaaaaaa") reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) if info := extractUserFromTestJWT(adminToken); info != nil { reqCtx = context.WithValue(reqCtx, mw.UserIDKey, info.userID) reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, info.role) } req = req.WithContext(reqCtx) w := httptest.NewRecorder() CreateTerminalPayment(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestTerminalPayment_ValidateAmountFails(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) adminToken := jwt.GenerateAdminToken() req := CreateTerminalPaymentRequest{ Amount: 0, PaymentType: "full", } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestTerminalPayment_ValidatePaymentTypeFails(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) adminToken := jwt.GenerateAdminToken() req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "invalid_type", } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", req, adminToken, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestSweepStalePendingPayments_MarksOldFailed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) // A fresh pending payment (should NOT be failed). freshID, err := fixtures.CreateTestPayment(tx, bookingID, 1000.00, "online_square", "full", "pending") if err != nil { t.Fatalf("failed to create fresh pending payment: %v", err) } // A stale pending payment (25h old — past Square's ~24h key retention). 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) } _, err = SweepStalePendingPayments(ctx) if err != nil { t.Fatalf("sweep failed: %v", err) } // Stale pending → failed; fresh pending untouched. var staleStatus, freshStatus string if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&staleStatus); err != nil { t.Fatalf("failed to query stale payment: %v", err) } if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", freshID).Scan(&freshStatus); err != nil { t.Fatalf("failed to query fresh payment: %v", err) } if staleStatus != "failed" { t.Errorf("expected stale pending payment to be marked failed, got %q", staleStatus) } if freshStatus != "pending" { t.Errorf("expected fresh pending payment to stay pending, got %q", freshStatus) } } // TestSavedCardPayment_LostResponseRetry_Dedups verifies the R1 fix: two // "Charge Saved Card" requests with identical inputs (booking + type + amount + // card) derive the SAME deterministic idempotency key, so a lost-response // retry reuses the completed payment instead of charging twice. Before the fix, // every request used a fresh random key → the second click double-charged. func TestSavedCardPayment_LostResponseRetry_Dedups(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:saved-card-test", "VISA", "4242") if err != nil { t.Fatalf("failed to create saved card: %v", err) } handler := CreateTerminalPayment reqBody := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, } // First charge. w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } // Same-input retry (lost response) — must dedup, not double-charge. w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx) if w2.Code != http.StatusOK { t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } // Exactly ONE payment record for this booking. var payCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' AND amount = 50.00`, bookingID).Scan(&payCount); err != nil { t.Fatalf("failed to count payments: %v", err) } if payCount != 1 { t.Errorf("expected exactly 1 payment record (dedup), got %d — double-charge!", payCount) } } // TestSavedCardPayment_SweptFailed_Rejected verifies the R2 fix: after the // sweep marks a pending payment failed, a same-key retry is cleanly rejected // with 409 instead of 500-ing on the idempotency_key UNIQUE constraint. func TestSavedCardPayment_SweptFailed_Rejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:swept-card-test", "VISA", "1111") if err != nil { t.Fatalf("failed to create saved card: %v", err) } // Seed a failed payment with the deterministic key the handler will derive. scKey := bookingID + "-sc-full-5000-" + cardID if _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, created_by, created_at, updated_at) VALUES ($1, 'full', 'online_square', 'failed', 50.00, $2, $3, $4, NOW(), NOW()) `, bookingID, scKey, cardID, adminID); err != nil { t.Fatalf("failed to seed failed payment: %v", err) } handler := CreateTerminalPayment reqBody := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx) if w.Code != http.StatusConflict { t.Fatalf("expected 409 (swept-failed rejection), got %d. body: %s", w.Code, w.Body.String()) } // No new payment row was inserted. var payCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, scKey).Scan(&payCount); err != nil { t.Fatalf("failed to count payments: %v", err) } if payCount != 1 { t.Errorf("expected exactly 1 (failed) payment row, got %d", payCount) } } // TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup verifies the Bug 2 // fix: the frontend sends a per-attempt idempotency UUID, so two DISTINCT // identical saved-card charges on the same booking (same amount, same card, // same payment type) must NOT collapse on the old deterministic // booking+type+amount+card key. Each becomes its own payment. func TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:distinct-key-card", "VISA", "4242") if err != nil { t.Fatalf("failed to create saved card: %v", err) } handler := CreateTerminalPayment // Two legitimately distinct £20 'full' charges on the same booking — the // frontend sends a different per-attempt UUID for each. Each stays within // the £50 booking's remaining obligation (a second £50 charge would be a // B3 overcharge on the now fully-paid booking and correctly rejected). for i, key := range []string{"saved-card-uuid-0001", "saved-card-uuid-0002"} { reqBody := CreateTerminalPaymentRequest{ Amount: 2000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: key, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("saved-card charge %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String()) } } // Exactly TWO payment records — the second charge must not be swallowed by // the dedup branch. var payCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil { t.Fatalf("failed to count payments: %v", err) } if payCount != 2 { t.Errorf("expected exactly 2 payment records (no dedup), got %d", payCount) } } // TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups verifies that a lost- // response retry carrying the SAME client UUID still dedups to a single // payment — the client key does not disable retry safety. func TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:same-key-card", "VISA", "4242") if err != nil { t.Fatalf("failed to create saved card: %v", err) } handler := CreateTerminalPayment reqBody := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, // Distinct from TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup's // keys: both tests share the package-level singleton mock (SquareClient // is set once in TestMain), and the mock now mirrors Square's body-aware // dedup — reusing a retained key with a different source_id returns // IDEMPOTENCY_KEY_REUSED (same as real Square). IdempotencyKey: "saved-card-uuid-retry-0001", } // First charge. w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx) if w1.Code != http.StatusOK { t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String()) } // Same-input retry (lost response) — same client UUID → dedup, not a // second charge. w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx) if w2.Code != http.StatusOK { t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } // Exactly ONE payment record. var payCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil { t.Fatalf("failed to count payments: %v", err) } if payCount != 1 { t.Errorf("expected exactly 1 payment record (same-key dedup), got %d — double-charge!", payCount) } } // TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup verifies the cash/ // giftcard branch: two identical cash receipts on the same booking are // legitimate distinct payments and must each insert their own row (there is // deliberately no idempotency dedup in this branch). Each £20 receipt stays // within the £50 booking's remaining obligation (B3 clamps only what exceeds // the remaining balance, and a fully-paid booking rejects further charges). func TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") handler := CreateTerminalPayment reqBody := CreateTerminalPaymentRequest{ Amount: 2000, PaymentType: "full", PaymentMethod: strPtr("cash"), } for i := 0; i < 2; i++ { w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("cash receipt %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String()) } } var payCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&payCount); err != nil { t.Fatalf("failed to count payments: %v", err) } if payCount != 2 { t.Errorf("expected exactly 2 cash payment rows (no dedup), got %d", payCount) } } // TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep // also marks stale pending till_sales rows (card payments) as failed, so a // lost-response till sale can't stay pending past Square's key retention. func TestSweepStalePendingPayments_CoversTillSales(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } // A stale pending till sale (card payment, 25h old). var tillSaleID string err = tx.QueryRow(ctx, ` INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at) VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW() - INTERVAL '25 hours', NOW()) RETURNING id `, adminID).Scan(&tillSaleID) if err != nil { t.Fatalf("failed to seed pending till sale: %v", err) } // A fresh pending till sale that must NOT be swept. var freshSaleID string err = tx.QueryRow(ctx, ` INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at) VALUES ('gift_card', 'Gift Card create', 1, 30.00, 30.00, 'online_square', 'pending', $1, NOW(), NOW()) RETURNING id `, adminID).Scan(&freshSaleID) if err != nil { t.Fatalf("failed to seed fresh till sale: %v", err) } _, err = SweepStalePendingPayments(ctx) if err != nil { t.Fatalf("sweep failed: %v", err) } var staleStatus, freshStatus string if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", tillSaleID).Scan(&staleStatus); err != nil { t.Fatalf("failed to query stale till sale: %v", err) } if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", freshSaleID).Scan(&freshStatus); err != nil { t.Fatalf("failed to query fresh till sale: %v", err) } if staleStatus != "failed" { t.Errorf("expected stale pending till sale to be marked failed, got %q", staleStatus) } if freshStatus != "pending" { t.Errorf("expected fresh pending till sale to stay pending, got %q", freshStatus) } } func TestBuildSplitRecords_TipOverflow_SeparateTipRecord(t *testing.T) { // A £60 payment on a £50 future booking with nothing paid yet: // deposit = min(60, 25) = £25, balance = min(35, 25) = £25, // tip = 60 - 25 - 25 = £10 — the overflow becomes a separate tip record. record := makeTestRecord("b-overflow", "full", 60) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 50, TotalPaid: 0, } records := buildSplitRecords(record, "full", info, 60) if len(records) != 3 { t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records)) } if records[0].PaymentType != "deposit" || records[0].Amount != 25 { t.Errorf("expected deposit 25, got %q %.2f", records[0].PaymentType, records[0].Amount) } if records[1].PaymentType != "balance" || records[1].Amount != 25 { t.Errorf("expected balance 25, got %q %.2f", records[1].PaymentType, records[1].Amount) } if records[2].PaymentType != "tip" || records[2].Amount != 10 { t.Errorf("expected tip record 10, got %q %.2f", records[2].PaymentType, records[2].Amount) } // Tip is an overflow split — zero fees, derived idempotency key -split-2. if records[2].Fees != 0 { t.Errorf("expected tip record fees=0, got %.2f", records[2].Fees) } wantKey := *record.IdempotencyKey + "-split-3" if *records[2].IdempotencyKey != wantKey { t.Errorf("expected tip key %q, got %q", wantKey, *records[2].IdempotencyKey) } // All three share the same SquarePaymentID (one charge, three ledger rows). if *records[2].SquarePaymentID != *record.SquarePaymentID { t.Error("tip split must share square_payment_id") } } // TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes is the // C1 money-bug regression: a user paying the FULL discounted amount on a // booking with an active time-based campaign must have the campaign discount // applied (booking_discounts row + discount payment record + campaign // redemption) and the booking must auto-complete. Previously the deposit+ // balance split was inserted BEFORE applyEligibleCampaignsAtPayment ran, so // the split's two just-inserted completed records tripped the // existingPayment>=2 guard in ComputeEligibleDiscounts and the discount was // never applied — the booking stayed unpaid on paper and never completed. func TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Fixture booking total is £50 (one test service). A 10% time-based // campaign makes the discounted total £45. userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) now := clock.Now() var campaignID string err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) RETURNING id `, "Summer Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) require.NoError(t, err) cardToken := "cnon:discounted-full" req := CreateBookingPaymentRequest{ Amount: 4500, // £45 = £50 - 10% discount PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "discounted-full-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) // The campaign discount must have been applied for this booking. var discountCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND source_id = $2`, bookingID, campaignID).Scan(&discountCount)) assert.Equal(t, 1, discountCount, "the campaign discount must be applied when the full discounted amount is paid") // The discount payment record and the campaign redemption counter follow. var discountPaymentCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPaymentCount)) assert.Equal(t, 1, discountPaymentCount, "the discount payment record must exist") var redeemed int require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed)) assert.Equal(t, 1, redeemed, "the campaign must be redeemed exactly once") // The booking must auto-complete: £25 deposit + £20 balance (real money) // plus the £5 discount covers the £50 total. var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)) assert.Equal(t, "completed", status, "a booking paid to its discounted total must auto-complete") }