# Vending runbook (Rewards V2, 2026-11-01)

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

For the ops person and the technician who looks after the Freebee vending machines and the
Rewards settings. Plain English; no code. Everything named here is in the ops portal at `/ops`
unless it says otherwise. The box that sits beside a machine is the **vend agent**; its own
install guide (hardware list, wiring, pairing command, systemd, troubleshooting) is
`vend-agent/README.md`. The API behind all of this is `docs/API_CONTRACT.md` §v2.0.

## 1. How a reward reaches a shopper

1. A shopper plays the cricket game on a kiosk or a walk-up cabinet. Every innings ends with a
   result card: a QR and a 6-letter code.
2. On their own phone (`/w/c/CODE`, or the Freebee app) they verify their number once and claim.
   A reward becomes a **coupon** with a product (a cold drink, chips…) and a channel: **counter**
   (show the coupon code to a person), **vending** (type a PIN on a machine) or **any**.
3. A vending coupon shows a **6-digit vend PIN** that lives 15 minutes. The shopper walks to a
   Freebee machine in that mall and types it on the keypad.
4. The agent asks our server; the server locks the coupon for 90 seconds and tells the agent which
   slot(s) to open and for how much credit. The machine lets the shopper pick; the agent reports
   **vended** or **failed**.
5. Vended: the coupon is used, the slot's stock goes down by one. Failed: the coupon is released,
   the same PIN still works, the shopper can try again or ask for a new PIN.

Points work the same way one step later: an innings that hits no rung earns points; past the
threshold the wallet offers the points catalogue; a redeemed product is a coupon like any other.

## 2. Adding a machine

Vending → **Add machine**.

- **Venue**: the mall it stands in. PINs are bound to the mall, so a wrong venue means every PIN
  is refused with "this code is for a machine in another mall".
- **Label**: what shoppers and alerts call it ("Atrium snacks").
- **Driver**: `manual` (no machine yet, a person hands the product over — see §9), `mdb` (our box
  with the MDB adapter plugged into the machine) or `vendor_api` (a vendor's own cloud; paste
  their credentials in the config box, they are stored encrypted and never shown again).
- **Where it stands**: one line, up to 120 characters, shown on the shopper's phone next to the
  machine's name ("Ground floor, by the food court") and on the keypad's "try &lt;machine&gt;" line. Write
  it for a visitor who has never been in the mall. Empty = the mall's name is shown.
- **Notes**: for ops only (price units, who refills it, the adapter model). Shoppers never see them.

A new machine is **Not paired** until the agent takes a pairing code.

## 3. Pairing the agent

1. Open the machine's page and press **Pairing code**. You get a short code valid 10 minutes;
   the page shows the exact command for the box.
2. On the box, the technician runs that command (`freebee-vend pair CODE --api …`, details in
   `vend-agent/README.md`). The page flips to **Paired** within a few seconds and shows the
   agent version and whether its driver answered (`driver ok`).
3. A code works once. If it expires, press the button again; the old code is replaced.

The machine token lives only on the box. If the box is lost or replaced, **Revoke** the machine
(§8) and pair the new box with a fresh code.

## 4. Slots, stock and refill mode

The slot map says what sits where. Slot codes are the machine's own selection codes (A1, B2…
or whatever the machine prints on its buttons); the product is from the catalogue (§10); capacity
and stock are counts; price is what the machine charges for that selection (the agent grants
exactly that much credit, so the shopper cannot pick something dearer).

- **Save** writes the whole map. A slot you remove from the list is gone; a slot with stock above
  0 is no longer "sold out".
- **Refill mode** is the same page with big buttons for the person who stocks the machine on a
  phone: minus, plus, **Full** (= capacity), one **Save stock** at the bottom. Do it while
  standing at the machine after the physical refill, not before.
- Stock goes down by one on every confirmed vend. The machine can also tell us a slot is empty
  (jam or sold out): the slot shows **sold out** and stock drops to 0 at once. Refilling (stock
  above 0, Save) clears it.
- The list card shows the stock summary "`stock/capacity` across `k` slots · `j` low" and a band:
  red under 10 %, amber under 30 %. "N empty" counts slots at 0 or sold out.
