//go:build test && dev package payments import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/db" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" ) // ============================================================================= // GetDiscountPreviewHandler — GET /api/bookings/{id}/discount-preview // ============================================================================= // setupDiscountPreviewTest creates a user, service, and booking for discount preview tests. func setupDiscountPreviewTest(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) { t.Helper() userID, err := fixtures.CreateTestUser(q) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(q) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(q, 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 = q.Exec(ctx, `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, baseCtx ...context.Context) *httptest.ResponseRecorder { handler := http.HandlerFunc(GetDiscountPreviewHandler) req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil) req.Header.Set("Authorization", "Bearer "+token) base := context.Background() if len(baseCtx) > 0 { base = baseCtx[0] } ctx := context.WithValue(base, 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := clock.Now() var campaignID string err := tx.QueryRow(ctx, ` 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, ctx) 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 tx.QueryRow(ctx, "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 tx.QueryRow(ctx, "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 tx.QueryRow(ctx, "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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := clock.Now() _, err := tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Expired Campaign', 'time_based', 10, 'active', $1, $2) `, clock.Now().Add(-72*time.Hour), clock.Now().Add(-24*time.Hour)) if err != nil { t.Fatalf("failed to create campaign: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Future Campaign', 'time_based', 10, 'active', $1, $2) `, clock.Now().Add(24*time.Hour), clock.Now().Add(72*time.Hour)) if err != nil { t.Fatalf("failed to create campaign: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) // Create a completed booking so the user has a booking count of at least 1 (plus the current test booking) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, 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 = tx.Exec(ctx, `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 = tx.Exec(ctx, ` 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) `, clock.Now().Add(-24*time.Hour), clock.Now().Add(24*time.Hour)) if err != nil { t.Fatalf("failed to create milestone campaign: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := clock.Now() var campaignID string err := tx.QueryRow(ctx, ` 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 tx.QueryRow(ctx, ` 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 = tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := clock.Now() // Create two active campaigns _, err := tx.Exec(ctx, ` 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 = tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) // Set first booking to be years ago so anniversary qualifies tx.Exec(ctx, ` 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 := tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx) now := clock.Now() var campaignID string err := tx.QueryRow(ctx, ` 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. A £10 deposit on the £50 // booking leaves enough headroom for the 10% (£5) discount to pass the F1 // over-credit cap (a full/overpaid fixture would now correctly skip it). _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 10.00, 'completed', NOW(), NOW()) `, bookingID) if err != nil { t.Fatalf("failed to create first payment: %v", err) } applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil) var discountCount int tx.QueryRow(ctx, "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). The £40 // balance completes the £50 booking; the 2-completed-payment lock in // ComputeEligibleDiscounts is what refuses the second application. _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'online_square', 40.00, 'completed', NOW(), NOW()) `, bookingID) if err != nil { t.Fatalf("failed to create second payment: %v", err) } applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil) tx.QueryRow(ctx, "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 tx.QueryRow(ctx, "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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create a booking without any services var bookingID string err = tx.QueryRow(ctx, ` 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 := clock.Now() _, err = tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) now := clock.Now() // Create an active campaign var campaignID string err := tx.QueryRow(ctx, ` 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 `, "Test Sale", 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, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp DiscountPreviewResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if !resp.Eligible { t.Error("expected eligible=true when there's an existing applied campaign discount") } if len(resp.Discounts) != 1 { t.Fatalf("expected 1 discount (applied at payment), got %d", len(resp.Discounts)) } if len(resp.Discounts) > 0 && resp.Discounts[0].Source != "campaign" { t.Errorf("expected campaign discount source, got %q", resp.Discounts[0].Source) } } func TestDiscountPreview_ReferralDiscount(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) var refID string err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id `, userID, userID).Scan(&refID) if err != nil { t.Fatalf("failed to create referral: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, false) `, userID, refID) if err != nil { t.Fatalf("failed to insert referral discount: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp DiscountPreviewResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if !resp.Eligible { t.Error("expected eligible=true when there's an unused referral discount") } foundReferral := false for _, d := range resp.Discounts { if d.Source == "referral" { foundReferral = true if d.Percent != 10.00 { t.Errorf("expected referral discount percent 10.00, got %f", d.Percent) } if d.Name != "Referral Discount (10%)" { t.Errorf("expected name 'Referral Discount (10%%)', got %q", d.Name) } } } if !foundReferral { t.Errorf("expected referral discount in discounts list, got %+v", resp.Discounts) } } func TestDiscountPreview_ReferralDiscount_AlreadyUsed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) // Insert a used referral discount var refID string err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id `, userID, userID).Scan(&refID) if err != nil { t.Fatalf("failed to create referral: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, true) `, userID, refID) if err != nil { t.Fatalf("failed to insert used referral discount: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp DiscountPreviewResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } for _, d := range resp.Discounts { if d.Source == "referral" { t.Error("expected no referral discount when already used") } } } func TestDiscountPreview_ReferralDiscount_AlreadyApplied(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) var refID string err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id `, userID, userID).Scan(&refID) if err != nil { t.Fatalf("failed to create referral: %v", err) } var rdID string err = tx.QueryRow(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used) VALUES ($1, $2, 10.00, false) RETURNING id `, userID, refID).Scan(&rdID) if err != nil { t.Fatalf("failed to insert referral discount: %v", err) } // Apply it to the booking already _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'referral', $3, 10.00, 100.00, 10.00) `, bookingID, userID, rdID) if err != nil { t.Fatalf("failed to insert booking discount: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp DiscountPreviewResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } for _, d := range resp.Discounts { if d.Source == "referral" { t.Error("expected no referral discount when already applied to booking") } } }