Vouchers
How your OWN tenant-side voucher tooling actually works under the hood — Generate Vouchers, Voucher Presets, Journey presets, All Vouchers, printed sheets, 1Voucher & OTT Voucher gateway redemption, and the abuse protection behind free bundles. This is your own issuance and redemption tooling, not the separate Partner Voucher Reselling feature where a retail partner resells vouchers under their own billing.
Two different redemption models, on purpose
Not every voucher on this platform works the same way once it's redeemed, and the difference matters for how you reason about support tickets. There are two distinct paths:
| Voucher type | What redeeming it actually does |
|---|---|
| Generate Vouchers, Voucher Presets, Journey-derived vouchers (everything you issue from this dashboard or the API) | Grants a specific bundle instantly — the exact data/duration/speed baked into that code at creation time. POST /api/voucher/redeem calls recordPurchase() directly with that bundle. No balance is involved at all. |
| 1Voucher, OTT Voucher, and a partner-resold voucher (anything issued by Flash Group or a reselling partner, outside your own dashboard) | Credits a running cash balance on the guest's account for this tenant (ledger table tenant_guest_voucher_ledger, via the guest_voucher_balance_adjust database function). Nothing is unlocked yet — the guest separately picks a bundle from External voucher bundles to spend it on, through POST /api/dataplan/purchase/from-balance. |
Why the split: a code you print already has an exact rand value, data size and duration decided by you at print time, so there's nothing to "choose" — it just grants that bundle. A 1Voucher/OTT PIN is a real rand amount a guest bought from Flash with cash at a shop or in their banking app, often not an exact match for any one bundle's price (a R20 PIN against a R15 bundle) — crediting a balance first means the leftover R5 is never lost, and a guest holding both a 1Voucher and an OTT PIN can stack them into one combined balance before spending anything. The balance is per-tenant and per-identifier: it survives logout, carries over between visits, and only that guest (identified by phone/email) can spend it, always checked against the server-held ledger total, never a client-sent number.
On the connect screen there's only one input box ("Redeem a Voucher") for all three kinds — your own code, a 1Voucher PIN, or an OTT Voucher PIN. Length alone disambiguates which is which before the server even looks it up: your own venue code is always 8 characters, an OTT Voucher PIN is always 12 digits, a 1Voucher PIN is always 16 digits, and a partner-resold voucher is 10 digits (9 random + a Luhn check digit). The guest never has to know or select which kind they're holding.
Generate Vouchers
A one-off print run of single-use (by default) codes, created via POST /api/isn/vouchers/generate and stored in tenant_vouchers. Codes are 8 characters drawn from ABCDEFGHJKLMNPQRSTUVWXYZ23456789 — the alphabet deliberately excludes 0/O and 1/I/L so a handwritten or printed code is never ambiguous. A code is stored with no separator; the dash shown on screen or on a printed slip (e.g. AB3D-9KLM) is purely cosmetic — redemption strips any non-alphanumeric characters before comparing, so it can't drift from how it's displayed. There's no separate checksum digit on a venue code (unlike the 10-digit partner-resold format, which carries a Luhn check digit) — validity is purely "does this code exist, unused, unexpired, in this tenant's table."
| Field | What it controls |
|---|---|
| Count | 1–500 vouchers per batch. |
| Data bundle (MB) / Unlimited data | Either a fixed MB size, or unlimited — stored internally as the sentinel 1,000,000,000 MB, the same value the internal-account voucher feature and data plans already use for "unlimited." |
| Duration (hours or days) | duration_hours, a whole number ≥ 1. The days field is just a convenience that multiplies by 24 before sending. |
| Price | For your own records only — the platform never charges anyone for a self-generated code; you decide how it's actually sold (cash, front desk, your own booking system). |
| Batch label | Free text, up to 80 characters, used to filter/group in All Vouchers and the print sheet. |
| Expiry (days) | Optional. Leave blank for a code that never expires on its own. |
| Max redemptions per code | 1 by default (single-use). Raise it for a shared code (e.g. a 10-use event code) — clamped server-side to between 1 and 10,000. |
| Max concurrent devices | 1–20. A voucher has no dedicated device-cap column of its own — this is folded into Max redemptions (bumped up to at least cover the requested device count) and drives "Allow adding extra devices" automatically, the same auto-derivation pattern Data Plans and Subscription plans use. You can't set the checkbox directly: above 1 device it turns on by itself, because a device that can't actually join would be silently broken. |
| Download / upload speed cap (Mbps) | down_kbps / up_kbps. 0 means "use the tenant/router default" — see panel 11 for exactly how this is enforced. |
| Only allow redemption on specific routers | Optional, default every router. Real enforcement (checked before the code's use is consumed), not just a display filter — see panel 11. |
Insert collisions (two generated codes landing on the same random 8 characters) are handled by retrying the whole batch insert up to 5 times rather than patching a single row — at 8 characters over a ~32-character alphabet this is vanishingly rare, so the retry loop exists for correctness, not because it fires often in practice.
Journey-based pricing replaces manual label/price/duration entry with a per-leg fare pulled from an already-mapped Journey preset: pick "One specific stop" (the normal print-shop workflow — pick the leg, set a quantity, print, repeat for the next leg) or "All stops" (one full batch per leg, generated in a single action via POST /api/isn/vouchers/generate-from-journey). Price and duration come straight from that leg's own fare; the router restriction is inherited automatically from the route (so the field only appears in Straight mode) — this is also what closes the fare-evasion gap described in panel 11.
Voucher Presets
A saved pricing template (tenant_voucher_presets, created via POST /api/isn/voucher-presets) rather than a one-off print run — e.g. "500MB – R1" or "1GB – R1.50" — so you don't have to re-type the same bundle/price/duration every time you need to issue one. Capped at 100 presets per tenant (TENANT_MAX_VOUCHER_PRESETS); you'd deactivate one before creating another once you hit that.
Same device/speed fields as Generate Vouchers (Max concurrent devices auto-deriving Allow adding extra devices, download/upload speed cap, router restriction), plus a validity rule that Generate Vouchers doesn't have:
| Item | Details |
|---|---|
| relative_days | Expires N days after whichever moment a voucher is actually issued from this preset (needs a whole-number day count ≥ 1). |
| fixed_date | Pinned to one exact calendar date regardless of when it's issued (useful for a campaign that must stop on a specific day). |
| never | No expiry at all. |
This is what makes the API safe to hand to your own systems: a booking or point-of-sale integration references a preset by its ID, and the bundle/price/duration always come from the preset you defined here on the dashboard — never from whatever the calling system happens to send in the request.
Journey-based pricing works differently here than on Generate Vouchers: because a preset is a standing template your own systems reference by ID indefinitely (not a one-off print run where "which stop" matters at print time), POST /api/isn/voucher-presets/from-journey always generates one preset per stop along the route in a single action — never a single-vs-all choice. Your booking system then references whichever per-stop preset ID matches the fare it's issuing.
Journey presets (for Vouchers)
Journey presets are a shared, platform-wide feature (also used by Data Plans, surveys, ads, and the API) that sizes access duration — and, for vouchers specifically, price — to a real bus/taxi/shuttle trip rather than a flat number you type in. The full mapping workflow (click-to-map stops, GraphHopper driving-time/distance estimation, grace period, departure window, delayed-trip GPS recovery, per-leg pricing, router restriction, return-trip cloning) is covered in depth on the main Documentation page and isn't repeated here — this page only covers how vouchers specifically consume a Journey preset.
For vouchers, a Journey preset feeds two things, both resolved server-side at generation time, not redemption time:
| Item | Details |
|---|---|
| Price and duration per leg | _computeJourneyStopPricing() turns a route's stops/legs/full-route-discount into one price+duration pair per stop, which Generate Vouchers and Voucher Presets both consume (generate-from-journey and from-journey respectively). |
| Router restriction | A journey-derived voucher batch inherits the route's own router_ids automatically (stored on tenant_journey_presets.router_ids), rather than asking you to tick routers a second time for the same route. |
A redeemed journey voucher is tagged with journey_preset_id and journey_stop_index (same columns the voucher itself was created with) so the delayed-trip GPS recovery sweep can extend that specific passenger toward the stop they actually bought — not automatically toward the route's final destination — exactly the same logic a journey-derived Data Plan purchase gets.
All Vouchers
GET /api/isn/vouchers lists up to 500 of your most recent voucher rows (newest first), optionally filtered by status or batchLabel. Every voucher you've ever generated — manually, from a preset, from a journey, or via the API — lives in the one tenant_vouchers table and shows up here.
Status lifecycle is three values:
| Status | Meaning |
|---|---|
unused | Still redeemable (also what a partially-used multi-redemption code shows until its last slot is taken). |
redeemed | Every available redemption slot has been consumed (redemption_count >= max_redemptions). |
void | Manually cancelled by you via POST /api/isn/vouchers/:id/void — only possible while a code is still unused; an already-redeemed or already-void code can't be voided again (the update is conditioned on status = 'unused', so a race against a guest redeeming it at that exact moment just fails harmlessly rather than double-cancelling anything). |
A multi-redemption code's row only ever shows its most recent redeemer's identifier/device/router — not a full per-redemption history — since tenant_vouchers stores one row per code, not one row per redemption. Each individual redemption is still fully recorded for billing/usage purposes via recordPurchase(); it just isn't surfaced back onto the voucher's own row.
Print voucher sheet
"Print voucher sheet (PDF)" on All Vouchers (GET /api/isn/vouchers/print-sheet.pdf) renders a printable A4 grid of tear-off slips for a front-desk "sell at the counter" workflow — no reading codes off a screen. Each slip shows your brand name, the code, data amount, duration, price (or "Free" if R0), and expiry date if one's set, with a dashed border meant for scissors/paper-cutter separation (2 slips per row, break-inside avoided so a slip never splits across a page edge).
- Only pulls from vouchers currently
status = 'unused'— already-redeemed or voided codes never print. - Capped at 100 vouchers per sheet (
VOUCHER_PRINT_SHEET_MAX) — already 10 A4 pages at 10 slips per page. - Can be filtered to one
batchLabelvia a query parameter, so you can print just today's new batch instead of every unused code on file. - Rate-limited to 10 requests per hour per tenant — each call launches a real headless-Chromium PDF render, a genuinely heavier operation than an ordinary data query, on infrastructure the codebase elsewhere documents as RAM-constrained on Render's free tier.
1Voucher & OTT Voucher (external gateway redemption)
Both products are issued and operated by Flash Group, not by ISN — a guest buys a real prepaid PIN with their own cash at a spaza shop, supermarket till, or straight from their own banking app, entirely independent of your venue. Before guests can redeem either, you connect the gateway under Payment gateways by supplying an apiKey, Flash merchant accountNumber, and an environment (sandbox or production) — stored as one JSON blob inside the same encrypted encrypted_secret column every other gateway credential uses.
What actually happens on redemption (POST /api/1voucher/redeem / POST /api/ott-voucher/redeem):
- The server fetches an OAuth2
client_credentialstoken from Flash's own/tokenendpoint (cached per API key/environment until shortly before it expires). - It calls Flash's aggregation API —
POST /aggregation/4.0/1voucher/redeemor/aggregation/4.0/ott/redeem— withamount: 0, which per Flash's own convention means "redeem the voucher's full value" rather than a partial/fixed amount. - A successful response (
responseCode === 0) returns the voucher's value in cents; the server converts to rand and callsguest_voucher_balance_adjustto credit that exact amount onto the guest's balance for this tenant. - A failure (bad PIN, already used, expired, wrong amount) is deliberately reported to the guest with one generic message — "Invalid or already-used voucher" — never which specific Flash response code it was, the same anti-enumeration convention used everywhere else voucher codes are checked. The real Flash response code is still logged server-side so support can tell a bad PIN apart from a genuine Flash outage.
1Voucher and OTT Voucher credit the exact same tenant_guest_voucher_ledger balance — a guest holding both can mix and match, and redeeming either only ever adds to the balance; it never auto-spends. The guest still separately picks which bundle to spend it on from External voucher bundles.
Honest caveat from the code itself: the OTT Voucher integration was built by mirroring 1Voucher's already-confirmed request/response shape (same Flash aggregation API, same token flow, just a different path) rather than being independently re-verified against Flash's own OTT documentation — worth a real sandbox test against a live Flash OTT account before leaning on it in production, same as any new integration built from a documented pattern instead of a fresh doc read for that specific product.
A gateway connection is "verified" the moment the API key alone is confirmed valid against Flash's token endpoint — the merchant account number can't be independently checked ahead of time (Flash has no separate "look up this account" call), so it's accepted as entered and only genuinely confirmed the first time a real guest redeems a real voucher.
External voucher bundles
This is the price/bundle menu a redeemed 1Voucher/OTT/partner balance actually buys — and it is deliberately the exact same underlying tenant_data_plans list as Data Plans, not a separate table. A bundle added under External voucher bundles shows up under Data Plans too, and vice versa; same fields, same per-bundle device limit, same optional promo protection, same optional per-router visibility restriction. The only difference is which panel you're looking at it from and which payment path the guest used to pay for it — by card through a connected gateway (Data Plans), or from a redeemed voucher balance (here).
Spending from balance goes through its own endpoint, POST /api/dataplan/purchase/from-balance — deliberately separate from the card-checkout path, not an overload of it, so "is this request allowed to just deduct balance and grant access with no external payment call at all" stays in one small, easy-to-audit place. The entire security boundary is the guest_voucher_balance_adjust database call: it takes a row lock before computing the new balance, so it holds even against two simultaneous spend requests racing each other, and even against a client that sent a planId for a bundle its own UI should never have let it click — if the plan's price exceeds the guest's real server-side balance, the function itself refuses and nothing is debited or granted.
On the captive portal, "Use my balance instead" opens this same bundle list, cheapest first. Every bundle the guest can't currently afford is shown but disabled, never hidden, so pricing stays visible even at a R0 balance. A guest who redeems R2 now and R3 later ends up with a real R5 balance whenever they next come back — nothing is lost to rounding, and closing the modal without buying anything never loses the balance.
A guest tapping a Data Plan directly (not through "Use my balance") also benefits from this silently: the portal tries their real 1Voucher/OTT balance first behind the scenes, and only falls through to card checkout if the balance genuinely can't cover the price — no "pick a payment method" prompt either way.
Low-data nudge
On by default (low_data_nudge_enabled defaults true). Shows a banner with a "Top up now" button on the guest's portal page the instant their active bundle drops below a configurable threshold — low_data_nudge_threshold_pct, default 20%. Managed via GET/POST /api/isn/low-data-nudge-settings, which reads/writes those columns directly on the tenants row.
Purely advisory — it never blocks anything, and a guest can dismiss it and keep browsing until the bundle genuinely runs out. Its real limitation is architectural, not a bug: the banner only exists on the portal page itself, so once a guest has real internet and closes that tab, nothing in ordinary browsing (Google, YouTube, anywhere else) routes back through this app, so there's no way to show them anything further.
"Also text the guest" (low_data_nudge_sms_enabled, off by default since it spends tenant SMS credits) closes that exact gap — an SMS reaches the guest's phone regardless of what they're browsing. It's automatically skipped for a guest who only ever provided an email (nothing to text), and the same guest won't be texted again about the same low-data spell within a 6-hour cooldown (checked against sms_log rows with reason = 'low_data_nudge'), so someone hovering right at the threshold doesn't get repeatedly messaged.
Promo / R0 bundle abuse protection
A bundle priced R0 is bait for abuse — exhaust it, then either register a new account on the same phone, or take an already-used account and connect from a different device, hoping the system treats it as someone new. Promo protection (promo_enabled on tenant_data_plans, enforced by _getPromoEligibility() and the atomic claim_promo_purchase database function) closes both at once by checking two independent signals together:
| Item | Details |
|---|---|
| Device fingerprint | A prior paid row in tenant_data_plan_purchases for this exact plan matching this device's device_id. |
| Account identifier | A prior paid row for this exact plan matching this phone number/email, regardless of which device made it. |
Whichever signal has already claimed the bundle blocks the next attempt — switching only the account doesn't work (the device still shows a prior claim), and switching only the device doesn't work either (the account still shows one). A plan priced exactly R0 gets this forced on automatically with a strict "once ever" default (promo_reset_hours = 0); a paid plan can opt in voluntarily too, useful for a steeply-discounted launch promo you still want rate-limited.
The reset window (promo_reset_hours) is configurable to any interval — hourly, daily, weekly, monthly, yearly, or "once ever" at 0 — plus an optional promo_start_at/promo_end_at window for a genuinely time-limited campaign. The actual claim is atomic: claim_promo_purchase checks eligibility and records the purchase row in one locked database transaction, so a promo that's already used up can never be claimed twice even by two requests landing at nearly the same instant. If the balance deduction that follows ends up failing (insufficient balance on a voucher-funded purchase), the reserved promo claim is rolled back rather than left burned on a purchase that never actually completed.
Guests don't find out the hard way — an already-claimed protected bundle shows greyed out on the connect screen before they even tap it. Not unbeatable (a guest changing both device and account entirely still presents as genuinely new), but it closes the common "just sign up again" pattern with zero friction for a real first-time guest.
Speed caps and max-concurrent-devices on vouchers
Every voucher — generated manually, from a preset, or from a journey — can carry its own down_kbps/up_kbps speed cap and device-sharing limit, validated by the shared _validateVoucherDeviceSpeedFields() helper used by both /api/isn/vouchers/generate and /api/isn/voucher-presets.
Max concurrent devices (1–20) has no dedicated column of its own on tenant_vouchers — it's folded directly into max_redemptions, bumped up to at least cover the requested device count whenever it's above 1, which is exactly what also flips allow_add_device on automatically. That's the same field resolveDeviceCapForIdentifier already reads elsewhere as the shared pooling cap, so a voucher's "devices" and "redemptions" are really the same underlying mechanism viewed two ways.
Shared-balance pooling at redemption: for an allow_add_device voucher, only the first redemption carries the real bundle_mb — every subsequent redemption (a second phone, a laptop) grants 0 MB of its own and is recorded purely as a marker row for usage visibility and fair-usage attribution, sharing the first redemption's pooled data instead of stacking a second full bundle on top. A non-pooled multi-use code (allow_add_device = false, meant for handing the same code to several different customers) behaves oppositely — every redeemer gets their own full bundle.
Speed cap enforcement: on redemption, _applyDataPlanSpeed(tenantId, voucher, identifier, deviceId) is called with the voucher row itself — the exact same function a Data Plan purchase uses to apply its own per-plan Mbps cap. It reads down_kbps/up_kbps straight off whatever object it's handed and no-ops when both are 0 (meaning "use the default"), so this is one shared code path, not a voucher-specific reimplementation.
Router restriction is real enforcement, checked before the code's use is consumed — not a display filter. At redemption, the router actually used is taken from the guest's genuine hotspot session (set server-side the moment a router redirects a guest through /hotspot), never from the client-suppliable routerId in the request body or an x-router-id header, both of which a guest could trivially forge since a router's own ID isn't a secret. If no trusted router session exists at all, a restricted voucher is denied by default — "we can't verify which router this guest is on" must never pass a restricted code. This is what makes the Journey-based fare-evasion protection actually hold: a cheap short-leg voucher, router-restricted to that route's bus, genuinely can't be redeemed while sitting on a different, pricier vehicle in the same fleet — buying the short leg and walking onto the long-haul coach doesn't work.
All of this — count, data cap, device limits, validity, speed caps, and fair-usage overrides — is shared across every leg a Journey-based batch produces; only price, duration, and the router restriction vary per stop.
Where to buy a voucher (outlet directory)
A plain, tenant-maintained list of the shops, kiosks or offices where a guest can physically buy a voucher, shown to guests under Where to buy a voucher inside the "Redeem a voucher" panel of the captive portal. It exists so an operator who sells vouchers through third-party outlets (a municipal Wi-Fi network selling through retail partners, for example) can tell guests where to go, and can keep that list current without any development work. It is free text only: nothing in it touches pricing, grants or redemption.
Fields and limits (stored in tenant_voucher_outlets, one row per outlet, scoped by tenant_id): name (required, 120 characters), address/location (300), opening hours (200), contact (120) and how to buy instructions (600, line breaks kept). Up to 300 outlets per tenant. Each outlet also has an active flag, so one can be hidden temporarily (a shop that is closed for the holidays) without losing its details. Anything that is not plain text or a number (an object or array sent to the API) is rejected rather than stored.
Managing it is under Vouchers → Where to buy in the ISN dashboard: add, edit, hide/show or remove, and the change is visible to guests straight away. The routes are GET/POST /api/isn/voucher-outlets and PATCH/DELETE /api/isn/voucher-outlets/:id, all behind a tenant session. They are filed under the same dashboard section as vouchers, so the Owner, IT and Financial roles can manage the list, and View Only can read but not change it. Every write is filtered by the session's own tenant_id, so another tenant's outlet id simply returns 404; a malformed id also returns 404 rather than an error.
What guests get: GET /api/voucher-outlets is public (the guest is not signed in yet when choosing where to buy), resolves the tenant from the host like every other guest route, returns only active outlets, only the six fields a guest needs (id, name, address, hours, contact, instructions, never tenant_id), and is sent no-store so edits show immediately. The portal builds the list with textContent only, never as HTML, because every field is typed by the tenant: markup typed into an outlet name or instructions shows up as plain visible text and cannot run. The list is hidden entirely when a tenant has no active outlets.
