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
@@ -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)
}
}