- **The low line** (Rewards V2.1): a slot is **LOW** when its stock is at or under
  `max(minimum, capacity × percent)` — the house settings "Low stock percent" (20) and "Low stock
  minimum" (3) on `/ops/rewards` → Vending — and **EMPTY** at 0 or when the machine says sold out.
  Refill mode shows the line per slot ("low at 3"), LOW / EMPTY badges, and lets you give one slot
  its own line (a slow seller can be "low at 1"). Each slot also shows the product's unit cost
  ("₹10 each · edit"): that is what the house pays for one, used by every spend figure (§14); edit
  it there or on the Products tab — it is one price per product, not per slot.
- When a slot crosses the line (a vend takes it there, the machine reports sold out, you type a
  number at or below the line in refill mode, or the 5-minute sweep finds it) every ops user gets a
  **slot low** / **slot empty** message once, with a link to that slot — and the venue's refillers
  get their message (§13). It does not nag: the slot is told once until someone refills it above
  the line, after which the next crossing is told again.
- If a slot that a live campaign depends on hits 0, every ops user also gets the **slot empty**
  message (at most one per slot every 6 hours).

## 5. What the shopper sees

On the phone: the PIN in large digits with a countdown, the machines in that mall with their
location line, and "Keep this page open until your snack drops". A machine that has not
checked in for 15 minutes is marked offline **only when another machine in the mall is online**
(a counter-only `manual` machine never checks in and must not read as broken). With no machine in
the mall the page says "show the code at the counter". If the machine is down the coupon's
counter code still works: tell the counter to accept it.

On the keypad display (two lines): "Type your code" → "Checking…" → "Pick your snack" →
"Enjoy!", or one of: "Code not valid", "Code already used / in use", "Code expired, get a new
one", "Out of stock here, try <other machine>", "Machine busy, wait 60 seconds" (5 wrong PINs in
2 minutes), "Did not drop, try code again" (the same PIN still works), "Time ran out, try code
again" (nobody picked within 60 s), "Machine problem, ask at counter" (the machine accepted a
selection but never said whether it dropped — see §6), "Machine offline, use the counter", "Needs
re-pairing, call Freebee ops". The full table is in `vend-agent/README.md`.

At the counter: while a coupon is being vended at a machine, or is disputed, the portal's Redeem
refuses the counter code with a sentence saying so, so a prize is never handed over twice.

## 6. Disputes and how to resolve them

A **dispute** is a vend the server cannot settle on its own. Three ways one appears:

- The agent took a PIN, the coupon was locked, and no vended/failed report arrived within 90
  seconds (box crashed, network dropped, power cut mid-vend, or the machine accepted the selection
  and never reported a drop — the box shows "Machine problem, ask at counter" and deliberately
  reports nothing). The next time anyone touches that coupon (the shopper asks for a PIN, a machine
  tries the PIN) it is disputed on the spot; the background job every 5 minutes catches the rest.
- The machine reported **failed** for the **second time** on the same coupon: rather than let a
  coupon bounce for ever, that attempt becomes a dispute for a person to look at.
- A machine vended a coupon that the **counter had already redeemed** meanwhile: nothing is paid
  twice on paper, but you are told so you can check whether a product left the machine.

In every case the coupon is locked so it cannot be used elsewhere, and every ops user gets a **vend
needs checking** message (time in IST) with a link to the request.

Vending → **Requests** → the "need your decision" cards (also reachable from the Today tiles).
For each one:

1. Read the machine's own vend counter or ask the person on site: did a product drop at that
   time? The agent's late verdict, if it reconnected, is shown on the row ("agent reported
   vended after the dispute window").
2. **Vended**: the coupon is marked used, the slot loses one. **Failed**: the coupon goes back to
   the shopper, who can get a new PIN. Add a one-line note. Once resolved a row never changes.
3. When in doubt choose **Failed**: the worst case is a second snack; the alternative is a shopper
   who paid with their time and got nothing.

The request ledger keeps every attempt forever (never deleted); the Requests tab lists the last 7
days by default with the coupon code, product, machine, slot, driver and result. It never shows
who the shopper is.

## 7. Offline machine, out of stock

