CRM PRD · crm-v1.4

CRM Loyalty & Points — v1.4

Unified loyalty & points system replacing pos_loyalty + credit_tier_configs. One program per facility. Earn from bookings, POS orders, and program enrollments. 4 configurable tiers (Bronze → Platinum). Staff-only redemption via CRM. Member view on web app.

Planning5 new tables15 API routes8 build phases0/30 AC done

Architecture

Key Decisions (DEC-120 – DEC-124)

DEC-120Unified system replacing pos_loyalty + credit_tier_configs

Decision

One CRM loyalty program per facility replaces both pos_loyalty_programs (POS-only) and credit_tier_configs (membership discount tiers). All point earning, tier calculation, and discount application goes through crm_loyalty_accounts.

Rationale

Two disconnected loyalty systems creates fragmented member experience and double maintenance burden. A single source of truth simplifies tier discounts, member views, and future integrations.

Alternatives Considered

  • Keep pos_loyalty and credit_tier_configs separate — too many sources of truth
  • Only migrate pos_loyalty and keep credit_tier_configs — still fragmented
DEC-121Tier based on lifetime_points — never decrements on redemption

Decision

crm_loyalty_accounts.current_tier is calculated from lifetime_points (total ever earned). Spending points decrements points_balance only. Tier cannot drop when a member redeems rewards.

Rationale

Industry standard (Starbucks, airline miles). Members who've earned Gold status shouldn't lose it just because they redeemed a reward. Lifetime points are a truer signal of member loyalty.

Alternatives Considered

  • Tier based on current balance — punishes redemption, deters member engagement
  • Tier locked after first reach — inflexible, doesn't incentivize continued spend
DEC-122One program per facility

Decision

A UNIQUE constraint on crm_loyalty_programs(facility_id) enforces exactly one program per facility. No multi-program support in v1.4.

Rationale

Simpler architecture. Single member UX (one balance, one tier). Multi-program adds join complexity with unclear member benefit. Can be re-evaluated post-v1.4 if a facility requests it.

Alternatives Considered

  • Multiple programs per facility — unnecessary complexity for v1.4 scope
  • No constraint, document by convention — unreliable over time
DEC-123Staff-only redemption in v1.4 — self-serve deferred

Decision

Reward redemption is performed by staff in the CRM admin only. Members see available rewards on the web app but cannot self-redeem. Self-serve redemption (member-initiated) is deferred to v1.5 or v2.0.

Rationale

Front-desk redemption is the current Pad Thai Padel workflow. Staff control prevents misuse during launch. Adds trust and auditability before opening self-serve. Reduces v1.4 scope and risk.

Alternatives Considered

  • Full self-serve redemption in v1.4 — too broad for initial launch
  • No redemption UI at all — doesn't complete the loyalty loop
DEC-124Member loyalty view on web app only (not OpenRally/TPS) for v1.4

Decision

Member-facing loyalty UI lives at apps/web /account/loyalty. OpenRally and TPS do not expose loyalty data in v1.4. OpenRally endpoints for loyalty are scoped to v1.5.

Rationale

Web app is the current member-facing product for Pad Thai Padel. TPS integration requires OpenRally Protocol changes outside v1.4 scope. Ship member visibility quickly via web app without blocking on protocol design.

Alternatives Considered

  • Build OpenRally loyalty endpoints in v1.4 — expands scope significantly
  • No member view at all — members can't see their points, devalues the program

Database

Database Schema — 5 New Tables

All tables use the crm_loyalty_ prefix and are facility-scoped with RLS. Written by DB triggers and the service role — staff have read-only access.

crm_loyalty_programs

One row per facility. Stores the program name, points name, earn rules JSONB, tier ladder JSONB, and expiry policy JSONB. UNIQUE on facility_id enforces single-program-per-facility.

DDL

CREATE TABLE crm_loyalty_programs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  facility_id uuid NOT NULL
    REFERENCES facilities(id) ON DELETE CASCADE,
  is_active boolean NOT NULL DEFAULT false,
  name text NOT NULL,
  points_name text NOT NULL DEFAULT 'pts',
  earn_rules jsonb NOT NULL DEFAULT '{}',
  tiers jsonb NOT NULL DEFAULT '[]',
  expiry_policy jsonb NOT NULL DEFAULT
    '{"enabled": false}',
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT crm_loyalty_programs_facility_unique
    UNIQUE (facility_id)
);

