Revenue-Share Partners
How a tenant splits one router's ad, voucher, and data-plan revenue with whoever actually hosts it — a taxi driver, a fleet owner, or a venue the tenant doesn't run day to day. Covers the admin side (adding a partner, groups, cutoff, payouts) and the partner's own restricted dashboard. This is a different feature from Partner Voucher Reselling (a partner selling prepaid vouchers for cash) — see that page for the reseller side.
What a revenue-share partner is
A Partner is a row in the platform's partners table, scoped to one tenant. It isn't a login on its own — it becomes a real, usable account only once the person sets a password through their invite link (see Panel 6). The business model: a router physically sits somewhere the tenant doesn't directly operate — a taxi, a fleet of them, a spaza shop, a venue — and whoever hosts it gets a percentage cut of everything that router earns: ad views, voucher redemptions, and data-plan purchases.
One router belongs to at most one partner at a time, by design — revenue from a router is never split between two different partners. A single partner can host as many routers as the tenant assigns them, and their dashboard combines earnings across all of them into one view. The split math is never a second, separate calculation: it's computed directly from the same per-router revenue numbers the tenant's own "Revenue by router" report uses (_computeRevenueByRouter), so a partner's cut can never quietly drift out of sync with what the tenant sees for that router.
This is distinct from a team member (an employee of the tenant, with an admin/IT/financial/view-only role) and from an advertiser (a business paying the tenant to run ads) — a revenue-share partner earns money FROM the tenant's router revenue, rather than working for the tenant or paying into it.
Adding a partner
Created via the Captive Dashboard's Partners tab → Add Partner, which calls POST /api/business/admin/partners. The form (and the endpoint) takes:
| Field | Rule |
|---|---|
| Name | Required. |
| Phone | At least one of phone or email is required. Any format works (+27..., 0..., spaced) — normalized server-side to one consistent shape so the same number is always recognized, whatever format it was typed in. |
| Optional if a phone is given. | |
| Group | Optional. Leaving it blank means "no group (set a custom % below)". |
| Custom split % (override) | Optional, 0–100. Used instead of or on top of a group's rate. |
| Assign router(s) | Optional and opt-in — a partner can be created with zero routers and have them assigned later via Edit. |
Every router picked is checked against two things before saving: that it actually belongs to this tenant, and that it isn't already assigned to a different partner. The actual assignment write is an atomic conditional update (.is('partner_id', null)) rather than a plain check-then-write — if two admins raced to assign the same router to two different partners at the same moment, whichever request's update runs second simply matches zero rows and the admin gets a clear "already assigned" error back, rather than the router silently ending up attributed to two partners' histories at once.
Router assignment and revenue tracking go live the instant the partner is saved — a partner_router_history row is written immediately, and earnings start accruing to that partner from that moment, regardless of whether they've ever logged in or set a password. The All Partners table (GET /api/business/admin/partners) is searchable by name/phone/email (including across +27.../0... phone-format variants) and paginated.
Partner groups & the split-percentage model
A tenant creates a named group (e.g. "Standard Drivers") with a default split percentage via POST /api/business/admin/partner-groups ({ name, splitPercentage }, 0–100), then assigns partners into it instead of setting a rate on every individual partner. The actual percentage used for any given partner is resolved by one small function, _effectivePartnerSplit(partnerRow, groupsById):
- If the partner has their own
override_split_percentageset, that number wins outright. - Otherwise, if the partner belongs to a group, that group's
split_percentageapplies. - If neither is set, the effective split is 0% — a partner with no group and no override earns nothing until one is configured.
Editing a group's percentage (PATCH /api/business/admin/partner-groups/:id) is immediate and retroactive for every current member — there's no rate-history timeline in the data model, so moving "Standard Drivers" from 20% to 22% changes both future revenue AND how that group's past earnings display from that point on, for every partner currently in it. A tenant that wants existing members to keep their original rate should leave that group alone and create a new group for new partners instead — two groups is the intended pattern for a rate change, not a special flag on the old one.
The partner's own dashboard
A partner logs in at the exact same /dashboard URL and login form as every other account on that captive, through business_accounts with role: 'partner' and is_admin: false. is_admin: false is the actual security boundary — every existing admin-only endpoint in the codebase gates on is_admin, so a partner is automatically rejected by all of them and can only ever reach their own data through the dedicated /api/partner/* endpoints, which resolve identity from the verified session (never a client-supplied partner ID).
Once logged in, the frontend recognizes the role and swaps in a completely different, restricted nav — Dashboard, Campaigns, Billing, Surveys, Routers, and the admin's own Partners tab are all explicitly hidden. A revenue-share partner's side panel shows exactly:
| Item | Details |
|---|---|
Overview (GET /api/partner/summary) | Combined earnings as Today / This week / This month / Lifetime, each computed as one aggregate query per window rather than a per-day loop (cheap even for a multi-year "lifetime"), plus the partner's status (Pending setup, Active, or Ended). |
My Routers (GET /api/partner/routers) | Only currently-assigned routers (a cut-off partner sees none here), each with live online/offline status and connected-device count from the in-memory router-metrics cache (online = seen within the last 2 minutes), 30-day earnings, and whether a driver link currently exists for it. |
Earnings (GET /api/partner/revenue) | A daily trend chart and a per-router breakdown (ad / voucher / data-plan / subscriber revenue, gross, and the partner's cut), up to 365 days back. |
My Rate (GET /api/partner/me) | The effective split percentage and whether it's inherited from a group or a personal override. |
| Settings | Shared with every other role; for a partner this is just their own password and an Active Sessions list to sign out a device they don't recognize. |
| News | The same read-only announcements feed every non-admin account sees. Unlike the old shallow summary, this isn't selectively granted per partner in the current code: the News nav item is shown to every account including every partner, with no partner-specific branch needed because the existing non-admin read-only view already applies to them. |
Driver links — the no-login QR companion
A partner (e.g. a fleet owner) is often not the person physically with the router day to day. Rather than creating a full Partner login for every driver, a partner can generate a driver link for any router currently assigned to them, from My Routers → Get link. This calls POST /api/partner/routers/:routerId/driver-view/token, which only succeeds if that router's router_registry.partner_id currently equals the caller's own partner ID — one partner can never generate a link for another partner's router. It writes a random 16-byte token onto router_registry.driver_view_token and returns a URL shaped https://<tenant-domain>/driver/<token>.
The link needs no account and no password. The page it opens (GET /driver/:token) is blocked with a plain 403 on the bare ISN marketing host, or if the token doesn't belong to whichever tenant's domain it was opened on — checked server-side before the page shell is even sent. Once loaded, it fetches live data from GET /api/driver-view/:token: that router's online status, connected-device count, today's ad views, and that partner's own earnings for that one router only (today's revenue × the same effective split percentage their own dashboard uses) — never the venue's full gross revenue, and never any other router's numbers. If the router has no partner assigned at all, the earnings figure is simply omitted rather than shown as zero or wrong.
Only one driver-link token is active per router at a time. Generating a new one overwrites the old token outright, immediately breaking any previously shared link or QR code; a dedicated revoke endpoint (POST .../driver-view/token/revoke) exists for clearing the link without immediately issuing a replacement — useful if a phone carrying the link/QR is lost or a driver leaves.
This is a genuinely different surface from the full Partner dashboard login covered in Panel 4: the driver link needs no credentials at all, shows only one router's figures (never the partner's combined multi-router view), and is branded to the tenant's own logo/colors/support contact via renderTenantHtml.
Invite channel & notifications
A new partner is sent an invite the moment they're created, by SMS, email, or both — whichever the tenant has set under Partners → Notification settings (GET/PATCH /api/business/admin/partner-settings, backed by tenants.notification_settings.partnerChannel, default both). The SMS copy sent (via _sendPartnerInvite, kind revenue_share) reads: "You've been added as a {{BRAND}} revenue partner. Set your password to see your earnings: <link>. Once done, log in anytime using your phone number as your username." — deliberately different wording from the reseller invite, which never mentions "earnings" since a reseller doesn't earn a revenue cut at all.
The invite link opens the exact same in-dashboard "set your password" overlay a newly-invited advertiser gets — it's server-rendered through the tenant's own branded dashboard, not a separate ISN-branded page, and works before the partner has ever logged in. Each channel's send is tracked independently (attempted vs. not attempted, ok vs. failed-with-reason) so a failed SMS send (e.g. "Insufficient SMS credits") surfaces to the admin as an explicit warning rather than a blanket "Success" toast that turns out to be wrong. A Resend invite button (POST .../partners/:id/resend-invite) is available only while a partner's status is still pending, and regenerates the invite token without recreating the partner or touching any revenue already tracked for them.
Day-to-day, this is deliberately not chatty — a partner isn't notified per ad view or voucher redemption, only on invite, resend, and cutoff (Panel 7).
Cutting off a partner
Cut off (POST /api/business/admin/partners/:id/cutoff) ends the arrangement. It immediately: clears partner_id on every router that partner hosted (reverting them to 100% tenant-owned until reassigned), closes every open partner_router_history row for that partner (unassigned_at = now), and sets the partner's own status to cut_off with a cut_off_at timestamp. The partner's login is not revoked — a cut-off partner can still sign in and see their full historical earnings and rate exactly as before, which matters if the arrangement is ever disputed later. A cutoff notice (email/SMS per the tenant's channel setting) is sent: "Your partner revenue-share has ended... You can still sign in at any time to see your full earnings history."
An individual router can also be released from a partner without ending the whole relationship, via POST /api/business/admin/partners/:id/unassign-router — same effect as cutoff but scoped to one router.
Reactivate (POST .../partners/:id/reactivate) undoes a cutoff. Status is recomputed rather than hardcoded: a partner who had never set a password before being cut off goes back to pending (their original invite link still works); one who already had a password goes to active. Router assignments are deliberately not restored automatically — those routers may belong to someone else by the time of reactivation, so re-guessing which ones to hand back risks a silent conflict. The response explicitly tells the admin to reassign routers manually from Edit if the partner should still host any.
Partner payouts — how settlement actually works
The platform computes and displays what a tenant currently owes out to its partners, but does not move money on the tenant's behalf. _computeTenantPartnerPayouts(tenantId, since, until) sums, across every partner whose status isn't cut_off, that partner's share (their effective split % × the gross revenue of every router they've been assigned, scoped to the chosen date range) — using the exact same _computeRevenueByRouter engine every other revenue number on the platform is built from, so it can't drift out of sync with what a partner's own dashboard shows them.
That single figure, labelled Partner Payouts, appears in two places: the root Captive Dashboard's Billing tab, and the ISN Portal's own My Revenue tab. In both places it's shown for context only — never subtracted from Total earned or Net. ISN is not a party to the actual payment; how and when a tenant settles with each partner (cash, EFT, or whatever arrangement they already run) happens entirely outside the platform. There is no built-in payout/transfer mechanism for revenue-share partners in this codebase.
Partner vs. Voucher Reseller — one table, one discriminator column
A partners row is either a revenue-share partner (this page — voucher_mode is NULL) or a voucher reseller (voucher_mode is 'cash' or 'prepaid') — never both at once, and no separate migration or table was needed to add the reseller kind; it's the same row shape with one extra nullable column acting as the discriminator. Both kinds share the same underlying account/invite/login plumbing (_createPartnerBusinessAccount, the same business_accounts row with role: 'partner', the same phone-or-email invite flow), but as of a 2026-10-01 product decision they're now fully separated everywhere a tenant or partner actually interacts with them: separate admin tabs and endpoints (/api/business/admin/partners filtered to voucher_mode IS NULL vs. /api/business/admin/voucher-resellers filtered to voucher_mode IS NOT NULL), and a completely different partner-side dashboard nav (this page's Overview/My Routers/Earnings/My Rate vs. a reseller's Sell Vouchers/Billing). A reseller never hosts a router and never earns a revenue-share cut — see the dedicated Partner Voucher Reselling documentation for how that side works.
