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:
@@ -3121,3 +3121,89 @@ func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdminBooking_WithDiscounts(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
// Create completed booking
|
||||
bookingTime := time.Now().Add(-24 * time.Hour)
|
||||
bookingID := createCompletedBookingWithTimeForAdmin(t, userID, serviceID, bookingTime, 50.00)
|
||||
|
||||
// Create a discount campaign and apply it
|
||||
var campaignID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
||||
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() + INTERVAL '2 days')
|
||||
RETURNING id
|
||||
`, "Admin Test Campaign").Scan(&campaignID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00)
|
||||
`, bookingID, userID, campaignID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking discount: %v", err)
|
||||
}
|
||||
|
||||
// Call GetAdminBookingHandler
|
||||
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
|
||||
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var booking bookings.Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse booking: %v", err)
|
||||
}
|
||||
|
||||
if len(booking.Discounts) != 1 {
|
||||
t.Fatalf("expected 1 discount, got %d", len(booking.Discounts))
|
||||
}
|
||||
|
||||
d := booking.Discounts[0]
|
||||
if d.CampaignName == nil || *d.CampaignName != "Admin Test Campaign" {
|
||||
t.Errorf("expected campaign name 'Admin Test Campaign', got %v", d.CampaignName)
|
||||
}
|
||||
if d.DiscountAmount != 5.00 {
|
||||
t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func createCompletedBookingWithTimeForAdmin(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string {
|
||||
t.Helper()
|
||||
var bookingID string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'completed')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create completed booking: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_services (booking_id, service_id, override_price)
|
||||
VALUES ($1, $2, $3)
|
||||
`, bookingID, serviceID, price)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link service: %v", err)
|
||||
}
|
||||
|
||||
return bookingID
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ type CampaignStats struct {
|
||||
func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
if statusFilter != "" {
|
||||
validStatuses := map[string]bool{"active": true, "paused": true, "cancelled": true, "expired": true}
|
||||
validStatuses := map[string]bool{"draft": true, "active": true, "completed": true, "cancelled": true}
|
||||
if !validStatuses[statusFilter] {
|
||||
http.Error(w, "invalid status filter", http.StatusBadRequest)
|
||||
return
|
||||
@@ -307,7 +307,7 @@ func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
req.MilestoneValue,
|
||||
req.MilestoneUnit,
|
||||
req.MaxRedemptions,
|
||||
"active",
|
||||
"draft",
|
||||
createdBy,
|
||||
).Scan(
|
||||
&campaign.ID,
|
||||
@@ -478,8 +478,8 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
argNum++
|
||||
}
|
||||
if req.Status != nil {
|
||||
if *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" {
|
||||
http.Error(w, "Status must be 'active', 'cancelled', or 'completed'", http.StatusBadRequest)
|
||||
if *req.Status != "draft" && *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" {
|
||||
http.Error(w, "Status must be 'draft', 'active', 'cancelled', or 'completed'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
query += ", status = $" + strconv.Itoa(argNum)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -77,6 +77,22 @@ func seedDefaultWorkingHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nextWorkingHour returns a time within working hours (08:00-19:00) that is
|
||||
// always < 24h from now. This avoids time-of-day flakiness in tests that need
|
||||
// a booking within the 24-hour no-show window. Working hours are 08:00-20:00;
|
||||
// we cap at 19:00 so a 60-minute service finishes before closing.
|
||||
func nextWorkingHour() time.Time {
|
||||
now := time.Now()
|
||||
soon := now.Add(2 * time.Hour).Truncate(time.Second)
|
||||
if soon.Hour() < 8 {
|
||||
return time.Date(soon.Year(), soon.Month(), soon.Day(), 9, 0, 0, 0, soon.Location())
|
||||
}
|
||||
if soon.Hour() >= 19 {
|
||||
return time.Date(soon.Year(), soon.Month(), soon.Day()+1, 9, 0, 0, 0, soon.Location())
|
||||
}
|
||||
return soon
|
||||
}
|
||||
|
||||
// helper function to make JSON request with JWT auth
|
||||
// For authenticated requests, use makeAuthRequest which extracts user from JWT
|
||||
func makeRequest(handler http.Handler, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
||||
@@ -1651,8 +1667,7 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
soonTime := nextWorkingHour()
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
@@ -1817,8 +1832,7 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
soonTime := nextWorkingHour()
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
@@ -1901,7 +1915,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// === First booking: no-show ===
|
||||
soonTime1 := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
soonTime1 := nextWorkingHour()
|
||||
|
||||
bookingReq1 := CreateBookingRequest{
|
||||
StartTime: soonTime1,
|
||||
@@ -1941,7 +1955,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||
}
|
||||
|
||||
// === Second booking: no-show ===
|
||||
soonTime2 := time.Now().Add(47 * time.Hour).Truncate(time.Second)
|
||||
soonTime2 := nextWorkingHour().AddDate(0, 0, 1)
|
||||
|
||||
bookingReq2 := CreateBookingRequest{
|
||||
StartTime: soonTime2,
|
||||
@@ -5275,3 +5289,94 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) {
|
||||
t.Error("expected second booking deposit_required=false in DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBooking_WithDiscounts(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create completed booking
|
||||
london, _ := time.LoadLocation("Europe/London")
|
||||
bookingTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour)
|
||||
bookingID := createCompletedBookingWithTime(t, userID, serviceID, bookingTime, 50.00)
|
||||
|
||||
// Create a discount campaign and apply it
|
||||
var campaignID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
||||
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day')
|
||||
RETURNING id
|
||||
`, "Test Campaign").Scan(&campaignID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00)
|
||||
`, bookingID, userID, campaignID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking discount: %v", err)
|
||||
}
|
||||
|
||||
// Call GetBookingHandler
|
||||
handler := http.HandlerFunc(GetBookingHandler)
|
||||
w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var booking Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse booking: %v", err)
|
||||
}
|
||||
|
||||
if len(booking.Discounts) != 1 {
|
||||
t.Fatalf("expected 1 discount, got %d", len(booking.Discounts))
|
||||
}
|
||||
|
||||
d := booking.Discounts[0]
|
||||
if d.CampaignName == nil || *d.CampaignName != "Test Campaign" {
|
||||
t.Errorf("expected campaign name 'Test Campaign', got %v", d.CampaignName)
|
||||
}
|
||||
if d.DiscountAmount != 5.00 {
|
||||
t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string {
|
||||
t.Helper()
|
||||
var bookingID string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'completed')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create completed booking: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_services (booking_id, service_id, override_price)
|
||||
VALUES ($1, $2, $3)
|
||||
`, bookingID, serviceID, price)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link service: %v", err)
|
||||
}
|
||||
|
||||
return bookingID
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
type CustomerRelationship struct {
|
||||
TotalSpend float64 `json:"totalSpend"`
|
||||
TotalSaved float64 `json:"totalSaved"`
|
||||
TotalTips float64 `json:"totalTips"`
|
||||
TotalVisits int `json:"totalVisits"`
|
||||
CustomerFor string `json:"customerFor"`
|
||||
@@ -62,11 +63,25 @@ func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) {
|
||||
WHERE b.user_id = $1
|
||||
AND p.status = 'completed'
|
||||
AND p.payment_type IN ('full', 'partial', 'balance', 'deposit')
|
||||
AND p.payment_method != 'discount'
|
||||
`, userID).Scan(&result.TotalSpend)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("Failed to get total spend for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(SUM(p.amount), 0)
|
||||
FROM payments p
|
||||
JOIN bookings b ON p.booking_id = b.id
|
||||
WHERE b.user_id = $1
|
||||
AND p.status = 'completed'
|
||||
AND p.payment_type IN ('full', 'partial', 'balance', 'deposit')
|
||||
AND p.payment_method = 'discount'
|
||||
`, userID).Scan(&result.TotalSaved)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("Failed to get total saved for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(SUM(p.amount), 0)
|
||||
FROM payments p
|
||||
|
||||
@@ -318,3 +318,56 @@ func createPayment(t *testing.T, pool *pgxpool.Pool, bookingID, paymentType stri
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerRelationship_WithDiscounts(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
svcID, err := createService(db.DB, "Test Service", 100.00)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
bookingID := createCompletedBooking(t, db.DB, userID, svcID, "2024-03-01 10:00:00+00", 100.00)
|
||||
|
||||
// User pays £80.00 cash/card and receives £20.00 discount
|
||||
createPayment(t, db.DB, bookingID, "balance", 80.00)
|
||||
createDiscountPayment(t, db.DB, bookingID, 20.00)
|
||||
|
||||
req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID)
|
||||
rr := httptest.NewRecorder()
|
||||
GetCustomerRelationshipHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result CustomerRelationship
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if result.TotalSpend != 80.00 {
|
||||
t.Errorf("expected total spend 80.00, got %.2f", result.TotalSpend)
|
||||
}
|
||||
|
||||
if result.TotalSaved != 20.00 {
|
||||
t.Errorf("expected total saved 20.00, got %.2f", result.TotalSaved)
|
||||
}
|
||||
}
|
||||
|
||||
func createDiscountPayment(t *testing.T, pool *pgxpool.Pool, bookingID string, amount float64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed')
|
||||
`, bookingID, amount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create discount payment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user