Files
Crussell/.sisyphus/plans/45-loyalty-discount-system.md
T
popertots bec4100e4d feat: customer relationship view, idempotency keys, approval decline, seed payments, backlog cleanup
- #3: Wire ApprovalModal handleDecline to POST /api/admin/bookings/{id}/cancel
- #25: New GET /api/admin/users/{id}/relationship endpoint with spend, tips, visits, customer-for duration, top services
- #25: UserModal reorganized — Personal Info, Booking History, Customer Relationship, Loyalty, Patch Tests
- #36: Idempotency keys on user and admin booking creation (UUID header, duplicate detection)
- local-dev-2.sh: seed payments via PL/pgSQL for completed bookings (5 randomized scenarios)
- local-dev-2.sh: shrink guest/time-blocker output, add payments to summary
- Backlog: mark #3/#25/#35/#36/#49 done, plan #36/#45, remove #46/#48, update #45 with milestone campaigns
- Remove notes history table, avg visits/year metric, Account Information, Privacy & Consent from UserModal
2026-05-04 12:20:03 +01:00

34 KiB

Plan: #45 Loyalty Stamp Redemption + Discount/Sales System

Context

Crussell already has:

  • loyalty_stamps INT column on users table (default 0)
  • Stamp award logic in ProgressBookingHandler (bookings.go lines 1566-1579): +1 stamp on booking completion, with 1-day dedup
  • payment_method enum already includes 'discount'
  • Account page shows "X stamps until 10% off" and "Congratulations! You've earned 10% off!" when stamps >= 10
  • No existing promo code, discount campaign, or redemption infrastructure
  • Customer Relationship view shows totalVisits, firstVisitDate, customerFor — useful for milestone eligibility checks

User Requirements

  1. Loyalty auto-redeem: When stamps reach 10, next completed booking gets 10% off automatically
  2. Discount applied at completion, not booking creation (booking may be cancelled)
  3. Customer UI: No exact discount prices shown (too many variables). When card is full, show info that next appointment is discounted
  4. Email notification: When card becomes full, email customer informing them next appointment is discounted (TODO — email system TBD)
  5. Discount campaigns: Admin can create time-based sales (e.g., "Easter Sale 5% off weekend", "Opening week 10% off first appointment")
  6. Milestone discounts: Admin can create milestone-based campaigns:
    • Per-user milestones: "Xth booking" for a specific customer (e.g., 100th booking → 15% off)
    • Global milestones: "Xth booking overall" across all customers (e.g., our 1000th booking → 10% off for that customer)
    • Anniversary milestones: First booking after hitting 6 months, 1 year, 2 years since customer's first booking
  7. Discount tracking: Need a system for managing discounts and sales broadly

Design Decisions

Campaign Types

Two distinct campaign types with different eligibility logic:

Time-Based Campaigns (existing plan):

  • Eligibility checked at booking creation (date range match)
  • Applied at booking completion
  • Examples: "Easter Sale 5% off", "Opening week 10% off first appointment"

Milestone Campaigns (new):

  • Eligibility checked at booking completion (when the milestone is actually reached)
  • Applied immediately upon completion
  • Three sub-types:
    • Per-user milestone: "100th booking" — counts completed bookings per user. When user hits the target, discount applies.
    • Global milestone: "1000th booking overall" — counts all completed bookings across all users. When the global counter hits the target, that booking gets the discount.
    • Anniversary milestone: "6 months since first booking" — checks time elapsed since user's first completed booking. First booking after crossing the threshold gets the discount.