-- earn_rules shape:
-- {
--   "booking":  { "enabled": true,
--                 "type": "per_hour",
--                 "points": 10 },
--   "pos":      { "enabled": true,
--                 "points_per_100_units": 5 },
--   "program":  { "enabled": true,
--                 "points_per_enrollment": 50 }
-- }

-- tiers shape (array):
-- [
--   { "name": "Bronze",
--     "lifetime_threshold": 0,
--     "court_discount_pct": 0,
--     "pos_discount_pct": 0,
--     "perks": "Welcome tier" },
--   { "name": "Silver",
--     "lifetime_threshold": 500,
--     "court_discount_pct": 5,
--     "pos_discount_pct": 5,
--     "perks": "5% off all bookings" },
--   { "name": "Gold",
--     "lifetime_threshold": 1500,
--     "court_discount_pct": 10,
--     "pos_discount_pct": 10,
--     "perks": "10% off + priority booking" },
--   { "name": "Platinum",
--     "lifetime_threshold": 5000,
--     "court_discount_pct": 15,
--     "pos_discount_pct": 15,
--     "perks": "15% off + exclusive events" }
-- ]

-- expiry_policy shape:
-- { "enabled": true, "window_days": 365 }

RLS Policies

  • SELECT: staff+ (owner, admin, manager, coach) can read their facility's program
  • INSERT/UPDATE: owner and admin only
  • DELETE: not permitted (use is_active = false to disable)

Indexes

  • UNIQUE (facility_id) — enforces one program per facility
crm_loyalty_accounts

One row per member per facility. Tracks spendable points_balance, lifetime_points (never decremented), and cached current_tier. Written by DB triggers; staff read-only.

DDL

CREATE TABLE crm_loyalty_accounts (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  facility_id uuid NOT NULL
    REFERENCES facilities(id) ON DELETE CASCADE,
  program_id uuid NOT NULL
    REFERENCES crm_loyalty_programs(id) ON DELETE CASCADE,
  profile_id uuid NOT NULL
    REFERENCES customers(id) ON DELETE CASCADE,
  points_balance integer NOT NULL DEFAULT 0,
  lifetime_points integer NOT NULL DEFAULT 0,
  current_tier text NOT NULL DEFAULT 'Bronze',
  tier_updated_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT crm_loyalty_accounts_unique
    UNIQUE (facility_id, profile_id)
);

RLS Policies

  • SELECT: staff+ can read accounts for their facility
  • INSERT/UPDATE: service role only (triggers write here)
  • SELECT (web): authenticated member can read their own row

Indexes

  • UNIQUE (facility_id, profile_id)
  • INDEX ON (program_id)
  • INDEX ON (current_tier) — for segment filter queries
crm_loyalty_ledger

Append-only audit log of every point event. Positive = earned/adjusted-up. Negative = spent/expired. Source tracked via source_type + source_id for full traceability.

DDL

CREATE TABLE crm_loyalty_ledger (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  facility_id uuid NOT NULL
    REFERENCES facilities(id),
  program_id uuid NOT NULL
    REFERENCES crm_loyalty_programs(id),
  profile_id uuid NOT NULL
    REFERENCES customers(id),
  event_type text NOT NULL CHECK (event_type IN (
    'earned','spent','expired','adjusted','reversed'
  )),
  points integer NOT NULL,
  source_type text NOT NULL CHECK (source_type IN (
    'booking','pos_order','program_enrollment',
    'redemption','manual','expiry'
  )),
  source_id uuid,
  description text,
  expires_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  created_by uuid REFERENCES customers(id)
);

RLS Policies

  • SELECT: staff+ can read ledger for their facility
  • INSERT: service role only (triggers + admin API write here)
  • SELECT (web): authenticated member can read their own rows

Indexes

  • INDEX ON (profile_id, facility_id, created_at DESC) — member history queries
  • INDEX ON (expires_at) WHERE expires_at IS NOT NULL — pg_cron expiry scan
  • INDEX ON (source_type, source_id) — deduplication checks
crm_rewards

Rewards catalogue: items members can redeem with their points. Staff create/edit/deactivate. reward_value JSONB carries type-specific payload (credit amount, discount %, session minutes).

DDL

