Architecture · crm-v1.4

CRM Loyalty System — Architecture

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.

5 tables4 DB functions / triggers12 admin API routes3 public API routes6 integration points

Overview

System 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

  • IN SCOPE point earning triggers, tier calculation, rewards catalogue, staff redemption, member web view, segment filter fields
  • OUT OF SCOPE (v1.4) OpenRally loyalty endpoints, TPS mobile integration, member self-redemption, expiry email notifications
  • REPLACES pos_loyalty_programs, pos_loyalty_accounts, pos_loyalty_transactions (soft-deprecated), credit_tier_configs discount logic

Diagram

Architecture 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

Database Schema — 5 Tables

All tables share the crm_loyalty_ prefix and are scoped by facility_id with row-level security enforced on every table.

crm_loyalty_programsCRM-LP

One row per facility. Defines the entire program: name, points currency, earn rules (JSONB), tier ladder (JSONB), expiry policy (JSONB), and active flag.

Written by: Owner / Admin via CRM settings UI (manager/admin/owner API auth)Read by: All staff roles (staff+), DB triggers (service role)

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

SELECTstaff+

facility_id = auth.facility_id() AND role IN ('owner','admin','manager','coach')

INSERTadmin+

facility_id = auth.facility_id() AND role IN ('owner','admin')

UPDATEadmin+

facility_id = auth.facility_id() AND role IN ('owner','admin')

DELETEnone

Not permitted — use is_active = false to disable

Indexes

UNIQUE (facility_id)

Enforces one program per facility — the core architectural constraint

Notes

  • UNIQUE (facility_id) is the key constraint — exactly one program per facility, ever.
  • earn_rules, tiers, expiry_policy are JSONB to allow schema evolution without migrations.
  • updated_at should be maintained via a trigger: CREATE TRIGGER ... BEFORE UPDATE ON crm_loyalty_programs EXECUTE FUNCTION moddatetime().
  • Tiers array is always sorted ascending by lifetime_threshold; enforced at application layer.
crm_loyalty_accountsCRM-LA

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.

