Architecture · snapshot-2026-05-06

Discounts, Memberships & Groups — PMS + POS + Terminal Snapshot

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

PMS: 5 wired · 2 stubsPOS / Terminal: 6 wired · 3 stubs7 group concepts13 gaps to decide

Overview

How the system is structured today

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

PMS ↔ POS integration — read this first

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

PMS discount & membership mechanisms (11)

#1

Subscription plan discount %

Connected

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 %

Connected

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

Connected

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 %

Deprecated

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 %

Deprecated

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)

Stub

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)

Connected

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

Connected

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)

Deprecated

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

Stub

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)

Partial

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

POS / Terminal discount mechanisms (12)

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

Connected

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

Connected

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

Connected

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

Connected

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)

Connected

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

Partial

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

Stub

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

Stub

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

Partial

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

Connected

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

Stub

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_*)

Deprecated

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

Every "group" concept and whether it affects price

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 only

pos_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-loyalty

Contract

How a discount lands on a booking row

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

  • bookings.promo_code_id exists but is never populated (gap #3).
  • bookings.refund_amount and refund_status are admin-set; no automatic refund calculation runs on cancellation. Refunds today are manual via Stripe dashboard.
  • Reporting that needs the discount value has to recompute it on read.

Edge case

Multi-player bookings × discounts

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

  • finalPrice = calculate_member_price(...) runs once for the organizer
  • cost_per_player_amount = finalPrice / players.length when cost_split = "split"
  • Reference: apps/admin/src/app/api/admin/bookings/route.ts:273, 310, 478

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

Cron coverage — including a missing job

cleanup-expired-holds

every 1 minute

Releases held court slots after 5-min TTL

no

auto-confirm-match-results

every 5 minutes

Resolves match-result disputes

no

expire_player_packages

hourly

Marks packages past expires_at as expired

Affects whether a package session can be redeemed at lesson confirmation.

indirect

webhook-delivery-worker

every 10 seconds

Delivers pending webhooks

no

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.

Not scheduled

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

Stripe wiring (memberships)

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

Where each thing lives in the apps

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

Gaps the boss needs to decide on (13)

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

1

PMS ↔ POS membership integration (FOUNDATION RESOLVED — v0.24.0 / DEC-186; till consumer pending — BUG-082)

Headlinepolicy

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

2

Tier discount on lessons / programs (RESOLVED — column dropped in v0.23.10 per DEC-184)

remove

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

3

Promo codes — PMS bookings

wire

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

4

Promo codes — POS sales

wire

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

5

Tier booking window (RESOLVED — column dropped in v0.23.10 per DEC-184, closes BUG-036)

remove

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

6

Membership max bookings cap

wire

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

7

PMS merchandise discount (RESOLVED — column dropped in v0.23.10 per DEC-184)

remove

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

8

Legacy POS loyalty (pos_loyalty_*)

policy

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

9

Unused pos_discounts table

audit

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

10

Membership expiry cron

Headlineschedule

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

11

Persist discount value on bookings (PARTIALLY RESOLVED — v0.24.0 / DEC-186)

Headlinepolicy

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

12

Cancellation refund — discounted or original?

policy

No automatic refund logic exists today. When automatic Stripe refunds are added, the question becomes load-bearing: refund the discounted amount or the original price?

13

Web 'Pay with credits' toggle

wire

Backend 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

Related documentation