PHASE2_08 — PyroRadius Mermaid Diagrams¶
25 Architecture & Flow Diagrams¶
Version: 2.0 | Updated: 2026-07-13¶
Diagram 1 — System Architecture Overview¶
End-to-end view of every major component and how traffic flows from browser through PHP-FPM into PostgreSQL, Redis, MikroTik, and FreeRADIUS.
graph TB
Browser[Browser / Mobile App] -->|HTTPS| Nginx
Nginx -->|FastCGI| PHPFpm[PHP-FPM 8.3]
PHPFpm -->|Eloquent ORM| PG[(PostgreSQL 16)]
PHPFpm -->|Cache/Queue| Redis[(Redis 7)]
PHPFpm -->|Inertia.js| React[React 18 Bundle]
PHPFpm -->|RouterOS API TCP 8728| MikroTik[MikroTik Router]
PHPFpm -->|HTTP POST| OpenWA[OpenWA Session]
FreeRADIUS[FreeRADIUS Server] -->|SQL Driver| PG
PPPoEClient[PPPoE Client] -->|Auth Request| FreeRADIUS
Supervisor[Supervisor] -->|Manages Workers| Redis
Diagram 2 — Staff Authentication Flow¶
Sequence from login POST through bcrypt verification, session regeneration, Inertia shared props, and final dashboard render.
sequenceDiagram
actor User
User->>+Nginx: POST /login (email, password)
Nginx->>+Laravel: route: login.post
Laravel->>Laravel: Auth::attempt() - bcrypt verify
Laravel->>Laravel: Session::regenerate()
Laravel->>Laravel: HandleInertiaRequests - share auth.user
Laravel-->>-User: redirect /dashboard (with session cookie)
User->>+Laravel: GET /dashboard
Laravel->>Laravel: auth middleware - check session
Laravel->>PG: load user + permissions JSONB
Laravel-->>-User: Inertia render Dashboard.jsx
Diagram 3 — Customer PPPoE Authentication (RADIUS Flow)¶
Full sequence from PPPoE login on MikroTik router through FreeRADIUS SQL driver to PostgreSQL radcheck/radusergroup/radgroupreply, with both reject and accept branches.
sequenceDiagram
participant Router as MikroTik Router
participant FR as FreeRADIUS
participant PG as PostgreSQL
participant Client as PPPoE Client
Client->>Router: PPPoE Login (username, password)
Router->>FR: Access-Request (User-Name, User-Password, NAS-IP)
FR->>PG: SELECT value FROM radcheck WHERE username=? AND attribute='Cleartext-Password'
PG-->>FR: Cleartext-Password value
FR->>FR: Compare password
FR->>PG: SELECT attribute FROM radcheck WHERE username=? AND attribute='Auth-Type'
alt Auth-Type = Reject
FR-->>Router: Access-Reject
Router-->>Client: Connection Refused
else No Reject
FR->>PG: SELECT groupname FROM radusergroup WHERE username=?
FR->>PG: SELECT attribute,op,value FROM radgroupreply WHERE groupname=?
FR-->>Router: Access-Accept + Mikrotik-Rate-Limit=10M/10M
Router-->>Client: Connected with speed limit
FR->>PG: INSERT INTO radacct (Accounting-Start)
end
Diagram 4 — Customer Recharge Flow (Full)¶
Complete recharge sequence: invoice generation, payment recording, ledger double-entry, RADIUS sync, WhatsApp notification, and audit log.
sequenceDiagram
actor Staff
Staff->>+CustomerController: POST /customers/{id}/recharge
CustomerController->>BillingService: generateInvoice(customer)
BillingService->>BillingService: idempotent check (same period?)
BillingService->>PG: INSERT invoices
CustomerController->>BillingService: recordPayment(customer, amount, method)
BillingService->>PG: INSERT payments (receipt_no from seq_receipt_no)
BillingService->>PG: INSERT ledger_entries x2 (customer + dealer)
BillingService->>PG: UPDATE invoices SET paid_amount, status=paid
BillingService->>BillingService: renew(customer, invoice)
BillingService->>PG: UPDATE customers SET expiry_date, status=active
BillingService->>RadiusService: syncCustomer(customer)
RadiusService->>PG: UPDATE radcheck SET Cleartext-Password
RadiusService->>PG: DELETE + INSERT radusergroup
RadiusService->>PG: DELETE + INSERT radgroupreply
BillingService-->>CustomerController: Payment record
CustomerController->>WhatsAppService: sendToCustomer(payment_received)
WhatsAppService->>PG: WhatsappTemplate lookup dealer-specific then global
WhatsAppService->>OpenWA: POST send message
WhatsAppService->>PG: INSERT whatsapp_logs
CustomerController->>AuditLogger: log(customer.recharged, old, new)
AuditLogger->>PG: INSERT audit_logs
CustomerController-->>-Staff: redirect back with success flash
Diagram 5 — Daily Expiry Check Flow¶
How the CheckExpiry Artisan command finds expired customers, updates their status, rejects them in RADIUS, and sends WhatsApp notifications.
flowchart TD
Cron["CheckExpiry Command (daily 00:05)"] --> Query["SELECT customers WHERE expiry_date < today\nAND status NOT IN (disabled, suspended)"]
Query --> ForEach["For each expired customer"]
ForEach --> UpdateStatus["UPDATE customers SET status=expired"]
UpdateStatus --> RADIUS["RadiusService::rejectCustomerRadius()\nINSERT radcheck Auth-Type=Reject"]
RADIUS --> WA["WhatsAppService::sendToCustomer(account_expired)"]
WA --> Audit["AuditLogger::log(customer.expired)"]
Audit --> Next["Next customer"]
Next --> ForEach
Diagram 6 — RADIUS Sync Customer Lifecycle State Machine¶
All states a customer can be in and how RADIUS entries change at each transition.
stateDiagram-v2
[*] --> Created: Customer created
Created --> ActiveRADIUS: syncCustomer()\nradcheck: Cleartext-Password\nradusergroup: package group\nradgroupreply: speed limit
ActiveRADIUS --> Expired: CheckExpiry command\nradcheck: add Auth-Type=Reject
Expired --> ActiveRADIUS: Payment received\nrenew() then syncCustomer()\nAuth-Type=Reject removed
ActiveRADIUS --> Rejected: Disable or Suspend\nAuth-Type=Reject added
Rejected --> ActiveRADIUS: Re-enable\nsyncCustomer()
ActiveRADIUS --> Deleted: Customer soft-deleted\ndeleteCustomerRadius()\nAll radcheck and radusergroup removed
Diagram 7 — Dealer Hierarchy¶
Complete role hierarchy from super_admin down through admin, staff roles, dealers, sub-dealers, and their customer ownership.
graph TD
SuperAdmin[super_admin] --> Admin[admin]
Admin --> NOC[noc_admin]
Admin --> Billing[billing_admin]
Admin --> Support[support_agent]
Admin --> Auditor[auditor]
Admin --> Engineer[field_engineer]
Admin --> Collection[collection_officer]
Admin --> Dealer1[Dealer A\nrole=dealer]
Admin --> Dealer2[Dealer B\nrole=dealer]
Admin --> CIR[CIR Dealer\nrole=dealer]
Dealer1 --> SubDealer1[Sub-Dealer X\nrole=sub_dealer\nparent_id=Dealer A]
Dealer1 --> Customer1[Customer\ndealer_id=A]
SubDealer1 --> Customer2[Customer\ndealer_id=A\nsub_dealer_id=X]
Dealer2 --> Customer3[Customer\ndealer_id=B]
CIR --> Customer4[CIR Customer\ndealer_id=CIR\nstatic IP / CIR type]
Diagram 8 — Permission Check Flow¶
How the authorization system checks role first, then admin flag, then the JSONB permissions column before denying access.
flowchart TD
Request[Controller receives request] --> CheckRole{user.role == super_admin?}
CheckRole -->|Yes| Allow[Allow - full access]
CheckRole -->|No| CheckAdmin{isAdmin?}
CheckAdmin -->|Yes| Allow
CheckAdmin -->|No| CheckJSONB{permissions JSONB\nhas ability key?}
CheckJSONB -->|Yes value=true| Allow
CheckJSONB -->|No or false| Deny[abort 403]
Diagram 9 — Dealer Scoping Query Flow¶
How the Customer::visibleTo() scope filters rows so each role sees only their own customers.
flowchart TD
Query[Customer::query()] --> VisibleTo[visibleTo(auth user)]
VisibleTo --> IsAdmin{isAdmin or isStaff?}
IsAdmin -->|Yes| NoFilter[No WHERE filter - see all]
IsAdmin -->|No| IsDealer{isDealer?}
IsDealer -->|Yes| DealerFilter[WHERE dealer_id = user.id]
IsDealer -->|No| IsSubDealer{isSubDealer?}
IsSubDealer -->|Yes| SubDealerFilter[WHERE sub_dealer_id = user.id]
IsSubDealer -->|No| IsEngineer{isFieldEngineer?}
IsEngineer -->|Yes| EngineerFilter[WHERE dealer_id = user.parent_id]
IsEngineer -->|No| IsCollection{isCollectionOfficer?}
IsCollection -->|Yes| CollectionFilter[WHERE dealer_id IN assigned\nOR area_id IN assigned]
IsCollection -->|No| SeeNothing[WHERE 1=0 - see nothing]
Diagram 10 — WhatsApp Message Flow¶
Full flow from trigger through template lookup (dealer-specific then global), placeholder rendering, OpenWA send, and log insert.
sequenceDiagram
participant Trigger as BillingService or Controller
participant WAS as WhatsAppService
participant PG as PostgreSQL
participant OpenWA as OpenWA Session
Trigger->>WAS: sendToCustomer(payment_received, customer)
WAS->>WAS: enabled()? - Setting::get(whatsapp_enabled)
WAS->>PG: WhatsappTemplate - dealer-specific lookup
alt Dealer template found
PG-->>WAS: template with body
else No dealer template
WAS->>PG: Global template lookup
PG-->>WAS: global template or null
end
alt Template found and enabled
WAS->>WAS: render(body, vars) - replace placeholders
WAS->>WAS: to = customer.whatsapp OR customer.mobile
alt driver = openwa
WAS->>PG: WhatsappSession WHERE status=ready
WAS->>OpenWA: POST send message
OpenWA-->>WAS: response
else driver = log
WAS->>WAS: Log::info(message)
end
WAS->>PG: INSERT whatsapp_logs (status, response)
else No template
WAS-->>Trigger: return null
end
Diagram 11 — OLT Signal Monitoring Flow¶
How PollOltOnts SSHes into the OLT every minute, writes ont_cache, and how SyncOntAssignments joins ont_cache to customers using the 2026-07-13 performance fix.
sequenceDiagram
participant Cron as Scheduler (every minute)
participant PollOlt as PollOltOnts command
participant SyncOnt as SyncOntAssignments command
participant OLT as OLT Device
participant PG as PostgreSQL
Cron->>PollOlt: Run
PollOlt->>OLT: SSH - get ONT list (serial, port, rx_power)
OLT-->>PollOlt: ONT data
PollOlt->>PG: UPSERT ont_cache (sn_decoded, description, rx_raw=power*100)
Cron->>SyncOnt: Run
SyncOnt->>PG: UPDATE customers c SET ont_serial, rx_power, rx_power_at\nFROM ont_cache o\nWHERE LOWER(TRIM(o.description)) = LOWER(c.pppoe_username)\nAND (c.ont_serial IS DISTINCT FROM o.sn_decoded\nOR c.rx_power IS DISTINCT FROM rx_raw/100)
Note over SyncOnt,PG: Only updates changed rows (2026-07-13 fix)\nRuntime: 418ms was 14 seconds
Diagram 12 — Background Job Queue Architecture¶
Three named queues (default, whatsapp, radius) with dedicated Supervisor workers and the jobs each worker processes.
graph LR
subgraph Triggers
Controller[Controller]
Command[Artisan Command]
end
subgraph Queue[Redis Queues]
Default[default queue]
WhatsApp[whatsapp queue]
Radius[radius queue]
end
subgraph Workers[Supervisor Workers]
W1[Worker 1\nqueue:work default]
W2[Worker 2\nqueue:work whatsapp]
W3[Worker 3\nqueue:work radius]
end
Controller -->|dispatch| Default
Controller -->|dispatch| WhatsApp
Command -->|dispatch| Radius
Default --> W1
WhatsApp --> W2
Radius --> W3
W1 -->|process| SendCampaignJob
W2 -->|process| SendWhatsAppNotificationJob
W3 -->|process| SyncCustomerRadiusJob
Diagram 13 — Mobile API Authentication¶
Sanctum token issuance on login, then bearer token usage for customer list and recharge endpoints with throttle limits.
sequenceDiagram
actor MobileApp
MobileApp->>+API: POST /api/mobile/login (email, password) throttle 5/min
API->>API: Auth::attempt()
API->>PG: CREATE personal_access_tokens
API-->>-MobileApp: token and user object
MobileApp->>+API: GET /api/mobile/customers\nAuthorization: Bearer token
API->>API: auth:sanctum middleware - validate token
API->>PG: SELECT customers visibleTo(user)
API-->>-MobileApp: data array with meta
MobileApp->>+API: POST /api/mobile/customers/{id}/recharge throttle 10/min
API->>API: Same billing flow as web
API-->>-MobileApp: payment object and success message
Diagram 14 — Invoice Lifecycle State Machine¶
All invoice states from unpaid through partial to paid, then to archived, with notes on what triggers each transition.
stateDiagram-v2
[*] --> unpaid: generateInvoice()\namount set, paid_amount=0
unpaid --> partial: recordPayment()\n0 < paid_amount < amount
partial --> paid: recordPayment()\npaid_amount >= amount
unpaid --> paid: recordPayment() full amount\npaid_amount = amount
paid --> archived: BillingArchiveService\nmonth-end archive
paid --> [*]: renew() called\nexpiry_date extended
Diagram 15 — BillingService recordPayment Deadlock Protection¶
Retry loop with exponential backoff protecting against PostgreSQL 40P01 deadlock and 55P03 lock timeout errors during concurrent payments.
flowchart TD
Start[recordPayment called] --> TryTx[DB::transaction attempt]
TryTx --> CreatePayment[INSERT payments]
CreatePayment --> CreateLedger[INSERT ledger_entries x2]
CreateLedger --> LockInvoice[Invoice::lockForUpdate()]
LockInvoice --> UpdateInvoice[UPDATE invoice paid_amount and status]
UpdateInvoice --> CheckPaid{invoice paid?}
CheckPaid -->|Yes| Renew[renew() then syncCustomer()]
CheckPaid -->|No| CheckExcess{excess amount?}
Renew --> CheckExcess
CheckExcess -->|Yes| ApplyExcess[applyExcessToUnpaid()]
CheckExcess -->|No| Commit[Commit transaction]
ApplyExcess --> Commit
Commit --> Return[return Payment]
TryTx -->|40P01 deadlock or 55P03 lock timeout| Retry{attempts < 3?}
UpdateInvoice -->|lock contention| Retry
Retry -->|Yes, wait 50ms x attempt| TryTx
Retry -->|No| Throw[throw QueryException]
Diagram 16 — SyncOntAssignments Performance Fix¶
Side-by-side comparison of the unconditional UPDATE (14 seconds, all 2144 rows) versus the IS DISTINCT FROM guarded UPDATE (418ms, changed rows only), plus the functional indexes added.
graph TB
subgraph Before[Before Fix 2026-07-13 - 14 seconds]
Q1["UPDATE customers SET ont_serial, rx_power, rx_power_at=NOW()\nFROM ont_cache WHERE username_match\nUpdates ALL 2144 rows every minute\nHolds row locks 14 seconds"]
end
subgraph After[After Fix - 418ms]
Q2["UPDATE customers SET ont_serial, rx_power\nrx_power_at = CASE WHEN value changed THEN NOW() ELSE rx_power_at END\nFROM ont_cache WHERE username_match\nAND (ont_serial IS DISTINCT FROM sn_decoded\nOR rx_power IS DISTINCT FROM new_rx)\nOnly updates changed rows"]
end
subgraph Indexes[Functional Indexes Added]
I1["idx_ont_cache_desc_lower\nON ont_cache(LOWER(TRIM(description)))"]
I2["idx_customers_pppoe_lower\nON customers(LOWER(pppoe_username))\nWHERE deleted_at IS NULL"]
end
Diagram 17 — Customer Status Lifecycle¶
All eight customer statuses with valid transitions, showing which statuses trigger RADIUS reject and which allow connectivity.
stateDiagram-v2
[*] --> active: Customer created\nRADIUS: allow
active --> temp_extended: Admin grants grace period\nRADIUS: still allow
active --> expired: CheckExpiry command\nexpiry_date < today\nRADIUS: reject
temp_extended --> expired: Grace period over
temp_extended --> active: Payment received\nrenew()
expired --> active: Payment received\nrenew()
active --> suspended: Admin suspends\nRADIUS: reject
active --> disabled: Admin disables\nRADIUS: reject
suspended --> active: Admin re-enables\nRADIUS: allow
disabled --> active: Admin re-enables\nRADIUS: allow
active --> deleted: Soft delete\nRADIUS: remove all entries
deleted --> active: Restore\nRADIUS: syncCustomer
Diagram 18 — Package and RADIUS Group Relationship (ER Diagram)¶
Entity relationships between packages, radgroupreply (4 vendor rows per package), radusergroup, radcheck, and customers.
erDiagram
Package {
string radius_group
string download_speed
string upload_speed
string burst_download
string burst_upload
}
RadGroupReply {
string groupname
string attribute
string op
string value
}
RadUserGroup {
string username
string groupname
int priority
}
RadCheck {
string username
string attribute
string value
}
Customer {
string pppoe_username
string pppoe_password
int package_id
}
Package ||--o{ RadGroupReply : "groupname 4 vendor rows"
Package ||--o{ Customer : "package_id"
Customer ||--|| RadCheck : "pppoe_username"
Customer ||--|| RadUserGroup : "pppoe_username"
RadUserGroup }o--|| RadGroupReply : "groupname"
Diagram 19 — Dealer Package Commercial Flow¶
How admin sets package pricing, dealer_package stores cost/sale prices, and PyroRadius records both the collection and the ledger entries.
flowchart LR
Admin["Admin sets:\nPackage price = 5000/month"] --> DealerPkg["dealer_package:\ndealer_id=4\npackage_id=2\ncost_price=4000\nsale_price=5500\nactive=true"]
DealerPkg --> Dealer["Dealer charges\ncustomer 5500/month"]
Dealer --> PyroRadius["PyroRadius records:\ncustomer.monthly_price=5500\npayment=5500\nledger: credit customer 5500\nledger: credit dealer 5500"]
PyroRadius --> Report["Reports:\nDealer collected 5500\nCost to dealer: 4000\nDealer margin: 1500"]
Diagram 20 — NAS and MikroTik Integration¶
Architecture of how PyroRadius talks to MikroTik via RouterOS API and SNMP, and how MikroTik talks to FreeRADIUS which reads from PostgreSQL.
graph TB
subgraph PyroRadius
NasController[NasController]
NasModel[(NAS table\nnasname, api_user\napi_password encrypted)]
MikroTikService[MikroTikService]
RouterOsLib[RouterOS API Library\nevilfreelancer/routeros-api-php]
end
subgraph MikroTik[MikroTik Router]
PPPoEServer[PPPoE Server]
RouterOS[RouterOS API port 8728]
SNMP[SNMP agent IF-MIB]
end
subgraph FreeRADIUS
Daemon[radiusd]
SQL[SQL driver]
end
NasController --> NasModel
MikroTikService --> RouterOsLib
RouterOsLib -->|TCP 8728| RouterOS
RouterOS --> PPPoEServer
MikroTikService -->|snmp_get| SNMP
PPPoEServer -->|Access-Request| Daemon
Daemon --> SQL
SQL --> PG[(PostgreSQL\nradcheck\nradusergroup\nradgroupreply)]
Diagram 21 — radclient CoA Session Disconnect¶
How kickSession works: lookup active radacct sessions, write NAS secret to temp file, shell out to radclient for Disconnect-Request on port 3799 (fallback 1700), then unlink secret file.
sequenceDiagram
participant Ctrl as CustomerController
participant RS as RadiusService
participant PG as PostgreSQL
participant Tmp as tmp secret file
participant RC as radclient binary
participant Router as MikroTik
Ctrl->>RS: kickSession(customer)
RS->>PG: SELECT radacct WHERE username=? AND acctstoptime IS NULL
PG-->>RS: active sessions list
RS->>PG: SELECT nas WHERE nasipaddress IN session IPs
loop per session
RS->>RS: sanitizeRadiusString() - strip injection chars
RS->>Tmp: write nas.secret chmod 600
RS->>RC: radclient -x -r 1 -t 2 -S /tmp/file\nnasip:3799 disconnect
RC->>Router: CoA Disconnect-Request port 3799
alt port 3799 success
Router-->>RC: Disconnect-ACK
RC-->>RS: output contains Disconnect-ACK
RS->>RS: acked++
else port 3799 no reply
RC->>Router: CoA Disconnect-Request port 1700
Router-->>RC: Disconnect-ACK or timeout
end
RS->>Tmp: unlink(secretFile) in finally block
end
RS-->>Ctrl: acked count
Diagram 22 — Import Customers Flow¶
XLS/XLSX upload through PhpSpreadsheet parsing, per-row validation, duplicate check, Customer creation, RADIUS sync, and result summary.
flowchart TD
Upload[Staff uploads XLS/XLSX] --> Parse[PhpSpreadsheet\nparse rows]
Parse --> ForEach[For each row]
ForEach --> Validate{Valid?\nrequired fields\nunique username}
Validate -->|No| ErrorRow[Mark row ERROR\ncollect message]
Validate -->|Yes| CheckDup{pppoe_username\nalready exists?}
CheckDup -->|Yes| SkipRow[Mark row SKIPPED]
CheckDup -->|No| CreateCustomer[Customer::create()\nCustomer::nextCode()]
CreateCustomer --> SyncRADIUS[RadiusService::syncCustomer()]
SyncRADIUS --> SuccessRow[Mark row SUCCESS]
SuccessRow --> Next[Next row]
ErrorRow --> Next
SkipRow --> Next
Next --> ForEach
ForEach --> ShowResults[Return results:\ncreated count\nskipped count\nerror count\nper-row details]
Diagram 23 — Health System Architecture¶
Health dashboard pulling from server stats history, Supervisor queue status, cron_jobs table, NAS port readings, and FreeRADIUS connectivity check.
graph TB
HealthPage[Health/Index.jsx] -->|GET /health| HealthController
HealthController --> ServerStats[RecordSysStats\nsys_stats_history\nCPU RAM Disk percent]
HealthController --> QueueStatus[Supervisor process check\nqueue workers running]
HealthController --> CronJobs[cron_jobs table\nenabled jobs and last run]
HealthController --> NasHealth[nas_port_readings\nport status per NAS]
HealthController --> RadiusCheck[FreeRADIUS connectivity\ntest auth against local]
CronPage[Health Cron Jobs tab] -->|CRUD| CronJobController
CronJobController --> CronJobsTable[(cron_jobs\ncommand, expression\nenabled, without_overlap)]
Scheduler[routes/console.php\nLaravel Schedule] --> CronJobsTable
Scheduler -->|foreach enabled job| ScheduleCommand[Schedule::command()\n.cron(expression)]
Diagram 24 — Multi-Vendor RADIUS Attribute Scheme¶
How syncPackage creates four radgroupreply rows per package: MikroTik (Mikrotik-Rate-Limit), Cisco (Cisco-AVPair), Juniper (ERX policy names), and Other (WISPr bandwidth).
graph TB
Package["Package: 10-Mbps-FTTH\ndownload=10M upload=10M"] --> Sync[RadiusService::syncPackage()]
Sync --> MikroTik["MikroTik group: 10-Mbps-FTTH\nMikrotik-Rate-Limit: 10M/10M"]
Sync --> Cisco["Cisco group: 10-Mbps-FTTH_cisco\nCisco-AVPair: qos-policy-in=PYRO_IN_10M\nCisco-AVPair: qos-policy-out=PYRO_OUT_10M\nWISPr-Bandwidth-Max-Down: 10000000"]
Sync --> Juniper["Juniper group: 10-Mbps-FTTH_juniper\nERX-Ingress-Policy-Name: PYRO_IN_10M\nERX-Egress-Policy-Name: PYRO_OUT_10M\nWISPr-Bandwidth-Max-Down: 10000000"]
Sync --> Other["Other group: 10-Mbps-FTTH_other\nWISPr-Bandwidth-Max-Down: 10000000\nWISPr-Bandwidth-Max-Up: 10000000"]
MikroTik --> PG[(radgroupreply table\n4 rows per package)]
Cisco --> PG
Juniper --> PG
Other --> PG
Diagram 25 — Complete Data Flow: New Customer Joins¶
End-to-end journey from admin clicking Create through code generation, database insert, all four RADIUS tables being written, audit log, and the customer being immediately connectable.
flowchart TD
A[Admin or Dealer creates customer] --> B[CustomerController::store()]
B --> C[Validate: unique pppoe_username\nrequired fields\ndealer scoping]
C --> D[Customer::nextCode()\ncustomer_code_seq yields PR02744]
D --> E[Customer::create() - customers table]
E --> F[RadiusService::syncCustomer()]
F --> G[RadCheck::updateOrCreate\nCleartext-Password]
F --> H[RadUserGroup::create\nusername to package group]
F --> I[RadGroupReply insert or update\nspeed limit attributes all 4 vendors]
E --> J[AuditLogger::log\ncustomer.created]
J --> K[AuditLog::create\nold=null new=customer data]
B --> L[redirect /customers/PR02744\nwith success flash]
L --> M[Customer now active\nCan authenticate PPPoE immediately]