Skip to main content

Loyalty Tiers

Column-level detail for loyalty.tier, tier_history

Overview​

Membership tiers were added in migrations 006-009 of the loyalty schema. They introduce two new tables — tier (each rung of the ladder and its rules) and tier_history (every promotion and demotion) — plus new columns on three existing tables (account, milestone, program). Every migration in this group is additive: new tables and nullable-or-defaulted columns only, so a binary built before them keeps working against the newer schema, which is what makes a code-only rollback safe.

The feature is point mode only, deliberately. The rule a merchant configures measures accumulated spend inside a rolling window, and amount_spent is NULL on every stamp row — a stamp is one stamp whatever the basket cost. Switching to stamp mode therefore sets tiers aside exactly as it already sets balances and the reward ladder aside.

Table loyalty.tier​

ColumnTypeNullableDefaultDescription
idSERIALNOauto incrementPrimary key
program_idINTEGERNO-FK to loyalty.program(id), ON DELETE CASCADE
line_oa_idINTEGERNO-LINE OA this tier belongs to
organization_idINTEGERNO-Owning organization
nameVARCHAR(60)NO-Tier name, e.g. Silver, Gold
rankINTEGERNO-Ladder position (must be greater than 0) — rank, never threshold, so the list does not reorder itself while the merchant is typing
thresholdNUMERIC(12,2)YES-Legacy single spend threshold in baht, superseded by conditions; retained for rollback and no longer read (migration 007)
colorVARCHAR(9)YES-Tier colour
bg_image_pathTEXTYES-Object path of the tier card background image
statusVARCHAR(20)NO'active'Status: active or archived
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPCreation timestamp
updated_dateTIMESTAMPTZ(3)YES-Last update timestamp
conditionsJSONBNO'{"join":"and","rules":[]}'The rule set for this tier (migration 007)
window_monthsINTEGERYES-Rolling window for this tier's totals in months (1-120), set per tier (migration 007)
text_colorVARCHAR(9)YES-Text colour on this tier's card — NULL uses the program's (migration 008)
is_defaultBOOLEANNOfalseThe floor tier a customer sits on until rules earn them higher; its own rules are never evaluated (migration 009)

Shape of conditions​

{
"join": "and",
"rules": [
{ "metric": "member_months", "op": "gte", "value": 6 },
{ "metric": "spend", "op": "gte", "value": 1000 },
{ "metric": "orders", "op": "gte", "value": 5 }
]
}
  • metric: spend | orders | points | member_months
  • op: gte | lte | eq
  • window_months applies to every metric except member_months, which is account age — a duration, not a total inside a window

Table loyalty.tier_history​

ColumnTypeNullableDefaultDescription
idBIGSERIALNOauto incrementPrimary key
account_idINTEGERNO-FK to loyalty.account(id), ON DELETE CASCADE
line_oa_idINTEGERNO-LINE OA this record belongs to
organization_idINTEGERNO-Owning organization
from_tier_idINTEGERYES-FK to loyalty.tier(id) — tier before the change (NULL on initial assignment)
to_tier_idINTEGERYES-FK to loyalty.tier(id) — tier after the change
reasonVARCHAR(20)NO-Reason: promote, demote, initial
window_valueNUMERIC(12,2)NO0The window total that produced the decision, so the row explains itself
created_dateTIMESTAMPTZ(3)NOCURRENT_TIMESTAMPWhen the change happened

Columns added to existing tables​

loyalty.account (migration 006)​

ColumnTypeNullableDefaultDescription
tier_idINTEGERYES-FK to loyalty.tier(id) — where the customer currently sits
tier_sinceTIMESTAMPTZ(3)YES-When they entered the current tier
tier_evaluated_dateTIMESTAMPTZ(3)YES-When the tier was last evaluated

loyalty.milestone (migration 006)​

ColumnTypeNullableDefaultDescription
min_tier_rankINTEGERYES-Minimum tier rank needed to claim this reward — NULL means open to everybody, which every existing reward must remain

loyalty.program (migration 006)​

ColumnTypeNullableDefaultDescription
tier_enabledBOOLEANNOfalseWhether tiers are on for this program
tier_window_monthsINTEGERNO3Program-level rolling window in months (1-120)
tier_fallbackVARCHAR(20)NO'step_down'Demotion behaviour: step_down (one rung at a time) or specific (drop to a named tier)
tier_fallback_tier_idINTEGERYES-FK to loyalty.tier(id) — the target when tier_fallback = 'specific'

Notes​

  • tier check constraints: chk_loy_tier_rank (rank > 0), chk_loy_tier_threshold (threshold >= 0), chk_loy_tier_status (status IN ('active','archived')) and chk_loy_tier_window (window_months NULL or between 1 and 120)
  • Other check constraints: chk_loy_tier_hist_reason (reason IN ('promote','demote','initial')), chk_loy_milestone_tier (min_tier_rank NULL or greater than 0), chk_loy_program_tier_window (tier_window_months between 1 and 120), chk_loy_program_tier_fallback (tier_fallback IN ('step_down','specific'))
  • Unique: idx_loy_tier_rank on (program_id, rank) WHERE status = 'active' — one tier per rung; being partial, archiving a tier frees its rank for reuse. And idx_loy_tier_one_default on program_id WHERE is_default AND status = 'active' — at most one default per program, since two defaults would make "the tier you fall to" ambiguous
  • Other indexes: idx_loy_tier_program on (program_id, rank); idx_loy_tier_hist_acct on (account_id, created_date DESC); idx_loy_account_tier on (line_oa_id, tier_id) WHERE tier_id IS NOT NULL
  • Thresholds are spend, not points: threshold and the spend metric are baht of spend inside the window. Points are burned by redemptions, so a point-based threshold would demote a customer for using a reward — the same trap that rules out computing tier from balance
  • Why tier_history is not optional bookkeeping: a rolling window drops customers without them doing anything, so "why did I drop?" is the support question this feature generates. Without the window_value that caused the decision, nobody can answer it
  • Why conditions is JSONB rather than a child table: the rules are always read as a whole with their tier and never queried across tiers, so a table would add a join and a second CRUD surface for something that is one object — matching bonus_rule.conditions and the audience filter
  • Migration 007 data move: existing threshold values were carried across into a single equivalent spend rule, so no merchant's ladder resets. threshold was made nullable rather than dropped so a rollback to pre-rules binaries still finds the column it expects; a COMMENT ON COLUMN marks it as legacy and not read