Loyalty — Customer Card Page
Overview
This endpoint delivers the entire loyalty card in a single round trip so the LIFF page can render it in one go: the program shape (stamp or point mode, theme, unit label), the active card, the milestone ladder, the rewards the customer holds, the balance, the tier, and the distance to the next tier. A companion endpoint serves the customer's card history.
A program runs in one of two modes whose behavior differs substantially: stamp (a punch card that automatically issues a reward once filled) and point (points act as currency and the customer chooses what to redeem).
Business Flow
Load the card — GET /api/loyalty/:hash
resolve(c)looks up the OA by the:hashparameter — a miss returns 404"loyalty card not found"— then verifies the LIFF token against that OA's channel using the same ladder as the bulletin module.- The OA's live program is loaded. Having no program is not an error: the response contains only the
OA name and images (
milestones: [],rewards: []), and the LIFF page shows a "not yet available" screen. EnsureAccountcreates aloyalty.accountrow for this LINE user if one does not exist.- All of the account's cards are loaded, and the one with
status = activebecomescard. - Milestones are loaded for that specific program id and version. Programs are versioned, so an older version's reward ladder is never displayed.
- The account's rewards are loaded and rendered through
NewRewardViews(rewards, storage, now), which decides expiry state against the current time. Balancecomes fromAvailableUnits(lots, now), counting only ledger lots that have not expired.- The tier block (point mode with
tier_enabledonly) is computed fresh on every card load rather than cached on the account, because the ladder can be re-priced in the CMS at any time and a cached tier name would keep showing a tier that has since been renamed or deleted.- The current tier is read directly by
tier_id, because tiers are not versioned with the program — they belong to the OA. Matching against the current program version's rungs instead would leave customers who earned their tier under an earlier version with no tier at all. - The full ladder is the set of active tiers for the OA, across program versions, for the same reason.
NextTieris the closest active rung ranked above the current one.SpendToNextisnextSpendTarget(next)minus spend within the time window, floored at 0, with a default window of 3 months. A tier with nospendmetric rule gets a target of 0, meaning there is no baht figure to count down — the system never invents one.
- The current tier is read directly by
- Every image (cover, logo, reward artwork, tier background) is resolved to a public URL via
publicURLOf. An empty path or missing storage means no image is sent at all, rather than a URL pointing at the bucket root.
Card history — GET /api/loyalty/:hash/cards
Calls EnsureAccount and returns every card as {cards: [...]} — a history view LINE itself does not
provide.
What is deliberately not in the response
Reward coupon codes. See loyalty-reward-redeem: if codes were included in the card response, a screenshot of it would remain redeemable forever.
Key Files & Functions
| Route | Handler |
|---|---|
GET /api/loyalty/:hash | internal/loyalty/handler.go → (*Handler).GetCard |
GET /api/loyalty/:hash/cards | (*Handler).ListCards |
internal/loyalty/register.go—Register(r, deps)plusappguard.AppEnabledGuard(db,"loyalty")internal/loyalty/handler.go—(*Handler).resolveand thecallertypeinternal/loyalty/auth.go— the token-verification ladder (a copy of the bulletin one)internal/loyalty/view.go—CardResponse,NewProgramView,NewCardView,NewCardViews,NewMilestoneViews,NewRewardViews,NewTierView,NewBranchViews,publicURLOfinternal/loyalty/repository.go—FindOaByHash,FindLiveProgram,EnsureAccount,ListCards,ListMilestones,ListRewards,Lots,GetTierByID,ListTiersByLineOa,SpendInWindowinternal/loyalty/entity.go—Program,Account,CardInstance,Milestone,Reward,AvailableUnits,PlanBurn,CardExpiry,CooldownBlocks,UnitsForSpend, and the constantsModeStamp/ModePoint,CardActive/CardComplete/CardExpired,RewardUnclaimed/RewardClaimed/RewardExpired,MaxCardSize=20,MaxEarnUnits=10000,TokenTTL=60s
Connections to Other Services
- Tables
loyalty.program,loyalty.account,loyalty.card_instance,loyalty.milestone,loyalty.reward,loyalty.transaction(ledger lots),loyalty.tier,line_oa internal/storagexfor the public URLs of every image- app-enabled-guard (appID
loyalty) and liff-authentication - loyalty-tier owns the tier calculation rules, while loyalty-earn and loyalty-reward-redeem are the paths that mutate what this page displays
- Corresponding client-web feature:
loyalty-card