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

- Add discount campaign management and validation logic
- Update booking handlers with discount application flow
- Add customer relationship endpoints for loyalty tracking
- Update frontend modals (booking, approval, payment, reschedule)
- Add DiscountsManagement and loyalty reference documentation
- Update dev scripts and database init for discount tables
- Clean up completed plan files
This commit is contained in:
2026-06-04 23:13:02 +01:00
parent 79b23a3cf7
commit 6d4bc4d637
28 changed files with 2248 additions and 1461 deletions
+193 -123
View File
@@ -1,6 +1,7 @@
package bookings
import (
"context"
"crussell/db"
"crussell/handlers/notifications"
"crussell/handlers/scheduling"
@@ -52,12 +53,79 @@ type Booking struct {
User *UserSummary `json:"user,omitempty"`
Services []BookingService `json:"services,omitempty"`
Payments []Payment `json:"payments,omitempty"`
Discounts []BookingDiscount `json:"discounts,omitempty"`
TotalAmount float64 `json:"total_amount"`
AmountPaid float64 `json:"amount_paid"`
AmountDue float64 `json:"amount_due"`
DurationMinutes int `json:"duration_minutes"`
}
type BookingDiscount struct {
ID string `json:"id"`
BookingID string `json:"booking_id"`
UserID string `json:"user_id"`
DiscountSource string `json:"discount_source"`
SourceID *string `json:"source_id,omitempty"`
CampaignName *string `json:"campaign_name,omitempty"`
CampaignType *string `json:"campaign_type,omitempty"`
MilestoneType *string `json:"milestone_type,omitempty"`
DiscountPercent float64 `json:"discount_percent"`
OriginalTotal float64 `json:"original_total"`
DiscountAmount float64 `json:"discount_amount"`
AppliedAt time.Time `json:"applied_at"`
}
func fetchBookingDiscounts(ctx context.Context, bookingID string) ([]BookingDiscount, error) {
discountRows, err := db.DB.Query(ctx, `
SELECT
bd.id, bd.booking_id, bd.user_id, bd.discount_source, bd.source_id,
bd.campaign_type, bd.milestone_type, bd.discount_percent, bd.original_total, bd.discount_amount, bd.applied_at,
c.name AS campaign_name
FROM booking_discounts bd
LEFT JOIN discount_campaigns c ON bd.discount_source = 'campaign' AND bd.source_id = c.id
WHERE bd.booking_id = $1
ORDER BY bd.applied_at ASC
`, bookingID)
if err != nil {
return nil, err
}
defer discountRows.Close()
var discounts []BookingDiscount
for discountRows.Next() {
var d BookingDiscount
var campaignName sql.NullString
var sourceID sql.NullString
var campaignType sql.NullString
var milestoneType sql.NullString
if err := discountRows.Scan(
&d.ID, &d.BookingID, &d.UserID, &d.DiscountSource, &sourceID,
&campaignType, &milestoneType, &d.DiscountPercent, &d.OriginalTotal, &d.DiscountAmount, &d.AppliedAt,
&campaignName,
); err != nil {
return nil, err
}
if sourceID.Valid {
s := sourceID.String
d.SourceID = &s
}
if campaignName.Valid && campaignName.String != "" {
c := campaignName.String
d.CampaignName = &c
}
if campaignType.Valid && campaignType.String != "" {
c := campaignType.String
d.CampaignType = &c
}
if milestoneType.Valid && milestoneType.String != "" {
m := milestoneType.String
d.MilestoneType = &m
}
discounts = append(discounts, d)
}
return discounts, nil
}
// populateDepositFields sets the computed deposit fields on a Booking.
// It must be called after TotalAmount, AmountPaid, and StartTime are already set.
//
@@ -884,6 +952,14 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.AmountDue = totalAmount - amountPaid
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
discounts, err := fetchBookingDiscounts(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
booking.Discounts = discounts
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
@@ -2063,141 +2139,127 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
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)
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)
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 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(), `
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)
}
_, _ = 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)
var userBookingCount int
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount)
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)
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 != "" {
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)
}
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).Scan(&globalCampaignID, &globalPercent)
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).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 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 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
}
}
}
@@ -2787,6 +2849,14 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
discounts, err := fetchBookingDiscounts(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
booking.Discounts = discounts
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)