Loyalty — Earning Stamps/Points via QR
Overview
This is the heart of the in-store transaction. The customer scans a QR the staff member generated (valid for 60 seconds) and posts that code to this endpoint. The system exchanges it for stamps or points, walks the customer's cards one at a time, closes any that fill up, issues rewards, applies the welcome bonus, and promotes the tier — all right there at the counter.
It carries the strictest rate limit in the module (10/60s) because it is the most attacked surface.
Business Flow
POST /api/loyalty/:hash/earn (rate limit 10/60s) — body {token}
Everything runs in one transaction, and the ordering is deliberate.
- A program that is not live returns 400
LOYALTY_PROGRAM_INACTIVE; an empty token returns 400LOYALTY_TOKEN_INVALID. EnsureAccountruns — an account must exist before a token can be claimed.ClaimTokenis a conditional UPDATE, so concurrency is decided at the write, not at a read. When the claim fails, the system distinguishes "never existed" from "already used or expired" viaTokenExists: a real token returns 409LOYALTY_TOKEN_CONSUMED, an unknown one returns 400LOYALTY_TOKEN_INVALID. There is nothing useful to tell the customer in the latter case.- The cooldown is checked after the claim, on purpose. Checking first would leave a token
rejected for cooldown still alive for the next person to scan off the same screen — exactly the
QR-sharing behavior the cooldown exists to prevent. A blocked scan returns 409
LOYALTY_COOLDOWN. applyEarnperforms the card arithmetic and writes the ledger.applyJoinBonusgrants the welcome bonus.maybePromotepromotes the tier; a failure here is non-fatal.
applyEarn — why cards are walked one at a time
- Find the open card and compute its expiry from
expiry_mode/expiry_months, anchored on the card's first earn. - The ledger row is written before any card is touched, so the idempotency key (the token itself) trips before a single mutation occurs. A replayed token therefore changes nothing at all, rather than half-updating a card and then failing.
- In modes where
sizeis 0 or less — point mode with no grid — the units are simply added and the flow ends. - Stamp mode fills card after card, persisting every card that completes.
- An earlier version computed the final position in one shot and wrote only the last card, which made cards filled along the way vanish silently: a customer whose first scan awarded 7 stamps on a 5-slot card ended up with a single card #1 holding the remainder, no completed card in their history, and rewards pointing at nothing.
- If no card exists yet, one is inserted with
sequence_no = 1andAttachEarnToCardlinks the ledger row written earlier to it — otherwise the log would contain stamps belonging to no card. - When a card fills,
UpdateCardProgress(status=complete)runs, rewards are issued for every milestone whoserequired_unitsfits within the card size, and a new card opens atsequence_no + 1. - Reward lifetime uses the milestone's
reward_expiry_dayswhen set, otherwise the card's expiry date: a reward outliving its card would have to be redeemed against a card the customer can no longer see.
applyJoinBonus
The bonus is granted on the first earn, not at account creation, because the account row is
created the moment anyone opens the card page — granting there would hand stamps to people who merely
looked. The real guarantee is the unique index on bonus_grant; GrantBonus returning false means
another request won the race.
The bonus amount is reported separately as bonusAwarded rather than folded into awarded, because a
customer who watched staff punch one stamp and then sees "2" will assume the system is broken.
maybePromote — promotions only
Demotion is the nightly job's responsibility. Nobody should drop a tier mid-transaction in front of a staff member with a queue behind them. Conversely, waiting until tomorrow to announce a new Gold tier throws away the one moment when the tier means something.
Failures here are swallowed and logged at warn level: the units are already credited, and failing the whole earn because a tier lookup broke would turn a cosmetic problem into a lost transaction — and the nightly job re-evaluates tiers anyway.
Response
{awarded, bonusAwarded, cardCompleted, newRewards, card}
Key Files & Functions
| Item | Value |
|---|---|
| Route | POST /api/loyalty/:hash/earn (rate limit 10/60s) |
| Handler | internal/loyalty/handler.go → (*Handler).Earn |
| Service | internal/loyalty/service.go → (*Service).Earn, applyEarn, applyJoinBonus, maybePromote, rewardExpiry; type EarnResult |
| Repository | internal/loyalty/repository.go → WithTx, EnsureAccount, ClaimToken, TokenExists, LastEarnAt, FindOpenCard, InsertEarn, AttachEarnToCard, InsertCard, UpdateCardProgress, ListMilestones, InsertReward, FindJoinBonus, GrantBonus, ListTiers, StatsInWindow, SetTier |
| Entity | CardExpiry, CooldownBlocks, Program.Size(), Program.Live(), SourceStaffQR, SourceBonus |
Connections to Other Services
- Tables
loyalty.earn_token,loyalty.account,loyalty.card_instance,loyalty.transaction,loyalty.milestone,loyalty.reward,loyalty.bonus_rule,loyalty.bonus_grant,loyalty.tier,loyalty.tier_history,loyalty.program - Tokens come from loyalty-staff via
POST /staff/tokens - Tier rules live in loyalty-tier; the rewards issued here are consumed by loyalty-reward-redeem
- The nightly tier re-evaluation and demotion job lives on the worker/CMS side, not in this service
- Corresponding client-web feature:
loyalty-card(the QR scan-to-earn flow)