rewards — Consumer Layer
Schema locked 2026-06-12. 6 tables, 89 cols: loyalty_program (12), earning_rule (17), tier_level (12), reward_option (17), loyalty_account (14), points_ledger (17).
Design Principles
Tenant-scoped: the nursery owns its loyalty program. rewards.* tables use WHERE tenant_id = current_setting('app.current_tenant_id')::UUID for RLS. The nursery configures earning rules, tiers, and redemption catalog; the nursery owns the ledger and account balances. consumer_id is an FK dimension (which consumer) on loyalty_account and points_ledger — it is NEVER the RLS scope. This is the structural inverse of consumer_tenant_link (which was consumer-scoped with tenant_id as dimension).
ConsumerService is the cross-store read boundary. A consumer reads their per-tenant balances through ConsumerService (service-role aggregation over tenant-scoped data). Balances do NOT pool across nurseries — per-business loyalty is the locked model. Platform-wide aggregated points is a future query/view over the per-tenant schema, not a schema redesign.
points_ledger is the source of truth; balances are maintained caches. loyalty_account.balance_points and loyalty_account.lifetime_points are maintained caches that reconcile to SUM(amount_points) over the ledger. The pattern is identical to pos.gift_card/pos.store_credit/billing.ar_account. A correction is an 'adjust' or 'reverse' ledger row — never an UPDATE to the ledger. The ledger is append-only (insert-once): no updated_at, no deleted_at.
No stub accounts. loyalty_account.consumer_id is NOT NULL. Unclaimed stubs do not get loyalty accounts. When a stub is claimed, ConsumerService credits historical eligible sale points in a batch of 'earn' ledger rows keyed to the original sale_id. No stub-points holding table exists.
No consent table. RewardsService reads crm.customer_consent (consent_type = 'loyalty') before crediting points. Consent lives in CRM — the same pattern as notifications reading crm.customer_consent for marketing preferences. Zero consent tables in rewards.
Hard non-negative balances (v1). CHECK (balance_points >= 0) on loyalty_account and CHECK (balance_after_points >= 0) on points_ledger. Over-redemption and refund scenarios are handled by capping or a 'reverse' row — not by allowing debt. Negative-balance accounts are not supported in v1.
Rewards vs Offers boundary. reward_option is the nursery's points-funded redemption catalog ("500 points = $5 off") — always nursery-funded, always denominated in points. offers (separate module) is coupon/promo issuance, including Vrida-funded promos with settlement economics ("20% off this weekend"). Never put a promo code in reward_option; never put a points redemption in offers. The offers module is separate because Vrida-funded offers require financial settlement mechanics that points-loyalty does not.
Cross-Phase FK Seams
| Column | Target | Status |
|---|---|---|
loyalty_program.tenant_id |
platform.tenant |
ENFORCED FK (platform locked 2026-06-09; enforced at rewards schema creation) |
earning_rule.tenant_id |
platform.tenant |
ENFORCED FK |
tier_level.tenant_id |
platform.tenant |
ENFORCED FK |
reward_option.tenant_id |
platform.tenant |
ENFORCED FK |
loyalty_account.tenant_id |
platform.tenant |
ENFORCED FK |
points_ledger.tenant_id |
platform.tenant |
ENFORCED FK |
loyalty_account.consumer_id |
consumer.consumer |
ENFORCED FK (consumer locked 2026-06-11; enforced at rewards schema creation) — READY |
points_ledger.sale_id |
pos.sale |
ENFORCED FK (pos locked 2026-06-10; enforced at rewards schema creation) — READY |
reward_option.free_item_variant_ref |
inventory.item_variant |
LOOSE text ref (loose ref by design — reward catalog items may reference variants that are archived or replaced; RewardsService validates at redemption time) |
earning_rule.scope_ref |
inventory.item_variant / category code |
LOOSE text ref (polymorphic target depending on rule_scope; RewardsService validates at earn time) |
points_ledger.source_ref |
various non-sale sources | LOOSE text ref (review ID, etc. — polymorphic; not FK-enforceable) |
rewards.loyalty_program — 12 cols
Per-tenant loyalty program configuration. One program per tenant in v1.0 (one-program-per-tenant partial unique enforced). Owns the base earn rate, rounding policy, minimum redemption threshold, and expiry policy. All other rewards tables FK back to this row.
Status: 'active' (earning and redemption enabled), 'paused' (no new earning; existing balances and redemption unaffected), 'archived' (program discontinued; balances frozen).
Expiry policy: 'never' = points never expire; 'rolling_months' = each earn row expires N months after earned (per-row expires_at on points_ledger); 'calendar_year' = all points expire end of calendar year (batch sweep); 'inactivity_months' = balance expires if no activity for N months. expiry_policy_value is required when the type is 'rolling_months' or 'inactivity_months', and must be NULL for 'never' and 'calendar_year' (enforced by CHECK).
One program per tenant v1: the partial unique (tenant_id) WHERE deleted_at IS NULL enforces this. Relax for seasonal / wholesale / VIP multi-program support in a later phase (remove the unique, add a program_type discriminator).
RLS: WHERE tenant_id = current_setting('app.current_tenant_id')::UUID.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant — RLS scope |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
name |
text | NOT NULL | — | Display name, e.g. 'Sprigs Rewards' |
status |
text | NOT NULL | 'active' |
CHECK (status IN ('active','paused','archived')) |
points_per_dollar |
numeric | NOT NULL | 1 |
Base earn rate: points earned per dollar (cent-divisor applied; e.g. 1.0 = 1 point per $1). Overridden by earning_rule multipliers/bonuses. |
points_rounding |
text | NOT NULL | 'floor' |
CHECK (points_rounding IN ('floor','round','ceil')). Applied when points_per_dollar × sale_total_cents / 100 is fractional. |
min_redeem_points |
integer | NOT NULL | 0 |
Minimum points required to redeem anything. 0 = no minimum. |
expiry_policy_type |
text | NOT NULL | 'never' |
CHECK (expiry_policy_type IN ('never','rolling_months','calendar_year','inactivity_months')) |
expiry_policy_value |
integer | nullable | — | Months for 'rolling_months' and 'inactivity_months'; NULL for 'never' and 'calendar_year'. CHECK ((expiry_policy_type IN ('rolling_months','inactivity_months') AND expiry_policy_value IS NOT NULL) OR (expiry_policy_type IN ('never','calendar_year') AND expiry_policy_value IS NULL)) |
Indexes:
- PK on
id - PARTIAL UNIQUE on (
tenant_id) WHEREdeleted_at IS NULL— one program per tenant v1 - on (
tenant_id,status) WHEREdeleted_at IS NULL— program lookup by status
rewards.earning_rule — 17 cols
Scoped bonus earning rules on top of the base loyalty_program.points_per_dollar rate. A separate table (not JSONB) because rules are queried at every earn event — RewardsService queries active rules matching the sale's item categories and line total. The base rate handles the simple case; earning_rule handles double-points promotions, category bonuses, signup bonuses, and birthday rewards without requiring additional tables.
rule_scope values: 'purchase' (applies to all sales, used for blanket multipliers), 'item_category' (applies to sales containing items in the category referenced by scope_ref), 'item_variant' (applies to a specific variant by slug ref), 'sale_threshold' (applies when the sale total meets threshold_cents), 'birthday' (one-time earn on birthday match — RewardsService checks consumer.consumer birth date; no birth-date column here), 'signup' (one-time earn on enrollment), 'review' (earn for a product review), 'manual' (staff-issued one-off bonus — creates a ledger row directly without a sale).
earn_type + field coherence: 'multiplier' requires multiplier IS NOT NULL and flat_bonus_points IS NULL; 'flat_bonus' requires flat_bonus_points IS NOT NULL and multiplier IS NULL (CHECK enforced).
scope_ref is a loose text ref (category code or inventory.item_variant.sku or slug). Not FK-enforced because category codes are not a locked table and item variants may be archived; RewardsService validates at earn time.
RLS: WHERE tenant_id = current_setting('app.current_tenant_id')::UUID.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant — RLS scope |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
loyalty_program_id |
UUID | NOT NULL | — | FK → rewards.loyalty_program |
name |
text | NOT NULL | — | Human-readable rule name, e.g. 'Double Points on Perennials' |
rule_scope |
text | NOT NULL | — | CHECK (rule_scope IN ('purchase','item_category','item_variant','sale_threshold','birthday','signup','review','manual')) |
scope_ref |
text | nullable | — | Loose ref: category code or item_variant sku/slug. NULL for 'purchase', 'birthday', 'signup', 'review', 'manual'. |
earn_type |
text | NOT NULL | — | CHECK (earn_type IN ('multiplier','flat_bonus')) |
multiplier |
numeric | nullable | — | Earn multiplier applied to base rate (e.g. 2.0 = double points). Required when earn_type = 'multiplier'. |
flat_bonus_points |
integer | nullable | — | Flat bonus points added (e.g. 50). Required when earn_type = 'flat_bonus'. |
threshold_cents |
bigint | nullable | — | Minimum sale total (cents) for 'sale_threshold' scope. NULL for other scopes. |
priority |
integer | NOT NULL | 0 |
Resolution order when multiple rules match. Higher value = higher priority. |
is_active |
boolean | NOT NULL | true |
Soft-disable without deleting. |
active_from |
timestamptz | nullable | — | Rule active window start (inclusive). NULL = no start constraint. |
active_to |
timestamptz | nullable | — | Rule active window end (exclusive). NULL = no end constraint. |
Constraints:
- CHECK ((
earn_type='multiplier'ANDmultiplierIS NOT NULL ANDflat_bonus_pointsIS NULL) OR (earn_type='flat_bonus'ANDflat_bonus_pointsIS NOT NULL ANDmultiplierIS NULL)) - CHECK ((
rule_scope='sale_threshold'ANDthreshold_centsIS NOT NULL) OR (rule_scope<>'sale_threshold'ANDthreshold_centsIS NULL)) - CHECK (
rule_scope<>'sale_threshold'ORscope_refIS NULL)
Indexes:
- PK on
id - on (
loyalty_program_id,rule_scope) WHEREis_active = true AND deleted_at IS NULL— earn-time rule query - on (
scope_ref) WHEREscope_ref IS NOT NULL AND deleted_at IS NULL— category/variant bonus lookup
rewards.tier_level — 12 cols
Tier definitions per loyalty program (e.g. Bronze / Silver / Gold). Tier membership is determined by loyalty_account.lifetime_points (total ever earned, never decremented). RewardsService checks for tier crossing at every earn event and updates loyalty_account.current_tier_level_id when the threshold is crossed.
min_points is unique per program (partial unique enforced) to avoid ambiguous tier resolution. sort_order determines display ordering; min_points determines tier achievement.
earn_multiplier is a per-tier bonus applied on top of all other multipliers (e.g. Gold tier earns 1.25× on every transaction). NULL = no tier bonus.
RLS: WHERE tenant_id = current_setting('app.current_tenant_id')::UUID.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant — RLS scope |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
loyalty_program_id |
UUID | NOT NULL | — | FK → rewards.loyalty_program |
tier_code |
text | NOT NULL | — | Tenant-defined code, e.g. 'bronze', 'silver', 'gold'. |
name |
text | NOT NULL | — | Display name, e.g. 'Bronze Member' |
min_points |
integer | NOT NULL | — | Lifetime points threshold to achieve this tier. CHECK (min_points >= 0) |
sort_order |
integer | NOT NULL | — | Display order (ascending = lowest tier first). |
earn_multiplier |
numeric | nullable | — | Per-tier earn multiplier applied on top of all other rates. NULL = no tier bonus. |
benefit_label |
text | nullable | — | Short benefit description for display, e.g. '10% bonus on every purchase'. |
Constraints:
- CHECK (
min_points>= 0)
Indexes:
- PK on
id - PARTIAL UNIQUE on (
loyalty_program_id,tier_code) WHEREdeleted_at IS NULL - PARTIAL UNIQUE on (
loyalty_program_id,min_points) WHEREdeleted_at IS NULL— prevents ambiguous tier thresholds - on (
loyalty_program_id,min_points) — tier-crossing lookup at earn time (find highest tier wheremin_points <= account.lifetime_points)
rewards.reward_option — 17 cols
The nursery's points-funded redemption catalog: what a consumer can exchange their points for. Always nursery-funded (the cost is the nursery's, not Vrida's). This is what makes rewards a loyalty program rather than just a ledger.
Boundary with offers: reward_option = points-funded redemption ("500 points → $5 off", "200 points → free 4-inch plant"). offers module = coupon/promo issuance, potentially Vrida-funded ("20% off this weekend"). Never put a promo code here; never put a points exchange in offers.
reward_type + field coherence (all enforced by CHECK):
'discount_amount': fixed dollar off →discount_amount_centsNOT NULL,discount_percentNULL,free_item_variant_refNULL'discount_percent': percent off →discount_percentNOT NULL, others NULL'free_item': free specific item →free_item_variant_refNOT NULL (loose text ref), monetary fields NULL'perk': non-monetary reward (free gift wrap, priority checkout, etc.) → all monetary fields NULL; description vianame
tier_level_id gates the option to members of a specific tier or above. NULL = available to all enrolled members regardless of tier.
free_item_variant_ref is a loose text ref to inventory.item_variant (sku or slug). Not FK-enforced — variants may be archived or substituted; RewardsService validates at redemption time.
RLS: WHERE tenant_id = current_setting('app.current_tenant_id')::UUID.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant — RLS scope |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
loyalty_program_id |
UUID | NOT NULL | — | FK → rewards.loyalty_program |
name |
text | NOT NULL | — | Display name, e.g. '$5 Off Your Purchase', 'Free 4-inch Succulent' |
reward_type |
text | NOT NULL | — | CHECK (reward_type IN ('discount_amount','discount_percent','free_item','perk')) |
points_cost |
integer | NOT NULL | — | Points required to redeem. CHECK (points_cost > 0) |
discount_amount_cents |
bigint | nullable | — | Fixed discount amount. Set when reward_type = 'discount_amount'. |
discount_percent |
numeric | nullable | — | Percentage discount (e.g. 10.00 = 10%). Set when reward_type = 'discount_percent'. CHECK (discount_percent > 0 AND discount_percent <= 100) when not null. |
free_item_variant_ref |
text | nullable | — | Loose text ref to inventory.item_variant (sku or slug). Set when reward_type = 'free_item'. Validated by RewardsService at redemption. |
min_purchase_cents |
bigint | nullable | — | Optional minimum basket total required to redeem this option. NULL = no minimum. |
tier_level_id |
UUID | nullable | — | FK → rewards.tier_level. Tier gate — only members at or above this tier may redeem. NULL = all enrolled members. |
is_active |
boolean | NOT NULL | true |
Soft-disable without deleting. |
active_from |
timestamptz | nullable | — | Option availability window start. NULL = no start constraint. |
active_to |
timestamptz | nullable | — | Option availability window end. NULL = no end constraint. |
Constraints:
- CHECK reward_type coherence: (
reward_type='discount_amount'ANDdiscount_amount_centsIS NOT NULL ANDdiscount_percentIS NULL ANDfree_item_variant_refIS NULL) OR (reward_type='discount_percent'ANDdiscount_percentIS NOT NULL ANDdiscount_amount_centsIS NULL ANDfree_item_variant_refIS NULL) OR (reward_type='free_item'ANDfree_item_variant_refIS NOT NULL ANDdiscount_amount_centsIS NULL ANDdiscount_percentIS NULL) OR (reward_type='perk'ANDdiscount_amount_centsIS NULL ANDdiscount_percentIS NULL ANDfree_item_variant_refIS NULL) - CHECK (
points_cost> 0) - CHECK (
discount_percentIS NULL OR (discount_percent> 0 ANDdiscount_percent<= 100))
Indexes:
- PK on
id - on (
loyalty_program_id) WHEREis_active = true AND deleted_at IS NULL— catalog query (list all redeemable options) - on (
loyalty_program_id,tier_level_id) WHEREis_active = true AND deleted_at IS NULL— tier-gated catalog lookup
rewards.loyalty_account — 14 cols
The per-(consumer, tenant) loyalty account. One row per consumer per nursery. Holds the current point balance and tier status as maintained caches (reconcilable against points_ledger). The consumer can have accounts at multiple nurseries (separate rows); the (consumer_id) index serves ConsumerService's cross-store aggregation read.
consumer_id NOT NULL = claimed consumers only. Unclaimed stubs do not receive loyalty_account rows. When a stub is claimed, ConsumerService issues a batch of back-dated 'earn' ledger rows for eligible historical sales, creating the account at claim time.
balance_points = spendable points (decremented by redeem/expire). lifetime_points = total ever earned (never decremented) — used for tier thresholds. Both are maintained caches; balance_points reconciles to SUM(amount_points) WHERE loyalty_account_id = ? over the ledger; lifetime_points reconciles to SUM(amount_points) WHERE entry_type = 'earn'.
current_tier_level_id and tier_achieved_at are maintained caches (history-lite). Full tier transition history is a deferred loyalty_tier_history table.
RLS: WHERE tenant_id = current_setting('app.current_tenant_id')::UUID. consumer_id is the FK dimension — never the RLS scope.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant — RLS scope |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete (account closure, not point zeroing — closing an account is distinct from zeroing balance) |
loyalty_program_id |
UUID | NOT NULL | — | FK → rewards.loyalty_program |
consumer_id |
UUID | NOT NULL | — | FK → consumer.consumer — DIMENSION (which consumer). Claimed consumers only. NOT NULL enforces no-stub-account rule. |
balance_points |
integer | NOT NULL | 0 |
Spendable points. Maintained cache. CHECK (balance_points >= 0) — hard non-negative v1. |
lifetime_points |
integer | NOT NULL | 0 |
Total points ever earned (not decremented by redemption/expiry). Used for tier threshold comparison. CHECK (lifetime_points >= 0) |
current_tier_level_id |
UUID | nullable | — | FK → rewards.tier_level — maintained cache of current tier. NULL = no tier (program has no tiers, or account below minimum threshold). |
tier_achieved_at |
timestamptz | nullable | — | When the current tier was first reached. Tier-history-lite; full history is a deferred table. |
enrolled_at |
timestamptz | NOT NULL | — | When the account was created (enrollment timestamp). |
last_earn_at |
timestamptz | nullable | — | Timestamp of most recent earn event. Updated by RewardsService on every earn. Used for inactivity-based expiry sweep. |
last_redeem_at |
timestamptz | nullable | — | Timestamp of most recent redemption. |
Constraints:
- CHECK (
balance_points>= 0) - CHECK (
lifetime_points>= 0) - PARTIAL UNIQUE on (
tenant_id,consumer_id) WHEREdeleted_at IS NULL— one account per consumer per nursery
Indexes:
- PK on
id - PARTIAL UNIQUE on (
tenant_id,consumer_id) WHEREdeleted_at IS NULL - on (
consumer_id) —ConsumerServicecross-store aggregation: "all loyalty accounts for this consumer across all nurseries" - on (
loyalty_program_id,current_tier_level_id) — tier-membership queries - on (
last_earn_at) WHEREdeleted_at IS NULL— inactivity-expiry sweep
rewards.points_ledger — 17 cols
The append-only source of truth for all points movements. Insert-once: no updated_at, no deleted_at. Every earn, redemption, expiry, adjustment, and reversal is a new row. loyalty_account.balance_points is always reconcilable to SUM(amount_points) WHERE loyalty_account_id = ?.
entry_type values:
'earn': points credited from a sale or non-sale source.amount_pointsis positive.'redeem': points consumed for areward_option.amount_pointsis negative.reward_option_idis required.'expire': points expired per the program's expiry policy.amount_pointsis negative.expired_from_ledger_idreferences the original earn row.'adjust': manual staff correction.amount_pointsmay be positive or negative.noteis required.created_by_user_idis set.'reverse': system-generated reversal of a prior entry (refund, voided sale, return).amount_pointsis the sign-flip of the reversed row.reversed_ledger_idreferences the original entry.
sale_id (FK → pos.sale): set when source_type = 'sale'. This is THE seam closure with POS. The ledger row references the sale; pos.sale.points_earned/points_redeemed are the sale's own receipt-level snapshots (not the ledger).
Source coherence (CHECK): when source_type = 'sale', sale_id IS NOT NULL and source_ref IS NULL. When source_type ≠ 'sale' and source_type ≠ 'manual' and source_type ≠ 'expiry', source_ref IS NOT NULL and sale_id IS NULL. 'manual' and 'expiry' may have neither.
balance_after_points: running per-account snapshot at the time of insert. CHECK >= 0 enforces the hard non-negative constraint at the ledger level (the authoritative enforcement point).
expires_at: per-earn-row expiry timestamp. Set by RewardsService at earn time when expiry_policy_type = 'rolling_months' (computed from enrolled_at + expiry_policy_value months). The expiry sweep queries this column to issue 'expire' rows. NULL for non-earn entries and programs with 'never'/'calendar_year' expiry.
RLS: WHERE tenant_id = current_setting('app.current_tenant_id')::UUID.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant — RLS scope. Denormalized from loyalty_account for direct RLS application. |
loyalty_account_id |
UUID | NOT NULL | — | FK → rewards.loyalty_account |
created_at |
timestamptz | NOT NULL | now() |
Insert timestamp — the ledger timestamp. No updated_at; no deleted_at — append-only. |
entry_type |
text | NOT NULL | — | CHECK (entry_type IN ('earn','redeem','expire','adjust','reverse')) |
amount_points |
integer | NOT NULL | — | Signed: positive for earn, negative for redeem/expire/reverse-of-earn. CHECK (entry_type <> 'earn' OR amount_points > 0); CHECK (entry_type NOT IN ('redeem','expire') OR amount_points < 0) |
balance_after_points |
integer | NOT NULL | — | Per-account running balance snapshot at time of insert. CHECK (balance_after_points >= 0) — authoritative non-negative enforcement. |
sale_id |
UUID | nullable | — | FK → pos.sale — THE SEAM. Set when source_type = 'sale'. NULL otherwise. |
source_type |
text | NOT NULL | — | CHECK (source_type IN ('sale','review','signup','birthday','manual','expiry')). For entry_type = 'redeem': use 'sale' + sale_id when redeemed at POS, or 'manual' for non-POS. For entry_type = 'reverse': 'sale' + sale_id when triggered by a voided sale, 'manual' when admin-triggered. |
source_ref |
text | nullable | — | Non-sale source identifier (e.g. review ID). Set when source_type ∉ {'sale','manual','expiry'}. NULL when sale_id is used. |
reward_option_id |
UUID | nullable | — | FK → rewards.reward_option. Required when entry_type = 'redeem'. CHECK (entry_type <> 'redeem' OR reward_option_id IS NOT NULL) |
earning_rule_id |
UUID | nullable | — | FK → rewards.earning_rule. Set when the earn was produced by a specific earning rule (for audit/analytics). NULL for base-rate earns and non-earn entries. |
expires_at |
timestamptz | nullable | — | Per-earn-row expiry. Set for entry_type = 'earn' when expiry_policy_type = 'rolling_months'. NULL for other entry types and 'never'/'calendar_year' programs. |
expired_from_ledger_id |
UUID | nullable | — | FK → rewards.points_ledger. Set for entry_type = 'expire' — references the earn row whose points are expiring. CHECK (entry_type <> 'expire' OR expired_from_ledger_id IS NOT NULL) |
reversed_ledger_id |
UUID | nullable | — | FK → rewards.points_ledger. Set for entry_type = 'reverse' — references the entry being reversed. CHECK (entry_type <> 'reverse' OR reversed_ledger_id IS NOT NULL) |
note |
text | nullable | — | Required for entry_type = 'adjust' (manual reason). Optional for others. CHECK (entry_type <> 'adjust' OR note IS NOT NULL) |
created_by_user_id |
UUID | nullable | — | FK → identity.identity_user. Set for entry_type = 'adjust' (staff-initiated manual adjustment). |
Constraints:
- CHECK (
balance_after_points>= 0) - CHECK (
entry_type<>'earn'ORamount_points> 0) - CHECK (
entry_typeNOT IN ('redeem','expire') ORamount_points< 0) - CHECK (
entry_type<>'redeem'ORreward_option_idIS NOT NULL) - CHECK (
entry_type<>'expire'ORexpired_from_ledger_idIS NOT NULL) - CHECK (
entry_type<>'reverse'ORreversed_ledger_idIS NOT NULL) - CHECK (
entry_type<>'adjust'ORnoteIS NOT NULL) - Source coherence: CHECK ((
source_type='sale'ANDsale_idIS NOT NULL ANDsource_refIS NULL) OR (source_typeIN ('manual','expiry') ANDsale_idIS NULL) OR (source_typeNOT IN ('sale','manual','expiry') ANDsource_refIS NOT NULL ANDsale_idIS NULL)) - CHECK (
entry_type<>'expire'ORsource_type='expiry') - CHECK (
source_type<>'expiry'ORentry_type='expire')
Indexes:
- PK on
id - on (
loyalty_account_id,created_at) — account history reads + reconciliation (SUM(amount_points)) - on (
sale_id) WHEREsale_id IS NOT NULL— reverse-lookup: "what ledger entries came from this sale?" - on (
expires_at) WHEREexpires_at IS NOT NULL AND entry_type = 'earn'— expiry sweep: find earn rows approaching expiry - on (
reversed_ledger_id) WHEREreversed_ledger_id IS NOT NULL— check if an entry has already been reversed (prevent double-reversal)
Column counts: loyalty_program(12) + earning_rule(17) + tier_level(12) + reward_option(17) + loyalty_account(14) + points_ledger(17) = 89
Deferred items
| Item | Deferred to |
|---|---|
Referral rewards (loyalty_referral) |
Future — friend-invite earn mechanics |
| Missions / challenges / badges / gamification | Future — Consumer App / Engagement phase |
| Household / family account pooling | Future — one account per consumer per tenant v1; household grouping is a separate relationship |
Full tier-transition history (loyalty_tier_history) |
Future — v1 uses current_tier_level_id + tier_achieved_at (history-lite) |
Per-lot FIFO expiry (points_bucket) |
Future — v1 uses points_ledger.expires_at (per-earn-row expiry is sufficient for rolling/inactivity policies); exact-FIFO lot tracking deferred |
| Multiple programs per tenant | Future — one-program-per-tenant v1; relax for seasonal / wholesale / VIP programs (remove partial unique, add program_type discriminator) |
| Platform-wide pooled points | Future query / view over per-tenant balances — not a schema change; per-business schema keeps the door open |
| Stub-points holding table | NONE — no stub accounts; historical points credited at claim via ConsumerService batch earn |
| Consent / opt-in table | NONE — reads crm.customer_consent (consent_type = 'loyalty'); zero consent tables in rewards |
| Offers / promos / coupon codes / Vrida-funded discounts | Separate offers module — different mechanics and settlement economics |
sale_line-level earn granularity |
Future — v1 earns on sale total; per-line earn (different rates per product category per line) uses earning_rule with rule_scope = 'item_category' at service layer; per-line ledger rows are a later refinement |
| Stub / self-signup email-collision merge path | Service-layer design detail — ConsumerService must detect unclaimed stubs at claim time and execute merge before activating. Mechanics to resolve at consumer-phase build. |