Discount Application Timing

  • Loyalty discount: Applied at booking completion (status → "completed"). At that point, calculate 10% of booking total, create a discount payment_method record for the discount amount, deduct stamps.
  • Time-based campaign discount: Applied at booking completion. Eligibility checked at booking creation (date range), but actual application happens at completion.
  • Milestone campaign discount: Applied at booking completion. Eligibility is only determinable at completion (that's when the milestone is reached).

Why Completion-Time Application?

If applied at booking creation:

  • Customer books → discount reserved → customer cancels → discount lost or needs rollback
  • Multiple pre-booked appointments create ambiguity about which one gets the discount

At completion:

  • Only actual completed appointments get discounts
  • No cancellation edge cases
  • Clean accounting

Global Booking Counter

For "Xth booking overall" milestones, we query SELECT COUNT(*) FROM bookings WHERE status = 'completed' at completion time. If the count is 99, the current booking is the 100th. No dedicated counter table — the query is fast enough with an index on status.

Anniversary Milestone Logic

For "6 months since first booking" campaigns:

  • At booking completion: check if user's first completed booking was ≥ 6 months ago AND they have no prior anniversary discount of this type applied
  • Use booking_discounts table to check if this specific anniversary milestone has already been claimed by this user
  • Only applies once per milestone per user

Campaign "First Appointment" Logic

For campaigns like "10% off first appointment":

  • At booking creation: check if user has any existing completed bookings. If not, mark booking as eligible for campaign discount.
  • At completion: apply the discount.

Customer UI Principle

The account page should NOT show "You'll save £12.50" because:

  • Services may change between booking and completion
  • Admin may override prices
  • Multiple bookings may be pending

Instead: "Your next completed appointment will receive a 10% loyalty discount."

For milestone campaigns, the customer doesn't need to know in advance — the discount is a surprise applied at completion. Admin can optionally notify via email (TODO).


Database Schema Changes

1. loyalty_redemptions Table

Tracks when stamps are converted into a pending discount.

CREATE TABLE loyalty_redemptions (
    id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'),
    user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    stamps_redeemed INT NOT NULL DEFAULT 10,
    status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending', 'applied', 'expired'
    applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
    redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    applied_at TIMESTAMPTZ,
    expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months')
);

CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id);
CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status);

Lifecycle:

  1. When stamps reach 10 → auto-create row with status = 'pending'
  2. When booking completes with redemption → status = 'applied', applied_to_booking_id set
  3. After 6 months unused → status = 'expired' (via cron or on-demand check)

2. discount_campaigns Table

Admin-managed promotional campaigns (time-based and milestone-based).

CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone');
CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary');
CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years');
CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only');
CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled');

CREATE TABLE discount_campaigns (
    id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'),
    name VARCHAR(100) NOT NULL,                    -- "Easter Sale 2026" or "100th Booking Milestone"
    description TEXT,                               -- "5% off all services this weekend"
    campaign_type campaign_type NOT NULL DEFAULT 'time_based',
    discount_percent NUMERIC(5,2) NOT NULL,         -- 5.00, 10.00, etc.
    
    -- Time-based fields (used when campaign_type = 'time_based')
    scope discount_campaign_scope,                  -- 'all_bookings', 'first_booking_only', etc.
    start_date TIMESTAMPTZ,                         -- NULL for milestone campaigns
    end_date TIMESTAMPTZ,                           -- NULL for milestone campaigns
    
    -- Milestone fields (used when campaign_type = 'milestone')
    milestone_type milestone_type,                  -- 'per_user_booking_count', 'global_booking_count', 'anniversary'
    milestone_value INT,                            -- Target number (e.g., 100 for 100th booking, 6 for 6 months)
    milestone_unit milestone_unit,                  -- 'bookings', 'months', 'years'
    
    -- Common fields
    status discount_campaign_status NOT NULL DEFAULT 'draft',
    max_redemptions INT,                            -- NULL = unlimited. For global milestones, this is the total count cap.
    times_redeemed INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
    
    CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)),
    CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100),
    CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL))
);

CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL;
CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status);
CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type);

Milestone Examples:

Campaign campaign_type milestone_type milestone_value milestone_unit
"100th booking for each customer" milestone per_user_booking_count 100 bookings
"Our 1000th booking overall" milestone global_booking_count 1000 bookings
"6 months since first visit" milestone anniversary 6 months
"1 year anniversary" milestone anniversary 1 years
"Easter Sale 5% off" time_based NULL NULL NULL

