Skip to content

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]