diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index db61d61..a51d6b7 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -212,7 +213,7 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", PaymentID: existing.ID, - Amount: existing.Amount, + Amount: int64(existing.Amount * 100), CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, @@ -227,10 +228,10 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { PaymentType: "full", PaymentMethod: "in_person_card", Status: "completed", - Amount: paymentResult.Amount, + Amount: float64(paymentResult.Amount) / 100.0, SquarePaymentID: &paymentResult.SquarePayID, IdempotencyKey: &idempotencyKey, - Fees: paymentResult.Fees, + Fees: float64(paymentResult.Fees) / 100.0, CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -294,6 +295,19 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { service := NewPaymentService() + if req.PaymentType == "partial" { + remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID) + if err != nil { + log.Printf("Failed to get remaining balance: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -321,7 +335,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { BookingID: existingPayment.BookingID, PaymentType: existingPayment.PaymentType, Status: existingPayment.Status, - Amount: existingPayment.Amount, + Amount: int64(existingPayment.Amount * 100), CreatedAt: existingPayment.CreatedAt.Format(time.RFC3339), }) return @@ -386,10 +400,10 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { PaymentType: req.PaymentType, PaymentMethod: "online_square", Status: "completed", - Amount: req.Amount, + Amount: float64(req.Amount) / 100.0, SquarePaymentID: &paymentResult.SquarePayID, IdempotencyKey: &req.IdempotencyKey, - Fees: fees, + Fees: float64(fees) / 100.0, UserSavedCardID: savedCardID, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -468,6 +482,46 @@ func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) } +type CreatePaymentMethodRequest struct { + CardNumber string `json:"card_number"` + Expiry string `json:"expiry"` + CVC string `json:"cvc"` +} + +func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + var req CreatePaymentMethodRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if req.CardNumber == "" || req.Expiry == "" || req.CVC == "" { + http.Error(w, "Card number, expiry, and CVC are required", http.StatusBadRequest) + return + } + + service := NewPaymentService() + card, err := service.CreatePaymentMethodFromDetails(r.Context(), userID, req.CardNumber, req.Expiry, req.CVC) + if err != nil { + if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "expired") { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("Failed to create payment method: %v", err) + http.Error(w, "Failed to add card", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(card) +} + func RefundPayment(w http.ResponseWriter, r *http.Request) { paymentID := chi.URLParam(r, "payment_id") if paymentID == "" { @@ -528,7 +582,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) { return } - if req.Amount+alreadyRefunded > payment.Amount { + if req.Amount+alreadyRefunded > int64(payment.Amount*100) { http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest) return } @@ -551,7 +605,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) { record := RefundRecord{ PaymentID: paymentID, BookingID: payment.BookingID, - Amount: req.Amount, + Amount: float64(req.Amount) / 100.0, SquareRefundID: &squareRefundID, Status: "completed", Reason: req.Reason, @@ -566,7 +620,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) { return } - if payment.PaymentType == "deposit" && (req.Amount+alreadyRefunded) >= payment.Amount { + if payment.PaymentType == "deposit" && (req.Amount+alreadyRefunded) >= int64(payment.Amount*100) { err = service.UpdateBookingDepositPaid(r.Context(), payment.BookingID, false) if err != nil { log.Printf("Failed to update deposit paid: %v", err) @@ -667,7 +721,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { PaymentType: "tip", PaymentMethod: "online_square", Status: "completed", - Amount: req.Amount, + Amount: float64(req.Amount) / 100.0, SquarePaymentID: &paymentResult.SquarePayID, IdempotencyKey: &idempotencyKey, Fees: 0, @@ -741,7 +795,8 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { BookingID: p.BookingID, PaymentType: p.PaymentType, Status: p.Status, - Amount: p.Amount, + Amount: int64(p.Amount * 100), + CardLast4: p.CardLast4, CreatedAt: p.CreatedAt.Format(time.RFC3339), } } @@ -751,7 +806,7 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { refunds[i] = RefundResponse{ ID: rf.ID, PaymentID: rf.PaymentID, - Amount: rf.Amount, + Amount: int64(rf.Amount * 100), Status: rf.Status, Reason: rf.Reason, CreatedAt: rf.CreatedAt.Format(time.RFC3339), @@ -760,10 +815,10 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentSummaryResponse{ - TotalAmount: summary.TotalAmount, - PaidAmount: summary.PaidAmount, - RefundedAmount: summary.RefundedAmount, - RemainingAmount: summary.RemainingAmount, + TotalAmount: int64(summary.TotalAmount * 100), + PaidAmount: int64(summary.PaidAmount * 100), + RefundedAmount: int64(summary.RefundedAmount * 100), + RemainingAmount: int64(summary.RemainingAmount * 100), Payments: payments, Refunds: refunds, }) diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index 1d954b1..8bfdd7a 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -8,11 +8,11 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" "testing" - "time" "crussell/db" "crussell/internal/square" @@ -22,7 +22,6 @@ import ( "crussell/testutils/testdb" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" ) func TestMain(m *testing.M) { @@ -140,7 +139,7 @@ func splitToken(token string) []string { } func base64URLDecode(s string) ([]byte, error) { - return base64.URLEncoding.DecodeString(s) + return base64.RawURLEncoding.DecodeString(s) } func extractPaymentIDFromPath(path string) (string, string) { @@ -150,7 +149,7 @@ func extractPaymentIDFromPath(path string) (string, string) { }{ {"/api/admin/payments/", "payment_id"}, {"/api/admin/bookings/", "id"}, - {"/api/admin/bookings/", "id"}, + {"/api/bookings/", "id"}, {"/api/user/payment-methods/", "id"}, } for _, p := range patterns { @@ -222,8 +221,6 @@ func TestTerminalPayment_HappyPath(t *testing.T) { if count != 0 { t.Errorf("expected 0 payments (created on completion), got %d", count) } - - _ = userID } func setupTestData(t *testing.T) (string, string, string) { @@ -519,7 +516,12 @@ func TestRefund_FullRefund(t *testing.T) { _, bookingID, _ := setupTestData(t) - adminToken := jwt.GenerateAdminToken() + 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 { @@ -563,7 +565,12 @@ func TestRefund_PartialRefund(t *testing.T) { _, bookingID, _ := setupTestData(t) - adminToken := jwt.GenerateAdminToken() + 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 { @@ -605,7 +612,7 @@ func TestRefund_OverRefundRejected(t *testing.T) { adminToken := jwt.GenerateAdminToken() - paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed") + 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) } @@ -771,7 +778,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) { 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, w.Body.String()) + t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String()) } var resp2 PaymentResponse @@ -850,4 +857,779 @@ func TestSquareWebhook_DevMode_NoSignature(t *testing.T) { _ = 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") + } } \ No newline at end of file diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index 727faea..41b83a1 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -5,6 +5,9 @@ import ( "crussell/db" "crussell/internal/square" "errors" + "fmt" + "strconv" + "strings" "time" "github.com/jackc/pgx/v5" @@ -35,15 +38,16 @@ type PaymentRecord struct { VendorCode *string InvoiceNumber *int Status string - Amount int64 + Amount float64 + CardLast4 string IsVATApplicable bool VATRate *float64 - VATAmount *int64 - NetAmount *int64 + VATAmount *float64 + NetAmount *float64 UserSavedCardID *string SquarePaymentID *string IdempotencyKey *string - Fees int64 + Fees float64 CreatedAt time.Time UpdatedAt time.Time CreatedBy *string @@ -53,7 +57,7 @@ type RefundRecord struct { ID string PaymentID string BookingID string - Amount int64 + Amount float64 SquareRefundID *string Status string Reason string @@ -62,19 +66,19 @@ type RefundRecord struct { } type PaymentSummary struct { - TotalAmount int64 - PaidAmount int64 - RefundedAmount int64 - RemainingAmount int64 + TotalAmount float64 + PaidAmount float64 + RefundedAmount float64 + RemainingAmount float64 Payments []PaymentRecord Refunds []RefundRecord } -func (s *PaymentService) CalculateFees(amount int64, method string) int64 { +func (s *PaymentService) CalculateFees(amount int64, method string) float64 { if method == "online" { - return (amount * 14 / 1000) + 25 + return float64((amount * 14 / 1000) + 25) / 100.0 } - return (amount * 175 / 10000) + return float64(amount * 175 / 10000) / 100.0 } func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) { @@ -143,7 +147,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID Refunds: []RefundRecord{}, } - var totalAmount int64 + var totalAmount float64 err := db.DB.QueryRow(ctx, ` SELECT COALESCE(SUM( COALESCE(bs.override_price, s.price) @@ -159,12 +163,13 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID summary.TotalAmount = totalAmount rows, err := db.DB.Query(ctx, ` - SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number, - status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, - user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by - FROM payments - WHERE booking_id = $1 - ORDER BY created_at ASC + SELECT p.id, p.booking_id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number, + p.status, p.amount, COALESCE(usc.last_4, ''), p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount, + p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by + FROM payments p + LEFT JOIN user_saved_cards usc ON p.user_saved_card_id = usc.id + WHERE p.booking_id = $1 + ORDER BY p.created_at ASC `, bookingID) if err != nil { @@ -172,12 +177,12 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID } defer rows.Close() - var paidAmount int64 + var paidAmount float64 for rows.Next() { var p PaymentRecord err := rows.Scan( &p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber, - &p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount, + &p.Status, &p.Amount, &p.CardLast4, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount, &p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees, &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, ) @@ -203,7 +208,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID } defer refundRows.Close() - var refundedAmount int64 + var refundedAmount float64 for refundRows.Next() { var r RefundRecord err := refundRows.Scan( @@ -268,7 +273,7 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) ( } func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) { - var amount int64 + var amount float64 err := db.DB.QueryRow(ctx, ` SELECT COALESCE(SUM(amount), 0) FROM refunds WHERE payment_id = $1 AND status = 'completed' @@ -277,7 +282,7 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID if err != nil { return 0, err } - return amount, nil + return int64(amount * 100), nil } func (s *PaymentService) UpdateBookingDepositPaid(ctx context.Context, bookingID string, depositPaid bool) error { @@ -318,6 +323,29 @@ func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) return userID, nil } +func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bookingID string) (int64, error) { + var remainingCents int64 + err := db.DB.QueryRow(ctx, ` + WITH booking_total AS ( + SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) AS total_pounds + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ), + paid_total AS ( + SELECT COALESCE(SUM(amount), 0) AS paid_pounds + FROM payments + WHERE booking_id = $1 AND status = 'completed' + ) + SELECT GREATEST(0, ROUND((bt.total_pounds - pt.paid_pounds) * 100))::bigint + FROM booking_total bt, paid_total pt + `, bookingID).Scan(&remainingCents) + if err != nil { + return 0, err + } + return remainingCents, nil +} + func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) { rows, err := db.DB.Query(ctx, ` SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default @@ -359,6 +387,57 @@ func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID return err } +func (s *PaymentService) CreatePaymentMethodFromDetails(ctx context.Context, userID, cardNumber, expiry, cvc string) (*SavedCard, error) { + parts := strings.Split(expiry, "/") + if len(parts) != 2 { + return nil, errors.New("invalid expiry format, use MM/YY") + } + expMonth, err := strconv.Atoi(parts[0]) + if err != nil || expMonth < 1 || expMonth > 12 { + return nil, errors.New("invalid expiry month") + } + expYear, err := strconv.Atoi(parts[1]) + if err != nil || expYear < 0 || expYear > 99 { + return nil, errors.New("invalid expiry year") + } + expYear += 2000 + + // Check if card is expired + now := time.Now() + expiryDate := time.Date(expYear, time.Month(expMonth), 1, 0, 0, 0, 0, now.Location()) + if expiryDate.Before(now) { + return nil, errors.New("card has expired") + } + + cardOnFile, err := SquareClient.CreateCardOnFileRaw(ctx, userID, cardNumber, expMonth, expYear, cvc) + if err != nil { + return nil, fmt.Errorf("failed to tokenize card: %w", err) + } + + var savedCardID string + var isDefault bool + err = db.DB.QueryRow(ctx, ` + INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) + SELECT $1, $2, $3, $4, $5, $6, $7, + NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL) + RETURNING id, is_default + `, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, expMonth, expYear, cardOnFile.Fingerprint).Scan(&savedCardID, &isDefault) + if err != nil { + return nil, fmt.Errorf("failed to save card: %w", err) + } + + return &SavedCard{ + ID: savedCardID, + SquareCardID: cardOnFile.CardID, + Brand: cardOnFile.Brand, + Last4: cardOnFile.Last4, + ExpMonth: expMonth, + ExpYear: expYear, + Fingerprint: cardOnFile.Fingerprint, + IsDefault: isDefault, + }, nil +} + func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) { var id string err := db.DB.QueryRow(ctx, ` diff --git a/backend/handlers/payments/validators.go b/backend/handlers/payments/validators.go index 5fa73ed..5919a9b 100644 --- a/backend/handlers/payments/validators.go +++ b/backend/handlers/payments/validators.go @@ -1,6 +1,9 @@ package payments -import "errors" +import ( + "errors" + "fmt" +) // Valid payment types var validPaymentTypes = map[string]bool{ @@ -11,11 +14,25 @@ var validPaymentTypes = map[string]bool{ "partial": true, } -// ValidateAmount checks that amount is greater than 0 +// ValidateAmount checks that amount is greater than 0 and has valid precision (max 2 decimal places when in pence) func ValidateAmount(amount int64) error { if amount <= 0 { return errors.New("amount must be greater than 0") } + // Amount is in pence (integer), so no precision issues possible at this level + // The frontend must ensure the input has max 2 decimal places before converting to pence + return nil +} + +// ValidatePartialAmount checks that the partial amount doesn't exceed the remaining balance +func ValidatePartialAmount(amountCents int64, remainingCents int64) error { + if amountCents > remainingCents { + return fmt.Errorf("partial amount (£%.2f) exceeds remaining balance (£%.2f)", + float64(amountCents)/100, float64(remainingCents)/100) + } + if amountCents <= 0 { + return errors.New("amount must be greater than 0") + } return nil }