2b. Global Booking Count (no dedicated table)

For "Xth booking overall" milestones, we query at completion time:

SELECT COUNT(*) FROM bookings WHERE status = 'completed';

If the count is 99, the booking being completed is the 100th. No dedicated counter table needed — the query is fast enough (indexed on status) and avoids a separate table. At completion, we check all active global_booking_count milestone campaigns to see if the current count matches any milestone value.

3. booking_discounts Table

Tracks which discounts were applied to which bookings.

CREATE TABLE booking_discounts (
    id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'),
    booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
    user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    discount_source VARCHAR(30) NOT NULL,  -- 'loyalty' or 'campaign'
    source_id CHAR(12),                    -- loyalty_redemption_id or campaign_id (nullable for loyalty)
    campaign_type campaign_type,           -- 'time_based' or 'milestone' (NULL for loyalty)
    milestone_type milestone_type,         -- NULL unless campaign_type = 'milestone'
    discount_percent NUMERIC(5,2) NOT NULL,
    original_total NUMERIC(10,2) NOT NULL, -- booking total before discount
    discount_amount NUMERIC(10,2) NOT NULL,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id);
CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id);
CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id);
CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id);

The user_id and milestone_type columns enable anniversary dedup: check if a user already has a booking_discounts row with milestone_type = 'anniversary' and source_id = <campaign_id> before applying.

4. Alter bookings Table

ALTER TABLE bookings ADD COLUMN discount_eligible BOOLEAN NOT NULL DEFAULT FALSE;

This flag is set at booking creation time if:

  • User has a pending loyalty redemption, OR
  • An active campaign applies to this booking

Backend Implementation

Phase 1: Loyalty Auto-Redemption

1.1 Stamp Threshold Detection (modify ProgressBookingHandler)

File: backend/handlers/bookings/bookings.go (around line 1566)

Current code increments stamps. After increment, check if stamps == 10:

// After: SET loyalty_stamps = loyalty_stamps + 1
// Add:
var newStampCount int
err = db.DB.QueryRow(r.Context(), `
    SELECT loyalty_stamps FROM users WHERE id = $1
`, booking.User.ID).Scan(&newStampCount)

if newStampCount == 10 {
    // Auto-create pending redemption
    _, err = db.DB.Exec(r.Context(), `
        INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
        VALUES ($1, 10, 'pending', NOW())
    `, booking.User.ID)
    
    // TODO: Send email notification when email system is wired
    // Email: "Congratulations! Your loyalty card is full. Your next completed appointment will receive 10% off."
    // log.Printf("TODO: Send loyalty card full email to user %s", booking.User.ID)
}

1.2 Booking Creation — Mark Discount Eligibility

File: backend/handlers/bookings/bookings.go (CreateBookingHandler, ~line 1267) File: backend/handlers/bookings/manage.go (AdminCreateBookingForUserHandler, ~line 621)

Before INSERT into bookings, check for pending loyalty redemption or active campaigns:

// Check if user has pending loyalty redemption
var hasPendingRedemption bool
err := db.DB.QueryRow(r.Context(), `
    SELECT EXISTS(
        SELECT 1 FROM loyalty_redemptions 
        WHERE user_id = $1 AND status = 'pending'
        AND expires_at > NOW()
    )
`, userID).Scan(&hasPendingRedemption)

// Check for active campaigns
var activeCampaigns int
err = db.DB.QueryRow(r.Context(), `
    SELECT COUNT(*) FROM discount_campaigns
    WHERE status = 'active'
    AND start_date <= NOW()
    AND end_date >= NOW()
    AND (scope = 'all_bookings' 
         OR (scope = 'first_booking_only' AND NOT EXISTS(
             SELECT 1 FROM bookings b WHERE b.user_id = $1 AND b.status = 'completed'
         ))
         OR (scope = 'new_customers_only' AND NOT EXISTS(
             SELECT 1 FROM bookings b WHERE b.user_id = $1
         ))
    )
    AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
`, userID).Scan(&activeCampaigns)

discountEligible := hasPendingRedemption || activeCampaigns > 0

Then include discount_eligible in the bookings INSERT.

1.3 Booking Completion — Apply Discount

File: backend/handlers/bookings/bookings.go (ProgressBookingHandler, ~line 1537)

After stamp award, before deposit reduction:

if req.Status == "completed" {
    // ... existing stamp award logic ...
    
    // Calculate booking total
    var bookingTotal float64
    err := db.DB.QueryRow(r.Context(), `
        SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
        FROM booking_services bs
        JOIN services s ON bs.service_id = s.id
        WHERE bs.booking_id = $1
    `, bookingID).Scan(&bookingTotal)
    
    // Check for pending loyalty redemption
    var redemptionID string
    err = db.DB.QueryRow(r.Context(), `
        SELECT id FROM loyalty_redemptions
        WHERE user_id = $1 AND status = 'pending'
        AND expires_at > NOW()
        ORDER BY redeemed_at ASC
        LIMIT 1
    `, booking.User.ID).Scan(&redemptionID)
    
    if redemptionID != "" {
        discountAmount := roundTo2(bookingTotal * 0.10)
        
        // Record discount
        _, err = 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, 'loyalty', $3, NULL, NULL, 10.00, $4, $5)
        `, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount)
        
        // Create discount payment record
        _, err = 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)
        
        // Update redemption status
        _, err = db.DB.Exec(r.Context(), `
            UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW()
            WHERE id = $2
        `, bookingID, redemptionID)
        
        // Deduct stamps
        _, err = db.DB.Exec(r.Context(), `
            UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1
        `, booking.User.ID)
    }
    
    // Check for active time-based campaign discount
    var campaignID string
    var campaignPercent float64
    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 (scope = 'all_bookings' OR ...)
        AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
        LIMIT 1
    `).Scan(&campaignID, &campaignPercent)
    
    if campaignID != "" && redemptionID == "" {
        // Only apply campaign if no loyalty discount was applied
        discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
        
        _, err = 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)
        
        _, err = 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)
        
        _, err = db.DB.Exec(r.Context(), `
            UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
        `, campaignID)
    }
    
    // Check for milestone campaigns (applied at completion time)
    if redemptionID == "" && campaignID == "" {
        // Per-user booking count milestone
        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)
        
        if milestoneCampaignID != "" {
            // Apply per-user milestone discount
            discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
            _, err = 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)
            _, err = 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)
            _, err = db.DB.Exec(r.Context(), `
                UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
            `, milestoneCampaignID)
        }
        
        // Global booking count milestone
        if milestoneCampaignID == "" {
            var globalCount int
            db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
            // globalCount is N-1 (current booking not yet marked completed), so this booking is the (globalCount+1)th
            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+1).Scan(&globalCampaignID, &globalPercent)
            
            if globalCampaignID != "" {
                discountAmount := roundTo2(bookingTotal * globalPercent / 100)
                _, err = 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)
                _, err = 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)
                _, err = db.DB.Exec(r.Context(), `
                    UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
                `, globalCampaignID)
            }
        }
        
        // Anniversary milestone
        if milestoneCampaignID == "" && globalCampaignID == "" {
            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() {
                // Check all active anniversary milestones
                rows, _ := 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)
                defer rows.Close()
                for rows.Next() {
                    var annID string
                    var annPercent float64
                    var annValue int
                    var annUnit string
                    rows.Scan(&annID, &annPercent, &annValue, &annUnit)
                    
                    elapsed := time.Since(firstVisitDate)
                    var matches bool
                    switch annUnit {
                    case "months":
                        matches = int(elapsed.Hours()/(30*24)) >= annValue
                    case "years":
                        matches = int(elapsed.Hours()/(365.25*24)) >= annValue
                    }
                    
                    if matches {
                        discountAmount := roundTo2(bookingTotal * annPercent / 100)
                        _, err = 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)
                        _, err = 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)
                        _, err = db.DB.Exec(r.Context(), `
                            UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
                        `, annID)
                        break // Only one anniversary milestone per completion
                    }
                }
            }
        }
    }
    
    // ... existing deposit reduction logic ...
}

