Skip to main content

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)

  1. resolve(c)EnsureAccountListRewards for the account.
  2. 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_INVALID without confirming whether that id exists at all.
  3. A status other than unclaimed returns 409 LOYALTY_REWARD_CLAIMED; an expired reward returns 409 LOYALTY_REWARD_EXPIRED.
  4. The response is {code, title}.
  5. 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.

  1. A milestoneId of 0 or less returns 400 LOYALTY_REWARD_INVALID.
  2. A program that is not live returns 400 LOYALTY_PROGRAM_INACTIVE. A program not in point mode returns 400 LOYALTY_NOT_POINT_MODE — allowing this path in stamp mode would mint a reward without completing a card, conjuring it out of thin air.
  3. EnsureAccount runs, 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, or required_units not greater than 0, returns 400 LOYALTY_REWARD_INVALID.
  4. The tier gate: a milestone with min_tier_rank above 0 requires the account's rank to reach it, otherwise 400 LOYALTY_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.
  5. 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 400 LOYALTY_INSUFFICIENT_UNITS.
  6. PlanBurn burns soonest-to-expire lots first (FIFO by expiry date), so the customer spends points they were about to lose anyway.
  7. ConsumeLot deducts 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.
  8. InsertBurn writes the ledger row with SourceRedeem.
  9. InsertReward creates 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.
  10. 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

RouteRate limitHandler
POST /api/loyalty/:hash/rewards/:id/code20/60sinternal/loyalty/handler.go(*Handler).RewardCode
POST /api/loyalty/:hash/rewards/redeem10/60s(*Handler).RedeemPoints
  • internal/loyalty/service_redeem.go(*Service).RedeemPoints
  • internal/loyalty/repository.goListRewards, LotsTx, ConsumeLot, InsertBurn, InsertReward, FindRewardByID, ListMilestones, ListTiers, WithTx
  • internal/loyalty/entity.goAvailableUnits, PlanBurn, rewardExpiry, and the error codes LOYALTY_INSUFFICIENT_UNITS, LOYALTY_NOT_POINT_MODE, LOYALTY_TIER_TOO_LOW, LOYALTY_REWARD_INVALID/CLAIMED/EXPIRED
  • internal/loyalty/view.goNewRewardViews

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)