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
+86
View File
@@ -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
}
+4 -4
View File
@@ -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)
+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)
+111 -6
View File
@@ -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)
}
}