CRM PRD · crm-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.
Architecture
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
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
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
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
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
Database
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.
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
Indexes
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
Indexes
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
Indexes
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
Indexes
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
Indexes
DB Logic
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
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
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
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.
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
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.
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
Get facility loyalty program config (or null if not set up yet)
Create loyalty program for this facility
Update program settings (name, earn rules, tiers, expiry, is_active toggle)
Member's loyalty account: tier, balance, lifetime points, next tier progress %
Member's point transaction history — paginated, newest first
Manual point adjustment (positive or negative) with required reason text — fully audited
List all rewards for this facility (active + inactive)
Create a new reward in the catalogue
Update reward name, description, point cost, or type
Soft-delete reward: sets is_active = false. Existing redemptions unaffected.
Staff redeems a reward for a customer. Atomic: checks balance, deducts, logs redemption.
Top 10 point earners this month sorted by points earned in the current calendar month
Public API
Authenticated member: their tier, balance, lifetime points, and progress to next tier
Authenticated member: their last 20 ledger entries
Public: list active rewards and their point costs — read-only for members
Member UX
apps/web/src/app/[facility]/account/loyalty/page.tsx
New member loyalty page under the account section. Only visible to authenticated members.
Segments
loyalty_tier
Loyalty Tier
crm_loyalty_accounts.current_tier
current_points_balance
Current Points Balance
crm_loyalty_accounts.points_balance
lifetime_points
Lifetime Points
crm_loyalty_accounts.lifetime_points
Migration
Migration File
supabase/migrations/20260401000001_crm_loyalty_unified.sql
Steps
Deprecated (soft)
Build Plan
feat(db): crm_loyalty_unified migration — 5 tables, triggers, pg_cron, data migration
feat(crm): loyalty program settings page — enable, earn rules, tier config, expiry
feat(crm): loyalty rewards catalogue — create, edit, deactivate
feat(crm): member loyalty panel on contact detail — tier, balance, ledger, adjust, redeem
feat(web): member loyalty page — tier badge, balance, progress, history, rewards
feat(crm): add loyalty_tier + points_balance fields to segment rule builder
test(crm): loyalty & points full E2E test suite
chore(docs): loyalty & points demo video
QA