Freebee Ads docs

Engineering docs

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

Last updated Nov 1, 2026 · 13 sections

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

AreaToday (v1.8 / 2026-10-24)V2
GameDice 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 rollsKiosk tablet (/kiosk/[screen]) after a phone ticket codeKiosk or the cabinet itself, driven by one physical button (walk-up, no ticket)
Resultclaim_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.
RedeemPOST /v1/redeem by the advertiser or ops at a counterCounter stays. New: the vending agent redeems a PIN, vends, confirms.
PointsnoneAppend-only point_entries ledger per wallet user; threshold → coupon → vend PIN
OpsFulfilment counts, coupons, remindersVending 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 surfaceWallet web page /wWeb 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

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

"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

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

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

TablePurposeNotes
reward_productscatalogueid, name, kind, cost_paise, image asset, points_cost (nullable: not redeemable with points), active
vending_machinesone row per machineid, venue_id, label, token_hash, status, last_seen_at, agent_version, mdb_mode, notes
vending_slotsslot map + stockmachine_id, slot_code, product_id, capacity, stock, price_paise, updated_by
vend_pinslive PINscoupon_id, pin_hash, venue_id, expires_at, used_at, created_at
vend_requestsevery redeem attemptid, 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_entriesappend-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_settingsops-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_historyaudit of every changekey, 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.

KeySeeded defaultWhat it changes
ladder.rungs[]the five prototype rungsrung id, rank, rule + parameter, display name, reward_id (picker from the catalogue), max_value_paise
ladder.odds13.33 / 13.33 / 13.34 / 20 / 20 / 20house dice odds for creatives that set none (must sum to 100, out > 0)
points.per_innings, points.per_run5, 1points minted on a no-reward innings
points.winners_earnfalsewinners also get the innings' points
points.redeem_threshold100balance needed to convert points to a coupon
points.expiry_days, points.reminder_days90, 7FIFO expiry and WhatsApp reminder
points.max_innings_per_phone_per_day5innings that earn points per phone per UTC day
fulfilment.defaultvendingcounter | vending | any for new creatives
vend_pin.ttl_minutes, vend_pin.max_wrong_per_machine15, 5PIN life and the keypad lock
app_bonus.mode, app_bonus.points, app_bonus.install_points, app_bonus.per_phone_per_day, app_bonus.banner_textpoints, 20, 50, 1, "Install the Freebee app…"§4.3
cabinet.result_card_s, cabinet.idle_close_s25, 60how 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

  1. QR not scanned: token expires 48 h; counted as unclaimed in the report.
  2. Someone photographs another player's QR: first claim wins; the second sees "already claimed by another wallet". Same as today.
  3. 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.
  4. 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.
  5. 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.
  6. Erased wallet: balance zeroed with an adjust entry, PINs deleted, requests keep only the coupon id. Mandatory erasure stays one call.

Vending

  1. 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).
  2. PIN typed on a machine that does not stock the product: 422 with the nearest machine's label; coupon untouched.
  3. Product sold out at that machine: substitute if configured, else 422 as above. MDB sold-out signals update stock live.
  4. 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.
  5. 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.
  6. Double redeem (two machines, same PIN, same second): the lock is a single UPDATE with WHERE status='issued'; the loser gets 409.
  7. Replayed confirm: idempotent on request id; a confirm for a request not in vending is ignored and logged.
  8. PIN expired: 410; the wallet offers a new PIN in one tap.
  9. Coupon expired before the shopper reached the machine: 410; the reminder job already nudges 24 h before expiry.
  10. Machine moved to another mall: ops edit venue_id; PINs are venue-bound, so a stale sticker in the old mall fails cleanly.
  11. Revoked machine token: 401 on every call; the agent shows "needs re-pairing" on its keypad screen.
  12. 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.
  13. Points expiry: FIFO expiry job nightly writes expire entries; reminder at 7 days.
  14. 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.
  15. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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).
  7. 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.
  8. 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.
  9. 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

PhaseScopeSizeGate
1. Ladder + slot modeserver 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 weekse2e script e2e_cricket.py extended; Playwright for cabinet + claim; a Mac with a USB button on the bench
2. Points walletpoint_entries ledger, claim path, /w balance + bar, catalogue, points → coupon, expiry job, ops liability, erasure~1 weekledger immutability tests; 729 + new backend tests green
3. Vendingmachine 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 offersoffers table, wallet "offers" tab fed by what the shopper's wallet has seen; push via WhatsApplaterneeds 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)

QuestionDecisionWhere it lives
Reward names and products per rungOps-editable, no deploy. Prototype names seeded as placeholders.Rewards settings page ladder.rungs[] + catalogue (§8.1), Phase 1
Points rate and redeem thresholdOps-editable; seeded 5 + 1/run, 100points.* (§8.1), Phase 1 (rates) and Phase 2 (threshold, expiry)
Winners also earn pointsOps flag, seeded offpoints.winners_earn, Phase 1
Vending machine modelPer machine in ops: mdb, vendor_api or manualVending page driver select (§5.1), Phase 3; manual from Phase 2 so a venue can run without a machine
Shopper surfaceWeb 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:

ColumnTypeMeaning
coupons.unit_cost_paiseINTEGER NULLthe cost snapshot; NULL = no catalogue product (legacy run band / sponsor counter prize): sponsor-paid, never house spend
coupons.fulfilled_viaVARCHAR(8) NULLvending | 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.

ColumnTypeMeaning
vending_slots.low_stock_atINTEGER NULLper-slot line override; omitted from a slots PUT = unchanged
vending_slots.low_notified_atDATETIME NULLset on the crossing, cleared once above the line
vending_slots.low_notified_stateVARCHAR(5) NULLlow | 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) NULLat least one of phone / email; whatsapp needs the phone, email the email, both both
channel VARCHAR(8)whatsapp | email | both
machine_ids JSON NULLnull / [] = 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
channelwhatsapp | email (a both contact gets one row per channel)
kindlow | empty | digest | manual
summary TEXTthe message that was sent ("Slot A1 Cold drink 2 left, B3 Chips EMPTY")
statussent | failed | not_configured
provider_ref VARCHAR(64) NULL, error VARCHAR(200) NULL, sent_at
sent_by_user_id FK users NULLthe 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.
Rewards V2 design · Docs