CREATE TABLE crm_rewards (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  facility_id uuid NOT NULL
    REFERENCES facilities(id) ON DELETE CASCADE,
  program_id uuid NOT NULL
    REFERENCES crm_loyalty_programs(id) ON DELETE CASCADE,
  name text NOT NULL,
  description text,
  point_cost integer NOT NULL CHECK (point_cost > 0),
  reward_type text NOT NULL CHECK (reward_type IN (
    'credit','discount_pct','free_session',
    'merchandise','other'
  )),
  reward_value jsonb,
  is_active boolean NOT NULL DEFAULT true,
  created_at timestamptz NOT NULL DEFAULT now()
);

-- reward_value examples:
-- credit:       { "amount": 500 }  (in smallest currency unit)
-- discount_pct: { "pct": 20 }
-- free_session: { "minutes": 60 }
-- merchandise:  { "sku": "tshirt-m" }

RLS Policies

  • SELECT: staff+ (and authenticated members on web) can read active rewards
  • INSERT/UPDATE: manager, admin, owner only
  • DELETE: not permitted (use is_active = false)

Indexes

  • INDEX ON (facility_id, is_active)
  • INDEX ON (program_id)
crm_reward_redemptions

Audit trail of every reward redemption. Links to the crm_loyalty_ledger 'spent' row, the reward, and the staff member who processed it.

DDL

CREATE TABLE crm_reward_redemptions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  facility_id uuid NOT NULL
    REFERENCES facilities(id),
  program_id uuid NOT NULL
    REFERENCES crm_loyalty_programs(id),
  reward_id uuid NOT NULL
    REFERENCES crm_rewards(id),
  profile_id uuid NOT NULL
    REFERENCES customers(id),
  ledger_entry_id uuid
    REFERENCES crm_loyalty_ledger(id),
  points_used integer NOT NULL,
  notes text,
  redeemed_at timestamptz NOT NULL DEFAULT now(),
  redeemed_by uuid NOT NULL REFERENCES customers(id)
);

RLS Policies

  • SELECT: staff+ can read redemptions for their facility
  • INSERT: service role only (redeem_loyalty_points() function writes here)
  • UPDATE/DELETE: not permitted — audit log is immutable

Indexes

  • INDEX ON (profile_id, facility_id, redeemed_at DESC)
  • INDEX ON (reward_id)
  • INDEX ON (redeemed_by)

DB Logic

Functions, Triggers & Scheduled Jobs

triggeron_booking_earn_loyalty_points()

Fires AFTER UPDATE on bookings WHERE new.status IN ('completed', 'checked_in'). Looks up the facility's active loyalty program, calculates points per earn_rules (flat or per_hour), inserts a ledger row, upserts crm_loyalty_accounts, and calls refresh_loyalty_tier().

Fires: AFTER UPDATE ON bookings — when status changes to completed or checked_in

triggeron_pos_order_earn_loyalty_points()

Fires AFTER UPDATE on pos_orders WHERE new.status = 'completed'. Calculates points using earn_rules.pos.points_per_100_units × (order total ÷ 100). Inserts ledger row, upserts account, refreshes tier.

Fires: AFTER UPDATE ON pos_orders — when status changes to completed

triggeron_program_enrollment_earn_loyalty_points()

Fires AFTER INSERT on program_enrollments. Awards earn_rules.program.points_per_enrollment flat points. Inserts ledger row, upserts account, refreshes tier.

Fires: AFTER INSERT ON program_enrollments

functionrefresh_loyalty_tier(profile_id, facility_id)

Recalculates current_tier by comparing lifetime_points against the program's tiers JSONB array thresholds. Updates crm_loyalty_accounts.current_tier and tier_updated_at. Called by all earn triggers.

cronexpire_loyalty_points(facility_id)

Nightly pg_cron job at 02:00. Finds all 'earned' ledger rows whose expires_at < now() that haven't been expired yet. Creates negative 'expired' ledger rows, decrements points_balance (not lifetime_points). Respects expiry_policy.enabled flag.

Schedule: 0 2 * * * — nightly at 02:00

functionget_loyalty_account(profile_id, facility_id)

Returns full account row enriched with: tier progress % toward next tier, points needed for next tier, and next tier name. Used by both admin API and web app API.

functionredeem_loyalty_points(profile_id, facility_id, reward_id, staff_id)

Atomic redemption function: verifies member balance >= reward.point_cost, deducts from points_balance, inserts negative ledger row (event_type='spent', source_type='redemption'), inserts crm_reward_redemptions row. Returns error if insufficient balance.

API

Admin CRM API (12 endpoints)

GET/api/admin/crm/loyalty/program

Get facility loyalty program config (or null if not set up yet)

