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
+123 -1
View File
@@ -569,7 +569,7 @@ D=$(open_day_past "$(TZ=Europe/London date -d "today -23 days" +%Y-%m-%d)")
if create_admin_booking "$GRACE_ID" "$(format_london_time "$D" "$SLOT_A")" "[\"$(get_svc 7)\"]" "" "Grace - Luxury Gel Manicure"; then count_past=$((count_past+1)); fi
# Primary test user — variety of past bookings
for day_offset in 2 4 8 11 15; do
for day_offset in 2 4 8 11 15 18 20 22 24 26 28; do
D=$(open_day_past "$(TZ=Europe/London date -d "today -$day_offset days" +%Y-%m-%d)")
SVC_IDX=$(( (day_offset % 4) ))
if create_admin_booking "$USER_USER_ID" "$(format_london_time "$D" "$SLOT_C")" "[\"$(get_svc $SVC_IDX)\"]" "" "User - Past booking $D"; then count_past=$((count_past+1)); fi
@@ -887,6 +887,127 @@ docker exec postgres psql -U myuser -d mydb -c \
completed_count=$(docker exec postgres psql -U myuser -d mydb -tAc \
"SELECT COUNT(*) FROM bookings WHERE status = 'completed'" 2>/dev/null)
# --- Seeding campaigns and stacked discounts for user@example.com ---
echo -e "\n${C_BLUE}🎟️ Seeding Campaigns & Loyalty Stacked Discounts...${C_RESET}"
docker exec -i postgres psql -U myuser -d mydb << 'CAMPAIGN_SQL' > /dev/null 2>&1
DO $$
DECLARE
u_id CHAR(12);
past_camp_id CHAR(12);
active_camp_id CHAR(12);
milestone_camp_id CHAR(12);
b_rec RECORD;
b_idx INT := 1;
red_id CHAR(12);
b_total NUMERIC(10,2);
loyalty_disc NUMERIC(10,2);
camp_disc NUMERIC(10,2);
balance NUMERIC(10,2);
BEGIN
-- 1. Get user id
SELECT id INTO u_id FROM users WHERE email = 'user@example.com';
-- 2. Insert 3 campaigns
INSERT INTO discount_campaigns (name, description, campaign_type, discount_percent, scope, start_date, end_date, status, max_redemptions)
VALUES ('Demo Sale', 'Get 10% off visits because teehee', 'time_based', 10.00, 'all_bookings', NOW() - INTERVAL '30 days', NOW() - INTERVAL '10 days', 'active', 500)
RETURNING id INTO past_camp_id;
INSERT INTO discount_campaigns (name, description, campaign_type, discount_percent, scope, start_date, end_date, status, max_redemptions)
VALUES ('Summer Sale', 'Enjoy 15% off all summer treatments', 'time_based', 15.00, 'all_bookings', NOW() - INTERVAL '1 day', NOW() + INTERVAL '30 days', 'active', 1000)
RETURNING id INTO active_camp_id;
INSERT INTO discount_campaigns (name, description, campaign_type, discount_percent, scope, status, milestone_type, milestone_value)
VALUES ('10th Visit Celebration', 'Receive 20% off on your 10th milestone visit', 'milestone', 20.00, 'all_bookings', 'active', 'per_user_booking_count', 10)
RETURNING id INTO milestone_camp_id;
-- 3. Loop through completed bookings of user@example.com in chronological order
FOR b_rec IN
SELECT b.id, b.start_time,
COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) as total
FROM bookings b
JOIN booking_services bs ON bs.booking_id = b.id
JOIN services s ON bs.service_id = s.id
WHERE b.user_id = u_id AND b.status = 'completed'
GROUP BY b.id, b.start_time
ORDER BY b.start_time ASC
LOOP
b_total := b_rec.total;
-- Booking 6 (chronologically Day 18) reaches 10 stamps (stamps go 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10)
IF b_idx = 6 THEN
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES (u_id, 10, 'pending', b_rec.start_time)
RETURNING id INTO red_id;
END IF;
-- Booking 7 (chronologically Day 15) consumes the redemption AND gets past campaign (since Day 15 is within past campaign dates)
IF b_idx = 7 THEN
-- Get the pending redemption
SELECT id INTO red_id FROM loyalty_redemptions WHERE user_id = u_id AND status = 'pending' ORDER BY redeemed_at ASC LIMIT 1;
IF red_id IS NOT NULL THEN
loyalty_disc := ROUND(b_total * 0.10, 2);
camp_disc := ROUND(b_total * 0.10, 2);
balance := ROUND(b_total - loyalty_disc - camp_disc, 2);
-- Create loyalty discount row
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount, applied_at)
VALUES (b_rec.id, u_id, 'loyalty', red_id, 10.00, b_total, loyalty_disc, b_rec.start_time);
-- Create loyalty payment row
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at)
VALUES (b_rec.id, 'partial', 'discount', loyalty_disc, 'completed', b_rec.start_time);
-- Update loyalty redemption
UPDATE loyalty_redemptions
SET status = 'applied', applied_to_booking_id = b_rec.id, applied_at = b_rec.start_time
WHERE id = red_id;
-- Create campaign discount row
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount, applied_at)
VALUES (b_rec.id, u_id, 'campaign', past_camp_id, 'time_based', 10.00, b_total, camp_disc, b_rec.start_time);
-- Create campaign payment row
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at)
VALUES (b_rec.id, 'partial', 'discount', camp_disc, 'completed', b_rec.start_time);
-- Increment campaign times_redeemed
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = past_camp_id;
-- Create standard payment balance row
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at)
VALUES (b_rec.id, 'balance', 'in_person_card', balance, 'completed', b_rec.start_time);
END IF;
-- Other bookings during past campaign (Bookings 1-5, and 8: Day 28, 26, 24, 22, 20, 11) get campaign discount only
ELSIF b_rec.start_time >= (NOW() - INTERVAL '30 days') AND b_rec.start_time <= (NOW() - INTERVAL '10 days') THEN
camp_disc := ROUND(b_total * 0.10, 2);
balance := ROUND(b_total - camp_disc, 2);
-- Create campaign discount row
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount, applied_at)
VALUES (b_rec.id, u_id, 'campaign', past_camp_id, 'time_based', 10.00, b_total, camp_disc, b_rec.start_time);
-- Create campaign payment row
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at)
VALUES (b_rec.id, 'partial', 'discount', camp_disc, 'completed', b_rec.start_time);
-- Increment campaign times_redeemed
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = past_camp_id;
-- Create standard payment balance row
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at)
VALUES (b_rec.id, 'balance', 'in_person_card', balance, 'completed', b_rec.start_time);
END IF;
b_idx := b_idx + 1;
END LOOP;
-- Update final stamps to 5
UPDATE users SET loyalty_stamps = 5 WHERE id = u_id;
END $$;
CAMPAIGN_SQL
# Pure SQL payment seeding — randomized per booking, no bash loops
docker exec -i postgres psql -U myuser -d mydb << 'PAYMENT_SQL' > /dev/null 2>&1
DO $$
@@ -907,6 +1028,7 @@ BEGIN
JOIN booking_services bs ON bs.booking_id = b.id
JOIN services s ON bs.service_id = s.id
WHERE b.status = 'completed'
AND NOT EXISTS (SELECT 1 FROM payments WHERE booking_id = b.id)
GROUP BY b.id
LOOP
booking_total := rec.total;