Skip to content

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)

olts
  └── ont_cache           (olt_id FK)

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.