Auth: Admin JWT (staff+)Returns: crm_loyalty_programs row or null
POST/api/admin/crm/loyalty/program

Create loyalty program for this facility

Auth: Admin JWT (owner/admin)Params: name, points_name, earn_rules, tiers, expiry_policyReturns: Created crm_loyalty_programs row
PUT/api/admin/crm/loyalty/program

Update program settings (name, earn rules, tiers, expiry, is_active toggle)

Auth: Admin JWT (owner/admin)Params: Partial crm_loyalty_programs fieldsReturns: Updated crm_loyalty_programs row
GET/api/admin/crm/loyalty/customers/[id]

Member's loyalty account: tier, balance, lifetime points, next tier progress %

Auth: Admin JWT (staff+)Returns: crm_loyalty_accounts enriched with tier progress
GET/api/admin/crm/loyalty/customers/[id]/ledger

Member's point transaction history — paginated, newest first

Auth: Admin JWT (staff+)Params: ?page=1&limit=20Returns: Paginated array of crm_loyalty_ledger rows
POST/api/admin/crm/loyalty/customers/[id]/adjust

Manual point adjustment (positive or negative) with required reason text — fully audited

Auth: Admin JWT (manager/admin/owner)Params: { points: number, reason: string }Returns: New ledger row
GET/api/admin/crm/loyalty/rewards

List all rewards for this facility (active + inactive)

Auth: Admin JWT (staff+)Returns: Array of crm_rewards rows
POST/api/admin/crm/loyalty/rewards

Create a new reward in the catalogue

Auth: Admin JWT (manager/admin/owner)Params: name, description, point_cost, reward_type, reward_valueReturns: Created crm_rewards row
PUT/api/admin/crm/loyalty/rewards/[id]

Update reward name, description, point cost, or type

Auth: Admin JWT (manager/admin/owner)Params: Partial crm_rewards fieldsReturns: Updated crm_rewards row
DELETE/api/admin/crm/loyalty/rewards/[id]

Soft-delete reward: sets is_active = false. Existing redemptions unaffected.

Auth: Admin JWT (manager/admin/owner)Returns: { success: true }
POST/api/admin/crm/loyalty/redeem

Staff redeems a reward for a customer. Atomic: checks balance, deducts, logs redemption.

Auth: Admin JWT (staff+)Params: { profile_id: string, reward_id: string, notes?: string }Returns: crm_reward_redemptions row or 400 if insufficient balance
GET/api/admin/crm/loyalty/leaderboard

Top 10 point earners this month sorted by points earned in the current calendar month

Auth: Admin JWT (staff+)Returns: Array of { profile, points_this_month, lifetime_points, current_tier }

Public API

Web App Public API (3 endpoints)

GET/api/v1/[facility]/loyalty

Authenticated member: their tier, balance, lifetime points, and progress to next tier

Auth: Web session JWT (authenticated member only — own account)Returns: crm_loyalty_accounts enriched with tier progress
GET/api/v1/[facility]/loyalty/history

Authenticated member: their last 20 ledger entries

Auth: Web session JWT (authenticated member only — own account)Returns: Array of crm_loyalty_ledger rows
GET/api/v1/[facility]/loyalty/rewards

Public: list active rewards and their point costs — read-only for members

Auth: None required (public endpoint)Returns: Array of active crm_rewards rows

Member UX

Web App — Member Loyalty Page

apps/web/src/app/[facility]/account/loyalty/page.tsx

New member loyalty page under the account section. Only visible to authenticated members.

  • Tier badge — current tier name with tier-specific accent color (Bronze/Silver/Gold/Platinum)
  • Points balance — large number display, labeled with facility's points_name
  • Progress bar — X pts to next tier, with tier name and threshold
  • Earn summary — how points are earned at this facility (reads earn_rules JSONB)
  • Transaction history table — last 20 ledger rows: date, event type, source, ±delta
  • Rewards catalogue — list of active rewards with point costs (read-only, note: 'Redeem at the front desk')

Segments

New Segment Filter Fields

loyalty_tier

Loyalty Tier

multiselectBronzeSilverGoldPlatinum

crm_loyalty_accounts.current_tier

current_points_balance

Current Points Balance

numbergtltgtelteeq

crm_loyalty_accounts.points_balance

lifetime_points

Lifetime Points

numbergtltgtelteeq

crm_loyalty_accounts.lifetime_points

Migration

Migration Strategy

Migration File

supabase/migrations/20260401000001_crm_loyalty_unified.sql

