diff --git a/.sisyphus/plans/45-loyalty-discount-system-v2.md b/.sisyphus/plans/45-loyalty-discount-system-v2.md deleted file mode 100644 index 33a7527..0000000 --- a/.sisyphus/plans/45-loyalty-discount-system-v2.md +++ /dev/null @@ -1,506 +0,0 @@ -# Plan: #45 Loyalty & Discount System v2 - -Supersedes: `.sisyphus/plans/45-loyalty-discount-system.md` (v1) - -## Status - -**Phase**: Planning — no implementation yet. - ---- - -## 1. Stamp Earning — Clarification & Fixes - -### Current Behaviour (correct, no change needed) - -The stamp earning logic in `ProgressBookingHandler` (bookings.go lines 2035-2063) is **already correct**: - -- **Every completed booking with `bookingTotal > 0` earns 1 stamp** — including the very first booking -- **Max 1 stamp per calendar day** — the `NOT EXISTS` subquery checks for other completed bookings with `updated_at >= CURRENT_DATE - INTERVAL '1 day'` -- **Free bookings (£0.00 total) earn no stamp** — the `if bookingTotal > 0` gate -- **At exactly 10 stamps** → auto-create `loyalty_redemptions` row with `status = 'pending'` -- **On next paid completion** → apply 10% discount, deduct 10 stamps (via `GREATEST(0, stamps - 10)`), then earn +1 for this completion → net stamps = 1 - -### The "zero-total" note explained - -"Zero-total bookings earn no stamps" refers to bookings where all services are £0.00 (free services, complimentary treatments). It does NOT mean "first N bookings don't earn stamps." A customer's very first paid booking earns stamp #1. - -### Flow summary - -``` -Booking 1 (£50) → stamp 1 -Booking 2 (£50) → stamp 2 (if different day) -... -Booking 10 (£50) → stamp 10 → pending redemption created -Booking 11 (£50) → apply 10% discount (£5 off) → stamps reset to 0 → earn +1 → stamps = 1 -Booking 12 (£50) → stamp 2 -... -Booking 21 (£50) → stamp 10 → pending redemption created -Booking 22 (£50) → apply 10% discount → stamps = 1 -... -``` - -### No code changes needed for stamp earning - -The existing logic is correct. Tests `TestDiscount_Loyalty_FullCycle`, `TestDiscount_Loyalty_OneStampPerDay`, `TestDiscount_Loyalty_ZeroTotalNoStamp`, and `TestDiscount_Loyalty_CycleRepeats` all verify this. - ---- - -## 2. Discount Stacking — Redesign - -### Problem - -The current system uses a strict **one-discount-per-booking priority cascade**: - -``` -Loyalty → blocks everything -Time-based campaign → blocks milestones -Per-user milestone → blocks global and anniversary -Global milestone → blocks anniversary -``` - -This means a customer with a full loyalty card AND an active "10% off" campaign AND a "100th booking" milestone only gets the loyalty discount. The campaign and milestone are wasted. - -### Desired Behaviour - -**All applicable discounts stack additively (not compound):** - -- 10% loyalty + 10% time-based campaign = 20% off -- 10% loyalty + 10% per-user milestone + 5% anniversary = 25% off -- 10% loyalty + 15% time-based + 10% per-user + 5% anniversary + 20% global = 60% off - -Each discount is calculated against the **original booking total** (not the post-discount total). Each creates its own `booking_discounts` row and its own `payments` row with `payment_method = 'discount'`. - -### Stacking Rules - -| Discount Source | Stacking | Selection | -|---|---|---| -| **Loyalty** (10%) | Always stacks | At most 1 per booking (from pending redemption) | -| **Time-based campaign** | Stacks with loyalty and milestones | Pick single best (highest %) if multiple active | -| **Per-user milestone** | Stacks with everything | At most 1 per user per campaign (dedup via `booking_discounts`) | -| **Global milestone** | Stacks with everything | Respects `max_redemptions` | -| **Anniversary milestone** | Stacks with everything | At most 1 per user per campaign (dedup via `booking_discounts`) | - -**Multiple milestones CAN stack on the same booking.** If a user hits their 100th booking (per-user milestone) on the same day as their 1-year anniversary, both apply. - -**Multiple time-based campaigns do NOT stack.** Pick the single highest % active campaign. - -### Implementation Changes - -**File**: `backend/handlers/bookings/bookings.go` — `ProgressBookingHandler` (lines 2004-2205) - -#### Step 1: Loyalty Redemption (lines 2004-2032) — NO CHANGE - -Keep as-is. Applies 10% if pending redemption exists. Creates `booking_discounts` + `payments` rows. - -#### Step 2: Time-Based Campaign (lines 2065-2096) — REMOVE GUARD - -**Remove** the `hasLoyaltyDiscount` check (lines 2066-2069). The time-based campaign block should execute regardless of whether loyalty was applied. - -```go -// BEFORE (line 2066-2069): -var hasLoyaltyDiscount bool -_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount) -if !hasLoyaltyDiscount { ... } - -// AFTER: -// Remove the hasLoyaltyDiscount check entirely. Always check for time-based campaigns. -``` - -#### Step 3: Milestone Campaigns (lines 2098-2205) — REMOVE GUARDS - -**Remove** the `hasDiscount` check (lines 2099-2102). Milestone checks should execute regardless of prior discounts. - -**Remove** the `milestoneCampaignID == ""` guards (lines 2130, 2158). Global and anniversary milestones should be checked independently of per-user milestones. - -**Scoping note:** The variables `userBookingCount` and `milestoneCampaignID` are currently declared inside the `if !hasDiscount {}` block. When that outer block is removed, these declarations simply become top-level within the `if bookingTotal > 0 {}` block. No restructuring needed. However, `milestoneCampaignID` will no longer be used as a guard — it becomes dead code after the per-user check and can be removed or left as-is (it's assigned but never read again). - -```go -// BEFORE (line 2099-2102): -var hasDiscount bool -_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount) -if !hasDiscount { ... } - -// AFTER: -// Remove the hasDiscount check entirely. Always check all milestone types. -``` - -```go -// BEFORE (line 2130): -if milestoneCampaignID == "" { /* check global */ } - -// AFTER: -// Remove this guard. Always check global milestone independently. -``` - -```go -// BEFORE (line 2158): -if milestoneCampaignID == "" { /* check anniversary */ } - -// AFTER: -// Remove this guard. Always check anniversary independently. -``` - -#### Discount Amount Calculation - -Each discount calculates against the **original booking total**: - -```go -discountAmount := roundTo2(bookingTotal * discountPercent / 100) -``` - -This is already how each step works — no change needed to the calculation itself. The key change is that multiple `booking_discounts` rows and multiple `payments` rows are created for the same booking. - -#### Example: £100 booking with loyalty + time-based + anniversary - -| Step | Source | % | Amount | `booking_discounts` row | `payments` row | -|---|---|---|---|---|---| -| 1 | loyalty | 10% | £10.00 | `discount_source='loyalty'` | `method='discount', amount=10.00` | -| 2 | time_based campaign | 5% | £5.00 | `discount_source='campaign', campaign_type='time_based'` | `method='discount', amount=5.00` | -| 3 | anniversary milestone | 10% | £10.00 | `discount_source='campaign', milestone_type='anniversary'` | `method='discount', amount=10.00` | -| **Total** | | **25%** | **£25.00** | 3 rows | 3 rows | - -Customer pays: £100 - £25 = £75 (via remaining payment methods). - -### Test Helper Issue - -The existing `getDiscountForBooking` helper does a single-row scan: - -```go -SELECT discount_source, discount_amount FROM booking_discounts WHERE booking_id = $1 -``` - -This will fail (or return arbitrary results) once stacking creates multiple rows per booking. The rewrite of `TestDiscount_LoyaltyPriority` must not use this helper, and the new stacking tests (13–22) will need different query patterns. - -**Recommended new helpers:** - -```go -// Count discount rows for a booking -func getDiscountRowCount(t *testing.T, bookingID string) int - -// Get all discounts for a booking as a slice -func getAllDiscountsForBooking(t *testing.T, bookingID string) []struct{ Source string; Amount float64 } - -// Sum all discount amounts for a booking -func getTotalDiscountAmount(t *testing.T, bookingID string) float64 -``` - -The existing `getDiscountForBooking` single-row helper should be retired or renamed to avoid confusion. - -### Test Changes - -The existing `TestDiscount_LoyaltyPriority` test (line 540) asserts that campaign discounts are blocked when loyalty applies. This test must be **rewritten** to assert the opposite: that both loyalty AND campaign discounts are applied. - ---- - -## 3. Expanded Test Suite - -### Current Tests (12) - -| # | Test | What It Verifies | -|---|---|---| -| 1 | `TestDiscount_Loyalty_FullCycle` | 10 stamps → redemption → 11th booking discount → stamps reset to 1 | -| 2 | `TestDiscount_Loyalty_ExistingRedemptionApplies` | Pre-existing pending redemption applies on completion | -| 3 | `TestDiscount_Loyalty_OneStampPerDay` | Same-day completions only give 1 stamp | -| 4 | `TestDiscount_Loyalty_ZeroTotalNoStamp` | £0 bookings don't earn stamps | -| 5 | `TestDiscount_Loyalty_CycleRepeats` | After redemption, cycle restarts | -| 6 | `TestDiscount_TimeBasedCampaign` | Time-based campaign applies correctly | -| 7 | `TestDiscount_PerUserMilestone` | Per-user booking count milestone | -| 8 | `TestDiscount_GlobalMilestone` | Global booking count milestone | -| 9 | `TestDiscount_AnniversaryMilestone` | Anniversary milestone with dedup | -| 10 | `TestDiscount_LoyaltyPriority` | Loyalty blocks campaigns (**MUST CHANGE**) | -| 11 | `TestDiscount_NoDiscountOnZeroTotal` | Zero-total gets no discounts | -| 12 | `TestDiscount_CampaignMaxRedemptions` | Campaign respects max_redemptions | - -### New Tests Required - -#### Stacking Tests - -| # | Test | What It Verifies | -|---|---|---| -| 13 | `TestDiscount_Stacking_LoyaltyPlusTimeBased` | Loyalty 10% + time-based 5% = 15% off, two `booking_discounts` rows, two `payments` rows | -| 14 | `TestDiscount_Stacking_LoyaltyPlusMilestone` | Loyalty 10% + per-user milestone 15% = 25% off | -| 15 | `TestDiscount_Stacking_LoyaltyPlusAnniversary` | Loyalty 10% + anniversary 10% = 20% off | -| 16 | `TestDiscount_Stacking_AllThreeTypes` | Loyalty + time-based + anniversary = sum of all three | -| 17 | `TestDiscount_Stacking_MultipleMilestones` | Per-user + global + anniversary all apply on same booking | -| 18 | `TestDiscount_Stacking_LoyaltyPlusGlobalMilestone` | Loyalty + global milestone stack | -| 19 | `TestDiscount_Stacking_TimeBasedPlusMilestone` | Time-based campaign + per-user milestone stack | -| 20 | `TestDiscount_Stacking_DiscountAmountsSumCorrectly` | Verify total discount = sum of individual amounts, each calculated against original total | -| 21 | `TestDiscount_Stacking_MultiplePaymentRecords` | Each discount creates its own `payments` row with `method='discount'` | -| 22 | `TestDiscount_Stacking_MultipleBookingDiscountRows` | Each discount creates its own `booking_discounts` row with correct source/type | - -#### Edge Case Tests - -| # | Test | What It Verifies | -|---|---|---| -| 23 | `TestDiscount_ExpiredRedemptionDoesNotApply` | Redemption past `expires_at` is not applied | -| 24 | `TestDiscount_MultiplePendingRedemptions_UsesOldest` | If somehow multiple pending redemptions exist, oldest is used first | -| 25 | `TestDiscount_StampCountAboveTen` | If stamps somehow exceed 10 (e.g., admin manual set), redemption still created at check, `GREATEST(0, stamps-10)` handles reset | -| 26 | `TestDiscount_MixedFreeAndPaidServices` | Booking with one £0 service and one £50 service → total £50 → earns stamp | -| 27 | `TestDiscount_CampaignBoundaryStart` | Campaign start_date exactly at completion time → applies | -| 28 | `TestDiscount_CampaignBoundaryEnd` | Campaign end_date exactly at completion time → applies | -| 29 | `TestDiscount_CampaignExpiredDoesNotApply` | Campaign past end_date → no discount | -| 30 | `TestDiscount_CampaignDraftDoesNotApply` | Campaign with status='draft' → no discount | -| 31 | `TestDiscount_CampaignCancelledDoesNotApply` | Campaign with status='cancelled' → no discount | -| 32 | `TestDiscount_PriceOverrideRespected` | Admin overrides service price → discount calculated on overridden price | -| 33 | `TestDiscount_AnniversaryDedupWithStacking` | Anniversary dedup still works even when other discounts also apply | -| 34 | `TestDiscount_PerUserMilestoneDedupWithStacking` | Per-user milestone dedup still works with stacking | -| 35 | `TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking` | Global max_redemptions still respected with stacking | -| 36 | `TestDiscount_BestTimeBasedCampaignSelected` | Multiple active time-based campaigns → highest % is selected | -| 37 | `TestDiscount_FirstBookingEarnsStamp` | Brand new user's first completed paid booking earns stamp #1 | -| 38 | `TestDiscount_TenStampsCreatesRedemption` | Completing a booking that brings stamps to exactly 10 creates pending redemption | -| 39 | `TestDiscount_RedemptionAppliedBeforeStampIncrement` | On discounted booking: redemption applied first (stamps → 0), then +1 stamp earned | - -#### Campaign Status Tests - -| # | Test | What It Verifies | -|---|---|---| -| 40 | `TestCampaign_CreateAsDraft` | Creating campaign without status → defaults to 'draft' | -| 41 | `TestCampaign_ActivateDraft` | Update draft → active works | -| 42 | `TestCampaign_CompleteActive` | Update active → completed works | -| 43 | `TestCampaign_CancelActive` | Update active → cancelled works | -| 44 | `TestCampaign_RevertToDraft` | Update active → draft works (for re-editing before re-activation) | -| 45 | `TestCampaign_DraftDoesNotApplyDiscounts` | Draft campaign does not trigger discounts at completion | - -**Total: 45 tests** (12 existing + 33 new, with test #10 rewritten) - ---- - -## 4. `discount_eligible` — Scrap It - -### Problem Analysis - -The `discount_eligible BOOLEAN` column on `bookings` is **completely dead code**: -- Defined in schema (init-script.sql line 241) -- Listed in Technical Manual -- **Never read, written, or referenced** by any Go code, TypeScript, or handler -- Not in the `Booking` struct -- Not in any INSERT or UPDATE query - -### Why Pre-Assignment Is Fundamentally Flawed - -The original v1 plan proposed setting `discount_eligible = TRUE` at booking creation time if the user has a pending redemption or active campaign. This is **architecturally wrong** because: - -1. **Out-of-order completion**: User books A (Tuesday) then B (Monday). B completes first and gets the loyalty discount. A was marked "eligible" but the redemption is now consumed. Bad state. - -2. **Campaign changes**: Admin creates a campaign after a booking is made. Existing bookings aren't retroactively marked eligible. Or admin cancels a campaign — bookings still marked eligible won't get the discount. - -3. **Price overrides**: Admin overrides service prices after booking creation. The eligibility was calculated on original prices. - -4. **Multiple pending bookings**: User has 3 future bookings. All marked "eligible." Only the first to complete actually gets the discount. The other 2 show misleading eligibility. - -5. **Milestone unpredictability**: Per-user and global milestones depend on completion counts that change as other bookings complete. You can't predict at creation time whether a specific booking will hit the milestone. - -### Decision: Drop the Column - -```sql -ALTER TABLE bookings DROP COLUMN discount_eligible; -``` - -### What Replaces It - -**Nothing on the booking row.** Discount eligibility is computed entirely at completion time in `ProgressBookingHandler`. This is the correct approach because: - -- All data is final at completion (prices, services, user history) -- No race conditions between multiple pending bookings -- No stale state from campaign changes -- No misleading UI indicators - -**For the customer UI** ("you have a discount waiting"), query the source tables directly: -- `SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()` → "You have a 10% loyalty discount waiting for your next completed appointment" -- `SELECT name FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'time_based' AND start_date <= NOW() AND end_date >= NOW()` → "Summer Sale: 5% off all services this week!" - -These queries are already fast (indexed) and always reflect current truth. - -### File Changes - -| File | Change | -|---|---| -| `init-scripts/init-script.sql` | Remove `discount_eligible BOOLEAN NOT NULL DEFAULT FALSE` from bookings table (line 241) | -| `obsidian/Crussell/Technical Manual.md` | Remove `discount_eligible` from bookings column list | - ---- - -## 5. Campaign Status Lifecycle — Fix - -### Current State (broken) - -| Operation | DB enum allows | Code allows | Issue | -|---|---|---|---| -| **Create** | `draft` (default), `active`, `completed`, `cancelled` | Hardcoded `"active"` only | Cannot create as `draft` | -| **Update** | `draft`, `active`, `completed`, `cancelled` | `active`, `cancelled`, `completed` only | Cannot update to `draft` | -| **GET filter** | `draft`, `active`, `completed`, `cancelled` | `active`, `paused`, `cancelled`, `expired` | Phantom statuses; missing `draft`/`completed` | - -### Desired Lifecycle - -``` -draft → active → completed - ↑ ↓ - └── cancelled -``` - -- **Create**: Always creates as `draft`. Admin reviews and manually activates. -- **Activate**: `draft` → `active`. Campaign starts applying discounts. -- **Complete**: `active` → `completed`. Campaign stops applying (past its useful life). -- **Cancel**: Any → `cancelled`. Campaign permanently disabled. -- **Revert**: `active` → `draft`. Admin wants to edit and re-activate. - -### Implementation Changes - -**File**: `backend/handlers/admin/discount_campaigns.go` - -#### 5.1 CreateDiscountCampaign (line 310) - -Change hardcoded `"active"` to `"draft"`: - -```go -// BEFORE (line 310): -"active", - -// AFTER: -"draft", -``` - -#### 5.2 UpdateDiscountCampaign (lines 480-488) - -Add `"draft"` to the whitelist: - -```go -// BEFORE: -if *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" { - http.Error(w, "Status must be 'active', 'cancelled', or 'completed'", http.StatusBadRequest) - return -} - -// AFTER: -if *req.Status != "draft" && *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" { - http.Error(w, "Status must be 'draft', 'active', 'cancelled', or 'completed'", http.StatusBadRequest) - return -} -``` - -#### 5.3 GetDiscountCampaigns filter (line 81) - -Fix phantom statuses: - -```go -// BEFORE: -validStatuses := map[string]bool{"active": true, "paused": true, "cancelled": true, "expired": true} - -// AFTER: -validStatuses := map[string]bool{"draft": true, "active": true, "completed": true, "cancelled": true} -``` - -#### 5.4 Frontend Campaign Management — ALREADY DONE - -The `DiscountsManagement.svelte` component already implements the full draft lifecycle: - -- `Campaign` type includes `'draft'` in the status union (line 26) -- Status badge display handles all four statuses including `draft` (lines 290–293) -- "Activate" button exists for `c.status === 'draft'` (lines 368/373, 423/428) -- "Complete" button exists for `c.status === 'active'` (lines 378/383, 433/438) -- "Cancel" button exists for non-cancelled campaigns (lines 388/393, 443/448) -- Conditional at `c.status === 'active' || c.status === 'completed'` (lines 398, 453) — likely "Revert to Draft" - -**No frontend changes needed.** The frontend is already aligned with the desired lifecycle. Only the backend changes in Phase 2 are required. - -### File Changes - -| File | Change | -|---|---| -| `backend/handlers/admin/discount_campaigns.go` | Fix create default, update whitelist, GET filter | -| ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Update status buttons for draft lifecycle~~ — **Already done** | - ---- - -## 6. Square Payments — Reality Check - -### Current State - -Square is **stubbed locally** via build tags (`//go:build !prod` in `internal/square/dev.go`). The dev mock simulates Square Terminal API responses without hitting real Square servers. - -**No real-world Square integration has been tested.** The prod stub (`internal/square/prod.go`) is a placeholder. - -### Impact on Loyalty/Discount System - -The loyalty/discount system is **independent of Square**. Discounts create `payments` rows with `payment_method = 'discount'` — this is a bookkeeping entry, not a Square transaction. The actual payment collection (cash, card via Square Terminal, etc.) happens separately. - -**No changes needed** to the loyalty/discount system for Square. When real Square is wired up, the discount payments will coexist with Square payments on the same booking. - -### Future Consideration (not in scope) - -When Square is live: -- Online deposit payments will create `payments` rows with `payment_method = 'square_terminal'` or `'square_web'` -- The discount `payments` rows reduce the remaining balance -- The Square payment amount should be `bookingTotal - sum(all discount amounts) - sum(other payments)` -- This is already how the system works — `amount_due = total - sum(completed payments)` - ---- - -## 7. Implementation Plan - -### Phase 1: Drop `discount_eligible` (trivial) - -| Step | File | Change | -|---|---|---| -| 1.1 | `init-scripts/init-script.sql` | Remove `discount_eligible` column from bookings table | -| 1.2 | `obsidian/Crussell/Technical Manual.md` | Remove from column list | - -### Phase 2: Fix Campaign Status Lifecycle - -| Step | File | Change | -|---|---|---| -| 2.1 | `backend/handlers/admin/discount_campaigns.go` | Change create default to `"draft"` (line 310) | -| 2.2 | `backend/handlers/admin/discount_campaigns.go` | Add `"draft"` to update whitelist (line 481) | -| 2.3 | `backend/handlers/admin/discount_campaigns.go` | Fix GET filter to match DB enum (line 81) | -| ~~2.4~~ | ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Update status buttons for draft lifecycle~~ — **Already done** | - -### Phase 3: Implement Discount Stacking - -| Step | File | Change | -|---|---|---| -| 3.1 | `backend/handlers/bookings/bookings.go` | Remove `hasLoyaltyDiscount` guard (lines 2066-2069) | -| 3.2 | `backend/handlers/bookings/bookings.go` | Remove `hasDiscount` guard (lines 2099-2102) | -| 3.3 | `backend/handlers/bookings/bookings.go` | Remove `milestoneCampaignID == ""` guards (lines 2130, 2158) | -| 3.4 | `backend/handlers/bookings/bookings.go` | Rewrite `TestDiscount_LoyaltyPriority` to assert stacking | - -### Phase 4: Expanded Test Suite - -| Step | File | Change | -|---|---|---| -| 4.1 | `backend/handlers/bookings/discount_test.go` | Add stacking tests (#13-22) | -| 4.2 | `backend/handlers/bookings/discount_test.go` | Add edge case tests (#23-39) | -| 4.3 | `backend/handlers/bookings/discount_test.go` | Add campaign status tests (#40-45) | -| 4.4 | `backend/handlers/bookings/discount_test.go` | Rewrite `TestDiscount_LoyaltyPriority` | - -### Phase 5: Frontend Updates - -| Step | File | Change | -|---|---|---| -| ~~5.1~~ | ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Draft lifecycle buttons~~ — **Already done** | -| 5.2 | `frontend/src/routes/account/+page.svelte` | No changes needed (already shows stamp card correctly) | - ---- - -## 8. File Change Summary - -| File | Change Type | Phase | -|---|---|---| -| `init-scripts/init-script.sql` | Remove `discount_eligible` column | 1 | -| `obsidian/Crussell/Technical Manual.md` | Remove `discount_eligible` reference | 1 | -| `backend/handlers/admin/discount_campaigns.go` | Fix status lifecycle (3 changes) | 2 | -| ~~`frontend/src/lib/components/admin/DiscountsManagement.svelte`~~ | ~~Draft lifecycle UI~~ — **Already done** | ~~2, 5~~ | -| `backend/handlers/bookings/bookings.go` | Remove mutual-exclusivity guards (4 changes) | 3 | -| `backend/handlers/bookings/discount_test.go` | Rewrite 1 test, add 33 new tests, add new helpers | 4 | - ---- - -## 9. Risks & Mitigations - -| Risk | Mitigation | -|---|---| -| Stacking produces unexpectedly large discounts | Each discount is a separate `booking_discounts` row — easy to audit. Consider adding a total discount cap (e.g., 50%) as a future safeguard. | -| Multiple `payments` rows with `method='discount'` confuse payment reconciliation | Each has a distinct `booking_discounts.source_id` linking to the specific redemption/campaign. Clear audit trail. | -| Removing mutual-exclusivity guards changes behaviour for existing bookings | Only affects future completions. Existing `booking_discounts` rows are unchanged. | -| Campaign status change from `"active"` default to `"draft"` breaks existing campaigns | No existing campaigns in production (system is local-only). For dev DB, run migration to update any existing `"active"` campaigns that should be `"draft"`. | -| `discount_eligible` column removal breaks something | Verified: zero references in any Go, TypeScript, or SQL code outside the schema definition itself. Safe to drop. | -| Test suite expansion reveals bugs in stacking logic | That's the point — tests should catch issues before production. | diff --git a/.sisyphus/plans/45-loyalty-discount-system.md b/.sisyphus/plans/45-loyalty-discount-system.md deleted file mode 100644 index 426dce4..0000000 --- a/.sisyphus/plans/45-loyalty-discount-system.md +++ /dev/null @@ -1,705 +0,0 @@ -# Plan: #45 Loyalty Stamp Redemption + Discount/Sales System - -## Context - -Crussell already has: -- `loyalty_stamps` INT column on `users` table (default 0) -- Stamp award logic in `ProgressBookingHandler` (`bookings.go` lines 1566-1579): +1 stamp on booking completion, with 1-day dedup -- `payment_method` enum already includes `'discount'` -- Account page shows "X stamps until 10% off" and "Congratulations! You've earned 10% off!" when stamps >= 10 -- No existing promo code, discount campaign, or redemption infrastructure -- Customer Relationship view shows `totalVisits`, `firstVisitDate`, `customerFor` — useful for milestone eligibility checks - -## User Requirements - -1. **Loyalty auto-redeem**: When stamps reach 10, next *completed* booking gets 10% off automatically -2. **Discount applied at completion**, not booking creation (booking may be cancelled) -3. **Customer UI**: No exact discount prices shown (too many variables). When card is full, show info that next appointment is discounted -4. **Email notification**: When card becomes full, email customer informing them next appointment is discounted (TODO — email system TBD) -5. **Discount campaigns**: Admin can create time-based sales (e.g., "Easter Sale 5% off weekend", "Opening week 10% off first appointment") -6. **Milestone discounts**: Admin can create milestone-based campaigns: - - **Per-user milestones**: "Xth booking" for a specific customer (e.g., 100th booking → 15% off) - - **Global milestones**: "Xth booking overall" across all customers (e.g., our 1000th booking → 10% off for that customer) - - **Anniversary milestones**: First booking after hitting 6 months, 1 year, 2 years since customer's first booking -7. **Discount tracking**: Need a system for managing discounts and sales broadly - -## Design Decisions - -### Campaign Types - -Two distinct campaign types with different eligibility logic: - -**Time-Based Campaigns** (existing plan): -- Eligibility checked at booking *creation* (date range match) -- Applied at booking *completion* -- Examples: "Easter Sale 5% off", "Opening week 10% off first appointment" - -**Milestone Campaigns** (new): -- Eligibility checked at booking *completion* (when the milestone is actually reached) -- Applied immediately upon completion -- Three sub-types: - - **Per-user milestone**: "100th booking" — counts completed bookings per user. When user hits the target, discount applies. - - **Global milestone**: "1000th booking overall" — counts all completed bookings across all users. When the global counter hits the target, that booking gets the discount. - - **Anniversary milestone**: "6 months since first booking" — checks time elapsed since user's first completed booking. First booking after crossing the threshold gets the discount. - -### Discount Application Timing - -- **Loyalty discount**: Applied at booking *completion* (status → "completed"). At that point, calculate 10% of booking total, create a `discount` payment_method record for the discount amount, deduct stamps. -- **Time-based campaign discount**: Applied at booking *completion*. Eligibility checked at booking creation (date range), but actual application happens at completion. -- **Milestone campaign discount**: Applied at booking *completion*. Eligibility is only determinable at completion (that's when the milestone is reached). - -### Why Completion-Time Application? - -If applied at booking creation: -- Customer books → discount reserved → customer cancels → discount lost or needs rollback -- Multiple pre-booked appointments create ambiguity about which one gets the discount - -At completion: -- Only actual completed appointments get discounts -- No cancellation edge cases -- Clean accounting - -### Global Booking Counter - -For "Xth booking overall" milestones, we query `SELECT COUNT(*) FROM bookings WHERE status = 'completed'` at completion time. If the count is 99, the current booking is the 100th. No dedicated counter table — the query is fast enough with an index on `status`. - -### Anniversary Milestone Logic - -For "6 months since first booking" campaigns: -- At booking completion: check if user's first completed booking was ≥ 6 months ago AND they have no prior anniversary discount of this type applied -- Use `booking_discounts` table to check if this specific anniversary milestone has already been claimed by this user -- Only applies once per milestone per user - -### Campaign "First Appointment" Logic - -For campaigns like "10% off first appointment": -- At booking creation: check if user has any existing completed bookings. If not, mark booking as eligible for campaign discount. -- At completion: apply the discount. - -### Customer UI Principle - -The account page should NOT show "You'll save £12.50" because: -- Services may change between booking and completion -- Admin may override prices -- Multiple bookings may be pending - -Instead: "Your next completed appointment will receive a 10% loyalty discount." - -For milestone campaigns, the customer doesn't need to know in advance — the discount is a surprise applied at completion. Admin can optionally notify via email (TODO). - ---- - -## Database Schema Changes - -### 1. `loyalty_redemptions` Table - -Tracks when stamps are converted into a pending discount. - -```sql -CREATE TABLE loyalty_redemptions ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stamps_redeemed INT NOT NULL DEFAULT 10, - status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending', 'applied', 'expired' - applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, - redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - applied_at TIMESTAMPTZ, - expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months') -); - -CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id); -CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status); -``` - -**Lifecycle**: -1. When stamps reach 10 → auto-create row with `status = 'pending'` -2. When booking completes with redemption → `status = 'applied'`, `applied_to_booking_id` set -3. After 6 months unused → `status = 'expired'` (via cron or on-demand check) - -### 2. `discount_campaigns` Table - -Admin-managed promotional campaigns (time-based and milestone-based). - -```sql -CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone'); -CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary'); -CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years'); -CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only'); -CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); - -CREATE TABLE discount_campaigns ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'), - name VARCHAR(100) NOT NULL, -- "Easter Sale 2026" or "100th Booking Milestone" - description TEXT, -- "5% off all services this weekend" - campaign_type campaign_type NOT NULL DEFAULT 'time_based', - discount_percent NUMERIC(5,2) NOT NULL, -- 5.00, 10.00, etc. - - -- Time-based fields (used when campaign_type = 'time_based') - scope discount_campaign_scope, -- 'all_bookings', 'first_booking_only', etc. - start_date TIMESTAMPTZ, -- NULL for milestone campaigns - end_date TIMESTAMPTZ, -- NULL for milestone campaigns - - -- Milestone fields (used when campaign_type = 'milestone') - milestone_type milestone_type, -- 'per_user_booking_count', 'global_booking_count', 'anniversary' - milestone_value INT, -- Target number (e.g., 100 for 100th booking, 6 for 6 months) - milestone_unit milestone_unit, -- 'bookings', 'months', 'years' - - -- Common fields - status discount_campaign_status NOT NULL DEFAULT 'draft', - max_redemptions INT, -- NULL = unlimited. For global milestones, this is the total count cap. - times_redeemed INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, - - CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)), - CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100), - CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL)) -); - -CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL; -CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status); -CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type); -``` - -**Milestone Examples**: - -| Campaign | campaign_type | milestone_type | milestone_value | milestone_unit | -|----------|--------------|----------------|-----------------|----------------| -| "100th booking for each customer" | `milestone` | `per_user_booking_count` | 100 | `bookings` | -| "Our 1000th booking overall" | `milestone` | `global_booking_count` | 1000 | `bookings` | -| "6 months since first visit" | `milestone` | `anniversary` | 6 | `months` | -| "1 year anniversary" | `milestone` | `anniversary` | 1 | `years` | -| "Easter Sale 5% off" | `time_based` | NULL | NULL | NULL | - -### 2b. Global Booking Count (no dedicated table) - -For "Xth booking overall" milestones, we query at completion time: - -```sql -SELECT COUNT(*) FROM bookings WHERE status = 'completed'; -``` - -If the count is 99, the booking being completed is the 100th. No dedicated counter table needed — the query is fast enough (indexed on `status`) and avoids a separate table. At completion, we check all active `global_booking_count` milestone campaigns to see if the current count matches any milestone value. - -### 3. `booking_discounts` Table - -Tracks which discounts were applied to which bookings. - -```sql -CREATE TABLE booking_discounts ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), - booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - discount_source VARCHAR(30) NOT NULL, -- 'loyalty' or 'campaign' - source_id CHAR(12), -- loyalty_redemption_id or campaign_id (nullable for loyalty) - campaign_type campaign_type, -- 'time_based' or 'milestone' (NULL for loyalty) - milestone_type milestone_type, -- NULL unless campaign_type = 'milestone' - discount_percent NUMERIC(5,2) NOT NULL, - original_total NUMERIC(10,2) NOT NULL, -- booking total before discount - discount_amount NUMERIC(10,2) NOT NULL, - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id); -CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id); -CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id); -CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id); -``` - -The `user_id` and `milestone_type` columns enable anniversary dedup: check if a user already has a `booking_discounts` row with `milestone_type = 'anniversary'` and `source_id = ` before applying. - -### 4. Alter `bookings` Table - -```sql -ALTER TABLE bookings ADD COLUMN discount_eligible BOOLEAN NOT NULL DEFAULT FALSE; -``` - -This flag is set at booking creation time if: -- User has a pending loyalty redemption, OR -- An active campaign applies to this booking - ---- - -## Backend Implementation - -### Phase 1: Loyalty Auto-Redemption - -#### 1.1 Stamp Threshold Detection (modify `ProgressBookingHandler`) - -**File**: `backend/handlers/bookings/bookings.go` (around line 1566) - -Current code increments stamps. After increment, check if stamps == 10: - -```go -// After: SET loyalty_stamps = loyalty_stamps + 1 -// Add: -var newStampCount int -err = db.DB.QueryRow(r.Context(), ` - SELECT loyalty_stamps FROM users WHERE id = $1 -`, booking.User.ID).Scan(&newStampCount) - -if newStampCount == 10 { - // Auto-create pending redemption - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) - VALUES ($1, 10, 'pending', NOW()) - `, booking.User.ID) - - // TODO: Send email notification when email system is wired - // Email: "Congratulations! Your loyalty card is full. Your next completed appointment will receive 10% off." - // log.Printf("TODO: Send loyalty card full email to user %s", booking.User.ID) -} -``` - -#### 1.2 Booking Creation — Mark Discount Eligibility - -**File**: `backend/handlers/bookings/bookings.go` (`CreateBookingHandler`, ~line 1267) -**File**: `backend/handlers/bookings/manage.go` (`AdminCreateBookingForUserHandler`, ~line 621) - -Before INSERT into bookings, check for pending loyalty redemption or active campaigns: - -```go -// Check if user has pending loyalty redemption -var hasPendingRedemption bool -err := db.DB.QueryRow(r.Context(), ` - SELECT EXISTS( - SELECT 1 FROM loyalty_redemptions - WHERE user_id = $1 AND status = 'pending' - AND expires_at > NOW() - ) -`, userID).Scan(&hasPendingRedemption) - -// Check for active campaigns -var activeCampaigns int -err = db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) FROM discount_campaigns - WHERE status = 'active' - AND start_date <= NOW() - AND end_date >= NOW() - AND (scope = 'all_bookings' - OR (scope = 'first_booking_only' AND NOT EXISTS( - SELECT 1 FROM bookings b WHERE b.user_id = $1 AND b.status = 'completed' - )) - OR (scope = 'new_customers_only' AND NOT EXISTS( - SELECT 1 FROM bookings b WHERE b.user_id = $1 - )) - ) - AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) -`, userID).Scan(&activeCampaigns) - -discountEligible := hasPendingRedemption || activeCampaigns > 0 -``` - -Then include `discount_eligible` in the bookings INSERT. - -#### 1.3 Booking Completion — Apply Discount - -**File**: `backend/handlers/bookings/bookings.go` (`ProgressBookingHandler`, ~line 1537) - -After stamp award, before deposit reduction: - -```go -if req.Status == "completed" { - // ... existing stamp award logic ... - - // Calculate booking total - var bookingTotal float64 - err := db.DB.QueryRow(r.Context(), ` - SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) - FROM booking_services bs - JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = $1 - `, bookingID).Scan(&bookingTotal) - - // Check for pending loyalty redemption - var redemptionID string - err = db.DB.QueryRow(r.Context(), ` - SELECT id FROM loyalty_redemptions - WHERE user_id = $1 AND status = 'pending' - AND expires_at > NOW() - ORDER BY redeemed_at ASC - LIMIT 1 - `, booking.User.ID).Scan(&redemptionID) - - if redemptionID != "" { - discountAmount := roundTo2(bookingTotal * 0.10) - - // Record discount - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'loyalty', $3, NULL, NULL, 10.00, $4, $5) - `, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount) - - // Create discount payment record - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - - // Update redemption status - _, err = db.DB.Exec(r.Context(), ` - UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW() - WHERE id = $2 - `, bookingID, redemptionID) - - // Deduct stamps - _, err = db.DB.Exec(r.Context(), ` - UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1 - `, booking.User.ID) - } - - // Check for active time-based campaign discount - var campaignID string - var campaignPercent float64 - err = db.DB.QueryRow(r.Context(), ` - SELECT id, discount_percent FROM discount_campaigns - WHERE status = 'active' AND campaign_type = 'time_based' - AND start_date <= NOW() AND end_date >= NOW() - AND (scope = 'all_bookings' OR ...) - AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) - LIMIT 1 - `).Scan(&campaignID, &campaignPercent) - - if campaignID != "" && redemptionID == "" { - // Only apply campaign if no loyalty discount was applied - discountAmount := roundTo2(bookingTotal * campaignPercent / 100) - - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) - `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount) - - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - - _, err = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, campaignID) - } - - // Check for milestone campaigns (applied at completion time) - if redemptionID == "" && campaignID == "" { - // Per-user booking count milestone - var userBookingCount int - db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount) - - var milestoneCampaignID string - var milestonePercent float64 - db.DB.QueryRow(r.Context(), ` - SELECT id, discount_percent FROM discount_campaigns - WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' - AND milestone_value = $1 - AND NOT EXISTS ( - SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id - ) - `, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent) - - if milestoneCampaignID != "" { - // Apply per-user milestone discount - discountAmount := roundTo2(bookingTotal * milestonePercent / 100) - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) - `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount) - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - _, err = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, milestoneCampaignID) - } - - // Global booking count milestone - if milestoneCampaignID == "" { - var globalCount int - db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) - // globalCount is N-1 (current booking not yet marked completed), so this booking is the (globalCount+1)th - var globalCampaignID string - var globalPercent float64 - db.DB.QueryRow(r.Context(), ` - SELECT id, discount_percent FROM discount_campaigns - WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' - AND milestone_value = $1 - AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) - `, globalCount+1).Scan(&globalCampaignID, &globalPercent) - - if globalCampaignID != "" { - discountAmount := roundTo2(bookingTotal * globalPercent / 100) - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) - `, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount) - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - _, err = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, globalCampaignID) - } - } - - // Anniversary milestone - if milestoneCampaignID == "" && globalCampaignID == "" { - var firstVisitDate time.Time - db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate) - if !firstVisitDate.IsZero() { - // Check all active anniversary milestones - rows, _ := db.DB.Query(r.Context(), ` - 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' - ) - `, booking.User.ID) - defer rows.Close() - for rows.Next() { - var annID string - var annPercent float64 - var annValue int - var annUnit string - rows.Scan(&annID, &annPercent, &annValue, &annUnit) - - elapsed := time.Since(firstVisitDate) - var matches bool - switch annUnit { - case "months": - matches = int(elapsed.Hours()/(30*24)) >= annValue - case "years": - matches = int(elapsed.Hours()/(365.25*24)) >= annValue - } - - if matches { - discountAmount := roundTo2(bookingTotal * annPercent / 100) - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) - `, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount) - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - _, err = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, annID) - break // Only one anniversary milestone per completion - } - } - } - } - } - - // ... existing deposit reduction logic ... -} -``` - -### Phase 2: Discount Campaign CRUD - -#### 2.1 New Handler File - -**File**: `backend/handlers/admin/discount_campaigns.go` - -Endpoints: -- `GET /api/admin/discount-campaigns` — list all campaigns -- `POST /api/admin/discount-campaigns` — create campaign -- `PUT /api/admin/discount-campaigns/{id}` — update campaign (status, dates, etc.) -- `DELETE /api/admin/discount-campaigns/{id}` — cancel campaign -- `GET /api/admin/discount-campaigns/{id}/stats` — redemption stats - -Handler structure follows existing admin pattern (like `handlers/admin/services.go`). - -#### 2.2 Router Wiring - -**File**: `backend/main.go` (~line 232, in admin routes) - -```go -r.Group(func(r chi.Router) { - r.Use(middleware.RequireAuth) - r.Use(middleware.RequireAdmin) - // ... existing routes ... - r.Get("/discount-campaigns", admin.GetDiscountCampaigns) - r.Post("/discount-campaigns", admin.CreateDiscountCampaign) - r.Put("/discount-campaigns/{id}", admin.UpdateDiscountCampaign) - r.Delete("/discount-campaigns/{id}", admin.DeleteDiscountCampaign) - r.Get("/discount-campaigns/{id}/stats", admin.GetCampaignStats) -}) -``` - -### Phase 3: Frontend — Customer Account Page - -**File**: `frontend/src/routes/account/+page.svelte` (lines 826-843) - -Current: Shows stamp count and "Congratulations! You've earned 10% off!" - -Changes: -- When stamps >= 10: "Your loyalty card is full! Your next completed appointment will receive 10% off." -- When stamps < 10: Keep existing countdown -- Add "Pending discount" indicator if user has a pending redemption: "You have a 10% discount waiting for your next completed appointment." -- NO exact price amounts shown - -### Phase 4: Frontend — Admin Campaign Management - -**File**: New `frontend/src/routes/admin/discounts/+page.svelte` - -Admin UI for: -- List campaigns with status badges (draft/active/completed/cancelled) -- Create campaign form: name, description, discount %, scope, date range, max redemptions -- Campaign stats: times redeemed, revenue impact -- Quick actions: activate, cancel - -Add link to admin dashboard/sidebar. - -### Phase 5: Email Notification (TODO Placeholder) - -**File**: `backend/handlers/bookings/bookings.go` (in stamp award section) - -```go -// TODO: Email notification — requires SMTP provider (E5) -// When: loyalty_stamps reaches 10 -// To: user's email -// Subject: "Your loyalty reward is ready! 🎉" -// Body: "Congratulations! You've collected 10 stamps. Your next completed appointment will receive 10% off automatically." -// -// When email system is wired (E5), call: -// emailService.SendLoyaltyRewardEmail(user.Email, user.FirstName) -``` - ---- - -## Migration SQL - -```sql --- Run as part of database migration - --- 1. Create loyalty_redemptions table -CREATE TABLE loyalty_redemptions ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stamps_redeemed INT NOT NULL DEFAULT 10, - status VARCHAR(20) NOT NULL DEFAULT 'pending', - applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, - redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - applied_at TIMESTAMPTZ, - expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months') -); - -CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id); -CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status); - --- 2. Create discount_campaigns table with milestone support -CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone'); -CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary'); -CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years'); -CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only'); -CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); - -CREATE TABLE discount_campaigns ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'), - name VARCHAR(100) NOT NULL, - description TEXT, - campaign_type campaign_type NOT NULL DEFAULT 'time_based', - discount_percent NUMERIC(5,2) NOT NULL, - scope discount_campaign_scope, - start_date TIMESTAMPTZ, - end_date TIMESTAMPTZ, - milestone_type milestone_type, - milestone_value INT, - milestone_unit milestone_unit, - status discount_campaign_status NOT NULL DEFAULT 'draft', - max_redemptions INT, - times_redeemed INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, - CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)), - CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100), - CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL)) -); - -CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL; -CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status); -CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type); - --- 3. Create booking_discounts table with milestone tracking -CREATE TABLE booking_discounts ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), - booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - discount_source VARCHAR(30) NOT NULL, - source_id CHAR(12), - campaign_type campaign_type, - milestone_type milestone_type, - discount_percent NUMERIC(5,2) NOT NULL, - original_total NUMERIC(10,2) NOT NULL, - discount_amount NUMERIC(10,2) NOT NULL, - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id); -CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id); -CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id); -CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id); - --- 4. Add discount_eligible to bookings -ALTER TABLE bookings ADD COLUMN discount_eligible BOOLEAN NOT NULL DEFAULT FALSE; - --- 5. Add trigger for discount_campaigns updated_at -CREATE TRIGGER trigger_update_discount_campaigns_timestamp - BEFORE UPDATE ON discount_campaigns - FOR EACH ROW - EXECUTE FUNCTION update_business_settings_timestamp(); -``` - ---- - -## File Change Summary - -| File | Change | -|------|--------| -| `init-scripts/init-script.sql` | Add 3 new tables + 5 new enums + 1 column alter | -| `backend/handlers/bookings/bookings.go` | Modify ProgressBookingHandler (stamp threshold + discount apply for loyalty/time-based/milestone), modify CreateBookingHandler (discount_eligible check for time-based campaigns) | -| `backend/handlers/bookings/manage.go` | Modify AdminCreateBookingForUserHandler (discount_eligible check) | -| `backend/handlers/admin/discount_campaigns.go` | NEW: CRUD handlers for discount campaigns (supports both time-based and milestone types) | -| `backend/main.go` | Wire discount campaign routes | -| `frontend/src/routes/account/+page.svelte` | Update loyalty display text, add pending discount indicator | -| `frontend/src/routes/admin/discounts/+page.svelte` | NEW: Admin campaign management UI (campaign type selector, milestone fields) | -| `frontend/src/lib/types/` | Add TypeScript types for loyalty_redemptions, discount_campaigns, booking_discounts, milestone enums | - ---- - -## Testing Plan - -1. **Unit**: Test discount calculation (rounding edge cases) -2. **Integration**: - - Complete booking with pending redemption → verify discount applied, stamps deducted - - Complete booking with active time-based campaign → verify discount applied, times_redeemed incremented - - Cancel booking with discount_eligible=true → verify no discount applied, redemption stays pending - - Multiple bookings pending → only first completed gets loyalty discount - - **Per-user milestone**: Create 99 completed bookings for a user, set up "100th booking" milestone campaign, complete 100th → verify discount applied - - **Global milestone**: Seed 99 completed bookings globally, set up "100th booking overall" milestone, complete next booking → verify discount applied - - **Anniversary milestone**: Create user with first booking 6+ months ago, set up "6 months" anniversary campaign, complete next booking → verify discount applied. Complete another → verify NO second discount (dedup works) -3. **Edge cases**: - - User has 10 stamps but redemption expired → no discount - - Campaign max_redemptions reached → no discount - - Both loyalty + campaign eligible → loyalty takes priority (only one discount per booking) - - Booking total is £0 (free service) → discount is £0, no payment created - - Two milestone campaigns with same value → both apply? No, single discount per booking, pick highest percent - ---- - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Discount + VAT interaction | Discount payment is separate from service payment. VAT calculated on net service amount. Discount payment has no VAT. | -| Admin overrides price after booking | Discount calculated at completion time using actual booking_services override prices — always accurate | -| Campaign date timezone issues | All dates stored as TIMESTAMPTZ, compared with NOW() — consistent | -| Double-discount (loyalty + campaign) | Priority: loyalty first. If loyalty applied, campaign skipped. Single discount per booking. | -| Stamps deducted but booking fails | All operations in single transaction — rollback on failure | -| Global booking count race condition | `SELECT COUNT(*)` at completion time is accurate — the booking being completed hasn't been marked completed yet, so count is N-1. The current booking is the Nth. | -| Anniversary milestone double-apply | `booking_discounts` table tracks per-user per-milestone applications. Query before applying to ensure not already claimed. | -| Per-user milestone double-apply | Same dedup via `booking_discounts` — check if user already has a discount for this specific milestone campaign. | diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index 67f4954..b96c8a4 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -3121,3 +3121,89 @@ func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) { } } } + +func TestGetAdminBooking_WithDiscounts(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + // Create completed booking + bookingTime := time.Now().Add(-24 * time.Hour) + bookingID := createCompletedBookingWithTimeForAdmin(t, userID, serviceID, bookingTime, 50.00) + + // Create a discount campaign and apply it + var campaignID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() + INTERVAL '2 days') + RETURNING id + `, "Admin Test Campaign").Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00) + `, bookingID, userID, campaignID) + if err != nil { + t.Fatalf("failed to create booking discount: %v", err) + } + + // Call GetAdminBookingHandler + handler := http.HandlerFunc(bookings.GetAdminBookingHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking bookings.Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse booking: %v", err) + } + + if len(booking.Discounts) != 1 { + t.Fatalf("expected 1 discount, got %d", len(booking.Discounts)) + } + + d := booking.Discounts[0] + if d.CampaignName == nil || *d.CampaignName != "Admin Test Campaign" { + t.Errorf("expected campaign name 'Admin Test Campaign', got %v", d.CampaignName) + } + if d.DiscountAmount != 5.00 { + t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount) + } +} + +func createCompletedBookingWithTimeForAdmin(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { + t.Helper() + var bookingID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create completed booking: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id, override_price) + VALUES ($1, $2, $3) + `, bookingID, serviceID, price) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + return bookingID +} diff --git a/backend/handlers/admin/discount_campaigns.go b/backend/handlers/admin/discount_campaigns.go index 0879eef..e42ffd9 100644 --- a/backend/handlers/admin/discount_campaigns.go +++ b/backend/handlers/admin/discount_campaigns.go @@ -78,7 +78,7 @@ type CampaignStats struct { func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) { statusFilter := r.URL.Query().Get("status") if statusFilter != "" { - validStatuses := map[string]bool{"active": true, "paused": true, "cancelled": true, "expired": true} + validStatuses := map[string]bool{"draft": true, "active": true, "completed": true, "cancelled": true} if !validStatuses[statusFilter] { http.Error(w, "invalid status filter", http.StatusBadRequest) return @@ -307,7 +307,7 @@ func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) { req.MilestoneValue, req.MilestoneUnit, req.MaxRedemptions, - "active", + "draft", createdBy, ).Scan( &campaign.ID, @@ -478,8 +478,8 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) { argNum++ } if req.Status != nil { - if *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" { - http.Error(w, "Status must be 'active', 'cancelled', or 'completed'", http.StatusBadRequest) + if *req.Status != "draft" && *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" { + http.Error(w, "Status must be 'draft', 'active', 'cancelled', or 'completed'", http.StatusBadRequest) return } query += ", status = $" + strconv.Itoa(argNum) diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index a87b2a0..a35defe 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -1,6 +1,7 @@ package bookings import ( + "context" "crussell/db" "crussell/handlers/notifications" "crussell/handlers/scheduling" @@ -52,12 +53,79 @@ type Booking struct { User *UserSummary `json:"user,omitempty"` Services []BookingService `json:"services,omitempty"` Payments []Payment `json:"payments,omitempty"` + Discounts []BookingDiscount `json:"discounts,omitempty"` TotalAmount float64 `json:"total_amount"` AmountPaid float64 `json:"amount_paid"` AmountDue float64 `json:"amount_due"` DurationMinutes int `json:"duration_minutes"` } +type BookingDiscount struct { + ID string `json:"id"` + BookingID string `json:"booking_id"` + UserID string `json:"user_id"` + DiscountSource string `json:"discount_source"` + SourceID *string `json:"source_id,omitempty"` + CampaignName *string `json:"campaign_name,omitempty"` + CampaignType *string `json:"campaign_type,omitempty"` + MilestoneType *string `json:"milestone_type,omitempty"` + DiscountPercent float64 `json:"discount_percent"` + OriginalTotal float64 `json:"original_total"` + DiscountAmount float64 `json:"discount_amount"` + AppliedAt time.Time `json:"applied_at"` +} + +func fetchBookingDiscounts(ctx context.Context, bookingID string) ([]BookingDiscount, error) { + discountRows, err := db.DB.Query(ctx, ` + SELECT + bd.id, bd.booking_id, bd.user_id, bd.discount_source, bd.source_id, + bd.campaign_type, bd.milestone_type, bd.discount_percent, bd.original_total, bd.discount_amount, bd.applied_at, + c.name AS campaign_name + FROM booking_discounts bd + LEFT JOIN discount_campaigns c ON bd.discount_source = 'campaign' AND bd.source_id = c.id + WHERE bd.booking_id = $1 + ORDER BY bd.applied_at ASC + `, bookingID) + if err != nil { + return nil, err + } + defer discountRows.Close() + + var discounts []BookingDiscount + for discountRows.Next() { + var d BookingDiscount + var campaignName sql.NullString + var sourceID sql.NullString + var campaignType sql.NullString + var milestoneType sql.NullString + if err := discountRows.Scan( + &d.ID, &d.BookingID, &d.UserID, &d.DiscountSource, &sourceID, + &campaignType, &milestoneType, &d.DiscountPercent, &d.OriginalTotal, &d.DiscountAmount, &d.AppliedAt, + &campaignName, + ); err != nil { + return nil, err + } + if sourceID.Valid { + s := sourceID.String + d.SourceID = &s + } + if campaignName.Valid && campaignName.String != "" { + c := campaignName.String + d.CampaignName = &c + } + if campaignType.Valid && campaignType.String != "" { + c := campaignType.String + d.CampaignType = &c + } + if milestoneType.Valid && milestoneType.String != "" { + m := milestoneType.String + d.MilestoneType = &m + } + discounts = append(discounts, d) + } + return discounts, nil +} + // populateDepositFields sets the computed deposit fields on a Booking. // It must be called after TotalAmount, AmountPaid, and StartTime are already set. // @@ -884,6 +952,14 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { booking.AmountDue = totalAmount - amountPaid populateDepositFields(&booking, depositRequired, preStartAmountPaid) + discounts, err := fetchBookingDiscounts(r.Context(), bookingID) + if err != nil { + log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + booking.Discounts = discounts + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(booking); err != nil { @@ -2063,141 +2139,127 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } if bookingTotal > 0 { - var hasLoyaltyDiscount bool - _ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount) + var campaignID string + var campaignPercent float64 + if err := db.DB.QueryRow(r.Context(), ` + 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 + `).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" { + discountAmount := roundTo2(bookingTotal * campaignPercent / 100) - if !hasLoyaltyDiscount { - var campaignID string - var campaignPercent float64 - if err := db.DB.QueryRow(r.Context(), ` - 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 - `).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" { - discountAmount := roundTo2(bookingTotal * campaignPercent / 100) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) + `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) - `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - - _, _ = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, campaignID) - } + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, campaignID) } } if bookingTotal > 0 { - var hasDiscount bool - _ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount) + var userBookingCount int + _ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount) - if !hasDiscount { - var userBookingCount int - _ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount) + var milestoneCampaignID string + var milestonePercent float64 + _ = db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' + AND milestone_value = $1 + AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id) + `, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent) - var milestoneCampaignID string - var milestonePercent float64 - _ = db.DB.QueryRow(r.Context(), ` - SELECT id, discount_percent FROM discount_campaigns - WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' - AND milestone_value = $1 - AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id) - `, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent) + if milestoneCampaignID != "" { + discountAmount := roundTo2(bookingTotal * milestonePercent / 100) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) + `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, milestoneCampaignID) + } - if milestoneCampaignID != "" { - discountAmount := roundTo2(bookingTotal * milestonePercent / 100) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) - `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - _, _ = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, milestoneCampaignID) - } + var globalCount int + _ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) + var globalCampaignID string + var globalPercent float64 + _ = db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' + AND milestone_value = $1 + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + `, globalCount).Scan(&globalCampaignID, &globalPercent) - if milestoneCampaignID == "" { - var globalCount int - _ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) - var globalCampaignID string - var globalPercent float64 - _ = db.DB.QueryRow(r.Context(), ` - SELECT id, discount_percent FROM discount_campaigns - WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' - AND milestone_value = $1 - AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) - `, globalCount).Scan(&globalCampaignID, &globalPercent) + if globalCampaignID != "" { + discountAmount := roundTo2(bookingTotal * globalPercent / 100) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) + `, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, globalCampaignID) + } - if globalCampaignID != "" { - discountAmount := roundTo2(bookingTotal * globalPercent / 100) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) - `, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - _, _ = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, globalCampaignID) - } - } - - if milestoneCampaignID == "" { - var firstVisitDate time.Time - _ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate) - if !firstVisitDate.IsZero() { - rows, err := db.DB.Query(r.Context(), ` - 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') - `, booking.User.ID) - if err == nil { - defer rows.Close() - for rows.Next() { - var annID string - var annPercent float64 - var annValue int - var annUnit string - if rows.Scan(&annID, &annPercent, &annValue, &annUnit) == nil { - var matches bool - elapsed := time.Since(firstVisitDate) - switch annUnit { - case "months": - months := int(elapsed.Hours() / (30 * 24)) - matches = months >= annValue - case "years": - years := int(elapsed.Hours() / (365.25 * 24)) - matches = years >= annValue - } - if matches { - discountAmount := roundTo2(bookingTotal * annPercent / 100) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) - VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) - `, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount) - _, _ = db.DB.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - _, _ = db.DB.Exec(r.Context(), ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, annID) - break - } - } + var firstVisitDate time.Time + _ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate) + if !firstVisitDate.IsZero() { + rows, err := db.DB.Query(r.Context(), ` + 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') + `, booking.User.ID) + if err == nil { + defer rows.Close() + for rows.Next() { + var annID string + var annPercent float64 + var annValue int + var annUnit string + if rows.Scan(&annID, &annPercent, &annValue, &annUnit) == nil { + var matches bool + elapsed := time.Since(firstVisitDate) + switch annUnit { + case "months": + months := int(elapsed.Hours() / (30 * 24)) + matches = months >= annValue + case "years": + years := int(elapsed.Hours() / (365.25 * 24)) + matches = years >= annValue + } + if matches { + discountAmount := roundTo2(bookingTotal * annPercent / 100) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) + `, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount) + _, _ = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, _ = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, annID) + break } } } @@ -2787,6 +2849,14 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { populateDepositFields(&booking, depositRequired, preStartAmountPaid) + discounts, err := fetchBookingDiscounts(r.Context(), bookingID) + if err != nil { + log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + booking.Discounts = discounts + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index 17bba80..5a047ec 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -77,6 +77,22 @@ func seedDefaultWorkingHours(t *testing.T) { } } +// nextWorkingHour returns a time within working hours (08:00-19:00) that is +// always < 24h from now. This avoids time-of-day flakiness in tests that need +// a booking within the 24-hour no-show window. Working hours are 08:00-20:00; +// we cap at 19:00 so a 60-minute service finishes before closing. +func nextWorkingHour() time.Time { + now := time.Now() + soon := now.Add(2 * time.Hour).Truncate(time.Second) + if soon.Hour() < 8 { + return time.Date(soon.Year(), soon.Month(), soon.Day(), 9, 0, 0, 0, soon.Location()) + } + if soon.Hour() >= 19 { + return time.Date(soon.Year(), soon.Month(), soon.Day()+1, 9, 0, 0, 0, soon.Location()) + } + return soon +} + // helper function to make JSON request with JWT auth // For authenticated requests, use makeAuthRequest which extracts user from JWT func makeRequest(handler http.Handler, method, path string, body interface{}, token string) *httptest.ResponseRecorder { @@ -1651,8 +1667,7 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { token := jwt.GenerateUserToken(userID) - // Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance) - soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second) + soonTime := nextWorkingHour() bookingReq := CreateBookingRequest{ StartTime: soonTime, @@ -1817,8 +1832,7 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { token := jwt.GenerateUserToken(userID) - // Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance) - soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second) + soonTime := nextWorkingHour() bookingReq := CreateBookingRequest{ StartTime: soonTime, @@ -1901,7 +1915,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { token := jwt.GenerateUserToken(userID) // === First booking: no-show === - soonTime1 := time.Now().Add(23 * time.Hour).Truncate(time.Second) + soonTime1 := nextWorkingHour() bookingReq1 := CreateBookingRequest{ StartTime: soonTime1, @@ -1941,7 +1955,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { } // === Second booking: no-show === - soonTime2 := time.Now().Add(47 * time.Hour).Truncate(time.Second) + soonTime2 := nextWorkingHour().AddDate(0, 0, 1) bookingReq2 := CreateBookingRequest{ StartTime: soonTime2, @@ -5275,3 +5289,94 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) { t.Error("expected second booking deposit_required=false in DB") } } + +func TestGetBooking_WithDiscounts(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + + // Create completed booking + london, _ := time.LoadLocation("Europe/London") + bookingTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) + bookingID := createCompletedBookingWithTime(t, userID, serviceID, bookingTime, 50.00) + + // Create a discount campaign and apply it + var campaignID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) + VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day') + RETURNING id + `, "Test Campaign").Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00) + `, bookingID, userID, campaignID) + if err != nil { + t.Fatalf("failed to create booking discount: %v", err) + } + + // Call GetBookingHandler + handler := http.HandlerFunc(GetBookingHandler) + w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse booking: %v", err) + } + + if len(booking.Discounts) != 1 { + t.Fatalf("expected 1 discount, got %d", len(booking.Discounts)) + } + + d := booking.Discounts[0] + if d.CampaignName == nil || *d.CampaignName != "Test Campaign" { + t.Errorf("expected campaign name 'Test Campaign', got %v", d.CampaignName) + } + if d.DiscountAmount != 5.00 { + t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount) + } +} + +func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { + t.Helper() + var bookingID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create completed booking: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id, override_price) + VALUES ($1, $2, $3) + `, bookingID, serviceID, price) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + return bookingID +} diff --git a/backend/handlers/bookings/discount_test.go b/backend/handlers/bookings/discount_test.go index 41efc6d..c4fcf81 100644 --- a/backend/handlers/bookings/discount_test.go +++ b/backend/handlers/bookings/discount_test.go @@ -186,6 +186,54 @@ func getDiscountForBooking(t *testing.T, bookingID string) (source string, amoun return source, amount, true } +type bookingDiscount struct { + Source string + Amount float64 + CampType string + MileType string +} + +func getDiscountRowCount(t *testing.T, bookingID string) int { + t.Helper() + var count int + err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&count) + require.NoError(t, err) + return count +} + +func getAllDiscountsForBooking(t *testing.T, bookingID string) []bookingDiscount { + t.Helper() + rows, err := db.DB.Query(context.Background(), ` + SELECT discount_source, discount_amount, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, '') + FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type + `, bookingID) + require.NoError(t, err) + defer rows.Close() + var discounts []bookingDiscount + for rows.Next() { + var d bookingDiscount + require.NoError(t, rows.Scan(&d.Source, &d.Amount, &d.CampType, &d.MileType)) + discounts = append(discounts, d) + } + return discounts +} + +func getTotalDiscountAmount(t *testing.T, bookingID string) float64 { + t.Helper() + var amount float64 + err := db.DB.QueryRow(context.Background(), `SELECT COALESCE(SUM(discount_amount), 0) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&amount) + require.NoError(t, err) + return amount +} + +func getPaymentDiscountRowCount(t *testing.T, bookingID string) int { + t.Helper() + var count int + err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&count) + require.NoError(t, err) + return count +} + // ============================================================================= // Loyalty Auto-Redemption Tests // ============================================================================= @@ -541,10 +589,84 @@ func TestDiscount_LoyaltyPriority(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) + // Create active time-based campaign with 5% discount + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + // Create user with 10 stamps and a pending redemption + userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + serviceID := createTestService(t, 100.00) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + // Both loyalty (10%) and time-based campaign (5%) should apply + assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows (loyalty + campaign)") + assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID), "Expected 2 discount payment rows") + + totalDiscount := getTotalDiscountAmount(t, bookingID) + // 10% of £100 = £10 (loyalty) + 5% of £100 = £5 (campaign) = £15 + assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total discount should be £15 (10% + 5%)") + + discounts := getAllDiscountsForBooking(t, bookingID) + require.Len(t, discounts, 2) + + // Verify we have both sources + sources := map[string]bool{} + for _, d := range discounts { + sources[d.Source] = true + } + assert.True(t, sources["loyalty"], "Should have loyalty discount") + assert.True(t, sources["campaign"], "Should have campaign discount") +} + +// ============================================================================= +// Stacking Tests +// ============================================================================= + +func TestDiscount_Stacking_LoyaltyPlusTimeBased(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + serviceID := createTestService(t, 100.00) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID), "Expected 2 discount payment rows") + + totalDiscount := getTotalDiscountAmount(t, bookingID) + assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)") + + discounts := getAllDiscountsForBooking(t, bookingID) + require.Len(t, discounts, 2) +} + +func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + mt := "per_user_booking_count" + mu := "bookings" + mv := 5 + _ = createTestCampaign(t, "5th Booking", "milestone", 15.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) @@ -554,19 +676,329 @@ func TestDiscount_LoyaltyPriority(t *testing.T) { serviceID := createTestService(t, 100.00) + // Create 4 prior completed bookings (backdated) + for i := 0; i < 4; i++ { + startTime := time.Now().AddDate(0, 0, -(i + 10)) + _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00) + } + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) - source, _, exists := getDiscountForBooking(t, bookingID) - require.True(t, exists) - assert.Equal(t, "loyalty", source) + assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") - var campaignDiscountCount int - err = db.DB.QueryRow(context.Background(), ` - SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' - `, bookingID).Scan(&campaignDiscountCount) + totalDiscount := getTotalDiscountAmount(t, bookingID) + assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 15%)") +} + +func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + mt := "anniversary" + mu := "years" + mv := 1 + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) require.NoError(t, err) - assert.Equal(t, 0, campaignDiscountCount, "No campaign discount when loyalty applies") + + serviceID := createTestService(t, 100.00) + + // First completed booking backdated 400 days (first visit > 1 year ago) + firstStartTime := time.Now().AddDate(0, 0, -400) + _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + + totalDiscount := getTotalDiscountAmount(t, bookingID) + assert.InDelta(t, 20.00, totalDiscount, 0.01, "Total should be £20 (10% loyalty + 10% anniversary)") +} + +func TestDiscount_Stacking_AllThreeTypes(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + mt := "anniversary" + mu := "years" + mv := 1 + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + serviceID := createTestService(t, 100.00) + + // First booking backdated 400 days for anniversary + firstStartTime := time.Now().AddDate(0, 0, -400) + _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows") + + totalDiscount := getTotalDiscountAmount(t, bookingID) + assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 5% + 10%)") +} + +func TestDiscount_Stacking_MultipleMilestones(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + mt1 := "per_user_booking_count" + mu1 := "bookings" + mv1 := 5 + _ = createTestCampaign(t, "5th Booking", "milestone", 10.0, &mt1, &mu1, &mv1, nil) + + mt2 := "global_booking_count" + mu2 := "bookings" + mv2 := 5 + _ = createTestCampaign(t, "5th Global", "milestone", 5.0, &mt2, &mu2, &mv2, nil) + + mt3 := "anniversary" + mu3 := "years" + mv3 := 1 + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt3, &mu3, &mv3, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 100.00) + + // 4 prior completed bookings, first one backdated 400+ days + for i := 0; i < 4; i++ { + var startTime time.Time + if i == 0 { + startTime = time.Now().AddDate(0, 0, -400) + } else { + startTime = time.Now().AddDate(0, 0, -(i * 7)) + } + _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00) + } + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows (all milestones)") +} + +func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + mt := "global_booking_count" + mu := "bookings" + mv := 1 + _ = createTestCampaign(t, "First Global", "milestone", 5.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + serviceID := createTestService(t, 100.00) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + + totalDiscount := getTotalDiscountAmount(t, bookingID) + assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)") +} + +func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + mt := "per_user_booking_count" + mu := "bookings" + mv := 3 + _ = createTestCampaign(t, "3rd Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 100.00) + + // 2 prior completed bookings + startTime1 := time.Now().AddDate(0, 0, -14) + _ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00) + + startTime2 := time.Now().AddDate(0, 0, -7) + _ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") + + totalDiscount := getTotalDiscountAmount(t, bookingID) + assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (5% + 10%)") +} + +func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + mt := "per_user_booking_count" + mu := "bookings" + mv := 1 + _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + serviceID := createTestService(t, 200.00) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows") + + totalDiscount := getTotalDiscountAmount(t, bookingID) + assert.InDelta(t, 50.00, totalDiscount, 0.01, "Total should be £50") + + // Verify each individual discount_amount is against the original total + discounts := getAllDiscountsForBooking(t, bookingID) + require.Len(t, discounts, 3) + for _, d := range discounts { + switch d.Source { + case "loyalty": + assert.InDelta(t, 20.00, d.Amount, 0.01, "Loyalty: 10% of £200") + case "campaign": + if d.CampType == "time_based" { + assert.InDelta(t, 10.00, d.Amount, 0.01, "Time-based: 5% of £200") + } else if d.CampType == "milestone" { + assert.InDelta(t, 20.00, d.Amount, 0.01, "Milestone: 10% of £200") + } + } + } +} + +func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + mt := "per_user_booking_count" + mu := "bookings" + mv := 1 + _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + serviceID := createTestService(t, 200.00) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 3, getPaymentDiscountRowCount(t, bookingID), "Expected 3 discount payment rows") + + var totalDiscountPayment float64 + err = db.DB.QueryRow(context.Background(), ` + SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' + `, bookingID).Scan(&totalDiscountPayment) + require.NoError(t, err) + assert.InDelta(t, 50.00, totalDiscountPayment, 0.01, "Sum of discount payments should be £50") +} + +func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + mt := "per_user_booking_count" + mu := "bookings" + mv := 1 + _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) + + userID := createTestUser(t, 10) + ctx := context.Background() + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + serviceID := createTestService(t, 200.00) + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 booking_discounts rows") + + // Verify discount_percent and original_total for each row + type discountDetail struct { + Source string + CampType string + MileType string + Percent float64 + OriginalTotal float64 + Amount float64 + } + rows, err := db.DB.Query(context.Background(), ` + SELECT discount_source, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, ''), discount_percent, original_total, discount_amount + FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type + `, bookingID) + require.NoError(t, err) + defer rows.Close() + var details []discountDetail + for rows.Next() { + var d discountDetail + require.NoError(t, rows.Scan(&d.Source, &d.CampType, &d.MileType, &d.Percent, &d.OriginalTotal, &d.Amount)) + details = append(details, d) + } + require.Len(t, details, 3) + + for _, d := range details { + assert.Equal(t, 200.00, d.OriginalTotal, "original_total should be £200") + switch d.Source { + case "loyalty": + assert.Equal(t, "", d.CampType) + assert.Equal(t, "", d.MileType) + assert.InDelta(t, 10.00, d.Percent, 0.01) + assert.InDelta(t, 20.00, d.Amount, 0.01) + case "campaign": + if d.CampType == "time_based" { + assert.Equal(t, "", d.MileType) + assert.InDelta(t, 5.00, d.Percent, 0.01) + assert.InDelta(t, 10.00, d.Amount, 0.01) + } else if d.CampType == "milestone" { + assert.Equal(t, "per_user_booking_count", d.MileType) + assert.InDelta(t, 10.00, d.Percent, 0.01) + assert.InDelta(t, 20.00, d.Amount, 0.01) + } + } + } } // ============================================================================= @@ -650,3 +1082,644 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, discountCount2, "No discount (max reached)") } + +func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string { + t.Helper() + ctx := context.Background() + var id string + now := time.Now() + startDate := now.Add(-24 * time.Hour) + endDate := now.Add(24 * time.Hour) + + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0) + RETURNING id + `, name, campaignType, percent, status, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id) + require.NoError(t, err) + return id +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +func TestDiscount_ExpiredRedemptionDoesNotApply(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 10) + serviceID := createTestService(t, 100.00) + + // Insert pending redemption that has already expired + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at, expires_at) + VALUES ($1, 10, 'pending', NOW(), NOW() - INTERVAL '1 day') + `, userID) + require.NoError(t, err) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Expired redemption should not apply discount") +} + +func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 10) + serviceID := createTestService(t, 100.00) + + ctx := context.Background() + + // Insert two pending redemptions with different timestamps + _, err := db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW() - INTERVAL '2 days') + `, userID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW() - INTERVAL '1 day') + `, userID) + require.NoError(t, err) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Exactly 1 loyalty discount row should be applied") + + // Verify the oldest redemption was applied and the newer one remains pending + type redemptionRow struct { + ID string + Status string + } + rows, err := db.DB.Query(ctx, ` + SELECT id, status FROM loyalty_redemptions WHERE user_id = $1 ORDER BY redeemed_at ASC + `, userID) + require.NoError(t, err) + defer rows.Close() + + var redemptions []redemptionRow + for rows.Next() { + var r redemptionRow + require.NoError(t, rows.Scan(&r.ID, &r.Status)) + redemptions = append(redemptions, r) + } + require.NoError(t, rows.Err()) + require.Equal(t, 2, len(redemptions)) + assert.Equal(t, "applied", redemptions[0].Status, "Oldest redemption should be applied") + assert.Equal(t, "pending", redemptions[1].Status, "Newer redemption should remain pending") +} + +func TestDiscount_StampCountAboveTen(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 9) + serviceID := createTestService(t, 50.00) + + // First booking: stamps 9 → 10, pending redemption auto-created + bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID1) + backdateBooking(t, bookingID1, 2) + + assert.Equal(t, 10, getStamps(t, userID), "Stamps should be 10 after first completion") + assert.Equal(t, 1, getPendingRedemptions(t, userID), "Pending redemption should be auto-created") + + // Second booking (different day): redemption applies, stamps reset to 0 then +1 + bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + completeBooking(t, bookingID2) + + source, amount, exists := getDiscountForBooking(t, bookingID2) + require.True(t, exists, "Expected loyalty discount on second booking") + assert.Equal(t, "loyalty", source) + assert.Equal(t, 5.00, amount, "10% of £50 = £5") + assert.Equal(t, 1, getStamps(t, userID), "Stamps: 0 after redemption + 1 for this booking = 1") + assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemptions after applying") +} + +func TestDiscount_MixedFreeAndPaidServices(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceIDFree := createTestService(t, 0) + serviceIDPaid := createTestService(t, 50.00) + + ctx := context.Background() + var bookingID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'confirmed') + RETURNING id + `, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID) + require.NoError(t, err) + + // Insert two booking_services rows: one free, one paid + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2), ($1, $3) + `, bookingID, serviceIDFree, serviceIDPaid) + require.NoError(t, err) + + completeBooking(t, bookingID) + + assert.Equal(t, 1, getStamps(t, userID), "Mixed free/paid booking earns 1 stamp (total > 0)") +} + +func TestDiscount_CampaignBoundaryStart(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) + RETURNING id + `, "Boundary Start").Scan(&campaignID) + require.NoError(t, err) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Campaign at start_date boundary should apply") +} + +func TestDiscount_CampaignBoundaryEnd(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 minute', 0) + RETURNING id + `, "Boundary End").Scan(&campaignID) + require.NoError(t, err) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Campaign at end_date boundary should apply") +} + +func TestDiscount_CampaignExpiredDoesNotApply(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() - INTERVAL '1 day', 0) + RETURNING id + `, "Expired Campaign").Scan(&campaignID) + require.NoError(t, err) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Expired campaign should not apply") +} + +func TestDiscount_CampaignDraftDoesNotApply(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaignWithStatus(t, "Draft Campaign", "time_based", 10.0, "draft", nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Draft campaign should not apply") +} + +func TestDiscount_CampaignCancelledDoesNotApply(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaignWithStatus(t, "Cancelled Campaign", "time_based", 10.0, "cancelled", nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Cancelled campaign should not apply") +} + +func TestDiscount_PriceOverrideRespected(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaign(t, "10% Off", "time_based", 10.0, nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 100.00) + + // Create booking with override_price = £80 + ctx := context.Background() + var bookingID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'confirmed') + RETURNING id + `, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id, override_price) + VALUES ($1, $2, $3) + `, bookingID, serviceID, 80.00) + require.NoError(t, err) + + completeBooking(t, bookingID) + + source, amount, exists := getDiscountForBooking(t, bookingID) + require.True(t, exists) + assert.Equal(t, "campaign", source) + assert.Equal(t, 8.00, amount, "10%% of £80 override = £8.00") +} + +func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + milestoneValue := 12 + milestoneType := "anniversary" + milestoneUnit := "months" + _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) + _ = createTestCampaign(t, "Spring Sale", "time_based", 5.0, nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 60.00) + + // Create a completed booking 400 days ago (> 12 months) + ctx := context.Background() + fourHundredDaysAgo := time.Now().AddDate(0, 0, -400) + var firstBookingID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, fourHundredDaysAgo).Scan(&firstBookingID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id, override_price) + VALUES ($1, $2, $3) + `, firstBookingID, serviceID, 60.00) + require.NoError(t, err) + + // First booking after anniversary threshold: should get anniversary + time_based + bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID1) + + discounts1 := getAllDiscountsForBooking(t, bookingID1) + assert.Equal(t, 2, len(discounts1), "First booking should get anniversary + time_based discounts") + + // Second booking (different day): should only get time_based (anniversary dedup) + bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + completeBooking(t, bookingID2) + + discounts2 := getAllDiscountsForBooking(t, bookingID2) + assert.Equal(t, 1, len(discounts2), "Second booking should only get time_based (anniversary dedup)") + // Verify the remaining discount is time_based + foundTimeBased := false + for _, d := range discounts2 { + if d.CampType == "time_based" { + foundTimeBased = true + } + } + assert.True(t, foundTimeBased, "Remaining discount should be time_based") +} + +func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + milestoneValue := 3 + milestoneType := "per_user_booking_count" + _ = createTestCampaign(t, "3rd Visit", "milestone", 10.0, &milestoneType, nil, &milestoneValue, nil) + _ = createTestCampaign(t, "May Sale", "time_based", 5.0, nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + // Create 2 prior completed bookings on different days + for i := 0; i < 2; i++ { + startTime := time.Now().AddDate(0, 0, -10-i*7) + _ = createCompletedBooking(t, userID, serviceID, startTime, 50.00) + } + + // 3rd booking: milestone + time_based + bookingID3 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID3) + + discounts3 := getAllDiscountsForBooking(t, bookingID3) + assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based") + + // 4th booking (different day): only time_based (milestone dedup) + bookingID4 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + completeBooking(t, bookingID4) + + discounts4 := getAllDiscountsForBooking(t, bookingID4) + assert.Equal(t, 1, len(discounts4), "4th booking should only get time_based (milestone dedup)") + foundTimeBased := false + for _, d := range discounts4 { + if d.CampType == "time_based" { + foundTimeBased = true + } + } + assert.True(t, foundTimeBased, "Remaining discount should be time_based") +} + +func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + maxRedemptions := 1 + milestoneValue := 5 + milestoneType := "global_booking_count" + _ = createTestCampaign(t, "5th Customer", "milestone", 5.0, &milestoneType, nil, &milestoneValue, &maxRedemptions) + _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) + + userID1 := createTestUser(t, 0) + userID2 := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + // Create 4 completed bookings (different days, user1) + for i := 0; i < 4; i++ { + startTime := time.Now().AddDate(0, 0, -10-i*7) + _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00) + } + + // 5th global booking (user1, different day): milestone + time_based + bookingID5 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID5) + + discounts5 := getAllDiscountsForBooking(t, bookingID5) + assert.Equal(t, 2, len(discounts5), "5th global booking should get milestone + time_based") + + // 6th global booking (user2, different day): only time_based (max_redemptions reached) + bookingID6 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) + completeBooking(t, bookingID6) + + discounts6 := getAllDiscountsForBooking(t, bookingID6) + assert.Equal(t, 1, len(discounts6), "6th global booking should only get time_based (max_redemptions reached)") + foundTimeBased := false + for _, d := range discounts6 { + if d.CampType == "time_based" { + foundTimeBased = true + } + } + assert.True(t, foundTimeBased, "Remaining discount should be time_based") +} + +func TestDiscount_BestTimeBasedCampaignSelected(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaign(t, "Low Sale", "time_based", 5.0, nil, nil, nil, nil) + _ = createTestCampaign(t, "High Sale", "time_based", 15.0, nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 100.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Only 1 campaign discount row (best selected)") + + source, amount, exists := getDiscountForBooking(t, bookingID) + require.True(t, exists) + assert.Equal(t, "campaign", source) + assert.Equal(t, 15.00, amount, "15%% of £100 = £15 (highest percent selected)") +} + +func TestDiscount_FirstBookingEarnsStamp(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 1, getStamps(t, userID), "First paid booking earns 1 stamp") +} + +func TestDiscount_TenStampsCreatesRedemption(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 9) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + assert.Equal(t, 10, getStamps(t, userID), "Stamps should reach 10") + assert.Equal(t, 1, getPendingRedemptions(t, userID), "1 pending redemption should be auto-created") +} + +func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 10) + serviceID := createTestService(t, 50.00) + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, userID) + require.NoError(t, err) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Loyalty discount should be applied") + + source, amount, exists := getDiscountForBooking(t, bookingID) + require.True(t, exists) + assert.Equal(t, "loyalty", source) + assert.Equal(t, 5.00, amount, "10%% of £50 = £5") + assert.Equal(t, 1, getStamps(t, userID), "Stamps: 0 after redemption + 1 for this booking = 1") +} + +// ============================================================================= +// Campaign Status Lifecycle Tests +// ============================================================================= + +func TestCampaign_CreateAsDraft(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0) + RETURNING id + `, "Draft Campaign").Scan(&campaignID) + require.NoError(t, err) + + var status string + err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "draft", status) +} + +func TestCampaign_ActivateDraft(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0) + RETURNING id + `, "Draft to Active").Scan(&campaignID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'active' WHERE id = $1`, campaignID) + require.NoError(t, err) + + var status string + err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "active", status) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Activated draft campaign should apply") +} + +func TestCampaign_CompleteActive(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) + RETURNING id + `, "Active to Completed").Scan(&campaignID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'completed' WHERE id = $1`, campaignID) + require.NoError(t, err) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Completed campaign should not apply") +} + +func TestCampaign_CancelActive(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) + RETURNING id + `, "Active to Cancelled").Scan(&campaignID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'cancelled' WHERE id = $1`, campaignID) + require.NoError(t, err) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Cancelled campaign should not apply") +} + +func TestCampaign_RevertToDraft(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + ctx := context.Background() + var campaignID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) + RETURNING id + `, "Active to Draft").Scan(&campaignID) + require.NoError(t, err) + + _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'draft' WHERE id = $1`, campaignID) + require.NoError(t, err) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Reverted-to-draft campaign should not apply") +} + +func TestCampaign_DraftDoesNotApplyDiscounts(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + _ = createTestCampaignWithStatus(t, "Draft Only", "time_based", 10.0, "draft", nil, nil, nil, nil) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 0, count, "Draft campaign should not apply discounts") +} diff --git a/backend/handlers/user/customer_relationship.go b/backend/handlers/user/customer_relationship.go index c83e006..cf34b59 100644 --- a/backend/handlers/user/customer_relationship.go +++ b/backend/handlers/user/customer_relationship.go @@ -18,6 +18,7 @@ import ( type CustomerRelationship struct { TotalSpend float64 `json:"totalSpend"` + TotalSaved float64 `json:"totalSaved"` TotalTips float64 `json:"totalTips"` TotalVisits int `json:"totalVisits"` CustomerFor string `json:"customerFor"` @@ -62,11 +63,25 @@ func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) { WHERE b.user_id = $1 AND p.status = 'completed' AND p.payment_type IN ('full', 'partial', 'balance', 'deposit') + AND p.payment_method != 'discount' `, userID).Scan(&result.TotalSpend) if err != nil && !errors.Is(err, sql.ErrNoRows) { log.Printf("Failed to get total spend for user %s: %v", userID, err) } + err = db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(p.amount), 0) + FROM payments p + JOIN bookings b ON p.booking_id = b.id + WHERE b.user_id = $1 + AND p.status = 'completed' + AND p.payment_type IN ('full', 'partial', 'balance', 'deposit') + AND p.payment_method = 'discount' + `, userID).Scan(&result.TotalSaved) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("Failed to get total saved for user %s: %v", userID, err) + } + err = db.DB.QueryRow(r.Context(), ` SELECT COALESCE(SUM(p.amount), 0) FROM payments p diff --git a/backend/handlers/user/customer_relationship_test.go b/backend/handlers/user/customer_relationship_test.go index 65bd9d4..77930db 100644 --- a/backend/handlers/user/customer_relationship_test.go +++ b/backend/handlers/user/customer_relationship_test.go @@ -318,3 +318,56 @@ func createPayment(t *testing.T, pool *pgxpool.Pool, bookingID, paymentType stri t.Fatalf("failed to create payment: %v", err) } } + +func TestCustomerRelationship_WithDiscounts(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + svcID, err := createService(db.DB, "Test Service", 100.00) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID := createCompletedBooking(t, db.DB, userID, svcID, "2024-03-01 10:00:00+00", 100.00) + + // User pays £80.00 cash/card and receives £20.00 discount + createPayment(t, db.DB, bookingID, "balance", 80.00) + createDiscountPayment(t, db.DB, bookingID, 20.00) + + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + rr := httptest.NewRecorder() + GetCustomerRelationshipHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String()) + } + + var result CustomerRelationship + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if result.TotalSpend != 80.00 { + t.Errorf("expected total spend 80.00, got %.2f", result.TotalSpend) + } + + if result.TotalSaved != 20.00 { + t.Errorf("expected total saved 20.00, got %.2f", result.TotalSaved) + } +} + +func createDiscountPayment(t *testing.T, pool *pgxpool.Pool, bookingID string, amount float64) { + t.Helper() + ctx := context.Background() + _, err := pool.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES ($1, 'partial', 'discount', $2, 'completed') + `, bookingID, amount) + if err != nil { + t.Fatalf("failed to create discount payment: %v", err) + } +} diff --git a/frontend/src/lib/components/account/EditRequestModal.svelte b/frontend/src/lib/components/account/EditRequestModal.svelte index 2d7216c..05984b5 100644 --- a/frontend/src/lib/components/account/EditRequestModal.svelte +++ b/frontend/src/lib/components/account/EditRequestModal.svelte @@ -720,7 +720,7 @@
diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 527fdfc..c0f5c86 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -7,7 +7,7 @@ import { Input } from '$lib/components/ui/input'; import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; import EditRequestModal from '$lib/components/account/EditRequestModal.svelte'; - import type { Booking } from '$lib/types/booking'; + import type { Booking, BookingDiscount, Payment } from '$lib/types/booking'; interface Props { open: boolean; @@ -244,11 +244,30 @@ return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } } + + function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string { + if (payment.payment_method === 'online_square') return 'Online Card'; + if (payment.payment_method === 'in_person_card') return 'Card Machine'; + if (payment.payment_method === 'cash') return 'Cash'; + if (payment.payment_method === 'giftcard') return 'Gift Card'; + if (payment.payment_method === 'discount') { + const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length; + const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01); + if (discountList[discountPaymentsBefore]) { + const d = discountList[discountPaymentsBefore]; + if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)'; + if (d.campaign_name) return `${d.campaign_name}`; + return 'Promo Campaign Discount'; + } + return 'Discount'; + } + return formatPaymentMethod(payment.payment_method); + }
@@ -417,26 +436,48 @@ {/if}
- {selectedBooking.amount_paid > selectedBooking.total_amount - ? 'Pre-tip Subtotal' - : 'Total Amount'} + Subtotal (Services) £{selectedBooking.total_amount.toFixed(2)}
+ + {#if selectedBooking.discounts && selectedBooking.discounts.length > 0} +
+ {#each selectedBooking.discounts as d} +
+ + + {#if d.discount_source === 'loyalty'} + Loyalty Stamp Card (10% Off) + {:else if d.campaign_name} + {d.campaign_name} ({d.discount_percent}% Off) + {:else} + Promo Campaign ({d.discount_percent}% Off) + {/if} + + -£{d.discount_amount.toFixed(2)} +
+ {/each} +
+
+ Net Total + £{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)} +
+ {/if} +
- Amount Paid - £{selectedBooking.amount_paid.toFixed(2)} + Amount Paid (Card/Cash) + + £{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)} +
- {#if selectedBooking.amount_due > 0} + + {#if Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)) > 0.01}
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'} - £{selectedBooking.amount_due.toFixed(2)} + £{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)}
{/if} @@ -454,9 +495,9 @@
- {formatPaymentMethod(payment.payment_method)} + {getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)} - {payment.payment_type.charAt(0).toUpperCase() + - payment.payment_type.slice(1)} payment - {#if payment.card_last4} - , Card ending in {payment.card_last4} + {#if payment.payment_method === 'discount'} + Applied automatically on completion + {:else} + {payment.payment_type.charAt(0).toUpperCase() + + payment.payment_type.slice(1)} payment + {#if payment.card_last4} + , Card ending in {payment.card_last4} + {/if} {/if}
{#if payment.is_vat_applicable} @@ -492,7 +537,7 @@
- £{payment.amount.toFixed(2)} + {payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
@@ -585,7 +630,7 @@ {/if} (showCancelConfirm = v)}> - + Cancel Booking @@ -623,7 +668,7 @@ } }} > - + Leave a Tip Show your appreciation for great service diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index e1297b6..7c58fa1 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -371,7 +371,7 @@ - + Approve Booking diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index b874e65..4c8d57f 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -6,8 +6,7 @@ import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte'; import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte'; import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format'; - import type { Booking, BookingService } from '$lib/types/booking'; - import type { Payment } from '$lib/types'; + import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking'; interface Props { open: boolean; @@ -118,7 +117,8 @@ total_amount: data.total_amount || 0, amount_paid: data.amount_paid || 0, amount_due: data.amount_due || 0, - duration_minutes: data.duration_minutes || 0 + duration_minutes: data.duration_minutes || 0, + discounts: data.discounts || [] }; } else { const text = await response.text(); @@ -130,6 +130,25 @@ } } + function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string { + if (payment.payment_method === 'online_square') return 'Online Card'; + if (payment.payment_method === 'in_person_card') return 'Card Machine'; + if (payment.payment_method === 'cash') return 'Cash'; + if (payment.payment_method === 'giftcard') return 'Gift Card'; + if (payment.payment_method === 'discount') { + const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length; + const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01); + if (discountList[discountPaymentsBefore]) { + const d = discountList[discountPaymentsBefore]; + if (d.discount_source === 'loyalty') return 'Loyalty Stamp Card (10% Off)'; + if (d.campaign_name) return `${d.campaign_name}`; + return 'Promo Campaign Discount'; + } + return 'Discount'; + } + return (payment.payment_method as string).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + } + $effect(() => { if (open && bookingId) { fetchBookingDetails(); @@ -139,7 +158,7 @@
@@ -418,23 +437,49 @@ {/if}
- Total Amount + Subtotal (Services) £{selectedBooking.total_amount.toFixed(2)}
+ + {#if selectedBooking.discounts && selectedBooking.discounts.length > 0} +
+ {#each selectedBooking.discounts as d} +
+ + + {#if d.discount_source === 'loyalty'} + Loyalty Stamp Card (10% Off) + {:else if d.campaign_name} + {d.campaign_name} ({d.discount_percent}% Off) + {:else} + Promo Campaign ({d.discount_percent}% Off) + {/if} + + -£{d.discount_amount.toFixed(2)} +
+ {/each} +
+
+ Net Total + £{(selectedBooking.total_amount - selectedBooking.discounts.reduce((sum, d) => sum + d.discount_amount, 0)).toFixed(2)} +
+ {/if} +
- Amount Paid - £{selectedBooking.amount_paid.toFixed(2)} + Amount Paid (Card/Cash) + + £{(selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0).toFixed(2)} +
+
- Amount Due + Balance Due sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)) > 0.01 ? 'text-red-600' : 'text-green-600'}" > - £{selectedBooking.amount_due.toFixed(2)} + £{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)}
@@ -452,14 +497,8 @@
- - {#if payment.payment_method === 'online_square'}Online - {:else if payment.payment_method === 'in_person_card'}Card Machine - {:else if payment.payment_method === 'cash'}Cash - {:else if payment.payment_method === 'giftcard'}Gift Card - {:else if payment.payment_method === 'discount'}Discount - {:else}{(payment.payment_method as string).replace('_', ' ')} - {/if} + + {getPaymentName(payment, index, selectedBooking.payments, selectedBooking.discounts)} - {payment.payment_type.charAt(0).toUpperCase() + - payment.payment_type.slice(1)} payment - {#if payment.card_last4} - , Card ending in {payment.card_last4} + {#if payment.payment_method === 'discount'} + Applied automatically on completion + {:else} + {payment.payment_type.charAt(0).toUpperCase() + + payment.payment_type.slice(1)} payment + {#if payment.card_last4} + , Card ending in {payment.card_last4} + {/if} {/if}
{#if payment.vendor_code || payment.invoice_number} @@ -505,7 +548,7 @@
- £{payment.amount.toFixed(2)} + {payment.payment_method === 'discount' ? '-' : ''}£{payment.amount.toFixed(2)}
diff --git a/frontend/src/lib/components/admin/DiscountsManagement.svelte b/frontend/src/lib/components/admin/DiscountsManagement.svelte index bc3fc72..2d5acaa 100644 --- a/frontend/src/lib/components/admin/DiscountsManagement.svelte +++ b/frontend/src/lib/components/admin/DiscountsManagement.svelte @@ -96,7 +96,7 @@ if (form.discount_percent <= 0 || form.discount_percent > 100) return false; if (form.campaign_type === 'time_based') { if (!form.start_date || !form.end_date) return false; - if (new SvelteDate(form.end_date) <= new SvelteDate(form.start_date)) return false; + if (new SvelteDate(form.end_date) < new SvelteDate(form.start_date)) return false; } if (form.campaign_type === 'milestone') { if (form.milestone_value <= 0) return false; @@ -146,8 +146,8 @@ campaign_type: c.campaign_type, discount_percent: c.discount_percent, scope: c.scope || 'all_bookings', - start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 16) : '', - end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 16) : '', + start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 10) : '', + end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 10) : '', milestone_type: c.milestone_type || 'per_user_booking_count', milestone_value: c.milestone_value || 0, milestone_unit: c.milestone_unit || 'bookings', @@ -168,7 +168,7 @@ if ( form.start_date && form.end_date && - new Date(form.end_date) <= new Date(form.start_date) + new Date(form.end_date) < new Date(form.start_date) ) { errors.end_date = 'Must be after start'; } @@ -188,8 +188,8 @@ }; if (form.campaign_type === 'time_based') { payload.scope = form.scope; - payload.start_date = new Date(form.start_date).toISOString(); - payload.end_date = new Date(form.end_date).toISOString(); + payload.start_date = form.start_date + 'T00:00:00.000Z'; + payload.end_date = form.end_date + 'T23:59:59.999Z'; } else { payload.milestone_type = form.milestone_type; payload.milestone_value = form.milestone_value; @@ -562,12 +562,12 @@
Start Date * - + {#if errors.start_date}

{errors.start_date}

{/if}
End Date * - + {#if errors.end_date}

{errors.end_date}

{/if}
diff --git a/frontend/src/lib/components/admin/PatchTestModal.svelte b/frontend/src/lib/components/admin/PatchTestModal.svelte index 6d1ee7a..aba0f05 100644 --- a/frontend/src/lib/components/admin/PatchTestModal.svelte +++ b/frontend/src/lib/components/admin/PatchTestModal.svelte @@ -98,7 +98,7 @@ - + Record Patch Test diff --git a/frontend/src/lib/components/admin/RescheduleModal.svelte b/frontend/src/lib/components/admin/RescheduleModal.svelte index c825100..83068f1 100644 --- a/frontend/src/lib/components/admin/RescheduleModal.svelte +++ b/frontend/src/lib/components/admin/RescheduleModal.svelte @@ -408,7 +408,7 @@ - + Reschedule Booking diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte index 446265b..251b790 100644 --- a/frontend/src/lib/components/admin/UserModal.svelte +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -72,6 +72,7 @@ type CustomerRelationship = { totalSpend: number; + totalSaved: number; totalTips: number; totalVisits: number; customerFor: string; @@ -485,13 +486,19 @@

Customer Relationship

-
+
Total Spend
£{customerRelationship.totalSpend.toFixed(2)}
+
+
Total Saved
+
+ £{customerRelationship.totalSaved.toFixed(2)} +
+
Total Tips
diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 9dd3c29..5ec6a85 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -4,7 +4,7 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Checkbox } from '$lib/components/ui/checkbox'; - import type { Booking, BookingService } from '$lib/types/booking'; + import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking'; import { authStore } from '$lib/stores/auth.svelte'; interface Props { @@ -91,15 +91,6 @@ let pollingInterval: ReturnType | null = null; - let tipPercentages = $derived.by(() => { - if (subtotal <= 0) return []; - return [ - { pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 }, - { pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 }, - { pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 } - ]; - }); - function selectTipPercent(percent: number) { selectedTipPercent = percent; customTipAmount = ''; @@ -135,16 +126,27 @@ } let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0)); + let discountSum = $derived((booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)); + let netTotal = $derived(Math.max(0, subtotal - discountSum)); + + let tipPercentages = $derived.by(() => { + if (netTotal <= 0) return []; + return [ + { pct: 10, amount: Math.round(netTotal * 0.1 * 100) / 100 }, + { pct: 15, amount: Math.round(netTotal * 0.15 * 100) / 100 }, + { pct: 20, amount: Math.round(netTotal * 0.2 * 100) / 100 } + ]; + }); let tipMultiplier = $derived( selectedTipPercent !== null ? 1 + selectedTipPercent / 100 : customTipAmount && parseFloat(customTipAmount) > 0 - ? 1 + parseFloat(customTipAmount) / subtotal + ? 1 + parseFloat(customTipAmount) / netTotal : 1 ); - let totalWithTip = $derived(tipEnabled ? subtotal * tipMultiplier : subtotal); + let totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal); let tipDisplay = $derived( selectedTipPercent !== null ? `${selectedTipPercent}%` @@ -153,7 +155,7 @@ : '' ); - let totalDue = $derived(tipEnabled ? totalWithTip : subtotal); + let totalDue = $derived(tipEnabled ? totalWithTip : netTotal); function formatCurrency(value: number): string { return new Intl.NumberFormat('en-GB', { @@ -469,9 +471,50 @@
-
+ {#if booking.discounts && booking.discounts.length > 0} +
+
+
+ + + + + Applied Discounts +
+ + {((discountSum / subtotal) * 100).toFixed(0)}% Off Total + +
+
+ {#each booking.discounts as d} +
+
+ + + {#if d.discount_source === 'loyalty'} + Loyalty Stamp Card (10% Off) + {:else if d.campaign_name} + {d.campaign_name} + {:else} + Promo Campaign ({d.discount_percent}% Off) + {/if} + +
+ -{formatCurrency(d.discount_amount)} +
+ {/each} +
+
+ {/if} + +
Total - {formatCurrency(subtotal)} +
+ {#if discountSum > 0.01} + {formatCurrency(subtotal)} + {/if} + {formatCurrency(totalDue)} +
{#if tipEnabled} diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index 5d01df5..76cdf9d 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -382,7 +382,7 @@ !open && handleClose()}> - + Make a Payment {#if booking.id} diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte index 0bd877c..ae2fd46 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -20,7 +20,7 @@ - + /dev/null) +# --- Seeding campaigns and stacked discounts for user@example.com --- +echo -e "\n${C_BLUE}🎟️ Seeding Campaigns & Loyalty Stacked Discounts...${C_RESET}" +docker exec -i postgres psql -U myuser -d mydb << 'CAMPAIGN_SQL' > /dev/null 2>&1 +DO $$ +DECLARE + u_id CHAR(12); + past_camp_id CHAR(12); + active_camp_id CHAR(12); + milestone_camp_id CHAR(12); + b_rec RECORD; + b_idx INT := 1; + red_id CHAR(12); + b_total NUMERIC(10,2); + loyalty_disc NUMERIC(10,2); + camp_disc NUMERIC(10,2); + balance NUMERIC(10,2); +BEGIN + -- 1. Get user id + SELECT id INTO u_id FROM users WHERE email = 'user@example.com'; + + -- 2. Insert 3 campaigns + INSERT INTO discount_campaigns (name, description, campaign_type, discount_percent, scope, start_date, end_date, status, max_redemptions) + VALUES ('Demo Sale', 'Get 10% off visits because teehee', 'time_based', 10.00, 'all_bookings', NOW() - INTERVAL '30 days', NOW() - INTERVAL '10 days', 'active', 500) + RETURNING id INTO past_camp_id; + + INSERT INTO discount_campaigns (name, description, campaign_type, discount_percent, scope, start_date, end_date, status, max_redemptions) + VALUES ('Summer Sale', 'Enjoy 15% off all summer treatments', 'time_based', 15.00, 'all_bookings', NOW() - INTERVAL '1 day', NOW() + INTERVAL '30 days', 'active', 1000) + RETURNING id INTO active_camp_id; + + INSERT INTO discount_campaigns (name, description, campaign_type, discount_percent, scope, status, milestone_type, milestone_value) + VALUES ('10th Visit Celebration', 'Receive 20% off on your 10th milestone visit', 'milestone', 20.00, 'all_bookings', 'active', 'per_user_booking_count', 10) + RETURNING id INTO milestone_camp_id; + + -- 3. Loop through completed bookings of user@example.com in chronological order + FOR b_rec IN + SELECT b.id, b.start_time, + COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) as total + FROM bookings b + JOIN booking_services bs ON bs.booking_id = b.id + JOIN services s ON bs.service_id = s.id + WHERE b.user_id = u_id AND b.status = 'completed' + GROUP BY b.id, b.start_time + ORDER BY b.start_time ASC + LOOP + b_total := b_rec.total; + + -- Booking 6 (chronologically Day 18) reaches 10 stamps (stamps go 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10) + IF b_idx = 6 THEN + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES (u_id, 10, 'pending', b_rec.start_time) + RETURNING id INTO red_id; + END IF; + + -- Booking 7 (chronologically Day 15) consumes the redemption AND gets past campaign (since Day 15 is within past campaign dates) + IF b_idx = 7 THEN + -- Get the pending redemption + SELECT id INTO red_id FROM loyalty_redemptions WHERE user_id = u_id AND status = 'pending' ORDER BY redeemed_at ASC LIMIT 1; + + IF red_id IS NOT NULL THEN + loyalty_disc := ROUND(b_total * 0.10, 2); + camp_disc := ROUND(b_total * 0.10, 2); + balance := ROUND(b_total - loyalty_disc - camp_disc, 2); + + -- Create loyalty discount row + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount, applied_at) + VALUES (b_rec.id, u_id, 'loyalty', red_id, 10.00, b_total, loyalty_disc, b_rec.start_time); + + -- Create loyalty payment row + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at) + VALUES (b_rec.id, 'partial', 'discount', loyalty_disc, 'completed', b_rec.start_time); + + -- Update loyalty redemption + UPDATE loyalty_redemptions + SET status = 'applied', applied_to_booking_id = b_rec.id, applied_at = b_rec.start_time + WHERE id = red_id; + + -- Create campaign discount row + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount, applied_at) + VALUES (b_rec.id, u_id, 'campaign', past_camp_id, 'time_based', 10.00, b_total, camp_disc, b_rec.start_time); + + -- Create campaign payment row + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at) + VALUES (b_rec.id, 'partial', 'discount', camp_disc, 'completed', b_rec.start_time); + + -- Increment campaign times_redeemed + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = past_camp_id; + + -- Create standard payment balance row + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at) + VALUES (b_rec.id, 'balance', 'in_person_card', balance, 'completed', b_rec.start_time); + END IF; + + -- Other bookings during past campaign (Bookings 1-5, and 8: Day 28, 26, 24, 22, 20, 11) get campaign discount only + ELSIF b_rec.start_time >= (NOW() - INTERVAL '30 days') AND b_rec.start_time <= (NOW() - INTERVAL '10 days') THEN + camp_disc := ROUND(b_total * 0.10, 2); + balance := ROUND(b_total - camp_disc, 2); + + -- Create campaign discount row + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount, applied_at) + VALUES (b_rec.id, u_id, 'campaign', past_camp_id, 'time_based', 10.00, b_total, camp_disc, b_rec.start_time); + + -- Create campaign payment row + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at) + VALUES (b_rec.id, 'partial', 'discount', camp_disc, 'completed', b_rec.start_time); + + -- Increment campaign times_redeemed + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = past_camp_id; + + -- Create standard payment balance row + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at) + VALUES (b_rec.id, 'balance', 'in_person_card', balance, 'completed', b_rec.start_time); + END IF; + + b_idx := b_idx + 1; + END LOOP; + + -- Update final stamps to 5 + UPDATE users SET loyalty_stamps = 5 WHERE id = u_id; +END $$; +CAMPAIGN_SQL + # Pure SQL payment seeding — randomized per booking, no bash loops docker exec -i postgres psql -U myuser -d mydb << 'PAYMENT_SQL' > /dev/null 2>&1 DO $$ @@ -907,6 +1028,7 @@ BEGIN JOIN booking_services bs ON bs.booking_id = b.id JOIN services s ON bs.service_id = s.id WHERE b.status = 'completed' + AND NOT EXISTS (SELECT 1 FROM payments WHERE booking_id = b.id) GROUP BY b.id LOOP booking_total := rec.total; diff --git a/obsidian/Crussell/Admin Manual.md b/obsidian/Crussell/Admin Manual.md index 6c042ae..6caf657 100644 --- a/obsidian/Crussell/Admin Manual.md +++ b/obsidian/Crussell/Admin Manual.md @@ -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 diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index aedeaf6..be75c44 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -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. | diff --git a/obsidian/Crussell/Loyalty & Discount System Reference.md b/obsidian/Crussell/Loyalty & Discount System Reference.md new file mode 100644 index 0000000..66b409f --- /dev/null +++ b/obsidian/Crussell/Loyalty & Discount System Reference.md @@ -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. diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index e8895f7..32f0f76 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -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` diff --git a/obsidian/Crussell/User Manual.md b/obsidian/Crussell/User Manual.md index d90d1b0..3fb2f6c 100644 --- a/obsidian/Crussell/User Manual.md +++ b/obsidian/Crussell/User Manual.md @@ -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: