Design Rationale — Rewards

Non-obvious design choices for the rewards module — the WHY behind each decision.

Rewards Module

Second module of the Consumer Layer. Tenant-scoped schema rewards. Locked 2026-06-12.


Decision: rewards.* tables use WHERE tenant_id = current_setting('app.current_tenant_id')::UUID for RLS. consumer_id appears only on loyalty_account as an FK dimension (which consumer holds this account) — it is NEVER the RLS scope. This is the structural inverse of consumer_tenant_link (consumer-scoped, tenant_id as dimension).

Why: The nursery must be able to query its own loyalty program: "what are all the accounts on my program?", "who earned points this month?", "what does my earn rule look like?" All of these are nursery-scoped reads, impossible under consumer-scoped RLS. Consumer-scoped RLS would protect the consumer's view; tenant-scoped RLS protects the nursery's view. The nursery owns the program.

Rejected: Consumer-scoped rewards.* (the consumer owns the balance view). Consumer-scoped rewards would prevent the nursery from querying its own accounts without service-role bypass — a constant operational burden. The inverse is correct: rewards is a nursery business object, consulted from the consumer side via ConsumerService.

Guard: rewards.* RLS predicate is ALWAYS tenant_id. Never add consumer_id as the RLS scope on any rewards.* table — consumer-scoping would stop a nursery querying its own program. ConsumerService handles the consumer-side read (aggregating per-tenant balances across nurseries) using service-role.


DR2 — points_ledger is append-only source of truth

Decision: points_ledger has no updated_at and no deleted_at. Every earn, redemption, expiry, adjustment, and reversal is a new INSERT. Corrections are 'adjust' (manual, note required) or 'reverse' (system sign-flip of prior entry) rows — never an UPDATE to an existing row.

Why: Points are a financial instrument. Once recorded, a ledger entry must be immutable — the same invariant that governs billing.ar_payment_application and pos.gift_card's ledger. Mutability would allow silent balance manipulation: a redeem row that gets retroactively changed breaks the SUM(amount_points) = balance_points reconciliation invariant. Immutability is what makes the maintained-cache pattern trustworthy.

Rejected: Allowing UPDATE on points_ledger rows to "correct" errors. Retroactive mutation breaks the reconciliation formula and hides the correction history. Corrections must be visible in the ledger as 'adjust' rows.

Guard: Never add updated_at or deleted_at to points_ledger. Never issue UPDATE on a points_ledger row. If a correction is needed, the service must INSERT an 'adjust' or 'reverse' row.


DR3 — Balance as reconcilable cache (gift-card/AR pattern)

Decision: loyalty_account.balance_points and lifetime_points are maintained caches that reconcile to SUM(amount_points) over the ledger. balance_points = SUM(amount_points) WHERE loyalty_account_id = ?. lifetime_points = SUM(amount_points) WHERE entry_type = 'earn' — never decremented by redemption or expiry. RewardsService updates both on every ledger insert.

Why: Scanning the full ledger on every balance-check would be O(n) over the account's history — unacceptable at POS for real-time earn/redeem decisions. The maintained cache gives O(1) reads. The same pattern is used by pos.gift_card, pos.store_credit, and billing.ar_account: maintain a running balance alongside an immutable ledger, reconcile periodically or on demand.

Why (lifetime_points non-decremented): lifetime_points is the tier-threshold value. If it decremented on redeem, a consumer who reaches Gold tier by earning 1000 points and then redeems 500 would drop back below the Gold threshold — defeating the purpose of lifetime achievement tiers. Lifetime earned is a monotonically increasing counter.

Guard: lifetime_points must never be decremented on redeem, expire, or reverse of an earn. Only balance_points decrements. RewardsService must maintain both caches consistently on every ledger INSERT.


DR4 — No stub accounts; reconcile points at claim via ConsumerService

Decision: loyalty_account.consumer_id is NOT NULL. Unclaimed stubs (consumer.consumer.status = 'unclaimed') 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 and populating it with all historical earned points.

Why: A stub has no authenticated identity — it is a probabilistic record (this person probably exists). Creating a loyalty account for a stub would require either (a) a stub-points holding table (extra schema + merge complexity) or (b) nullable consumer_id on loyalty_account (breaks the one-account-per-(consumer, tenant) invariant). Neither is acceptable. When the stub is claimed and the identity is verified, ConsumerService can backfill with precision.

Rejected: Stub-points holding table. A holding table would require migration logic on claim, risk gaming (a stub could accumulate points before claim), and create merge complexity if two stubs claim to the same identity.

