Loyalty — Redeeming Rewards & Codes
Overview
Two customer-facing reward endpoints: requesting a short-lived coupon code for a reward the customer already holds (so the staff device can scan it), and buying a reward with points, which applies to point mode only.
The difference between the two program modes is clearest here. In stamp mode, rewards are minted by completing a card — the customer chooses nothing and nothing is deducted. In point mode, the balance is a currency: the customer picks a reward off the ladder and pays for it.
Business Flow
Request a coupon code — POST /api/loyalty/:hash/rewards/:id/code (rate limit 20/60s)
resolve(c)→EnsureAccount→ListRewardsfor the account.- The requested id is looked up only within the account's own rewards. Someone else's reward is
simply not in the list, so the endpoint returns 404
LOYALTY_REWARD_INVALIDwithout confirming whether that id exists at all. - A status other than
unclaimedreturns 409LOYALTY_REWARD_CLAIMED; an expired reward returns 409LOYALTY_REWARD_EXPIRED. - The response is
{code, title}. - The code never appears in the card page response, by design — a screenshotted response would otherwise stay redeemable forever.
Buy a reward with points — POST /api/loyalty/:hash/rewards/redeem (rate limit 10/60s)
Body {milestoneId}. The rate limit is stricter than the read paths because this one deducts balance.
Everything runs in one transaction: burning lots successfully but then failing to issue the reward would take the customer's points and give nothing back — the one failure that cannot be recovered without an apology and a manual fix.
- A
milestoneIdof 0 or less returns 400LOYALTY_REWARD_INVALID. - A program that is not live returns 400
LOYALTY_PROGRAM_INACTIVE. A program not in point mode returns 400LOYALTY_NOT_POINT_MODE— allowing this path in stamp mode would mint a reward without completing a card, conjuring it out of thin air. EnsureAccountruns, then the milestone is loaded for the current program id and version. An id from a superseded version is no longer on the ladder; looking up by bare id would let a stale LIFF screen buy a reward that has since been retired. Not found, orrequired_unitsnot greater than 0, returns 400LOYALTY_REWARD_INVALID.- The tier gate: a milestone with
min_tier_rankabove 0 requires the account's rank to reach it, otherwise 400LOYALTY_TIER_TOO_LOW. This must be enforced server-side, not merely by hiding a button — 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 minted stay usable even if the customer is later demoted. They paid for it, and clawing it back because they slipped out of a rolling window is indefensible. - Lots are read inside the transaction via
LotsTx. Reading outside it would let a double-tap on the redeem button see the same balance twice and succeed both times. An insufficient balance returns 400LOYALTY_INSUFFICIENT_UNITS. PlanBurnburns soonest-to-expire lots first (FIFO by expiry date), so the customer spends points they were about to lose anyway.ConsumeLotdeducts each lot conditionally. If any call returns false — another redemption slipped in between the read and the write — the whole operation aborts rather than deducting partially. A burned total that does not match the required amount also aborts, as a second safety belt.InsertBurnwrites the ledger row withSourceRedeem.InsertRewardcreates the reward with no card attached: a point-mode reward belongs to the account, not to a card. The nullable column exists precisely for this case.- The freshly minted reward is returned as a
RewardView.
The staff side
Actually burning the reward in store lives in loyalty-staff at
POST /staff/redeem.
Key Files & Functions
| Route | Rate limit | Handler |
|---|---|---|
POST /api/loyalty/:hash/rewards/:id/code | 20/60s | internal/loyalty/handler.go → (*Handler).RewardCode |
POST /api/loyalty/:hash/rewards/redeem | 10/60s | (*Handler).RedeemPoints |
internal/loyalty/service_redeem.go—(*Service).RedeemPointsinternal/loyalty/repository.go—ListRewards,LotsTx,ConsumeLot,InsertBurn,InsertReward,FindRewardByID,ListMilestones,ListTiers,WithTxinternal/loyalty/entity.go—AvailableUnits,PlanBurn,rewardExpiry, and the error codesLOYALTY_INSUFFICIENT_UNITS,LOYALTY_NOT_POINT_MODE,LOYALTY_TIER_TOO_LOW,LOYALTY_REWARD_INVALID/CLAIMED/EXPIREDinternal/loyalty/view.go—NewRewardViews
Connections to Other Services
- Tables
loyalty.reward,loyalty.transaction(lots and burns),loyalty.milestone,loyalty.tier,loyalty.account,loyalty.program - app-enabled-guard and liff-authentication
- Stamp-mode rewards originate in loyalty-earn; in-store redemption happens in loyalty-staff; rank rules come from loyalty-tier
- Corresponding client-web feature:
loyalty-card(redeeming rewards and displaying short-lived coupon codes)