PHASE2_05 — Feature Dependency Graph¶
PyroRadius Enterprise Documentation | Phase 2 Last Updated: 2026-07-13 Audience: Senior Developers, Architects
SECTION 1: Module Dependency Overview¶
PyroRadius is organized around the Customer as the central entity. Every major subsystem connects through it.
PyroRadius Core Modules
CUSTOMER (hub of everything)
├── BILLING
│ ├── Invoice
│ ├── Payment
│ ├── LedgerEntry (customer + dealer accounts)
│ ├── InvoiceBatch
│ └── BillingArchive
├── RADIUS
│ ├── radcheck (auth credentials)
│ ├── radusergroup (package assignment)
│ ├── radgroupreply (speed limits)
│ └── radacct (sessions, read-only)
├── NETWORK
│ ├── NAS (router)
│ ├── OLT (fiber head-end)
│ ├── OntCache (fiber port data)
│ └── MikroTik (RouterOS API)
├── COMMUNICATIONS
│ ├── WhatsApp (messages)
│ ├── Notifications (push)
│ └── Campaigns (bulk)
├── SUPPORT
│ └── Tickets
├── AUDIT
│ └── AuditLog (every change)
└── GEO
├── Country / City / Area / SubArea
└── CustomerType / ConnectionType / ServiceType
Key coupling rules: - RADIUS is a write-target only — PyroRadius never reads radacct for billing decisions - BILLING and RADIUS are always coupled on recharge; neither runs alone - AUDIT is a one-way sink — every module writes to it, nothing reads from it during normal operations - GEO is a pure lookup layer — changes to it do not cascade to RADIUS
SECTION 2: Feature Dependency Map¶
Customer CREATION¶
Touches the following in order:
| Step | Table / Service | Operation |
|---|---|---|
| 1 | customers |
INSERT new row |
| 2 | customer_code_seq |
NEXTVAL (PostgreSQL sequence) |
| 3 | radcheck |
INSERT: Cleartext-Password attribute |
| 4 | radusergroup |
INSERT: package group link |
| 5 | radgroupreply |
VERIFY group exists (package must be pre-synced) |
| 6 | audit_logs |
INSERT: event customer.created |
No billing rows are created on customer creation. Invoice and payment rows are only created on recharge.
Customer RECHARGE (most complex flow)¶
This is the most critical path in the system. Every step must succeed atomically.
| Step | Table / Service | Operation |
|---|---|---|
| 1 | invoices |
INSERT (idempotent — duplicate guard by period) |
| 2 | payments |
INSERT new payment row |
| 3 | seq_receipt_no |
NEXTVAL (PostgreSQL sequence) |
| 4 | ledger_entries |
INSERT ×2: one customer debit, one dealer credit |
| 5 | invoices |
UPDATE paid_amount + status (lockForUpdate) |
| 6 | customers |
UPDATE expiry_date + status |
| 7 | radcheck |
UPSERT: Cleartext-Password |
| 8 | radusergroup |
DELETE + INSERT: group reassignment |
| 9 | radgroupreply |
DELETE + INSERT: speed limit group |
| 10 | whatsapp_logs |
INSERT: outbound message record |
| 11 | audit_logs |
INSERT: event customer.recharged |
If excess payment exists (overpayment), step 5 repeats for each unpaid invoice found.
Customer EXPIRY (CheckExpiry command — runs daily)¶
| Step | Table / Service | Operation |
|---|---|---|
| 1 | customers |
UPDATE status = 'expired' for all past expiry_date |
| 2 | radcheck |
INSERT: Auth-Type = Reject attribute |
| 3 | whatsapp_logs |
INSERT (optional): expiry reminder if template configured |
| 4 | audit_logs |
INSERT: event customer.expired |
Note: Existing live sessions are NOT killed. The customer's PPPoE session continues until it naturally drops and the router tries to re-authenticate — at which point RADIUS rejects it.
Package CHANGE¶
| Step | Table / Service | Operation |
|---|---|---|
| 1 | customers |
UPDATE package_id |
| 2 | radusergroup |
DELETE old group + INSERT new group |
| 3 | radgroupreply |
Verified to exist (must have been synced when package was saved) |
| 4 | audit_logs |
INSERT: event customer.package_changed |
Speed takes effect on the customer's next authentication (session reconnect), not immediately.
Package SPEED UPDATE¶
When a package's speed limits are edited, this affects ALL customers on that package.
| Step | Table / Service | Operation |
|---|---|---|
| 1 | packages |
UPDATE download_speed / upload_speed |
| 2 | radgroupreply |
DELETE all rows for group + INSERT new rows |
| 3 | Scope | All four vendor namespaces: mikrotik, cisco, juniper, other |
| 4 | audit_logs |
INSERT: event package.updated |
Customers currently online are NOT kicked. Their speed updates on their next PPPoE reconnect or session renewal.
NAS DELETION¶
| Step | Table / Service | Operation |
|---|---|---|
| 1 | nas |
DELETE row |
| 2 | dealer_nas |
DELETE pivot rows (cascade) |
| 3 | customers |
nas_id becomes a dangling foreign key — must be manually reassigned |
Warning: NAS deletion does not cascade to customers. After deleting a NAS, run a manual query to update or null customers.nas_id for affected rows. This is a known operational risk.
WhatsApp CAMPAIGN¶
| Step | Table / Service | Operation |
|---|---|---|
| 1 | campaigns |
INSERT: campaign record with status = 'pending' |
| 2 | campaign_recipients |
INSERT: one row per target customer |
| 3 | queue jobs table |
INSERT: SendCampaignJob dispatched per batch |
| 4 | whatsapp_logs |
INSERT: one row per message sent by queue worker |
| 5 | campaigns |
UPDATE status = 'completed' when all jobs done |
The queue is the critical dependency. If queue workers are down, messages are held in the jobs table until workers restart.
SECTION 3: Service Call Graph¶
CustomerController::recharge() — Full Call Tree¶
CustomerController::recharge()
└── BillingService::generateInvoice()
└── Invoice::create() [idempotent — guards duplicate by customer + period]
└── BillingService::recordPayment()
└── Payment::create()
└── LedgerEntry::create() ×2 [customer debit + dealer credit]
└── Invoice::lockForUpdate() + save() [paid_amount update]
└── BillingService::renew()
└── Customer::save() [expiry_date + status]
└── RadiusService::syncCustomer()
└── RadCheck::updateOrCreate() [Cleartext-Password]
└── RadUserGroup::delete() + create()
└── RadiusService::writeGroupReply()
└── RadGroupReply::delete() + insert()
[4 vendor namespaces: mikrotik, cisco, juniper, other]
└── BillingService::applyExcessToUnpaid() [if overpayment]
└── Invoice::lockForUpdate() ×N [each unpaid invoice]
└── BillingService::renew() ×N [if excess covers it]
└── WhatsAppService::sendToCustomer()
└── WhatsappTemplate::where() [dealer-specific first, then global fallback]
└── WhatsappLog::create()
└── OpenWA API call [or log driver in dev]
└── AuditLogger::log()
└── AuditLog::create() [subject_type=Customer, event=customer.recharged]
RadiusService::kickSession() — Full Call Tree¶
RadiusService::kickSession()
└── RadAcct::where(username, acctstoptime=null).get()
[find all open sessions for this username]
└── Nas::whereIn(nasname).get()
[eager load NAS records — prevents N+1 on session loop]
└── foreach session:
└── RadiusService::sanitizeRadiusString() ×per-field
[strip null bytes, special chars from Framed-IP-Address etc]
└── tempnam() → write shared secret to file → chmod 600
[secret never passed as CLI arg — written to temp file]
└── Process::run(['radclient', '-f', secretFile, nasIp, 'disconnect', ...])
[port 3799 first, fallback to 1700]
└── @unlink(secretFile) [always in finally block — never left on disk]
SECTION 4: Database Table Relationships¶
customers (central entity)¶
customers
├── invoices (customer_id FK)
├── payments (customer_id FK)
├── ledger_entries (customer_id FK)
├── radcheck (username = pppoe_username — string join, no FK)
├── radusergroup (username = pppoe_username — string join, no FK)
├── radacct (username = pppoe_username — read-only from FreeRADIUS)
├── ont_cache (description = pppoe_username — string join for signal data)
├── signal_readings (customer_id FK)
├── customer_traffic_readings (customer_id FK)
├── tickets (customer_id FK)
├── customer_acs_devices (customer_id FK)
├── audit_logs (subject_id WHERE subject_type='Customer' — polymorphic)
└── whatsapp_logs (customer_id FK)
Note: RADIUS tables (radcheck, radusergroup, radacct) use a string join on username = pppoe_username. There is no foreign key constraint. If a customer's pppoe_username changes, all RADIUS rows must be deleted and recreated under the new username.
users — dealers, sub-dealers, staff¶
users
├── customers (dealer_id FK, sub_dealer_id FK nullable)
├── invoices (dealer_id FK)
├── payments (dealer_id FK)
├── ledger_entries (account_id WHERE account_type='dealer' — polymorphic)
├── dealer_nas (user_id → nas pivot)
└── dealer_package (dealer_id → packages pivot with custom_rate)
packages¶
packages
├── customers (package_id FK)
├── radgroupreply (groupname = radius_group — string join, no FK)
└── dealer_package (package_id pivot)
nas (router/access concentrator)¶
nas
├── customers (nas_id FK — nullable, customers may predate NAS)
├── dealer_nas (nas_id pivot)
├── radacct (nasipaddress = nasname — string join, read-only)
└── nas_port_readings (nas_id FK)
olts (fiber head-end)¶
SECTION 5: Impact Analysis¶
If RADIUS server goes down¶
| Affected | Detail |
|---|---|
| New PPPoE authentications | FAIL — customers cannot connect |
| Existing PPPoE sessions | Continue until session timeout or line drop |
| PyroRadius billing | Continues normally |
| PyroRadius RADIUS sync | Sync writes queue — retried when RADIUS recovers |
| Session kick (CoA) | Fails — logged as warning, not retried automatically |
Recovery: RADIUS restart automatically. PyroRadius does not need restart. Run SyncAllRadius command manually if sync queue grew large during outage.
If Redis goes down¶
| Affected | Detail |
|---|---|
| Online customer count | Shows stale data or 0 |
| Queue workers | Stop processing jobs (campaigns, notifications paused) |
| Session cache | Falls back to DB queries |
| Billing / RADIUS | Unaffected — no Redis dependency |
| Application pages | Load normally |
Recovery: Redis restart automatically restores queue processing. Stale online count refreshes within the next poll cycle.
If PostgreSQL goes down¶
| Affected | Detail |
|---|---|
| Entire application | Complete outage — all pages return error |
| All features | Unavailable |
Recovery: PostgreSQL restart. App recovers automatically. No manual intervention needed. PgBouncer (port 6432) reconnects automatically.
If MikroTik API unavailable¶
| Affected | Detail |
|---|---|
| Live traffic charts | Show N/A (falls back to SNMP, then "unavailable") |
| Online customer count | Falls back to radacct query |
| Session kick (CoA Disconnect) | Fails silently — logged as warning |
| Billing / RADIUS auth | Unaffected |
Recovery: MikroTik API restores on its own. No PyroRadius restart needed.
If WhatsApp session disconnects¶
| Affected | Detail |
|---|---|
| WhatsApp messages | Not sent — logged as failed in whatsapp_logs |
| Campaigns | Jobs fail — campaign status shows partial |
| Billing / RADIUS | Completely unaffected |
| Core features | All work normally |
Recovery: WhatsApp → Sessions → Reconnect → scan QR code on phone. Pending failed messages are NOT automatically retried — campaigns must be re-sent manually for the failed recipients.