# 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/<jwt>` 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.**