Guard: loyalty_account.consumer_id NOT NULL must never be relaxed. Do not create a holding table for unclaimed points. Do not make consumer_id nullable "temporarily" to unblock an edge case — the reconcile-at-claim path is the designed solution.


Decision: There is no consent or opt-in table in rewards. RewardsService reads crm.customer_consent (consent_type = 'loyalty') before crediting points. Consent lives in CRM and is owned by the tenant's CRM relationship with the customer.

Why: The same pattern as notifications: NotificationService reads crm.customer_consent for marketing preferences before sending. Consent is a per-nursery customer attribute — it belongs next to the customer record in crm, not scattered across every feature module that needs it. A separate consent table in rewards would create consent drift (CRM has one value, rewards has another).

Guard: Do not create a consent, opt-in, or marketing-preferences table in rewards. If a loyalty-consent feature is needed, add a consent_type value to crm.customer_consent — not a new table. RewardsService must check consent before every points credit.


DR6 — reward_option vs offers: points-funded redemption vs promo/settlement

Decision: reward_option is the nursery's points-funded redemption catalog — always nursery-funded, always denominated in points. offers (separate module) is coupon/promo issuance, including Vrida-funded offers with settlement economics. The two modules are permanently separate.

Why: Vrida-funded offers require financial settlement mechanics: Vrida subsidizes a discount, the nursery is reimbursed, settlement invoices flow between Vrida and the nursery. None of this exists in a points-loyalty ledger. Merging them would force financial settlement tables into rewards (wrong ownership) or leave settlement undefined in offers (wrong direction). The settlement economics forces separation.

Rejected: Consolidating reward_option into offers (or vice versa). A unified "discount engine" would obscure the fundamental difference: points are a liability the nursery accrued over time; promos are a marketing spend decision at issuance time.

Guard: Never put a promo code, coupon, or Vrida-funded discount in reward_option. Never put a points-exchange redemption in offers. If a future feature seems to blur the boundary (e.g. "earn points on a coupon redemption"), that is a service-layer integration, not a schema merge.


DR7 — 'reverse' vs 'adjust': system sign-flip vs manual correction

Decision: Two distinct entry types for corrections. 'reverse' = system-generated sign-flip of a prior entry (triggered by a voided sale, refund, or return); must set reversed_ledger_id; amount_points is the sign-flip of the original row. 'adjust' = manual staff correction; must set note (explanation required); sets created_by_user_id for audit; amount_points may be positive or negative.

Why: A system reversal of a sale earn (triggered by a POS refund) is a deterministic, auditable event with a direct causal reference. A manual adjustment is a discretionary human action that requires justification. Conflating them would lose the ability to distinguish "this points change was caused by a refund of sale X" from "a staff member changed this customer's balance manually." The distinction is critical for fraud detection and dispute resolution.

Guard: RewardsService must only use 'reverse' for system-triggered reversals with a reversed_ledger_id. Manual staff corrections always use 'adjust' with note. Never use 'reverse' for a staff-initiated change; never use 'adjust' for a voided-sale reversal.


DR8 — Source coherence: sale_id XOR source_ref by source_type

Decision: points_ledger.source_type determines exclusively whether sale_id or source_ref is set — never both. source_type = 'sale' requires sale_id IS NOT NULL AND source_ref IS NULL. source_type NOT IN ('sale','manual','expiry') requires source_ref IS NOT NULL AND sale_id IS NULL. 'manual' and 'expiry' require sale_id IS NULL (source_ref may be NULL or NOT NULL). Additionally, entry_type = 'expire' ↔ source_type = 'expiry' is enforced bidirectionally by two CHECKs.

Why: Without the coherence CHECK, a careless INSERT could populate both sale_id and source_ref, creating ambiguity about what triggered the points movement. The DB CHECK is the authoritative guard — the service layer cannot be trusted to maintain this invariant consistently across all code paths.

Rejected: Service-layer-only enforcement (no DB CHECK). Service-layer invariants are bypassed by migrations, admin scripts, direct DB access, and future developers who don't read the service code. DB CHECKs enforce the invariant unconditionally.

Guard: The source coherence CHECK is load-bearing — do not remove or weaken it. If a new source_type value is added, update the CHECK to categorize it as sale-style (uses sale_id) or ref-style (uses source_ref) or neither-style (uses neither). The entry_type='expire' ↔ source_type='expiry' bidirectional CHECK pair must be maintained together — removing one half allows malformed rows.


Last modified: Jun 17, 2026, 8:37 PM PT
On this page
Esc