- **Offline** (no heartbeat for 15 minutes): you get one **machine offline** message per
  episode. The shopper's keypad says "machine offline, use the counter"; their PIN stays valid.
  Check power to the box and the machine, then network, then `journalctl -u freebee-vend` on the
  box (README troubleshooting). A redeem needs the server (a lock is taken per vend), so the agent
  never queues vends offline by design.
- **Out of stock**: the keypad says "out of stock here" and names the nearest Freebee machine in
  the mall that has the product; the coupon is untouched. If no machine has it, the shopper is
  told to use the counter. Refill, or change the rung's product (§10) if the product is gone for
  good.
- **Jam / nothing dropped**: the agent reports failed, the coupon is released (same PIN still
  works), the slot is marked sold out. Clear the jam, refill, Save. A second failure on the same
  coupon becomes a dispute (§6).

## 8. Switching the driver, revoking

- **Switching driver** (for example `manual` → `mdb` the day the adapter arrives): edit the
  machine, change the driver, Save. The box picks it up on its next heartbeat, no reinstall. The
  select is disabled while the machine has an open or disputed request: resolve those first.
  Moving away from `vendor_api` also removes the stored credentials.
- **Revoke**: the box's token stops working at once (every call gets 401, its display says "needs
  re-pairing"). Use it when a box is lost, replaced or a mall contract ends. The machine record,
  its slots and its history stay; a new pairing code brings it back.
- **Moving a machine to another mall**: change its venue. PINs are bound to the mall, so a
  sticker left in the old mall fails cleanly.

## 9. The `manual` driver (a venue without a machine)

A venue can run the whole rewards flow before any machine exists. Create the machine record
with driver **manual** and a label like "Counter, ground floor". Two ways to use it:

- **With a box and keypad at the counter**: the shopper types the PIN; the box prints "HAND
  OVER: Chips 50 g" for the counter person, who presses Enter when handed over (or `n` if they
  could not). The server sees a normal vend, stock and reports included.
- **Without any box**: leave the machine unpaired and set the house setting
  `fulfilment.default` to **counter** (§10). Coupons then carry the counter code only, and the
  counter person marks them used with Redeem in the portal as today. Flip the default to
  `vending` when a machine is live.

Either way, a `manual` machine never heartbeats, so it is never reported offline.

## 10. The Rewards settings page (`/ops/rewards`)

Every value here changes the live game without a deploy. Each field shows its current value,
the seeded default, who last saved it and when; **Check figures** runs a 20,000-innings
simulation of the draft (hit rate per rung, any-reward rate, average points, liability per 100
players) before you save. Save validates everything together and keeps a history (the History
tab). If someone else saved first you get "reload and apply your change again".

**The snapshot rule.** An innings copies the ladder, the points rates and the channel when it
starts. A save takes effect from the next innings on that screen, never halfway through one,
and nothing already minted or issued is rewritten. So the page is **safe to edit during mall
hours**; the only thing to think about is what the next innings will promise.

Every save must carry the version you loaded (the page does this for you); a save that lands
after someone else's is refused with "reload and apply your change again".

| Setting | What it does | During mall hours |
|---|---|---|
| Ladder rungs (name, rule, product, rank, daily budget, max value) | What the five cards on the screen say and pay. Rank 1 is the best; the highest rung hit wins. Daily budget (seeded 10 / 20 / 40 / 60 / 80 a day, best rung first) is how many of that rung the house pays per day before it falls through to the next; max value refuses a product dearer than that. | Safe. Check figures first (it shows the per-day liability with the budgets applied); a product must be active in the catalogue. |
| Dice odds | House odds for creatives that set none; must add to 100, out above 0. | Safe but changes the whole game's feel; prefer between campaigns. |
| Points per innings / per run | What a no-reward innings earns (seeded 5 + 1 per run). | Safe; every ledger row records the rate it was minted with. |
| Winners also earn points | Reward winners get the innings' points too. | Safe, from the next innings. Raises liability. |
| Redeem threshold | Points needed before the wallet offers the catalogue. | Safe. Lowering makes more wallets eligible at once; raising never takes a coupon back. |
| Points expiry days / reminder days | FIFO expiry and the WhatsApp reminder a week before (nightly 02:30 IST). | Safe; reminder must be less than expiry. |
| Max innings per phone per day | Innings that earn points per phone per day (UTC). Over it, a claim banks 0 points. | Safe. |
| Default fulfilment | `counter`, `vending` or `any` for coupons that do not say. | **Set to `counter` until a machine exists in the mall**; `vending` once it does. |
| Vend PIN life / wrong PINs per machine | PIN minutes (15) and the keypad lock (5 wrong in 2 min → 60 s). | Safe. |
| App bonus (mode, points, install points, per phone per day, banner text) | What claiming in the Freebee app earns, and the banner on the web claim page. Seeded **off**. Turn it on only when the app is in the stores and the engineer confirms `WALLET_APP_KEY` is set on the server and in the store builds (without it no claim can ever count as "in the app"); `tier_up` hands the lowest rung's product instead of points. The banner text is free text: if you change the bonus points, change the text too. | Safe. |
| Result card seconds / idle close seconds | How long the cabinet shows the QR (25) and when a walk-away innings is closed (60). | Safe. |

