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.goWithTx, 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)