Loyalty — Tier System
Overview
The mechanism that grades customers (Bronze / Silver / Gold, for example) based on a rule set rather than a single threshold number. Each tier carries its own conditions, measured across four metrics within a time window that can be configured per tier.
It applies only to point mode with tier_enabled on, and it is pure logic with no endpoint of its
own. Three callers use it: the earn path (immediate promotion), the card page load (displaying the
tier and the distance to the next one), and a nightly job that lives elsewhere.
Business Flow
Metrics a rule can measure
spend— baht spent within the windoworders— number of earn transactions within the windowpoints— units earned within the windowmember_months— membership age; the window has no effect on this metric
Evaluation (Qualifies / PickTierByRules)
ParseTierConditionsreads the jsonbconditionscolumn into{join:"and"|"or", rules:[{metric,op,value}]}.joindefaults to"and", and any value other than"or"is coerced to"and".- A rule set that fails to parse matches nobody — not everybody. Failing open would promote the
entire customer base because of a typo, a mistake that cannot be walked back once people have seen
their new tier. The implementation injects a
metric:"__invalid__"rule that nothing can satisfy. - An empty rule set also matches nobody. A tier with no conditions yet is a half-finished tier; reading it as "everyone" would move the whole customer base onto the rung an admin is still typing.
- Unknown metrics and unknown operators (only
gte,lte,eqare supported) fail the check. PickTierByRuleswalks the ladder bottom to top and keeps the last match, yielding the highest qualifying rank rather than the first match — admins can write overlapping rules, and someone who qualifies for both Silver and Gold must get Gold.- The default tier (
is_default) is skipped during the walk. Its rules are never evaluated, and it is returned only when no other tier qualifies. That makes it a true floor, unable to beat a tier the customer actually earned, no matter what rank it is assigned.
Ordering the ladder (SortTiers)
Sorted by rank only, never by threshold. An admin mid-edit may briefly leave two tiers with equal
thresholds, and a numeric ordering would reshuffle itself under their hands.
The time window (WindowFor)
The tier's own window_months wins if set; otherwise the program's tier_window_months; otherwise a
default of 3 months. Because each tier measures within its own window, maybePromote collects stats
per tier rather than once per account.
Movement up and down (ResolveFromEarned)
This is kept separate from "picking a tier" so that the movement rules do not change when thresholds become rule sets — only how a tier is earned changes, not what happens when someone moves.
| Situation | Outcome |
|---|---|
| No current tier, earned tier present | TierInitial |
| No current tier, no earned tier | Nothing written (empty reason) |
| Earned rank above current | TierPromote |
| Earned rank equals current | Nothing written, so tier_history does not fill with "stayed put" rows |
Earned rank below current, fallback step_down | Drop one rung (byRank(rank-1)) rather than jumping to the earned tier — someone who goes quiet for a month loses one rung and can win it back with a single visit |
Earned rank below current, fallback specific | Drop to the configured tier, unless that tier's rank is at or above the current one; otherwise anyone already sitting on the fallback tier would be "demoted" onto it every night |
Inside the earn path (maybePromote)
Only TierPromote and TierInitial are honored. ResolveFromEarned can legitimately return a
demotion when the window shifts, but acting on it mid-transaction at the counter is off the table.
nextSpendTarget
Produces the baht figure for the "฿X to go" line on the card: it looks for a rule where metric is
spend with a gte or eq operator. If none exists the result is 0, meaning no figure is shown. A
ladder built on order counts or membership age has no amount to count down toward, and inventing one
would put a meaningless number on the customer's card.
Key Files & Functions
This feature exposes no routes of its own.
| File | Functions |
|---|---|
internal/loyalty/tier.go | Tier, SortTiers, NextTier, byRank, ResolveFromEarned; constants FallbackStepDown, FallbackSpecific, TierPromote, TierDemote, TierInitial |
internal/loyalty/tier_rules.go | TierRule, TierConditions, AccountStats, ParseTierConditions, statFor, matchRule, Qualifies, PickTierByRules, WindowFor, nextSpendTarget; metrics MetricSpend/MetricOrders/MetricPoints/MetricMemberMonths |
internal/loyalty/service.go | (*Service).maybePromote, the call site on the earn path |
internal/loyalty/handler.go | The tier block in GetCard (GetTierByID, ListTiersByLineOa, NextTier, SpendInWindow) |
internal/loyalty/repository.go | ListTiers, ListTiersByLineOa, GetTierByID, StatsInWindow, SpendInWindow, SetTier |
internal/loyalty/view.go | NewTierView (per-tier color, text color, background image) |
Connections to Other Services
- Tables
loyalty.tier(jsonbconditions,rank,window_months,is_default,status,color,text_color,bg_image_path),loyalty.tier_history,loyalty.account(tier_id),loyalty.transaction(the source of all stats),loyalty.program(tier_enabled,tier_window_months,tier_fallback) - Called from loyalty-earn and loyalty-card; acts as the
gate for loyalty-reward-redeem via
min_tier_rank - Tiers are not versioned with the program — they belong to the OA. See loyalty-card for the reasoning
- The nightly re-evaluation and demotion job runs outside this service (worker/CMS) but must apply the same rules
- Corresponding client-web feature:
loyalty-card