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:
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user