Skip to main content

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

  1. resolve(c) looks up the OA by the :hash parameter — 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.
  2. 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.
  3. EnsureAccount creates a loyalty.account row for this LINE user if one does not exist.
  4. All of the account's cards are loaded, and the one with status = active becomes card.
  5. Milestones are loaded for that specific program id and version. Programs are versioned, so an older version's reward ladder is never displayed.
  6. The account's rewards are loaded and rendered through NewRewardViews(rewards, storage, now), which decides expiry state against the current time.
  7. Balance comes from AvailableUnits(lots, now), counting only ledger lots that have not expired.
  8. The tier block (point mode with tier_enabled only) 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.
    • NextTier is the closest active rung ranked above the current one.
    • SpendToNext is nextSpendTarget(next) minus spend within the time window, floored at 0, with a default window of 3 months. A tier with no spend metric rule gets a target of 0, meaning there is no baht figure to count down — the system never invents one.
  9. 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

RouteHandler
GET /api/loyalty/:hashinternal/loyalty/handler.go(*Handler).GetCard
GET /api/loyalty/:hash/cards(*Handler).ListCards
  • internal/loyalty/register.goRegister(r, deps) plus appguard.AppEnabledGuard(db,"loyalty")
  • internal/loyalty/handler.go(*Handler).resolve and the caller type
  • internal/loyalty/auth.go — the token-verification ladder (a copy of the bulletin one)
  • internal/loyalty/view.goCardResponse, NewProgramView, NewCardView, NewCardViews, NewMilestoneViews, NewRewardViews, NewTierView, NewBranchViews, publicURLOf
  • internal/loyalty/repository.goFindOaByHash, FindLiveProgram, EnsureAccount, ListCards, ListMilestones, ListRewards, Lots, GetTierByID, ListTiersByLineOa, SpendInWindow
  • internal/loyalty/entity.goProgram, Account, CardInstance, Milestone, Reward, AvailableUnits, PlanBurn, CardExpiry, CooldownBlocks, UnitsForSpend, and the constants ModeStamp/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/storagex for 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