Architecture · snapshot-2026-05-06
What's connected, what's not, what to finalize
Cross-product snapshot of every discount, membership, and group concept across the Setpoint stack — PMS (apps/admin, court bookings) and the POS surface (apps/pos for cafe/pro-shop sales, plus apps/terminal for check-in kiosk + integrated cart). Maps each mechanism in the DB schema to the code that applies it (or doesn't). v0.24.0's Centralized Loyalty & Discount Engine ships the engine + admin only; the headline gap (POS membership-blindness) is structurally addressed (engine + locked-in pos_orders contract columns + canonical admin consumer pattern) but the till-side wiring is delegated to the POS/Terminal dev as a follow-up (BUG-082) so ownership stays with the author. Programs/matches consumer wiring deferred (BUG-074); OpenRally surface deferred (BUG-073); legacy POS group tables left in place per POS dev's call (BUG-075).
Overview
PMS — two membership models
Subscriptions and credit tiers run in parallel (DEC-183, 2026-05-01). A customer can hold both. calculate_member_price computes each side's discount independently and applies the GREATEST of the two percentages — 'better-of' wins. As of v0.24.0 (DEC-186), calculate_member_price delegates internally to resolve_discounts (the new central resolver) but preserves the same JSON shape for consumers; the better-of arm is now expressed as two crm_discount_rules (one auto-mirrored from each subscription plan, one auto-mirrored from each credit-tier config) competing under the resolver's per-rule stackability + 100% cap. The facilities.settings.membership_model JSON key remains unread by the pricing engine.
POS surface — apps/pos + apps/terminal
The POS surface — apps/pos (cafe / pro shop till) AND apps/terminal (check-in kiosk + integrated cart) — runs on its own pricing engine, separate from the PMS court-booking flow. Both apps share the same pos_discount_presets, pos_customer_groups, and pos_discount_group_variations tables, so any wiring described under 'POS' applies to both. Court booking pricing (member rates, tier discounts) is calculated in admin at booking time and baked into bookings.price_amount; the POS surface only displays and bills that pre-determined amount. v0.24.0 (DEC-186) ships the engine + admin only; the resolver consumer wiring at the till is delegated to the POS/Terminal dev (BUG-082) — they consume resolve_discounts via the locked-in pos_orders.discount_kind + pos_order_items.is_foc/foc_reason columns and the @setpoint/common/discounts wrappers, mirroring the admin booking route pattern. Until that follow-up PR lands, a PMS subscription/credit-tier member or a CRM-managed customer-group member buying merchandise at the till still pays full price (the existing cashier preset/ad-hoc + group-variation layers continue to work as before — additive, not replaced). The legacy POS group surface is intentionally left untouched. Programs/matches consumer wiring deferred (BUG-074); OpenRally surface deferred (BUG-073).
Pricing cascade recap
PMS court price = pricing-rules cascade (specific court+sport+day+time → facility default), then resolve_discounts (post-v0.24.0 central resolver) applies the better-of subscription/tier percentage AND any matching customer-group rule if the user qualifies. The rule-level member_price_amount remains a hard per-slot override taken before any percentage. POS price = item/variant base, then any auto discount (item > category) modified by Customer Group variation if the customer is selected, then any manual discount, then service charge, then tax. (Resolver layer at the till = follow-up; will stack additively on the manual layer once BUG-082 ships.)
Headline
The POS surface (apps/pos + apps/terminal) was membership-blind at transaction time. v0.24.0 (DEC-186) ships the Centralized Discount Engine + admin consumer; the till-side consumer wiring is delegated to the POS/Terminal dev as a follow-up (BUG-082) so ownership stays with the author.
Pre-v0.24.0: a 'Hall of Fame' credit-tier player walking into the cafe paid full price for their coffee. A subscription-plan member buying a tube of balls at the pro shop paid full price. The only way a member got a live POS discount was if an operator had manually added them to a POS Customer Group. Post-v0.24.0 (engine-side): the contract is in place — auto-mirror triggers (crm_sync_subscription_plan_rule + crm_sync_credit_tier_rule) keep crm_discount_rules in sync with membership_plans + credit_tier_configs INSERT/UPDATE/DELETE; resolve_discounts + commit_discount_applications + the @setpoint/common/discounts wrappers + the locked-in pos_orders.discount_kind/note + pos_order_items.is_foc/foc_reason columns are all live on main. The admin booking route consumes the engine and serves as the canonical pattern. Court-booking discounts continue to be applied earlier (calculate_member_price delegates to resolve_discounts internally now, single source of truth). What's still pending: the till consumer call. The POS/Terminal dev's follow-up PR (BUG-082) mirrors the admin route pattern in apps/{pos,terminal}/src/components/terminal/checkout-dialog.tsx — once it merges, any plan's discount_percentage and any tier's court_discount_pct will apply at the till too. The legacy POS Customer Group surface remains in place per POS dev's call (BUG-075) — additive only.
Three options to close the gap
(a) Auto-mirror PMS plans/tiers into POS Customer Groups
When a user gets a PMS membership or hits a new tier, automatically add them to a corresponding POS Customer Group. Reuses existing POS discount engine.
(b) Read PMS membership/tier directly from POS at checkout
POS checkout queries user_memberships and credit_tier_configs at the time of sale. Unifies the two systems but ties POS to the PMS schema.
(c) Wait for the unified CRM Loyalty system
arch-crm-loyalty already plans to deprecate credit_tier_configs and pos_loyalty_* into one ledger. The POS bridge becomes a downstream consumer.
Evidence (grep on 2026-04-28)
user_memberships
0 active references in apps/pos/src or apps/terminal/src
credit_tier_configs
0 active references in apps/pos/src or apps/terminal/src
membership_plans
0 active references in apps/pos/src or apps/terminal/src
member_price_amount
0 active references in apps/pos/src or apps/terminal/src
Verified by grep on 2026-04-28. The four strings appear only in the auto-generated database.types.ts files of each app. apps/terminal additionally references calculate_member_price and calculate_credit_tier in its generated types but does not call either RPC.
PMS · Setpoint admin
#1
Subscription plan discount %
Configured at
Admin → Loyalty → Plans
Stored in
membership_plans.discount_percentage
Applied at price?
Automatic at booking. Member's active subscription discount is computed; competes with the credit-tier discount in a better-of rule (DEC-183).
Code path
calculate_member_price() RPC, supabase/migrations/20260501123207_parallel_subscriptions_and_credit_tiers.sql; subscription path computes v_sub_pct.
#2
Credit tier court discount %
Configured at
Admin → Pricing → Credit Tiers
Stored in
credit_tier_configs.court_discount_pct
Applied at price?
Automatic. User's tier is computed from rolling 36-month topup volume, then the tier's court_discount_pct competes with the subscription discount under a better-of rule (DEC-183).
Code path
calculate_credit_tier() + calculate_member_price() RPC; tier path computes v_tier_pct.
#3
Member price on a pricing rule
Configured at
Admin → Pricing → Rules
Stored in
pricing_rules.member_price_amount
Applied at price?
Hard per-slot override. When set, takes precedence over either percentage path — represents the operator's exact 'member rate' for that slot. User must still qualify as a member via subscription OR tier to receive it.
Code path
calculate_member_price() RPC — v_rule.member_price_amount branch in the better-of arm.
#4
Program / lesson tier discount %
Configured at
(removed)
Stored in
(was credit_tier_configs.program_discount_pct)
Applied at price?
Removed in v0.23.10 (DEC-184). Was a stub — admin could set it, no code read it. Tiers now expose court_discount_pct only.
Code path
Migration 20260504050515_simplify_tier_to_court_discount_only.sql dropped the column.
#5
Merchandise tier discount %
Configured at
(removed)
Stored in
(was credit_tier_configs.merch_discount_pct)
Applied at price?
Removed in v0.23.10 (DEC-184). PMS has no merchandise system; POS is membership-blind. Column had no consumer anywhere.
Code path
Migration 20260504050515_simplify_tier_to_court_discount_only.sql dropped the column.
#6
Promo codes (PMS bookings)
Configured at
No admin UI today
Stored in
promo_codes table; bookings.promo_code_id
Applied at price?
Validation endpoint works (returns discount_amount). Booking POST never accepts a promo_code field. bookings.promo_code_id is never populated.
Code path
Validate route exists at apps/admin/src/app/api/v1/promo-codes/validate/route.ts. Booking POST at apps/admin/src/app/api/admin/bookings/route.ts ignores it.
#7
Package sessions (prepaid lessons)
Configured at
Admin → Packages
Stored in
player_packages + player_package_sessions
Applied at price?
For lessons only. Session count auto-decrements when a lesson is confirmed.
Code path
RPC consume_package_session invoked in apps/admin/src/app/api/v1/lessons/[id]/confirm/route.ts:101
#8
Manual price override
Configured at
Admin → Create Booking
Stored in
bookings.price_amount (direct)
Applied at price?
When admin sets price_override: true, all discount logic is bypassed and the typed amount is used as the final price.
Code path
apps/admin/src/app/api/admin/bookings/route.ts:127-128
#9
Tier booking window (days ahead)
Configured at
(removed)
Stored in
(was credit_tier_configs.booking_window_days)
Applied at price?
Removed in v0.23.10 (DEC-184, resolves BUG-036). Was returned by pricing API + displayed but never enforced. Dropped rather than wired — if per-tier booking windows are needed later, a focused loyalty PR will re-add the column with the booking-POST + web-availability enforcement at the same time.
Code path
Migration 20260504050515_simplify_tier_to_court_discount_only.sql dropped the column.
#10
Membership max bookings per period
Configured at
Admin → Loyalty → Plans
Stored in
membership_plans.max_bookings_per_period
Applied at price?
Displayed on plan cards (admin + web) but no counting code exists. A capped plan can be used unlimited times.
Code path
Stub. No counting at booking time.
#11
CRM customer-group discount rule (Centralized Discount Engine)
Configured at
Admin → CRM → Groups + CRM → Discount rules
Stored in
crm_customer_groups + crm_customer_group_members + crm_discount_rules + crm_discount_rule_targets + crm_discount_applications
Applied at price?
Centralized engine landed in v0.24.0 (DEC-186). Operators define groups, attach customers, and create rules with actor_type='customer_group' (also supports 'membership_plan' / 'credit_tier' which are auto-mirrored from existing tables via triggers). resolve_discounts(p_facility_id, p_profile_id, p_lines, p_channel, p_at) returns per-line discount; commit_discount_applications(p_applications) is the idempotent writer to crm_discount_applications. v0.24.0 wires admin /api/admin/bookings (resolve+commit post-insert) — that route is the canonical consumer pattern. POS + Terminal till-side wiring is delegated (BUG-082) to the POS/Terminal dev so ownership stays with the author; the contract (pos_orders.discount_kind/note + pos_order_items.is_foc/foc_reason columns + @setpoint/common/discounts wrappers) ships in this PR. calculate_member_price() delegates to the resolver internally, preserving DEC-183 better-of semantics. Lesson + program consumer wiring deferred (BUG-074); OpenRally deferred (BUG-073).
Code path
Migration: supabase/migrations/20260506094046_centralized_discount_engine.sql. Resolver: resolve_discounts + commit_discount_applications. Shared types: packages/common/src/discounts.ts. Admin booking wire (canonical pattern): apps/admin/src/app/api/admin/bookings/route.ts post-insert. POS + Terminal consumer wiring: delegated, see plan-pos-terminal-discount-integration.md.
POS surface · apps/pos + apps/terminal
Both apps share the same pos_discount_presets, pos_customer_groups, and pos_discount_group_variations tables. Every mechanism below applies equally to apps/pos (cafe / pro-shop till) and apps/terminal (check-in kiosk + integrated cart).
#1
Manual discount preset
Configured at
POS Admin → /items/discounts
Stored in
pos_discount_presets (percent or fixed, date-bounded)
Applied at price?
Cashier picks a preset (or types a manual amount) at checkout. Discount reduces the order subtotal.
Code path
apps/pos/src/mutations/use-discount-preset-mutations.ts; checkout field at apps/pos/src/components/terminal/checkout-dialog.tsx:464-467
#2
Item-level auto discount
Configured at
POS Admin → DiscountModal → Items
Stored in
pos_item_discounts
Applied at price?
Auto-applies on add to cart. Tile shows '% OFF' badge. No cashier action.
Code path
apps/pos/src/queries/use-discount-assignments.ts:59-93
#3
Category-level auto discount
Configured at
POS Admin → DiscountModal → Categories
Stored in
pos_category_discounts
Applied at price?
Auto-applies as a fallback when item has no direct discount.
Code path
apps/pos/src/queries/use-discount-assignments.ts:97-125
#4
Customer Group discount variation
Configured at
POS Admin → DiscountModal → Group variations
Stored in
pos_discount_group_variations
Applied at price?
When a customer is selected at checkout, the system looks up their groups and applies the best variation. Auto override of the base discount.
Code path
apps/pos/src/queries/use-customer-groups.ts:119-147; apps/pos/src/lib/discount-utils.ts:108-115
#5
Service charge (related, not a discount)
Configured at
POS Admin → /items/service-charges
Stored in
pos_service_charges + pos_order_service_charges
Applied at price?
Added to total after discounts. Not a discount but affects the final amount paid.
Code path
apps/pos/src/queries/use-service-charges.ts
#6
PMS membership / credit-tier carryover
Configured at
(PMS — Loyalty / Tiers)
Stored in
PMS-side only; surfaced via crm_discount_rules auto-mirror from membership_plans + credit_tier_configs
Applied at price?
Foundation closed by v0.24.0 (DEC-186). The Centralized Discount Engine auto-mirrors membership_plans + credit_tier_configs into crm_discount_rules (actor_type='membership_plan' / 'credit_tier'); the contract for the till consumer is shipped (resolve_discounts + commit_discount_applications + pos_orders.discount_kind/note columns). POS + Terminal consumer wiring is delegated to the POS/Terminal dev (BUG-082) so ownership stays with the author. Until that follow-up PR merges, a PMS subscriber walking into the cafe still pays full price at the till — the resolver-driven automatic path activates once the till consumer wiring lands, mirroring the admin booking route pattern.
Code path
Foundation: supabase/migrations/20260506094046_centralized_discount_engine.sql + packages/common/src/discounts.ts. Pattern to mirror: apps/admin/src/app/api/admin/bookings/route.ts (~line 264+). Consumer wiring: delegated — see plan-pos-terminal-discount-integration.md.
#7
Package session redemption at POS
Configured at
(PMS — Packages)
Stored in
player_package_sessions
Applied at price?
Packages are PMS-only. Sessions can only be consumed at lesson confirmation, not at the till.
Code path
Not wired in apps/pos/src.
#8
Promo / coupon code
Configured at
(no UI)
Stored in
promo_codes table
Applied at price?
No input field at POS checkout, no validation call from POS. Schema present but completely unused in POS flow.
Code path
Not wired.
#9
Comp / void
Configured at
POS Admin (informally)
Stored in
(no dedicated column)
Applied at price?
Implemented as a manual discount, not a distinct concept. bookings.external_payment_method_type enum includes 'comp' for the booking side; POS does not track comps separately.
Code path
Manual discount field covers it.
#10
Gift cards
Configured at
POS Admin → catalogue
Stored in
pos_gift_cards + pos_gift_card_redemptions
Applied at price?
Redemption reduces tender at checkout, not subtotal. Distinct from a discount but affects what the customer hands over.
Code path
apps/pos/src/components/terminal/checkout-dialog.tsx:268-279
#11
Unused pos_discounts table
Configured at
(no UI)
Stored in
pos_discounts (separate from pos_discount_presets)
Applied at price?
Generic discount table at initial_schema.sql:3967 with auto_apply, conditions JSONB, max_uses. Zero references in source. Reserved for a planned engine, or legacy — boss decision.
Code path
Not wired.
#12
Legacy POS loyalty (pos_loyalty_*)
Configured at
(no UI today)
Stored in
pos_loyalty_programs, pos_loyalty_accounts, pos_loyalty_transactions
Applied at price?
Tables exist; checkout doesn't touch them. Soft-deprecated by arch-crm-loyalty, which plans a unified loyalty system.
Code path
Not wired in POS.
Groups
The plain-English version: only POS Customer Groups change price today. Everything else with "group" in the name is either menu structure (tabs, modifier sets, variant axes) or a future feature.
pos_catalogue_groups + pos_catalogue_group_items
Menu tabs/sections you see on the register (Coffee, Food, Merch). Pure visual taxonomy.
Data classification onlypos_customer_groups + pos_customer_group_members
Legacy POS customer segments (e.g. VIP, Wholesale, Staff). Powers pos_discount_group_variations. Used by both apps/pos and apps/terminal — same engine. Left untouched by v0.24.0 per POS dev's call (γ pure, additive only). Slated for retirement once the legacy path has zero readers (BUG-075).
Wired — POS-side legacy group surface (retirement deferred — BUG-075)crm_customer_groups + crm_customer_group_members
Centralized customer segments introduced in v0.24.0 (DEC-186). Drives crm_discount_rules with actor_type='customer_group'. Read by resolve_discounts at admin booking time today; lesson + program + till consumer wiring are follow-ups (BUG-074, BUG-082). Replaces (eventually) the legacy pos_customer_groups path.
Wired — central engine (v0.24.0); till consumer follow-up (BUG-082)pos_modifier_groups, pos_item_modifier_groups
Modifier sets ("Size", "Toppings") attached to menu items. The modifier itself carries price_adjustment_cents; the group is just structure.
Structure only (modifiers do affect price)pos_item_variant_groups
Variant axes ("Color", "Size") for items like branded merch. Variants carry price_cents; the group is just structure.
Structure only (variants do affect price)crm_segments
Planned CRM segmentation in v1.4. Not yet integrated with anything that affects price.
Planned (CRM v1.4)pos_loyalty_programs (legacy)
Old POS-only loyalty program. Being deprecated in favour of the unified CRM Loyalty system.
Deprecated by arch-crm-loyaltyContract
Bookings table — discount storage contract
bookings has no separate member_price_amount column. After calculate_member_price() runs, only price_amount (the final, post-discount amount) is written. The discount is implicit: original_price − price_amount = discount_applied. The RPC returns the original price, the discount percentage, the tier name, and is_member, but none of those are persisted on the booking row.
Related findings
Edge case
For multi-player court bookings (open matches, group bookings), the discount is applied to the organizer's total before the per-player split.
How it works
Consequence
A non-member player joining a member organizer's match pays a share of the discounted total, not their own non-member price. DEC-152 deferred per-player pricing entirely; this is the current de-facto behaviour.
Background jobs
cleanup-expired-holds
every 1 minute
Releases held court slots after 5-min TTL
auto-confirm-match-results
every 5 minutes
Resolves match-result disputes
expire_player_packages
hourly
Marks packages past expires_at as expired
Affects whether a package session can be redeemed at lesson confirmation.
webhook-delivery-worker
every 10 seconds
Delivers pending webhooks
expire_memberships()
(NOT SCHEDULED)
Should mark memberships past their end_date as expired
Function exists at supabase/migrations/00000000000000_initial_schema.sql:1503 but no cron schedules it. The faq-pricing.ts entry says 'expired via a daily pg_cron job' — that job does not exist.
Edge functions: None of the Supabase edge functions touch pricing or discount logic. Every function in supabase/functions/ is notification/email (send-booking-confirmation, send-membership-payment-link, send-membership-welcome, etc.).
Pay with credits: The admin booking POST accepts payment_method='credits' and the schema's enum includes credits, but the public web checkout (apps/web/src/components/booking/step-payment.tsx) only renders the Stripe Elements UI. There's no credits radio/toggle for players. The gap is the web UI, not the backend.
Stripe
Lazy create
Membership plans lazy-create Stripe Products/Prices on first subscribe. No upfront Stripe setup needed when admin creates a plan.
apps/admin/src/app/api/admin/_lib/membership-plan-stripe.ts
Checkout
Subscriptions go through Stripe Checkout in subscription mode (redirects to Stripe's hosted page).
apps/admin/src/app/api/v1/memberships/subscribe/route.ts
Webhook
checkout.session.completed webhook upserts user_memberships with orphan-adoption logic (DEC-162 hardening).
apps/admin/src/app/api/v1/webhooks/stripe/route.ts:215-386
Core upsert at lines 287-352 (collision detection, orphan adoption, and insert paths).
UI surface
Admin (PMS)
Membership plans
apps/admin/src/components/admin/membership-plans-list.tsx
CRUD subscription plans: name, billing period, price, discount %, max bookings, Stripe link.
Loyalty page — Memberships section
apps/admin/src/components/admin/memberships-page-client.tsx
Top stacked Card on /loyalty. Hosts subscription Plans (membership-plans-list.tsx) and a Pending Payments sub-list (pending-memberships-list.tsx) that surfaces orphan user_memberships waiting for Stripe Checkout. Section header carries the Assign Membership entry point (data-testid 'assign-membership-btn').
Pending Payments (orphan user_memberships)
apps/admin/src/components/admin/pending-memberships-list.tsx
Operations queue inside the Memberships section. Lists active user_memberships rows with a NULL stripe_subscription_id. Per-row actions: View Customer (deep-links to CRM detail), Get Payment Link (opens PaymentLinkActions dialog), Cancel Membership. Renders nothing when empty.
Loyalty page — Wallets section
apps/admin/src/components/admin/tier-config-tab.tsx
Bottom stacked Card on /loyalty. Edit tier thresholds and court discount %. Add new tiers (BUG-044 fix in v0.23.10). Per DEC-184, tiers no longer expose program/merch/booking-window fields — those columns were dropped. The component renders without its own outer Card; the Wallets section provides the chrome.
Member wallets (CRM)
apps/admin/src/app/[facility]/crm/customers/[id]/page.tsx
Per-customer wallet detail: balance, top-up history, Add Credits dialog (cash + Stripe). Wallet ops moved here from the loyalty page in v0.23.9.
Pricing rules
apps/admin/src/components/admin/pricing-rules-list.tsx
CRUD slot pricing by court/sport/day/time/duration. Edit price_per_slot_amount and member_price_amount.
Create booking
apps/admin/src/components/admin/create-booking-dialog.tsx
Admin books a court. Price auto-calculated with member discounts or manually overridden. No promo code input.
POS / Terminal
POS — Discount presets list
apps/pos/src/app/(dashboard)/items/discounts/page.tsx
CRUD discount presets with date validity, type (percent/fixed), and active toggle.
POS — Discount modal
apps/pos/src/components/discounts/discount-modal.tsx
Assign a discount preset to items, categories, or customer groups (with per-group variations).
POS — Cashier checkout
apps/pos/src/components/terminal/checkout-dialog.tsx
Cashier UI. Auto discounts on tiles, Customer Group variations when customer is selected, manual discount, gift card redemption.
POS — Customer groups
apps/pos/src/queries/use-customer-groups.ts
Fetch groups, group memberships, per-group discount variations. Same tables as Terminal — shared engine.
Terminal — Check-in + cart
apps/terminal/src/components/terminal/ticket-modal.tsx
Staff kiosk on port 3007. Checks players in for a court booking, generates a facility_tickets row, and rings up court fees + merchandise in one cart. Reuses pos_discount_presets / pos_customer_groups.
Terminal — Booking detail / payment
apps/terminal/src/components/booking-detail-sheet.tsx
Displays the booking's pre-calculated price_amount and payment_method. Does NOT recalculate member price at the till — that was baked in at admin booking creation.
Public web
Court slot pricing display
apps/web/src/components/booking/step-select.tsx:109-115
Shows 'Regular: ฿X / Member: ฿Y' on slot tiles when a member price is available.
Program pricing card
apps/web/src/app/(public)/[facility]/programs/[programId]/page.tsx:194-197
Shows '฿X for members' badge when program.member_price_amount < price_amount.
Member portal — credits & tier
apps/web/src/hooks/use-my-credits.ts
Fetches user balance, current tier, court discount %. Display only. (program/merch/booking-window were dropped in v0.23.10 per DEC-184.)
Membership plan card
apps/web/src/components/memberships/plan-card.tsx
Shows plan price, billing period, discount %, and 'X bookings per period' if max_bookings_per_period is set.
Decisions
Headline gaps are highlighted. Each gap names the column or file path where the consumer code is missing, and the type of decision needed (wire, remove, schedule, policy, audit).
PMS ↔ POS membership integration (FOUNDATION RESOLVED — v0.24.0 / DEC-186; till consumer pending — BUG-082)
HeadlinepolicyFoundation resolved by v0.24.0's Centralized Loyalty & Discount Engine. Option (b) shipped — engine + admin consumer pattern (admin booking route) + locked-in pos_orders contract columns + auto-mirror triggers keeping crm_discount_rules in sync with membership_plans + credit_tier_configs INSERT/UPDATE/DELETE. The till-side consumer wiring (the actual useResolverDiscounts call inside apps/{pos,terminal}/src/components/terminal/checkout-dialog.tsx) is delegated to the POS/Terminal dev (BUG-082) so ownership lives with the author of those apps. Once that follow-up merges, a PMS subscription/tier change will surface at the till the same as it does on the booking. Lesson + program consumer wiring deferred (BUG-074); OpenRally surface deferred (BUG-073).
Tier discount on lessons / programs (RESOLVED — column dropped in v0.23.10 per DEC-184)
removecredit_tier_configs.program_discount_pct was a stub field with no consumer. Per DEC-184 (2026-05-04) the column was dropped rather than wired. If lesson/program-specific tier discounts are wanted later, the right shape is per-program member_price_amount or a new dedicated mechanism, not a generic stub on the tier table.
Promo codes — PMS bookings
wireValidation endpoint at apps/admin/src/app/api/v1/promo-codes/validate/route.ts works. The booking POST never accepts a promo code. bookings.promo_code_id is a column that's never populated. Decide: wire it to the booking flow.
Promo codes — POS sales
wireNever wired to POS checkout at all. No input field, no validation call from POS. Decide: implement, or merge with PMS promo codes once #3 is wired.
Tier booking window (RESOLVED — column dropped in v0.23.10 per DEC-184, closes BUG-036)
removecredit_tier_configs.booking_window_days was returned by APIs and displayed in UI but never enforced. Rather than wire enforcement at booking POST + web availability, DEC-184 dropped the column. If per-tier booking windows are needed later, a focused PR will re-add the column with enforcement at the same time.
Membership max bookings cap
wiremembership_plans.max_bookings_per_period is displayed but no counting code exists. Decide: enforce (count bookings per current period at booking time) or remove the field.
PMS merchandise discount (RESOLVED — column dropped in v0.23.10 per DEC-184)
removecredit_tier_configs.merch_discount_pct was a stub field. PMS has no shop, POS is membership-blind. DEC-184 dropped the column. If a future PMS↔POS bridge (gap #1) wants tier-based merch discounts, it can re-add the column with the bridge consumer at the same time.
Legacy POS loyalty (pos_loyalty_*)
policyarch-crm-loyalty already plans to deprecate pos_loyalty_programs / accounts / transactions. Confirm migration path so we don't accidentally build new features on the legacy tables.
Unused pos_discounts table
auditpos_discounts (separate from pos_discount_presets) has auto_apply, conditions JSONB, max_uses columns. Zero references in source. Decide: was this a planned next-gen discount engine that was never built, or legacy? Drop or document.
Membership expiry cron
Headlinescheduleexpire_memberships() function exists at initial_schema.sql:1503 but isn't scheduled. The faq says memberships expire 'via a daily pg_cron job' — that job does not exist. Decide: schedule it (low effort) or rely fully on Stripe webhook events for status changes.
Persist discount value on bookings (PARTIALLY RESOLVED — v0.24.0 / DEC-186)
HeadlinepolicyResolved at the engine level by the Centralized Discount Engine: every commit_discount_applications call writes a row to crm_discount_applications with rule_id + discount_amount + document_type + document_id (the booking/lesson/order/match/program ID) + line_ref + currency + channel + applied_at. Reporting/audit no longer has to recompute on read — the ledger is authoritative. bookings.price_amount itself remains the final discounted amount (no schema change to bookings); the breakdown lives in crm_discount_applications. If a future report needs the original-pre-discount value, it can join crm_discount_applications back to the booking/lesson row.
Cancellation refund — discounted or original?
policyNo automatic refund logic exists today. When automatic Stripe refunds are added, the question becomes load-bearing: refund the discounted amount or the original price?
Web 'Pay with credits' toggle
wireBackend supports payment_method='credits'. Web UI does not. Decide: ship the player-facing toggle, kill it, or leave as an admin-only payment method.
Related
FAQ — Setpoint Pricing Model
How pricing works: court booking cascade, membership models, tiers, credits, Stripe, cancellations.
PRD — CRM Loyalty (unified system)
The system that plans to replace pos_loyalty_* and credit_tier_configs.
Arch — CRM Loyalty
5-table schema for the unified loyalty system. Read this for option (c) of the PMS↔POS bridge decision.
PRD — Loyalty Designer
Designer tool for the loyalty program inside Setpoint CRM.
Decisions log
Filter for DEC-152 (cost split deferred), DEC-162 (Stripe webhook hardening).