Phase 2: Discount Campaign CRUD

2.1 New Handler File

File: backend/handlers/admin/discount_campaigns.go

Endpoints:

  • GET /api/admin/discount-campaigns — list all campaigns
  • POST /api/admin/discount-campaigns — create campaign
  • PUT /api/admin/discount-campaigns/{id} — update campaign (status, dates, etc.)
  • DELETE /api/admin/discount-campaigns/{id} — cancel campaign
  • GET /api/admin/discount-campaigns/{id}/stats — redemption stats

Handler structure follows existing admin pattern (like handlers/admin/services.go).

2.2 Router Wiring

File: backend/main.go (~line 232, in admin routes)

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireAuth)
    r.Use(middleware.RequireAdmin)
    // ... existing routes ...
    r.Get("/discount-campaigns", admin.GetDiscountCampaigns)
    r.Post("/discount-campaigns", admin.CreateDiscountCampaign)
    r.Put("/discount-campaigns/{id}", admin.UpdateDiscountCampaign)
    r.Delete("/discount-campaigns/{id}", admin.DeleteDiscountCampaign)
    r.Get("/discount-campaigns/{id}/stats", admin.GetCampaignStats)
})

Phase 3: Frontend — Customer Account Page

File: frontend/src/routes/account/+page.svelte (lines 826-843)

Current: Shows stamp count and "Congratulations! You've earned 10% off!"

Changes:

  • When stamps >= 10: "Your loyalty card is full! Your next completed appointment will receive 10% off."
  • When stamps < 10: Keep existing countdown
  • Add "Pending discount" indicator if user has a pending redemption: "You have a 10% discount waiting for your next completed appointment."
  • NO exact price amounts shown

Phase 4: Frontend — Admin Campaign Management

File: New frontend/src/routes/admin/discounts/+page.svelte

Admin UI for:

  • List campaigns with status badges (draft/active/completed/cancelled)
  • Create campaign form: name, description, discount %, scope, date range, max redemptions
  • Campaign stats: times redeemed, revenue impact
  • Quick actions: activate, cancel

Add link to admin dashboard/sidebar.

Phase 5: Email Notification (TODO Placeholder)

File: backend/handlers/bookings/bookings.go (in stamp award section)

// TODO: Email notification — requires SMTP provider (E5)
// When: loyalty_stamps reaches 10
// To: user's email
// Subject: "Your loyalty reward is ready! 🎉"
// Body: "Congratulations! You've collected 10 stamps. Your next completed appointment will receive 10% off automatically."
// 
// When email system is wired (E5), call:
// emailService.SendLoyaltyRewardEmail(user.Email, user.FirstName)

Migration SQL

-- Run as part of database migration

-- 1. Create loyalty_redemptions table
CREATE TABLE loyalty_redemptions (
    id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'),
    user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    stamps_redeemed INT NOT NULL DEFAULT 10,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
    redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    applied_at TIMESTAMPTZ,
    expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months')
);

CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id);
CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status);

-- 2. Create discount_campaigns table with milestone support
CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone');
CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary');
CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years');
CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only');
CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled');

CREATE TABLE discount_campaigns (
    id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'),
    name VARCHAR(100) NOT NULL,
    description TEXT,
    campaign_type campaign_type NOT NULL DEFAULT 'time_based',
    discount_percent NUMERIC(5,2) NOT NULL,
    scope discount_campaign_scope,
    start_date TIMESTAMPTZ,
    end_date TIMESTAMPTZ,
    milestone_type milestone_type,
    milestone_value INT,
    milestone_unit milestone_unit,
    status discount_campaign_status NOT NULL DEFAULT 'draft',
    max_redemptions INT,
    times_redeemed INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
    CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)),
    CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100),
    CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL))
);

CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL;
CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status);
CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type);

