rewards — new module (2026-07-11), per-merchant points/loyalty program
7 tables, 109 columns (up from 6/102) — schema built and migration applied/verified live 2026-07-11; locked the same day (PROJECT_DECISIONS #56), then reopened again later the same day (still 2026-07-11) to fix a real bug — rewards.sync_loyalty_account_balance() had hard-enforced exact, full negation only on a reverse entry, rejecting a genuine partial return (e.g. 2 of 5 units) outright — and to add proportional/cumulative-cap partial reversal as a first-class capability, via a new mutable tracker table, loyalty_point_ledger_reversal_tracker (PROJECT_DECISIONS #60); re-locked same day, still schema-only. rewards is the per-merchant points/loyalty engine — programs, accrual rules, reward tiers, redeemable reward options, per-consumer accounts, the point ledger, and (new) a per-ledger-entry reversal tracker. Ledger-first: loyalty_point_ledger is the append-only source of truth; loyalty_account.balance_points/.lifetime_points are genuinely trigger-maintained caches (rewards.sync_loyalty_account_balance(), written into the migration), not just a documented-as-a-formula convention. Tenant-scoped throughout — consumer_id is an ordinary FK dimension here, never the RLS scope, the structural inverse of consumer.consumer_merchant_link (which is consumer-scoped with tenant_id as its own dimension). No AI features are built this pass, but every table that plausibly could be agent-proposed later already carries the standard autonomy pack (automation_source/review-status quintet), so a future AI pass needs no reopen.
Depends on platform (tenant), multi_loc (site — loyalty_program's own site-scoping), identity (actor — reviewed_by_actor_id/created_by_actor_id), consumer (consumer.consumer — bare FK, non-tenant-scoped target), and pos (sale — composite FK on loyalty_point_ledger.sale_id; requires pos.sale's own UNIQUE (id, tenant_id), added as a prerequisite by this same migration, not deferred).
PROJECT_DECISIONS entry: #56 (original 2026-07-11 build + lock) and #60 (same-day reopen — proportional/partial reversal fix, adding loyalty_point_ledger_reversal_tracker).
Global rules for this schema:
- Uniform tenant-scoping — all 7 tables carry
tenant_id NOT NULLFK →platform.tenant, standard RLS<table>_tenant_isolationpolicy,FOR ALL TO authenticated,USING/WITH CHECKbothtenant_id = current_setting('app.current_tenant_id')::uuid. Plain index ontenant_idon all 7. updated_attrigger-maintained viaplatform.set_updated_at()on 6 of 7 tables (loyalty_program,loyalty_accrual_rule,loyalty_reward_tier,reward_option,loyalty_account, and — added by the same-day reopen —loyalty_point_ledger_reversal_tracker). Not onloyalty_point_ledger— an append-only ledger fact with noupdated_atcolumn at all.- Soft delete (
deleted_at) on the same original 5 tables. Not onloyalty_point_ledger(append-only ledger fact) nor on the newloyalty_point_ledger_reversal_tracker(a small mutable counter row with no lifecycle to soft-delete). - Append-only, 1 table:
loyalty_point_ledger—REVOKE UPDATE, DELETE FROM authenticated+ triggertrg_loyalty_point_ledger_append_only, reusingplatform.reject_append_only_mutation()verbatim. PK defaultplatform.uuid_generate_v7()(time-ordered, matching this codebase's append-only-ledger PK convention). The newloyalty_point_ledger_reversal_trackeris deliberately the opposite — genuinely mutable — precisely because the ledger itself cannot host a maintained running-total column (see below). - Agent-as-actor — every
*_actor_idtargetsidentity.actor. - Autonomy tiers, non-uniform by design.
loyalty_accrual_ruleandreward_optionget a 5-column review seam (automation_source/review_status/review_reason/reviewed_by_actor_id/reviewed_at) but nodecision_provenanceand nocreated_by_actor_id.loyalty_programandloyalty_reward_tierget no autonomy columns at all — static program/catalog config, mirroringpricing.price_level's own "no autonomy columns" precedent.loyalty_accountalso gets no autonomy pack — a maintained balance record, not itself an agent-actionable decision surface; every balance change flows exclusively throughloyalty_point_ledger, which DOES carryautomation_source+created_by_actor_id. The newloyalty_point_ledger_reversal_trackerlikewise gets no autonomy pack — a maintained cumulative-cap counter row, not itself an agent-actionable surface. - THE ATOMIC SYNC TRIGGER —
rewards.sync_loyalty_account_balance(),BEFORE INSERT ON loyalty_point_ledger, closes a real concurrency race the design's own verification found: a single atomic statement whose ownUPDATE ... RETURNING ... INTO NEW.balance_after_pointstakes a row lock onloyalty_account, serializing concurrent writers — NOT a two-trigger (BEFORE-check / AFTER-sync) shape, which is exploitable (a second concurrent insert can read a stale pre-commit balance in the gap between the two triggers). Reopened the same day (PROJECT_DECISIONS #60) to replace an exact-full-negation-only reversal rule (areverseentry was rejected unless it zeroed out its original exactly) with genuine proportional/cumulative-cap partial reversal: areverseentry lazily creates a row in the newloyalty_point_ledger_reversal_trackertable snapshotting its original entry's magnitude (ON CONFLICT (tenant_id, ledger_id) DO NOTHING, so a 2nd+ partial reversal reuses the same row), then a single atomicUPDATE ... WHERE total_reversed_points + abs(:amt) <= abs(original_amount_points) ... RETURNINGboth caps and increments the cumulative total in one row-locking statement — a concurrent second reversal against the same original blocks on that row's lock and re-evaluates against the true post-serialization total, never a stale read-then-check-then-write race. See "The atomic sync trigger" below for the full mechanism and code. - Composite
(col, tenant_id)FK convention, used for every intra-rewardsandrewards → posFK (loyalty_program_id,sale_id,reward_option_id,loyalty_accrual_rule_id,current_tier_level_id,tier_level_id,expired_from_ledger_id,reversed_ledger_id— the last 2 self-referencing onloyalty_point_ledgeritself — plus, added by the same-day reopen,loyalty_point_ledger_reversal_tracker.ledger_id).consumer_idstays a bare FK →consumer.consumerthroughout —consumer.consumeris non-tenant-scoped, so no composite FK is possible or needed (the reverse direction from every other FK in this module).
Cross-Phase / Cross-Module Foreign Keys (rewards)
| Column | Target | Notes |
|---|---|---|
*.tenant_id (all 7 tables) |
platform.tenant |
NOT NULL |
loyalty_program.site_id |
multi_loc.site (id, tenant_id) |
nullable, composite FK loyalty_program_site_tenant_fkey |
loyalty_accrual_rule.loyalty_program_id, loyalty_reward_tier.loyalty_program_id, reward_option.loyalty_program_id, loyalty_account.loyalty_program_id |
rewards.loyalty_program (id, tenant_id) |
NOT NULL, composite |
reward_option.tier_level_id, loyalty_account.current_tier_level_id |
rewards.loyalty_reward_tier (id, tenant_id) |
nullable, composite |
loyalty_account.consumer_id |
consumer.consumer.id |
NOT NULL, bare — non-tenant-scoped target, no composite possible |
loyalty_point_ledger.loyalty_account_id |
rewards.loyalty_account (id, tenant_id) |
NOT NULL, composite |
loyalty_point_ledger.sale_id |
pos.sale (id, tenant_id) |
nullable, composite. Required pos.sale's own UNIQUE (id, tenant_id) — added by this same migration (sale_id_tenant_id_unique, Block 4 dependency resolved, not deferred; 486 live rows confirmed at migration time, zero-risk regardless of row count since id was already the sole PK) |
loyalty_point_ledger.reward_option_id |
rewards.reward_option (id, tenant_id) |
nullable, composite |
loyalty_point_ledger.loyalty_accrual_rule_id |
rewards.loyalty_accrual_rule (id, tenant_id) |
nullable, composite |
loyalty_point_ledger.expired_from_ledger_id, .reversed_ledger_id |
rewards.loyalty_point_ledger (id, tenant_id) |
nullable, self-referencing composite |
loyalty_point_ledger_reversal_tracker.ledger_id |
rewards.loyalty_point_ledger (id, tenant_id) |
NOT NULL, composite. NEW (same-day reopen, PROJECT_DECISIONS #60). UNIQUE (tenant_id, ledger_id) — one tracker row per original entry, also the ON CONFLICT target for the lazy-create insert |
loyalty_accrual_rule.reviewed_by_actor_id, reward_option.reviewed_by_actor_id, loyalty_point_ledger.created_by_actor_id |
identity.actor |
nullable, bare (codebase-wide convention for actor FKs) |
rewards.loyalty_program (14 cols) — the program config
Renamed from v1's loyalty_program (siblings likewise renamed: earning_rule → loyalty_accrual_rule, tier_level → loyalty_reward_tier, points_ledger → loyalty_point_ledger). v1's 12 columns preserved + 2 new: site_id (nullable) + applies_all_sites, reusing pricing.price_rule's own site-scoping shape. Extends v1's "one program per tenant" uniqueness to: one all-sites program per tenant, OR one program per (tenant, site).
Tenant-scoped. RLS enabled —
loyalty_program_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). No autonomy columns — static config.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
name |
text | NOT NULL | — | |
status |
text | NOT NULL | 'active' |
CHECK IN (active,paused,archived) |
points_per_dollar |
numeric | NOT NULL | 1 |
|
points_rounding |
text | NOT NULL | 'floor' |
CHECK IN (floor,round,ceil) |
min_redeem_points |
integer | NOT NULL | 0 |
|
expiry_policy_type |
text | NOT NULL | 'never' |
CHECK IN (never,rolling_months,calendar_year,inactivity_months) |
expiry_policy_value |
integer | nullable | — | Required IFF expiry_policy_type IN ('rolling_months','inactivity_months') |
site_id |
UUID | nullable | — | Composite FK → multi_loc.site |
applies_all_sites |
boolean | NOT NULL | false |
XOR with site_id |
CHECK constraints (6): chk_loyalty_program_status; chk_loyalty_program_points_rounding; chk_loyalty_program_expiry_policy_type; chk_loyalty_program_expiry_policy_value (coherence between expiry_policy_type and expiry_policy_value); chk_loyalty_program_applies_all_sites_xor_site_id (applies_all_sites = false OR site_id IS NULL).
Indexes (6): PK on id; loyalty_program_id_tenant_id_unique (UNIQUE, id+tenant_id — prerequisite for every child table's composite FK); loyalty_program_tenant_id_idx; loyalty_program_tenant_id_site_id_unique (UNIQUE, WHERE deleted_at IS NULL AND site_id IS NOT NULL); loyalty_program_tenant_id_all_sites_unique (UNIQUE, WHERE deleted_at IS NULL AND applies_all_sites = true); loyalty_program_tenant_id_status_idx (partial).
rewards.loyalty_accrual_rule (22 cols) — how points are earned
Renamed from v1's earning_rule. All 17 v1 columns preserved verbatim + 5 net-new: the review-seam quintet (automation_source/review_status/review_reason/reviewed_by_actor_id/reviewed_at).
Tenant-scoped. RLS enabled —
loyalty_accrual_rule_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). Autonomy: 5-column review seam only (nodecision_provenance, nocreated_by_actor_id).
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
loyalty_program_id |
UUID | NOT NULL | — | Composite FK → rewards.loyalty_program |
name |
text | NOT NULL | — | |
rule_scope |
text | NOT NULL | — | CHECK IN 8 values (purchase,item_category,item_variant,sale_threshold,birthday,signup,review,manual) |
scope_ref |
text | nullable | — | Loose ref — category code or item_variant sku/slug, validated at earn time |
earn_type |
text | NOT NULL | — | CHECK IN (multiplier,flat_bonus) |
multiplier |
numeric | nullable | — | Required IFF earn_type='multiplier' |
flat_bonus_points |
integer | nullable | — | Required IFF earn_type='flat_bonus' |
threshold_cents |
bigint | nullable | — | Required IFF rule_scope='sale_threshold' |
priority |
integer | NOT NULL | 0 |
|
is_active |
boolean | NOT NULL | true |
|
active_from |
timestamptz | nullable | — | |
active_to |
timestamptz | nullable | — | |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — |
CHECK constraints (8): chk_loyalty_accrual_rule_rule_scope; chk_loyalty_accrual_rule_earn_type; chk_loyalty_accrual_rule_earn_type_coherence; chk_loyalty_accrual_rule_threshold_coherence; chk_loyalty_accrual_rule_threshold_no_scope_ref (rule_scope != 'sale_threshold' OR scope_ref IS NULL); chk_loyalty_accrual_rule_automation_source; chk_loyalty_accrual_rule_review_status.
Indexes (4): PK on id; loyalty_accrual_rule_id_tenant_id_unique (UNIQUE); loyalty_accrual_rule_tenant_id_idx; loyalty_accrual_rule_program_scope_idx (partial); loyalty_accrual_rule_scope_ref_idx (partial).
rewards.loyalty_reward_tier (12 cols) — static tier catalog
Renamed from v1's tier_level, unchanged. Static catalog, no autonomy pack (matches loyalty_program/pricing.price_level).
Tenant-scoped. RLS enabled —
loyalty_reward_tier_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
loyalty_program_id |
UUID | NOT NULL | — | Composite FK → rewards.loyalty_program |
tier_code |
text | NOT NULL | — | |
name |
text | NOT NULL | — | |
min_points |
integer | NOT NULL | — | CHECK >= 0 |
sort_order |
integer | NOT NULL | — | |
earn_multiplier |
numeric | nullable | — | |
benefit_label |
text | nullable | — |
CHECK constraints (1): chk_loyalty_reward_tier_min_points_nonneg.
Indexes (5): PK on id; loyalty_reward_tier_id_tenant_id_unique (UNIQUE); loyalty_reward_tier_tenant_id_idx; loyalty_reward_tier_program_tier_code_unique (UNIQUE, WHERE deleted_at IS NULL); loyalty_reward_tier_program_min_points_unique (UNIQUE, WHERE deleted_at IS NULL); loyalty_reward_tier_program_min_points_idx.
rewards.reward_option (22 cols) — redeemable catalog
v1's 17 columns unchanged + the same 5-column autonomy pack as loyalty_accrual_rule.
Tenant-scoped. RLS enabled —
reward_option_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). Autonomy: 5-column review seam only.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
loyalty_program_id |
UUID | NOT NULL | — | Composite FK → rewards.loyalty_program |
name |
text | NOT NULL | — | |
reward_type |
text | NOT NULL | — | CHECK IN (discount_amount,discount_percent,free_item,perk) |
points_cost |
integer | NOT NULL | — | CHECK > 0 |
discount_amount_cents |
bigint | nullable | — | Required IFF reward_type='discount_amount' |
discount_percent |
numeric | nullable | — | Required IFF reward_type='discount_percent'. CHECK range (0, 100] |
free_item_variant_ref |
text | nullable | — | Loose ref — inventory.item_variant sku/slug, validated at redemption time. Required IFF reward_type='free_item' |
min_purchase_cents |
bigint | nullable | — | |
tier_level_id |
UUID | nullable | — | Composite FK → rewards.loyalty_reward_tier |
is_active |
boolean | NOT NULL | true |
|
active_from |
timestamptz | nullable | — | |
active_to |
timestamptz | nullable | — | |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — |
CHECK constraints (7): chk_reward_option_reward_type; chk_reward_option_reward_type_coherence (4-branch, one column set required per reward_type, others forbidden); chk_reward_option_points_cost_positive; chk_reward_option_discount_percent_range; chk_reward_option_automation_source; chk_reward_option_review_status.
Indexes (4): PK on id; reward_option_id_tenant_id_unique (UNIQUE); reward_option_tenant_id_idx; reward_option_program_active_idx (partial); reward_option_program_tier_idx (partial).
rewards.loyalty_account (14 cols) — the per-(consumer, tenant) account
v1 unchanged. balance_points/lifetime_points are genuinely trigger-maintained (see loyalty_point_ledger's own sync trigger below), not just documented as a formula. consumer_id NOT NULL = claimed consumers only (no stub accounts, matching v1's own "no stub-accounts" design principle).
Tenant-scoped. RLS enabled —
loyalty_account_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). No autonomy pack — a maintained balance record, not an agent-actionable surface.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
loyalty_program_id |
UUID | NOT NULL | — | Composite FK → rewards.loyalty_program |
consumer_id |
UUID | NOT NULL | — | FK → consumer.consumer (bare) |
balance_points |
integer | NOT NULL | 0 |
CHECK >= 0 — defensive backstop; the real, atomic, row-locking enforcement point is loyalty_point_ledger's own sync trigger |
lifetime_points |
integer | NOT NULL | 0 |
CHECK >= 0 |
current_tier_level_id |
UUID | nullable | — | Composite FK → rewards.loyalty_reward_tier |
tier_achieved_at |
timestamptz | nullable | — | |
enrolled_at |
timestamptz | NOT NULL | now() |
|
last_earn_at |
timestamptz | nullable | — | |
last_redeem_at |
timestamptz | nullable | — |
CHECK constraints (2): chk_loyalty_account_balance_points_nonneg; chk_loyalty_account_lifetime_points_nonneg.
Indexes (6): PK on id; loyalty_account_id_tenant_id_unique (UNIQUE — prerequisite for loyalty_point_ledger's composite FK); loyalty_account_tenant_id_idx; loyalty_account_tenant_id_consumer_id_unique (UNIQUE, WHERE deleted_at IS NULL); loyalty_account_consumer_id_idx; loyalty_account_program_tier_idx; loyalty_account_last_earn_at_idx (partial).
rewards.loyalty_point_ledger (18 cols) — append-only, THE source of truth
Renamed from v1's points_ledger. v1's 17 columns preserved (1 retargeted: created_by_user_id → created_by_actor_id) + automation_source = 18. entry_type CHECK covers v1's real 5 values (earn/redeem/expire/adjust/reverse) + promo_bonus (genuinely new). balance_after_points >= 0 is kept as a defensive backstop alongside the sync trigger, never dropped (mirrors receiving.goods_receipt_line's own precedent of keeping a CHECK backstop alongside trigger enforcement).
Tenant-scoped, append-only. RLS enabled —
loyalty_point_ledger_tenant_isolation. REVOKE UPDATE, DELETE FROM authenticated + triggertrg_loyalty_point_ledger_append_only. Noupdated_at, no soft delete.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | platform.uuid_generate_v7() |
PK — append-only PK convention |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
loyalty_account_id |
UUID | NOT NULL | — | Composite FK → rewards.loyalty_account |
created_at |
timestamptz | NOT NULL | now() |
|
entry_type |
text | NOT NULL | — | CHECK IN (earn,redeem,expire,adjust,reverse,promo_bonus) |
amount_points |
integer | NOT NULL | — | Sign enforced per entry_type — see CHECKs |
balance_after_points |
integer | NOT NULL | — | Trigger-derived via UPDATE ... RETURNING ... INTO NEW.balance_after_points — never caller-supplied |
sale_id |
UUID | nullable | — | Composite FK → pos.sale |
source_type |
text | NOT NULL | — | CHECK IN (sale,review,signup,birthday,manual,expiry) |
source_ref |
text | nullable | — | Loose ref — non-sale source identifier |
reward_option_id |
UUID | nullable | — | Composite FK → rewards.reward_option |
loyalty_accrual_rule_id |
UUID | nullable | — | Composite FK → rewards.loyalty_accrual_rule |
expires_at |
timestamptz | nullable | — | |
expired_from_ledger_id |
UUID | nullable | — | Self-referencing composite FK |
reversed_ledger_id |
UUID | nullable | — | Self-referencing composite FK |
note |
text | nullable | — | Required IFF entry_type='adjust' |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
CHECK constraints (14, up from 13 — the same-day reopen added the 14th): chk_loyalty_point_ledger_balance_after_points_nonneg; chk_loyalty_point_ledger_entry_type; chk_loyalty_point_ledger_earn_positive (entry_type NOT IN ('earn','promo_bonus') OR amount_points > 0); chk_loyalty_point_ledger_redeem_expire_negative (entry_type NOT IN ('redeem','expire') OR amount_points < 0); chk_loyalty_point_ledger_redeem_requires_reward_option; chk_loyalty_point_ledger_expire_requires_source; chk_loyalty_point_ledger_reverse_requires_source; chk_loyalty_point_ledger_adjust_requires_note; chk_loyalty_point_ledger_source_type; chk_loyalty_point_ledger_source_coherence (3-branch — sale requires sale_id+no source_ref; manual/expiry require no sale_id; every other source_type requires source_ref+no sale_id); chk_loyalty_point_ledger_expire_source_type; chk_loyalty_point_ledger_expiry_source_entry_type; chk_loyalty_point_ledger_automation_source; chk_loyalty_point_ledger_reverse_nonzero (entry_type != 'reverse' OR amount_points != 0, added by the same-day reopen, PROJECT_DECISIONS #60 — a belt-and-suspenders backstop; in practice sync_loyalty_account_balance()'s own sign-match logic already rejects a zero-magnitude reversal first).
Indexes (5): PK on id; loyalty_point_ledger_id_tenant_id_unique (UNIQUE — prerequisite for the self-referencing composite FKs); loyalty_point_ledger_account_created_at_idx; loyalty_point_ledger_sale_id_idx (partial); loyalty_point_ledger_expires_at_idx (partial, WHERE entry_type = 'earn'); loyalty_point_ledger_reversed_ledger_id_idx (partial).
rewards.loyalty_point_ledger_reversal_tracker (7 cols) — cumulative-reversed-amount tracker, added by the 2026-07-11 same-day reopen
New table (PROJECT_DECISIONS #60), not part of the original 2026-07-11 build. A small, genuinely mutable counter row per original loyalty_point_ledger entry that has ever been (partially or fully) reversed — lazily created on first touch (INSERT ... ON CONFLICT (tenant_id, ledger_id) DO NOTHING, snapshotting the original's own amount_points), then atomically capped on every subsequent reversal by rewards.sync_loyalty_account_balance()'s own UPDATE ... WHERE total_reversed_points + abs(:amt) <= abs(original_amount_points) ... RETURNING. Exists as a separate table, not a maintained column on loyalty_point_ledger itself, because that ledger is append-only via platform.reject_append_only_mutation() — confirmed via pg_get_functiondef to be an unconditional RAISE EXCEPTION with no column-level exception — so a running-total column directly on the ledger would hit that trigger on its very first UPDATE. Mirrors offers.offer.budget_used_cents/.redemption_count's own established "maintained counter on a mutable row" precedent, generalized to a case with no natural existing header to hold it.
Tenant-scoped. RLS enabled —
loyalty_point_ledger_reversal_tracker_tenant_isolation.updated_at: trigger-maintained viaplatform.set_updated_at(). No soft delete — a small mutable counter row, not a lifecycle entity. No autonomy pack.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
ledger_id |
UUID | NOT NULL | — | Composite FK → rewards.loyalty_point_ledger — the original entry being (partially) reversed |
original_amount_points |
integer | NOT NULL | — | Snapshotted from the original ledger row's own amount_points on first touch (lazy-create, ON CONFLICT (tenant_id, ledger_id) DO NOTHING) |
total_reversed_points |
integer | NOT NULL | 0 |
Cumulative running tally across every reverse entry against this original, atomically capped in the same statement that increments it |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
CHECK constraints (1): chk_loyalty_point_ledger_reversal_tracker_total_reversed_bounds (total_reversed_points >= 0 AND total_reversed_points <= abs(original_amount_points)) — a defensive backstop; the real, atomic enforcement point is sync_loyalty_account_balance()'s own capped UPDATE (see "The atomic sync trigger" below), mirroring this codebase's established convention of keeping a CHECK backstop alongside trigger enforcement.
Indexes (2): PK on id; loyalty_point_ledger_reversal_tracker_tenant_ledger_unique (UNIQUE, tenant_id+ledger_id — one tracker row per original entry, and the ON CONFLICT target for the lazy-create insert); loyalty_point_ledger_reversal_tracker_tenant_id_idx.
The atomic sync trigger
rewards.sync_loyalty_account_balance() — the mechanism keeping loyalty_account.balance_points/.lifetime_points genuinely in sync with loyalty_point_ledger, not just documented as a formula. A single atomic BEFORE INSERT statement: its own UPDATE takes a row lock on the target loyalty_account row, so a concurrent second transaction's identical UPDATE blocks until the first commits or rolls back — NEW.balance_after_points always reflects the true post-serialization value, never a stale snapshot. This closes a real concurrency race the design's own independent verification found in an earlier two-trigger (BEFORE-check / AFTER-sync) draft, where two concurrent redemptions could both read the same pre-commit balance and both pass a sufficient-balance check that only one of them should have passed.
Reopened the same day (PROJECT_DECISIONS #60) — proportional/cumulative-cap partial reversal. The original build's version of this function hard-enforced exact, full negation only on a reverse entry: reverse ALL of an earn's points or NONE, rejecting a genuine partial return (e.g. 2 of 5 units) outright. The corrected function below adds a reverse-only preamble that (a) lazily creates a row in the new loyalty_point_ledger_reversal_tracker table for the original entry being reversed, snapshotting its amount_points (ON CONFLICT (tenant_id, ledger_id) DO NOTHING, so a 2nd+ partial reversal against the same original reuses the existing row — the lazy-create SELECT itself excludes entry_type='reverse' rows, so a reversal of a reversal finds no eligible original), then (b) performs a single atomic, row-locking UPDATE ... WHERE ... RETURNING that validates in one statement: the tracker row exists (the original was found and is reversible), this reversal's sign is opposite the original's own sign (sign(original_amount_points) = -1 * sign(NEW.amount_points), which generalizes across earn(+)/redeem(-)/adjust(±) originals with no per-entry-type special-casing), and the cumulative reversed total — including this one — does not exceed the original's own magnitude. Zero rows returned ⇒ reject. A concurrent second reversal against the same original blocks on that row's lock and re-evaluates against the true post-serialization total — never a stale read-then-check-then-write race (independently proven: a standalone naive read-then-check-then-write version of this same logic was shown to actually over-reverse under 3 concurrent -200 calls against a +500 cap, ending at 600; the atomic version, under the identical load, correctly stopped at exactly 400 with 1 of the 3 rejected).
CREATE OR REPLACE FUNCTION rewards.sync_loyalty_account_balance()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_lifetime_delta integer := CASE WHEN NEW.entry_type = 'earn' THEN NEW.amount_points ELSE 0 END;
v_original_amount integer;
v_rows int;
BEGIN
IF NEW.entry_type = 'reverse' THEN
-- Lazily create the tracker row for the original entry being reversed
-- (snapshotting its amount_points) -- ON CONFLICT DO NOTHING so a 2nd+
-- partial reversal against the same original reuses the existing row.
-- The SELECT deliberately excludes entry_type='reverse' rows -- you
-- cannot reverse a reversal.
INSERT INTO rewards.loyalty_point_ledger_reversal_tracker (tenant_id, ledger_id, original_amount_points)
SELECT NEW.tenant_id, NEW.reversed_ledger_id, l.amount_points
FROM rewards.loyalty_point_ledger l
WHERE l.id = NEW.reversed_ledger_id AND l.tenant_id = NEW.tenant_id AND l.entry_type != 'reverse'
ON CONFLICT (tenant_id, ledger_id) DO NOTHING;
-- Atomic cap + increment: a single row-locking UPDATE whose own WHERE
-- clause validates (a) the tracker row exists, (b) this reversal's sign
-- is opposite the original's sign, and (c) the cumulative reversed total
-- (including this one) does not exceed the original's own magnitude. A
-- concurrent second reversal against the same original blocks on this
-- row's lock and re-evaluates against the true post-serialization total
-- -- no read-then-check-then-write race window.
UPDATE rewards.loyalty_point_ledger_reversal_tracker
SET total_reversed_points = total_reversed_points + abs(NEW.amount_points)
WHERE tenant_id = NEW.tenant_id
AND ledger_id = NEW.reversed_ledger_id
AND sign(original_amount_points) = -1 * sign(NEW.amount_points)
AND total_reversed_points + abs(NEW.amount_points) <= abs(original_amount_points)
RETURNING original_amount_points INTO v_original_amount;
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows = 0 THEN
RAISE EXCEPTION 'reverse entry for reversed_ledger_id % would exceed the cumulative reversible amount, has the wrong sign, references a reversal, or was not found', NEW.reversed_ledger_id;
END IF;
END IF;
UPDATE rewards.loyalty_account
SET balance_points = balance_points + NEW.amount_points,
lifetime_points = lifetime_points + v_lifetime_delta,
last_earn_at = CASE WHEN NEW.entry_type = 'earn' THEN now() ELSE last_earn_at END,
last_redeem_at = CASE WHEN NEW.entry_type = 'redeem' THEN now() ELSE last_redeem_at END
WHERE id = NEW.loyalty_account_id
RETURNING balance_points INTO NEW.balance_after_points;
-- The UPDATE above takes a row lock on loyalty_account; a concurrent
-- second transaction's identical UPDATE blocks until this one commits or
-- rolls back, so NEW.balance_after_points always reflects the true
-- post-serialization value, not a stale snapshot.
IF NEW.balance_after_points < 0 THEN
RAISE EXCEPTION 'insufficient points balance for account %', NEW.loyalty_account_id;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_loyalty_point_ledger_sync_account
BEFORE INSERT ON rewards.loyalty_point_ledger
FOR EACH ROW EXECUTE FUNCTION rewards.sync_loyalty_account_balance();
A redemption (entry_type='redeem', negative amount_points) that would drive the balance below zero is rejected outright by the IF NEW.balance_after_points < 0 guard — fail-closed, not merely flagged. lifetime_points only ever increases (driven off entry_type='earn' specifically, not every positive-amount_points entry — promo_bonus is also positive but does not, by this trigger's own logic, add to lifetime totals since v_lifetime_delta only fires on 'earn'). A reverse entry now supports genuine proportional/partial claw-back (capped cumulatively via loyalty_point_ledger_reversal_tracker, never exceeding the original's own magnitude), superseding the original build's exact-full-negation-only rule.
rewards — Design Patterns Summary
Column-count reconciliation
| Table | Cols |
|---|---|
loyalty_program |
14 |
loyalty_accrual_rule |
22 |
loyalty_reward_tier |
12 |
reward_option |
22 |
loyalty_account |
14 |
loyalty_point_ledger |
18 |
loyalty_point_ledger_reversal_tracker |
7 |
| Total | 109 |
Counted directly from the applied CREATE TABLE statements — the original 6 tables from packages/db/migrations/20260711000000_consumer_rewards_offers.sql (102 cols, cross-checked against the Drizzle source's own per-table comments, all 6 agreeing exactly) plus the 7-column loyalty_point_ledger_reversal_tracker added by the same-day reopen migration, packages/db/migrations/20260711030000_rewards_offers_proportional_clawback.sql. 7 tables, 109 columns, matching PROJECT_DECISIONS #60's own stated delta (+7 over the #56 baseline).
Triggers, full list (3 functions, 8 trigger objects)
| Trigger | Table | Fires | Function |
|---|---|---|---|
set_updated_at |
loyalty_program, loyalty_accrual_rule, loyalty_reward_tier, reward_option, loyalty_account, loyalty_point_ledger_reversal_tracker |
BEFORE UPDATE |
platform.set_updated_at() (shared, reused) |
trg_loyalty_point_ledger_append_only |
loyalty_point_ledger |
BEFORE UPDATE OR DELETE |
platform.reject_append_only_mutation() (shared, reused) |
trg_loyalty_point_ledger_sync_account |
loyalty_point_ledger |
BEFORE INSERT |
rewards.sync_loyalty_account_balance() (reopened 2026-07-11, PROJECT_DECISIONS #60 — proportional/cumulative-cap reversal; see above) |
Service layer
No RewardsService yet — schema-only this pass, same pattern as every other module's deferred service layer at build time. The point-earning/redemption business logic (which loyalty_accrual_rule applies to a given sale, tier progression) is entirely a future service-layer concern; the schema only guarantees the ledger stays internally consistent and the account balance stays synced.
JSONB columns
None in this schema — every column is a real typed column, no JSONB anywhere in rewards.
Open items carried forward
- No
RewardsServiceyet — accrual-rule application, tier progression, and redemption business logic are all unbuilt. - Point expiry is schema-representable (
entry_type='expire',expires_at,expired_from_ledger_id) but nothing yet schedules or triggers it — a future batch job's own concern. loyalty_program's site-scoping uniqueness (one all-sites program per tenant, OR one program per(tenant, site)) is a disclosed choice made at build time, not stated explicitly in the original design doc's own table walk.loyalty_point_ledger.source_refis a loose text ref (review ID, etc.) for non-sale sources — no FK, validated at write time only by the service layer once built.- This proportional-reversal fix (PROJECT_DECISIONS #60) is Pass 1 of a 2-pass effort — Pass 2 builds the
returnsmodule againstreturns-module-design-proposal-2026-07-11.md, which is the actual consumer of this capability (a return needs to claw back a proportional share of loyalty points/offer discount).