From 1f54d8565cd5b411f1fc991a17d3032691893288 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 8 May 2026 17:32:20 +0100 Subject: [PATCH] feat: loyalty/discount system with milestone campaigns, auto-redemption, and admin UI - Database: loyalty_redemptions, discount_campaigns, booking_discounts tables - Backend: auto-create pending redemption at 10 stamps, apply discounts at completion - Backend: discount_eligible flag on booking creation (user + admin flows) - Backend: campaign CRUD handlers (GET/POST/PUT/DELETE + stats) - Backend: milestone campaigns (per-user, global, anniversary) - Frontend: customer account page shows 'card full' status at 10 stamps - Frontend: admin discounts page with campaign management UI - Frontend: TypeScript types for all discount entities - Tests: 9 integration tests covering loyalty, campaigns, milestones, edge cases --- backend/handlers/admin/discount_campaigns.go | 723 +++++++++++++ backend/handlers/bookings/bookings.go | 223 +++- backend/handlers/bookings/discount_test.go | 769 ++++++++++++++ backend/handlers/bookings/manage.go | 21 +- backend/main.go | 9 + frontend/src/lib/types/booking.ts | 59 ++ frontend/src/routes/account/+page.svelte | 6 +- .../src/routes/admin/discounts/+page.svelte | 978 ++++++++++++++++++ init-scripts/init-script.sql | 84 +- 9 files changed, 2864 insertions(+), 8 deletions(-) create mode 100644 backend/handlers/admin/discount_campaigns.go create mode 100644 backend/handlers/bookings/discount_test.go create mode 100644 frontend/src/routes/admin/discounts/+page.svelte diff --git a/backend/handlers/admin/discount_campaigns.go b/backend/handlers/admin/discount_campaigns.go new file mode 100644 index 0000000..81cf26b --- /dev/null +++ b/backend/handlers/admin/discount_campaigns.go @@ -0,0 +1,723 @@ +package admin + +import ( + "crussell/db" + "crussell/internal/validators" + "crussell/mw" + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" +) + +// DiscountCampaign represents a discount campaign in the system +type DiscountCampaign struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + CampaignType string `json:"campaign_type"` + DiscountPercent float64 `json:"discount_percent"` + Scope *string `json:"scope,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` + MilestoneType *string `json:"milestone_type,omitempty"` + MilestoneValue *int `json:"milestone_value,omitempty"` + MilestoneUnit *string `json:"milestone_unit,omitempty"` + Status string `json:"status"` + MaxRedemptions *int `json:"max_redemptions,omitempty"` + TimesRedeemed int `json:"times_redeemed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy *string `json:"created_by,omitempty"` +} + +// CreateCampaignRequest represents the request payload for creating a new campaign +type CreateCampaignRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + CampaignType string `json:"campaign_type"` // "time_based" or "milestone" + DiscountPercent float64 `json:"discount_percent"` + Scope *string `json:"scope,omitempty"` + StartDate *string `json:"start_date,omitempty"` // ISO 8601 + EndDate *string `json:"end_date,omitempty"` // ISO 8601 + MilestoneType *string `json:"milestone_type,omitempty"` + MilestoneValue *int `json:"milestone_value,omitempty"` + MilestoneUnit *string `json:"milestone_unit,omitempty"` + MaxRedemptions *int `json:"max_redemptions,omitempty"` +} + +// UpdateCampaignRequest represents the request payload for updating a campaign +type UpdateCampaignRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DiscountPercent *float64 `json:"discount_percent,omitempty"` + Scope *string `json:"scope,omitempty"` + StartDate *string `json:"start_date,omitempty"` + EndDate *string `json:"end_date,omitempty"` + MilestoneType *string `json:"milestone_type,omitempty"` + MilestoneValue *int `json:"milestone_value,omitempty"` + MilestoneUnit *string `json:"milestone_unit,omitempty"` + Status *string `json:"status,omitempty"` + MaxRedemptions *int `json:"max_redemptions,omitempty"` +} + +// CampaignStats represents statistics for a campaign +type CampaignStats struct { + Campaign DiscountCampaign `json:"campaign"` + TotalDiscounts float64 `json:"total_discount_amount"` + BookingCount int `json:"booking_count"` +} + +// GetDiscountCampaigns handles GET /api/admin/discount-campaigns +// Optional query param ?status=active to filter by status +func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) { + statusFilter := r.URL.Query().Get("status") + + var query string + var rows pgx.Rows + var err error + + if statusFilter != "" { + query = ` + SELECT id, name, description, campaign_type, discount_percent, scope, + start_date, end_date, milestone_type, milestone_value, milestone_unit, + status, max_redemptions, times_redeemed, created_at, updated_at, created_by + FROM discount_campaigns + WHERE status = $1 + ORDER BY created_at DESC + ` + rows, err = db.DB.Query(r.Context(), query, statusFilter) + } else { + query = ` + SELECT id, name, description, campaign_type, discount_percent, scope, + start_date, end_date, milestone_type, milestone_value, milestone_unit, + status, max_redemptions, times_redeemed, created_at, updated_at, created_by + FROM discount_campaigns + ORDER BY created_at DESC + ` + rows, err = db.DB.Query(r.Context(), query) + } + + if err != nil { + http.Error(w, "Failed to fetch campaigns: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var campaigns []DiscountCampaign + + for rows.Next() { + var campaign DiscountCampaign + var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString + var startDate, endDate sql.NullTime + var milestoneValue, maxRedemptions sql.NullInt32 + var createdBy sql.NullString + + err := rows.Scan( + &campaign.ID, + &campaign.Name, + &description, + &campaignType, + &campaign.DiscountPercent, + &scope, + &startDate, + &endDate, + &milestoneType, + &milestoneValue, + &milestoneUnit, + &status, + &maxRedemptions, + &campaign.TimesRedeemed, + &campaign.CreatedAt, + &campaign.UpdatedAt, + &createdBy, + ) + if err != nil { + http.Error(w, "Failed to read campaign data: "+err.Error(), http.StatusInternalServerError) + return + } + + // Convert nullable fields + if description.Valid { + campaign.Description = &description.String + } + if campaignType.Valid { + campaign.CampaignType = campaignType.String + } + if scope.Valid { + campaign.Scope = &scope.String + } + if startDate.Valid { + campaign.StartDate = &startDate.Time + } + if endDate.Valid { + campaign.EndDate = &endDate.Time + } + if milestoneType.Valid { + campaign.MilestoneType = &milestoneType.String + } + if milestoneValue.Valid { + val := int(milestoneValue.Int32) + campaign.MilestoneValue = &val + } + if milestoneUnit.Valid { + campaign.MilestoneUnit = &milestoneUnit.String + } + if status.Valid { + campaign.Status = status.String + } + if maxRedemptions.Valid { + val := int(maxRedemptions.Int32) + campaign.MaxRedemptions = &val + } + if createdBy.Valid { + campaign.CreatedBy = &createdBy.String + } + + campaigns = append(campaigns, campaign) + } + + if err = rows.Err(); err != nil { + http.Error(w, "Error iterating over campaigns: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if campaigns == nil { + campaigns = []DiscountCampaign{} + } + + if err := json.NewEncoder(w).Encode(campaigns); err != nil { + log.Printf("Error encoding campaigns: %v", err) + } +} + +// CreateDiscountCampaign handles POST /api/admin/discount-campaigns +func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) { + var req CreateCampaignRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + // Validate required fields + if req.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + if req.DiscountPercent <= 0 || req.DiscountPercent > 100 { + http.Error(w, "Discount percent must be greater than 0 and less than or equal to 100", http.StatusBadRequest) + return + } + if req.CampaignType != "time_based" && req.CampaignType != "milestone" { + http.Error(w, "Campaign type must be 'time_based' or 'milestone'", http.StatusBadRequest) + return + } + + // Validate time_based requirements + if req.CampaignType == "time_based" { + if req.StartDate == nil || req.EndDate == nil { + http.Error(w, "Start date and end date are required for time_based campaigns", http.StatusBadRequest) + return + } + + startTime, err := time.Parse(time.RFC3339, *req.StartDate) + if err != nil { + http.Error(w, "Invalid start date format. Use ISO 8601 format", http.StatusBadRequest) + return + } + endTime, err := time.Parse(time.RFC3339, *req.EndDate) + if err != nil { + http.Error(w, "Invalid end date format. Use ISO 8601 format", http.StatusBadRequest) + return + } + if !endTime.After(startTime) { + http.Error(w, "End date must be after start date", http.StatusBadRequest) + return + } + } + + // Validate milestone requirements + if req.CampaignType == "milestone" { + if req.MilestoneType == nil || req.MilestoneValue == nil || req.MilestoneUnit == nil { + http.Error(w, "Milestone type, value, and unit are required for milestone campaigns", http.StatusBadRequest) + return + } + if *req.MilestoneValue <= 0 { + http.Error(w, "Milestone value must be greater than 0", http.StatusBadRequest) + return + } + } + + // Get creator ID from context + var createdBy *string + if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok { + createdBy = &userID + } + + // Insert new campaign + query := ` + INSERT INTO discount_campaigns ( + name, description, campaign_type, discount_percent, scope, + start_date, end_date, milestone_type, milestone_value, milestone_unit, + max_redemptions, status, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING + id, name, description, campaign_type, discount_percent, scope, + start_date, end_date, milestone_type, milestone_value, milestone_unit, + status, max_redemptions, times_redeemed, created_at, updated_at, created_by + ` + + var campaign DiscountCampaign + var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString + var startDate, endDate sql.NullTime + var milestoneValue, maxRedemptions sql.NullInt32 + var createdByDB sql.NullString + + err := db.DB.QueryRow(r.Context(), + query, + req.Name, + req.Description, + req.CampaignType, + req.DiscountPercent, + req.Scope, + req.StartDate, + req.EndDate, + req.MilestoneType, + req.MilestoneValue, + req.MilestoneUnit, + req.MaxRedemptions, + "active", + createdBy, + ).Scan( + &campaign.ID, + &campaign.Name, + &description, + &campaignType, + &campaign.DiscountPercent, + &scope, + &startDate, + &endDate, + &milestoneType, + &milestoneValue, + &milestoneUnit, + &status, + &maxRedemptions, + &campaign.TimesRedeemed, + &campaign.CreatedAt, + &campaign.UpdatedAt, + &createdByDB, + ) + + if err != nil { + http.Error(w, "Failed to create campaign: "+err.Error(), http.StatusInternalServerError) + return + } + + // Convert nullable fields + if description.Valid { + campaign.Description = &description.String + } + if campaignType.Valid { + campaign.CampaignType = campaignType.String + } + if scope.Valid { + campaign.Scope = &scope.String + } + if startDate.Valid { + campaign.StartDate = &startDate.Time + } + if endDate.Valid { + campaign.EndDate = &endDate.Time + } + if milestoneType.Valid { + campaign.MilestoneType = &milestoneType.String + } + if milestoneValue.Valid { + val := int(milestoneValue.Int32) + campaign.MilestoneValue = &val + } + if milestoneUnit.Valid { + campaign.MilestoneUnit = &milestoneUnit.String + } + if status.Valid { + campaign.Status = status.String + } + if maxRedemptions.Valid { + val := int(maxRedemptions.Int32) + campaign.MaxRedemptions = &val + } + if createdByDB.Valid { + campaign.CreatedBy = &createdByDB.String + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(campaign); err != nil { + log.Printf("Error encoding campaign: %v", err) + } +} + +// UpdateDiscountCampaign handles PUT /api/admin/discount-campaigns/{id} +func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) { + campaignID := chi.URLParam(r, "id") + if campaignID == "" || !validators.IsValidID(campaignID) { + http.Error(w, "Campaign not found", http.StatusNotFound) + return + } + + // Check if campaign exists + var exists bool + err := db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists) + if err != nil { + http.Error(w, "Failed to check campaign: "+err.Error(), http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, "Campaign not found", http.StatusNotFound) + return + } + + var req UpdateCampaignRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + // Build dynamic update query + query := "UPDATE discount_campaigns SET updated_at = NOW()" + args := []interface{}{} + argNum := 1 + + if req.Name != nil { + query += ", name = $" + string(rune('0'+argNum)) + args = append(args, *req.Name) + argNum++ + } + if req.Description != nil { + query += ", description = $" + string(rune('0'+argNum)) + args = append(args, *req.Description) + argNum++ + } + if req.DiscountPercent != nil { + if *req.DiscountPercent <= 0 || *req.DiscountPercent > 100 { + http.Error(w, "Discount percent must be greater than 0 and less than or equal to 100", http.StatusBadRequest) + return + } + query += ", discount_percent = $" + string(rune('0'+argNum)) + args = append(args, *req.DiscountPercent) + argNum++ + } + if req.Scope != nil { + query += ", scope = $" + string(rune('0'+argNum)) + args = append(args, *req.Scope) + argNum++ + } + if req.StartDate != nil { + startTime, err := time.Parse(time.RFC3339, *req.StartDate) + if err != nil { + http.Error(w, "Invalid start date format. Use ISO 8601 format", http.StatusBadRequest) + return + } + query += ", start_date = $" + string(rune('0'+argNum)) + args = append(args, startTime) + argNum++ + } + if req.EndDate != nil { + endTime, err := time.Parse(time.RFC3339, *req.EndDate) + if err != nil { + http.Error(w, "Invalid end date format. Use ISO 8601 format", http.StatusBadRequest) + return + } + query += ", end_date = $" + string(rune('0'+argNum)) + args = append(args, endTime) + argNum++ + } + if req.MilestoneType != nil { + query += ", milestone_type = $" + string(rune('0'+argNum)) + args = append(args, *req.MilestoneType) + argNum++ + } + if req.MilestoneValue != nil { + if *req.MilestoneValue <= 0 { + http.Error(w, "Milestone value must be greater than 0", http.StatusBadRequest) + return + } + query += ", milestone_value = $" + string(rune('0'+argNum)) + args = append(args, *req.MilestoneValue) + argNum++ + } + if req.MilestoneUnit != nil { + query += ", milestone_unit = $" + string(rune('0'+argNum)) + args = append(args, *req.MilestoneUnit) + 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) + return + } + query += ", status = $" + string(rune('0'+argNum)) + args = append(args, *req.Status) + argNum++ + } + if req.MaxRedemptions != nil { + query += ", max_redemptions = $" + string(rune('0'+argNum)) + args = append(args, *req.MaxRedemptions) + argNum++ + } + + query += " WHERE id = $" + string(rune('0'+argNum)) + args = append(args, campaignID) + + _, err = db.DB.Exec(r.Context(), query, args...) + if err != nil { + http.Error(w, "Failed to update campaign: "+err.Error(), http.StatusInternalServerError) + return + } + + // Fetch updated campaign + var campaign DiscountCampaign + var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString + var startDate, endDate sql.NullTime + var milestoneValue, maxRedemptions sql.NullInt32 + var createdBy sql.NullString + + err = db.DB.QueryRow(r.Context(), ` + SELECT id, name, description, campaign_type, discount_percent, scope, + start_date, end_date, milestone_type, milestone_value, milestone_unit, + status, max_redemptions, times_redeemed, created_at, updated_at, created_by + FROM discount_campaigns + WHERE id = $1 + `, campaignID).Scan( + &campaign.ID, + &campaign.Name, + &description, + &campaignType, + &campaign.DiscountPercent, + &scope, + &startDate, + &endDate, + &milestoneType, + &milestoneValue, + &milestoneUnit, + &status, + &maxRedemptions, + &campaign.TimesRedeemed, + &campaign.CreatedAt, + &campaign.UpdatedAt, + &createdBy, + ) + + if err != nil { + http.Error(w, "Failed to fetch updated campaign: "+err.Error(), http.StatusInternalServerError) + return + } + + // Convert nullable fields + if description.Valid { + campaign.Description = &description.String + } + if campaignType.Valid { + campaign.CampaignType = campaignType.String + } + if scope.Valid { + campaign.Scope = &scope.String + } + if startDate.Valid { + campaign.StartDate = &startDate.Time + } + if endDate.Valid { + campaign.EndDate = &endDate.Time + } + if milestoneType.Valid { + campaign.MilestoneType = &milestoneType.String + } + if milestoneValue.Valid { + val := int(milestoneValue.Int32) + campaign.MilestoneValue = &val + } + if milestoneUnit.Valid { + campaign.MilestoneUnit = &milestoneUnit.String + } + if status.Valid { + campaign.Status = status.String + } + if maxRedemptions.Valid { + val := int(maxRedemptions.Int32) + campaign.MaxRedemptions = &val + } + if createdBy.Valid { + campaign.CreatedBy = &createdBy.String + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(campaign); err != nil { + log.Printf("Error encoding campaign: %v", err) + } +} + +// DeleteDiscountCampaign handles DELETE /api/admin/discount-campaigns/{id} +// Soft delete - sets status to "cancelled" +func DeleteDiscountCampaign(w http.ResponseWriter, r *http.Request) { + campaignID := chi.URLParam(r, "id") + if campaignID == "" || !validators.IsValidID(campaignID) { + http.Error(w, "Campaign not found", http.StatusNotFound) + return + } + + // Check if campaign exists + var exists bool + err := db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists) + if err != nil { + http.Error(w, "Failed to check campaign: "+err.Error(), http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, "Campaign not found", http.StatusNotFound) + return + } + + // Soft delete - set status to cancelled + query := "UPDATE discount_campaigns SET status = 'cancelled', updated_at = NOW() WHERE id = $1" + result, err := db.DB.Exec(r.Context(), query, campaignID) + if err != nil { + http.Error(w, "Failed to delete campaign: "+err.Error(), http.StatusInternalServerError) + return + } + + if result.RowsAffected() == 0 { + http.Error(w, "Campaign not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Campaign deleted successfully", + "id": campaignID, + }) +} + +// GetCampaignStats handles GET /api/admin/discount-campaigns/{id}/stats +func GetCampaignStats(w http.ResponseWriter, r *http.Request) { + campaignID := chi.URLParam(r, "id") + if campaignID == "" || !validators.IsValidID(campaignID) { + http.Error(w, "Campaign not found", http.StatusNotFound) + return + } + + // Fetch campaign details + var campaign DiscountCampaign + var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString + var startDate, endDate sql.NullTime + var milestoneValue, maxRedemptions sql.NullInt32 + var createdBy sql.NullString + + err := db.DB.QueryRow(r.Context(), ` + SELECT id, name, description, campaign_type, discount_percent, scope, + start_date, end_date, milestone_type, milestone_value, milestone_unit, + status, max_redemptions, times_redeemed, created_at, updated_at, created_by + FROM discount_campaigns + WHERE id = $1 + `, campaignID).Scan( + &campaign.ID, + &campaign.Name, + &description, + &campaignType, + &campaign.DiscountPercent, + &scope, + &startDate, + &endDate, + &milestoneType, + &milestoneValue, + &milestoneUnit, + &status, + &maxRedemptions, + &campaign.TimesRedeemed, + &campaign.CreatedAt, + &campaign.UpdatedAt, + &createdBy, + ) + + if err == sql.ErrNoRows { + http.Error(w, "Campaign not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Failed to fetch campaign: "+err.Error(), http.StatusInternalServerError) + return + } + + // Convert nullable fields + if description.Valid { + campaign.Description = &description.String + } + if campaignType.Valid { + campaign.CampaignType = campaignType.String + } + if scope.Valid { + campaign.Scope = &scope.String + } + if startDate.Valid { + campaign.StartDate = &startDate.Time + } + if endDate.Valid { + campaign.EndDate = &endDate.Time + } + if milestoneType.Valid { + campaign.MilestoneType = &milestoneType.String + } + if milestoneValue.Valid { + val := int(milestoneValue.Int32) + campaign.MilestoneValue = &val + } + if milestoneUnit.Valid { + campaign.MilestoneUnit = &milestoneUnit.String + } + if status.Valid { + campaign.Status = status.String + } + if maxRedemptions.Valid { + val := int(maxRedemptions.Int32) + campaign.MaxRedemptions = &val + } + if createdBy.Valid { + campaign.CreatedBy = &createdBy.String + } + + // Get stats from booking_discounts table + var totalDiscounts float64 + var bookingCount int + + err = db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(discount_amount), 0), COUNT(*) + FROM booking_discounts + WHERE source_id = $1 + `, campaignID).Scan(&totalDiscounts, &bookingCount) + + if err != nil { + http.Error(w, "Failed to fetch campaign stats: "+err.Error(), http.StatusInternalServerError) + return + } + + stats := CampaignStats{ + Campaign: campaign, + TotalDiscounts: totalDiscounts, + BookingCount: bookingCount, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(stats); err != nil { + log.Printf("Error encoding stats: %v", err) + } +} \ No newline at end of file diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index f04f5b8..3290a21 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -243,6 +243,11 @@ type AdminBookingDetail struct { DurationMinutes int `json:"duration_minutes"` } +// roundTo2 rounds a float64 to 2 decimal places +func roundTo2(f float64) float64 { + return float64(int(f*100+0.5)) / 100 +} + // Helper function to parse query parameters func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { req := GetAllBookingsRequest{ @@ -1302,6 +1307,24 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { createdBy = &creatorID } + var discountEligible bool + if !isGuest { + var hasPendingRedemption bool + _ = db.DB.QueryRow(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()) + `, userID).Scan(&hasPendingRedemption) + + var activeCampaigns int + _ = db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'time_based' + AND start_date <= NOW() AND end_date >= NOW() + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + `).Scan(&activeCampaigns) + + discountEligible = hasPendingRedemption || activeCampaigns > 0 + } + tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) @@ -1323,10 +1346,10 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { var booking Booking booking.User = &UserSummary{} if err := tx.QueryRow(r.Context(), ` - INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key) - VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6) + INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key, discount_eligible) + VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6, $7) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required - `, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan( + `, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}, discountEligible).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, &booking.DepositRequired, @@ -1636,6 +1659,200 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err) } + var newStampCount int + if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 { + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, booking.User.ID) + if err != nil { + log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err) + } + } + + var bookingTotal float64 + if err := db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, bookingID).Scan(&bookingTotal); err != nil { + log.Printf("Failed to calculate booking total for %s: %v", bookingID, err) + } + + if bookingTotal > 0 { + var redemptionID string + if err := db.DB.QueryRow(r.Context(), ` + SELECT id FROM loyalty_redemptions + WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW() + ORDER BY redeemed_at ASC LIMIT 1 + `, booking.User.ID).Scan(&redemptionID); err == nil && redemptionID != "" { + discountAmount := roundTo2(bookingTotal * 0.10) + + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'loyalty', $3, NULL, NULL, 10.00, $4, $5) + `, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount) + + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + + _, _ = db.DB.Exec(r.Context(), ` + UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW() + WHERE id = $2 + `, bookingID, redemptionID) + + _, _ = db.DB.Exec(r.Context(), ` + UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1 + `, booking.User.ID) + } + } + + if bookingTotal > 0 { + var hasLoyaltyDiscount bool + _ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount) + + if !hasLoyaltyDiscount { + var campaignID string + var campaignPercent float64 + if err := db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'time_based' + AND start_date <= NOW() AND end_date >= NOW() + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + ORDER BY discount_percent DESC LIMIT 1 + `).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" { + discountAmount := roundTo2(bookingTotal * campaignPercent / 100) + + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) + `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount) + + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, campaignID) + } + } + } + + if bookingTotal > 0 { + var hasDiscount bool + _ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount) + + if !hasDiscount { + var userBookingCount int + _ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount) + + var milestoneCampaignID string + var milestonePercent float64 + _ = db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' + AND milestone_value = $1 + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id) + `, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent) + + if milestoneCampaignID != "" { + discountAmount := roundTo2(bookingTotal * milestonePercent / 100) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) + `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, milestoneCampaignID) + } + + if milestoneCampaignID == "" { + var globalCount int + _ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) + var globalCampaignID string + var globalPercent float64 + _ = db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' + AND milestone_value = $1 + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + `, globalCount+1).Scan(&globalCampaignID, &globalPercent) + + if globalCampaignID != "" { + discountAmount := roundTo2(bookingTotal * globalPercent / 100) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) + `, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, globalCampaignID) + } + } + + if milestoneCampaignID == "" { + var firstVisitDate time.Time + _ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate) + if !firstVisitDate.IsZero() { + rows, err := db.DB.Query(r.Context(), ` + SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary' + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary') + `, booking.User.ID) + if err == nil { + defer rows.Close() + for rows.Next() { + var annID string + var annPercent float64 + var annValue int + var annUnit string + if rows.Scan(&annID, &annPercent, &annValue, &annUnit) == nil { + var matches bool + elapsed := time.Since(firstVisitDate) + switch annUnit { + case "months": + months := int(elapsed.Hours() / (30 * 24)) + matches = months >= annValue + case "years": + years := int(elapsed.Hours() / (365.25 * 24)) + matches = years >= annValue + } + if matches { + discountAmount := roundTo2(bookingTotal * annPercent / 100) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) + `, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, annID) + break + } + } + } + } + } + } + } + } + var paymentCount int db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount) if paymentCount > 0 { diff --git a/backend/handlers/bookings/discount_test.go b/backend/handlers/bookings/discount_test.go new file mode 100644 index 0000000..4934488 --- /dev/null +++ b/backend/handlers/bookings/discount_test.go @@ -0,0 +1,769 @@ +//go:build test +// +build test + +package bookings_test + +// Integration tests for the loyalty/discount system. +// +// Test Coverage: +// - Loyalty auto-redemption: stamps -> pending redemption -> applied discount +// - Time-based campaign discounts +// - Per-user milestone discounts (10th booking) +// - Global milestone discounts +// - Anniversary milestone discounts +// - Discount priority (loyalty > campaign > milestone) +// - Edge cases: zero total, max redemptions, eligibility flag + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "crussell/db" + "crussell/handlers/bookings" + "crussell/mw" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + "crussell/testutils/testdb" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupTestDB replaces the global db.DB with a test pool and returns a cleanup function +func setupTestDB(t *testing.T) func() { + t.Helper() + + pool := testdb.Pool(t) + testdb.Migrate(t, pool) + testdb.TruncateTables(t, pool) + + // Replace global db.DB with test pool + originalDB := db.DB + db.DB = pool + + // Initialize JWT for tests + jwt.Init() + + return func() { + db.DB = originalDB + pool.Close() + } +} + +// seedDefaultWorkingHours seeds default working hours for tests +func seedDefaultWorkingHours(t *testing.T) { + t.Helper() + + hours := []struct { + weekday int + startTime string + endTime string + isOpen bool + }{ + {0, "08:00", "20:00", true}, // Monday + {1, "08:00", "20:00", true}, // Tuesday + {2, "08:00", "20:00", true}, // Wednesday + {3, "08:00", "20:00", true}, // Thursday + {4, "08:00", "20:00", true}, // Friday + {5, "08:00", "20:00", true}, // Saturday + {6, "08:00", "20:00", true}, // Sunday + } + + for _, h := range hours { + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO working_hours (weekday, start_time, end_time, is_open) + VALUES ($1, $2, $3, $4) + ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4 + `, h.weekday, h.startTime, h.endTime, h.isOpen) + if err != nil { + t.Fatalf("failed to seed working hours: %v", err) + } + } +} + +// makeProgressRequest creates a request to progress a booking status +func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + bodyBytes, _ := json.Marshal(body) + req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + // Set up chi routing context for path params + rctx := chi.NewRouteContext() + if idx := strings.LastIndex(path, "/"); idx > 0 { + rctx.URLParams.Add("id", path[idx+1:]) + } + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + + // Set user context for admin + if token != "" { + ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001") + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + } + + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// createTestUser creates a test user with optional loyalty stamps +func createTestUser(t *testing.T, stamps int) string { + t.Helper() + userID, err := fixtures.CreateTestUser(db.DB) + require.NoError(t, err) + + if stamps > 0 { + _, err := db.DB.Exec(context.Background(), "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID) + require.NoError(t, err) + } + + return userID +} + +// createTestService creates a test service with a specific price +func createTestService(t *testing.T, price float64) string { + t.Helper() + ctx := context.Background() + var serviceID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, "Test Service", "A test service", price, 60, true, 16).Scan(&serviceID) + require.NoError(t, err) + return serviceID +} + +// createTestCampaign creates a discount campaign with the specified parameters +func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string { + t.Helper() + ctx := context.Background() + var id string + now := time.Now() + startDate := now.Add(-24 * time.Hour) + endDate := now.Add(24 * time.Hour) + + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed) + VALUES ($1, $2, $3, 'active', $4, $5, $6, $7, $8, $9, 0) + RETURNING id + `, name, campaignType, percent, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id) + require.NoError(t, err) + return id +} + +// createCompletedBooking creates a completed booking directly in the database +func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { + t.Helper() + ctx := context.Background() + var bookingID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, startTime).Scan(&bookingID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id, override_price) + VALUES ($1, $2, $3) + `, bookingID, serviceID, price) + require.NoError(t, err) + + return bookingID +} + +// createPendingBooking creates a pending booking +func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string { + t.Helper() + ctx := context.Background() + var bookingID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'confirmed') + RETURNING id + `, userID, startTime).Scan(&bookingID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + require.NoError(t, err) + + return bookingID +} + +// ============================================================================= +// Loyalty Auto-Redemption Tests +// ============================================================================= + +// TestDiscount_LoyaltyAutoRedemption tests the full loyalty stamp -> redemption -> discount flow +func TestDiscount_LoyaltyAutoRedemption(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Step 1: Create user with 9 stamps + userID := createTestUser(t, 9) + serviceID := createTestService(t, 50.00) + + // Step 2: Create and complete first booking (adds 1 stamp -> 10) + bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") + + // Verify pending loyalty_redemption was created + var redemptionCount int + err := db.DB.QueryRow(context.Background(), ` + SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' + `, userID).Scan(&redemptionCount) + require.NoError(t, err) + assert.Equal(t, 1, redemptionCount, "Expected 1 pending loyalty redemption") + + // Verify user now has 10 stamps + var stamps int + err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) + require.NoError(t, err) + assert.Equal(t, 10, stamps, "Expected 10 stamps after first completed booking") + + // Step 3: Create and complete second booking (should apply discount) + bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + + w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") + + // Verify booking_discounts row exists with loyalty source + var discountCount int + var discountSource string + err = db.DB.QueryRow(context.Background(), ` + SELECT COUNT(*), discount_source FROM booking_discounts WHERE booking_id = $1 GROUP BY discount_source + `, bookingID2).Scan(&discountCount, &discountSource) + require.NoError(t, err) + assert.Equal(t, 1, discountCount, "Expected 1 discount applied") + assert.Equal(t, "loyalty", discountSource, "Expected discount source to be 'loyalty'") + + // Verify stamps reduced to 0 + err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) + require.NoError(t, err) + assert.Equal(t, 0, stamps, "Expected 0 stamps after redemption applied") + + // Verify redemption status = 'applied' + var redemptionStatus string + err = db.DB.QueryRow(context.Background(), ` + SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'applied' + `, userID).Scan(&redemptionStatus) + require.NoError(t, err) + assert.Equal(t, "applied", redemptionStatus, "Expected redemption status to be 'applied'") + + // Verify discount payment was created + var paymentMethod string + var paymentAmount float64 + err = db.DB.QueryRow(context.Background(), ` + SELECT payment_method, amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount' + `, bookingID2).Scan(&paymentMethod, &paymentAmount) + require.NoError(t, err) + assert.Equal(t, "discount", paymentMethod, "Expected payment method to be 'discount'") + assert.Equal(t, 5.00, paymentAmount, "Expected 10% discount on £50 booking") // 10% of 50 = 5 +} + +// ============================================================================= +// Time-Based Campaign Tests +// ============================================================================= + +// TestDiscount_TimeBasedCampaign tests that time-based campaign discounts are applied +func TestDiscount_TimeBasedCampaign(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Create active time-based campaign + campaignID := createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 100.00) + + // Create and complete booking + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") + + // Verify booking_discounts row with campaign source + var discountSource, campaignType string + err := db.DB.QueryRow(context.Background(), ` + SELECT discount_source, campaign_type FROM booking_discounts WHERE booking_id = $1 + `, bookingID).Scan(&discountSource, &campaignType) + require.NoError(t, err) + assert.Equal(t, "campaign", discountSource, "Expected discount source to be 'campaign'") + assert.Equal(t, "time_based", campaignType, "Expected campaign type to be 'time_based'") + + // Verify campaign times_redeemed incremented + var timesRedeemed int + err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed) + require.NoError(t, err) + assert.Equal(t, 1, timesRedeemed, "Expected times_redeemed to be 1") + + // Verify discount payment + var paymentAmount float64 + err = db.DB.QueryRow(context.Background(), ` + SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount' + `, bookingID).Scan(&paymentAmount) + require.NoError(t, err) + assert.Equal(t, 5.00, paymentAmount, "Expected 5% discount on £100 booking") // 5% of 100 = 5 +} + +// ============================================================================= +// Per-User Milestone Tests +// ============================================================================= + +// TestDiscount_PerUserMilestone tests that per-user booking count milestones work +func TestDiscount_PerUserMilestone(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Create "10th booking" per-user milestone campaign + milestoneValue := 10 + milestoneType := "per_user_booking_count" + campaignID := createTestCampaign(t, "10th Visit Bonus", "milestone", 15.0, &milestoneType, nil, &milestoneValue, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 80.00) + + // Create 9 completed bookings (direct SQL to avoid triggering discounts) + for i := 0; i < 9; i++ { + startTime := time.Now().AddDate(0, -1, -i*7) // Space them out over time + _ = createCompletedBooking(t, userID, serviceID, startTime, 80.00) + } + + // Create and complete the 10th booking + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") + + // Verify discount applied with milestone_type='per_user_booking_count' + var discountSource, milestoneTypeResult string + err := db.DB.QueryRow(context.Background(), ` + SELECT discount_source, milestone_type FROM booking_discounts WHERE booking_id = $1 + `, bookingID).Scan(&discountSource, &milestoneTypeResult) + require.NoError(t, err) + assert.Equal(t, "campaign", discountSource, "Expected discount source to be 'campaign'") + assert.Equal(t, "per_user_booking_count", milestoneTypeResult, "Expected milestone type 'per_user_booking_count'") + + // Verify discount amount (15% of £80 = £12) + var discountAmount float64 + err = db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount) + require.NoError(t, err) + assert.Equal(t, 12.00, discountAmount, "Expected 15% discount (£12)") + + // Complete another booking - verify NO second discount (dedup works) + bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") + + // Should have no discount (already used this milestone) + var discountCount int + err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount) + require.NoError(t, err) + assert.Equal(t, 0, discountCount, "Expected no discount on 11th booking (dedup)") +} + +// ============================================================================= +// Global Milestone Tests +// ============================================================================= + +// TestDiscount_GlobalMilestone tests that global booking count milestones work +func TestDiscount_GlobalMilestone(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Create global milestone campaign for 5th total booking + milestoneValue := 5 + milestoneType := "global_booking_count" + campaignID := createTestCampaign(t, "5th Customer Milestone", "milestone", 20.0, &milestoneType, nil, &milestoneValue, nil) + + userID1 := createTestUser(t, 0) + userID2 := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + // Create 4 completed bookings (any users) + for i := 0; i < 4; i++ { + startTime := time.Now().AddDate(0, 0, -i-1) + _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00) + } + + // Create and complete the 5th booking + bookingID := createPendingBooking(t, userID2, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") + + // Verify discount applied with milestone_type='global_booking_count' + var milestoneTypeResult string + err := db.DB.QueryRow(context.Background(), ` + SELECT milestone_type FROM booking_discounts WHERE booking_id = $1 + `, bookingID).Scan(&milestoneTypeResult) + require.NoError(t, err) + assert.Equal(t, "global_booking_count", milestoneTypeResult, "Expected milestone type 'global_booking_count'") + + // Verify discount amount (20% of £50 = £10) + var discountAmount float64 + err = db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount) + require.NoError(t, err) + assert.Equal(t, 10.00, discountAmount, "Expected 20% discount (£10)") +} + +// ============================================================================= +// Anniversary Milestone Tests +// ============================================================================= + +// TestDiscount_AnniversaryMilestone tests that anniversary-based milestones work +func TestDiscount_AnniversaryMilestone(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Create anniversary campaign (6 months) + milestoneValue := 6 + milestoneType := "anniversary" + milestoneUnit := "months" + campaignID := createTestCampaign(t, "6 Month Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 60.00) + + // Create first booking 7 months ago (using direct SQL to set start_time) + ctx := context.Background() + sevenMonthsAgo := time.Now().AddDate(0, -7, 0) + var firstBookingID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, sevenMonthsAgo).Scan(&firstBookingID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id, override_price) + VALUES ($1, $2, $3) + `, firstBookingID, serviceID, 60.00) + require.NoError(t, err) + + // Create and complete new booking + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") + + // Verify discount applied with milestone_type='anniversary' + var milestoneTypeResult string + err = db.DB.QueryRow(context.Background(), ` + SELECT milestone_type FROM booking_discounts WHERE booking_id = $1 + `, bookingID).Scan(&milestoneTypeResult) + require.NoError(t, err) + assert.Equal(t, "anniversary", milestoneTypeResult, "Expected milestone type 'anniversary'") + + // Complete another booking - verify NO second discount (dedup via booking_discounts) + bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") + + var discountCount int + err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount) + require.NoError(t, err) + assert.Equal(t, 0, discountCount, "Expected no discount on second booking (dedup)") +} + +// ============================================================================= +// Loyalty Priority Tests +// ============================================================================= + +// TestDiscount_LoyaltyPriority tests that loyalty discounts take priority over campaigns +func TestDiscount_LoyaltyPriority(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Create active time-based campaign + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + // Create user with 10 stamps (pending redemption) + userID := createTestUser(t, 10) + + // Create pending redemption + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + serviceID := createTestService(t, 100.00) + + // Create and complete booking + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") + + // Verify ONLY loyalty discount applied (not campaign) + var discountSource string + err = db.DB.QueryRow(context.Background(), ` + SELECT discount_source FROM booking_discounts WHERE booking_id = $1 + `, bookingID).Scan(&discountSource) + require.NoError(t, err) + assert.Equal(t, "loyalty", discountSource, "Expected only loyalty discount (priority)") + + // Verify NOT campaign + var campaignDiscountCount int + err = db.DB.QueryRow(context.Background(), ` + SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' + `, bookingID).Scan(&campaignDiscountCount) + require.NoError(t, err) + assert.Equal(t, 0, campaignDiscountCount, "Expected no campaign discount (loyalty takes priority)") +} + +// ============================================================================= +// Zero Total Edge Case Tests +// ============================================================================= + +// TestDiscount_NoDiscountOnZeroTotal tests that no discount is applied when booking total is 0 +func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Create user with 10 stamps (pending redemption) + userID := createTestUser(t, 10) + + // Create pending redemption + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + // Create a free service (price = 0) + var serviceID string + err = db.DB.QueryRow(ctx, ` + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID) + require.NoError(t, err) + + // Create and complete booking with £0 total + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") + + // Verify NO discount payment created (booking total is 0, discount would be 0) + var paymentCount int + err = db.DB.QueryRow(context.Background(), ` + SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' + `, bookingID).Scan(&paymentCount) + require.NoError(t, err) + assert.Equal(t, 0, paymentCount, "Expected no discount payment (total is 0)") + + // Verify redemption stays pending (not applied) + var redemptionStatus string + err = db.DB.QueryRow(context.Background(), ` + SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' + `, userID).Scan(&redemptionStatus) + require.NoError(t, err) + assert.Equal(t, "pending", redemptionStatus, "Expected redemption to stay pending") + + // Verify stamps still at 10 + var stamps int + err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) + require.NoError(t, err) + assert.Equal(t, 10, stamps, "Expected stamps to remain at 10") +} + +// ============================================================================= +// Max Redemptions Tests +// ============================================================================= + +// TestDiscount_CampaignMaxRedemptions tests that campaigns respect max_redemptions limit +func TestDiscount_CampaignMaxRedemptions(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Create campaign with max_redemptions=1 + maxRedemptions := 1 + campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions) + + userID1 := createTestUser(t, 0) + userID2 := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + // First booking - should get discount + bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) + + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.ProgressBookingHandler) + w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on first booking completion") + + // Verify discount applied + var discountCount1 int + err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1) + require.NoError(t, err) + assert.Equal(t, 1, discountCount1, "Expected discount on first booking") + + // Verify times_redeemed = 1 + var timesRedeemed int + err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed) + require.NoError(t, err) + assert.Equal(t, 1, timesRedeemed, "Expected times_redeemed = 1") + + // Second booking - should NOT get discount (max reached) + bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) + w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") + + require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") + + // Verify NO discount applied + var discountCount2 int + err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2) + require.NoError(t, err) + assert.Equal(t, 0, discountCount2, "Expected no discount on second booking (max reached)") +} + +// ============================================================================= +// Discount Eligibility Flag Tests +// ============================================================================= + +// TestDiscount_EligibilityFlag tests that booking.discount_eligible is set correctly +func TestDiscount_EligibilityFlag(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + // Test case 1: User with pending redemption should be eligible + userID1 := createTestUser(t, 10) + + // Create pending redemption + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID1) + require.NoError(t, err) + + serviceID := createTestService(t, 50.00) + + // Create a new booking + bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) + + // Check discount_eligible flag + var discountEligible1 bool + err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID1).Scan(&discountEligible1) + require.NoError(t, err) + assert.True(t, discountEligible1, "Expected discount_eligible = true for user with pending redemption") + + // Test case 2: User with no pending redemption and no active campaigns should NOT be eligible + userID2 := createTestUser(t, 0) // No stamps, no pending redemption + + bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) + + var discountEligible2 bool + err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID2).Scan(&discountEligible2) + require.NoError(t, err) + assert.False(t, discountEligible2, "Expected discount_eligible = false for user with no pending redemption and no campaigns") + + // Test case 3: User with active time-based campaign should be eligible + userID3 := createTestUser(t, 0) + + // Create active time-based campaign + _ = createTestCampaign(t, "Active Campaign", "time_based", 5.0, nil, nil, nil, nil) + + bookingID3 := createPendingBooking(t, userID3, serviceID, time.Now().Add(72*time.Hour)) + + var discountEligible3 bool + err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID3).Scan(&discountEligible3) + require.NoError(t, err) + assert.True(t, discountEligible3, "Expected discount_eligible = true when active time-based campaign exists") +} + +// ============================================================================= +// Helper: Truncate discount-specific tables +// ============================================================================= + +// truncateDiscountTables truncates discount-related tables that aren't in the standard truncate list +func truncateDiscountTables(t *testing.T, pool *pgxpool.Pool) { + t.Helper() + ctx := context.Background() + + tables := []string{ + "loyalty_redemptions", + "discount_campaigns", + "booking_discounts", + } + + for _, table := range tables { + _, err := pool.Exec(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) + if err != nil { + t.Logf("Warning: could not truncate %s: %v", table, err) + } + } +} \ No newline at end of file diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 49b39e4..3b5bdbc 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -667,6 +667,21 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { } + // Check discount eligibility + var discountEligible bool + var hasPendingRedemption bool + _ = db.DB.QueryRow(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()) + `, req.UserID).Scan(&hasPendingRedemption) + var activeCampaigns int + _ = db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'time_based' + AND start_date <= NOW() AND end_date >= NOW() + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + `).Scan(&activeCampaigns) + discountEligible = hasPendingRedemption || activeCampaigns > 0 + tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) @@ -683,9 +698,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { status, notes, created_by, - idempotency_key + idempotency_key, + discount_eligible ) - VALUES ($1, $2, 'confirmed', $3, $4, $5) + VALUES ($1, $2, 'confirmed', $3, $4, $5, $6) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by ` @@ -700,6 +716,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { req.Notes, adminID, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}, + discountEligible, ).Scan( &booking.ID, &booking.User.ID, diff --git a/backend/main.go b/backend/main.go index b3331ba..39d5d16 100644 --- a/backend/main.go +++ b/backend/main.go @@ -21,6 +21,7 @@ import ( "crussell/mw" authHandlers "crussell/handlers/auth" + "crussell/handlers/admin" "crussell/handlers/bookings" "crussell/handlers/notifications" "crussell/handlers/portfolio" @@ -270,6 +271,14 @@ r.Route("/admin/users", func(r chi.Router) { r.Post("/", scheduling.CreateTimeBlocker) r.Delete("/{id}", scheduling.DeleteTimeBlocker) }) + + r.Route("/admin/discount-campaigns", func(r chi.Router) { + r.Get("/", admin.GetDiscountCampaigns) + r.Post("/", admin.CreateDiscountCampaign) + r.Put("/{id}", admin.UpdateDiscountCampaign) + r.Delete("/{id}", admin.DeleteDiscountCampaign) + r.Get("/{id}/stats", admin.GetCampaignStats) + }) }) }) diff --git a/frontend/src/lib/types/booking.ts b/frontend/src/lib/types/booking.ts index 23b9944..f609665 100644 --- a/frontend/src/lib/types/booking.ts +++ b/frontend/src/lib/types/booking.ts @@ -73,6 +73,7 @@ export interface BookingUser { date_of_birth?: string; account_role: string; loyalty_stamps?: number; + pending_redemption?: boolean; referral_code?: string; referral_code_uses?: number; created_at: string; @@ -120,6 +121,7 @@ export interface Booking { amount_paid: number; amount_due: number; duration_minutes: number; + discount_eligible?: boolean; } export interface BookingListResponse { @@ -130,3 +132,60 @@ export interface BookingListResponse { total_pages: number; } +export interface LoyaltyRedemption { + id: string; + user_id: string; + stamps_redeemed: number; + status: 'pending' | 'applied' | 'expired'; + applied_to_booking_id?: string; + redeemed_at: string; + applied_at?: string; + expires_at: string; +} + +export type CampaignType = 'time_based' | 'milestone'; +export type MilestoneType = 'per_user_booking_count' | 'global_booking_count' | 'anniversary'; +export type MilestoneUnit = 'bookings' | 'months' | 'years'; +export type DiscountCampaignScope = 'all_bookings' | 'first_booking_only' | 'new_customers_only'; +export type DiscountCampaignStatus = 'draft' | 'active' | 'completed' | 'cancelled'; + +export interface DiscountCampaign { + id: string; + name: string; + description?: string; + campaign_type: CampaignType; + discount_percent: number; + scope?: DiscountCampaignScope; + start_date?: string; + end_date?: string; + milestone_type?: MilestoneType; + milestone_value?: number; + milestone_unit?: MilestoneUnit; + status: DiscountCampaignStatus; + max_redemptions?: number; + times_redeemed: number; + created_at: string; + updated_at: string; + created_by?: string; +} + +export interface BookingDiscount { + id: string; + booking_id: string; + user_id: string; + discount_source: 'loyalty' | 'campaign'; + source_id?: string; + campaign_type?: CampaignType; + milestone_type?: MilestoneType; + discount_percent: number; + original_total: number; + discount_amount: number; + applied_at: string; +} + +export interface CampaignStats { + campaign: DiscountCampaign; + total_discount_amount: number; + booking_count: number; +} + diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index e66b6dc..552c425 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -82,6 +82,7 @@ let userData = $state(null); let loadingUser = $state(true); let stamps = $state(0); + let pendingRedemption = $state(false); let uploadingPic = $state(false); // Image cropper state @@ -287,6 +288,7 @@ const data = await response.json(); userData = data; stamps = userData?.loyaltyStamps ?? 0; + pendingRedemption = stamps >= 10; } else { toast.error('Failed to load profile data'); } @@ -832,10 +834,10 @@
{10 - stamps}
- {:else} + {:else if userData && stamps >= 10}
- Congratulations! You've earned 10% off your next appointment! + Your loyalty card is full! Your next completed appointment will receive 10% off.
{/if} diff --git a/frontend/src/routes/admin/discounts/+page.svelte b/frontend/src/routes/admin/discounts/+page.svelte new file mode 100644 index 0000000..466a6d8 --- /dev/null +++ b/frontend/src/routes/admin/discounts/+page.svelte @@ -0,0 +1,978 @@ + + +{#if pageState === 'loading'} + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + {#each Array(3) as _, i (i)} + + + + + + + + + {/each} + +
+
+ + +
+
+
+
+
+{:else if pageState === 'authorized'} +
+ +
+
+

Discount Campaigns

+

Create and manage discount campaigns for your customers

+
+ +
+ + + + + All Campaigns + View and manage your discount campaigns + + + + + + +
+ {#if campaignsLoading} + {#each Array(3) as _, i (i)} +
+
+ + +
+ + +
+
+
+ {/each} + {:else} + {#each campaigns as campaign (campaign.id)} +
+
+
+
+

{campaign.name}

+ {#if campaign.description} +

{campaign.description}

+ {/if} +
+ + {campaign.status} + +
+ +
+ {campaign.discount_percent}% off + + + {getCampaignTypeLabel( + campaign.campaign_type, + campaign.milestone_type as MilestoneType + )} + + + + {campaign.times_redeemed} + {campaign.max_redemptions ? `/${campaign.max_redemptions}` : '/∞'} + redeemed + +
+ +
+ {#if campaign.status === 'draft'} + + {:else if campaign.status === 'active'} + + {/if} + + {#if campaign.status !== 'cancelled'} + + {/if} +
+
+
+ {/each} + {/if} +
+ + {#if !campaignsLoading && campaigns.length === 0} +
+ No campaigns found. Click "Create Campaign" to create your first campaign. +
+ {/if} +
+
+
+ + + + + + Create Campaign + Create a new discount campaign for your customers. + + +
+ +
+ Campaign Name * + + {#if formErrors.name} +

{formErrors.name}

+ {/if} +
+ + +
+ Milestone Unit + { newCampaign.milestone_unit = v as MilestoneUnit; }}> + + + Bookings + Months + Years + + +
+ + +
+ Campaign Type * + { newCampaign.campaign_type = v as CampaignType; }}> + + + Time-based + Milestone + + +
+ + +
+ Discount Percent * + +

Percentage off the booking total

+
+ + + {#if newCampaign.campaign_type === 'time_based'} + +
+ Scope + { newCampaign.scope = v as DiscountCampaignScope; }}> + + + All bookings + First booking only + New customers only + + +
+ + +
+ Start Date * + +
+ + +
+ End Date * + +
+ {/if} + + + {#if newCampaign.campaign_type === 'milestone'} + +
+ Milestone Type * + { + newCampaign.milestone_type = v as MilestoneType; + if (v === 'anniversary') { + newCampaign.milestone_unit = 'years'; + } else if (v === 'global_booking_count') { + newCampaign.milestone_unit = 'bookings'; + } else { + newCampaign.milestone_unit = 'bookings'; + } + }}> + + + Per-user booking count + Global booking count + Anniversary + + +
+ + +
+ Milestone Value * + +
+ + +
+ Milestone Unit + { newCampaign.milestone_unit = v as MilestoneUnit; }}> + + + Bookings + Months + Years + + +
+ {/if} + + +
+ Max Redemptions + +

Leave at 0 for unlimited

+
+
+ + + + + +
+
+ + + + + + + Campaign Stats + + + {selectedCampaign?.name || 'Campaign'} statistics + + + +
+ {#if statsLoading} +
+ + +
+ {:else if campaignStats} +
+ +
+
Total Discount Given
+
+ £{campaignStats.total_discount_amount.toFixed(2)} +
+
+ + +
+
Bookings Discounted
+
+ {campaignStats.booking_count} +
+
+
+ + +
+
Campaign Details
+
+
+ Discount: + + {campaignStats.campaign.discount_percent}% + +
+
+ Status: + + {campaignStats.campaign.status} + +
+
+ Redeemed: + + {campaignStats.campaign.times_redeemed} + {campaignStats.campaign.max_redemptions + ? `/${campaignStats.campaign.max_redemptions}` + : '/∞'} + +
+
+
+ {:else} +
+ No stats available for this campaign. +
+ {/if} +
+ + + + +
+
+{/if} \ No newline at end of file diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 086a945..4f1c8fa 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -217,7 +217,8 @@ CREATE TABLE bookings ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12), - idempotency_key VARCHAR(64) UNIQUE + idempotency_key VARCHAR(64) UNIQUE, + discount_eligible BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX idx_bookings_userid ON bookings(user_id); @@ -387,6 +388,75 @@ CREATE INDEX idx_payments_bookingid ON payments(booking_id); CREATE INDEX idx_payments_status ON payments(status); CREATE INDEX idx_payments_createdat ON payments(created_at); +-- ======================================= +-- LOYALTY REDEMPTIONS TABLE +-- ======================================= +CREATE TABLE loyalty_redemptions ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stamps_redeemed INT NOT NULL DEFAULT 10, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + applied_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months') +); + +CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id); +CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status); + +-- ======================================= +-- DISCOUNT CAMPAIGNS TABLE +-- ======================================= +CREATE TABLE discount_campaigns ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'), + name VARCHAR(100) NOT NULL, + description TEXT, + campaign_type campaign_type NOT NULL DEFAULT 'time_based', + discount_percent NUMERIC(5,2) NOT NULL, + scope discount_campaign_scope, + start_date TIMESTAMPTZ, + end_date TIMESTAMPTZ, + milestone_type milestone_type, + milestone_value INT, + milestone_unit milestone_unit, + status discount_campaign_status NOT NULL DEFAULT 'draft', + max_redemptions INT, + times_redeemed INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)), + CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100), + CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL)) +); + +CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL; +CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status); +CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type); + +-- ======================================= +-- BOOKING DISCOUNTS TABLE +-- ======================================= +CREATE TABLE booking_discounts ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), + booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + discount_source VARCHAR(30) NOT NULL, + source_id CHAR(12), + campaign_type campaign_type, + milestone_type milestone_type, + discount_percent NUMERIC(5,2) NOT NULL, + original_total NUMERIC(10,2) NOT NULL, + discount_amount NUMERIC(10,2) NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id); +CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id); +CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id); +CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id); + -- ======================================= -- BUSINESS SETTINGS TABLE (FOR COMPLIANCE) -- Stores legal business info for receipts, VAT status, currency, etc. @@ -420,6 +490,11 @@ CREATE TRIGGER trigger_update_business_settings_timestamp FOR EACH ROW EXECUTE FUNCTION update_business_settings_timestamp(); +CREATE TRIGGER trigger_update_discount_campaigns_timestamp + BEFORE UPDATE ON discount_campaigns + FOR EACH ROW + EXECUTE FUNCTION update_business_settings_timestamp(); + -- Insert default row INSERT INTO business_settings ( business_name, @@ -445,6 +520,13 @@ INSERT INTO business_settings ( CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid', 'edit_request'); +-- Loyalty/Discount System ENUMs +CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone'); +CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary'); +CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years'); +CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only'); +CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); + CREATE TABLE admin_notifications ( id SERIAL PRIMARY KEY, reason admin_notification_reason NOT NULL,