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
+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: