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
@@ -1,506 +0,0 @@
# Plan: #45 Loyalty & Discount System v2
Supersedes: `.sisyphus/plans/45-loyalty-discount-system.md` (v1)
## Status
**Phase**: Planning — no implementation yet.
---
## 1. Stamp Earning — Clarification & Fixes
### Current Behaviour (correct, no change needed)
The stamp earning logic in `ProgressBookingHandler` (bookings.go lines 2035-2063) is **already correct**:
- **Every completed booking with `bookingTotal > 0` earns 1 stamp** — including the very first booking
- **Max 1 stamp per calendar day** — the `NOT EXISTS` subquery checks for other completed bookings with `updated_at >= CURRENT_DATE - INTERVAL '1 day'`
- **Free bookings (£0.00 total) earn no stamp** — the `if bookingTotal > 0` gate
- **At exactly 10 stamps** → auto-create `loyalty_redemptions` row with `status = 'pending'`
- **On next paid completion** → apply 10% discount, deduct 10 stamps (via `GREATEST(0, stamps - 10)`), then earn +1 for this completion → net stamps = 1
### The "zero-total" note explained
"Zero-total bookings earn no stamps" refers to bookings where all services are £0.00 (free services, complimentary treatments). It does NOT mean "first N bookings don't earn stamps." A customer's very first paid booking earns stamp #1.
### Flow summary
```
Booking 1 (£50) → stamp 1
Booking 2 (£50) → stamp 2 (if different day)
...
Booking 10 (£50) → stamp 10 → pending redemption created
Booking 11 (£50) → apply 10% discount (£5 off) → stamps reset to 0 → earn +1 → stamps = 1
Booking 12 (£50) → stamp 2
...
Booking 21 (£50) → stamp 10 → pending redemption created
Booking 22 (£50) → apply 10% discount → stamps = 1
...
```
### No code changes needed for stamp earning
The existing logic is correct. Tests `TestDiscount_Loyalty_FullCycle`, `TestDiscount_Loyalty_OneStampPerDay`, `TestDiscount_Loyalty_ZeroTotalNoStamp`, and `TestDiscount_Loyalty_CycleRepeats` all verify this.
---
## 2. Discount Stacking — Redesign
### Problem
The current system uses a strict **one-discount-per-booking priority cascade**:
```
Loyalty → blocks everything
Time-based campaign → blocks milestones
Per-user milestone → blocks global and anniversary
Global milestone → blocks anniversary
```
This means a customer with a full loyalty card AND an active "10% off" campaign AND a "100th booking" milestone only gets the loyalty discount. The campaign and milestone are wasted.
### Desired Behaviour
**All applicable discounts stack additively (not compound):**
- 10% loyalty + 10% time-based campaign = 20% off
- 10% loyalty + 10% per-user milestone + 5% anniversary = 25% off
- 10% loyalty + 15% time-based + 10% per-user + 5% anniversary + 20% global = 60% off
Each discount is calculated against the **original booking total** (not the post-discount total). Each creates its own `booking_discounts` row and its own `payments` row with `payment_method = 'discount'`.
### Stacking Rules
| Discount Source | Stacking | Selection |
|---|---|---|
| **Loyalty** (10%) | Always stacks | At most 1 per booking (from pending redemption) |
| **Time-based campaign** | Stacks with loyalty and milestones | Pick single best (highest %) if multiple active |
| **Per-user milestone** | Stacks with everything | At most 1 per user per campaign (dedup via `booking_discounts`) |
| **Global milestone** | Stacks with everything | Respects `max_redemptions` |
| **Anniversary milestone** | Stacks with everything | At most 1 per user per campaign (dedup via `booking_discounts`) |
**Multiple milestones CAN stack on the same booking.** If a user hits their 100th booking (per-user milestone) on the same day as their 1-year anniversary, both apply.
**Multiple time-based campaigns do NOT stack.** Pick the single highest % active campaign.
### Implementation Changes
**File**: `backend/handlers/bookings/bookings.go``ProgressBookingHandler` (lines 2004-2205)
#### Step 1: Loyalty Redemption (lines 2004-2032) — NO CHANGE
Keep as-is. Applies 10% if pending redemption exists. Creates `booking_discounts` + `payments` rows.
#### Step 2: Time-Based Campaign (lines 2065-2096) — REMOVE GUARD
**Remove** the `hasLoyaltyDiscount` check (lines 2066-2069). The time-based campaign block should execute regardless of whether loyalty was applied.
```go
// BEFORE (line 2066-2069):
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)
if !hasLoyaltyDiscount { ... }
// AFTER:
// Remove the hasLoyaltyDiscount check entirely. Always check for time-based campaigns.
```
#### Step 3: Milestone Campaigns (lines 2098-2205) — REMOVE GUARDS
**Remove** the `hasDiscount` check (lines 2099-2102). Milestone checks should execute regardless of prior discounts.
**Remove** the `milestoneCampaignID == ""` guards (lines 2130, 2158). Global and anniversary milestones should be checked independently of per-user milestones.
**Scoping note:** The variables `userBookingCount` and `milestoneCampaignID` are currently declared inside the `if !hasDiscount {}` block. When that outer block is removed, these declarations simply become top-level within the `if bookingTotal > 0 {}` block. No restructuring needed. However, `milestoneCampaignID` will no longer be used as a guard — it becomes dead code after the per-user check and can be removed or left as-is (it's assigned but never read again).
```go
// BEFORE (line 2099-2102):
var hasDiscount bool
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount)
if !hasDiscount { ... }
// AFTER:
// Remove the hasDiscount check entirely. Always check all milestone types.
```
```go
// BEFORE (line 2130):
if milestoneCampaignID == "" { /* check global */ }
// AFTER:
// Remove this guard. Always check global milestone independently.
```
```go
// BEFORE (line 2158):
if milestoneCampaignID == "" { /* check anniversary */ }
// AFTER:
// Remove this guard. Always check anniversary independently.
```
#### Discount Amount Calculation
Each discount calculates against the **original booking total**:
```go
discountAmount := roundTo2(bookingTotal * discountPercent / 100)
```
This is already how each step works — no change needed to the calculation itself. The key change is that multiple `booking_discounts` rows and multiple `payments` rows are created for the same booking.
#### Example: £100 booking with loyalty + time-based + anniversary
| Step | Source | % | Amount | `booking_discounts` row | `payments` row |
|---|---|---|---|---|---|
| 1 | loyalty | 10% | £10.00 | `discount_source='loyalty'` | `method='discount', amount=10.00` |
| 2 | time_based campaign | 5% | £5.00 | `discount_source='campaign', campaign_type='time_based'` | `method='discount', amount=5.00` |
| 3 | anniversary milestone | 10% | £10.00 | `discount_source='campaign', milestone_type='anniversary'` | `method='discount', amount=10.00` |
| **Total** | | **25%** | **£25.00** | 3 rows | 3 rows |
Customer pays: £100 - £25 = £75 (via remaining payment methods).
### Test Helper Issue
The existing `getDiscountForBooking` helper does a single-row scan:
```go
SELECT discount_source, discount_amount FROM booking_discounts WHERE booking_id = $1
```
This will fail (or return arbitrary results) once stacking creates multiple rows per booking. The rewrite of `TestDiscount_LoyaltyPriority` must not use this helper, and the new stacking tests (1322) will need different query patterns.
**Recommended new helpers:**
```go
// Count discount rows for a booking
func getDiscountRowCount(t *testing.T, bookingID string) int
// Get all discounts for a booking as a slice
func getAllDiscountsForBooking(t *testing.T, bookingID string) []struct{ Source string; Amount float64 }
// Sum all discount amounts for a booking
func getTotalDiscountAmount(t *testing.T, bookingID string) float64
```
The existing `getDiscountForBooking` single-row helper should be retired or renamed to avoid confusion.
### Test Changes
The existing `TestDiscount_LoyaltyPriority` test (line 540) asserts that campaign discounts are blocked when loyalty applies. This test must be **rewritten** to assert the opposite: that both loyalty AND campaign discounts are applied.
---
## 3. Expanded Test Suite
### Current Tests (12)
| # | Test | What It Verifies |
|---|---|---|
| 1 | `TestDiscount_Loyalty_FullCycle` | 10 stamps → redemption → 11th booking discount → stamps reset to 1 |
| 2 | `TestDiscount_Loyalty_ExistingRedemptionApplies` | Pre-existing pending redemption applies on completion |
| 3 | `TestDiscount_Loyalty_OneStampPerDay` | Same-day completions only give 1 stamp |
| 4 | `TestDiscount_Loyalty_ZeroTotalNoStamp` | £0 bookings don't earn stamps |
| 5 | `TestDiscount_Loyalty_CycleRepeats` | After redemption, cycle restarts |
| 6 | `TestDiscount_TimeBasedCampaign` | Time-based campaign applies correctly |
| 7 | `TestDiscount_PerUserMilestone` | Per-user booking count milestone |
| 8 | `TestDiscount_GlobalMilestone` | Global booking count milestone |
| 9 | `TestDiscount_AnniversaryMilestone` | Anniversary milestone with dedup |
| 10 | `TestDiscount_LoyaltyPriority` | Loyalty blocks campaigns (**MUST CHANGE**) |
| 11 | `TestDiscount_NoDiscountOnZeroTotal` | Zero-total gets no discounts |
| 12 | `TestDiscount_CampaignMaxRedemptions` | Campaign respects max_redemptions |
### New Tests Required
#### Stacking Tests
| # | Test | What It Verifies |
|---|---|---|
| 13 | `TestDiscount_Stacking_LoyaltyPlusTimeBased` | Loyalty 10% + time-based 5% = 15% off, two `booking_discounts` rows, two `payments` rows |
| 14 | `TestDiscount_Stacking_LoyaltyPlusMilestone` | Loyalty 10% + per-user milestone 15% = 25% off |
| 15 | `TestDiscount_Stacking_LoyaltyPlusAnniversary` | Loyalty 10% + anniversary 10% = 20% off |
| 16 | `TestDiscount_Stacking_AllThreeTypes` | Loyalty + time-based + anniversary = sum of all three |
| 17 | `TestDiscount_Stacking_MultipleMilestones` | Per-user + global + anniversary all apply on same booking |
| 18 | `TestDiscount_Stacking_LoyaltyPlusGlobalMilestone` | Loyalty + global milestone stack |
| 19 | `TestDiscount_Stacking_TimeBasedPlusMilestone` | Time-based campaign + per-user milestone stack |
| 20 | `TestDiscount_Stacking_DiscountAmountsSumCorrectly` | Verify total discount = sum of individual amounts, each calculated against original total |
| 21 | `TestDiscount_Stacking_MultiplePaymentRecords` | Each discount creates its own `payments` row with `method='discount'` |
| 22 | `TestDiscount_Stacking_MultipleBookingDiscountRows` | Each discount creates its own `booking_discounts` row with correct source/type |
#### Edge Case Tests
| # | Test | What It Verifies |
|---|---|---|
| 23 | `TestDiscount_ExpiredRedemptionDoesNotApply` | Redemption past `expires_at` is not applied |
| 24 | `TestDiscount_MultiplePendingRedemptions_UsesOldest` | If somehow multiple pending redemptions exist, oldest is used first |
| 25 | `TestDiscount_StampCountAboveTen` | If stamps somehow exceed 10 (e.g., admin manual set), redemption still created at check, `GREATEST(0, stamps-10)` handles reset |
| 26 | `TestDiscount_MixedFreeAndPaidServices` | Booking with one £0 service and one £50 service → total £50 → earns stamp |
| 27 | `TestDiscount_CampaignBoundaryStart` | Campaign start_date exactly at completion time → applies |
| 28 | `TestDiscount_CampaignBoundaryEnd` | Campaign end_date exactly at completion time → applies |
| 29 | `TestDiscount_CampaignExpiredDoesNotApply` | Campaign past end_date → no discount |
| 30 | `TestDiscount_CampaignDraftDoesNotApply` | Campaign with status='draft' → no discount |
| 31 | `TestDiscount_CampaignCancelledDoesNotApply` | Campaign with status='cancelled' → no discount |
| 32 | `TestDiscount_PriceOverrideRespected` | Admin overrides service price → discount calculated on overridden price |
| 33 | `TestDiscount_AnniversaryDedupWithStacking` | Anniversary dedup still works even when other discounts also apply |
| 34 | `TestDiscount_PerUserMilestoneDedupWithStacking` | Per-user milestone dedup still works with stacking |
| 35 | `TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking` | Global max_redemptions still respected with stacking |
| 36 | `TestDiscount_BestTimeBasedCampaignSelected` | Multiple active time-based campaigns → highest % is selected |
| 37 | `TestDiscount_FirstBookingEarnsStamp` | Brand new user's first completed paid booking earns stamp #1 |
| 38 | `TestDiscount_TenStampsCreatesRedemption` | Completing a booking that brings stamps to exactly 10 creates pending redemption |
| 39 | `TestDiscount_RedemptionAppliedBeforeStampIncrement` | On discounted booking: redemption applied first (stamps → 0), then +1 stamp earned |
#### Campaign Status Tests
| # | Test | What It Verifies |
|---|---|---|
| 40 | `TestCampaign_CreateAsDraft` | Creating campaign without status → defaults to 'draft' |
| 41 | `TestCampaign_ActivateDraft` | Update draft → active works |
| 42 | `TestCampaign_CompleteActive` | Update active → completed works |
| 43 | `TestCampaign_CancelActive` | Update active → cancelled works |
| 44 | `TestCampaign_RevertToDraft` | Update active → draft works (for re-editing before re-activation) |
| 45 | `TestCampaign_DraftDoesNotApplyDiscounts` | Draft campaign does not trigger discounts at completion |
**Total: 45 tests** (12 existing + 33 new, with test #10 rewritten)
---
## 4. `discount_eligible` — Scrap It
### Problem Analysis
The `discount_eligible BOOLEAN` column on `bookings` is **completely dead code**:
- Defined in schema (init-script.sql line 241)
- Listed in Technical Manual
- **Never read, written, or referenced** by any Go code, TypeScript, or handler
- Not in the `Booking` struct
- Not in any INSERT or UPDATE query
### Why Pre-Assignment Is Fundamentally Flawed
The original v1 plan proposed setting `discount_eligible = TRUE` at booking creation time if the user has a pending redemption or active campaign. This is **architecturally wrong** because:
1. **Out-of-order completion**: User books A (Tuesday) then B (Monday). B completes first and gets the loyalty discount. A was marked "eligible" but the redemption is now consumed. Bad state.
2. **Campaign changes**: Admin creates a campaign after a booking is made. Existing bookings aren't retroactively marked eligible. Or admin cancels a campaign — bookings still marked eligible won't get the discount.
3. **Price overrides**: Admin overrides service prices after booking creation. The eligibility was calculated on original prices.
4. **Multiple pending bookings**: User has 3 future bookings. All marked "eligible." Only the first to complete actually gets the discount. The other 2 show misleading eligibility.
5. **Milestone unpredictability**: Per-user and global milestones depend on completion counts that change as other bookings complete. You can't predict at creation time whether a specific booking will hit the milestone.
### Decision: Drop the Column
```sql
ALTER TABLE bookings DROP COLUMN discount_eligible;
```
### What Replaces It
**Nothing on the booking row.** Discount eligibility is computed entirely at completion time in `ProgressBookingHandler`. This is the correct approach because:
- All data is final at completion (prices, services, user history)
- No race conditions between multiple pending bookings
- No stale state from campaign changes
- No misleading UI indicators
**For the customer UI** ("you have a discount waiting"), query the source tables directly:
- `SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()` → "You have a 10% loyalty discount waiting for your next completed appointment"
- `SELECT name FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'time_based' AND start_date <= NOW() AND end_date >= NOW()` → "Summer Sale: 5% off all services this week!"
These queries are already fast (indexed) and always reflect current truth.
### File Changes
| File | Change |
|---|---|
| `init-scripts/init-script.sql` | Remove `discount_eligible BOOLEAN NOT NULL DEFAULT FALSE` from bookings table (line 241) |
| `obsidian/Crussell/Technical Manual.md` | Remove `discount_eligible` from bookings column list |
---
## 5. Campaign Status Lifecycle — Fix
### Current State (broken)
| Operation | DB enum allows | Code allows | Issue |
|---|---|---|---|
| **Create** | `draft` (default), `active`, `completed`, `cancelled` | Hardcoded `"active"` only | Cannot create as `draft` |
| **Update** | `draft`, `active`, `completed`, `cancelled` | `active`, `cancelled`, `completed` only | Cannot update to `draft` |
| **GET filter** | `draft`, `active`, `completed`, `cancelled` | `active`, `paused`, `cancelled`, `expired` | Phantom statuses; missing `draft`/`completed` |
### Desired Lifecycle
```
draft → active → completed
↑ ↓
└── cancelled
```
- **Create**: Always creates as `draft`. Admin reviews and manually activates.
- **Activate**: `draft``active`. Campaign starts applying discounts.
- **Complete**: `active``completed`. Campaign stops applying (past its useful life).
- **Cancel**: Any → `cancelled`. Campaign permanently disabled.
- **Revert**: `active``draft`. Admin wants to edit and re-activate.
### Implementation Changes
**File**: `backend/handlers/admin/discount_campaigns.go`
#### 5.1 CreateDiscountCampaign (line 310)
Change hardcoded `"active"` to `"draft"`:
```go
// BEFORE (line 310):
"active",
// AFTER:
"draft",
```
#### 5.2 UpdateDiscountCampaign (lines 480-488)
Add `"draft"` to the whitelist:
```go
// BEFORE:
if *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" {
http.Error(w, "Status must be 'active', 'cancelled', or 'completed'", http.StatusBadRequest)
return
}
// AFTER:
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
}
```
#### 5.3 GetDiscountCampaigns filter (line 81)
Fix phantom statuses:
```go
// BEFORE:
validStatuses := map[string]bool{"active": true, "paused": true, "cancelled": true, "expired": true}
// AFTER:
validStatuses := map[string]bool{"draft": true, "active": true, "completed": true, "cancelled": true}
```
#### 5.4 Frontend Campaign Management — ALREADY DONE
The `DiscountsManagement.svelte` component already implements the full draft lifecycle:
- `Campaign` type includes `'draft'` in the status union (line 26)
- Status badge display handles all four statuses including `draft` (lines 290293)
- "Activate" button exists for `c.status === 'draft'` (lines 368/373, 423/428)
- "Complete" button exists for `c.status === 'active'` (lines 378/383, 433/438)
- "Cancel" button exists for non-cancelled campaigns (lines 388/393, 443/448)
- Conditional at `c.status === 'active' || c.status === 'completed'` (lines 398, 453) — likely "Revert to Draft"
**No frontend changes needed.** The frontend is already aligned with the desired lifecycle. Only the backend changes in Phase 2 are required.
### File Changes
| File | Change |
|---|---|
| `backend/handlers/admin/discount_campaigns.go` | Fix create default, update whitelist, GET filter |
| ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Update status buttons for draft lifecycle~~**Already done** |
---
## 6. Square Payments — Reality Check
### Current State
Square is **stubbed locally** via build tags (`//go:build !prod` in `internal/square/dev.go`). The dev mock simulates Square Terminal API responses without hitting real Square servers.
**No real-world Square integration has been tested.** The prod stub (`internal/square/prod.go`) is a placeholder.
### Impact on Loyalty/Discount System
The loyalty/discount system is **independent of Square**. Discounts create `payments` rows with `payment_method = 'discount'` — this is a bookkeeping entry, not a Square transaction. The actual payment collection (cash, card via Square Terminal, etc.) happens separately.
**No changes needed** to the loyalty/discount system for Square. When real Square is wired up, the discount payments will coexist with Square payments on the same booking.
### Future Consideration (not in scope)
When Square is live:
- Online deposit payments will create `payments` rows with `payment_method = 'square_terminal'` or `'square_web'`
- The discount `payments` rows reduce the remaining balance
- The Square payment amount should be `bookingTotal - sum(all discount amounts) - sum(other payments)`
- This is already how the system works — `amount_due = total - sum(completed payments)`
---
## 7. Implementation Plan
### Phase 1: Drop `discount_eligible` (trivial)
| Step | File | Change |
|---|---|---|
| 1.1 | `init-scripts/init-script.sql` | Remove `discount_eligible` column from bookings table |
| 1.2 | `obsidian/Crussell/Technical Manual.md` | Remove from column list |
### Phase 2: Fix Campaign Status Lifecycle
| Step | File | Change |
|---|---|---|
| 2.1 | `backend/handlers/admin/discount_campaigns.go` | Change create default to `"draft"` (line 310) |
| 2.2 | `backend/handlers/admin/discount_campaigns.go` | Add `"draft"` to update whitelist (line 481) |
| 2.3 | `backend/handlers/admin/discount_campaigns.go` | Fix GET filter to match DB enum (line 81) |
| ~~2.4~~ | ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Update status buttons for draft lifecycle~~**Already done** |
### Phase 3: Implement Discount Stacking
| Step | File | Change |
|---|---|---|
| 3.1 | `backend/handlers/bookings/bookings.go` | Remove `hasLoyaltyDiscount` guard (lines 2066-2069) |
| 3.2 | `backend/handlers/bookings/bookings.go` | Remove `hasDiscount` guard (lines 2099-2102) |
| 3.3 | `backend/handlers/bookings/bookings.go` | Remove `milestoneCampaignID == ""` guards (lines 2130, 2158) |
| 3.4 | `backend/handlers/bookings/bookings.go` | Rewrite `TestDiscount_LoyaltyPriority` to assert stacking |
### Phase 4: Expanded Test Suite
| Step | File | Change |
|---|---|---|
| 4.1 | `backend/handlers/bookings/discount_test.go` | Add stacking tests (#13-22) |
| 4.2 | `backend/handlers/bookings/discount_test.go` | Add edge case tests (#23-39) |
| 4.3 | `backend/handlers/bookings/discount_test.go` | Add campaign status tests (#40-45) |
| 4.4 | `backend/handlers/bookings/discount_test.go` | Rewrite `TestDiscount_LoyaltyPriority` |
### Phase 5: Frontend Updates
| Step | File | Change |
|---|---|---|
| ~~5.1~~ | ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Draft lifecycle buttons~~**Already done** |
| 5.2 | `frontend/src/routes/account/+page.svelte` | No changes needed (already shows stamp card correctly) |
---
## 8. File Change Summary
| File | Change Type | Phase |
|---|---|---|
| `init-scripts/init-script.sql` | Remove `discount_eligible` column | 1 |
| `obsidian/Crussell/Technical Manual.md` | Remove `discount_eligible` reference | 1 |
| `backend/handlers/admin/discount_campaigns.go` | Fix status lifecycle (3 changes) | 2 |
| ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Draft lifecycle UI~~**Already done** | ~~2, 5~~ |
| `backend/handlers/bookings/bookings.go` | Remove mutual-exclusivity guards (4 changes) | 3 |
| `backend/handlers/bookings/discount_test.go` | Rewrite 1 test, add 33 new tests, add new helpers | 4 |
---
## 9. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Stacking produces unexpectedly large discounts | Each discount is a separate `booking_discounts` row — easy to audit. Consider adding a total discount cap (e.g., 50%) as a future safeguard. |
| Multiple `payments` rows with `method='discount'` confuse payment reconciliation | Each has a distinct `booking_discounts.source_id` linking to the specific redemption/campaign. Clear audit trail. |
| Removing mutual-exclusivity guards changes behaviour for existing bookings | Only affects future completions. Existing `booking_discounts` rows are unchanged. |
| Campaign status change from `"active"` default to `"draft"` breaks existing campaigns | No existing campaigns in production (system is local-only). For dev DB, run migration to update any existing `"active"` campaigns that should be `"draft"`. |
| `discount_eligible` column removal breaks something | Verified: zero references in any Go, TypeScript, or SQL code outside the schema definition itself. Safe to drop. |
| Test suite expansion reveals bugs in stacking logic | That's the point — tests should catch issues before production. |
@@ -1,705 +0,0 @@
# 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. |
+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)
+84 -14
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,10 +2139,6 @@ 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)
if !hasLoyaltyDiscount {
var campaignID string
var campaignPercent float64
if err := db.DB.QueryRow(r.Context(), `
@@ -2093,13 +2165,8 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
`, 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)
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)
@@ -2127,7 +2194,6 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
`, milestoneCampaignID)
}
if milestoneCampaignID == "" {
var globalCount int
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var globalCampaignID string
@@ -2153,9 +2219,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
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() {
@@ -2202,8 +2266,6 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
}
}
}
var paymentCount int
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount)
@@ -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)
}
}
@@ -720,7 +720,7 @@
<Modal.Root bind:open>
<Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header>
<div class="flex items-center justify-between">
@@ -7,7 +7,7 @@
import { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import type { Booking } from '$lib/types/booking';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
interface Props {
open: boolean;
@@ -244,11 +244,30 @@
return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
}
function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string {
if (payment.payment_method === 'online_square') return 'Online Card';
if (payment.payment_method === 'in_person_card') return 'Card Machine';
if (payment.payment_method === 'cash') return 'Cash';
if (payment.payment_method === 'giftcard') return 'Gift Card';
if (payment.payment_method === 'discount') {
const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length;
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
if (discountList[discountPaymentsBefore]) {
const d = discountList[discountPaymentsBefore];
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
if (d.campaign_name) return `${d.campaign_name}`;
return 'Promo Campaign Discount';
}
return 'Discount';
}
return formatPaymentMethod(payment.payment_method);
}
</script>
<Modal.Root bind:open>
<Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header>
<div class="flex items-center justify-between">
@@ -417,26 +436,48 @@
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600"
>{selectedBooking.amount_paid > selectedBooking.total_amount
? 'Pre-tip Subtotal'
: 'Total Amount'}</span
>
<span class="text-sm text-gray-600">Subtotal (Services)</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid</span>
<span class="font-semibold text-green-700"
>£{selectedBooking.amount_paid.toFixed(2)}</span
>
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
{#each selectedBooking.discounts as d}
<div class="flex items-center justify-between text-xs text-fuchsia-800">
<span class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.campaign_name}
{d.campaign_name} ({d.discount_percent}% Off)
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
<span class="font-medium">-£{d.discount_amount.toFixed(2)}</span>
</div>
{#if selectedBooking.amount_due > 0}
{/each}
</div>
<div class="flex items-center justify-between font-medium text-gray-900">
<span class="text-sm">Net Total</span>
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
<span class="font-semibold text-green-700">
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
</span>
</div>
{#if Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)) > 0.01}
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
</span>
<span class="text-lg font-bold text-red-600">
£{selectedBooking.amount_due.toFixed(2)}
£{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)}
</span>
</div>
{/if}
@@ -454,9 +495,9 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium"
>{formatPaymentMethod(payment.payment_method)}</span
>
<span class="font-medium text-gray-900"
>{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}</span
>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{payment.status === 'completed'
@@ -469,11 +510,15 @@
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
{#if payment.payment_method === 'discount'}
Applied automatically on completion
{:else}
{payment.payment_type.charAt(0).toUpperCase() +
payment.payment_type.slice(1)} payment
{#if payment.card_last4}
, Card ending in {payment.card_last4}
{/if}
{/if}
</div>
{#if payment.is_vat_applicable}
<div class="mt-2 text-xs text-gray-600">
@@ -492,7 +537,7 @@
</div>
</div>
<div class="text-right font-semibold">
£{payment.amount.toFixed(2)}
{payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
</div>
</div>
</div>
@@ -585,7 +630,7 @@
{/if}
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
<Modal.Content class="max-w-[calc(100%-2rem)]">
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
<Modal.Header>
<Modal.Title>Cancel Booking</Modal.Title>
<Modal.Description>
@@ -623,7 +668,7 @@
}
}}
>
<Modal.Content class="max-w-[calc(100%-2rem)]">
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
@@ -371,7 +371,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Content class="!z-[70] max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Approve Booking</Modal.Title>
<Modal.Description>
@@ -6,8 +6,7 @@
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
import type { Booking, BookingService } from '$lib/types/booking';
import type { Payment } from '$lib/types';
import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking';
interface Props {
open: boolean;
@@ -118,7 +117,8 @@
total_amount: data.total_amount || 0,
amount_paid: data.amount_paid || 0,
amount_due: data.amount_due || 0,
duration_minutes: data.duration_minutes || 0
duration_minutes: data.duration_minutes || 0,
discounts: data.discounts || []
};
} else {
const text = await response.text();
@@ -130,6 +130,25 @@
}
}
function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string {
if (payment.payment_method === 'online_square') return 'Online Card';
if (payment.payment_method === 'in_person_card') return 'Card Machine';
if (payment.payment_method === 'cash') return 'Cash';
if (payment.payment_method === 'giftcard') return 'Gift Card';
if (payment.payment_method === 'discount') {
const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length;
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
if (discountList[discountPaymentsBefore]) {
const d = discountList[discountPaymentsBefore];
if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)';
if (d.campaign_name) return `${d.campaign_name}`;
return 'Promo Campaign Discount';
}
return 'Discount';
}
return (payment.payment_method as string).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
$effect(() => {
if (open && bookingId) {
fetchBookingDetails();
@@ -139,7 +158,7 @@
<Modal.Root bind:open>
<Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
class="!z-[60] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header>
<div class="flex items-center justify-between">
@@ -418,23 +437,49 @@
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span>
<span class="text-sm text-gray-600">Subtotal (Services)</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid</span>
<span class="font-semibold text-green-700"
>£{selectedBooking.amount_paid.toFixed(2)}</span
>
{#if selectedBooking.discounts && selectedBooking.discounts.length > 0}
<div class="border-y border-fuchsia-100 bg-fuchsia-50/20 py-2 my-2 space-y-1 rounded-md px-2">
{#each selectedBooking.discounts as d}
<div class="flex items-center justify-between text-xs text-fuchsia-800">
<span class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.campaign_name}
{d.campaign_name} ({d.discount_percent}% Off)
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
<span class="font-medium">{d.discount_amount.toFixed(2)}</span>
</div>
{/each}
</div>
<div class="flex items-center justify-between font-medium text-gray-900">
<span class="text-sm">Net Total</span>
<span>£{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)}</span>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
<span class="font-semibold text-green-700">
£{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}
</span>
</div>
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">Amount Due</span>
<span class="font-medium text-gray-900">Balance Due</span>
<span
class="text-lg font-bold {selectedBooking.amount_due > 0
class="text-lg font-bold {Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)) > 0.01
? 'text-red-600'
: 'text-green-600'}"
>
£{selectedBooking.amount_due.toFixed(2)}
£{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)}
</span>
</div>
</div>
@@ -452,14 +497,8 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">
{#if payment.payment_method === 'online_square'}Online
{:else if payment.payment_method === 'in_person_card'}Card Machine
{:else if payment.payment_method === 'cash'}Cash
{:else if payment.payment_method === 'giftcard'}Gift Card
{:else if payment.payment_method === 'discount'}Discount
{:else}{(payment.payment_method as string).replace('_', ' ')}
{/if}
<span class="font-medium text-gray-900">
{getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)}
</span>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
@@ -475,11 +514,15 @@
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
{#if payment.payment_method === 'discount'}
Applied automatically on completion
{:else}
{payment.payment_type.charAt(0).toUpperCase() +
payment.payment_type.slice(1)} payment
{#if payment.card_last4}
, Card ending in {payment.card_last4}
{/if}
{/if}
</div>
{#if payment.vendor_code || payment.invoice_number}
<div class="mt-1 text-xs text-gray-500">
@@ -505,7 +548,7 @@
</div>
</div>
<div class="text-right font-semibold">
£{payment.amount.toFixed(2)}
{payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
</div>
</div>
</div>
@@ -96,7 +96,7 @@
if (form.discount_percent <= 0 || form.discount_percent > 100) return false;
if (form.campaign_type === 'time_based') {
if (!form.start_date || !form.end_date) return false;
if (new SvelteDate(form.end_date) <= new SvelteDate(form.start_date)) return false;
if (new SvelteDate(form.end_date) < new SvelteDate(form.start_date)) return false;
}
if (form.campaign_type === 'milestone') {
if (form.milestone_value <= 0) return false;
@@ -146,8 +146,8 @@
campaign_type: c.campaign_type,
discount_percent: c.discount_percent,
scope: c.scope || 'all_bookings',
start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 16) : '',
end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 16) : '',
start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 10) : '',
end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 10) : '',
milestone_type: c.milestone_type || 'per_user_booking_count',
milestone_value: c.milestone_value || 0,
milestone_unit: c.milestone_unit || 'bookings',
@@ -168,7 +168,7 @@
if (
form.start_date &&
form.end_date &&
new Date(form.end_date) <= new Date(form.start_date)
new Date(form.end_date) < new Date(form.start_date)
) {
errors.end_date = 'Must be after start';
}
@@ -188,8 +188,8 @@
};
if (form.campaign_type === 'time_based') {
payload.scope = form.scope;
payload.start_date = new Date(form.start_date).toISOString();
payload.end_date = new Date(form.end_date).toISOString();
payload.start_date = form.start_date + 'T00:00:00.000Z';
payload.end_date = form.end_date + 'T23:59:59.999Z';
} else {
payload.milestone_type = form.milestone_type;
payload.milestone_value = form.milestone_value;
@@ -562,12 +562,12 @@
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label.Root for="dc-start">Start Date *</Label.Root>
<Input id="dc-start" type="datetime-local" bind:value={form.start_date} />
<Input id="dc-start" type="date" bind:value={form.start_date} />
{#if errors.start_date}<p class="text-xs text-red-500">{errors.start_date}</p>{/if}
</div>
<div class="space-y-2">
<Label.Root for="dc-end">End Date *</Label.Root>
<Input id="dc-end" type="datetime-local" bind:value={form.end_date} />
<Input id="dc-end" type="date" bind:value={form.end_date} />
{#if errors.end_date}<p class="text-xs text-red-500">{errors.end_date}</p>{/if}
</div>
</div>
@@ -98,7 +98,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="sm:max-w-[425px]">
<Modal.Content class="!z-[60] sm:max-w-[425px]">
<Modal.Header>
<Modal.Title>Record Patch Test</Modal.Title>
</Modal.Header>
@@ -408,7 +408,7 @@
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
<Modal.Content class="!z-[70] max-h-[90vh] max-w-4xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
<Modal.Description>
@@ -72,6 +72,7 @@
type CustomerRelationship = {
totalSpend: number;
totalSaved: number;
totalTips: number;
totalVisits: number;
customerFor: string;
@@ -485,13 +486,19 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Relationship
</h3>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid gap-3 md:grid-cols-5">
<div>
<div class="text-xs text-gray-500">Total Spend</div>
<div class="text-2xl font-bold text-emerald-600">
£{customerRelationship.totalSpend.toFixed(2)}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Total Saved</div>
<div class="text-2xl font-bold text-fuchsia-600">
£{customerRelationship.totalSaved.toFixed(2)}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Total Tips</div>
<div class="text-2xl font-bold text-amber-600">
@@ -4,7 +4,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking, BookingService } from '$lib/types/booking';
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
@@ -91,15 +91,6 @@
let pollingInterval: ReturnType<typeof setInterval> | null = null;
let tipPercentages = $derived.by(() => {
if (subtotal <= 0) return [];
return [
{ pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
];
});
function selectTipPercent(percent: number) {
selectedTipPercent = percent;
customTipAmount = '';
@@ -135,16 +126,27 @@
}
let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0));
let netTotal = $derived(Math.max(0, subtotal - discountSum));
let tipPercentages = $derived.by(() => {
if (netTotal <= 0) return [];
return [
{ pct: 10, amount: Math.round(netTotal * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(netTotal * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(netTotal * 0.2 * 100) / 100 }
];
});
let tipMultiplier = $derived(
selectedTipPercent !== null
? 1 + selectedTipPercent / 100
: customTipAmount && parseFloat(customTipAmount) > 0
? 1 + parseFloat(customTipAmount) / subtotal
? 1 + parseFloat(customTipAmount) / netTotal
: 1
);
let totalWithTip = $derived(tipEnabled ? subtotal * tipMultiplier : subtotal);
let totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
let tipDisplay = $derived(
selectedTipPercent !== null
? `${selectedTipPercent}%`
@@ -153,7 +155,7 @@
: ''
);
let totalDue = $derived(tipEnabled ? totalWithTip : subtotal);
let totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-GB', {
@@ -469,9 +471,50 @@
</div>
</div>
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
{#if booking.discounts && booking.discounts.length > 0}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50/40 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="text-sm font-semibold text-fuchsia-800 flex items-center gap-1.5">
<svg class="h-4 w-4 text-fuchsia-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
<span class="rounded-full bg-fuchsia-100 px-2 py-0.5 text-xs font-semibold text-fuchsia-700">
{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
</span>
</div>
<div class="space-y-2 text-sm">
{#each booking.discounts as d}
<div class="flex items-center justify-between text-gray-600">
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-500"></span>
<span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.campaign_name}
{d.campaign_name}
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
</div>
<span class="font-medium text-fuchsia-600">-{formatCurrency(d.discount_amount)}</span>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex justify-between items-center rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(subtotal)}</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through">{formatCurrency(subtotal)}</span>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
</div>
{#if tipEnabled}
@@ -382,7 +382,7 @@
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="max-h-[90vh] max-w-md overflow-y-auto">
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-md overflow-y-auto">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
{#if booking.id}
@@ -20,7 +20,7 @@
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<Dialog.Overlay class={typeof className === 'string' && /(!?z-\[[^\]]+\]|!?z-\d+)/.test(className) ? className.match(/(!?z-\[[^\]]+\]|!?z-\d+)/)?.[0] : ''} />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
+2
View File
@@ -118,6 +118,7 @@ export interface Booking {
user?: BookingUser;
services?: BookingService[];
payments?: Payment[];
discounts?: BookingDiscount[];
total_amount: number;
amount_paid: number;
amount_due: number;
@@ -167,6 +168,7 @@ export interface BookingDiscount {
user_id: string;
discount_source: 'loyalty' | 'campaign';
source_id?: string;
campaign_name?: string;
campaign_type?: CampaignType;
milestone_type?: MilestoneType;
discount_percent: number;
+1 -2
View File
@@ -237,8 +237,7 @@ CREATE TABLE bookings (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by CHAR(12),
idempotency_key VARCHAR(64) UNIQUE,
discount_eligible BOOLEAN NOT NULL DEFAULT FALSE
idempotency_key VARCHAR(64) UNIQUE
);
CREATE INDEX idx_bookings_userid ON bookings(user_id);
+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;
+25 -12
View File
@@ -318,27 +318,32 @@ The **Schedule** page (`/admin/schedule`) gives you a bird's-eye view of the ent
### Discount Campaigns
This is where you set up promotional discounts.
This is where you set up promotional discounts. Campaigns are always created as **Draft** — review them and activate when ready.
**Time-Based Campaigns:**
- Set a start date and end date
- Choose a discount percentage (for example, 5% off)
- Choose who it applies to: all bookings, first-time bookings only, or new customers only
- The discount automatically applies to eligible bookings during the campaign period
- If multiple time-based campaigns are active, only the **highest percentage** applies (they don't stack with each other)
**Milestone Campaigns:**
- These trigger when a customer reaches a certain milestone — for example, their 5th booking, or the anniversary of their first booking
- Set the milestone type and value
- The discount applies automatically when the milestone is reached
- **Per-user booking count** triggers when a customer reaches a specific number of completed bookings (e.g., their 10th visit). One-shot per customer per campaign.
- **Global booking count** — triggers when the salon's total completed bookings hit a number (e.g., 1000th booking overall). Can have a max redemptions cap.
- **Anniversary** — triggers based on time since the customer's first completed visit (e.g., 1-year anniversary). One-shot per customer per campaign.
**Discount Stacking:**
All applicable discounts **add together**. If a customer has a full loyalty card (10% off) AND there's an active "5% off this week" campaign AND they hit a milestone, they get all three discounts on the same booking. Each discount is calculated against the original booking total (not the post-discount total).
**Viewing Campaign Stats:**
- For each campaign, you can see how many times it's been used and how much discount has been given out
**Campaign Statuses:**
- **Draft** — set up but not yet active
- **Active** — currently running
- **Completed** — the campaign period has ended
- **Cancelled**you've stopped the campaign early
- **Draft** — set up but not yet active. Won't apply any discounts.
- **Active** — currently running. Discounts apply to qualifying completed bookings.
- **Completed** — the campaign period has ended. No longer applies.
- **Cancelled**permanently disabled. Cannot be reactivated.
You can activate a draft, complete an active campaign, cancel any campaign, or revert an active campaign back to draft for editing.
---
@@ -403,7 +408,8 @@ A complete list of every appointment this customer has ever had with the salon
### Customer Relationship Summary
This section gives you a quick overview of the customer's history with the salon:
- **Total spend** — how much they've paid across all completed appointments
- **Total Spend** — how much they've paid across all completed appointments (excluding discounts)
- **Total Saved** — how much they've saved via loyalty redemptions and campaign discounts
- **Total visits** — how many appointments they've completed
- **First visit** and **last visit** dates
- **Average visits per month** — how regularly they come in
@@ -728,7 +734,7 @@ When a customer cancels late, you can choose to forgive the penalty. If you do:
### How Stamps Are Earned
- Customers earn **1 stamp** for each completed appointment
- Customers earn **1 stamp** for each completed paid appointment
- Maximum **1 stamp per day** — even if a customer has multiple appointments on the same day, they only get one stamp
- Appointments with a total price of £0 don't earn stamps
@@ -736,8 +742,15 @@ When a customer cancels late, you can choose to forgive the penalty. If you do:
- At **10 stamps**, a discount is automatically set up
- The discount is **10% off** the customer's next completed appointment
- After the discount is used, stamps reset to 0 and the cycle starts again
- After the discount is used, stamps reset and the cycle starts again
- Discounts expire after **6 months** if not used
- If a customer has multiple pending redemptions, the oldest is used first
### How Discounts Stack
Loyalty discounts **add together** with campaign discounts. A customer with a full loyalty card (10%) and an active "5% off" campaign gets **15% off** their next completed appointment. Multiple milestones can also stack on the same booking (e.g., per-user milestone + anniversary). Each discount is calculated against the original booking total, not the post-discount total.
On a completed booking, you'll see each discount as a separate line item showing the source (loyalty, campaign name, milestone type) and the amount.
### What You Can See
@@ -73,7 +73,7 @@ No external dependencies. No paid services. No API keys needed.
| 42 | **Dark mode** | M (1-2d) | Frontend | SvelteKit + Tailwind supports it easily. No dark mode toggle or `prefers-color-scheme` support. |
| 43 | **PWA support** | L (3-5d) | Frontend | No service worker, no manifest.json, no offline support. Customers can't "install" the booking app. |
| 44 | **Automated database backups** | M (1d) | Infrastructure | No backup strategy. PostgreSQL volume is persistent but no automated dumps, no point-in-time recovery. Can use `pg_dump` cron on the host — no external service needed. |
| 45 | **Loyalty stamp redemption + discount system** | XL (5-7d) | Full-stack | **Plan written** (`.sisyphus/plans/45-loyalty-discount-system.md`). **Design:** (1) Auto-redeem: when stamps hit 10, create pending `loyalty_redemption`. (2) Discount applied at booking *completion* (not creation) — avoids cancellation edge cases. (3) 10% loyalty discount creates `discount` payment_method record, deducts 10 stamps. (4) Campaign system: `discount_campaigns` table for time-based sales (Easter 5% off, opening week 10% off first appointment). (5) Customer UI shows "next completed appointment gets 10% off" — NO exact prices. (6) Email notification TODO where SMTP will go. (7) `booking_discounts` table tracks all discounts applied. Priority: loyalty > campaign, one discount per booking. DB: 3 new tables + 2 new enums + 1 column alter. |
| ~~45~~ | ~~**Loyalty stamp redemption + discount system**~~ | XL (5-7d) | Full-stack | **Complete June 2026.** v2 plan (`.sisyphus/plans/45-loyalty-discount-system-v2.md`). Auto-redeem at 10 stamps, discount applied at completion. All discounts stack additively (loyalty + time-based + milestones). Campaign lifecycle: draft → active → completed. 45 tests covering stacking, edge cases, and campaign status. `discount_eligible` dead column removed. 3 tables: `loyalty_redemptions`, `discount_campaigns`, `booking_discounts`. |
| ~~46~~ | ~~**Staff management**~~ 🗑️ | — | — | Removed — single employee sole trader business, no multi-staff needed. |
| 47 | **Recurring bookings** | L (3-5d) | Full-stack | Customers can't book the same slot weekly/monthly. Would need a `recurring_bookings` table + background job to materialize instances. |
| ~~48~~ | ~~**Waitlist functionality**~~ 🗑️ | — | — | Removed — not desired for this business. |
@@ -0,0 +1,303 @@
# Loyalty & Discount System — Full Reference
Complete reference for the loyalty and discount system, written for four audiences.
---
## A) New Customer — "How do discounts work?"
### Loyalty Card
Every time you complete a paid appointment, you earn **1 stamp** (max 1 per day — so two appointments on the same day only count once). Free appointments don't earn stamps.
After **10 stamps**, you get a **10% discount** automatically applied to your next paid appointment. After that discount is used, your stamps reset and you start collecting again.
**You don't need to do anything.** The system tracks your stamps and applies the discount automatically when your appointment is completed.
### Campaign Discounts
The salon occasionally runs promotions — like "10% off this week" or "15% off your 5th visit." If a campaign is active and you qualify, the discount is applied automatically when your appointment is completed.
### Stacking
Discounts **add together**. If you have a full loyalty card (10% off) AND there's an active "5% off this week" campaign, you get **15% off** — not just the better one. Every discount you qualify for stacks on top of the others.
### What you'll see
- Your stamp count on your account page
- A notification when you earn a stamp or unlock a discount
- The discount applied to your booking total when your appointment is completed
---
## B) Staff Member — "What applies and when?"
### When a booking is completed, the system checks for discounts in this order:
| # | Discount Type | What triggers it | How much |
|---|---|---|---|
| 1 | **Loyalty** | Customer has 10 stamps (a pending redemption) | 10% off |
| 2 | **Time-based campaign** | An active campaign with start/end dates covering today | Whatever % the campaign is set to |
| 3 | **Per-user milestone** | Customer hits an exact booking count (e.g., their 10th visit) | Whatever % the campaign is set to |
| 4 | **Global milestone** | The salon's total completed bookings hit a number (e.g., 1000th booking overall) | Whatever % the campaign is set to |
| 5 | **Anniversary** | Time since the customer's first completed visit (e.g., 1-year anniversary) | Whatever % the campaign is set to |
**All of these stack.** A customer can get loyalty + campaign + milestone discounts on the same booking. Each one is calculated against the **original booking total** (not the post-discount total).
### Key rules
- **Free bookings (£0 total)** earn no stamps and get no discounts
- **One stamp per calendar day** — even if a customer has 3 appointments on Monday, they get 1 stamp
- **Time-based campaigns**: if multiple are active, only the **highest %** applies (they don't stack with each other)
- **Milestones are one-shot per customer per campaign** — a "10th visit" discount only fires once per customer
- **Global milestones** can have a max redemptions cap — once reached, no more
- **Campaigns must be "active"** — draft, completed, or cancelled campaigns don't apply
### What you'll see on a completed booking
Each discount appears as a separate discount line on the booking. A £100 booking with loyalty (10%) + campaign (5%) shows:
- Discount 1: Loyalty — £10.00
- Discount 2: Campaign (Summer Sale) — £5.00
- **Total discount: £15.00**
- Customer pays: £85.00
---
## C) Technical Admin — "How do I manage campaigns?"
### Campaign Lifecycle
```
draft → active → completed
↑ ↓
└── cancelled
```
| Action | What happens |
|---|---|
| **Create** | Always creates as `draft`. Review and activate when ready. |
| **Activate** | `draft → active`. Campaign starts applying to completed bookings. |
| **Complete** | `active → completed`. Campaign stops applying. |
| **Cancel** | Any status → `cancelled`. Permanently disabled. |
| **Revert** | `active → draft`. For editing before re-activation. |
### Campaign Types
| Type | `campaign_type` | `milestone_type` | Trigger |
|---|---|---|---|
| Time-based | `time_based` | — | Date range (start_date to end_date) |
| Per-user milestone | `milestone` | `per_user_booking_count` | User's personal completed booking count hits exact value |
| Global milestone | `milestone` | `global_booking_count` | Salon-wide completed booking count hits exact value |
| Anniversary | `milestone` | `anniversary` | Time since user's first completed booking (months/years) |
### Configuration Fields
| Field | Used by | Notes |
|---|---|---|
| `discount_percent` | All | The % off (calculated against original booking total) |
| `start_date` / `end_date` | Time-based | Must be `active` AND within date range |
| `milestone_value` | Milestones | The exact count to trigger on |
| `milestone_unit` | Anniversary | `months` or `years` |
| `max_redemptions` | Global milestone | Cap on total times this can be applied across all users |
| `times_redeemed` | All | Auto-incremented each time the discount is applied |
### Dedup & Safety
| Discount | Dedup mechanism |
|---|---|
| Loyalty | One pending redemption per user; consumed on use |
| Time-based | Single best selected (`ORDER BY discount_percent DESC LIMIT 1`) |
| Per-user milestone | `NOT EXISTS` check on `booking_discounts` — one per user per campaign |
| Global milestone | `times_redeemed < max_redemptions` |
| Anniversary | `NOT EXISTS` check on `booking_discounts` — one per user per campaign |
### Database Tables
| Table | Purpose |
|---|---|
| `discount_campaigns` | Campaign definitions (type, %, dates, milestones, status) |
| `booking_discounts` | Applied discount records (one row per discount per booking) |
| `loyalty_redemptions` | Stamp redemption tracking (pending → applied) |
| `payments` (method=`discount`) | Financial record for each discount applied |
---
## D) Programmer — "Give me the full spec"
### Stamp Earning (ProgressBookingHandler, bookings.go ~L2035)
```
On booking completion:
IF bookingTotal > 0:
UPDATE users SET loyalty_stamps += 1
WHERE NOT EXISTS (
another completed booking for this user
with updated_at >= CURRENT_DATE - 1 day
)
IF newStampCount == 10:
INSERT INTO loyalty_redemptions (status='pending', stamps_redeemed=10)
```
- `bookingTotal` = `SUM(COALESCE(override_price, service.price))` for all `booking_services`
- 1-stamp-per-day enforced via `NOT EXISTS` subquery on same-day completions
- Free bookings (`bookingTotal == 0`) skip entirely
### Discount Application Order (ProgressBookingHandler, bookings.go ~L2005-2195)
All 5 discount types execute **unconditionally and independently** within `if bookingTotal > 0`. Each creates:
1. A `booking_discounts` row
2. A `payments` row with `payment_method = 'discount'`, `payment_type = 'partial'`
#### Step 1: Loyalty Redemption (~L2005-2033)
```sql
SELECT id FROM loyalty_redemptions
WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()
ORDER BY redeemed_at ASC LIMIT 1
```
- Applies 10% of `bookingTotal`
- Updates redemption: `status = 'applied'`
- Resets stamps: `loyalty_stamps = GREATEST(0, stamps - 10)`
- Oldest pending redemption used first (FIFO)
#### Step 2: Time-Based Campaign (~L2065-2091)
```sql
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
```
- Single highest-% campaign selected
- `booking_discounts.campaign_type = 'time_based'`
- Increments `times_redeemed`
#### Step 3: Per-User Milestone (~L2093-2119)
```sql
SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'
-- then:
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone'
AND milestone_type = 'per_user_booking_count'
AND milestone_value = $userBookingCount
AND NOT EXISTS (SELECT 1 FROM booking_discounts
WHERE user_id = $1 AND source_id = discount_campaigns.id)
```
- `userBookingCount` includes the current booking (status already set to 'completed')
- Exact match on `milestone_value`
- Dedup: `NOT EXISTS` on `booking_discounts` per user per campaign
#### Step 4: Global Milestone (~L2121-2145)
```sql
SELECT COUNT(*) FROM bookings WHERE status = 'completed'
-- then:
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone'
AND milestone_type = 'global_booking_count'
AND milestone_value = $globalCount
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
```
- No user-level dedup — relies on `max_redemptions` cap
- Exact match on `milestone_value`
#### Step 5: Anniversary (~L2147-2195)
```sql
SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'
-- then iterate:
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')
```
- Calculates elapsed time since first completed booking
- `months`: `elapsed.Hours() / (30 * 24)`
- `years`: `elapsed.Hours() / (365.25 * 24)`
- Matches if `computed >= milestone_value`
- Dedup: `NOT EXISTS` on `booking_discounts` per user per campaign
- `break` after first match (only one anniversary discount per booking)
### Discount Amount Calculation
Every discount: `discountAmount = roundTo2(bookingTotal * discountPercent / 100)`
All calculated against the **original** `bookingTotal` — never against a post-discount amount. Discounts are **additive, not compound**.
### Schema
```sql
-- discount_campaigns
id CHAR(12) PK
name VARCHAR(100)
campaign_type ENUM('time_based', 'milestone')
discount_percent NUMERIC(5,2)
status ENUM('draft', 'active', 'completed', 'cancelled') DEFAULT 'draft'
start_date TIMESTAMPTZ -- time_based only
end_date TIMESTAMPTZ -- time_based only
milestone_type ENUM('per_user_booking_count', 'global_booking_count', 'anniversary')
milestone_value INT
milestone_unit ENUM('bookings', 'months', 'years')
max_redemptions INT -- NULL = unlimited
times_redeemed INT DEFAULT 0
-- loyalty_redemptions
id CHAR(12) PK
user_id CHAR(12) FK -> users
stamps_redeemed INT
status ENUM('pending', 'applied', 'expired')
redeemed_at TIMESTAMPTZ
expires_at TIMESTAMPTZ
applied_at TIMESTAMPTZ
applied_booking_id CHAR(12)
-- booking_discounts
id CHAR(12) PK
booking_id CHAR(12) FK -> bookings
user_id CHAR(12) FK -> users
discount_source ENUM('loyalty', 'campaign')
source_id CHAR(12) -- loyalty_redemptions.id or discount_campaigns.id
campaign_type ENUM('time_based', 'milestone')
milestone_type ENUM('per_user_booking_count', 'global_booking_count', 'anniversary')
discount_percent NUMERIC(5,2)
original_total NUMERIC(10,2)
discount_amount NUMERIC(10,2)
-- payments (discount rows)
booking_id CHAR(12) FK -> bookings
payment_type ENUM('partial')
payment_method ENUM('discount')
amount NUMERIC(10,2)
status ENUM('completed')
```
### Stacking Example: £100 booking, loyalty + 5% campaign + 10% anniversary
| Step | Source | % | Amount | `booking_discounts` | `payments` |
|---|---|---|---|---|---|
| 1 | loyalty | 10% | £10.00 | `source='loyalty'` | `method='discount', 10.00` |
| 2 | campaign | 5% | £5.00 | `source='campaign', type='time_based'` | `method='discount', 5.00` |
| 3 | anniversary | 10% | £10.00 | `source='campaign', type='milestone', milestone='anniversary'` | `method='discount', 10.00` |
| | **Total** | **25%** | **£25.00** | **3 rows** | **3 rows** |
Customer pays: **£75.00**
### What's NOT in the system
- No total discount cap (future safeguard: consider 50% max)
- No compound discounts (all additive against original total)
- No pre-assigned eligibility on booking rows (all computed at completion time)
- No online/advance discount preview (UI queries source tables directly for "you have X waiting" messages)
---
## E) Customer Relationship Metric Separation
To maintain clear and accurate accounting for each client:
1. **Total Spend** represents actual card, cash, and gift card payments only. It excludes discount amounts, giving an accurate count of actual business revenue received from the customer.
2. **Total Saved** represents the sum of all loyalty card redemptions and campaign discounts applied to the customer's completed bookings.
These are computed automatically by the `/api/admin/users/{id}/relationship` endpoint on the backend and displayed as separate, side-by-side metric cards inside the administrator's **User Details** modal under the "Customer Relationship" section.
---
## F) Frontend UI Enhancements
To deliver an incredibly clear and robust experience for administrators and customers:
1. **Tip Suggestions on Net Total**: Tip percentages (10%, 15%, 20%) in the `Take Payment` modal are calculated dynamically on the **net total after discounts** (`subtotal - discountSum`) instead of the pre-discount subtotal.
2. **z-index Layering Resolution**: The `Booking Details` modal uses elevated Svelte backdrop-overlay and content layers (`!z-[60]`) to ensure it always opens smoothly in front of the `User Details` modal (`z-50`) without being obscured.
3. **Visually Distinguished Negative Discounts**: Under "Payment History" in both Admin and Customer views, applied discounts are clearly presented as negative numbers (e.g., `-£2.50`) to visually distinguish them from customer cash/card payments.
4. **Chronological Discount Payment Matching**: To prevent duplicate descriptions on multiple discounts with identical amounts, Svelte uses index-based chronological lookup to match each discount payment row uniquely to its exact Campaign or Loyalty source.
+25 -14
View File
@@ -333,7 +333,7 @@ src/lib/components/
| `patch_tests` | Patch test definitions (notice_duration_hours, expiry_months, service_ids) |
| `user_patch_tests` | User patch test completion records (tested_at, notes) |
| `services` | Service offerings |
| `bookings` | Appointment records (idempotency_key, discount_eligible, deposit_required, deposit_paid, deposit_amount, deposit_deadline) |
| `bookings` | Appointment records (idempotency_key, deposit_required, deposit_paid, deposit_amount, deposit_deadline) |
| `booking_services` | Services per booking (override_price, override_duration_minutes) |
| `booking_edit_requests` | Pending customer edit requests |
| `user_referrals` | Referral tracking |
@@ -348,9 +348,9 @@ src/lib/components/
| `refunds` | Refund records linked to payments (amount, reason, square_refund_id) |
| `square_deposits` | Square deposit batch tracking for bank reconciliation (batch_id, total_amount, deposited_at) |
| `affiliate_payouts` | Affiliate commission tracking |
| `loyalty_redemptions` | Loyalty stamp redemptions (6-month expiry) |
| `discount_campaigns` | Discount campaigns (time-based and milestone) |
| `booking_discounts` | Applied discounts per booking |
| `loyalty_redemptions` | Loyalty stamp redemptions (pending → applied, 6-month expiry, FIFO) |
| `discount_campaigns` | Discount campaigns: time-based and milestone (draft → active → completed lifecycle) |
| `booking_discounts` | Applied discounts per booking (multiple rows per booking when stacking) |
| `business_settings` | Business configuration (VAT, currency, contact) |
| `admin_notifications` | Admin notification queue |
| `user_notification_preferences` | User notification preferences (email/sms/push) |
@@ -604,17 +604,28 @@ type EnrichedEditRequest struct {
### Loyalty & Discount System
**Loyalty Stamps:**
- 1 stamp per completed booking (1 per day max)
- Zero-total bookings don't earn stamps
- At 10 stamps → pending `loyalty_redemption` created
- Next completed booking → 10% discount applied, stamps reset to 0, +1 for completion
- Redemption expires after 6 months
- 1 stamp per completed paid booking (max 1 per calendar day)
- Zero-total bookings (£0) don't earn stamps
- At 10 stamps → pending `loyalty_redemption` created (6-month expiry)
- Next completed paid booking → 10% discount applied, stamps reset via `GREATEST(0, stamps - 10)`, +1 stamp earned for this completion → net stamps = 1
- Oldest pending redemption used first (FIFO by `redeemed_at`)
**Discount Campaigns:**
- **Time-based**: Active during date range
- **Milestone**: `per_user_booking_count`, `global_booking_count`, `anniversary`
- Scopes: `all_bookings`, `first_booking_only`, `new_customers_only`
- Priority: loyalty > campaign (one discount per booking)
**Discount Stacking:**
All applicable discounts stack additively (not compound). Each discount is calculated against the **original booking total** and creates its own `booking_discounts` row and `payments` row (`payment_method = 'discount'`).
| # | Source | Trigger | Dedup |
|---|--------|---------|-------|
| 1 | Loyalty | Pending redemption exists (`status = 'pending'`, `expires_at > NOW()`) | One redemption consumed per use |
| 2 | Time-based campaign | `status = 'active'`, within `start_date``end_date` range | Single best (highest %) selected |
| 3 | Per-user milestone | User's completed booking count matches `milestone_value` exactly | `NOT EXISTS` on `booking_discounts` per user per campaign |
| 4 | Global milestone | Salon-wide completed booking count matches `milestone_value` exactly | `times_redeemed < max_redemptions` |
| 5 | Anniversary | Time since user's first completed booking ≥ `milestone_value` (months/years) | `NOT EXISTS` on `booking_discounts` per user per campaign |
**Campaign Lifecycle:** `draft → active → completed` (also: any → `cancelled`, `active → draft` for re-editing). Campaigns created as `draft` by default; must be manually activated.
**Formula:** `discountAmount = roundTo2(bookingTotal × discountPercent / 100)` — always against original total.
**Example:** £100 booking with loyalty (10%) + time-based campaign (5%) + anniversary milestone (10%) = 3 discount rows totalling £25.00. Customer pays £75.00.
**Tables:** `loyalty_redemptions`, `discount_campaigns`, `booking_discounts`
+9 -1
View File
@@ -174,9 +174,17 @@ Every time you complete an appointment, you earn a loyalty stamp. You can see yo
- Appointments with a total price of £0 don't earn stamps
- When you reach **10 stamps**, a discount is automatically set up for your next completed appointment
- The discount gives you **10% off** your next appointment
- After the discount is used, your stamp count resets to 0 and you start earning again
- After the discount is used, your stamp count resets and you start earning again
- If you don't use your discount within **6 months**, it expires
### Campaign Discounts
The salon occasionally runs promotions — like "10% off this week" or "15% off your 5th visit." If a campaign is active and you qualify, the discount is applied automatically when your appointment is completed. You don't need to do anything.
### How Discounts Combine
Discounts **add together**. If you have a full loyalty card (10% off) AND there's an active "5% off this week" campaign, you get **15% off** — not just the better one. Every discount you qualify for stacks on top of the others. Each discount is calculated against the original booking total.
### Your Booking History
A list of all your appointments — upcoming and past — is shown on the Account page. Each entry shows: