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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user