feat(loyalty-discount): implement loyalty and discount system

- Add discount campaign management and validation logic
- Update booking handlers with discount application flow
- Add customer relationship endpoints for loyalty tracking
- Update frontend modals (booking, approval, payment, reschedule)
- Add DiscountsManagement and loyalty reference documentation
- Update dev scripts and database init for discount tables
- Clean up completed plan files
This commit is contained in:
2026-06-04 23:13:02 +01:00
parent 79b23a3cf7
commit 6d4bc4d637
28 changed files with 2248 additions and 1461 deletions
+86
View File
@@ -3121,3 +3121,89 @@ func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) {
}
}
}
func TestGetAdminBooking_WithDiscounts(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create completed booking
bookingTime := time.Now().Add(-24 * time.Hour)
bookingID := createCompletedBookingWithTimeForAdmin(t, userID, serviceID, bookingTime, 50.00)
// Create a discount campaign and apply it
var campaignID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() + INTERVAL '2 days')
RETURNING id
`, "Admin Test Campaign").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
_, 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.0, 50.00, 5.00)
`, bookingID, userID, campaignID)
if err != nil {
t.Fatalf("failed to create booking discount: %v", err)
}
// Call GetAdminBookingHandler
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking: %v", err)
}
if len(booking.Discounts) != 1 {
t.Fatalf("expected 1 discount, got %d", len(booking.Discounts))
}
d := booking.Discounts[0]
if d.CampaignName == nil || *d.CampaignName != "Admin Test Campaign" {
t.Errorf("expected campaign name 'Admin Test Campaign', got %v", d.CampaignName)
}
if d.DiscountAmount != 5.00 {
t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount)
}
}
func createCompletedBookingWithTimeForAdmin(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string {
t.Helper()
var bookingID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create completed booking: %v", err)
}
_, err = db.DB.Exec(context.Background(), `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, bookingID, serviceID, price)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
return bookingID
}
+4 -4
View File
@@ -78,7 +78,7 @@ type CampaignStats struct {
func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) {
statusFilter := r.URL.Query().Get("status")
if statusFilter != "" {
validStatuses := map[string]bool{"active": true, "paused": true, "cancelled": true, "expired": true}
validStatuses := map[string]bool{"draft": true, "active": true, "completed": true, "cancelled": true}
if !validStatuses[statusFilter] {
http.Error(w, "invalid status filter", http.StatusBadRequest)
return
@@ -307,7 +307,7 @@ func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
req.MilestoneValue,
req.MilestoneUnit,
req.MaxRedemptions,
"active",
"draft",
createdBy,
).Scan(
&campaign.ID,
@@ -478,8 +478,8 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
argNum++
}
if req.Status != nil {
if *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" {
http.Error(w, "Status must be 'active', 'cancelled', or 'completed'", http.StatusBadRequest)
if *req.Status != "draft" && *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" {
http.Error(w, "Status must be 'draft', 'active', 'cancelled', or 'completed'", http.StatusBadRequest)
return
}
query += ", status = $" + strconv.Itoa(argNum)