Data Plans
How a guest buys internet access directly by card on a Data Plan, how a tenant configures what's for sale, and exactly what protects a real payment from turning into free (or double-charged) access.
What a Data Plan is, end to end
A Data Plan is a bundle a tenant sells directly to a guest for real money — a price, a data allowance (or unlimited), a duration, and an optional speed cap — bought right on the captive portal's connect screen with no voucher code involved. It's the "pay by card" counterpart to a voucher: the same underlying table (tenant_data_plans) backs both "Data plans" and "External voucher bundles" in the tenant dashboard, so a bundle created in either place shows up in both.
The guest flow, step by step:
- Guest taps a plan on the connect screen (
home.html's "Buy internet access" modal). The browser callsPOST /api/dataplan/purchase/initwith the plan id and the guest's own identifier (phone/email) — the server rejects the request with 403 unless the caller's own session actually owns that identifier, which stops one guest from burning a free plan's eligibility against a stranger's phone number. - If the guest already has a 1Voucher/OTT Voucher balance on their account that covers the price, the portal tries
POST /api/dataplan/purchase/from-balancefirst, silently — no "pick a payment method" prompt. Only if the balance can't cover it does it fall through to a real card/QR checkout. - For a card/QR purchase,
purchase/initstarts a checkout with whichever payment gateway the tenant has connected and returns one of: an inline Paystack popup, a full-page redirect URL (PayFast, Yoco, DPO Pay, or a Paystack fallback), or a QR code payload (SnapScan, Zapper). A new row is inserted intotenant_data_plan_purchaseswithstatus='pending'. - The guest's browser polls
GET /api/dataplan/purchase/status?reference=...every 2 seconds. Real settlement never happens on this poll — it only happens when the gateway's own webhook lands and calls_settleDataPlanPurchase(see panels 4–5). The poll gives up showing "still confirming" after 15 attempts (30 seconds) but access still activates automatically the moment the webhook does land — the guest isn't stuck if their payment just took longer than that. The one exception is DPO Pay, which has no webhook at all: when the guest returns, the status check itself asks DPO directly whether the payment really succeeded (see the DPO Pay row in panel 3 and the recovery notes in panel 5). - Once
statusflips topaid, the portal stores the guest's identifier inlocalStoragesologin.htmlrecognizes them automatically on return, instead of making a guest who just paid re-enter a login form.
How a tenant admin configures a plan
Configured from the dashboard's "Data plans" section (isn/business-dashboard.html, id #data-plans), validated server-side by _validateDataPlanBody. The exact fields:
| Field | What it controls |
|---|---|
| Plan name | Free-text label, shown to the guest (e.g. "1 Hour Unlimited"). |
| Price (R) | 0 or blank makes it a genuinely free plan — see panel 8. Any value >0 requires a connected, verified payment gateway. |
| Duration (hours) / duration (days) | How long access lasts once granted. The dashboard offers either field; both write to the same duration_hours column. |
| Unlimited data / Data (MB) | Either an unlimited flag or a specific bundle_mb cap. MB is ignored if unlimited is checked. |
| Max concurrent devices | 1–20, default 1 ("treated as a standalone guest account"). "Allow adding extra devices" is NOT a separate toggle the tenant controls — it's server-derived, forced on automatically whenever this is set above 1, so the "Add a device" pairing-code flow is always available the moment a plan allows more than one device. |
| Download / upload speed cap (Mbps) | Maps to down_kbps/up_kbps. 0 means "no plan-specific cap — leave whatever's already active," letting a cheaper plan ship with a lower cap than a pricier one. |
| Only offer this on specific routers | Optional allow-list (router_ids). Leave every router unticked to show the plan everywhere — this only controls what a guest is shown, not a fare/payment gate on its own. |
| Fair usage override (optional) | Per-plan fupThresholdGB/throttle down/up Mbps. Left blank, the plan inherits the tenant-wide Fair Usage Policy; set explicitly to give one plan its own threshold (e.g. a cheap 24h top-up that throttles sooner than a 30-day plan). |
| Event pass window (optional) | A plan can carry an On sale from and On sale until (event ends) time (available_from / available_until). Outside the window the plan is hidden from the guest list and a purchase attempt is refused ("not on sale yet" / "this pass has ended"); the window is checked again on the balance-purchase and free-plan paths, not only in the list. Tick Access ends when the event ends (expires_at_event_end) and every grant is capped so it never outlasts the event, whatever the plan's own duration (a 24-hour pass bought 3 hours before the end gives about 3 hours; the floor is 1 minute). An end date in the past, a start after the end, or "ends with the event" without an end date are rejected. A payment already in flight when the event ends is still settled; the cap then leaves it a minimum of 1 minute of access. |
| Promo protection | Forced ON, server-side, for any plan priced at R0 — a client can't bypass this by omitting the field. Optional for a paid plan (e.g. a steep promo discount). Limits repeat claims by BOTH device fingerprint and identifier, on a reset schedule from once-lifetime up to yearly. |
Journey-based pricing is an alternate entry mode on the same form: instead of typing one name/price/duration, the tenant picks an already-mapped, per-leg-priced route from Journey presets, and the server generates one bundle per stop automatically (e.g. "Durban → Pietermaritzburg", "Durban → Bloemfontein"), each priced and timed from that route's own legs. Every other field (data cap, device limits, speed caps, FUP, promo, router restriction) still applies uniformly to all the generated bundles.
Payment gateways this flow actually supports
Confirmed directly from /api/dataplan/purchase/init and its webhook handlers in server.js — six providers: five that notify us by webhook, plus DPO Pay, which is checked by asking DPO directly instead. They split into groups that can coexist differently:
| Gateway | Checkout shape | Settlement path |
|---|---|---|
| Paystack | Inline in-page popup (card entry inside Paystack's own iframe) if the tenant has pasted a Public key; otherwise falls back to a full-page redirect via a server-to-server /transaction/initialize call. | POST /api/dataplan/purchase/webhook/:tenantId — HMAC-SHA512 signature over the raw body, timing-safe compared. |
| PayFast | Full-page redirect only (PayFast's classic integration has no inline/popup equivalent). | POST /api/dataplan/purchase/webhook/payfast/:tenantId — PayFast ITN signature verified against the tenant's own passphrase. |
| Yoco | Full-page redirect via a server-to-server /api/checkouts call. | POST /api/dataplan/purchase/webhook/yoco/:tenantId — Svix-style signature (webhook-id/timestamp/signature headers, HMAC-SHA256), plus a 3-minute replay window check. |
| SnapScan | QR code only — no redirect or server call at checkout start; the guest scans with their own SnapScan app. strict=true is passed, SnapScan's own anti-tamper flag against underpayment/replay. | POST /api/dataplan/purchase/webhook/snapscan/:tenantId. |
| Zapper | QR widget only (Zapper's own embeddable JS), same no-redirect shape as SnapScan. | POST /api/dataplan/purchase/webhook/zapper/:tenantId. |
| DPO Pay | Full-page redirect to DPO's hosted payment page (payv3.php) after a server-to-server createToken. The only gateway here that can charge Namibian Dollars; a tenant's Currency setting decides whether the transaction is created in NAD or ZAR. The payment link expires after 10 minutes if unpaid. | No webhook. verifyToken is called whenever the guest returns (the status poll), when they reopen the portal or tap Buy again (_dpoReconcilePendingForIdentifier), and from the background sweep every 15 minutes. Only DPO's result code 000, in the same amount and the same currency as the purchase row, settles a purchase. |
Paystack, PayFast, and Yoco are mutually exclusive in the dashboard's connect flow — at most one of those three is ever status='verified' for a tenant at a time, since they're all "cards." SnapScan and Zapper are QR-scan methods, not cards, so they can coexist with a card gateway and with each other — a tenant can legitimately offer three live payment options on one connect screen. DPO Pay can also run alongside a card gateway; when the tenant's Currency is NAD, the guest is offered DPO first, otherwise it is tried last so it never displaces a gateway the tenant already uses. A guest also never needs a card at all if a 1Voucher/OTT balance already covers the price (panel 1) — that path (/from-balance) skips every external gateway entirely.
Underpayment-bypass protection — a real, previously-fixed CRITICAL bug
Every settlement path — all five webhooks, the DPO status checks and the reconciliation sweep (panel 5) — funnels through one shared function, _settleDataPlanPurchase, and that function treats "a real payment happened" and "the right amount was paid" as two separate questions that must BOTH be true before access is granted.
Why this matters specifically for Paystack's inline checkout: the in-page popup hands the guest's own browser a publishable (Public) key and lets it call Paystack's widget directly. Nothing before the webhook ever tells Paystack what the real price was from a source the guest's browser couldn't tamper with — so without an independent re-check, a guest could in principle complete a genuine payment for a deliberately reduced amount (e.g. R1 against a R99 plan) and Paystack would still report success, because "a real payment happened" was the only thing being checked.
The actual, current check in _settleDataPlanPurchase:
expectedAmountKobois computed from the purchase row's own storedamount(set server-side at/purchase/inittime, before the guest ever reaches a payment screen) — never from anything the gateway's callback claims the price should have been.paidAmountKobois the amount the gateway's webhook (or, for the reconciliation sweep, a direct server-to-server verify call) actually reports as paid.- DPO Pay adds a second check. DPO answers HTTP 200 to everything (even bad credentials), so only the XML result
000counts as paid, and the currency DPO reports must equal the currency the purchase was created in. A payment made in ZAR against a NAD purchase (or the reverse) is failed and never grants access. An over- or under-payment (002) is never auto-settled: it is marked "DPO needs review" for a person to look at. - If
paidAmountKobo < expectedAmountKobo, the purchase is markedstatus='failed'with a logged reason, adataplan_webhook_amount_mismatchsecurity event is recorded, and access is never granted — regardless of what the gateway's own success flag said.
The same discipline is deliberately repeated, independently, in the separate end-customer subscriber-checkout PayFast webhook (comment explicitly references this as "the same CRITICAL bug class" already fixed here) — underpayment re-verification is now a standing pattern across every payment surface on the platform, not a one-off patch.
Crash-safety — what happens if the server dies right after a successful payment
Before this was fixed, a process crash between "Paystack/PayFast confirms the payment" and "the code that actually grants network access runs" could leave a guest genuinely charged with no automatic way to ever get connected. The fix adds two columns to tenant_data_plan_purchases — grant_params (jsonb) and access_granted_at (timestamptz) — and changes what each status transition means:
- Step 1 — claim. When a webhook first reaches a
pendingrow and the amount check (panel 4) passes, the row is atomically updated tostatus='paid'AND the exact grant plan — duration, journey preset/stop if applicable, whether strict duration is skipped — is snapshotted intogrant_paramsin that same write. The update is conditioned on.eq('status','pending'), so if two callers (e.g. the webhook and the reconciliation sweep) race for the same row, only one of them gets a non-null row back; the other seesalready_settledand does nothing further. - Step 2 — grant. Only after that claim succeeds does the function call
recordPurchase(the function that actually gives the guest a session with data/duration/speed) and, as the real completion marker, setaccess_granted_at. - The crash gap this closes: if the process dies between step 1 and step 2, the row is left sitting at
status='paid'withaccess_granted_atstill null — a real, charged guest with no access and no in-memory state left to recover from._runDataPlanReconciliationSweepruns on an interval and queries exactly this condition (status='paid',access_granted_at IS NULL,paid_atolder than a 2-minute floor so it never races a normal in-flight completion). For each row it finds, it calls_settleDataPlanPurchaseagain — which detects the'paid'-with-grant_paramscase and replays the EXACT previously-committed grant (not re-deriving it, since a journey preset could have been edited in the meantime) rather than guessing what the guest originally bought. - A separate, older recovery path handles the "webhook itself never arrived at all" case (tenant's webhook URL misconfigured, network partition): rows still
pendingafter 10 minutes are checked directly against Paystack's own transaction-verify API, up to a 48-hour abandonment cutoff, after which they're markedfailedrather than polled forever. - DPO Pay has its own recovery path because it never sends a webhook. A guest whose phone died or connection dropped after paying is settled the moment they reopen the portal (
/api/me/usagetriggers the check) or tap Buy again, and the 15-minute sweep catches anyone who never returns. A payment DPO reports as cancelled or declined (901/904) is shown as failed but re-checked for about 45 minutes in case the guest used DPO's "Retry Payment"; an expired payment (903) is final. Tapping Buy again while a payment is still open sends the guest back to the same DPO link instead of creating a second charge.
Disclosed limitation: the grant-only retry step (step 2's recovery) has no atomic claim of its own before calling recordPurchase — safe today because this app runs as a single process, but if the sweep ever ran as two concurrent processes, both could fetch the same stuck row before either sets access_granted_at, and both would grant it. This is explicitly flagged in the code as something to revisit (an intermediate "granting" sentinel) before this sweep is ever run across more than one instance.
Static IP / reserved addressing — not a Data Plan feature
Worth stating precisely, since the two features sit right next to each other in the dashboard and are easy to conflate: static/reserved IP addressing exists on this platform, but it belongs to the separate Subscriber accounts feature (tenant_subscription_plans / tenant_end_customers, id #end-customers in the dashboard) — recurring, login-based accounts billed on a cycle, not the one-off card purchase a Data Plan is. A subscription plan has its own "Reserve a static IP for subscribers on this plan" checkbox (static_ip_enabled); tenant_data_plans has no equivalent field at all, and _validateDataPlanBody never reads or writes one. A guest buying a one-time Data Plan never gets a reserved IP through that purchase, by design — that's a recurring-subscriber benefit.
Congestion / outage alerting — router-level, not per-plan
The platform does run router bandwidth-congestion alerting (_checkRouterCongestionAndAlert, a 90% usage threshold with a 75% hysteresis clear-point so it doesn't flap) and router-offline/outage SMS alerting. Both are per-router, tenant-facing operational alerts — they tell a tenant admin their router is saturated or down. Neither is scoped to, or triggered by, a specific Data Plan purchase; a Data Plan's only direct interaction with router capacity is that its own speed cap (panel 2) is applied to the guest's session the instant access is granted, and that a guest already over the tenant's Fair Usage Policy threshold gets throttled speed applied instead of the plan's advertised speed (see panel 8), so a cheap top-up can't be used to repeatedly dodge FUP by re-buying every few minutes.
Edge cases and limits worth knowing
| Item | Details |
|---|---|
| R0 plans skip payment gateways entirely A plan priced at 0 (or left blank | Number('') evaluates to 0 the same way) is a genuinely free bundle: no gateway call happens at all, and promo protection is force-enabled so the same device/identifier can't repeatedly re-claim it. Enforced atomically via a claim_promo_purchase database RPC with a row lock, closing the race where two simultaneous requests under two different accounts on the same device could otherwise both win a once-in-a-lifetime free promo. |
Device-count and promo checks happen twice Once at /purchase/init time (no point charging a guest for a device slot about to be refused), and again inside _settleDataPlanPurchase at the actual grant moment | Closing the race window where two devices under the same identifier both pay at nearly the same instant. |
| Duplicate-purchase protection on retry | If a guest's connection drops right after paying and they hit "Buy" again, /purchase/init checks the last 5 purchases for this plan+identifier: a pending row younger than 10 minutes, or a paid row paid within the last 5 minutes, blocks a second charge and instead resumes tracking (or immediately confirms) the original purchase. |
| Plan deleted mid-flight | If a tenant deactivates/deletes a plan between a guest starting checkout and the webhook landing, settlement fails cleanly with status='failed', failure_reason='Plan no longer available' rather than leaving the row stuck forever — a real, if rare, race the fix explicitly closes (an earlier version of this code path left the guest stuck on pending indefinitely). |
| FUP can silently override a plan's advertised speed If a guest is already over the tenant's fair-usage threshold from an earlier, still-active grant, a brand-new Data Plan purchase does NOT reset them to full speed | The throttled FUP speed is applied instead of the plan's own down_kbps/up_kbps, closing a path where a guest could buy a cheap top-up every few minutes to keep resetting to full speed indefinitely. |
| The low-data nudge only reaches an open tab The "Top up now" banner (default threshold 20% remaining, tenant-configurable) only exists on the portal page itself | Once a guest has real internet and closes that tab, nothing routes their ordinary browsing back through the app, so there's no way to show them anything further. An optional SMS nudge (off by default, spends the tenant's own SMS credit) closes that specific gap, skipped for email-only guests and rate-limited to once per 6 hours per low-data spell. |
| Card data never touches ISN's own servers for any of these six gateways | Paystack's inline flow is Paystack's own iframe, PayFast/Yoco/DPO Pay are full redirects to the provider's own hosted page, and SnapScan/Zapper are QR codes the guest scans from their own banking/wallet app. |