Written by: Service role only (DB triggers upsert on earn; redeem_loyalty_points() deducts; expire job decrements)Read by: Staff+ via admin API; authenticated member via public API (own row 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 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

SELECT (staff)staff+

facility_id = auth.facility_id()

SELECT (member)authenticated

profile_id = auth.uid() — own row only

INSERT / UPDATEservice_role

Only triggers and server-side functions write here

DELETEnone

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

  • points_balance CHECK >= 0 prevents negative balances at DB level.
  • lifetime_points is NEVER decremented — not on redemption, not on expiry. Only incremented by earn triggers.
  • current_tier is a denormalised cache — recalculated by refresh_loyalty_tier() every time points are earned. This avoids re-reading the tiers JSONB on every member view.
  • Upserted (INSERT ... ON CONFLICT DO UPDATE) by earn triggers — row created on first earn event.
crm_loyalty_ledgerCRM-LL

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.

Written by: Service role only (earn triggers, redeem function, expire cron, staff manual-adjust API)Read by: Staff+ via admin API (full history); authenticated member via public API (own rows, last 20)

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

SELECT (staff)staff+

facility_id = auth.facility_id()

SELECT (member)authenticated

profile_id = auth.uid()

INSERTservice_role

Triggers + admin API only

UPDATE / DELETEnone

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

  • Append-only — no UPDATE or DELETE ever. Every correction is a new row (event_type = 'adjusted' or 'reversed').
  • expires_at is only set on 'earned' rows when expiry_policy.enabled = true. Null = never expires.
  • The deduplication index (source_type, source_id) prevents double-awarding points if a trigger fires twice.
  • created_by = null means the row was written by a DB trigger (no staff action). Set to a staff profile_id for manual adjustments.
crm_rewardsCRM-RW

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.

Written by: Manager / Admin / Owner via CRM rewards UIRead by: Staff+ via admin API; public (unauthenticated) via web v1 API for catalogue display

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

SELECT (staff)staff+

facility_id = auth.facility_id()

SELECT (public)anon / authenticated

is_active = true — public catalogue read

INSERT / UPDATEmanager+

role IN ('manager','admin','owner')

DELETEnone

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

  • reward_value JSONB schema differs per reward_type — validated at application layer, not DB constraint.
  • court_credit amount stored in facility's smallest currency unit (satang for THB, cents for USD). Display divides by 100.
  • Deleted rewards would break historical redemption records. Soft-delete only (is_active = false).
crm_reward_redemptionsCRM-RR

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.

Written by: Service role only (redeem_loyalty_points() function)Read by: Staff+ via admin API

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

SELECTstaff+

facility_id = auth.facility_id()

INSERTservice_role

redeem_loyalty_points() function only

UPDATE / DELETEnone

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

  • ledger_entry_id links back to the 'spent' ledger row — provides full audit chain: redemption → ledger debit → account balance.
  • redeemed_by is always a staff member (not the member themselves) — enforced by the redemption API auth check.
  • points_used is stored denormalized for historical accuracy (reward point_cost could change after redemption).

DB Logic

Functions, Triggers & Scheduled Jobs (4)

trigger functionon_booking_earn_loyalty_points()

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

  • SECURITY DEFINER runs as the function owner (service role) so RLS does not block the insert.
  • Deduplication check (source_id = booking.id) prevents double-awarding if the trigger fires twice.
  • v_hours rounded to nearest integer point to avoid fractional pts.
  • Same pattern used for on_pos_order_earn_loyalty_points() and on_program_enrollment_earn_loyalty_points().
helper functionrefresh_loyalty_tier(p_profile_id uuid, p_facility_id uuid)

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

  • Called by all three earn triggers after every point award.
  • Walks tiers array in ascending threshold order — last tier whose threshold <= lifetime_points wins.
  • Updates current_tier on crm_loyalty_accounts (denormalised cache) — avoids re-reading JSONB on every read.
  • Tier never drops on redemption because it reads lifetime_points, not points_balance.
helper functionredeem_loyalty_points(p_profile_id, p_facility_id, p_reward_id, p_staff_id)

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

  • SELECT ... FOR UPDATE locks the account row — prevents two concurrent redemptions both passing the balance check.
  • Raises typed exceptions (P0001, P0002) that the API layer catches and converts to 400 responses.
  • Atomic: all three writes (ledger, account, redemption) succeed or all roll back together.
  • Only decrements points_balance — never lifetime_points. Tier preserved.
cronexpire_loyalty_points(p_facility_id uuid)

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

  • Uses NOT EXISTS subquery to find earned rows that haven't been expired yet — idempotent.
  • GREATEST(0, balance - expired) ensures balance never goes negative even if accounting is off.
  • Does NOT decrement lifetime_points — tier is preserved through expiry.
  • Per-facility pg_cron job registered when program is first activated.
  • pg_cron timezone: job runs at 02:00 UTC; facility timezone offset handled at application layer for display.

Integrations

Integration Points (6)

bookings table

bookings → loyalty

PostgreSQL AFTER

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

PostgreSQL AFTER

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

PostgreSQL AFTER

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

PostgreSQL function

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

New filter

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

Public API

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

Security Model

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

  • crm_loyalty_ledger: INSERT only via service role (triggers + admin API). No UPDATE or DELETE ever.
  • crm_loyalty_accounts: INSERT/UPDATE only via service role (triggers + redeem function).
  • crm_reward_redemptions: INSERT only via redeem_loyalty_points() function.
  • crm_loyalty_programs: INSERT/UPDATE limited to owner/admin role.
  • crm_rewards: INSERT/UPDATE limited to manager/admin/owner role.

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 Strategy

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)

  1. 1Create crm_loyalty_programs table + UNIQUE constraint + RLS
  2. 2Create crm_loyalty_accounts table + CHECK constraints + RLS + indexes
  3. 3Create crm_loyalty_ledger table + CHECK constraints + RLS + indexes
  4. 4Create crm_rewards table + RLS + indexes
  5. 5Create crm_reward_redemptions table + RLS + indexes
  6. 6Create trigger functions: on_booking_earn_loyalty_points, on_pos_order_earn_loyalty_points, on_program_enrollment_earn_loyalty_points
  7. 7Attach triggers to bookings, pos_orders, program_enrollments tables
  8. 8Create helper functions: refresh_loyalty_tier, get_loyalty_account, redeem_loyalty_points
  9. 9Create expire_loyalty_points function (pg_cron job registered per-facility on activation)
  10. 10Migrate existing pos_loyalty_accounts → crm_loyalty_accounts (IF rows exist)
  11. 11Migrate existing pos_loyalty_transactions → crm_loyalty_ledger (IF rows exist)
  12. 12ADD COLUMN deprecated boolean DEFAULT false TO pos_loyalty_programs (soft deprecation marker)
  13. 13Update calculate_member_price() to read discount from crm_loyalty_accounts.current_tier + program tiers JSONB (replacing credit_tier_configs lookup)

Not Dropped (kept intact)

  • pos_loyalty_programs — kept, deprecated=true set after migration
  • pos_loyalty_accounts — kept for historical reference
  • pos_loyalty_transactions — kept for historical reference
  • credit_tier_configs — kept intact, discount logic replaced in calculate_member_price() only

Rollback Plan

All changes are additive. To roll back: drop crm_loyalty_* tables, restore original calculate_member_price() function body from git history.

API

API Surface — 15 Total Endpoints

Admin CRM API — 12 endpoints (apps/admin)

GET/api/admin/crm/loyalty/program
staff+Get program config or null
POST/api/admin/crm/loyalty/program
admin+Create program
PUT/api/admin/crm/loyalty/program
admin+Update settings, toggle active
GET/api/admin/crm/loyalty/customers/[id]
staff+Member account + tier progress
GET/api/admin/crm/loyalty/customers/[id]/ledger
staff+Paginated transaction history
POST/api/admin/crm/loyalty/customers/[id]/adjust
manager+Manual point adjustment + reason
GET/api/admin/crm/loyalty/rewards
staff+List all rewards (active + inactive)
POST/api/admin/crm/loyalty/rewards
manager+Create reward
PUT/api/admin/crm/loyalty/rewards/[id]
manager+Update reward
DELETE/api/admin/crm/loyalty/rewards/[id]
manager+Soft-delete (is_active = false)
POST/api/admin/crm/loyalty/redeem
staff+Atomic staff redemption
GET/api/admin/crm/loyalty/leaderboard
staff+Top earners this month

Public API v1 — 3 endpoints (apps/web)

GET/api/v1/[facility]/loyalty
member JWTOwn tier, balance, next tier progress
GET/api/v1/[facility]/loyalty/history
member JWTLast 20 ledger rows (own account)
GET/api/v1/[facility]/loyalty/rewards
nonePublic catalogue (active rewards only)

Performance

Performance Considerations (5)

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.