# Rewards V2 — Bonus-Ball Cricket Dice, slot cabinets, vending redemption and points

Last updated Nov 1, 2026 · Page: https://freebeeads.com/docs/repo/rewards-v2-design · Markdown: https://freebeeads.com/docs/md/repo/rewards-v2-design.md · All docs: https://freebeeads.com/llms-full.txt

Status: **Built 2026-11-01; see API_CONTRACT v2.0** (design written 2026-10-31; decisions recorded 2026-11-01, see §10.1; what shipped vs. this design is listed at the end). Builds on contract v1.5
(wallet), v1.7.1 (kiosk + wall), v1.8 (dice) and the 2026-10-24 prize-fulfilment rules.
Nothing here re-opens a locked premise; it adds a fulfilment channel and a points
balance to the existing wallet.

Source of the new game: `Bonus_Ball_Cricket_Dice.html` (the artifact prototype) and its
context file. The prototype is a single-player browser page; this document turns it into
a server-scored game that runs on two kinds of hardware and pays out through a vending
machine.

---

## 0. One-paragraph summary

A shopper plays the Freebee cricket dice either on a **kiosk** (wall + tablet + phone
ticket, as today) or on a **slot cabinet** (one physical SPIN button, no tablet, no phone
needed to play). Every ball is rolled on the server. The innings ends out or out of balls.
The player wins **the highest rung of a five-rung reward ladder** they hit, or, if they hit
none, **points**. The result screen shows a QR code and a short code. On their own phone
they enter their number, get an OTP, and the reward or points land in their wallet. For a
reward they get a **6-digit vend PIN**, walk to the Freebee vending machine in the mall,
type it, and the drink, chocolate or chips drops. Points accumulate per phone number and
convert to the same vend PIN once they cross a threshold. Later, the same wallet shows
in-app offers next to the ads we play.

---

## 1. What exists today and what changes

| Area | Today (v1.8 / 2026-10-24) | V2 |
|---|---|---|
| Game | Dice mode exists: server rolls from `odds`, a six adds a ball, prizes by **run bands** (0–3 bands big/mid/small) | Same dice. Prizes by a **milestone ladder** (5 rungs, one reward per player, highest rung wins). Run bands stay as a legacy mode. |
| Who rolls | Kiosk tablet (`/kiosk/[screen]`) after a phone ticket code | Kiosk **or** the cabinet itself, driven by one physical button (walk-up, no ticket) |
| Result | `claim_url` only when a prize band is hit; otherwise "thanks for playing" | **Every** innings mints a token: a reward token or a points token. QR + 6-char short code on screen. |
| Claim | `/w/claim/{token}` → phone + OTP + consent → coupon with a code "show at the counter" | Same page. A coupon now carries a **product** and a **fulfilment channel**. Vending coupons get a **vend PIN**. Points tokens add to a **points ledger**. |
| Redeem | `POST /v1/redeem` by the advertiser or ops at a counter | Counter stays. New: the **vending agent** redeems a PIN, vends, confirms. |
| Points | none | Append-only `point_entries` ledger per wallet user; threshold → coupon → vend PIN |
| Ops | Fulfilment counts, coupons, reminders | Vending tab (machines, stock, jams, disputes), reward catalogue, points liability, and a **Rewards settings** page where ladder names/products, points rates, the winners-earn flag, the machine driver and the app bonus are edited live (§8.1) |
| Shopper surface | Wallet web page `/w` | Web page **and** the Freebee app (shopper role in the one Flutter app). Same QR and short code open either; claiming in the app earns a configurable bonus (§4.4) |

---

## 2. The two hardware modes

```mermaid
flowchart LR
  subgraph Kiosk mode (exists)
    P[Shopper's phone<br/>scan wall QR] -->|name + 4-digit code| K[Kiosk tablet<br/>enter code, ROLL]
    K -->|ball| API
    API -->|turn| W[Wall / big screen<br/>dice tumbles]
  end
  subgraph Slot mode (new)
    B[Physical SPIN button] -->|keypress| C[Cabinet screen<br/>cricket engine]
    C -->|start innings, ball| API
  end
  API[(FastAPI)] --> R[Result card<br/>QR + short code]
```

**Kiosk mode** is unchanged: the wall is the stage, the phone is the ticket, the tablet
is the controller. One innings at a time per wall.

**Slot mode** is the new cabinet (42" inverted-J, IR touch on the flat part, one arcade
button). Rules:

- The cabinet runs the same `cricket.html` engine inside the Freebee player, in a new
  `input: "button"` configuration. No tablet, no ticket, no name, no avatar. The player
  walks up and presses.
- The arcade button is a USB keyboard encoder (Zero Delay / Ultimarc class) mapped to
  **Space**. The engine already treats Space as Roll. The shell adds a 350 ms debounce and
  ignores key-repeat, so a held button is one press.
- Press 1 starts the innings (server call) and rolls ball 1. Each later press rolls one
  ball. The engine refuses presses while the dice is tumbling (`busy`), exactly as the
  prototype does.
- The IR touch area shows the same SPIN as a big on-screen button, so a cabinet with no
  physical button still works. Both paths call the same `roll()`.
- One innings at a time per cabinet. There is no queue: whoever is in front of it plays.
- The result card stays for 25 s (configurable) with the QR and short code, then the
  cabinet returns to attract mode. A press during the result card starts a new innings
  at once (the card is dismissed, the token is already minted and stays valid).

**Why the server still rolls in slot mode.** A cabinet is a PC in a public place. If the
HTML rolled, a tampered cabinet could mint Queen Bee tokens all day. So the cabinet asks
the server for every ball, the server stores the roll, and prizes are derived from the
stored balls, never from what the screen reports. This is the same rule the wall follows.

---

## 3. The game, server side

### 3.1 Dice and balls (already shipped, v1.8)

Faces and default odds from the prototype: 1 → 13.33 %, 2 → 13.33 %, dot → 13.34 %,
4 → 20 %, out → 20 %, 6 → 20 %. Six balls to start, +1 ball per six, hard cap
`max_balls` (12). The server's `roll_value`, `ball_limit` and `score` already do this. The
only change: the default `odds` move from the v1.8 numbers (out 18 %) to the prototype's
(out 20 %), and advertisers edit them in the portal with the prototype's "must sum to 100"
check and the live expected-runs / expected-balls / not-out figures.

### 3.2 The reward ladder (new)

`CricketConfig.ladder` replaces run bands for new creatives (`prizes` stays for old rows;
a creative uses one or the other, validated).

```json
"ladder": [
  {"id": "six6",   "rank": 1, "rule": "six_on_ball",   "ball": 6, "reward_id": "...", "daily_budget": 10, "stock_total": null},
  {"id": "four4",  "rank": 2, "rule": "four_on_ball",  "ball": 4, "reward_id": "..."},
  {"id": "runs20", "rank": 3, "rule": "runs_at_least", "runs": 20, "reward_id": "..."},
  {"id": "balls7", "rank": 4, "rule": "balls_at_least","balls": 7, "reward_id": "..."},
  {"id": "trips",  "rank": 5, "rule": "three_of_kind", "reward_id": "..."}
],
"points": {"per_innings": 5, "per_run": 1, "max_innings_per_phone_per_day": 5},
"fulfilment": "vending"
```

Rules are a closed set the server understands (`six_on_ball`, `four_on_ball`,
`runs_at_least`, `balls_at_least`, `three_of_kind`, `sixes_at_least`, `runs_exact`).
Advertisers choose rules and rewards; they cannot upload code. Ranks are unique, 1 to 5.

