//go:build test && dev package payments import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/db" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) 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) } 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, "cfa_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_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 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, CardToken: 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, CardToken: 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) } } func TestSquareWebhook_DevMode_NoSignature(t *testing.T) { t.Parallel() t.Skip("webhook handler tested in webhooks package") } // ============================================================ // 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)) } } // --------------------------------------------------------------------------- // 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) req := CreateTipPaymentRequest{ Amount: 500, CardToken: "cnon:wrong-owner-tip", } 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++ { req := CreateTipPaymentRequest{ Amount: int64(200 + i*100), CardToken: fmt.Sprintf("cnon:multi-tip-%d", i), } 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_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() req := CreateTipPaymentRequest{ Amount: 500, CardToken: "cnon:tip-card", } 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{ CardNumber: "4111111111111111", Expiry: "12/30", CVC: "123", } 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_ExpiredCardRejected(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{ CardNumber: "4111111111111111", Expiry: "01/20", CVC: "123", } w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreatePaymentMethod_InvalidExpiryRejected(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 expiry string }{ {"bad format", "12-30"}, {"bad month", "13/30"}, {"bad year", "12/abc"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := CreatePaymentMethod reqBody := CreatePaymentMethodRequest{ CardNumber: "4111111111111111", Expiry: tt.expiry, CVC: "123", } w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, 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 number", CreatePaymentMethodRequest{Expiry: "12/30", CVC: "123"}}, {"no expiry", CreatePaymentMethodRequest{CardNumber: "4111111111111111", CVC: "123"}}, {"no cvc", CreatePaymentMethodRequest{CardNumber: "4111111111111111", Expiry: "12/30"}}, } 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{ CardNumber: "4111111111111111", Expiry: "12/30", CVC: "123", }, "", 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{ CardNumber: "4111111111111111", Expiry: "12/30", CVC: "123", } 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{ CardNumber: "5500000000000004", Expiry: "06/30", CVC: "456", } 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) 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) req := httptest.NewRequest("GET", "/api/checkout/1234567890abc/status?booking_id=abc", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", "1234567890abc") reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) w := httptest.NewRecorder() GetCheckoutStatus(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } 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() 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() 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, "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", "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() 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) 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() 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) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, 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()) } }