Skip to main content

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.

  1. A program that is not live returns 400 LOYALTY_PROGRAM_INACTIVE; an empty token returns 400 LOYALTY_TOKEN_INVALID.
  2. EnsureAccount runs — an account must exist before a token can be claimed.
  3. ClaimToken is 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" via TokenExists: a real token returns 409 LOYALTY_TOKEN_CONSUMED, an unknown one returns 400 LOYALTY_TOKEN_INVALID. There is nothing useful to tell the customer in the latter case.
  4. 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.
  5. applyEarn performs the card arithmetic and writes the ledger.
  6. applyJoinBonus grants the welcome bonus.
  7. maybePromote promotes the tier; a failure here is non-fatal.

applyEarn — why cards are walked one at a time​

  1. Find the open card and compute its expiry from expiry_mode / expiry_months, anchored on the card's first earn.
  2. 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.
  3. In modes where size is 0 or less — point mode with no grid — the units are simply added and the flow ends.
  4. 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 = 1 and AttachEarnToCard links 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 whose required_units fits within the card size, and a new card opens at sequence_no + 1.
    • Reward lifetime uses the milestone's reward_expiry_days when 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​

ItemValue
RoutePOST /api/loyalty/:hash/earn (rate limit 10/60s)
Handlerinternal/loyalty/handler.go → (*Handler).Earn
Serviceinternal/loyalty/service.go → (*Service).Earn, applyEarn, applyJoinBonus, maybePromote, rewardExpiry; type EarnResult
Repositoryinternal/loyalty/repository.go → WithTx, EnsureAccount, ClaimToken, TokenExists, LastEarnAt, FindOpenCard, InsertEarn, AttachEarnToCard, InsertCard, UpdateCardProgress, ListMilestones, InsertReward, FindJoinBonus, GrantBonus, ListTiers, StatsInWindow, SetTier
EntityCardExpiry, 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)