**Where the names and products come from (ops-editable).** The five rungs, their display
names ("Queen Bee" …), their rules and the product behind each rung are a **house ladder**
kept in `house_settings` and edited on the ops Rewards settings page (§8.1). A cricket
creative's `ladder` is either the string `"house"` (default: follow the house ladder as it
is today) or an inline array for a sponsor that brings its own prizes. `reward_id` always
points at a `reward_products` row, so renaming a product or swapping a chips brand in ops
changes every screen, claim page and WhatsApp message at once with no deploy. Rung
budgets (`daily_budget`, `stock_total`) are per creative, because they are the sponsor's
money, not the house's.

**Snapshot at innings start.** When an innings begins, the server copies the resolved
ladder (names, rules, products, availability) into `cricket_sessions.ladder_snapshot`.
Every ball and the final mint use that snapshot. An ops edit therefore takes effect from
the next innings, never halfway through one, and a minted token always describes the
ladder the player actually saw.

**Milestone detection** moves to the server (`services/cricket.milestones(balls)`), a
straight port of the prototype's `milestones()`. It runs after every ball, so the kiosk and
cabinet views can carry `stakes[]` (what is on the line this ball) and `hit[]` (rungs
already hit) and render the prototype's drama without computing anything themselves.

**One reward per player: the highest rung reached.** `mint_reward` at the end of the
innings walks the ladder by rank and takes the first rung that is hit **and** has budget
and stock left (`wins_today`, `stock_exhausted`, as the run bands do now). If the top rung
hit is sold out, the player gets the next rung down they also hit, else points. The
stakes shown before a roll must only name rungs that currently have stock, so the screen
never promises a reward the server cannot mint (the kiosk/cabinet view carries
`available` per rung, refreshed every ball).

### 3.3 Points (new)

Every innings that mints no reward mints a **points token** worth
`per_innings + per_run × runs`. Both numbers are house settings edited in ops
(`points.per_innings`, `points.per_run`; the seeded defaults are 5 and 1, so an average
innings is ~15 points). A creative may override them only downwards, so a sponsor cannot
inflate the house liability.

Whether **winners also earn points** is the house flag `points.winners_earn` (seeded
`false`: the reward is the prize and the budget stays simple). Flipping it on in ops means
the next innings onwards mints a reward token that also carries the innings' points; the
claim adds both. Nothing already minted changes.

Points live in the wallet, keyed to the phone hash like coupons. The redeem threshold
(`points.redeem_threshold`, seeded 100) converts to a coupon for a product from the points
catalogue (products whose `points_cost` is set in the catalogue). Points expire
`points.expiry_days` after they are earned (seeded 90, FIFO), with a WhatsApp reminder at
`points.reminder_days` (seeded 7), reusing the coupon reminder job. Lowering the threshold
in ops immediately makes more wallets eligible; raising it never takes a coupon back.

### 3.4 Prototype features that are purely visual

Stakes chips, the charged ROLL button, fire and sparkle canvases, milestone banners,
reward card flips, confetti and fireworks by rank, bee-umpire faces: all stay in the
engine, driven by the server's `stakes[]` / `hit[]` / `reward` fields. The session
scorebook and editable odds table from the prototype move to the **portal preview** and
the ops screen page; they are not shown on a cabinet.

---

## 4. The end-to-end flow

### 4.1 Play → token → wallet → vending

```mermaid
sequenceDiagram
  autonumber
  participant S as Shopper
  participant C as Cabinet / Kiosk
  participant API as Freebee API
  participant Ph as Shopper's phone
  participant VM as Vending agent (machine)

  S->>C: press SPIN
  C->>API: POST /v1/device/cricket/{creative}/innings (slot) or kiosk claim (kiosk)
  loop each ball
    S->>C: press SPIN
    C->>API: POST .../sessions/{id}/ball {client_ball_id}
    API-->>C: ball, runs, limit, stakes[], hit[]
  end
  API->>API: innings over → milestones → highest rung with stock → WinToken(kind=reward) or WinToken(kind=points)
  API-->>C: result {claim_url, short_code, reward|points}
  C-->>S: result card: QR + short code (25 s)
  S->>Ph: scan QR (or type short code at freebeeads.com/w)
  Ph->>API: GET /v1/wallet/claim/{token} (preview, public)
  Ph->>API: POST /v1/wallet/otp/start, /otp/verify (consent)
  Ph->>API: POST /v1/wallet/claim {token}
  API-->>Ph: coupon {product, fulfilment: vending, vend_pin, pin_expires_at} or {points_added, balance}
  S->>VM: walk to the machine, type the 6-digit PIN
  VM->>API: POST /v1/vend/redeem {pin}
  API-->>VM: {request_id, slot, product} (coupon locked for 90 s)
  VM->>VM: vend the slot, drop sensor confirms
  VM->>API: POST /v1/vend/requests/{id}/confirm {result: vended}
  API->>API: coupon → redeemed (or back to issued on failed)
  VM-->>S: "Enjoy your Honeycomb"
```

### 4.2 Points path

```mermaid
flowchart TD
  A[Innings ends, no rung hit] --> B[WinToken kind=points, value = 5 + runs]
  B --> C[QR + short code on screen]
  C --> D[Phone: OTP once, then claim]
  D --> E[point_entries += value]
  E --> F{balance ≥ threshold?}
  F -- no --> G[Wallet shows balance and bar to next reward]
  F -- yes --> H[Tap Redeem → pick product → coupon source=points]
  H --> I[point_entries −= cost]
  I --> J[vend PIN → machine → drop]
```

### 4.3 Shopper surface: web wallet and the app

Decision (2026-11-01): **both**. The wallet web page stays the zero-install path; the
Freebee app gets a **shopper role** inside the existing Flutter app (`CLAUDE.md`: one app,
two roles becomes one app, three roles; still never a second app). Claiming in the app is
rewarded so people install it.

- The QR and the short code encode one URL, `https://freebeeads.com/w/c/{short_code}`. It
  is an iOS universal link / Android app link: with the app installed it opens the app's
  claim screen, otherwise the web page. The web page shows an **"Install the Freebee app
  and get +N points on this claim"** banner (text and N from settings), with store links
  that carry the short code, so the first in-app screen after install is the claim.
