# 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.
