# 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 — two appointments on the same day only count once). Free appointments (£0 total) don't earn stamps. After **10 stamps**, you can use them for a **10% discount** on your next booking. You decide when to use them: - **When paying online**: a checkbox appears in the payment form: "Use my Loyalty Stamp Card — 10% off" - **At the salon**: the staff will ask "Would you like to use your Loyalty Stamp Card?" - After the discount is used, your stamps are consumed and you start collecting again. If you cancel a booking where you used your loyalty discount, the **10 stamps are refunded** back to your account. ### Campaign Discounts The salon 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 whether you pay in advance or at completion. ### Global Milestones Occasional salon-wide celebrations (e.g., "100th booking! 20% off") are awarded to the first eligible booking after the milestone is reached. Paying online won't lock one in — they're awarded to the next in-person booking at the till, so the milestone is real. ### Stacking Discounts **add together**. If you use your loyalty card (10% off) AND there's an active "5% off this week" campaign, you get **15% off**. Every discount you qualify for stacks on top of the others. ### What you'll see - Your stamp count on your account page - A checkbox to use your stamps when paying - Active campaign discounts shown as auto-applied in your booking summary --- ## B) Staff Member — "What applies and when?" ### Discounts can apply at TWO trigger points | Trigger Point | When it fires | |---|---| | **Payment time** | When a deposit or full payment is made (online or at till) | | **Completion time** | When the booking is marked as completed at the till | Each discount type has specific rules about which trigger(s) it fires at. ### Discount Application Order #### At Payment Time (`applyEligibleCampaignsAtPayment` in handlers.go) | # | Discount Type | Applies? | When | |---|---|---|---| | 1 | **Loyalty** | ✅ User opt-in via checkbox | User checks "Use Loyalty Stamp Card" in payment modal | | 2 | **Time-based campaign** | ✅ Auto-applied | Campaign is active (start_date ≤ NOW() ≤ end_date) | | 3 | **Per-user milestone** | ✅ Auto-applied | User's completed count matches milestone_value | | 4 | **Global milestone** | ⚠️ Only if first in-person payment | Booking has at least one `in_person_card` payment; milestone_value ≤ global completed count | | 5 | **Anniversary** | ✅ Auto-applied | Elapsed time since first completed visit matches milestone | #### At Completion Time (`ProgressBookingHandler` in bookings.go) | # | Discount Type | Applies? | Guard | |---|---|---|---| | 1 | **Loyalty** | ⚠️ Skip if already applied | `NOT EXISTS booking_discounts WHERE booking_id AND source='loyalty'` | | 2 | **Time-based campaign** | ⚠️ Skip if already applied | `NOT EXISTS booking_discounts WHERE booking_id AND campaign_type='time_based'` | | 3 | **Per-user milestone** | ⚠️ Skip if already applied | `NOT EXISTS booking_discounts WHERE user_id + source_id` | | 4 | **Global milestone** | ⚠️ Only if in-person; skip if already applied | Has `in_person_card` payment; `milestone_value <= globalCount AND times_redeemed < max_redemptions` | | 5 | **Anniversary** | ⚠️ Skip if already applied | `NOT EXISTS booking_discounts WHERE user_id + source_id + milestone_type` | ### 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**: milestone_value is used as a minimum threshold (`<=` not `=`). If booking #100 is ineligible (no in-person payment), booking #101, #102, etc. will get it instead until `max_redemptions` is reached - **Global milestones can have a max redemptions cap** — once reached, no more - **Campaigns must be "active"** — draft, completed, or cancelled campaigns don't apply - **All discounts stack** additive against the original booking total (not compound) - **Discount payment records** are excluded from refund calculations (refunds return only real money) ### Loyalty at the till (staff workflow) When completing a booking, staff see: "This customer has X stamps. Would you like to use their Loyalty Stamp Card?" - If the customer already used stamps at payment time, the checkbox is hidden (the discount already shows in the applied discounts list) - If the customer has < 10 stamps, the checkbox is hidden - If the customer has ≥ 10 stamps and hasn't used them, the checkbox is shown ### 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 ### Tip calculation Tip percentages (10%, 15%, 20%) are calculated on the **net total after discounts** (`subtotal - discountSum`). --- ## 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 | Trigger | Applies at Payment? | |---|---|---| | Time-based | Date range (start_date to end_date) | ✅ Yes | | Per-user milestone | User's personal completed booking count hits value | ✅ Yes | | Global milestone | Salon-wide completed booking count hits value (minimum threshold) | ⚠️ Only if first payment is in-person | | Anniversary | Time since user's first completed booking (months/years) | ✅ Yes | ### 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 | Minimum threshold — applied when `currentCount >= milestone_value` | | `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 | `NOT EXISTS booking_discounts WHERE booking_id AND source='loyalty'` — one per booking | | Time-based | `NOT EXISTS booking_discounts WHERE booking_id AND campaign_type='time_based'` — one per booking | | Per-user milestone | `NOT EXISTS booking_discounts WHERE user_id + source_id` — one per user per campaign | | Global milestone | `milestone_value <= globalCount AND times_redeemed < max_redemptions AND NOT EXISTS booking_discounts WHERE source_id = campaign.id AND booking_id` — one per booking + max cap | | Anniversary | `NOT EXISTS booking_discounts WHERE user_id + source_id + milestone_type='anniversary'` — 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" ### Constants Backend (`backend/handlers/payments/refund_policy.go`): ```go const ( RequiredDepositPct = 0.20 ProtectedDepositMaxPct = 0.50 LoyaltyStampCost = 10 // stamps needed for one redemption ) ``` Frontend (`frontend/src/lib/constants/policy.ts`): ```ts export const POLICY = { LOYALTY_STAMP_REDEMPTION_COST: 10, REQUIRED_DEPOSIT_PCT: 0.20, PROTECTED_DEPOSIT_MAX_PCT: 0.50, // ... other policy constants } as const; ``` ### Stamp Earning (ProgressBookingHandler) ``` On booking completion (at the till): IF bookingTotal > 0: UPDATE users SET loyalty_stamps += 1 WHERE NOT EXISTS (another completed booking for this user with same-day completion) IF newStampCount >= LoyaltyStampCost: 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 ### Endpoint: `POST /api/bookings/{id}/apply-redemption` Called when user (via payment modal checkbox) or admin (via till checkbox) applies loyalty stamps: ``` 1. Validate booking ownership + non-terminal status 2. Validate user has >= LoyaltyStampCost stamps 3. Validate no loyalty discount already on this booking 4. Start transaction: a. Calculate discount: roundTo2(bookingTotal * 0.10) b. INSERT booking_discounts (source='loyalty') c. INSERT payments (method='discount') d. UPDATE loyalty_redemption SET status='applied', applied_to_booking_id = id e. UPDATE users SET loyalty_stamps = GREATEST(0, stamps - LoyaltyStampCost) 5. Return { success: true, discount_amount: X.XX } ``` Source: `backend/handlers/payments/loyalty.go` ### Function: `applyEligibleCampaignsAtPayment(ctx, q db.Querier, bookingID, userID)` Called from inside the payment transaction in `CreateBookingPayment` handler (moved from outside — now atomic with payment writes). Runs campaign checks at payment time. Uses the provided `db.Querier` (`q`) instead of managing its own transaction — if the payment commit fails, the discount writes roll back atomically. **Signature change:** `applyEligibleCampaignsAtPayment` now accepts `db.Querier q` as the second parameter (replacing `context.Context`). The caller passes in their active transaction (or pool). The function no longer calls `Begin`/`Commit` — the caller owns the transaction lifecycle. ``` 1. Time-based campaign: SELECT ... WHERE start_date <= NOW() AND end_date >= NOW() AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) AND NOT EXISTS booking_discounts WHERE booking_id + campaign_type='time_based' ORDER BY discount_percent DESC LIMIT 1 2. Per-user milestone: SELECT ... WHERE milestone_type = 'per_user_booking_count' AND milestone_value <= userBookingCount AND NOT EXISTS booking_discounts WHERE user_id + source_id 3. Anniversary: SELECT ... WHERE milestone_type = 'anniversary' AND NOT EXISTS booking_discounts WHERE user_id + source_id + milestone_type 4. Global milestone (only if first payment is in_person_card): SELECT ... WHERE milestone_type = 'global_booking_count' AND milestone_value <= globalCount AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) AND EXISTS(SELECT 1 FROM payments WHERE booking_id AND payment_method = 'in_person_card') ORDER BY milestone_value DESC LIMIT 1 ``` Source: `backend/handlers/payments/handlers.go` line 684+ ### Discount Application at Completion (ProgressBookingHandler) All discount types execute within `if bookingTotal > 0`, guarded by dedup checks. Each creates: **Anniversary campaign sorting:** Anniversary campaigns are now sorted by `milestone_value DESC` before application. This ensures that when multiple anniversary milestones are active (e.g., 1-year and 2-year), only the longest (highest milestone_value) is applied. Previously, the first qualifying campaign was used regardless of value — now the `sort.Slice` before the loop guarantees deterministic "longest wins" behaviour. 1. A `booking_discounts` row 2. A `payments` row with `payment_method = 'discount'`, `payment_type = 'partial'` 3. Increments `discount_campaigns.times_redeemed` (for campaigns) #### Step 1: Loyalty Dedup Guard ```go var loyaltyAlreadyApplied bool tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAlreadyApplied) if !loyaltyAlreadyApplied { // existing loyalty redemption logic (unchanged) } ``` #### Step 4: Global Milestone (with in-person check + next-eligible via `<=`) ```go 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) ORDER BY milestone_value DESC LIMIT 1 ``` Only applies if the booking has at least one `in_person_card` payment. ### 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**. ### Stamp Refund on Cancellation When a booking with a loyalty discount is cancelled, the stamps are refunded (`loyalty_stamps + LoyaltyStampCost`). This happens in `ProcessCancellationRefund` after all monetary refunds are processed. Stamps are only refunded for registered users (not guests). ### 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 end_date TIMESTAMPTZ 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_to_booking_id CHAR(12) -- NEW: tracks which booking the redemption was used on -- booking_discounts id CHAR(12) PK booking_id CHAR(12) FK -> bookings user_id CHAR(12) FK -> users discount_source VARCHAR(30) -- 'loyalty' | 'campaign' source_id CHAR(12) -- campaign.id (for campaigns) campaign_type campaign_type -- 'time_based' | 'milestone' milestone_type milestone_type -- 'per_user_booking_count' | 'global_booking_count' | 'anniversary' discount_percent NUMERIC(5,2) original_total NUMERIC(10,2) discount_amount NUMERIC(10,2) applied_at TIMESTAMPTZ DEFAULT NOW() -- 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** ### Rescheduling with Discounts - **Edit request with time change + booking has discounts**: Auto-approve is BLOCKED regardless of notice period. The request goes to admin review. - **Admin approval**: The admin sees a discount warning in the approval modal. - **If rejected**: The user can cancel through normal refund tiers and rebook at full price. - **Discounts are not revoked on reschedule** — they were earned at payment time and stay locked. ### No-Show Tracking No-show tracking uses a counter-based system: 1. A cancellation of a confirmed booking within 24h of start time -> booking status becomes `'no_show'` 2. `ApplyDepositsIfNeeded(userID)` counts unforgiven no-shows in the last 6 months 3. At 2+ no-shows, `deposits_required` is set to 3 4. Each completed booking with a payment decrements `deposits_required` by 1 5. When `deposits_required` reaches 0, all no-show records are auto-forgiven (inserted into `forgiven_no_shows`) No-show forgiveness (by admin) is tracked via `forgiven_no_shows` table. ### Test Files | File | Tests | Coverage | |---|---|---| | `handlers/payments/loyalty_test.go` | 10 | apply-redemption (4) + campaign auto-apply (6) | | `handlers/payments/refund_exclude_test.go` | 2 | Discount/OTH excluded from refunds | | `handlers/bookings/dedup_test.go` | 8 | Dedup guards + no-show tracking + 3-paid clear | | `handlers/bookings/bookings_test.go` | 9 | Auto-approve blocked by discounts; still works without; duplicate completion; daily stamp cap; invalid transitions; sequential edit; timezone independence; daily stamp cap SQL; past no-show guard | ### Key Design Decisions - **Global milestones at payment time only for in-person payments**: If paid online and later cancelled, the milestone was "claimed" by a phantom booking. In-person means the slot was physically filled. - **Global milestone "next eligible"**: Changed from exact match (`=`) to minimum threshold (`<=`). If booking #100 is ineligible, #101 gets the milestone instead. - **Stamps refunded on cancellation**: If a booking with loyalty discount applied is cancelled, the 10 stamps are refunded back to the user. - **Discount payment records excluded from refunds**: `payment_method IN ('discount', 'on_the_house')` are excluded from `ProcessCancellationRefund` TotalPaid calculation. - **Loyalty is always opt-in**: Users choose when to use stamps via checkbox. Staff ask at the till. Never auto-applied. - **No global milestone notification when skipped**: If a global milestone is skipped (online payment), no notification is sent. The next eligible booking claims it silently.