- `POST /v1/wallet/claim` records `claimed_via` (`web` \| `app`, from the client, bound
  to the JWT's `aud`). The **app bonus** is a house setting (§8.1): `app_bonus.mode`
  (`off` \| `points` \| `tier_up`), `app_bonus.points`, `app_bonus.per_phone_per_day`.
  `points` adds a `point_entries` row with reason `app_bonus`; `tier_up` lets a points
  token become the lowest rung's product when stock allows. The bonus is applied at claim
  time only, never retroactively, so a web claim that later installs the app gets a
  one-time `app_bonus.install_points` on first app login instead.
- The app wallet and the web wallet are one wallet: same phone hash, same JWT role,
  same endpoints. The app adds push notifications (expiring points, new offers) where the
  web path has only WhatsApp, which is the Phase 4 offers hook.
- Phase 2 ships the web banner and the bonus logic; the app shopper role is Phase 2b and
  can slip without blocking vending.

### 4.4 State machines

**Innings (slot mode)**: `playing` → `done` (out, limit reached, idle 60 s, or cabinet
reports abandon) → token minted exactly once. `playing` sessions older than 60 s with no
ball are closed by `housekeeping` as they stand, so an abandoned innings never blocks the
cabinet.

**Token**: `issued` → `claimed` (once, by one wallet) | `expired` (48 h). Unchanged.

**Coupon** (extended):

```
issued ──(vend PIN requested)──► issued + live PIN
issued ──(POST /v1/vend/redeem)─► vending (locked, 90 s)
vending ──confirm vended────────► redeemed
vending ──confirm failed────────► issued   (PIN invalidated, a new one can be requested)
vending ──no confirm in 90 s────► disputed (ops queue; agent reconciles on reconnect)
issued ──counter /v1/redeem─────► redeemed
issued ──expires_at passed──────► expired
```

**Vend request** (one row per redeem attempt, never deleted): `requested` → `vending` →
`vended` | `failed` | `disputed` → (ops) `resolved_vended` | `resolved_refunded`.

**Points entry**: append-only. Balance = sum of deltas, cached on the wallet user and
recomputed nightly. Reasons: `innings`, `redeem`, `expire`, `adjust` (ops, with note).

---

## 5. Vending machine integration

### 5.1 How the machine is driven

Recommended: our **vend agent** acts as an **MDB cashless peripheral**, not as a
slot-by-slot wiring job.

- Hardware per machine: a Raspberry Pi (or the same Android box we use for screens) + an
  MDB-to-USB cashless adapter (Qibixx MDB-USB class) + a small numeric keypad or 7"
  touchscreen for the PIN + optional drop sensor pass-through. Any MDB vending machine
  works, which covers most snack and drink machines sold in India.
- The agent is a small Python service (same codebase style as `simulator/`), paired to the
  backend with a pairing code exactly like a screen device (rule 4: machine token, never a
  user credential, revocable).
- Flow at the machine: shopper types PIN → agent calls `/v1/vend/redeem` → server answers
  with the product and its price in machine credit → agent grants that credit over MDB →
  the machine's own selection buttons light up for the matching slot(s) → the shopper picks
  → the machine reports `VEND_SUCCESS` or `VEND_FAILURE` over MDB → agent confirms to the
  server. No slot wiring, and the machine's own drop sensor and refund logic are reused.
- Single-product coupons: the server tells the agent the allowed slot codes; the agent
  grants credit equal to that product's price only, so a shopper cannot pick something
  dearer. A machine without per-slot pricing granularity gets a "product group" credit.

Alternative with no keypad (same backend): the shopper taps **Vend now** on their phone
and picks the machine from a list (machine ID on a sticker); the server pushes the grant to
that agent. Cheaper hardware, and a good fallback if a keypad is broken. The PIN keypad is
what you asked for and stays the primary design; the phone path is a Phase 3 option.

If a mall vendor already runs machines with their own cloud API (Daalchini, Vendiman
class), a second agent implementation talks to that API instead of MDB. The server
contract is the same.

**The driver is chosen per machine in ops, not in code.** `vending_machines.driver` is one
of `mdb` (our agent on a Pi/Android box with an MDB adapter), `vendor_api` (the vendor's
cloud; `vendor` + encrypted credentials in `driver_config_enc`, Fernet like LLM keys and
ARNs) or `manual` (no machine logic: the PIN is shown to a person at a counter, which is
how a venue runs before its machine arrives). The agent reads its driver on every
heartbeat, so switching a machine from `manual` to `mdb` once the adapter is fitted, or
moving a venue to a vendor's machines, is an ops edit with no deploy. Each driver
implements the same four calls (`grant`, `cancel`, `status`, `slots`); the server never
knows which one is behind a request beyond logging it on `vend_requests.driver`.

### 5.2 Vend PIN

- 6 digits, generated when the shopper taps **Redeem at the machine** (or automatically on
  claim when `fulfilment=vending`), shown on screen and sent on WhatsApp.
- Valid 15 minutes, one live PIN per coupon, bound to the coupon's venue. Stored hashed
  with HMAC like OTPs. Requesting a new PIN invalidates the old one.
- Entered on any Freebee machine in that venue. The machine checks nothing itself; it is
  the server that validates, locks and answers.
- Guessing: 1 million codes, 15 minute life, and the agent is limited to 5 wrong PINs per
  machine per 2 minutes before a 60 s lock (the same shape as the kiosk code lock). The
  expected number of live PINs in a venue at any moment is tiny, so brute force is
  hopeless.
- This is deliberately **not** the login OTP. The OTP proves the phone and is consumed on
  verify; the PIN authorises one physical vend. Reusing the OTP would let the SMS content
  alone take a product. To the shopper it still feels like "one code": the wallet shows
  the PIN right after the OTP step.

### 5.3 Stock and products

- `reward_products`: Freebee's catalogue (drink, chocolate, chips, …) with cost, image,
  tier hint. Sponsors pick a product per rung, or supply their own counter prize (then
  `fulfilment=counter`).
- `vending_slots`: per machine, slot code → product, capacity, stock, price. Stock is set
  by the refill person in the ops app and decremented by confirmed vends. The agent
  reports MDB sold-out signals, which set `stock=0` immediately.
- At redeem time the server picks a slot with stock for the coupon's product on that
  machine. None: if the rung has `substitutes[]`, offer one; else answer "this machine is
  out of your prize, try the machine at X or the counter" and leave the coupon issued.

---

## 6. Data model changes (migration `rewards_v2`)

New tables

| Table | Purpose | Notes |
|---|---|---|
| `reward_products` | catalogue | id, name, kind, cost_paise, image asset, points_cost (nullable: not redeemable with points), active |
| `vending_machines` | one row per machine | id, venue_id, label, token_hash, status, last_seen_at, agent_version, mdb_mode, notes |
| `vending_slots` | slot map + stock | machine_id, slot_code, product_id, capacity, stock, price_paise, updated_by |
| `vend_pins` | live PINs | coupon_id, pin_hash, venue_id, expires_at, used_at, created_at |
| `vend_requests` | every redeem attempt | id, coupon_id, machine_id, slot_code, status, requested_at, confirmed_at, agent_result, error, resolved_by_user_id, note. Status changes only forward; never deleted. |
| `point_entries` | **append-only ledger** (same enforcement as `plays`) | wallet_user_id, delta, reason (`earn` \| `app_bonus` \| `install_bonus` \| `redeem` \| `expire` \| `adjust`), ref_token_id / ref_coupon_id, created_at, expires_at |
| `house_settings` | ops-editable tunables (§8.1) | key, value JSON, version, updated_by_user_id, updated_at. One row per key; a write bumps `version` and copies the old row to `house_settings_history` |
| `house_settings_history` | audit of every change | key, old_value, new_value, version, changed_by_user_id, changed_at, note. Append-only, same guards as `plays` |

Extended tables

- `win_tokens` + `kind` (`reward` \| `points`), `points` (int), `rung_id`, `product_id`,
  `short_code` (6 chars, unique, indexed), `fulfilment`.
- `coupons` + `product_id`, `fulfilment` (`counter` \| `vending` \| `any`), `source`
  (`win` \| `points`), `vending_status` (`none` \| `vending` \| `disputed`),
  `venue_id`.
- `cricket_sessions` + `mode` (`ticket` \| `walkup`), `milestones` JSON,
  `rung_id`, `cabinet_device_id`, `ladder_snapshot` JSON (resolved ladder + points rates at
  innings start, §3.2).
- `vending_machines` carries `driver` (`mdb` \| `vendor_api` \| `manual`), `vendor`,
  `driver_config_enc` (Fernet); `vend_requests` carries `driver` for the record.
- `coupons` + `claimed_via` (`web` \| `app`); `point_entries.reason` includes
  `app_bonus` / `install_bonus`.
- `wallet_users` + `points_balance` (cache), `points_updated_at`.
- `creatives.config` (cricket) + `ladder`, `points`, `fulfilment`, `result_card_s`.

Privacy: nothing new is personal data. Points, PINs and vend requests hang off
`wallet_user_id` and are anonymised by the existing erasure (balance zeroed with an
`adjust` entry, PINs deleted, requests keep the coupon id only). Rule 3a holds. Machines
never see a phone number or a name; they see a PIN and a slot.

---

## 7. API additions (contract v2.0)

Cabinet (device token, slot mode)

```
POST /v1/device/cricket/{creative_id}/innings            → {session_id, key, limit, stakes}
POST /v1/device/cricket/sessions/{id}/ball {key, client_ball_id} → ball, runs, limit, stakes[], hit[], done?, result?
POST /v1/device/cricket/sessions/{id}/abandon {key}      → closes as it stands (idempotent)
GET  /v1/device/cricket/sessions/{id}?key=               → recovery after a reload mid-innings
```

`client_ball_id` makes a retried press idempotent: a flapping Wi-Fi never rolls twice.
`result` carries `{kind, reward|points, claim_url, short_code, qr_png_url}`.

Kiosk/phone views (existing endpoints) gain `stakes[]`, `hit[]`, `ladder[]` with
`available`, and the same `result` shape.

Wallet (public / wallet token)

```
GET  /v1/wallet/claim/code/{short_code}        → same as /claim/{token} (rate limited per IP)
POST /v1/wallet/claim {token}                  → coupon (as today) | {points_added, balance, next_reward_at}
GET  /v1/wallet/me                             → + points {balance, expiring_soon, threshold}, coupons[].product/fulfilment/vend
POST /v1/wallet/coupons/{id}/vend-pin          → {pin, expires_at, machines:[{id,label,where}]}
GET  /v1/wallet/catalogue?venue=               → products redeemable with points, with points_cost
POST /v1/wallet/points/redeem {product_id, venue_id} → coupon (source=points) + vend PIN
```

Vending agent (machine token; pairing like devices)

```
POST /v1/vend/pair {code}                                → {machine_token, machine}
POST /v1/vend/heartbeat {agent_version, mdb_ok, slots:[{code, sold_out}]}
POST /v1/vend/redeem {pin}                               → {request_id, product, slots[], credit_paise, expires_in_s} | 404 unknown | 409 locked/used | 410 expired | 422 not stocked here
POST /v1/vend/requests/{id}/confirm {result: vended|failed|cancelled, slot?, error?}  (idempotent)
GET  /v1/vend/requests/open                              → requests this machine must still confirm (boot recovery)
```

Ops (role=ops)

```
GET/POST /v1/ops/vending/machines, /{id}, /{id}/pairing-code, /{id}/revoke
PUT  /v1/ops/vending/machines/{id}/slots                 → stock + product map (refill)
GET  /v1/ops/vending/requests?status=disputed&days=7
POST /v1/ops/vending/requests/{id}/resolve {outcome, note}
GET/POST /v1/ops/rewards/products
GET  /v1/ops/wallet/fulfilment                           → + vending {vended, failed, disputed}, points {issued, redeemed, outstanding_liability}
GET  /v1/ops/settings/rewards                            → every key in §8.1 with value, default, version, updated_by, updated_at
PUT  /v1/ops/settings/rewards {key: value, …, note}      → validated as a set (ladder ranks unique, odds sum 100, threshold > 0 …), one history row per changed key, 409 on stale version
GET  /v1/ops/settings/rewards/history?key=&days=         → audit trail
POST /v1/ops/settings/rewards/simulate {ladder?, points?, odds?} → 20k-innings expected figures (rung hit rates, points per innings, liability/day at N players) before saving
```

Settings reads are cached in-process for 30 s (the kiosk and cabinet read them once per
innings via the snapshot anyway), so the page can be edited freely during mall hours.

Metrics: `engagement.cricket` gains `rungs{six6,…}`, `points_issued`, `vended`,
`vend_fail_rate`; `coupons.csv` gains product and channel columns.

---

## 8. Ops dashboard changes

### 8.1 Rewards settings page (new, ops editor+)

Everything Praveen asked to tweak without a deploy lives here, backed by `house_settings`.
Each field shows its current value, its default, who last changed it and when; Save
validates the whole set, writes history rows and shows the simulator's expected figures
next to the ladder so a change can be judged before it goes live.

| Key | Seeded default | What it changes |
|---|---|---|
| `ladder.rungs[]` | the five prototype rungs | rung id, rank, rule + parameter, display name, `reward_id` (picker from the catalogue), `max_value_paise` |
| `ladder.odds` | 13.33 / 13.33 / 13.34 / 20 / 20 / 20 | house dice odds for creatives that set none (must sum to 100, out > 0) |
| `points.per_innings`, `points.per_run` | 5, 1 | points minted on a no-reward innings |
| `points.winners_earn` | false | winners also get the innings' points |
| `points.redeem_threshold` | 100 | balance needed to convert points to a coupon |
| `points.expiry_days`, `points.reminder_days` | 90, 7 | FIFO expiry and WhatsApp reminder |
| `points.max_innings_per_phone_per_day` | 5 | innings that earn points per phone per UTC day |
| `fulfilment.default` | `vending` | `counter` \| `vending` \| `any` for new creatives |
| `vend_pin.ttl_minutes`, `vend_pin.max_wrong_per_machine` | 15, 5 | PIN life and the keypad lock |
| `app_bonus.mode`, `app_bonus.points`, `app_bonus.install_points`, `app_bonus.per_phone_per_day`, `app_bonus.banner_text` | `points`, 20, 50, 1, "Install the Freebee app…" | §4.3 |
| `cabinet.result_card_s`, `cabinet.idle_close_s` | 25, 60 | how long the QR stays, when a walk-away innings closes |

Per-machine settings (driver, vendor, credentials, slot map, stock) are edited on the
Vending page, not here; per-creative settings (rung budgets, a sponsor's own ladder) stay
in the portal creative editor. Three rules keep live edits safe: an innings uses the
snapshot it started with (§3.2); minted tokens and issued coupons are never rewritten; a
change that would make a live campaign unpayable (a rung's product removed from the
catalogue while a creative references it) is refused with the creative named.

### 8.2 Other pages

- **Devices** page: a cabinet shows `mode: slot` and the last innings time; a new alert
  `button_silent` when a cabinet that usually sees presses has had none for 2 hours in
  mall hours (button broken or cabinet off the floor).
- **Vending** page (new): machines with online/offline, last vend, stock per slot with a
  red band under 10 %, jams and disputes with one-tap resolve, pairing-code button, and a
  refill mode for the person who stocks it (phone-sized).
- **Rewards** page (new): product catalogue, per-campaign rung budgets and stock, points
  liability (outstanding points × product cost) so Praveen can see what the house owes.
- **Today** panel: vends today, failed vends, points issued, claim rate by mode (kiosk vs
  slot).
- Notifications: `vend_disputed`, `machine_offline` (15 min), `slot_empty` for a slot that
  a live campaign depends on.

---

## 9. Edge cases and how each is handled

Play

1. **Button mashed or held**: shell debounce 350 ms, key-repeat ignored; the engine
   refuses presses while `busy`. One press, one ball.
2. **Press during the tumble**: dropped, with the dice shake shortened to 1.2 s when
   presses are queued so it never feels stuck.
3. **Network flaps mid-innings**: the cabinet retries the same `client_ball_id`; the
   server returns the stored ball. If the API is unreachable for 10 s the screen says
   "hold on, the pitch is being rolled" and keeps retrying; after 45 s the innings is
   shown as paused and the server will close it idle at 60 s. Nothing is minted twice.
4. **Walk-away mid-innings**: closed idle after 60 s, token minted as it stands, result
   card shown for 25 s (the next person sees it and may take the QR; acceptable, it is one
   innings of value).
5. **Power loss**: server-side idle close; the player loses only if they left before the
   result card. The cabinet's own crash reporting already tells ops.
6. **Cabinet reload mid-innings**: `GET /sessions/{id}` with the key stored in
   localStorage restores the over tracker.
7. **Game window not live** (hourly windows): the cabinet shows the ad loop and "next game
   at HH:MM"; the button does nothing except show that card.
8. **Odds that do not sum to 100** or a ladder with a duplicate rank: 422 at save, the
   portal shows the prototype's inline message. Old creatives keep run bands.
9. **Rung budget or stock runs out mid-day**: `available=false` in the next ball's
   response; stakes stop naming it; the server mints the next rung down. The screen never
   shows a sealed card it cannot pay.
10. **Tampered cabinet HTML**: harmless; all rolls and all minting are server side, and
    the device token only lets it roll on its own paired screen.
11. **Two people press at once on one cabinet**: one innings, one button, one player. The
    kiosk mode keeps its one-innings-per-wall lock.
12. **Kid under 18 / no phone**: the parent's phone claims; nothing stops a child playing.
    The claim page consent text already states 18+ for the wallet.

Claim

13. **QR not scanned**: token expires 48 h; counted as `unclaimed` in the report.
14. **Someone photographs another player's QR**: first claim wins; the second sees
    "already claimed by another wallet". Same as today.
15. **Short code typed wrong**: 6 chars from the no-0/O/1/I alphabet; 5 wrong per IP per
    minute then 429. 1 billion codes, 48 h life.
16. **OTP not delivered**: resend after 30 s (5 per hour per phone cap exists), WhatsApp
    first then SMS fallback when `SMS_PROVIDER` is set. The claim page says which channel
    it used.
17. **Same phone plays 20 times a day**: `points.max_innings_per_phone_per_day` (5) caps
    points claims; the existing per-phone prize limits (2 per campaign, 5 per day) cap
    rewards. The screen cannot know the phone, so the cap is applied at claim with the
    existing "come back tomorrow" message. Over the cap the token still claims as 0 points
    so it is marked used.
18. **Erased wallet**: balance zeroed with an `adjust` entry, PINs deleted, requests keep
    only the coupon id. Mandatory erasure stays one call.

Vending

19. **Machine offline**: the keypad says "machine offline, show your code at the counter"
    (the coupon still has its counter code). PIN stays valid; the agent queues nothing
    (a redeem must be online, because a lock is needed).
20. **PIN typed on a machine that does not stock the product**: 422 with the nearest
    machine's label; coupon untouched.
21. **Product sold out at that machine**: substitute if configured, else 422 as above.
    MDB sold-out signals update stock live.
22. **Vend fails (jam, no drop)**: agent confirms `failed`; coupon returns to `issued`,
    old PIN invalidated, wallet shows "that one did not drop, request a new PIN". A
    `vend_requests` row records the failure for the refill person.
23. **Agent crashes between grant and confirm**: request is `disputed` after 90 s; on
    reboot the agent fetches `/requests/open` and reports what MDB logged. Ops resolve the
    rest from the machine's own vend counter at refill time.
24. **Double redeem** (two machines, same PIN, same second): the lock is a single UPDATE
    with `WHERE status='issued'`; the loser gets 409.
25. **Replayed confirm**: idempotent on request id; a confirm for a request not in
    `vending` is ignored and logged.
26. **PIN expired**: 410; the wallet offers a new PIN in one tap.
27. **Coupon expired before the shopper reached the machine**: 410; the reminder job
    already nudges 24 h before expiry.
28. **Machine moved to another mall**: ops edit `venue_id`; PINs are venue-bound, so a
    stale sticker in the old mall fails cleanly.
29. **Revoked machine token**: 401 on every call; the agent shows "needs re-pairing" on
    its keypad screen.
30. **Points threshold reached but no points product stocked in this venue**: catalogue
    returns empty for the venue; the wallet says "redeem at X mall" and keeps the balance.
31. **Points expiry**: FIFO expiry job nightly writes `expire` entries; reminder at 7 days.
32. **Ledger integrity**: `point_entries` and `vend_requests` get the same before-update /
    before-delete guards as `plays`; corrections are compensating rows with a note and the
    ops user id.
33. **Prize law**: free to play, no purchase, no consideration, so this is a promotion,
    not gaming. Keep a visible "no purchase necessary, one reward per innings" line on the
    cabinet and the claim page, and cap the top rung's value in config (`max_value_paise`)
    so a sponsor cannot accidentally run a lottery-sized prize.

Live settings and the app

34. **Ladder edited while an innings is running**: the innings keeps its
    `ladder_snapshot`; the stakes chips, the result card and the minted token all describe
    what the player saw. The next innings picks up the edit.
35. **Two ops users save settings at once**: `PUT` carries the version it was loaded with;
    the second save gets 409 with the fresh values and re-applies its change by hand.
36. **A product is deleted or deactivated while a rung uses it**: refused, with the
    creatives named. Deactivating a product that only old coupons reference is allowed;
    those coupons still vend because the coupon stores `product_id`, not the name.
37. **Threshold lowered below balances already held**: eligible wallets see "redeem now"
    on next open; no automatic coupon is minted, the shopper still chooses the product.
    Threshold raised: coupons already issued stand.
38. **Points rates changed mid-day**: every `point_entries` row records the delta it was
    minted with, so the liability figure stays exact; the day's metrics show two rates.
39. **Driver switched while a vend request is open**: `vend_requests.driver` pins the
    driver that took the request; the switch applies to new requests only, and open ones
    must be confirmed or resolved first (the Vending page blocks the switch until then).
40. **App bonus farming**: the bonus applies per claim, capped by
    `app_bonus.per_phone_per_day`, and a token is single-use, so claiming on web and then
    in the app is impossible. `install_points` is once per phone hash ever. The app's
    `claimed_via=app` is trusted only when the JWT was issued to the app audience, so a
    browser cannot spoof it.
41. **App not installed, universal link falls back to web**: the web claim page is the
    same page as today plus the install banner; nothing is lost. If the app is installed
    but the OS opens the browser anyway (common after an update), the page shows "Open in
    app" with a custom-scheme link.
42. **Settings table empty on a fresh deploy**: every read falls back to the seeded
    default in code, so a missing row never breaks a mint; the first save creates it.

---

## 10. Build plan

| Phase | Scope | Size | Gate |
|---|---|---|---|
| **1. Ladder + slot mode** | server milestones, `ladder` config + portal editor with the odds table and expected figures, every innings mints a token (reward or points-as-number only), short code, cabinet walk-up endpoints, engine button mode + stakes/celebration port, shell debounce, Devices page `mode`, metrics rungs | ~1.5 weeks | e2e script `e2e_cricket.py` extended; Playwright for cabinet + claim; a Mac with a USB button on the bench |
| **2. Points wallet** | `point_entries` ledger, claim path, `/w` balance + bar, catalogue, points → coupon, expiry job, ops liability, erasure | ~1 week | ledger immutability tests; 729 + new backend tests green |
| **3. Vending** | machine pairing, agent (MDB cashless), PIN issue/redeem/confirm, disputes, ops Vending page, refill mode, counter fallback | ~2–3 weeks + hardware lead time (adapter + one machine) | bench vend with a real machine; dispute drill (pull the plug mid-vend) |
| **4. App offers** | offers table, wallet "offers" tab fed by what the shopper's wallet has seen; push via WhatsApp | later | needs the ad-to-offer mapping decided |

Counter fulfilment keeps working throughout, so Phases 1 and 2 are shippable in a mall
before any machine exists.

### 10.1 Decisions (2026-11-01)

| Question | Decision | Where it lives |
|---|---|---|
| Reward names and products per rung | Ops-editable, no deploy. Prototype names seeded as placeholders. | Rewards settings page `ladder.rungs[]` + catalogue (§8.1), Phase 1 |
| Points rate and redeem threshold | Ops-editable; seeded 5 + 1/run, 100 | `points.*` (§8.1), Phase 1 (rates) and Phase 2 (threshold, expiry) |
| Winners also earn points | Ops flag, seeded off | `points.winners_earn`, Phase 1 |
| Vending machine model | Per machine in ops: `mdb`, `vendor_api` or `manual` | Vending page driver select (§5.1), Phase 3; `manual` from Phase 2 so a venue can run without a machine |
| Shopper surface | Web wallet **and** a shopper role in the one Flutter app, with an app bonus to drive installs | §4.3; web banner + bonus in Phase 2, app role Phase 2b |

Phase 1 therefore includes the settings table, the ops Rewards settings page with the
ladder and points fields, and the simulator endpoint, because the ladder editor is the
only way the names get set. The remaining keys land with the phase that uses them.

---

## 11. What shipped vs. this design (2026-11-01)

Everything in §10 phases 1, 2, 2b and 3 is built and committed (design `8ac418c`, mobile +
vend-agent `c424a4c`, backend `edc89ed`, web `144c09b`, then the QA and review rounds up to
`91470dc`); Phase 4 (app offers) is not started.
The exact shapes are in `docs/API_CONTRACT.md` §v2.0; the differences from this document, in the
order the sections above introduce them:

- **Prize limits pay points, not 409** (§9 #17). A ladder reward token claimed by a wallet that
  already banked a coupon from the same campaign today, or is over the per-phone prize limits, is
  claimed as the innings' points (`kind: points, limited: true`). The 409 stays for legacy
  run-band tokens.
- **Used PIN answers 409**, expired PIN or coupon 410, product not stocked 422 with `try_machine`,
  keypad lock 429 with `retry_after_s`. Only an unknown PIN (404) counts as a wrong PIN.
- **A failed or cancelled vend keeps the PIN valid** until it expires (§4.4 and #22 said the PIN is
  invalidated). The coupon goes back to `issued`; the shopper can retry at once without a new PIN.
  A `disputed` coupon stays locked until ops resolve it.
- **The request status while locked is `requested`**, not `vending`; the coupon carries
  `vending_status: vending`. `vend_requests` gains `resolved_at` and `resolution`.
- **A second cabinet innings closes the first** as it stands (its token stays valid). There is no
  "someone is batting" refusal for cabinets.
- **No WhatsApp for the vend PIN** (§5.2): the wallet shows it (web and app); the plaintext is
  stored Fernet-encrypted next to the HMAC so `/me` can re-show a live PIN with the machines list.
- **No `substitutes[]` per rung** (§5.3): a machine without the product answers 422 + the nearest
  machine that has it, coupon untouched.
- **The app audience is a shared secret, not a body field** (§4.3 #40): `aud: app` is issued only
  when OTP verify carries `X-Freebee-App-Key` = `WALLET_APP_KEY`; the house `app_bonus.mode` is
  seeded **off** until the app is in the stores and the key is set. `tier_up` pays the lowest
  rung's product **instead of** points (0 points, 0 bonus), and is refused when the wallet is
  over its limits.
- **Counter and machine cannot both pay** (§4.4): `POST /v1/redeem` is 409 while a coupon is
  `vending` or `disputed`; a machine that vends a coupon the counter already redeemed is recorded
  and reported, never paid twice.
- **Disputes are lazy as well as scheduled**: the 90 s lock is disputed the moment the coupon is
  touched again (redeem or a new PIN), `expires_in_s` counts down, and the second failed vend on
  one coupon becomes a dispute instead of a third try. The housekeeping job also idle-closes
  innings on every screen.
- **Cabinet throttles** (§9 #10): one ball per second (`too_fast`) and 120 innings per hour per
  cabinet device, so a leaked device token cannot print rewards. Seeded house rungs carry daily
  budgets 10 / 20 / 40 / 60 / 80, and `max_value_paise` is enforced against the product's cost.
- **Shoppers see `where_text`, never ops notes** (migration `rewards_v2_phase3b`). PIN hashes are
  salted per coupon. Points redeem runs under a wallet row lock, the token claim under a guarded
  update, and a nightly `points_negative` check tells ops if a ledger ever sums below zero.
- **Settings saves must carry `expected_versions`** for every changed key (422 otherwise); rows
  carry `help`; the simulator adds a per-day, budget-capped liability.
- **Short-code throttle counts misses only** and `WALLET_PROXY_KEY` lets the web proxy sign the
  shopper's IP, so one mall's scans never throttle each other.
- **Driver `manual` is the default** for a new machine and is a real driver in the agent
  (`vend-agent/README.md` "Manual mode": the box prints "hand over X" for the counter person).
  `mdb` follows the Qibixx MDB-USB line protocol as publicly described and still needs a bench
  test against the unit bought; `vendor_api` is a generic mapping.
- **Pairing code lives on the machine row** (one outstanding code, 10 min), not in a
  `pairing_requests` table. The heartbeat field is `driver_ok` (not `mdb_ok`); a sold-out report
  zeroes stock at once, clearing it only clears the flag.
- **Settings API shape**: `GET` returns a list of `{key, value, default, version, updated_by,
  updated_at}`; `PUT` takes `{changes, note, expected_versions}` and answers 409 with the fresh
  values when stale. Validation is 422 with one sentence.
- **Products are never deleted**; deactivating one a live ladder pays is 409 with the creatives
  named. There is no product image upload: `image_url` is a URL or a `/v1/library/{key}` path.
- **QR**: `GET /v1/qr/claim/{short_code}.png` (public, `segno`), carried as `result.qr_url`;
  no per-session QR endpoint. The web origin is the existing `WEB_URL`, not a new
  `PUBLIC_WEB_BASE`.
- **Views add** `stakes[].sure`, `mode_play` (`ticket` | `walkup`), `done` and `ball` on walk-up
  responses, `config.input` / `has_ladder` / `fulfilment` / `result_card_s` and a top-level
  `ladder` block on `/v1/play`, `/v1/kiosk` and the device poll.
- **Points cap**: over `points.max_innings_per_phone_per_day` the token claims as 0 points and is
  marked used (as #17 says). Day boundaries for the cap and for rung `daily_budget` are UTC, not
  IST.
- **`claim_rate_by_mode`** and `points.issued` live on `GET /v1/ops/wallet/fulfilment`, not on the
  network metrics; `engagement.cricket` adds `rungs`, `points_issued`, `vended`,
  `vend_fail_rate`, `walkup_innings`. `coupons.csv` adds `product,channel`.
- **Devices**: the card field is `cricket_mode` (`slot` | `kiosk`) + `last_innings_at`; the
  `button_silent` alert (§8.2) is not built.
- **App (Phase 2b)**: the shopper role is in `mobile/` (`docs/MOBILE_SPEC.md`). Universal links
  need the web's `/.well-known/apple-app-site-association` and `assetlinks.json`, which 404 until
  `APPLE_TEAM_ID` / `ANDROID_CERT_SHA256` are set on Vercel; store URLs in `web/lib/app-links.ts`
  come from `NEXT_PUBLIC_APP_STORE_URL` / `NEXT_PUBLIC_PLAY_STORE_URL` (banner hidden until set);
  the store build needs `--dart-define=WALLET_APP_KEY`; the Android app link is `/w/c/` only; the
  app does not read install referrers yet; `install_points` is applied server-side with no UI.
  Shopper strings are English only.
- **Vend agent extras**: driver outcomes are `vended | failed | timeout | unknown` — after the
  machine has accepted a selection (VEND APPROVED) the agent never reports `failed`; an `unknown`
  confirms nothing and leaves the 90 s dispute path and the machine's vend counter to decide. A
  local journal (`~/.freebee-vend/journal.json`, states `granted → approved → result`) lets boot
  recovery confirm `vended` when the machine reported success before a crash, `failed` with
  `error=agent_restart_before_result` when it never got past `granted`, and leaves `approved`
  entries to the dispute path. A slot that vends again after a failure is reported `sold_out:
  false` once. Open hardware questions (adapter line spellings, price
  units, item → slot map, LCD part, per-slot sold-out over MDB) are listed in
  `vend-agent/README.md` and the vend-agent build notes.
- **Phase 4** (offers next to the ads, push) is not started; the app has no push for the shopper
  role yet.

---

## 12. Giveaway ledger, spend and refillers (2026-11-01, Rewards V2.1)

Praveen's ask, verbatim intent: "which screen and which game in that screen is given how many of
each of these gifts; on the vending machine see how much money we are spending (20 cokes in 1 hr
at ₹10 = ₹200); option to add the price of what we put in the machine; see how many are
retrieved by users and update the dashboard instantly; status of the vending machine showing the
counts; if it is getting low, an alert/message to the ops person saying refill; add a person for
refilling and they should get the message about when to refill." Migration `rewards_v2_phase4`.
Shapes: `docs/API_CONTRACT.md` §v2.1. Ops runbook: `docs/VENDING_RUNBOOK.md` §13–§14.

### 12.1 Money truth: the cost snapshot

`reward_products.cost_paise` is THE unit cost the house pays ("the price of what we put in the
machine"); it is edited on the Products tab and inline from the Vending page's refill mode (one
price per product, not per slot — `vending_slots.price_paise` stays the machine's shelf price and
is never used for spend). On **every redemption write** — a machine vend (`_settle_vended`), a
counter `POST /v1/redeem`, and an ops dispute resolved `vended` — the server copies the product's
cost at that moment onto the coupon:

| Column | Type | Meaning |
|---|---|---|
| `coupons.unit_cost_paise` | INTEGER NULL | the cost snapshot; NULL = no catalogue product (legacy run band / sponsor counter prize): sponsor-paid, never house spend |
| `coupons.fulfilled_via` | VARCHAR(8) NULL | `vending` \| `counter` — HOW it was handed over (`claimed_via` stays the wallet audience) |

A later price edit never rewrites what was spent. Liability (tokens not yet claimed, coupons not
yet handed over, neither expired) is always costed at the product's **current** price — it has not
been spent yet.

### 12.2 Giveaway ledger — `GET /v1/ops/rewards/giveaways` (+ `.csv`)

One row set keyed by the reward token's **mint time** (IST days): every `win_tokens` row with
`kind = reward` joined to the coupon it became (`coupons.win_token_id`), the token's device → screen
→ zone → venue, its creative (game), and, for vended coupons, the `vend_requests` row that dropped it
(`status vended`, or `resolved` with `resolution vended`). Per row: `minted` (tokens created in
range), `claimed` (`claimed_at` set), `redeemed` (the coupon is redeemed, **whenever** it was
redeemed), `vended` / `counter` (by `fulfilled_via`, falling back to "a vend request dropped it" for
pre-snapshot rows), `expired` (unclaimed token past its life, or an expired coupon), `as_points`
(claimed, no coupon: the prize limit paid the reward as points — `wallet.claim_points(limited=True)`
— nothing owed, nothing to expire), `outstanding` (= minted − redeemed − expired − as_points),
`spent_paise` (Σ `unit_cost_paise`, product cost when NULL on an old row), `liability_paise`
(Σ current product cost over outstanding). `claimed − as_points` = coupons issued. Dimensions (`group_by`):
`screen {id, label, venue}`, `game {id, name, brand}`, `rung {id, name}` (the token's rung id and
title), `product {id, name, cost_paise}`, `machine {id, label} | null` (counter hand-overs have
none), `hour "HH:00"`, `day "YYYY-MM-DD"` (both IST, mint time). Dimensions not in `group_by` are
`null` on the row.

The `by_hour` series is the one thing keyed by **redeem time** — that is what "₹200 in the last
hour" needs: every redeemed coupon in the range (reward and points, machine and counter), one
zero-filled bucket per IST hour, present when the range is ≤ 7 days, `[]` otherwise. Under a
`device_id` filter it holds reward coupons only (a points coupon has no screen to filter by).

Totals add `innings` (`cricket_sessions` done in range), `points_minted` (positive `earn` /
`app_bonus` / `install_bonus` deltas; honours every filter through the token), `points_redeemed`
(−Σ `redeem` deltas) and `points_spent_paise` (Σ unit cost of every non-reward coupon redeemed in
range: points-catalogue coupons — `win_token_id` NULL, `product_id` set — **and `tier_up` coupons**,
which hang off a `points` token: the points *became* the product (§4.3), so it is points money and
never a reward row). The two points-redeem figures honour the venue filter only (a points coupon
has no screen or game). Invariant: for a day in which every coupon was minted and redeemed,
`Σ by_hour.spent_paise = spent_paise + points_spent_paise` — the strip adds up to its own chart.
The ledger counts **per coupon**; `/spend` counts **per drop**: the H3 "counter then machine"
double drop (a machine vends a coupon the counter already redeemed — the `vend_requests` row is
still `vended`, the snapshot stands) is one `counter` unit here and two units in `/spend`, which
is physically the truer figure.

Report cost: Q1 ships one row per token with the coupon, screen and venue; the creatives behind
those tokens (few) are loaded once by id for name + brand — never `Creative.config` per token.

### 12.3 Live spend — `GET /v1/ops/vending/spend?window=1h|today|7d`

Per machine: confirmed drops in the window (`vend_requests` vended / resolved vended, **by confirm
or resolve time, filtered in SQL on those columns** — no `requested_at` prefilter, so a dispute
resolved weeks after the request counts on the day it was resolved; index
`ix_vend_requests_machine_confirmed`) costed from the coupon snapshot, per product, `last_vend_at`,
plus a `last_hour` block; `totals` add the counter hand-overs in the window (`fulfilled_via =
counter`, index `ix_coupons_redeemed_at`) and `rate_per_hour_paise` (machine spend ÷ window hours;
`today` = hours elapsed since 00:00 IST, at least 1). The machine dict itself carries `spend
{today_paise, today_vended, last_hour_paise, last_hour_vended}` so the Vending page's 5 s poll needs
no second call — the list endpoint fills it for every machine from ONE grouped query
(`vend.machines_spend`); the Today panel's 60 s poll reads `/spend?window=today`. "Instantly" =
≤ 60 s on Today, ≤ 5 s on Vending.

### 12.4 Low stock

House settings `vending.low_stock_pct` (default 20, 0–90) and `vending.low_stock_min` (default 3,
0–50). A slot's line = its own `vending_slots.low_stock_at` when set (refill mode), else
`max(min, ceil(capacity × pct / 100))` capped at `capacity − 1` when the capacity is known (a line
at or above capacity could never be `ok`, so the slot would never re-arm: a 3-unit slot has line 2,
a 1-unit slot line 0 = only ever empty; capacity 0 = unknown keeps the house minimum; an explicit
override is honoured verbatim). `slot_state`: **empty** when stock is 0 or the agent flagged
sold-out; **low** when 0 < stock ≤ line; else **ok**. The three functions live in
`services/low_stock.py` (shared by `vend` and `refill`; a detached row is judged by the house
defaults). Every slot dict (ops machine list, refill mode, the heartbeat answer) carries `state` and
`low_at`; every machine dict carries `low_slots`, `empty_slots`, `stock_total`, `capacity_total`.

Notification kind **`slot_low`** (ops in-app + email, href `/ops/vending?machine=..&slot=..`): fires
on a **crossing** — the slot's state changed to low or empty, tracked on `low_notified_at` +
`low_notified_state` — from the vend decrement, the agent's sold-out report, the refill PUT when
ops type a number at or below the line (yes, on purpose — but to ops only, see §12.5), and the
5-minute `vend.housekeeping` sweep (`low_slots` in its result) which catches anything a code path
missed. Both trackers are set on the crossing and cleared the moment stock is back above the line,
so a slot that stays low all day is told once, a slot told "is low" that then runs to 0 is told
"is empty" once more (the escalation), and the next crossing after a refill notifies again; the
in-app row itself is deduped once per (slot, title) per 6 h. **One in-app row per event:** on a
crossing to empty, `_maybe_slot_low` first offers the event to `_maybe_slot_empty` — when a live
campaign pays the product the richer `slot_empty` row ("… and {campaigns} pay it") goes out and the
`slot_low "is empty"` row is skipped; otherwise `slot_low` says it. `after_stock_change` is the one
gate: a **revoked** machine raises nothing (nothing can vend from it, nothing to refill); refillers
are messaged only for a **paired** machine. There is no machine-level `machine_low` kind — the
refiller message rolls up.

| Column | Type | Meaning |
|---|---|---|
| `vending_slots.low_stock_at` | INTEGER NULL | per-slot line override; omitted from a slots PUT = unchanged |
| `vending_slots.low_notified_at` | DATETIME NULL | set on the crossing, cleared once above the line |
| `vending_slots.low_notified_state` | VARCHAR(5) NULL | `low` \| `empty` — the state the last row was about, so low → empty escalates once |

### 12.5 Refillers — the person who gets the message

**`refill_contacts`** is the second consented personal-data class (CLAUDE.md rule 3a): staff,
entered by ops, with the mandatory tick "This person agreed to get refill alerts on this number"
recorded as `consent_at` (422 without it; a *new* number needs the tick again). The phone is stored
exactly like a wallet user's — Fernet `phone_enc`, HMAC `phone_hash`, `phone_masked` — decrypted
only at send time, never logged, never returned (responses carry `phone_masked` only). Deleting a
contact is a hard delete; the alert log keeps `contact_name` as text and its `contact_id` goes NULL.

| `refill_contacts` | |
|---|---|
| `id, venue_id FK NOT NULL, name VARCHAR(120)` | |
| `phone_enc TEXT NULL, phone_hash VARCHAR(64) NULL, phone_masked VARCHAR(20) NULL, email VARCHAR(255) NULL` | at least one of phone / email; `whatsapp` needs the phone, `email` the email, `both` both |
| `channel VARCHAR(8)` | `whatsapp` \| `email` \| `both` |
| `machine_ids JSON NULL` | null / `[]` = every machine at the venue; ids must be machines of that venue |
| `active BOOL, consent_at DATETIME NOT NULL, created_by_user_id FK, created_at, updated_at` | |

| `refill_alerts` | |
|---|---|
| `id, venue_id, machine_id, contact_id NULL (SET NULL on delete), contact_name` | |
| `channel` | `whatsapp` \| `email` (a `both` contact gets one row per channel) |
| `kind` | `low` \| `empty` \| `digest` \| `manual` |
| `summary TEXT` | the message that was sent ("Slot A1 Cold drink 2 left, B3 Chips EMPTY") |
| `status` | `sent` \| `failed` \| `not_configured` |
| `provider_ref VARCHAR(64) NULL, error VARCHAR(200) NULL, sent_at` | |
| `sent_by_user_id FK users NULL` | the ops user behind a `manual` row (the 10/h per-user cap counts on it); NULL on automatic / digest rows |

**Sending** (`app/services/refill.py`): one message per contact per machine — "Freebee refill:
{machine} at {venue} ({where_text}) — {n} slots need stock: A1 Cold drink 2 left, B3 Chips EMPTY.
Reply done when refilled." — under 1024 chars, ≤ 8 slots then "+k more". WhatsApp through AiSensy
template `AISENSY_REFILL_CAMPAIGN` = `freebee_refill_alert` ({{1}} machine, {{2}} venue + where,
{{3}} slot list); with `WHATSAPP_PROVIDER` ≠ `aisensy` the row is `not_configured` (never raises).
Email through `services/email.send_email` when `EMAIL_PROVIDER` delivers, else `not_configured`.
Provider errors become `status: failed, error: <safe message>`. Template parameters are collapsed
to single spaces (Meta rejects newlines / tabs / runs of spaces in a body parameter).

**Who gets messaged when.** Refillers hear about a crossing from a vend, a sold-out report or the
sweep on a paired, active machine. The refill PUT (ops typing the number they just loaded, at or
below the line because that is all they had) raises the ops `slot_low` row only — the person typing
already knows. **Throttle**: one automatic alert per (contact, machine) per 6 h, however many slots
cross (the message lists every needy slot at that moment): any `low` / `empty` / `digest` row in the
window blocks a `low`; an `empty` passes a window that has no `empty` row yet — one escalation per
window, because "EMPTY" is the message the refiller actually needs. `digest` (scheduler job
`refill.digest`, daily 09:30 IST: every active machine with ≥ 1 low / empty slot → one message per
contact) skips a (contact, machine) pair messaged in the last 2 h (a crossing at 09:25 already said
everything the 09:30 digest would; the result's `skipped` counts them). `manual` (the ops "Send
refill list now" / "Send test" button, with a confirm dialog) is never throttled per message, but
one ops user gets at most 10 presses per hour (429) — every press spends provider credit; `contact_id`
must name an active contact that covers the machine (409 otherwise: "Send test" tests the real
route). `GET /v1/ops/vending/refill-config` tells the Refillers tab what the server can deliver
(`whatsapp_provider` is `aisensy` or `log`; the `cloud` provider cannot send a template and reads as
`log`).

**422 bodies never echo the input.** The phone and email lengths are checked inside
`refill._clean_phone` / `_clean_email` (a generic message), never by pydantic, and an app-wide
`RequestValidationError` handler scrubs every 422 (`scrub_value` over the errors: phones and codes
redacted, sensitive keys replaced) — the wallet's OTP routes included.

### 12.6 Not built / deviations from the brief

- Points-redeem totals honour the venue filter only (see §12.2). `tier_up` coupons are not reward
  rows; their spend is in `points_spent_paise`.
- `slot_low` also fires for a slot that goes straight from ok to empty (so a slot nobody's ladder
  pays still tells ops when it is out); on a live-campaign slot the one row is `slot_empty` (§12.4)
  — never both — and the refiller gets one message.
- The refill PUT warns ops only, not the refillers (§12.5); "Send test" refuses an inactive or
  non-covering contact instead of sending anyway; manual sends are capped at 10/h per ops user.
- The refiller message is text-only in email; the WhatsApp template must be approved in AiSensy
  with three body parameters before `WHATSAPP_PROVIDER=aisensy` delivers it.
