Files
Crussell/obsidian/Crussell/Loyalty & Discount System Reference.md
T
popertotsandSisyphus 5de0d49454 chore: update README, init-script SQL, and obsidian documentation
Update README with latest changes. Revise init-script.sql with schema updates. Sync obsidian technical docs.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:44:06 +01:00

18 KiB

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

const (
    RequiredDepositPct     = 0.20
    ProtectedDepositMaxPct = 0.50
    LoyaltyStampCost       = 10   // stamps needed for one redemption
)

Frontend (frontend/src/lib/constants/policy.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

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 <=)

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

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