diff --git a/backend/handlers/payments/discount_preview_test.go b/backend/handlers/payments/discount_preview_test.go new file mode 100644 index 0000000..25c8fbe --- /dev/null +++ b/backend/handlers/payments/discount_preview_test.go @@ -0,0 +1,639 @@ +//go:build test && dev +// +build test,dev + +package payments + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/db" + "crussell/mw" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" +) + +// ============================================================================= +// GetDiscountPreviewHandler — GET /api/bookings/{id}/discount-preview +// ============================================================================= + +// setupDiscountPreviewTest creates a user, service, and booking for discount preview tests. +func setupDiscountPreviewTest(t *testing.T) (string, string, string) { + t.Helper() + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = db.DB.Exec(context.Background(), `UPDATE bookings SET status = 'confirmed' WHERE id = $1`, bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + token := jwt.GenerateUserToken(userID) + return userID, bookingID, token +} + +// serveDiscountPreviewHandler wires chi context with {id} param and serves the handler. +// Pass userID explicitly so the handler sees the correct user in context. +func serveDiscountPreviewHandler(bookingID, userID, token string) *httptest.ResponseRecorder { + handler := http.HandlerFunc(GetDiscountPreviewHandler) + + req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil) + req.Header.Set("Authorization", "Bearer "+token) + + ctx := req.Context() + ctx = context.WithValue(ctx, mw.UserIDKey, userID) + ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") + req = req.WithContext(ctx) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", bookingID) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// TestDiscountPreview_NoCampaigns returns eligible=false when no active campaigns exist. +func TestDiscountPreview_NoCampaigns(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Eligible { + t.Error("expected eligible=false with no active campaigns") + } + if len(resp.Discounts) != 0 { + t.Errorf("expected 0 discounts, got %d", len(resp.Discounts)) + } + if resp.OriginalTotal <= 0 { + t.Errorf("expected positive original_total, got %f", resp.OriginalTotal) + } + if resp.DiscountedTotal != resp.OriginalTotal { + t.Errorf("expected discounted_total == original_total with no discounts, got %f vs %f", + resp.DiscountedTotal, resp.OriginalTotal) + } +} + +// TestDiscountPreview_TimeBasedCampaign returns the correct discount for an active time-based campaign. +func TestDiscountPreview_TimeBasedCampaign(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + now := time.Now() + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ('Test Sale', 'time_based', 15, 'active', $1, $2, 0) + RETURNING id + `, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !resp.Eligible { + t.Fatal("expected eligible=true with active time-based campaign") + } + if len(resp.Discounts) != 1 { + t.Fatalf("expected 1 discount, got %d", len(resp.Discounts)) + } + + d := resp.Discounts[0] + if d.Source != "campaign" { + t.Errorf("expected source 'campaign', got %q", d.Source) + } + if d.Percent != 15 { + t.Errorf("expected 15 percent, got %f", d.Percent) + } + if d.Amount <= 0 { + t.Errorf("expected positive discount amount, got %f", d.Amount) + } + + expectedDiscount := resp.OriginalTotal * 15 / 100 + if d.Amount != expectedDiscount { + t.Errorf("expected discount amount %f, got %f", expectedDiscount, d.Amount) + } + + if resp.DiscountedTotal != resp.OriginalTotal-d.Amount { + t.Errorf("discounted_total %f should be original %f - discount %f", + resp.DiscountedTotal, resp.OriginalTotal, d.Amount) + } + + // Verify preview did NOT create any actual booking_discounts or payment records + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount) + if discountCount != 0 { + t.Errorf("preview should not create booking_discounts, found %d", discountCount) + } + + var paymentCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount) + if paymentCount != 0 { + t.Errorf("preview should not create discount payment records, found %d", paymentCount) + } + + // Verify campaign redemption count was NOT incremented + var timesRedeemed int + db.DB.QueryRow(context.Background(), + "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed) + if timesRedeemed != 0 { + t.Errorf("preview should not increment times_redeemed, got %d", timesRedeemed) + } +} + +// TestDiscountPreview_CampaignExhausted returns not eligible when a campaign has reached max_redemptions. +func TestDiscountPreview_CampaignExhausted(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + now := time.Now() + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) + VALUES ('Exhausted Campaign', 'time_based', 20, 'active', $1, $2, 5, 5) + `, now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Eligible { + t.Error("expected eligible=false when campaign is exhausted") + } +} + +// TestDiscountPreview_CampaignExpired returns not eligible for a past campaign. +func TestDiscountPreview_CampaignExpired(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ('Expired Campaign', 'time_based', 10, 'active', $1, $2) + `, time.Now().Add(-72*time.Hour), time.Now().Add(-24*time.Hour)) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + var resp DiscountPreviewResponse + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Eligible { + t.Error("expected eligible=false when campaign has expired") + } +} + +// TestDiscountPreview_CampaignNotStarted returns not eligible for a future campaign. +func TestDiscountPreview_CampaignNotStarted(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ('Future Campaign', 'time_based', 10, 'active', $1, $2) + `, time.Now().Add(24*time.Hour), time.Now().Add(72*time.Hour)) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + var resp DiscountPreviewResponse + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Eligible { + t.Error("expected eligible=false when campaign has not started") + } +} + +// TestDiscountPreview_MilestoneCampaign returns the discount for a milestone campaign the user has reached. +func TestDiscountPreview_MilestoneCampaign(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + // Create a completed booking so the user has a booking count of at least 1 (plus the current test booking) + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + pastBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create completed booking: %v", err) + } + // The fixture creates bookings with status 'pending' — mark as completed for milestone counting + _, err = db.DB.Exec(context.Background(), `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID) + if err != nil { + t.Fatalf("failed to mark booking as completed: %v", err) + } + + // Create milestone campaign for 1st booking + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value) + VALUES ('First Booking Bonus', 'milestone', 25, 'active', $1, $2, 'per_user_booking_count', 1) + `, time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to create milestone campaign: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !resp.Eligible { + t.Fatal("expected eligible=true for milestone campaign") + } + + foundMilestone := false + for _, d := range resp.Discounts { + if d.Percent == 25 { + foundMilestone = true + break + } + } + if !foundMilestone { + t.Error("expected a 25% milestone discount in the preview") + } +} + +// TestDiscountPreview_AlreadyApplied excludes discounts already applied to this booking. +func TestDiscountPreview_AlreadyApplied(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + now := time.Now() + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ('Already Applied', 'time_based', 10, 'active', $1, $2, 0) + RETURNING id + `, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // Simulate the discount already being applied by inserting a booking_discounts record + var bookingTotal float64 + db.DB.QueryRow(context.Background(), ` + SELECT COALESCE(SUM(price_val), 0) FROM ( + SELECT COALESCE(bs.override_price, s.price) AS price_val + FROM booking_services bs JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ) sub + `, bookingID).Scan(&bookingTotal) + discountAmount := bookingTotal * 10 / 100 + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 10, $4, $5) + `, bookingID, userID, campaignID, bookingTotal, discountAmount) + if err != nil { + t.Fatalf("failed to apply discount: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Eligible { + t.Error("expected eligible=false when discount already applied to this booking") + } +} + +// TestDiscountPreview_InvalidBookingID returns 400 for invalid booking ID. +func TestDiscountPreview_InvalidBookingID(t *testing.T) { + handler := http.HandlerFunc(GetDiscountPreviewHandler) + + req := httptest.NewRequest("GET", "/api/bookings/invalid/discount-preview", nil) + ctx := context.WithValue(req.Context(), mw.UserIDKey, "test-user-id") + ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") + req = req.WithContext(ctx) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "invalid-id") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid booking ID, got %d", w.Code) + } +} + +// TestDiscountPreview_MultipleCampaigns returns all eligible discounts. +func TestDiscountPreview_MultipleCampaigns(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + now := time.Now() + + // Create two active campaigns + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ('Summer Sale', 'time_based', 15, 'active', $1, $2) + `, now.Add(-48*time.Hour), now.Add(48*time.Hour)) + if err != nil { + t.Fatalf("failed to create first campaign: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) + VALUES ('Flash Sale', 'time_based', 20, 'active', $1, $2, 100, 0) + `, now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to create second campaign: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !resp.Eligible { + t.Fatal("expected eligible=true with multiple active campaigns") + } + if len(resp.Discounts) < 1 { + t.Fatalf("expected at least 1 discount, got %d", len(resp.Discounts)) + } +} + +// TestDiscountPreview_AnniversaryCampaign returns the correct discount for an anniversary milestone. +func TestDiscountPreview_AnniversaryCampaign(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + // Set first booking to be years ago so anniversary qualifies + db.DB.Exec(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') + `, userID, time.Date(2020, 1, 15, 10, 0, 0, 0, time.UTC)) + + // Create anniversary milestone campaign + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit) + VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years') + `, "One Year Anniversary") + if err != nil { + t.Fatalf("failed to create anniversary campaign: %v", err) + } + + w := serveDiscountPreviewHandler(bookingID, userID, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !resp.Eligible { + t.Fatal("expected eligible=true for anniversary campaign") + } + + foundAnniversary := false + for _, d := range resp.Discounts { + if d.Source == "campaign" && d.Percent == 20 { + foundAnniversary = true + break + } + } + if !foundAnniversary { + t.Error("expected a 20% anniversary discount in the preview") + } +} + +// TestDiscountPreview_PaymentLock prevents new discounts after a completed payment exists. +// After the first payment, the apply function applies discounts. After a second payment, +// no new discounts should be added. +func TestDiscountPreview_PaymentLock(t *testing.T) { + resetTestData(t) + + userID, bookingID, _ := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + now := time.Now() + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) + RETURNING id + `, "Payment Lock Test", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // First payment — discount should be applied + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'deposit', 'online_square', 1000, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create first payment: %v", err) + } + + applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount) + if discountCount != 1 { + t.Errorf("expected 1 discount after first payment, got %d", discountCount) + } + + // Second payment — NO new discounts should be added (lock active) + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'full', 'online_square', 4000, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create second payment: %v", err) + } + + applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount) + if discountCount != 1 { + t.Errorf("expected still 1 discount after second payment (lock active), got %d", discountCount) + } + + // Verify campaign was only redeemed once + var timesRedeemed int + db.DB.QueryRow(context.Background(), + "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed) + if timesRedeemed != 1 { + t.Errorf("expected 1 redemption, got %d", timesRedeemed) + } +} + +// TestDiscountPreview_BookingNoServices returns not eligible for a booking with no services. +func TestDiscountPreview_BookingNoServices(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Create a booking without any services + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW(), 'confirmed') RETURNING id + `, userID).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + now := time.Now() + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ('Test Sale', 'time_based', 10, 'active', $1, $2) + `, now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + token := jwt.GenerateUserToken(userID) + w := serveDiscountPreviewHandler(bookingID, userID, token) + + var resp DiscountPreviewResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Eligible { + t.Error("expected eligible=false for booking with no services") + } + if resp.OriginalTotal != 0 { + t.Errorf("expected original_total=0, got %f", resp.OriginalTotal) + } +} + +// TestDiscountPreview_ReturnsReadOnly confirms the preview endpoint never modifies state +// even when eligible campaigns exist. +func TestDiscountPreview_ReturnsReadOnly(t *testing.T) { + resetTestData(t) + + userID, bookingID, token := setupDiscountPreviewTest(t) + defer fixtures.DeleteUser(db.DB, userID) + + now := time.Now() + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ('Read-Only Check', 'time_based', 10, 'active', $1, $2) + `, now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // Call preview twice — both should return same result and neither should create records + for i := 0; i < 2; i++ { + w := serveDiscountPreviewHandler(bookingID, userID, token) + + if w.Code != http.StatusOK { + t.Fatalf("attempt %d: expected 200, got %d", i+1, w.Code) + } + + var resp DiscountPreviewResponse + json.NewDecoder(w.Body).Decode(&resp) + if !resp.Eligible { + t.Fatalf("attempt %d: expected eligible=true", i+1) + } + } + + // Verify no records were created after two calls + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount) + if discountCount != 0 { + t.Errorf("preview created %d booking_discounts (should be 0)", discountCount) + } +} diff --git a/backend/handlers/payments/payment_status_test.go b/backend/handlers/payments/payment_status_test.go new file mode 100644 index 0000000..bbf0f00 --- /dev/null +++ b/backend/handlers/payments/payment_status_test.go @@ -0,0 +1,525 @@ +//go:build test && dev +// +build test,dev + +package payments + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/db" + "crussell/mw" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" +) + +// ============================================================================= +// Unit tests — IsValidBookingStatusForPayment (pure function, no DB) +// ============================================================================= + +func TestIsValidBookingStatusForPayment_Confirmed(t *testing.T) { + if !IsValidBookingStatusForPayment("confirmed") { + t.Error("expected 'confirmed' to be valid for payment") + } +} + +func TestIsValidBookingStatusForPayment_Pending(t *testing.T) { + if !IsValidBookingStatusForPayment("pending") { + t.Error("expected 'pending' to be valid for payment") + } +} + +func TestIsValidBookingStatusForPayment_PendingRelease(t *testing.T) { + if !IsValidBookingStatusForPayment("pending_release") { + t.Error("expected 'pending_release' to be valid for payment") + } +} + +func TestIsValidBookingStatusForPayment_InProgress(t *testing.T) { + if !IsValidBookingStatusForPayment("in_progress") { + t.Error("expected 'in_progress' to be valid for payment") + } +} + +func TestIsValidBookingStatusForPayment_RejectsDepositLapsed(t *testing.T) { + if IsValidBookingStatusForPayment("deposit_lapsed") { + t.Error("expected 'deposit_lapsed' to be rejected for payment") + } +} + +func TestIsValidBookingStatusForPayment_RejectsClientCancelled(t *testing.T) { + if IsValidBookingStatusForPayment("client_cancelled") { + t.Error("expected 'client_cancelled' to be rejected for payment") + } +} + +func TestIsValidBookingStatusForPayment_RejectsWeCancelled(t *testing.T) { + if IsValidBookingStatusForPayment("we_cancelled") { + t.Error("expected 'we_cancelled' to be rejected for payment") + } +} + +func TestIsValidBookingStatusForPayment_RejectsCompleted(t *testing.T) { + if IsValidBookingStatusForPayment("completed") { + t.Error("expected 'completed' to be rejected for payment") + } +} + +func TestIsValidBookingStatusForPayment_RejectsNoShow(t *testing.T) { + if IsValidBookingStatusForPayment("no_show") { + t.Error("expected 'no_show' to be rejected for payment") + } +} + +// ============================================================================= +// Integration tests — CreateBookingPayment status guard +// ============================================================================= + +// setupPaymentStatusTest creates a user, service, and booking with the given +// status, returning the userID, bookingID, and user token. +func setupPaymentStatusTest(t *testing.T, status string) (string, string, string) { + t.Helper() + + 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) + } + + // Use a far-future date so the booking is never in the cleanup window. + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create test booking: %v", err) + } + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", status, bookingID) + if err != nil { + t.Fatalf("failed to set booking status to %q: %v", status, err) + } + + userToken := jwt.GenerateUserToken(userID) + return userID, bookingID, userToken +} + +func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + + cardToken := "cnon:test-card-nonce" + req := CreateBookingPaymentRequest{ + Amount: 2500, + PaymentType: "deposit", + NewCardToken: &cardToken, + SaveCard: true, + IdempotencyKey: "deposit-pending-release-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify the booking was promoted back to confirmed. + var status string + err := db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if status != "confirmed" { + t.Errorf("expected booking promoted from pending_release to 'confirmed', got %q", status) + } +} + +func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + + // Pay 15% (£7.50 on a £50 booking) — below 20% threshold. + cardToken := "cnon:test-card-nonce" + req := CreateBookingPaymentRequest{ + Amount: 750, // £7.50 = 15% of £50 + PaymentType: "partial", + NewCardToken: &cardToken, + SaveCard: false, + IdempotencyKey: "below-threshold-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Payment below 20% threshold — booking should remain pending_release. + var status string + err := db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query booking: %v", err) + } + if status != "pending_release" { + t.Errorf("expected booking to remain pending_release, got %q", status) + } +} + +func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + + // Pay £25 via "balance" type — still should meet the 20% threshold. + cardToken := "cnon:test-card-nonce" + req := CreateBookingPaymentRequest{ + Amount: 2500, // £25.00 = 50% of £50 + PaymentType: "balance", + NewCardToken: &cardToken, + SaveCard: false, + IdempotencyKey: "balance-promotes-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var status string + err := db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query booking: %v", err) + } + if status != "confirmed" { + t.Errorf("expected booking promoted to 'confirmed', got %q", status) + } +} + +func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + + cardToken := "cnon:test-card-nonce" + req := CreateBookingPaymentRequest{ + Amount: 2500, + PaymentType: "deposit", + NewCardToken: &cardToken, + SaveCard: false, + IdempotencyKey: "deposit-confirmed-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateBookingPayment_RejectsPending(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "pending") + + cardToken := "cnon:test-card-nonce" + req := CreateBookingPaymentRequest{ + Amount: 2500, + PaymentType: "deposit", + NewCardToken: &cardToken, + SaveCard: false, + IdempotencyKey: "deposit-pending-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409 for pending booking (not yet confirmed), got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed") + + cardToken := "cnon:test-card-nonce" + req := CreateBookingPaymentRequest{ + Amount: 2500, + PaymentType: "deposit", + NewCardToken: &cardToken, + SaveCard: false, + IdempotencyKey: "reject-lapsed-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) { + resetTestData(t) + + _, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled") + + cardToken := "cnon:test-card-nonce" + req := CreateBookingPaymentRequest{ + Amount: 2500, + PaymentType: "deposit", + NewCardToken: &cardToken, + SaveCard: false, + IdempotencyKey: "reject-cancelled-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Payment Lock Tests +// ============================================================================= + +func paymentLockRequest(method, path string, token string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + + rctx := chi.NewRouteContext() + bookingID, _ := extractPaymentIDFromPath(path) + rctx.URLParams.Add("id", bookingID) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + + if token != "" { + if info := extractUserFromTestJWT(token); info != nil { + ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) + ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) + } + } + + AcquirePaymentLock(w, req.WithContext(ctx)) + return w +} + +func TestAcquirePaymentLock_Confirmed(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusOK { + t.Errorf("expected 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_InProgress(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "in_progress") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusOK { + t.Errorf("expected 200 for in_progress booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_PendingRelease(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "pending_release") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusOK { + t.Errorf("expected 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_RejectsDepositLapsed(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_RejectsClientCancelled(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_RejectsWeCancelled(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "we_cancelled") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for we_cancelled booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_RejectsNoShow(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "no_show") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for no_show booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_RejectsPending(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "pending") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for pending booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestAcquirePaymentLock_RejectsCompleted(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "completed") + w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for completed booking, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// ReleasePaymentLock — DELETE /api/bookings/{id}/payment-lock +// ============================================================================= + +func releasePaymentLockRequest(path string, token string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest("DELETE", path, nil) + req.Header.Set("Authorization", "Bearer "+token) + + rctx := chi.NewRouteContext() + bookingID, _ := extractPaymentIDFromPath(path) + rctx.URLParams.Add("id", bookingID) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + + if token != "" { + if info := extractUserFromTestJWT(token); info != nil { + ctx = context.WithValue(ctx, mw.UserIDKey, info.userID) + ctx = context.WithValue(ctx, mw.UserRoleKey, info.role) + } + } + + ReleasePaymentLock(w, req.WithContext(ctx)) + return w +} + +func TestReleasePaymentLock_HappyPath(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + + // First, acquire the lock to create a PAYMENT_IN_FLIGHT time_blocker + lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if lockW.Code != http.StatusOK { + t.Fatalf("expected 200 when acquiring lock, got %d", lockW.Code) + } + + // Verify the lock exists in the DB + var lockCount int + err := db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) + if err != nil { + t.Fatalf("failed to query time_blockers: %v", err) + } + if lockCount != 1 { + t.Fatalf("expected 1 time_blocker before release, got %d", lockCount) + } + + // Now release the lock + w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 No Content, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify the lock was removed from the DB + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) + if err != nil { + t.Fatalf("failed to query time_blockers after release: %v", err) + } + if lockCount != 0 { + t.Errorf("expected 0 time_blockers after release, got %d", lockCount) + } +} + +func TestReleasePaymentLock_NoExistingLock(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + + // Release without acquiring first — should be idempotent (204) + w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 No Content for idempotent release, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestReleasePaymentLock_InvalidBookingID(t *testing.T) { + w := releasePaymentLockRequest("/api/bookings/invalid/payment-lock", "") + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for invalid booking ID, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestReleasePaymentLock_EmptyBookingID(t *testing.T) { + w := releasePaymentLockRequest("/api/bookings//payment-lock", "") + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for empty booking ID, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestReleasePaymentLock_AfterMultipleAcquires(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupPaymentStatusTest(t, "confirmed") + + // Acquire the lock twice — AcquirePaymentLock should be idempotent + lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if lockW.Code != http.StatusOK { + t.Fatalf("expected 200 on first acquire, got %d", lockW.Code) + } + lockW = paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken) + if lockW.Code != http.StatusOK { + t.Fatalf("expected 200 on second acquire, got %d", lockW.Code) + } + + // Release should still succeed + w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 No Content after multiple acquires, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify all locks are gone + var lockCount int + err := db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) + if err != nil { + t.Fatalf("failed to query time_blockers: %v", err) + } + if lockCount != 0 { + t.Errorf("expected 0 time_blockers after release, got %d", lockCount) + } +} diff --git a/backend/handlers/payments/refund_exclude_test.go b/backend/handlers/payments/refund_exclude_test.go new file mode 100644 index 0000000..a7a2b68 --- /dev/null +++ b/backend/handlers/payments/refund_exclude_test.go @@ -0,0 +1,110 @@ +//go:build test && dev +// +build test,dev + +package payments + +import ( + "context" + "testing" + "time" + + "crussell/db" + "crussell/testutils/fixtures" +) + +func setupRefundTestWithDiscount(t *testing.T) (string, string, float64) { + t.Helper() + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + // Insert a real cash payment of 50 + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'full', 'cash', 5000, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create cash payment: %v", err) + } + + // Insert a discount payment record (should be excluded from refund) + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'partial', 'discount', 500, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create discount payment: %v", err) + } + + // Insert an on_the_house payment record (should also be excluded) + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'partial', 'on_the_house', 1000, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create on_the_house payment: %v", err) + } + + return userID, bookingID, 50.0 +} + +func TestProcessCancellationRefund_ExcludesDiscountPayments(t *testing.T) { + resetTestData(t) + userID, bookingID, total := setupRefundTestWithDiscount(t) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour) // >72h before + + result, err := ProcessCancellationRefund( + context.Background(), bookingID, total, 50, + farFuture, now, "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + + // Refundable should be 50 (only the cash payment), not 65 (which would include discount + OTH) + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50 (excluding discount/OTH), got %.2f", result.RefundableAmount) + } +} + +func TestProcessCancellationRefund_ExcludesOnTheHousePayments(t *testing.T) { + resetTestData(t) + userID, bookingID, total := setupRefundTestWithDiscount(t) + + // Make on_the_house the only non-discount payment by marking the 50 cash as a payment that gets refunded + // but also add a pure on_the_house booking with no real money + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'partial', 'discount', 2500, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create extra discount: %v", err) + } + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour) + + result, err := ProcessCancellationRefund( + context.Background(), bookingID, total, 50, + farFuture, now, "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + + // Refundable should be based on the actual cash payment only + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50 (real money only), got %.2f", result.RefundableAmount) + } +}