Loyalty Card (Customer Side)
Overview
The loyalty card is a shop's rewards card that customers open from LINE. It renders as a card styled to the shop's brand colors and supports two modes:
- Stamp mode — collect stamps; filling the card earns a reward. Suits businesses that count visits, such as coffee shops.
- Point mode — accumulate points based on spend and redeem rewards at will, with optional membership tiers. Suits businesses where bill sizes vary widely.
The cycle is simple: customers earn by scanning a QR the staff generates, and redeem by showing a coupon code for staff to scan.
Business Flow
Earning
Two paths are supported so both existing and brand-new customers are covered:
- Scanning from inside the card app — a customer already viewing their card taps the scan button. The app opens the LIFF scanner, extracts the token from whatever was scanned (handling both a bare token and a full URL), and submits it to record the earn.
- Cold scan from the phone camera — a new customer who has not added the OA as a
friend can scan the QR with an ordinary phone camera, because the QR encodes a LIFF
link. LINE opens the card page (prompting them to add the OA), and the card page
records the earn automatically with no extra tap.
- The token is read from the URL, handling both the direct query parameter and the
case where it is nested inside
liff.state, which happens when LINE routes through itself. - The app waits for card data to load before recording, so the confirmation message uses the wording that matches the card's mode (points versus stamps).
- Duplicate submission is guarded against, since a token is single-use, and the parameter is stripped from the URL once consumed.
- The token is read from the URL, handling both the direct query parameter and the
case where it is nested inside
Redeeming and Using Rewards
- In point mode, the customer taps to redeem a reward. Points are deducted, a coupon is issued, and the app switches to the My Coupons tab and opens the code sheet immediately.
- The coupon code is requested only when the code sheet opens — it never ships with the card data at page load. This keeps codes out of any response a customer could screenshot and reuse later.
- A code is displayed for 180 seconds and can be requested again afterwards. This is an anti-screenshot measure, not the coupon's expiry — the coupon itself remains waiting in the My Coupons tab.
- Staff are the ones who scan the code to burn the coupon (see loyalty-staff).
Special States Users May Encounter
- Sign-in failed — show the sign-in-again screen.
- The platform admin has disabled the feature — show that it is off, and do not retry, since the answer will not change.
- The shop has not enabled loyalty cards — say so directly.
- Opened outside the LINE app — warn up front that scanning will not work, rather than letting the user tap and hit a failure.
Key Screens & Components
The card page lives at /{hash}/loyalty, with all components under
src/app/[hash]/loyalty/:
- Card header (
CardHeader) — the card face, membership tier, and progress. - Stamp grid (
StampGrid,StampMark) — used in stamp mode to show collected stamps. - Reward list (
RewardList) — split into available rewards and My Coupons tabs. - Code sheet (
RewardSheet,CodeBlocks) — opens to display the code with a countdown. - Sign-in-again screen (
SignInAgain) — used when authentication is lost. - Scanner helper (
lib/scanner.ts) — checks whether the environment can scan, opens the scanner, and distinguishes failure causes: opened outside LINE, capability unavailable, user cancelled, or a general failure. - Page container (
loyalty.container.tsx) — wires everything together, including reading the token from the URL, redemption, and the automatic cold-scan behavior.
Every color on the card (accent, stamp fill, text, button) comes from the API already
validated; the web page merely applies it. Fonts load through next/font using
Noto Sans Thai.
Endpoints Used
| Method | Path |
|---|---|
| GET | /loyalty/{hash} |
| GET | /loyalty/{hash}/cards |
| POST | /loyalty/{hash}/earn |
| POST | /loyalty/{hash}/rewards/redeem |
| POST | /loyalty/{hash}/rewards/{rewardId}/code |
The common error cases are translated into Thai messages, covering an already-consumed token, an invalid token, an active earn cooldown, an inactive program, and insufficient points.
Dependencies
- The LIFF scanner is the only scanner available, because a LIFF page cannot reach the camera through browser APIs directly. Requirements: the Scan QR permission must be enabled in the LINE Developers Console, the LIFF app size must be Full, and on iOS the LINE app must be version 9.19.0 or later.
- The bulletin board's authentication hook is reused here, since the authentication chain is identical step for step (see bulletin-board).
- Ant Design — uses
Drawer,QRCode,Spin, and the message system viaApp.useApp(). - The store staff side is the matching half of this feature: tokens issued there are consumed here, and coupons created here are burned there (see loyalty-staff).
- A test suite covers earn-token parsing and consumption, the staff permission gate, and stamp grid rendering.
Backend Details (Client API)
Gates before the handler
The /loyalty/:hash route group is wrapped in the same per-organization app toggle
middleware as the bulletin board (app id loyalty). An organization with the app disabled
gets a 403 with message APP_DISABLED, which is exactly the "feature disabled" screen the
web app shows without retrying. The gate covers both the customer and the staff side —
disabling the app also stops the staff till from working.
Every endpoint then resolves the OA from :hash (not found → 404
"loyalty card not found") and verifies the LIFF token against that OA's LINE Login
channel, using the same ladder as the bulletin board (channel binding).
GET /api/loyalty/:hash — the whole card in one round trip
This endpoint returns everything the LIFF page needs at once: the program shape (mode, theme, unit label), the active card, the reward ladder, held rewards, the balance, the membership tier, and the distance to the next tier.
- A shop with no program is not an error — the response comes back with just the OA name and picture plus empty lists, so the page can show "not available yet" without parsing an error.
- A loyalty account row is created for this LINE user if none exists, and the card whose status is active becomes the current card.
- The reward ladder is loaded by program id plus version. Programs are versioned, so an older version's ladder is never displayed.
- The balance is computed from ledger lots that have not expired, not from a number cached on the account.
- Every image (card cover, logo, reward pictures, tier backgrounds) resolves to a public URL, and when there is no path or no storage configured no image is sent at all — never a URL pointing at the bucket root.
GET /api/loyalty/:hash/cardsis the card history page, returning every card on the account.
Why the tier is computed live every time
The tier is not cached on the account, because the tier ladder can be re-priced in the CMS at any time, and a cached tier name would keep displaying a tier that has since been renamed or deleted.
- The current tier is read by tier id directly, because tiers are bound to the OA and are not versioned with the program. Matching against the current program version's ladder would leave a customer who earned their tier under an earlier version with no tier at all.
- The next tier is the nearest active tier whose rank is above the current one.
- The "how much more to spend" figure comes from the next tier's target minus spend within the time window (3 months by default). A tier with no spend-based rule gets a target of 0, meaning no figure is shown — the system never invents one, because a ladder built on order counts or membership age has no baht amount to count down.
The tier engine
Tiers are decided by a rule set, not a single threshold, measuring four metrics: spend, number of earn transactions, points earned in the window, and membership age (that last one ignores the window). Rules combine with and/or, and the whole engine applies only to point mode with tiers enabled.
- A rule that cannot be parsed matches nobody, not everybody. Failing open would promote the entire customer base because of one admin typo — an unrecoverable mistake once people have seen their new tier.
- An empty rule set matches nobody, for the same reason: a tier with no conditions yet is a half-finished tier.
- The ladder walk goes bottom to top, keeping the last match, yielding the highest rank that qualifies rather than the first. Admins can write overlapping rules, and someone who qualifies for both Silver and Gold must get Gold.
- The default tier is skipped during the walk — its rules are never evaluated, and it is returned only when nothing else qualifies. That makes it a genuine floor that can never beat a tier the customer earned, whatever rank it is assigned.
- The ladder is sorted by rank only, never by threshold, because an admin mid-edit may briefly leave two tiers with equal values, and a numeric ordering would reshuffle itself under their hands.
- The time window prefers the tier's own setting, then the program's, then 3 months. Since each tier measures in its own window, statistics are gathered per tier, not once per account.
POST /api/loyalty/:hash/earn — earning (rate limited 10/60s)
This has the tightest rate limit in the group, because it is the most attackable surface. The whole thing runs in one transaction, and the order is meaningful:
- Program not live → 400
LOYALTY_PROGRAM_INACTIVE; empty token → 400LOYALTY_TOKEN_INVALID. - Create the account if needed (an account must exist before a token can be claimed).
- Claim the token with a conditional UPDATE — the race between two people scanning the
same QR is settled here, not at the read. A failed claim where the token does exist → 409
LOYALTY_TOKEN_CONSUMED; a token that never existed → 400LOYALTY_TOKEN_INVALID. - Cooldown is checked after the claim, deliberately. Checking first would leave a
token rejected for cooldown still alive for the next person to scan off the same screen —
precisely the QR-sharing this is meant to prevent. In cooldown → 409
LOYALTY_COOLDOWN. - Run the card arithmetic and write the ledger, grant the welcome bonus, then consider a promotion.
Walking the cards one at a time — the ledger row is written before touching any card, because the idempotency key (the token itself) trips before any mutation happens. A replayed token therefore changes nothing at all, rather than half-updating a card and then failing. After that:
- Point mode with no grid → add the units and finish.
- Stamp mode → fill card by card and persist every card that completes. The earlier version computed the final position in one shot and wrote only the last card, so cards that filled along the way vanished silently: a customer whose first scan awarded 7 stamps on a 5-slot card ended up with a single partly-filled card, no completed card in their history, and a reward pointing at nothing.
- A filled card is closed, a reward is issued for every milestone whose required units fit within the card size, and the next card in the sequence opens.
- Reward lifetime uses the milestone's own setting when present, otherwise the card's expiry date — a reward outliving its card would be redeemable against a card the customer can no longer see.
The welcome bonus is granted on the first earn, not at account creation, because the account row is created the moment anyone opens the card. Granting it there would hand stamps to people who merely looked. The real guarantee is a unique index rather than a pre-check, and the bonus amount is reported separately from what the staff member awarded, because a customer stamped once who sees a 2 will assume the system is broken.
Promotion during an earn only ever moves up. Demotion is the nightly job's business — nobody should drop a tier mid-transaction at a counter with a queue behind them. Conversely, waiting until tomorrow to say someone just reached Gold throws away the one moment a tier means anything. Failure at this step is swallowed (logged as a warning), because the units are already on the account: failing the whole earn over a broken tier lookup would turn a cosmetic problem into a lost transaction, and the nightly job re-evaluates anyway.
POST /api/loyalty/:hash/rewards/redeem — redeeming with points (rate limited 10/60s)
Stricter than the read endpoints because it deducts a balance, and it runs in one transaction: burning the lots successfully but failing to issue the reward would take the customer's points and give nothing back — the one failure that cannot be recovered without a manual apology.
- Program not live → 400
LOYALTY_PROGRAM_INACTIVE; not point mode → 400LOYALTY_NOT_POINT_MODE(allowing this in stamp mode would conjure a reward out of thin air without completing a card). - The milestone is loaded only from the current program version — an id from a superseded version is no longer on the ladder, and looking it up by bare id would let a stale LIFF screen buy a reward that has been retired.
- The tier gate — a reward with a minimum tier requires the account's rank to reach it,
otherwise 400
LOYALTY_TIER_TOO_LOW. This is enforced server-side rather than by hiding a button, because a stale screen or a hand-crafted request must not buy a Gold reward on a Silver account. The gate applies only to new redemptions: rewards already issued stay usable even if the customer is later demoted (they paid for it, and clawing it back because a rolling window moved is indefensible). - Lot balances are read inside the transaction. Read outside, two taps of the redeem
button would see the same balance and both succeed. Insufficient balance → 400
LOYALTY_INSUFFICIENT_UNITS. - Lots closest to expiring are burned first (FIFO by expiry date), so the customer gets to use points they were going to lose anyway.
- Each lot deduction is a conditional write; if any one fails (another redemption slipped in between the read and the write), the whole thing aborts — never a partial deduction.
- A burn ledger row is written, and the reward is issued without being attached to any card, because point-mode rewards belong to the account rather than to a card.
POST /api/loyalty/:hash/rewards/:id/code — requesting a coupon code (rate limited 20/60s)
- The reward is looked up only within the caller's own account rewards, so someone else's
reward simply is not in the list and answers 404
LOYALTY_REWARD_INVALIDwithout confirming the id exists. - A status other than unclaimed → 409
LOYALTY_REWARD_CLAIMED; already expired → 409LOYALTY_REWARD_EXPIRED. - The code never appears in the card response, deliberately — matching what the web side describes about requesting the code only when the sheet opens. Shipped with the card data, a screenshotted response would remain redeemable forever.