Per-machine things (driver, slots, stock) live on the Vending page; a sponsor's own ladder and
rung budgets live in the creative editor in the portal.

**The catalogue** (Products tab): name, kind, cost (what the house pays, used for liability),
image URL, points cost (blank = not buyable with points), active. Products are never deleted;
deactivating one that a live ladder pays is refused with the campaigns named, so change the rung
first. Renaming a product renames it on every screen, claim page and wallet at once.

## 11. First day with vending in a mall

1. Vending → add the machine with the right venue and its "Where it stands" line; pair the box; selftest
   on the box passes with the real serial port.
2. Fill the slot map from the machine's actual layout; set prices to what the machine charges;
   stock to the real count.
3. Rewards settings: `fulfilment.default` = `vending` now (it was `counter` while there was no
   machine); check the ladder's five products are all stocked in some slot; set each rung's daily
   budget in the creative if the sponsor has a cap; press Check figures and read the liability
   line.
4. Do one vend yourself: play the cabinet, claim on your phone, type the PIN, watch the drop,
   confirm the slot's stock went down on the Vending page and the request row says vended.
5. Pull the box's power mid-vend once (the dispute drill): within 5 minutes you should get the
   "needs checking" message; resolve it as failed; confirm the coupon is usable again.
6. Note the machine's price units (rupees or paise) in its notes for the next technician.

## 12. Messages ops receive

| Message | Means | Do |
|---|---|---|
| A vend on X needs checking | no confirmation within 90 s, a second failure on one coupon, or a machine vended a coupon the counter had redeemed | §6 |
| Points below zero for wallet … | a points balance went negative (should never happen; the nightly check found it) | tell the engineer; the wallet id in the link is all we hold |
| X is offline | no heartbeat for 15 minutes | §7 |
| X: slot A1 is empty | a slot a live campaign pays from hit 0 (one message per event — you never also get the "is low / is empty" one for it) | refill, or change the rung's product |
| X: slot A1 is low / is empty | the slot crossed the low line (§4); a slot already "low" that runs out tells you once more, "is empty". The refillers were messaged too — unless it was you typing the number in refill mode, or the machine is revoked / not yet paired | check the Today panel's alert row ("2 slots low on X — Ramesh messaged 14:02"); refill |

All of them arrive in the ops bell (`/ops/notifications`) and by email when email is set up.

## 13. Refillers

A **refiller** is the person who actually carries the boxes: a mall staffer, a vendor's runner,
or you. They do not log in; they get a WhatsApp (or an email) that says what to bring where.

Vending → **Refillers** tab (per venue):

- **Add**: name, phone (10 digits or +91…) and/or email, channel (WhatsApp, email or both), and
  optionally which machines they cover (none ticked = every machine at the venue). The box **"This
  person agreed to get refill alerts on this number"** must be ticked — ask them first; it is their
  phone number and we record the date they agreed. Changing to a new number asks for the tick again.
- The number is stored encrypted, shown back only as `+91••••••1234`, never in any report or log,
  and **Remove** deletes it for good (the alert log keeps only the name).
