# Prepaid Service Model — Design (design-first, not yet built) Status: **DRAFT for review** · 2026-06-16 · grounded in an infra audit of the hub/ERPNext/F/RADIUS/OLT stack. Goal: offer a **prepaid** internet service. When the paid-through date lapses, give a **3-day grace** (full speed), then **reduce speed**, then **disable** internet — all reversible instantly on top-up. --- ## 0. The critical reality (read this first) **Today there is ZERO automated network enforcement.** Marking a service `Suspendu`/`disabled` in F or ERPNext only changes records and what staff see — no code cuts or restores a customer's internet. Verified across the whole hub + ops app + legacy bridge. **The actual speed/access lever is RADIUS:** - A FreeRADIUS + MySQL server at `10.5.2.25` (db `radiusdb`). Subscriber auth = `radcheck` (Cleartext-Password) + `radusergroup` (policy class). The group name (e.g. `residentiel`) is the **speed bucket** — FreeRADIUS reply attributes for that group define the rate limit. - Per-service RADIUS creds live on the service record: legacy `service.radius_user`/`radius_pwd`, mirrored to ERPNext `Service Subscription.radius_user/radius_pwd`. ERPNext `speed_down`/`speed_up` are **descriptive only** (not enforced). - **The hub has no RADIUS code at all** — no `radiusdb` connection, no CoA/Disconnect. **Implication:** the prepaid *ledger + grace state machine* is straightforward to build in the hub/ERPNext. The hard part — and the real prerequisite — is a **RADIUS control plane**: write `radusergroup` + push a live change to the subscriber session (CoA/Disconnect) on the BNG/NAS. That actuator must be built, and the BNG/NAS that terminates PPPoE/IPoE sessions (for CoA) is **not identified anywhere in the repo** — only the FreeRADIUS DB host is known. ### Enforcement levers, ranked | Lever | State today | Use | |---|---|---| | **RADIUS group reassign + CoA/Disconnect** | Not built; `radiusdb`@10.5.2.25 known, BNG unknown | **Primary** — matches how speed already works | | OLT ONU service-profile (`profileid`) swap via n8n `dostuff` PATCH | Contract exists, not wired (hub only calls GET) | Secondary GPON-layer throttle | | OLT ONU enable/disable | CLI strings generated (`provision.js`), not executed; SNMP read-only | Disable fallback via n8n SSH | | TR-069 `setParameterValues` (rate-limit/disable WAN) | Raw `/tasks` proxy exists (`devices.js:523`); no caller; CPE param support unverified | Tertiary, if CPE model supports it | | Modem GUI (modem-bridge) | Read-only Playwright scraper | Not a control lever as built | --- ## 1. Customer flow & state machine ``` top-up (any state) ─────────────────────────────┐ ▼ ACTIVE ──paid_through passes──▶ GRACE (3 d, full speed, notify) ──▶ THROTTLED ──▶ SUSPENDED ▲ (full speed) │ remind day 1/2/3 (slow group) (walled-garden / reject) └──────────────────────── top-up restores group + CoA ◀───────────────────────────┘ ``` - **ACTIVE**: paid_through_date in the future. Normal RADIUS group. - **GRACE** (`0 < days_past ≤ 3`): full speed, escalating notifications (SMS + email + portal banner). - **THROTTLED** (`3 < days_past ≤ disable_after`): RADIUS group → `residentiel-throttle` (e.g. 1 Mbps) + CoA to apply live. - **SUSPENDED** (`days_past > disable_after`): RADIUS → walled-garden/reject + Disconnect-Message. - **Top-up** from any state: extend paid_through_date, restore full group, CoA → back to ACTIVE. All transitions **idempotent** (act only on change) and **audited** (every customer-impacting action logged) — this is the hard lesson from the PPA double-charge incident. --- ## 2. Data model (the prepaid ledger — does not exist today) New ERPNext doctype **`Prepaid Account`** (hub/OPS-owned; see §8 decision on F vs ERPNext): - `customer` (link), `service` (link to Service Subscription), `radius_user` (denormalized for the actuator) - `daily_rate` (derived from the plan price ÷ cycle, or a flat prepaid daily price) - `paid_through_date` (the anchor — everything derives from this) - `state` (Active / Grace / Throttled / Suspended) - `grace_days` (default 3), `disable_after_days` (e.g. 10), `throttle_group`, `active_group` - `last_enforced_state` + `last_enforced_at` (idempotency) Top-ups recorded as ERPNext **Payment Entry** (existing, idempotent by `reference_no`) and **extend `paid_through_date`** by `amount / daily_rate`. --- ## 3. Enforcement actuator — new hub module `lib/service-control.js` ``` setGroup(radius_user, group) // UPDATE radusergroup.groupname (radiusdb @ 10.5.2.25) coa(radius_user, action) // CoA (apply new policy) or Disconnect (drop session) → BNG/NAS throttle(svc) / suspend(svc) / restore(svc) // setGroup + coa, idempotent + audited ``` - New env `RADIUS_DB_URL` (mysql @ 10.5.2.25/radiusdb) + `BNG_HOST`/`BNG_COA_SECRET` (OPEN — see §8). - If live CoA is infeasible at first: change the group anyway; it applies on next re-auth/lease renewal (slower, but functional). CoA is the upgrade for instant effect. - Fallbacks wired behind the same interface: OLT `profileid` swap (n8n `dostuff` PATCH) and TR-069 set-param. --- ## 4. Daily cron (gated, dry-run first) Hub cron (once daily, low-traffic hour): for each Prepaid Account, compute `days_past = today − paid_through_date`, derive target state, and **only act if it differs from `last_enforced_state`**. Emit notifications on entering GRACE (day 1/2/3), THROTTLED, SUSPENDED. Safety (mandatory, per PPA-incident lessons): - `PREPAID_ENFORCE` env: unset/`report` = log intended actions, change nothing; `enforce` = act. (Same pattern as `PAYMENTS_AUTH`.) - Idempotency on `last_enforced_state`; never re-disable/re-charge. - Per-run cap + kill switch; structured audit log of every state change. --- ## 5. Top-up (reuse existing Stripe flow — already usable) - New `POST /prepaid/topup` (or reuse `/payments/checkout-product`): ad-hoc Stripe Checkout for $X. `ensureStripeCustomer` already reuses F's authoritative `cus_…`, so no double customer. - On payment success (webhook → `recordPayment`, idempotent): credit balance → extend `paid_through_date` → if state was Throttled/Suspended, `restore()` (group + CoA). - Saved-card off-session top-up possible via `POST /payments/charge` for auto-renew prepaid. --- ## 6. Read-side (extend what exists) - `GET /collab/service-status` → add a `prepaid` block: `{state, balance_days, paid_through, daily_rate}`. - Client `/diag/` page → show "X days remaining / topped through DATE", state banner, **Top-up now** CTA. --- ## 7. Phasing (value early, risk late) - **Phase 1 — ledger + top-up + reminders, NO enforcement** (SAFE, no network actions): `Prepaid Account` doctype, Stripe top-up, balance/days on service-status + `/diag`, grace/expiry notifications. Delivers prepaid billing + dunning immediately with zero risk. - **Phase 2 — actuator in report mode**: `lib/service-control.js` connects to `radiusdb`, logs what it *would* change. Identify the BNG/NAS for CoA. No live changes. - **Phase 3 — throttle (gated)**: define `residentiel-throttle` group attrs in FreeRADIUS; enable group reassign + CoA for the THROTTLED transition. - **Phase 4 — disable (gated)**: walled-garden/reject group + Disconnect for SUSPENDED. --- ## 8. Open decisions (need your input before Phase 1) 1. **Balance home — ERPNext/hub-owned ledger (recommended) vs F.** F is authoritative for *postpaid* billing but has no prepaid concept; bolting prepaid onto legacy F is heavy. Recommend the hub/ERPNext owns the prepaid ledger, with prepaid services flagged distinctly. (This is a deliberate divergence from "F authoritative" — for a model F doesn't have.) 2. **The BNG/NAS** that terminates subscriber PPPoE/IPoE sessions — IP + CoA shared secret? Without it, enforcement relies on re-auth/lease expiry (minutes-to-hours lag) instead of instant CoA. 3. **Throttle target** — a new FreeRADIUS group (`residentiel-throttle`, e.g. 1 Mbps)? Its reply attributes must be defined on the RADIUS server. 4. **Disable UX** — hard reject (no internet) vs walled-garden (captive redirect to the top-up page). Walled-garden is far better UX but needs a captive portal. 5. **Windows** — grace = 3 d (given). Gap before disable? (e.g. throttle at d3, disable at d10.) 6. **Who is prepaid** — new signups only? Opt-in per service? A `billing_mode = prepaid|postpaid` flag on the service. --- ## Key files (for whoever builds this) - `services/targo-hub/lib/payments.js` — Stripe (`ensureStripeCustomer`, `checkout-product`, `charge`, `recordPayment`); reuse for top-ups. - `services/targo-hub/lib/ticket-collab.js` — `serviceStatus()` (read-side), `dostuffStatus()` (n8n `dostuff`, GET only). - `services/targo-hub/lib/devices.js` — TR-069 `/tasks` passthrough (`:523`). - `services/targo-hub/lib/olt-snmp.js` (read-only), `lib/provision.js` (OLT CLI gen, not executed), `lib/client-diag.js` (`/diag`). - `docs/reference/legacy-wizard/account_wizard.php:142-169` — how RADIUS users/groups are provisioned today (the template for the actuator). - RADIUS: FreeRADIUS + MySQL `radiusdb` @ `10.5.2.25` (creds in the legacy wizard). **BNG/NAS for CoA: unknown — must be located.**