Architecture · crm-v1.4
Unified Points, Tiers & Rewards
Complete architecture for the Setpoint CRM loyalty & points system. Covers the 5-table database schema, trigger-driven point earning, tier calculation, pg_cron expiry, staff redemption flow, admin + public API layers, RLS security model, and deprecation of pos_loyalty + credit_tier_configs.
Overview
Placement
The loyalty system lives entirely within the Setpoint admin app (apps/admin). It is a CRM-owned subsystem that integrates with three event sources (bookings, POS orders, program enrollments) via PostgreSQL triggers. Staff interact through the CRM admin UI. Members read their loyalty data through the public web app (apps/web) via the v1 API.
Data Flow
Event fires (booking/POS/program) → DB trigger evaluates earn_rules JSONB → inserts crm_loyalty_ledger row → upserts crm_loyalty_accounts (balance + lifetime) → calls refresh_loyalty_tier() → tier cached on account row. Staff reads via admin API. Member reads via public v1 API.
Scope Boundaries
Diagram
System Diagram — Event Sources → Core → API
┌─────────────────────────────────────────────────────────────────────────────┐ │ SETPOINT LOYALTY SYSTEM │ ├───────────────────────────┬─────────────────────────────────────────────────┤ │ EVENT SOURCES │ CRM LOYALTY CORE │ │ │ │ │ ┌─────────────────────┐ │ ┌─────────────┐ ┌────────────────────────┐ │ │ │ bookings │──┼─▶│ DB Trigger │───▶│ crm_loyalty_ledger │ │ │ │ (status → complete) │ │ │ on_booking_ │ │ (append-only log) │ │ │ └─────────────────────┘ │ │ earn_loyalty │ └───────────┬────────────┘ │ │ │ └─────────────┘ │ │ │ ┌─────────────────────┐ │ ┌─────────────┐ ▼ │ │ │ pos_orders │──┼─▶│ DB Trigger │ ┌────────────────────────┐ │ │ │ (status → complete) │ │ │ on_pos_order_│───▶│ crm_loyalty_accounts │ │ │ └─────────────────────┘ │ │ earn_loyalty │ │ balance + lifetime │ │ │ │ └─────────────┘ │ + cached tier │ │ │ ┌─────────────────────┐ │ ┌─────────────┐ └───────────┬────────────┘ │ │ │ program_enrollments │──┼─▶│ DB Trigger │ │ │ │ │ (AFTER INSERT) │ │ │ on_program_ │ refresh_ │ │ │ └─────────────────────┘ │ │ earn_loyalty │ loyalty_ │ │ │ │ └─────────────┘ tier() ▼ │ │ ┌─────────────────────┐ │ ┌─────────────┐ ┌────────────────────────┐ │ │ │ pg_cron (nightly) │──┼─▶│ expire_ │ │ crm_loyalty_programs │ │ │ │ 02:00 facility TZ │ │ │ loyalty_pts │ │ (earn_rules JSONB) │ │ │ └─────────────────────┘ │ └─────────────┘ │ (tiers JSONB) │ │ │ │ └────────────────────────┘ │ ├───────────────────────────┼─────────────────────────────────────────────────┤ │ REDEMPTION FLOW │ REWARDS CATALOGUE │ │ │ │ │ Staff selects member │ ┌────────────────────────────────────────────┐ │ │ Staff picks reward ────┼─▶│ redeem_loyalty_points() │ │ │ [CRM admin UI] │ │ 1. CHECK balance >= reward.point_cost │ │ │ │ │ 2. INSERT ledger (spent, negative) │ │ │ │ │ 3. UPDATE account (balance -=) │ │ │ │ │ 4. INSERT crm_reward_redemptions │ │ │ │ └──────────────────┬─────────────────────────┘ │ │ │ │ │ │ │ ┌──────────────────▼─────────────────────────┐ │ │ │ │ crm_rewards catalogue │ │ │ │ │ (court_credit / discount_pct / │ │ │ │ │ free_session / merchandise / other) │ │ │ │ └────────────────────────────────────────────┘ │ ├───────────────────────────┴─────────────────────────────────────────────────┤ │ API LAYER │ │ │ │ apps/admin (Next.js) apps/web (Next.js) │ │ ┌──────────────────────────────────┐ ┌───────────────────────────────┐ │ │ │ /api/admin/crm/loyalty/* │ │ /api/v1/[facility]/loyalty/* │ │ │ │ JWT (staff+) — 12 endpoints │ │ Web session JWT — 3 endpoints │ │ │ └──────────────────────────────────┘ └───────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────┘
Database
All tables share the crm_loyalty_ prefix and are scoped by facility_id with row-level security enforced on every table.
One row per facility. Defines the entire program: name, points currency, earn rules (JSONB), tier ladder (JSONB), expiry policy (JSONB), and active flag.
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 JSONB shape:
-- {
-- "booking": { "enabled": true, "type": "per_hour", "points": 10 },
-- "pos": { "enabled": true, "points_per_100_units": 5 },
-- "program": { "enabled": false, "points_per_enrollment": 50 }
-- }
-- tiers JSONB shape (array, ordered ascending by threshold):
-- [
-- { "name": "Bronze", "lifetime_threshold": 0, "court_discount_pct": 0, "pos_discount_pct": 0, "perks": "..." },
-- { "name": "Silver", "lifetime_threshold": 500, "court_discount_pct": 5, "pos_discount_pct": 5, "perks": "..." },
-- { "name": "Gold", "lifetime_threshold": 1500, "court_discount_pct": 10, "pos_discount_pct": 10, "perks": "..." },
-- { "name": "Platinum", "lifetime_threshold": 5000, "court_discount_pct": 15, "pos_discount_pct": 15, "perks": "..." }
-- ]
-- expiry_policy JSONB shape:
-- { "enabled": true, "window_days": 365 }RLS Policies
facility_id = auth.facility_id() AND role IN ('owner','admin','manager','coach')
facility_id = auth.facility_id() AND role IN ('owner','admin')
facility_id = auth.facility_id() AND role IN ('owner','admin')
Not permitted — use is_active = false to disable
Indexes
UNIQUE (facility_id)
Enforces one program per facility — the core architectural constraint
Notes
One row per member per facility. Tracks spendable points_balance, lifetime_points (monotonically increasing), and cached current_tier. The authoritative source of a member's loyalty state.
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 CHECK (points_balance >= 0), lifetime_points integer NOT NULL DEFAULT 0 CHECK (lifetime_points >= 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
facility_id = auth.facility_id()
profile_id = auth.uid() — own row only
Only triggers and server-side functions write here
Not permitted
Indexes
UNIQUE (facility_id, profile_id)
One account per member per facility; also the upsert target key
INDEX ON (program_id)
Program-scoped queries (leaderboard, tier distribution)
INDEX ON (facility_id, current_tier)
Segment filter: loyalty_tier = 'Gold'
Notes
Append-only audit log of every point event. Positive rows = earned or adjusted-up. Negative rows = spent, expired, or adjusted-down. The source of truth for all balance calculations.
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, -- positive: earned/adjusted-up | negative: spent/expired
source_type text NOT NULL CHECK (source_type IN (
'booking','pos_order','program_enrollment',
'redemption','manual','expiry'
)),
source_id uuid, -- FK to booking/order/enrollment; null for manual/expiry
description text,
expires_at timestamptz, -- set on 'earned' rows when expiry policy is enabled
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES customers(id) -- null = trigger; uuid = staff member
);RLS Policies
facility_id = auth.facility_id()
profile_id = auth.uid()
Triggers + admin API only
Immutable — audit log cannot be modified
Indexes
INDEX ON (profile_id, facility_id, created_at DESC)
Primary query: member's transaction history, newest first
INDEX ON (expires_at) WHERE expires_at IS NOT NULL
pg_cron nightly scan: find expired rows efficiently
INDEX ON (source_type, source_id)
Deduplication: check if a booking already earned points before inserting
Notes
Catalogue of redeemable rewards. Each reward has a point cost, a type (court_credit / discount_pct / free_session / merchandise / other), and a JSONB payload encoding the reward value.
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 (
'court_credit','discount_pct','free_session',
'merchandise','other'
)),
reward_value jsonb, -- type-specific payload (see examples below)
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- reward_value examples:
-- court_credit: { "amount": 140000 } ← in facility currency smallest unit
-- discount_pct: { "pct": 20 } ← 20% off one booking
-- free_session: { "minutes": 60 } ← 60-min free court time
-- merchandise: { "description": "Pad Thai Padel branded tee, choose size at desk" }RLS Policies
facility_id = auth.facility_id()
is_active = true — public catalogue read
role IN ('manager','admin','owner')
Not permitted — use is_active = false
Indexes
INDEX ON (facility_id, is_active)
Most queries filter active rewards for a facility
INDEX ON (program_id)
Fetch all rewards for a program
Notes
Immutable audit trail of every redemption event. Links the reward, the member, the ledger 'spent' row, and the staff member who processed it. Used for reporting, dispute resolution, and reward fulfilment tracking.
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
facility_id = auth.facility_id()
redeem_loyalty_points() function only
Immutable audit record
Indexes
INDEX ON (profile_id, facility_id, redeemed_at DESC)
Member redemption history
INDEX ON (reward_id)
Reward usage reporting
INDEX ON (redeemed_by)
Staff activity auditing
Notes
DB Logic
Fires: AFTER UPDATE ON bookings — when NEW.status IN ('completed','checked_in') AND OLD.status NOT IN ('completed','checked_in')
Pseudocode / Implementation
CREATE OR REPLACE FUNCTION on_booking_earn_loyalty_points()
RETURNS TRIGGER AS $$
DECLARE
v_program crm_loyalty_programs%ROWTYPE;
v_points integer;
v_hours numeric;
BEGIN
-- 1. Find active loyalty program for this facility
SELECT * INTO v_program
FROM crm_loyalty_programs
WHERE facility_id = NEW.facility_id AND is_active = true;
IF NOT FOUND THEN RETURN NEW; END IF; -- no program, skip
-- 2. Check earn_rules.booking.enabled
IF NOT (v_program.earn_rules->'booking'->>'enabled')::boolean THEN
RETURN NEW;
END IF;
-- 3. Deduplication: already earned for this booking?
IF EXISTS (
SELECT 1 FROM crm_loyalty_ledger
WHERE source_type = 'booking' AND source_id = NEW.id
) THEN RETURN NEW; END IF;
-- 4. Calculate points
IF v_program.earn_rules->'booking'->>'type' = 'per_hour' THEN
v_hours := EXTRACT(EPOCH FROM (NEW.end_time - NEW.start_time)) / 3600.0;
v_points := ROUND(v_hours * (v_program.earn_rules->'booking'->>'points')::integer);
ELSE
v_points := (v_program.earn_rules->'booking'->>'points')::integer;
END IF;
IF v_points <= 0 THEN RETURN NEW; END IF;
-- 5. Insert ledger row
INSERT INTO crm_loyalty_ledger (facility_id, program_id, profile_id,
event_type, points, source_type, source_id, description, expires_at)
VALUES (
NEW.facility_id, v_program.id, NEW.profile_id,
'earned', v_points, 'booking', NEW.id,
'Court booking — ' || to_char(NEW.start_time, 'DD Mon YYYY'),
CASE WHEN (v_program.expiry_policy->>'enabled')::boolean
THEN now() + ((v_program.expiry_policy->>'window_days')::integer || ' days')::interval
ELSE NULL END
);
-- 6. Upsert account balance
INSERT INTO crm_loyalty_accounts
(facility_id, program_id, profile_id, points_balance, lifetime_points)
VALUES (NEW.facility_id, v_program.id, NEW.profile_id, v_points, v_points)
ON CONFLICT (facility_id, profile_id)
DO UPDATE SET
points_balance = crm_loyalty_accounts.points_balance + v_points,
lifetime_points = crm_loyalty_accounts.lifetime_points + v_points;
-- 7. Refresh tier
PERFORM refresh_loyalty_tier(NEW.profile_id, NEW.facility_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trg_booking_earn_loyalty
AFTER UPDATE ON bookings
FOR EACH ROW
WHEN (
NEW.status IN ('completed','checked_in') AND
OLD.status NOT IN ('completed','checked_in')
)
EXECUTE FUNCTION on_booking_earn_loyalty_points();Implementation Notes
Pseudocode / Implementation
CREATE OR REPLACE FUNCTION refresh_loyalty_tier(
p_profile_id uuid,
p_facility_id uuid
) RETURNS void AS $$
DECLARE
v_lifetime integer;
v_program crm_loyalty_programs%ROWTYPE;
v_tier text := 'Bronze';
v_tier_obj jsonb;
BEGIN
-- Get current lifetime points
SELECT lifetime_points INTO v_lifetime
FROM crm_loyalty_accounts
WHERE profile_id = p_profile_id AND facility_id = p_facility_id;
-- Get program tiers
SELECT * INTO v_program
FROM crm_loyalty_programs
WHERE facility_id = p_facility_id AND is_active = true;
-- Walk tiers (sorted ascending by threshold — enforced at app layer)
FOR v_tier_obj IN
SELECT value FROM jsonb_array_elements(v_program.tiers) AS t(value)
ORDER BY (value->>'lifetime_threshold')::integer ASC
LOOP
IF v_lifetime >= (v_tier_obj->>'lifetime_threshold')::integer THEN
v_tier := v_tier_obj->>'name';
END IF;
END LOOP;
-- Update cached tier on account
UPDATE crm_loyalty_accounts
SET current_tier = v_tier,
tier_updated_at = now()
WHERE profile_id = p_profile_id AND facility_id = p_facility_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;Implementation Notes
Pseudocode / Implementation
CREATE OR REPLACE FUNCTION redeem_loyalty_points(
p_profile_id uuid,
p_facility_id uuid,
p_reward_id uuid,
p_staff_id uuid,
p_notes text DEFAULT NULL
) RETURNS uuid AS $$ -- returns redemption id
DECLARE
v_account crm_loyalty_accounts%ROWTYPE;
v_reward crm_rewards%ROWTYPE;
v_ledger_id uuid;
v_redemp_id uuid;
BEGIN
-- Lock account row to prevent concurrent race conditions
SELECT * INTO v_account
FROM crm_loyalty_accounts
WHERE profile_id = p_profile_id AND facility_id = p_facility_id
FOR UPDATE;
SELECT * INTO v_reward
FROM crm_rewards
WHERE id = p_reward_id AND facility_id = p_facility_id AND is_active = true;
-- Guard: reward not found
IF NOT FOUND THEN
RAISE EXCEPTION 'Reward not found or inactive' USING ERRCODE = 'P0001';
END IF;
-- Guard: insufficient balance
IF v_account.points_balance < v_reward.point_cost THEN
RAISE EXCEPTION 'Insufficient balance: % pts available, % pts required',
v_account.points_balance, v_reward.point_cost
USING ERRCODE = 'P0002';
END IF;
-- 1. Insert negative ledger row
INSERT INTO crm_loyalty_ledger
(facility_id, program_id, profile_id, event_type, points,
source_type, description, created_by)
VALUES (
p_facility_id, v_account.program_id, p_profile_id,
'spent', -v_reward.point_cost,
'redemption', 'Redeemed: ' || v_reward.name, p_staff_id
)
RETURNING id INTO v_ledger_id;
-- 2. Decrement spendable balance (NOT lifetime_points)
UPDATE crm_loyalty_accounts
SET points_balance = points_balance - v_reward.point_cost
WHERE id = v_account.id;
-- 3. Insert redemption record
INSERT INTO crm_reward_redemptions
(facility_id, program_id, reward_id, profile_id,
ledger_entry_id, points_used, notes, redeemed_by)
VALUES (
p_facility_id, v_account.program_id, p_reward_id, p_profile_id,
v_ledger_id, v_reward.point_cost, p_notes, p_staff_id
)
RETURNING id INTO v_redemp_id;
RETURN v_redemp_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;Implementation Notes
Schedule: 0 2 * * * — nightly at 02:00 facility local time via pg_cron
Pseudocode / Implementation
-- pg_cron job (registered per facility on program activation):
-- SELECT cron.schedule(
-- 'expire-loyalty-' || facility_id,
-- '0 2 * * *',
-- format('SELECT expire_loyalty_points(%L)', facility_id)
-- );
CREATE OR REPLACE FUNCTION expire_loyalty_points(p_facility_id uuid)
RETURNS void AS $$
DECLARE
v_row RECORD;
BEGIN
FOR v_row IN
SELECT l.profile_id,
SUM(ABS(l.points)) AS points_to_expire
FROM crm_loyalty_ledger l
WHERE l.facility_id = p_facility_id
AND l.event_type = 'earned'
AND l.expires_at < now()
AND NOT EXISTS (
SELECT 1 FROM crm_loyalty_ledger l2
WHERE l2.source_type = 'expiry'
AND l2.source_id = l.id
)
GROUP BY l.profile_id
LOOP
-- Insert negative expiry ledger row
INSERT INTO crm_loyalty_ledger
(facility_id, program_id, profile_id, event_type, points, source_type, description)
SELECT p_facility_id, program_id, v_row.profile_id,
'expired', -v_row.points_to_expire, 'expiry',
'Points expired — ' || to_char(now(), 'DD Mon YYYY')
FROM crm_loyalty_accounts
WHERE profile_id = v_row.profile_id AND facility_id = p_facility_id;
-- Decrement spendable balance (floor at 0)
UPDATE crm_loyalty_accounts
SET points_balance = GREATEST(0, points_balance - v_row.points_to_expire)
WHERE profile_id = v_row.profile_id
AND facility_id = p_facility_id;
END LOOP;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;Implementation Notes
Integrations
bookings table
bookings → loyalty
trg_booking_earn_loyalty fires when status changes to 'completed' or 'checked_in'. Reads earn_rules JSONB to calculate points. Creates ledger row + upserts account.
Impact: Zero change to booking flow. Trigger fires transparently — staff do not need to take any action.
pos_orders table
pos_orders → loyalty
trg_pos_order_earn_loyalty fires when status changes to 'completed'. Points = floor(order_total / 100) × earn_rules.pos.points_per_100_units.
Impact: Zero change to POS checkout flow. Points awarded automatically when cashier marks order complete.
program_enrollments table
enrollments → loyalty
trg_program_enrollment_earn_loyalty fires on any new enrollment row (paid or free). Awards earn_rules.program.points_per_enrollment flat points.
Impact: Zero change to program enrollment flow. Bonus points awarded automatically.
calculate_member_price() function
loyalty → pricing
The existing calculate_member_price() function is updated to read court_discount_pct from the member's crm_loyalty_accounts.current_tier against the program's tiers JSONB, replacing the credit_tier_configs lookup. The function signature stays the same — callers unchanged.
Impact: Court discounts now flow from CRM loyalty tiers instead of credit_tier_configs. Members get discounts automatically at checkout based on their loyalty tier.
crm_segments (segment rule builder)
loyalty → segments
Three new filter fields added: loyalty_tier (multiselect), current_points_balance (number comparison), lifetime_points (number comparison). Segment SQL generator JOINs crm_loyalty_accounts ON profile_id.
Impact: Operators can create segments like 'Gold members with 200+ pts' for targeted campaigns.
apps/web (public web app)
loyalty → member
Three new GET endpoints at /api/v1/[facility]/loyalty — account, history, rewards. Loyalty page added at /[facility]/account/loyalty.
Impact: Members can see their tier, balance, progress, and available rewards on the web app.
Security
Auth Layers
Admin API
/api/admin/crm/loyalty/*
JWT (Supabase session) + facility scope check
createAdminClient() in every API route. facility_id extracted from URL param, verified against JWT claims.
Public API
/api/v1/[facility]/loyalty/*
Web session JWT (member must be logged in)
supabase.auth.getUser() in API routes. profile_id extracted from JWT — member can only read own loyalty data.
Rewards Catalogue (public read)
GET /api/v1/[facility]/loyalty/rewards
None required — public endpoint
RLS policy: SELECT on crm_rewards WHERE is_active = true. No auth required for catalogue display.
DB Triggers
N/A — DB level
SECURITY DEFINER (service role)
All trigger functions run as SECURITY DEFINER — they bypass RLS. This is required since the trigger fires in the context of the booking/order update, not in a staff session.
Write Protection
Race Condition Handling
redeem_loyalty_points() uses SELECT ... FOR UPDATE on the account row to prevent concurrent redemptions both passing the balance check. Earn triggers use INSERT ... ON CONFLICT DO UPDATE (upsert) which is atomic at the row level.
Migration
Migration File
supabase/migrations/20260401000001_crm_loyalty_unified.sql
Single migration file. Additive — no existing tables dropped. pos_loyalty soft-deprecated (deprecated flag added). credit_tier_configs kept intact.
Steps (in order)
Not Dropped (kept intact)
Rollback Plan
All changes are additive. To roll back: drop crm_loyalty_* tables, restore original calculate_member_price() function body from git history.
API
Admin CRM API — 12 endpoints (apps/admin)
Public API v1 — 3 endpoints (apps/web)
Performance
current_tier cache
current_tier on crm_loyalty_accounts is a denormalised cache of the calculated tier. This avoids re-reading and traversing the tiers JSONB array on every member profile load. Refreshed synchronously by every earn trigger — always up to date.
Ledger query performance
The member history query (profile_id, facility_id, created_at DESC) is covered by the composite index. For a member with 1,000+ ledger rows, this stays fast. The leaderboard query aggregates ledger rows by month — should be materialised if volume grows beyond 100k rows/month.
Segment filter join
Segment queries JOIN crm_loyalty_accounts ON profile_id. The tier index (facility_id, current_tier) makes tier-based segments fast. Points balance comparisons do a filtered scan — acceptable for club scale (< 10k members).
pg_cron expiry
The nightly expiry job uses the (expires_at) partial index to find expired rows. For a club with 3,000 members and 365-day expiry, this scans at most one year of earned rows. At 10 pts/hr average, that's ~36,500 rows/year — negligible.
JSONB earn_rules evaluation
Trigger functions evaluate earn_rules JSONB on every qualifying event. The program row is fetched once per trigger execution. For clubs with high booking volume (>100/day), a minor optimisation is to cache the program config in a Postgres shared memory extension — not needed at Pad Thai Padel scale.