//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) { 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_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, _ := setupTestData(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, _ := setupTestData(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) } } // ============================================================ // 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. rows, err := tx.Query(ctx, `SELECT payment_type, amount, square_payment_id FROM payments WHERE booking_id = $1 ORDER BY amount DESC`, 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) } } // --------------------------------------------------------------------------- // 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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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) } } 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 amountCents int64 remainingCents 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.amountCents, tt.remainingCents) 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 TestGetBookingRemainingBalanceCents(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.GetBookingRemainingBalanceCents(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.GetBookingRemainingBalanceCents(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } if afterPartial != initialRemaining-2000 { t.Errorf("expected %d cents 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.GetBookingRemainingBalanceCents(ctx, bookingID) if err != nil { t.Fatalf("unexpected error: %v", err) } if afterFull != 0 { t.Errorf("expected 0 cents remaining after full payment, got %d", afterFull) } } 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: GetBookingRemainingBalanceCents // ============================================================================= func TestGetBookingRemainingBalanceCents_NotFound(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _ = tx svc := NewPaymentService() _, err := svc.GetBookingRemainingBalanceCents(ctx, "000000000001") if err == nil { t.Error("expected error for non-existent booking ID") } } func TestGetBookingRemainingBalanceCents_FullBalance(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) svc := NewPaymentService() cents, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID) if err != nil { t.Errorf("unexpected error: %v", err) } if cents <= 0 { t.Errorf("expected positive remaining balance for unpaid booking, got %d", cents) } } // ============================================================================= // 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) } } // 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") } }