-- 3. Create booking_discounts table with milestone tracking
CREATE TABLE booking_discounts (
    id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'),
    booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
    user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    discount_source VARCHAR(30) NOT NULL,
    source_id CHAR(12),
    campaign_type campaign_type,
    milestone_type milestone_type,
    discount_percent NUMERIC(5,2) NOT NULL,
    original_total NUMERIC(10,2) NOT NULL,
    discount_amount NUMERIC(10,2) NOT NULL,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id);
CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id);
CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id);
CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id);

-- 4. Add discount_eligible to bookings
ALTER TABLE bookings ADD COLUMN discount_eligible BOOLEAN NOT NULL DEFAULT FALSE;

-- 5. Add trigger for discount_campaigns updated_at
CREATE TRIGGER trigger_update_discount_campaigns_timestamp
    BEFORE UPDATE ON discount_campaigns
    FOR EACH ROW
    EXECUTE FUNCTION update_business_settings_timestamp();

File Change Summary

File Change
init-scripts/init-script.sql Add 3 new tables + 5 new enums + 1 column alter
backend/handlers/bookings/bookings.go Modify ProgressBookingHandler (stamp threshold + discount apply for loyalty/time-based/milestone), modify CreateBookingHandler (discount_eligible check for time-based campaigns)
backend/handlers/bookings/manage.go Modify AdminCreateBookingForUserHandler (discount_eligible check)
backend/handlers/admin/discount_campaigns.go NEW: CRUD handlers for discount campaigns (supports both time-based and milestone types)
backend/main.go Wire discount campaign routes
frontend/src/routes/account/+page.svelte Update loyalty display text, add pending discount indicator
frontend/src/routes/admin/discounts/+page.svelte NEW: Admin campaign management UI (campaign type selector, milestone fields)
frontend/src/lib/types/ Add TypeScript types for loyalty_redemptions, discount_campaigns, booking_discounts, milestone enums

Testing Plan

  1. Unit: Test discount calculation (rounding edge cases)
  2. Integration:
    • Complete booking with pending redemption → verify discount applied, stamps deducted
    • Complete booking with active time-based campaign → verify discount applied, times_redeemed incremented
    • Cancel booking with discount_eligible=true → verify no discount applied, redemption stays pending
    • Multiple bookings pending → only first completed gets loyalty discount
    • Per-user milestone: Create 99 completed bookings for a user, set up "100th booking" milestone campaign, complete 100th → verify discount applied
    • Global milestone: Seed 99 completed bookings globally, set up "100th booking overall" milestone, complete next booking → verify discount applied
    • Anniversary milestone: Create user with first booking 6+ months ago, set up "6 months" anniversary campaign, complete next booking → verify discount applied. Complete another → verify NO second discount (dedup works)
  3. Edge cases:
    • User has 10 stamps but redemption expired → no discount
    • Campaign max_redemptions reached → no discount
    • Both loyalty + campaign eligible → loyalty takes priority (only one discount per booking)
    • Booking total is £0 (free service) → discount is £0, no payment created
    • Two milestone campaigns with same value → both apply? No, single discount per booking, pick highest percent

Risks & Mitigations

Risk Mitigation
Discount + VAT interaction Discount payment is separate from service payment. VAT calculated on net service amount. Discount payment has no VAT.
Admin overrides price after booking Discount calculated at completion time using actual booking_services override prices — always accurate
Campaign date timezone issues All dates stored as TIMESTAMPTZ, compared with NOW() — consistent
Double-discount (loyalty + campaign) Priority: loyalty first. If loyalty applied, campaign skipped. Single discount per booking.
Stamps deducted but booking fails All operations in single transaction — rollback on failure
Global booking count race condition SELECT COUNT(*) at completion time is accurate — the booking being completed hasn't been marked completed yet, so count is N-1. The current booking is the Nth.
Anniversary milestone double-apply booking_discounts table tracks per-user per-milestone applications. Query before applying to ensure not already claimed.
Per-user milestone double-apply Same dedup via booking_discounts — check if user already has a discount for this specific milestone campaign.