//go:build test && dev // +build test,dev package payments import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "testing" "crussell/db" "crussell/internal/square" "crussell/mw" "crussell/testutils/fixtures" "crussell/testutils/jwt" "crussell/testutils/testdb" "github.com/go-chi/chi/v5" ) func TestMain(m *testing.M) { pool, err := testdb.NewPool("") if err != nil { panic(err) } testdb.Migrate(&testing.T{}, pool) db.DB = pool jwt.Init() square.Client = square.NewDevClient() SquareClient = square.Client code := m.Run() pool.Close() os.Exit(code) } func resetTestData(t *testing.T) { t.Helper() testdb.TruncateTables(t, db.DB) } func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder { return makePaymentAuthRequest(handler, method, path, body, token, "") } func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string) *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(req.Context(), 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) { resetTestData(t) _, bookingID, _ := setupTestData(t) adminToken := jwt.GenerateAdminToken() req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", TipEnabled: true, } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken) 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 := db.DB.QueryRow(context.Background(), "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) (string, string, string) { userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = db.DB.Exec(context.Background(), "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) { resetTestData(t) _, bookingID, _ := setupTestData(t) 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) 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(db.DB, 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) 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) { resetTestData(t) adminToken := jwt.GenerateAdminToken() req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", } handler := CreateTerminalPayment w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken) 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) { resetTestData(t) userID, bookingID, _ := setupTestData(t) 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) 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 := db.DB.QueryRow(context.Background(), "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) { resetTestData(t) userID, bookingID, _ := setupTestData(t) cardID, err := fixtures.CreateTestPaymentMethod(db.DB, 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) 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) { resetTestData(t) _, bookingID, _ := setupTestData(t) otherUserID, err := fixtures.CreateTestUser(db.DB) 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) 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_card_1", "VISA", "1111") if err != nil { t.Fatalf("failed to create payment method 1: %v", err) } _, err = fixtures.CreateTestPaymentMethod(db.DB, 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) 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(db.DB, 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) 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) { resetTestData(t) _, bookingID, _ := setupTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_123" _, err = db.DB.Exec(context.Background(), "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) 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) { resetTestData(t) _, bookingID, _ := setupTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_456" _, err = db.DB.Exec(context.Background(), "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) 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) { resetTestData(t) _, bookingID, _ := setupTestData(t) adminToken := jwt.GenerateAdminToken() paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50.00, "in_person_card", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } squarePaymentID := "sqp_test_789" _, err = db.DB.Exec(context.Background(), "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) 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) { resetTestData(t) adminToken := jwt.GenerateAdminToken() req := RefundRequest{ Amount: 1000, Reason: "test", } handler := RefundPayment w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken) 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) { resetTestData(t) _, bookingID, _ := setupTestData(t) adminToken := jwt.GenerateAdminToken() paymentID, err := fixtures.CreateTestPayment(db.DB, 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) 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) { resetTestData(t) userID, bookingID, _ := setupTestData(t) _, err := fixtures.CreateTestPayment(db.DB, 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) 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) { resetTestData(t) userID, bookingID, _ := setupTestData(t) 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) 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) { resetTestData(t) userID, bookingID, _ := setupTestData(t) 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) 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) 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 := db.DB.QueryRow(context.Background(), "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) { resetTestData(t) userID, bookingID, _ := setupTestData(t) 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) 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) if w2.Code != http.StatusOK { t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var count int err := db.DB.QueryRow(context.Background(), "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 (different keys), got %d", count) } } func TestSquareWebhook_DevMode_NoSignature(t *testing.T) { resetTestData(t) req := httptest.NewRequest("POST", "/api/webhooks/square", nil) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() _ = req _ = w t.Skip("webhook handler tested in webhooks package") } // ============================================================ // User Booking Payment Tests — deposit, full, partial, balance // ============================================================ func setupDepositBooking(t *testing.T) (string, string) { userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = db.DB.Exec(context.Background(), "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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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 := db.DB.QueryRow(context.Background(), "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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) } } func TestBookingPayment_ZeroAmountRejected(t *testing.T) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) { resetTestData(t) _, bookingID := setupDepositBooking(t) 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, "") 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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) if w2.Code != http.StatusOK { t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var count int err := db.DB.QueryRow(context.Background(), "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) { resetTestData(t) userID, bookingID := setupDepositBooking(t) 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) 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) if w2.Code != http.StatusOK { t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var count int err := db.DB.QueryRow(context.Background(), "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) { resetTestData(t) _, bookingID, _ := setupTestData(t) _, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } otherUserID, err := fixtures.CreateTestUser(db.DB) 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) 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) { resetTestData(t) userID, bookingID, _ := setupTestData(t) _, err := fixtures.CreateTestPayment(db.DB, 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) 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 = db.DB.QueryRow(context.Background(), "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 TestGetUserPaymentMethods_NoCards(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) 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) 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_wrong_owner", "VISA", "0000") if err != nil { t.Fatalf("failed to create payment method: %v", err) } otherUserID, err := fixtures.CreateTestUser(db.DB) 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) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var count int err = db.DB.QueryRow(context.Background(), "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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } service := NewPaymentService() initialRemaining, err := service.GetBookingRemainingBalanceCents(context.Background(), 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(context.Background(), PaymentRecord{ BookingID: bookingID, PaymentType: "partial", PaymentMethod: "cash", Status: "completed", Amount: 20.00, }) if err != nil { t.Fatalf("failed to create payment: %v", err) } afterPartial, err := service.GetBookingRemainingBalanceCents(context.Background(), 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(context.Background(), PaymentRecord{ BookingID: bookingID, PaymentType: "balance", PaymentMethod: "cash", Status: "completed", Amount: float64(afterPartial) / 100.0, }) if err != nil { t.Fatalf("failed to create payment: %v", err) } afterFull, err := service.GetBookingRemainingBalanceCents(context.Background(), 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) 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) 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) 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) 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) 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) 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) 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) 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) { resetTestData(t) handler := CreatePaymentMethod w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{ CardNumber: "4111111111111111", Expiry: "12/30", CVC: "123", }, "") 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) 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) 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) 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") } }