Steps

  1. 1Create 5 new crm_loyalty_* tables with RLS policies and indexes
  2. 2Create trigger functions: on_booking_earn_loyalty_points, on_pos_order_earn_loyalty_points, on_program_enrollment_earn_loyalty_points
  3. 3Create helper functions: refresh_loyalty_tier, get_loyalty_account, redeem_loyalty_points
  4. 4Register pg_cron job: expire_loyalty_points — nightly at 02:00
  5. 5Migrate existing pos_loyalty_accounts data into crm_loyalty_accounts (IF EXISTS)
  6. 6Migrate existing pos_loyalty_transactions into crm_loyalty_ledger
  7. 7Add deprecated boolean DEFAULT false to pos_loyalty_programs (soft deprecation — keep data)
  8. 8Update calculate_member_price() to read discount from crm_loyalty_accounts.current_tier + program tiers JSONB, replacing credit_tier_configs lookup
  9. 9Keep credit_tier_configs table intact — no DROP (migration safety, revert path)

Deprecated (soft)

pos_loyalty_programspos_loyalty_accountspos_loyalty_transactionscredit_tier_configs (discount logic only)

Build Plan

Implementation Phases

1
DB Migration

feat(db): crm_loyalty_unified migration — 5 tables, triggers, pg_cron, data migration

  • Write 20260401000001_crm_loyalty_unified.sql
  • 5 tables: crm_loyalty_programs, crm_loyalty_accounts, crm_loyalty_ledger, crm_rewards, crm_reward_redemptions
  • Trigger: on_booking_earn_loyalty_points (completed/checked_in status change)
  • Trigger: on_pos_order_earn_loyalty_points (pos_orders status = completed)
  • Trigger: on_program_enrollment_earn_loyalty_points (INSERT)
  • Function: refresh_loyalty_tier(profile_id, facility_id)
  • Function: get_loyalty_account(profile_id, facility_id)
  • Function: redeem_loyalty_points(profile_id, facility_id, reward_id, staff_id)
  • pg_cron: expire_loyalty_points — nightly at 02:00
  • Data migration: pos_loyalty_accounts → crm_loyalty_accounts
  • Data migration: pos_loyalty_transactions → crm_loyalty_ledger
  • Update calculate_member_price() — swap credit_tier_configs for crm loyalty tier discount
  • supabase db reset — verify zero errors
2
Program Settings UI

feat(crm): loyalty program settings page — enable, earn rules, tier config, expiry

  • apps/admin/src/app/[facility]/crm/loyalty/page.tsx — program overview page
  • Program status card (enabled toggle, name, points name)
  • Earn rates summary (booking, POS, programs)
  • Tier ladder visual (Bronze → Silver → Gold → Platinum)
  • Top earners leaderboard (calls /leaderboard API)
  • apps/admin/src/app/[facility]/crm/loyalty/settings/page.tsx — settings form
  • Program name + points name inputs
  • Earn rules toggle + rate inputs per source type
  • Tier configuration: 4 rows, each with name, threshold, court %, POS %, perks
  • Expiry policy: enabled toggle + window_days select
  • Admin API: GET/POST/PUT /api/admin/crm/loyalty/program
3
Rewards Catalogue UI

feat(crm): loyalty rewards catalogue — create, edit, deactivate

  • apps/admin/src/app/[facility]/crm/loyalty/rewards/page.tsx — rewards list + create/edit dialog
  • Table: name, type, point cost, status (active/inactive)
  • Create/Edit dialog: name, description, point_cost, reward_type, reward_value
  • Deactivate button (soft delete)
  • Admin API: GET/POST /api/admin/crm/loyalty/rewards
  • Admin API: PUT/DELETE /api/admin/crm/loyalty/rewards/[id]
4
Member Loyalty Panel

feat(crm): member loyalty panel on contact detail — tier, balance, ledger, adjust, redeem

  • Add Loyalty tab/section to apps/crm/src/app/[facility]/contacts/[id]/page.tsx
  • Tier badge: current tier name + accent color
  • Points balance + lifetime points
  • Progress bar: pts to next tier
  • Full ledger table: date, event type, source, delta (paginated)
  • Manual adjustment dialog: ±points + reason (required)
  • Staff redemption panel: select reward → confirm → deduct
  • Admin API: GET /api/admin/crm/loyalty/customers/[id]
  • Admin API: GET /api/admin/crm/loyalty/customers/[id]/ledger
  • Admin API: POST /api/admin/crm/loyalty/customers/[id]/adjust
  • Admin API: POST /api/admin/crm/loyalty/redeem
  • Admin API: GET /api/admin/crm/loyalty/leaderboard