- **Send test** sends that one person the current list right away (even when nothing is low: "all
  slots are stocked"), so you can check the number works before you need it. It goes the real
  route: a contact who is switched off, or who does not cover that machine, is refused with the
  reason — switch them on or tick the machine first.
- **Send refill list now** on a machine card sends everyone who covers that machine the current
  list — no waiting, no throttle; it asks you to confirm. Each ops login gets ten of these an hour
  (test or real): every one costs a WhatsApp message.
- The **alert log** under the list shows every message: who, which machine, what it said, and a
  chip — **sent**, **failed** (the provider's reason), or **not configured**. If the tab shows the
  persistent note "WhatsApp is not configured on the server (WHATSAPP_PROVIDER). Alerts are logged
  only.", nothing is leaving the building: ask the engineer to set `WHATSAPP_PROVIDER=aisensy` and
  get the `freebee_refill_alert` template approved (three fields: machine, venue + where, slot list).
  Email needs `EMAIL_PROVIDER=resend`.

What the message says: "Freebee refill: Atrium snacks at Inorbit Mall (ground floor, by the food
court) — 2 slots need stock: A1 Cold drink 2 left, B3 Chips EMPTY. Reply done when refilled." Up
to eight slots are listed, then "+k more".

When it goes out:

| When | Kind | Rule |
|---|---|---|
| a slot crosses the low line (§4) because of a vend, the machine saying "sold out", or the 5-minute sweep | low / empty | one message per refiller per machine every 6 hours at most, listing every low or empty slot on that machine at that moment — more slots crossing inside those 6 hours add nothing, except that a slot running EMPTY gets through once even if a "low" went out minutes ago |
| you type a stock at or below the line in refill mode | — | nobody is messaged (you already know); the ops bell gets the "is low" row |
| every morning 09:30 IST | digest | one message per refiller per machine that still has a low or empty slot (the scheduler job `refill.digest`; Today → Background jobs → Run now for a dry run) — skipped for anyone who already got a message about that machine in the last 2 hours, and it counts as that person's one message for the next 6 hours |
| you press the button | manual | at once, every time (ten per hour per ops login) |

"Reply done" is for the person, not for us: the machine's stock is updated by whoever does the
refill in refill mode (§4), which also clears the low state and re-arms the alert.

## 14. Reading the spend numbers

Every figure that says ₹ comes from one rule: **when a prize leaves (machine drop, counter
hand-over, or you resolving a dispute as "vended"), the product's unit cost at that moment is
written on the coupon and never changes.** Editing a product's cost later changes what the *next*
hand-over costs, not what the past ones cost. A prize with no catalogue product (an old run-band
win, a sponsor's own counter gift) carries no cost and is never counted as our spend.

- **Today panel → "Spent today"**: `₹X` = everything handed over since midnight IST (machines and
  counter), with "n vends · m counter · ₹Y last hour". Refreshes within a minute.
- **Vending page machine card**: "₹200 today · 20 drops · ₹40/hr last hour" — that machine's drops
  since midnight IST and in the last 60 minutes. Refreshes every 5 seconds while the page is open.
  "20 cokes in an hour at ₹10 = ₹200" is exactly this line.
- **Rewards → Giveaways tab**: which screen and which game gave how many of each gift, for a day
  range (today, yesterday, 7 days, 30 days, custom), grouped by screen / game / rung / product /
  machine / hour / day. Columns: **Minted** (prizes the games promised), **Claimed** (banked in a
  wallet), **Redeemed** (handed over — split into vended by a machine / counter), **Expired**
  (never claimed in 48 h, or the coupon ran out), **Outstanding** (promised, not yet handed over, not
  expired), **Spent ₹** (what the handed-over ones cost us), **Liability ₹** (what the outstanding
  ones would cost at today's price). A prize the daily limit turned into points counts as claimed
  (CSV column `as_points`) and is never a liability. The rows are by the time the prize was *won*; the hourly bar
  underneath is by the time it was *handed over* (that is the "₹200 in the last hour" view). Points
  have their own three numbers in the strip: points minted, points redeemed, and ₹ spent on
  points-catalogue coupons. **Download CSV** gives the same table plus a TOTAL line for the
  accountant.
- A day is an IST day everywhere on these pages. Liability uses today's catalogue price because
  nothing has been paid yet; spend uses the price on the day it was paid.
