Subscribers
How recurring, ISP-style WiFi accounts work on ISN Free WiFi — subscription plans, subscriber accounts, static IP reservations, automatic recurring billing with retries and cancellation, the abuse protections this feature reuses, and the cross-tenant plan-scoping bug that was found and fixed along the way.
What a subscriber is (and what it isn't)
A subscriber is a recurring account on tenant_end_customers — your own regular customer on a fixed, ISP-style line, billed automatically every cycle once a card is on file. It's a distinct concept from the two other "a guest gets access" paths this platform has:
| Concept | Table | What it is |
|---|---|---|
| Subscriber | tenant_end_customers | Your own paying customer on a recurring plan. Logs in by being recognized (device/session), not a password, unless a tenant assigns one manually. Billed every cycle automatically. |
| Data Plan purchase | tenant_data_plan_purchases | A one-off, single payment for a fixed bundle of data/time. No recurring billing, no ongoing account relationship — once the bundle is used or expires, that's it until the guest buys again. |
| Internal Account | tenant_internal_accounts | A named login you (the tenant) provision directly — staff, residents, SSO users. No billing at all; access is managed by account status, not a payment cycle. |
Subscribers and Internal Accounts share one thing neither Data Plans nor vouchers get: they're the only two account types eligible for a static IP reservation, because both represent a persistent, ongoing device relationship rather than a bounded, often-anonymous purchase. On the captive portal, starting a subscription is a separate button in the "Buy Internet Access" modal, grouped apart from one-off vouchers and data plans, so it's obvious to a guest which one commits them to a recurring charge.
Subscription plans
Configured on tenant_subscription_plans via POST /api/isn/subscription-plans (validated by _validateSubscriptionPlanBody). Each plan has:
| Item | Details |
|---|---|
| name and price_zar | Required; price must be a valid number ≥ 0. |
| billing_interval | weekly or monthly only (default monthly). This directly sets how many days get added at each renewal: 7 for weekly, 30 for monthly. |
| Data allowance | Either unlimited_data: true, or a bundle_mb > 0. The server rejects a plan with neither set ("Set a data bundle size, or mark this plan unlimited"). |
| down_kbps / up_kbps | Optional per-plan speed caps that override the tenant-wide bandwidth defaults, the same relationship a Data Plan's own speed setting has to the tenant's defaults. |
| max_concurrent_devices | Minimum 1. Setting it above 1 server-derives allow_add_device = true (not client-settable) so a second device can actually join through the existing "Add a device" pairing-code flow — without this, device #2 would be silently refused even though the device count nominally allowed it. |
| static_ip_enabled | Turns on the static-IP option for every subscriber on this specific plan. See Static IP below. |
| fup_threshold_gb / fup_throttle_down_mbps / fup_throttle_up_mbps | Optional per-plan Fair Usage Policy override, validated by the same _validateFupOverrideFields helper Data Plans use. Leaving all three blank inherits the tenant-wide FUP rule exactly; it only ever applies while the tenant-wide FUP is switched on at all. |
A plan is deactivated (PATCH with {active:false}), not deleted, to stop new signups while leaving existing subscribers untouched. DELETE /api/isn/subscription-plans/:id is blocked outright (HTTP 409) while any tenant_end_customers row still references that plan_id — "Cannot delete a plan with subscribers on it — deactivate it instead."
Subscriber accounts — two ways in, one management view
A subscription starts one of two ways:
| Item | Details |
|---|---|
| Self-service via POST /api/subscriber/checkout/init | A guest picks a plan from the captive portal's "Buy Internet Access" modal. If no tenant_end_customers row exists yet for their identifier, this call creates one on the spot with status: 'trialing' and no password (password_hash: null — the column was altered with DROP NOT NULL specifically for this), then runs a real PayFast checkout. |
| Dashboard-assigned (cash) via POST /api/isn/end-customers | For a customer who paid outside the platform (cash, EFT, in person). The tenant searches the real, already-recognized guest directory (the same one Guest Activity and device management use) by phone/email/name and picks a result; there is no field to type in an arbitrary new identifier. This deliberately never invents a fresh login — it attaches the plan to the guest's real existing identity so they're recognized automatically next time they connect. planId is required. |
The Subscribers table (GET /api/isn/end-customers, capped at 200 rows, searchable by identifier/display name) shows each subscriber's plan, status (trialing / active / past_due / canceled / suspended), next_billing_date, and assigned static IP if any. A cash-paid subscriber has no card on file and is never touched by the automatic billing sweep (see panel 5) — the tenant renews them manually with POST /api/isn/end-customers/:id/mark-paid, which logs a charge_type: 'cash' row on tenant_end_customer_charges, extends next_billing_date by one interval (from whichever is later — today or the existing date, so an early renewal stacks rather than shortens, and a late one starts fresh instead of compounding missed cycles), reactivates the account if it had lapsed, and re-grants access on every device already on file for that identifier via _grantEndCustomerAccessToKnownDevices.
Deletion: a subscriber never actually charged (DELETE /api/isn/end-customers/:id) is removed outright. One with real charge history can't be — the FK from tenant_end_customer_charges to tenant_end_customers deliberately does not cascade, so the delete fails with a 409 and the message "This subscriber has billing history and can't be deleted — set their status to Canceled instead." Canceling keeps the audit trail intact while stopping future billing.
Static IP / reserved addressing (PPPoE-style)
A static DHCP reservation layered onto an existing subscriber account — not a separate connection type or a real routable/public IP, and not a PPPoE tunnel. It exists for a device that needs the same LAN address every time (port forwarding, a security camera, a small office device). Available only when the subscriber's plan has static_ip_enabled turned on (checked server-side on assignment, not just hidden client-side).
Assigned via POST /api/isn/end-customers/:id/static-ip with an IPv4 address, a MAC address, and which router the subscriber connects through:
- The IPv4 address is validated with a strict per-octet regex bounding each octet to 0–255 — the looser regex this replaced accepted garbage like
999.999.999.999as "valid" and forwarded it straight to the router. - The MAC must match
AA:BB:CC:DD:EE:FFformat exactly, uppercased. _routerBelongsToTenantconfirms the router is actually this tenant's before anything is pushed.
Once validated, it's pushed as a real DHCP static-lease command: a MikroTik script (/ip dhcp-server lease add/set, tagged with comment presida-subscriber-static) or an OpenWRT UCI script (uci set dhcp.<mac-derived-section>, restarting dnsmasq) depending on router vendor — only these two vendor families are automated today; any other vendor gets a clear refusal rather than a silent no-op. The command is logged to router_cli_audit_log (same audit trail Remote CLI uses, purpose static_ip_assign) and queued via queueRouterExec, applying the moment the router next checks in. Internal Accounts get the same mechanism with no plan gate (each internal account is already configured individually), and an SSO login inherits it automatically since an SSO login is a real internal account under the hood. It deliberately does not extend to vouchers, data plans, surveys, ads, or roaming — those are bounded/anonymous grants or (for roaming) span networks with no shared address space to reserve on.
Recurring billing, retries & cancellation
Card-based subscriptions ride the tenant's own verified PayFast gateway (tenant_payment_gateways, provider='payfast', status='verified') — the same connection already used for one-off data-plan checkouts, nothing separate to set up. The first checkout (POST /api/subscriber/checkout/init) uses tenantPayfast.buildTokenizationCheckoutUrl with PayFast's subscription_type=2, so it both takes the first payment and tokenizes the card; the webhook (POST /api/subscriber/checkout/webhook/:tenantId) stores the encrypted token on tenant_end_customer_cards (upserted, one row per subscriber). The raw card number is never stored by this platform.
The recurring sweep — _runEndCustomerBillingCycle(), run on the same interval as ISN's own tenant-billing job: every 6 hours, plus once immediately on server startup.
| Stage | What happens |
|---|---|
| Due charge | Every status='active' subscriber whose next_billing_date has arrived is charged via tenantPayfast.chargeSavedCard against their stored token. Cash-paid subscribers (no card on file) are never charged by this sweep — they renew through "Mark as paid", and if their date passes unpaid they go past_due and then follow the same 3-day grace countdown below. |
| Reminder | Once per billing cycle, an SMS goes out to every active subscriber whose billing date is within 3 days: card subscribers are told the date their card will be charged, cash subscribers are told payment is due. It counts against the tenant's normal daily SMS cap. |
| Charge succeeds | Status stays/returns to active, next_billing_date extends by the plan's interval, payment_failed_at/payment_retry_count reset to null/0, and an SMS receipt goes out with the amount and next billing date. |
| Charge fails | Status flips to past_due, payment_failed_at is set (only on the first failure — it's not overwritten on each retry), payment_retry_count increments, and an SMS tells the subscriber payment failed and access pauses in 3 days if unresolved. |
| Retry cadence | A past_due subscriber is retried every sweep run, gated by a 6-hour cooldown (END_CUSTOMER_RETRY_COOLDOWN_MS) measured from their last charge attempt — so retries effectively happen roughly every 6 hours, not on every sweep tick. |
| Grace period expires | END_CUSTOMER_BILLING_GRACE_PERIOD_MS = 3 days from the first failure. Once elapsed with the account still past_due after a retry attempt, status flips to suspended, _cutEndCustomerAccess actually cuts the router-side access for every one of the subscriber's authorized devices, and a final SMS explains why. A suspended card subscriber keeps being retried every 6 hours for up to 14 days; the moment a charge succeeds they are set back to active and their known devices are reconnected automatically. A cash subscriber is reconnected when the tenant uses "Mark as paid", or sets their status back to Active (known devices are reconnected then too). |
Cancellation — POST /api/subscriber/cancel, callable by the subscriber themselves from the captive portal's "My Subscription" menu item ("Cancel subscription"), or by the tenant setting status to canceled from the dashboard. This only stops future billing: a subscriber's access window is capped strictly to their own next_billing_date server-side and can never be extended by re-logging in, but it also is never cut short early by canceling — access keeps running exactly until the already-paid-for period ends, then quietly doesn't renew. An SMS confirms the cancellation and states the date access keeps running until.
Subscriber self-service: My Subscription
Guests who have a subscription get a My Subscription item in the captive portal's menu, directly below My Usage. It appears for every signed-in guest: a guest with a subscription sees everything below, anyone else sees a short note that they have no subscription yet (or, if the device could not be confirmed, a note to log out and in again). It is the one place for everything about a subscription, so My Usage and My Account no longer repeat any of it.
| Feature | How it works |
|---|---|
| Status and next billing | Plan, status (Active, Payment failed, Paused for non-payment, Paused), next billing date, any plan change waiting for the next renewal, and the date a pause ends. |
| Pay now | Shown when payment failed or access was cut for non-payment. Starts the same secure PayFast checkout as signing up; once it succeeds the subscriber is active and their devices come back online. |
| Change plan | Pick another of the tenant's active plans. The change takes effect at the next renewal (no pro-rating): until then the subscriber keeps the plan they paid for. Choosing the current plan again cancels a waiting change. |
| Pause | 1, 2 or 3 months. Access continues to the end of the period already paid for, then billing stops and access pauses until the chosen date. After that, billing resumes from that day. A subscriber can cancel a scheduled pause, or resume early from the paused state. |
| Payments and invoices | Lists up to the last 36 payments (failed ones are marked). Each successful payment has an Invoice (PDF), and a month picker produces a Monthly statement (PDF). |
Tax invoices. In the ISN Portal, under Subscribers, Invoices and month-end statements holds the registered business name, address, VAT number and VAT rate (default 15%). With a VAT number saved, documents are headed TAX INVOICE and show amount excluding VAT, the VAT amount and the total (prices are treated as VAT-inclusive); without one they are headed RECEIPT. Invoice numbers are unique per payment (date plus a payment code). Check with your accountant whether your jurisdiction requires strictly sequential numbering.
Month-end billing run. The same section can download one PDF containing a statement page for every subscriber who paid in the chosen month (up to 300 subscribers per run).
Security. Every self-service route checks that the caller's session owns the identifier (_sessionOwnsIdentifier) and only ever reads or writes that one subscriber's own row inside the current captive's tenant. Invoice downloads are rate-limited per subscriber and a payment that belongs to someone else, or that did not succeed, returns "not found".
Security & abuse prevention
| Item | Details |
|---|---|
Session-ownership checks, not bare identifiers Both checkout init and cancellation call _sessionOwnsIdentifier(req, identifier) before touching a tenant_end_customers row | The same check guarding data-plan checkout — so one guest can't view, cancel, or start a checkout against another guest's subscription just by supplying their phone/email. |
| Dashboard-assigned subscribers are never fabricated logins The cash-assignment flow only attaches a plan to a guest already found via the real directory search | It can't create a disconnected account with a tenant-invented password that the actual guest could never reach. |
| Webhook signature verification | tenantPayfast.verifyItnSignature(req.body, creds.passphrase) runs before anything in the PayFast notification is trusted; a mismatch is logged as subscriber_checkout_webhook_signature_mismatch and rejected with HTTP 401. |
Amount re-verification, not just a status string The webhook independently checks amount_gross against the charge's real expected price (expectedAmountRands - 0.01 tolerance) before marking anything successful | The same underpayment-bypass class of bug found and fixed earlier on the data-plan checkout path. A mismatch is logged as subscriber_checkout_webhook_amount_mismatch and the charge is marked failed instead. |
| Idempotent settlement | The success update uses .eq('status','pending') combined with .select().maybeSingle() rather than a bare update, so two near-simultaneous duplicate ITN deliveries can't both pass and both grant access or send a receipt twice — only the delivery that actually flips the row from pending gets a non-null result. |
| Duplicate-checkout guard | Starting a second checkout while one is still pending is blocked for 10 minutes (to stop an accidental double-charge from a guest who assumes a slow confirmation means it failed), with an explicit supersedePrevious: true "try again now" override for a guest who genuinely canceled out of PayFast. |
| Everything else that protects paid access elsewhere applies unchanged | The account's own Fair Usage Policy (with this plan's optional override), Fair Pause protection if a router goes offline mid-cycle, tenant-wide device-bandwidth caps, and the concurrent-device limit enforced through the same "Add a device" pairing flow as every other multi-device plan. |
The cross-tenant plan_id fix
Plan UUIDs are not secret — GET /api/subscriber/plans publicly returns a tenant's own plan IDs to any guest on that tenant's captive portal (so the "Buy Internet Access" modal can list them). That meant an unscoped write to a subscriber's plan_id could, in principle, be made to point at a different tenant's plan UUID. This session's own security audit found this live and fixed it in three places:
| Item | Details |
|---|---|
| PATCH /api/isn/end-customers/:id | When body.planId is set, the handler now first confirms the plan belongs to this tenant (.eq('id', body.planId).eq('tenant_id', tenantId)) before writing it; otherwise it returns "Plan not found." Without this, tenant A could point their own subscriber at tenant B's plan — its price, data allowance, and FUP configuration. |
| POST /api/subscriber/checkout/init (self-service first-time signup) | The requested planId is verified against the signing-up tenant before the new tenant_end_customers row is ever inserted, because the later tenant-scoped plan fetch (used for pricing) runs after the insert and would be too late to stop a foreign plan_id from landing on the record in the first place. |
| DELETE /api/isn/subscription-plans/:id | The "is this plan still in use" check (tenant_end_customers lookup by plan_id) was unscoped by tenant_id. A UUID collision across tenants is astronomically unlikely, but the real risk this closed is the same class of bug: without the tenant_id filter, a stray cross-tenant reference from the bugs above could have permanently blocked tenant B from deleting their own plan because of tenant A's leftover subscriber pointer. |
All three now scope every tenant_subscription_plans lookup involved in writing or checking a plan_id to eq('tenant_id', tenantId), so a plan reference can never cross a tenant boundary regardless of which of these three paths it's reached through.
Payment history — the combined ledger
GET /api/isn/payments merges two differently-shaped tables into one normalized transaction list for the dashboard: tenant_data_plan_purchases (one-off data-plan buys; statuses paid/failed/expired/pending) and tenant_end_customer_charges (every subscriber charge — signup, renewal, retry, and cash; statuses success/failed/pending). Since the two tables use different status vocabularies, the merge is done in JS rather than a SQL UNION, normalizing paid → success for consistency.
Each subscription-side row is labeled by the endpoint as Subscription signup only when charge_type === 'signup', and Subscription renewal for every other charge type. In practice, a subscriber's actual first-payment charge is recorded with charge_type: 'first_payment' (not 'signup') by the checkout-init endpoint, so it currently falls into the "Subscription renewal" label along with genuine recurring (automatic sweep) and cash (manual "Mark as paid") charges — a cosmetic label mismatch worth knowing about if a subscriber's very first charge shows up as a "renewal" in this ledger. All charge types appear as real transactions either way, so a cash renewal shows up identically to a card charge for reporting purposes. The endpoint supports a type filter (data_plan / subscription / all), a status filter, free-text search, a days window (1–365, default 30), and pagination (page/pageSize, capped at 100 per page), with each query independently capped at 500 raw rows before normalization.
