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
This commit is contained in:
2026-05-04 12:20:03 +01:00
parent 88ee265603
commit bec4100e4d
15 changed files with 1688 additions and 887 deletions
@@ -0,0 +1,705 @@
# 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.
```sql
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).
```sql
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:
```sql
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.
```sql
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
```sql
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:
```go
// 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:
```go
// 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:
```go
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)
```go
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)
```go
// 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
```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. |