5
Web App Member View

feat(web): member loyalty page — tier badge, balance, progress, history, rewards

  • apps/web/src/app/[facility]/account/loyalty/page.tsx
  • Tier badge with tier-specific color
  • Points balance (large, labeled with points_name)
  • Progress bar to next tier
  • Earn summary (how to earn at this facility)
  • Transaction history: last 20 rows
  • Rewards catalogue: read-only, note 'Redeem at the front desk'
  • Public API: GET /api/v1/[facility]/loyalty
  • Public API: GET /api/v1/[facility]/loyalty/history
  • Public API: GET /api/v1/[facility]/loyalty/rewards
  • Add Loyalty link to account nav
6
Segment Filter Fields

feat(crm): add loyalty_tier + points_balance fields to segment rule builder

  • Add loyalty_tier, current_points_balance, lifetime_points to segment rule field definitions
  • Update segment SQL generation to JOIN crm_loyalty_accounts on profile_id
  • Update segment preview count query to handle loyalty fields
  • Verify pg_cron segment refresh still works with new join
7
E2E Tests

test(crm): loyalty & points full E2E test suite

  • apps/admin/e2e/crm-loyalty.e2e.ts — full test suite covering all 30 AC items
  • Test: create loyalty program, configure earn rules, add tiers, enable
  • Test: create reward, edit reward, deactivate reward
  • Test: trigger earn (booking completion → points awarded via trigger)
  • Test: staff redemption flow — select member, choose reward, confirm, verify balance deducted
  • Test: manual adjustment — add and deduct points with reason
  • Test: member panel shows correct tier, balance, ledger
  • Test: web app /account/loyalty renders tier badge, balance, history
  • Test: segment filter with loyalty_tier = 'Gold' returns correct members
  • Run supabase db reset && npx playwright test — zero failures required
8
Demo Video

chore(docs): loyalty & points demo video

  • apps/admin/e2e/demo-crm-loyalty.e2e.ts — silent screen recording
  • Show: create program → configure tiers → create rewards
  • Show: member earns points (booking completion)
  • Show: staff views member panel → redeems reward → balance updates
  • Show: web app member loyalty page — tier badge, progress bar, history
  • Copy .webm files to apps/docs/public/demos/
  • Update apps/docs/src/data/deployment.ts with demo entries

QA

Acceptance Criteria (0/30 complete)

Loyalty program can be created, enabled, and disabled per facility
Custom points name supported (pts / stars / tokens / custom text)
Earn rule: flat points per booking (e.g., 10 pts per booking)
Earn rule: points per hour played (e.g., 10 pts/hr)
Earn rule: points per POS spend (e.g., 5 pts per 100 THB)
Earn rule: flat points per program enrollment
Points awarded automatically via DB trigger — no staff action needed
4 tiers with configurable names (default: Bronze / Silver / Gold / Platinum)
Tier thresholds are configurable lifetime-points values
Each tier has configurable court discount % and POS discount %
Each tier has a freetext perks description visible to members
Tier is calculated from lifetime_points — balance decrements do not drop tier
Points expiry policy: configurable window (90 / 180 / 365 days or never)
Nightly pg_cron job expires stale points and decrements spendable balance
Rewards catalogue: create, edit, deactivate rewards with point cost + type
Reward types: court credit, discount %, free session, merchandise, other
Staff redemption flow: select member → choose reward → confirm → points deducted + redemption logged
Cannot redeem if member balance < reward cost — error shown to staff
Manual point adjustment: staff can add/deduct points with reason (fully audited)
Member loyalty panel on contact detail: tier badge, balance, progress bar, full ledger
Ledger shows: date, event type (earned/spent/expired/adjusted), source, points delta
Admin loyalty overview page: program status, earn rates, tier ladder, leaderboard
Web app: authenticated member sees tier badge + points balance on account page
Web app: progress bar showing points needed to reach next tier
Web app: points transaction history (last 20 entries)
Web app: rewards catalogue visible (read-only, note 'Redeem at front desk')
Segment rule builder has loyalty_tier and points_balance filter fields
pos_loyalty_programs deprecated — existing data migrated to crm_loyalty
credit_tier_configs discounts replaced by crm loyalty tier discounts in calculate_member_price()
All loyalty API routes protected by facility staff JWT — no public write access