//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" ) // ============================================================================= // ApplyLoyaltyRedemption — POST /api/bookings/{id}/apply-redemption // ============================================================================= func setupLoyaltyUser(t *testing.T, stamps int) (string, string, string) { t.Helper() userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = db.DB.Exec(context.Background(), "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID) if err != nil { t.Fatalf("failed to set loyalty_stamps: %v", err) } // Create a pending loyalty_redemption if stamps >= 10 if stamps >= 10 { _, err = db.DB.Exec(context.Background(), ` INSERT INTO loyalty_redemptions (user_id, status, redeemed_at, expires_at) VALUES ($1, 'pending', NOW(), NOW() + INTERVAL '6 months') `, userID) if err != nil { t.Fatalf("failed to create loyalty redemption: %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 set booking status: %v", err) } userToken := jwt.GenerateUserToken(userID) return userID, bookingID, userToken } func makeApplyRedemptionRequest(bookingID, token string) *httptest.ResponseRecorder { handler := http.HandlerFunc(ApplyLoyaltyRedemption) req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil) req.Header.Set("Authorization", "Bearer "+token) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) userID := extractUserFromTestJWT(token) if userID != nil { ctx = context.WithValue(ctx, mw.UserIDKey, userID.userID) ctx = context.WithValue(ctx, mw.UserRoleKey, userID.role) } req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } func TestApplyLoyaltyRedemption_Success(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupLoyaltyUser(t, 10) w := makeApplyRedemptionRequest(bookingID, userToken) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp["success"] != true { t.Error("expected success=true") } discountAmount, ok := resp["discount_amount"].(float64) if !ok || discountAmount <= 0 { t.Errorf("expected positive discount_amount, got %v", resp["discount_amount"]) } // Verify booking_discounts was created var discountCount int err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount) if err != nil { t.Fatalf("failed to query booking_discounts: %v", err) } if discountCount != 1 { t.Errorf("expected 1 loyalty discount record, got %d", discountCount) } // Verify stamps were deducted (10 - 10 = 0) var stamps int err = db.DB.QueryRow(context.Background(), "SELECT loyalty_stamps FROM users WHERE id = (SELECT user_id FROM bookings WHERE id = $1)", bookingID).Scan(&stamps) if err != nil { t.Fatalf("failed to query stamps: %v", err) } if stamps != 0 { t.Errorf("expected 0 stamps after redemption, got %d", stamps) } // Verify a discount payment record was created var paymentCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to query payments: %v", err) } if paymentCount != 1 { t.Errorf("expected 1 discount payment record, got %d", paymentCount) } } func TestApplyLoyaltyRedemption_InsufficientStamps(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupLoyaltyUser(t, 5) w := makeApplyRedemptionRequest(bookingID, userToken) if w.Code != http.StatusBadRequest && w.Code != http.StatusConflict { t.Fatalf("expected 4xx for insufficient stamps, got %d: %s", w.Code, w.Body.String()) } } func TestApplyLoyaltyRedemption_AlreadyApplied(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupLoyaltyUser(t, 10) // First call should succeed w := makeApplyRedemptionRequest(bookingID, userToken) if w.Code != http.StatusOK { t.Fatalf("first call expected 200, got %d: %s", w.Code, w.Body.String()) } // Second call should be rejected w = makeApplyRedemptionRequest(bookingID, userToken) if w.Code != http.StatusConflict && w.Code != http.StatusBadRequest { t.Fatalf("second call expected 4xx, got %d: %s", w.Code, w.Body.String()) } // Verify still only 1 discount record var discountCount int db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 loyalty discount record, got %d", discountCount) } } func TestApplyLoyaltyRedemption_TerminalBooking(t *testing.T) { resetTestData(t) _, bookingID, userToken := setupLoyaltyUser(t, 10) // Set booking to a terminal status _, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set booking status: %v", err) } w := makeApplyRedemptionRequest(bookingID, userToken) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for terminal booking, got %d: %s", w.Code, w.Body.String()) } } // ============================================================================= // applyEligibleCampaignsAtPayment — campaign auto-apply at payment time // ============================================================================= func setupCampaignTest(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 set booking status: %v", err) } userToken := jwt.GenerateUserToken(userID) return userID, bookingID, userToken } func TestCampaignAutoApply_TimeBased(t *testing.T) { resetTestData(t) userID, bookingID, _ := setupCampaignTest(t) // Create an active time-based campaign 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 `, "Early Payment Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) if err != nil { t.Fatalf("failed to create campaign: %v", err) } // Insert a deposit payment to trigger campaign auto-apply _, 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', 2500, 'completed', NOW(), NOW()) `, bookingID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Call applyEligibleCampaignsAtPayment applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) // Verify booking_discounts was created var discountCount int db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount, got %d", discountCount) } // Verify times_redeemed was incremented 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) } } func TestCampaignAutoApply_UserMilestone(t *testing.T) { resetTestData(t) userID, bookingID, _ := setupCampaignTest(t) // Give user 5 completed bookings to match milestone_value=5 for i := 0; i < 5; i++ { var bid string db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, time.Date(2024, time.Month(i+1), 15, 10, 0, 0, 0, time.UTC)).Scan(&bid) } // Create user milestone campaign for 5th booking var campaignID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ($1, 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'per_user_booking_count', 5, 1, 0) RETURNING id `, "5th Booking Bonus").Scan(&campaignID) if err != nil { t.Fatalf("failed to create campaign: %v", err) } // Insert a payment _, 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', 2500, 'completed', NOW(), NOW()) `, bookingID) if err != nil { t.Fatalf("failed to create 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 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount, got %d", discountCount) } } func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) { resetTestData(t) userID, bookingID, _ := setupCampaignTest(t) // Set global completed count high enough now := time.Now() for i := 0; i < 100; i++ { db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, now.Add(-time.Duration(i)*24*time.Hour)).Scan(new(string)) } // Create global milestone campaign at milestone_value=100 var campaignID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0) RETURNING id `, "100th Booking Celebration").Scan(&campaignID) if err != nil { t.Fatalf("failed to create campaign: %v", err) } // Insert an ONLINE payment first (not in_person_card) _, 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', 2500, 'completed', NOW(), NOW()) `, bookingID) if err != nil { t.Fatalf("failed to create payment: %v", err) } applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) // Verify NO discount was applied (global milestone skipped for online payment) var discountCount int db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 0 { t.Errorf("expected 0 campaign discounts (global milestone skipped for online), got %d", discountCount) } } func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) { resetTestData(t) userID, bookingID, _ := setupCampaignTest(t) for i := 0; i < 100; i++ { db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, time.Date(2024, time.Month(i%12+1), 15, 10, 0, 0, 0, time.UTC)).Scan(new(string)) } var campaignID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0) RETURNING id `, "100th Booking Celebration").Scan(&campaignID) if err != nil { t.Fatalf("failed to create campaign: %v", err) } // Insert an IN-PERSON payment _, err = db.DB.Exec(context.Background(), ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW()) `, bookingID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Simulate what CreateBookingPayment does: set status to confirmed after payment db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1", bookingID) applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) var discountCount int db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount (in-person global milestone), got %d", discountCount) } } func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) { resetTestData(t) userID, bookingID, _ := setupCampaignTest(t) 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 `, "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) } // Manually insert a booking_discount to simulate it was already applied at payment time _, err = db.DB.Exec(context.Background(), ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500) `, bookingID, userID, campaignID) if err != nil { t.Fatalf("failed to insert existing discount: %v", err) } // Pretend campaign was already redeemed db.DB.Exec(context.Background(), "UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1", campaignID) // Insert payment to trigger applyEligibleCampaignsAtPayment 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', 2500, 'completed', NOW(), NOW()) `, bookingID) applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) // Verify still only 1 discount var discountCount int db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) if discountCount != 1 { t.Errorf("expected 1 campaign discount (no double-apply), got %d", discountCount) } }