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>
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 untilmax_redemptionsis 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 allbooking_services- 1-stamp-per-day enforced via
NOT EXISTSsubquery 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.
- A
booking_discountsrow - A
paymentsrow withpayment_method = 'discount',payment_type = 'partial' - 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:
- A cancellation of a confirmed booking within 24h of start time -> booking status becomes
'no_show' ApplyDepositsIfNeeded(userID)counts unforgiven no-shows in the last 6 months- At 2+ no-shows,
deposits_requiredis set to 3 - Each completed booking with a payment decrements
deposits_requiredby 1 - When
deposits_requiredreaches 0, all no-show records are auto-forgiven (inserted intoforgiven_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 fromProcessCancellationRefundTotalPaid 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.