Skip to main content

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 window
  • orders — number of earn transactions within the window
  • points — units earned within the window
  • member_months — membership age; the window has no effect on this metric

Evaluation (Qualifies / PickTierByRules)

  1. ParseTierConditions reads the jsonb conditions column into {join:"and"|"or", rules:[{metric,op,value}]}. join defaults to "and", and any value other than "or" is coerced to "and".
  2. 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.
  3. 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.
  4. Unknown metrics and unknown operators (only gte, lte, eq are supported) fail the check.
  5. PickTierByRules walks 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.
  6. 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.

SituationOutcome
No current tier, earned tier presentTierInitial
No current tier, no earned tierNothing written (empty reason)
Earned rank above currentTierPromote
Earned rank equals currentNothing written, so tier_history does not fill with "stayed put" rows
Earned rank below current, fallback step_downDrop 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 specificDrop 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.

FileFunctions
internal/loyalty/tier.goTier, SortTiers, NextTier, byRank, ResolveFromEarned; constants FallbackStepDown, FallbackSpecific, TierPromote, TierDemote, TierInitial
internal/loyalty/tier_rules.goTierRule, 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.goThe tier block in GetCard (GetTierByID, ListTiersByLineOa, NextTier, SpendInWindow)
internal/loyalty/repository.goListTiers, ListTiersByLineOa, GetTierByID, StatsInWindow, SpendInWindow, SetTier
internal/loyalty/view.goNewTierView (per-tier color, text color, background image)

Connections to Other Services

  • Tables loyalty.tier (jsonb conditions, 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