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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user