Loyalty Tiers
Overview
An extension of Loyalty Cards & Membership that adds membership tiers — e.g. Silver, Gold, Platinum — with promotion based on conditions a shop defines, evaluated within a rolling time window.
This system has one intentional restriction: point mode only, because the main condition
is cumulative spend from the amount_spent column, which is NULL on every row in stamp mode
(a single stamp is worth one stamp regardless of basket value). If a shop switches back to
stamp mode, tier membership is set aside, just like the balance is.
The entire migration set is additive only — every change is either a new table or a nullable column — so older binaries keep working against the new schema. That's what makes a "code-only rollback" safe without ever having to reverse the schema.
Core Data Structure
loyalty.tier — a program's tier ladder
-
program_idis a FK withON DELETE CASCADE, plusname -
rankis an ordering, not a threshold — because a shop may keep editing the ladder, the system must never reorder it automatically. A partial unique index(program_id, rank) WHERE status='active'lets the rank of an archived tier be reused -
conditionsis JSONB holding each tier's rule set, replacing the single threshold used in the first version:{"join":"and","rules":[{"metric":"member_months","op":"gte","value":6},{"metric":"spend","op":"gte","value":1000},{"metric":"orders","op":"gte","value":5}]}metricsupportsspend,orders,points, andmember_months;opsupportsgte,lte, andeq. JSONB was chosen over a child table because the rules are always read as a whole alongside the tier, and are never queried across tiers -
window_monthsis each tier's time window, which must always be paired with a number, otherwise a condition like "5,000 baht" would be meaningless — except for themember_monthsmetric, which doesn't use this value since it's the member's age, not a cumulative amount within a window -
threshold(NUMERIC) is a legacy column kept around for rollback purposes, documented with aCOMMENT ON COLUMN, and no longer read by current code -
Display:
color,text_color(empty means fall back to the program's color), andbg_image_path -
is_defaultmarks the baseline tier everyone starts at before meeting any condition, enforced by a partial unique index(program_id) WHERE is_default AND status='active'so there's only ever one, and this tier's own rules are never evaluated
loyalty.tier_history — promotion/demotion history
Records every promotion and demotion.
from_tier_id,to_tier_id, andreason(promote,demote, orinitial)window_valuestores the window value that caused the change, so a "why was I demoted" question can be answered instantly without recomputing from data that has since moved on
Columns added to existing tables
| Table | Added columns |
|---|---|
loyalty.account | tier_id, tier_since, tier_evaluated_date |
loyalty.milestone | min_tier_rank (empty means open to everyone) |
loyalty.program | tier_enabled, tier_window_months (default 3, must be 1–120), tier_fallback (step_down or specific), tier_fallback_tier_id |
Related Files
apps/loyalty/migrations/006_tiers.sql— thetierandtier_historytables plus the added columnsapps/loyalty/migrations/007_tier_rules.sql— the JSONBconditionscolumn and migrating the old threshold into a ruleapps/loyalty/migrations/008_tier_text_color.sql— per-tier text colorapps/loyalty/migrations/009_tier_default.sql— the baseline tierdocs/superpowers/specs/2026-07-28-loyalty-tier-design.md— the design specdocs/ROLLBACK-tier.md— every repo's commit at the point before this feature started, with a DROP script if needed
Connections to Other Services
- worker-go and cms-api-go act as the tier evaluators — reading
transactionwithin the time window, comparing it againstconditions, then updatingaccount.tier_idand writing totier_history - client-api-go displays the current tier, the card's color, and rewards unlocked per
min_tier_rankon the card screen in LIFF - All of this builds on top of Loyalty Cards & Membership