Skip to content

PyroRadius AI Context v2

Complete Knowledge Base for AI Assistants

Version: 2.0 | Updated: 2026-07-13

Read this ENTIRE file before making any changes to PyroRadius.

This is the authoritative reference — if code contradicts this, check which is current.


TABLE OF CONTENTS

  1. Project Identity
  2. One-Paragraph Architecture Summary
  3. ABSOLUTE RULES — Never Violate These
  4. Stack and Versions
  5. Complete Folder Map
  6. Key Files Reference
  7. All 10 Roles and What They Can Do
  8. All ~80 Permissions
  9. The Dealer Scoping Pattern
  10. Business Rules
  11. Naming Conventions
  12. Customer Lifecycle
  13. Billing Flow
  14. RADIUS Architecture
  15. MikroTik Integration
  16. OLT/GPON Integration
  17. WhatsApp Integration
  18. The Permission Check Pattern
  19. The AuditLogger Pattern
  20. The Retry Pattern
  21. Deploy Procedure
  22. Database Reference
  23. PostgreSQL-Specific Features Used
  24. Redis Usage
  25. Queue Architecture
  26. Scheduled Commands
  27. API Architecture
  28. Mobile API
  29. Frontend Architecture
  30. React Component Conventions
  31. Security Patterns
  32. Performance Patterns
  33. Known Issues (as of 2026-07-13)
  34. Recently Fixed Issues
  35. Future Roadmap
  36. Common Mistakes to Avoid
  37. Standard Code Patterns
  38. AI Navigation Index

1. PROJECT IDENTITY

Name: PyroRadius Owner: PyroNet Solutions, Lahore, Pakistan Type: ISP Management System — billing, RADIUS provisioning, customer management, OLT monitoring, WhatsApp notifications, dealer hierarchy Primary Users: PyroNet admin staff, dealers, sub-dealers, field engineers, collection officers Production URL: Internal deployment; accessed via browser and mobile app Database prefix: PR (customer codes like PR02744) License: Proprietary — not open source

What it does: - Manages ISP customers (PPPoE, FTTH, CIR, business) - Handles billing: invoice generation, payment recording, ledger double-entry - Provisions RADIUS (FreeRADIUS via PostgreSQL tables) — customer connects to internet the moment they are created - Monitors OLT signal levels per customer (rx_power in dBm) - Sends WhatsApp notifications (payment receipts, expiry warnings, welcome messages) - Provides a dealer portal so ISP resellers manage their own customers - Integrates with MikroTik routers via RouterOS API and SNMP


2. ONE-PARAGRAPH ARCHITECTURE SUMMARY

PyroRadius is a Laravel 11 / Inertia.js / React 18 monolith running on PHP-FPM 8.3 behind Nginx, backed by PostgreSQL 16 (primary data store and FreeRADIUS SQL backend) and Redis 7 (cache and queue broker). The frontend is rendered server-side via Inertia (no separate API for web) but a separate Sanctum token API exists for the mobile app under /api/mobile/. Authentication uses Laravel sessions for web and Bearer tokens (Sanctum) for mobile. Multi-tenancy is enforced at the Eloquent query scope level via Customer::visibleTo() — no separate schemas or databases per dealer. FreeRADIUS authenticates PPPoE sessions by querying the same PostgreSQL database that PyroRadius writes to, so every customer change in the UI is immediately reflected in RADIUS. Background jobs run through Redis queues managed by Supervisor. Scheduled commands run through Laravel's scheduler (routes/console.php reading from cron_jobs table). OLT signal data is polled every minute via SSH, cached in ont_cache, and synced to customers with a single bulk UPDATE using IS DISTINCT FROM to avoid touching unchanged rows.


3. ABSOLUTE RULES — Never Violate These

Rule 1: Never bypass the visibleTo() scope on Customer queries

WHY: Without this scope, a dealer can see another dealer's customers. This is a critical data isolation and privacy rule. Every Customer::query() used in a controller MUST chain ->visibleTo(auth()->user()) before executing. The only exceptions are internal console commands and super_admin-level reports explicitly designed to see all data.

Rule 2: Never store passwords in plain text in the users table

WHY: User login passwords use bcrypt. However, pppoe_password in customers IS stored as cleartext — this is intentional because FreeRADIUS needs it for PAP authentication. Do not change pppoe_password to hashed. Do not apply bcrypt to it. Do not "fix" this — it is by design.

Rule 3: Never modify PyroRadius_Migration folder

WHY: That folder is a read-only archive of the old system state used only for historical reference during migration. It is not a running codebase. Touching it can corrupt the migration audit trail.

Rule 4: Never delete from radcheck, radusergroup, or radgroupreply directly without going through RadiusService

WHY: The sync logic in RadiusService maintains consistency between the four vendor groups per package (MikroTik, Cisco, Juniper, Other). Manually deleting rows will leave orphaned entries or break authentication for active customers.

Rule 5: Never run raw SQL UPDATE on customers without the retry wrapper for lock errors

WHY: SyncOntAssignments and CheckExpiry both write to the customers table on a schedule. Concurrent writes cause PostgreSQL 55P03 (lock timeout) or 40P01 (deadlock) errors. Any direct customer update must use the retry pattern (see Section 20).

Rule 6: Never hardcode dealer IDs, package IDs, or area IDs in business logic

WHY: These IDs vary between development and production databases. Always look them up by slug, name, or setting key. Never write if ($customer->dealer_id === 4) in business logic.

Rule 7: Never call WhatsApp send from within a database transaction

WHY: If the transaction rolls back, the WhatsApp message has already been sent and cannot be recalled. Always dispatch WhatsApp sends after the transaction commits, either via a queued job or after DB::transaction() returns successfully.

Rule 8: Never expose the NAS API password or WhatsApp session credentials in logs

WHY: NAS passwords are encrypted in the database (nas.api_password). The decrypted value must never appear in application logs, error messages, or stack traces. Use sanitizeRadiusString() on any user-controlled input that touches shell commands.

Rule 9: Never skip AuditLogger on state-changing actions

WHY: PyroRadius is used in a regulated ISP environment. Dealers dispute charges, customers dispute disconnections. Every create/update/delete on customers, payments, and invoices must have an audit trail. If you add a new action that changes financial or connectivity state, add AuditLogger::log().

Rule 10: Never use php artisan migrate in production without explicit operator approval

WHY: Production database migrations can lock tables, drop data, or cause FreeRADIUS to fail authentication if RADIUS tables are modified. All migrations must be reviewed, timed for off-peak hours, and run with operator standing by.


4. STACK AND VERSIONS

Component Version Notes
PHP 8.3 PHP-FPM, strict_types preferred
Laravel 11 No legacy service providers pattern
Inertia.js 1.x (server) With SSR disabled
React 18 JSX, hooks only — no class components
Vite 5.x Asset bundler
PostgreSQL 16 Primary DB and FreeRADIUS backend
Redis 7 Cache and queue
FreeRADIUS 3.x SQL driver pointing at PostgreSQL
Nginx 1.24+ Reverse proxy and static asset server
Supervisor 4.x Queue worker process manager
Node.js 20 LTS Build tooling only — not runtime
Composer 2.x PHP dependency manager
RouterOS PHP evilfreelancer/routeros-api-php MikroTik API
PhpSpreadsheet latest XLS/XLSX import/export
Laravel Sanctum 3.x Mobile API token auth
OpenWA self-hosted WhatsApp session bridge

5. COMPLETE FOLDER MAP

app/
  Console/
    Commands/                   All Artisan commands (CheckExpiry, PollOltOnts, SyncOntAssignments, etc.)
  Http/
    Controllers/
      Api/                      Mobile API controllers (namespace App\Http\Controllers\Api)
      Auth/                     LoginController, LogoutController
      CustomerController.php    Largest controller — customer CRUD, recharge, import, kick session
      BillingController.php     Invoice listing, payment history
      DealerController.php      Dealer management, dealer package pricing
      NasController.php         NAS/router management
      PackageController.php     Package CRUD + RADIUS sync
      WhatsAppController.php    Template management, session management
      HealthController.php      System health dashboard
      ReportController.php      Financial and operational reports
      AuditLogController.php    View audit trail
      AreaController.php        Geographic area management
      OltController.php         OLT device management
      DashboardController.php   Dashboard stats + View As feature
    Middleware/
      HandleInertiaRequests.php Shares auth.user, can(), flash to all Inertia responses
      CheckPermission.php       Route-level permission gate
    Requests/                   Form request validation classes
  Models/
    Customer.php                Core model — scopes, accessors, status helpers
    User.php                    ALL_PERMISSIONS constant, role helpers, permissions JSONB
    Payment.php                 Payment model with receipt_no from sequence
    Invoice.php                 Invoice model with lockForUpdate usage
    Package.php                 Package model with RADIUS group fields
    Nas.php                     NAS/router model with encrypted api_password
    AuditLog.php                Audit log model
    WhatsappTemplate.php        Per-dealer or global template
    WhatsappSession.php         OpenWA session record
    WhatsappLog.php             Sent message log
    OntCache.php                ONT signal cache from OLT poll
    CronJob.php                 Dynamic scheduled command record
    Area.php                    Geographic area
    OltDevice.php               OLT device connection details
    LedgerEntry.php             Double-entry ledger rows
    DealerPackage.php           Dealer-specific package pricing
  Services/
    BillingService.php          generateInvoice, recordPayment, renew, applyExcess
    RadiusService.php           syncCustomer, syncPackage, deleteCustomerRadius, kickSession
    WhatsAppService.php         sendToCustomer, render, enabled, log
    MikroTikService.php         RouterOS API interactions, SNMP queries
    AuditLogger.php             Static log() method used everywhere
  Jobs/
    SendWhatsAppNotificationJob.php  Queued WhatsApp send
    SyncCustomerRadiusJob.php        Queued RADIUS sync
    SendCampaignJob.php              Bulk WhatsApp campaign send
resources/
  js/
    Pages/                      All Inertia page components (React)
      Customers/                Customer list, show, create, edit, recharge
      Billing/                  Invoices, payments, ledger
      Reports/                  Financial, operational reports
      Health/                   System health, cron jobs
      Auth/                     Login page
      Dashboard/                Main dashboard
    Components/                 Shared React components
    Layouts/                    AppLayout, AuthLayout
    Hooks/                      usePermissions, useDarkMode, etc.
    lib/                        Utility functions (formatCurrency, formatDate, etc.)
  views/
    app.blade.php               Root Inertia template
database/
  migrations/                   All Laravel migrations
  seeders/                      Dev/test data seeders
routes/
  web.php                       All web routes (auth protected)
  api.php                       Mobile API routes (Sanctum)
  console.php                   Scheduled commands + dynamic cron_jobs loader
config/
  radius.php                    FreeRADIUS connection config
  whatsapp.php                  OpenWA driver config
  pyronet.php                   App-specific config (receipt prefix, etc.)

6. KEY FILES REFERENCE

File Purpose Key Methods / Constants
app/Services/BillingService.php All billing logic generateInvoice(), recordPayment(), renew(), applyExcessToUnpaid()
app/Services/RadiusService.php All RADIUS sync syncCustomer(), syncPackage(), deleteCustomerRadius(), rejectCustomerRadius(), kickSession()
app/Services/WhatsAppService.php WhatsApp send sendToCustomer(), render(), enabled()
app/Services/AuditLogger.php Audit trail log(string $action, $old, $new, $subject) — static
app/Services/MikroTikService.php RouterOS API connect(), getActiveSessions(), getPPPoEInterfaces()
app/Models/User.php Auth + roles ALL_PERMISSIONS (array const), can(), isAdmin(), isDealer(), isSubDealer(), isStaff(), defaultPermissions()
app/Models/Customer.php Customer entity visibleTo() scope, scopeActive(), scopeExpired(), nextCode(), statusLabel()
app/Http/Controllers/CustomerController.php Customer CRUD store(), update(), recharge(), import(), kickSession()
app/Http/Controllers/DashboardController.php Dashboard + View As index(), viewAs(), exitViewAs()
app/Http/Middleware/HandleInertiaRequests.php Inertia shared props Shares auth.user, flash, can object, settings
app/Console/Commands/CheckExpiry.php Daily expiry check handle() — finds expired customers, rejects in RADIUS, sends WA
app/Console/Commands/PollOltOnts.php OLT signal poll handle() — SSH to OLT, upsert ont_cache
app/Console/Commands/SyncOntAssignments.php ONT→customer sync handle() — bulk UPDATE with IS DISTINCT FROM guard (2026-07-13)
routes/web.php All web routes Named routes, middleware groups, resource routes
routes/api.php Mobile API routes Sanctum protected, throttled endpoints
routes/console.php Scheduler Dynamic cron_jobs loader + hardcoded schedules
resources/js/Pages/Customers/Index.jsx Customer list page DataTable, filters, bulk actions
resources/js/Pages/Customers/Show.jsx Customer detail Timeline, invoices, payments, RADIUS status
resources/js/Hooks/usePermissions.js Frontend auth can(ability) hook reading Inertia shared props
database/migrations/*_create_radcheck_table.php RADIUS tables radcheck, radusergroup, radgroupreply, radacct
config/pyronet.php App config Receipt prefix, customer code prefix, OLT defaults
app/Models/OntCache.php ONT signal cache sn_decoded, description, rx_raw (power × 100)

7. ALL 10 ROLES AND WHAT THEY CAN DO

super_admin

Full unrestricted access to everything. Can impersonate any user, see all dealers, modify system settings, access all financial data, manage staff roles. There is only one super_admin account in production.

admin

Same access as super_admin for most operations but cannot modify system-level security settings. Can create dealers, manage packages, run reports across all dealers, configure WhatsApp templates globally.

noc_admin

Network Operations Center staff. Can view all customers and their RADIUS/OLT status, kick sessions, view health dashboard, view cron jobs. Cannot process payments, create customers, or access financial reports.

billing_admin

Can process payments, generate invoices, view financial reports, manage the ledger. Cannot manage network settings or modify RADIUS configuration directly. Can view all customers to look up accounts.

support_agent

Can view customer details (all dealers), update basic customer info (phone, address), view invoice/payment history. Cannot process payments, change packages, or access financial totals.

auditor

Read-only access to all data including audit logs, financial reports, payment records, customer history. Cannot modify anything. Designed for external audit or compliance review.

field_engineer

Can view customers under their assigned dealer (user.parent_id = dealer). Can view OLT signal data, update OLT-related fields (ont_serial, port). Cannot process payments or change billing.

collection_officer

Can view customers assigned to them by dealer_id or area_id. Can record cash payments. Cannot change package, RADIUS settings, or access other dealers' customers.

dealer

Can create customers under their own account (dealer_id = self), process payments for their customers, view their own financial reports, manage sub-dealers under them. Cannot see other dealers' data. Portal is scoped to dealer_id = self.

sub_dealer

Same as dealer but scoped to sub_dealer_id = self. Cannot create other sub-dealers. Their customers are visible to their parent dealer. Cannot see the parent dealer's other customers.


8. ALL ~80 PERMISSIONS

Permissions are stored as a JSONB column in the users table. The constant User::ALL_PERMISSIONS defines all valid keys. Admins and super_admins bypass permission checks. For all other roles, the JSONB value must be true to allow the action.

Customer Permissions - customers.view — View customer list - customers.create — Create new customer - customers.edit — Edit customer details - customers.delete — Soft-delete customer - customers.restore — Restore soft-deleted customer - customers.import — Import customers from XLS - customers.export — Export customer list to XLS/PDF - customers.view_password — See PPPoE password in UI - customers.change_package — Change customer's internet package - customers.change_status — Enable/suspend/disable customer - customers.kick_session — Disconnect active PPPoE session - customers.view_radius_status — See RADIUS auth status - customers.view_signal — See OLT rx_power signal - customers.send_whatsapp — Send manual WhatsApp message

Billing Permissions - billing.recharge — Process a payment / recharge - billing.view_invoices — View invoice list - billing.edit_invoice — Edit invoice amount or due date - billing.delete_invoice — Delete/void an invoice - billing.view_payments — View payment history - billing.edit_payment — Edit payment record - billing.delete_payment — Delete payment record - billing.view_ledger — View ledger entries - billing.apply_discount — Apply discount on recharge - billing.waive_balance — Waive outstanding balance

Dealer Permissions - dealers.view — View dealer list - dealers.create — Create new dealer - dealers.edit — Edit dealer details - dealers.delete — Delete dealer - dealers.view_financial — View dealer financial summary - dealers.manage_packages — Assign/remove packages for dealer - dealers.manage_sub_dealers — Create/manage sub-dealers

Package Permissions - packages.view — View package list - packages.create — Create new package - packages.edit — Edit package - packages.delete — Delete package - packages.sync_radius — Trigger RADIUS sync for package

Reports Permissions - reports.financial — View financial reports - reports.customer — View customer reports - reports.dealer — View dealer performance reports - reports.radius — View RADIUS accounting reports - reports.collection — View collection reports

WhatsApp Permissions - whatsapp.view_templates — View message templates - whatsapp.create_template — Create new template - whatsapp.edit_template — Edit template - whatsapp.delete_template — Delete template - whatsapp.view_logs — View sent message log - whatsapp.send_campaign — Send bulk WhatsApp campaign - whatsapp.manage_sessions — Manage OpenWA sessions

RADIUS / Network Permissions - radius.view — View RADIUS tables - radius.edit — Edit RADIUS entries directly - radius.view_accounting — View radacct sessions

NAS / MikroTik Permissions - nas.view — View NAS list - nas.create — Add new NAS - nas.edit — Edit NAS - nas.delete — Delete NAS

OLT Permissions - olt.view — View OLT devices - olt.create — Add OLT device - olt.edit — Edit OLT - olt.poll — Trigger manual ONT poll

Area Permissions - areas.view — View areas - areas.create — Create area - areas.edit — Edit area - areas.delete — Delete area

Health / System Permissions - health.view — View health dashboard - health.manage_cron — Create/edit/delete cron jobs - health.view_logs — View application error logs

Audit Permissions - audit.view — View audit log - audit.export — Export audit log

Settings Permissions - settings.view — View system settings - settings.edit — Edit system settings


9. THE DEALER SCOPING PATTERN

This is the single most important pattern in PyroRadius. Every customer query MUST use it.

How It Works

// ALWAYS — never skip this on customer queries in controllers
Customer::query()
    ->visibleTo(auth()->user())
    ->with(['package', 'area'])
    ->paginate(50);

The visibleTo() Scope Logic

// In Customer.php
public function scopeVisibleTo(Builder $query, User $user): Builder
{
    if ($user->isAdmin() || $user->isStaff()) {
        return $query; // no filter — see all
    }

    if ($user->isDealer()) {
        return $query->where('dealer_id', $user->id);
    }

    if ($user->isSubDealer()) {
        return $query->where('sub_dealer_id', $user->id);
    }

    if ($user->isFieldEngineer()) {
        return $query->where('dealer_id', $user->parent_id);
    }

    if ($user->isCollectionOfficer()) {
        return $query->where(function ($q) use ($user) {
            $q->whereIn('dealer_id', $user->assignedDealerIds())
              ->orWhereIn('area_id', $user->assignedAreaIds());
        });
    }

    // Unknown role — see nothing
    return $query->whereRaw('1=0');
}

Rules for Using the Scope

  1. Use it on EVERY Customer::query() in controllers. No exceptions.
  2. Do NOT use it in console commands that process all customers (CheckExpiry needs all customers).
  3. When displaying a customer count on the dashboard, apply the scope so dealers see only their count.
  4. When searching for a customer by pppoe_username (e.g., for RADIUS status lookup), admin routes may bypass scope — document this explicitly.

The "View As" Feature

Admins can impersonate a dealer view. When active: - session('view_as_dealer') contains the dealer's User model - DashboardController::index() passes this to the view - Customer queries in this mode use the impersonated dealer as the scope user - Does NOT actually change auth()->user() — only affects display and some queries


10. BUSINESS RULES

These rules are enforced in code. Do not add features that violate them.

  1. One active invoice per billing period per customer. generateInvoice() checks for an existing unpaid invoice for the same period before creating a new one (idempotency).

  2. Receipt numbers are sequential and never reused. seq_receipt_no PostgreSQL sequence generates them. Never insert a payment with a manually chosen receipt number.

  3. Customer codes are sequential and never reused. customer_code_seq generates them with prefix PR. Customer::nextCode() is the only way to get the next code.

  4. RADIUS sync happens immediately after any customer state change. After recharge → sync. After suspend → sync. After package change → sync. Never defer RADIUS sync to a cron job unless explicitly noted.

  5. Payments are double-entry. Every payment creates two ledger entries: one for the customer's account credit, one for the dealer's account credit.

  6. Dealer margin is not directly stored. It is calculated as sale_price - cost_price from dealer_package. Reports calculate it dynamically — do not add a margin column.

  7. PPPoE password is stored as cleartext. FreeRADIUS uses PAP authentication which requires the plaintext password in radcheck. This is intentional. Do not hash it.

  8. Soft deletes everywhere. Customers, users, packages use SoftDeletes. Never hard-delete a customer. The deleted_at column marks deletion.

  9. Expiry dates are inclusive. A customer with expiry_date = 2026-07-13 is valid through end of day 2026-07-13. The CheckExpiry command uses < today (i.e., < 2026-07-14) to find expired customers.

  10. Sub-dealers cannot have sub-dealers. The hierarchy is exactly two levels: dealer → sub_dealer. A sub_dealer's parent_id is always a dealer. Enforce this on user create.

  11. A customer belongs to exactly one dealer. dealer_id is NOT NULL. If a sub-dealer creates a customer, dealer_id = the sub-dealer's parent dealer, and sub_dealer_id = the sub-dealer.

  12. CIR customers have static IPs. CIR package type = 'cir'. These customers have a static_ip field. RADIUS must return Framed-IP-Address for them.

  13. WhatsApp messages are logged whether they succeed or fail. Every send attempt (including failures) inserts a whatsapp_logs row with status and response.

  14. No financial data is ever deleted. Payments, invoices, and ledger entries are never hard-deleted. If a payment was made in error, a reversal entry is created.

  15. Area assignments are optional. A customer may have no area (area_id = null). Area is used for geographic grouping and collection officer scoping but is not required for billing or RADIUS.

  16. Packages have RADIUS groups. A package without a radius_group set cannot be assigned to a customer (validation prevents it).

  17. All four vendor groups are created per package. When syncPackage() is called, it creates MikroTik, Cisco, Juniper, and Other radgroupreply rows even if only MikroTik is used. This allows switching NAS vendor without re-syncing.

  18. OLT rx_power is stored as a decimal dBm value. ont_cache.rx_raw stores power × 100 (integer). customers.rx_power stores the decimal (e.g., -22.50). Conversion happens in SyncOntAssignments.

  19. Session kick requires NAS secret. The radclient command needs the NAS shared secret to send CoA. This is decrypted from nas.api_password at runtime and written to a temp file that is immediately deleted after use.

  20. Bulk WhatsApp campaigns respect the session's rate limit. SendCampaignJob dispatches one message per job, with a configured delay between jobs to avoid OpenWA rate limiting.

  21. Cron jobs are stored in the database. All scheduled commands are in the cron_jobs table. The scheduler in routes/console.php reads this table at runtime and registers commands dynamically. Adding a command without a cron_jobs row means it never runs.

  22. Health checks run on every health page load. The HealthController collects metrics at request time — there is no separate health daemon.

  23. Dealer can see all their sub-dealers' customers. When a dealer queries customers, the scope includes both dealer_id = self AND sub-dealer customers (via the dealer_id field which is always the top-level dealer).

  24. User passwords expire policy is not enforced in code. There is no forced password rotation. Document this as a security recommendation for the admin.

  25. Package changes trigger immediate RADIUS sync. Changing a customer's package immediately updates radusergroup to the new group and runs syncCustomer. The old session may continue at the old speed until the customer reconnects (PPPoE session is not automatically kicked — a separate kick may be needed).

  26. The temp_extended status is a grace period. Admin manually grants it. RADIUS still allows connection. It is a flag only — no automatic transition logic exists. Manual follow-up required.

  27. Global settings override is not possible from dealer level. Dealers cannot change system settings. Only admin and super_admin can change settings via the Settings page.

  28. All monetary amounts are in PKR. No multi-currency support. Amounts stored as integer or decimal columns without currency code — PKR is implied.

  29. Import creates customers in bulk but each one triggers individual RADIUS sync. There is no bulk RADIUS insert in the import flow. Each row calls syncCustomer() separately. This is slower but safer.

  30. Audit log captures both old and new state as JSON. The old and new columns store the model's attribute arrays before and after the change. This allows reconstructing what changed.


11. NAMING CONVENTIONS

PHP / Laravel

  • Classes: PascalCase — CustomerController, BillingService, CheckExpiry
  • Methods: camelCase — recordPayment(), syncCustomer(), visibleTo()
  • Variables: camelCase — $billingService, $activeCustomers
  • Database columns: snake_case — expiry_date, pppoe_username, dealer_id
  • Constants: UPPER_SNAKE_CASE — ALL_PERMISSIONS, STATUS_ACTIVE
  • Route names: dot notation — customers.show, billing.recharge, api.mobile.login
  • Service injection: Constructor injection preferred; facade usage only for DB::, Log::, Cache::

React / JavaScript

  • Components: PascalCase — CustomerTable, RechargeModal, SignalIndicator
  • Files: PascalCase for pages and components — Customers/Index.jsx, Components/DataTable.jsx
  • Hooks: camelCase with use prefix — usePermissions, useDarkMode, useCustomerFilters
  • Props: camelCase — customerId, onSuccess, isLoading
  • CSS classes: Tailwind utility classes — no custom CSS unless absolutely necessary
  • State variables: camelCase with descriptive names — isSubmitting, selectedCustomers

Database

  • Tables: plural snake_case — customers, audit_logs, whatsapp_templates
  • RADIUS tables: singular no underscores (FreeRADIUS convention) — radcheck, radusergroup, radgroupreply, radacct
  • Primary keys: id (bigint)
  • Foreign keys: {table_singular}_iddealer_id, package_id, area_id
  • Timestamps: created_at, updated_at, deleted_at
  • Soft delete: deleted_at nullable timestamp
  • Boolean columns: is_ prefix or adjective — is_active, enabled, without_overlap
  • Sequence names: seq_{purpose}seq_receipt_no, customer_code_seq

Routes

  • Web routes: RESTful resource naming — /customers, /customers/{customer}, /customers/{customer}/recharge
  • API routes: Versioned under prefix — /api/mobile/...
  • Named routes: Use route names in code, never hardcode URLs
  • Middleware: Named middleware groups — auth, verified, admin, dealer

12. CUSTOMER LIFECYCLE

Statuses

Status RADIUS Description
active Allow Subscribed and paid up
temp_extended Allow Grace period granted by admin
expired Reject Expiry date passed, payment overdue
suspended Reject Admin manually suspended
disabled Reject Permanently disabled account
deleted No entries Soft-deleted, all RADIUS entries removed

Status Transitions

  • Created → active: On Customer::create(). RadiusService::syncCustomer() runs.
  • active → expired: CheckExpiry command runs daily at 00:05. Adds Auth-Type=Reject to radcheck.
  • expired → active: On BillingService::renew() after payment. Removes Auth-Type=Reject, re-syncs.
  • active → temp_extended: Admin action (manual). No RADIUS change.
  • temp_extended → active: On payment. Renew runs normally.
  • temp_extended → expired: CheckExpiry still runs. Grace period offers no protection from expiry.
  • active → suspended: Admin action. Adds Auth-Type=Reject.
  • suspended → active: Admin re-enables. Removes Auth-Type=Reject, re-syncs.
  • active → disabled: Admin action. Adds Auth-Type=Reject.
  • disabled → active: Admin re-enables. Re-syncs.
  • active → deleted: Soft delete. deleteCustomerRadius() removes ALL radcheck, radusergroup entries.
  • deleted → active: Restore action. syncCustomer() re-creates all RADIUS entries.

13. BILLING FLOW

Step-by-Step: Full Recharge

  1. Staff calls POST /customers/{id}/recharge with amount, payment_method, note.

  2. CustomerController::recharge() validates input and calls BillingService::generateInvoice($customer).

  3. generateInvoice() checks if an unpaid invoice for the current billing period already exists (idempotency). If yes, returns the existing invoice. If no, calculates period_from, period_to, amount (from customer.monthly_price), and inserts a new invoice with status=unpaid.

  4. CustomerController calls BillingService::recordPayment($customer, $invoice, $amount, $method).

  5. recordPayment() opens a DB::transaction():

  6. Inserts into paymentsreceipt_no from nextval('seq_receipt_no').
  7. Inserts two rows into ledger_entries (double-entry: customer credit, dealer credit).
  8. Calls Invoice::lockForUpdate() then updates paid_amount and status.
  9. If invoice fully paid: calls renew($customer, $invoice).
  10. If excess amount: calls applyExcessToUnpaid() for any other unpaid invoices.
  11. Commits transaction.
  12. On 40P01 or 55P03 error: retries up to 3 times with 50ms × attempt exponential backoff.

  13. renew() updates customer.expiry_date (adds one month or sets fixed date), sets customer.status = active.

  14. syncCustomer() updates all four RADIUS tables to reflect current customer state.

  15. WhatsAppService::sendToCustomer('payment_received', $customer) is called AFTER the transaction commits.

  16. AuditLogger::log('customer.recharged', $before, $after) is called.

  17. Response: Redirect back with flash success message and invoice/payment details.


14. RADIUS ARCHITECTURE

Tables Used

Table Purpose Key Columns
radcheck Per-user auth rules username, attribute, op, value — key row: attribute='Cleartext-Password' or 'Auth-Type'
radusergroup User → group mapping username, groupname, priority
radgroupreply Group speed/attribute groupname, attribute, op, value — key: Mikrotik-Rate-Limit
radacct Session accounting username, acctstarttime, acctstoptime, nasipaddress, acctinputoctets
nas NAS device list nasname, nasipaddress, secret, type

Sync Flow (syncCustomer)

syncCustomer($customer):
  1. radcheck: updateOrCreate where username=pppoe_username AND attribute='Cleartext-Password' → value=pppoe_password
  2. radcheck: delete WHERE username=pppoe_username AND attribute='Auth-Type' (remove any existing reject)
  3. radusergroup: deleteWhere username=pppoe_username, then insert groupname=package.radius_group, priority=1
  4. radgroupreply: already maintained by syncPackage() — not touched here

Reject Flow (rejectCustomerRadius)

rejectCustomerRadius($customer):
  radcheck: updateOrCreate username=pppoe_username, attribute='Auth-Type', op=':=', value='Reject'

Delete Flow (deleteCustomerRadius)

deleteCustomerRadius($customer):
  radcheck: deleteWhere username=pppoe_username
  radusergroup: deleteWhere username=pppoe_username
  (radgroupreply is NOT touched — it belongs to the package group, not the user)

Vendor Groups Per Package

For a package with radius_group = 10M-FTTH, syncPackage creates: - 10M-FTTH → Mikrotik-Rate-Limit: 10M/10M - 10M-FTTH_cisco → Cisco-AVPair qos policies + WISPr bandwidth - 10M-FTTH_juniper → ERX policy names + WISPr bandwidth - 10M-FTTH_other → WISPr bandwidth only

FreeRADIUS picks the right group based on NAS-Identifier or NAS vendor VSA.

Important RADIUS Notes

  • FreeRADIUS connects to PostgreSQL directly — it reads radcheck, radusergroup, radgroupreply in real time.
  • Changes to these tables take effect on the NEXT authentication attempt. Active sessions use cached speed from the Access-Accept reply.
  • radacct rows are written by FreeRADIUS automatically via accounting packets — PyroRadius reads them but does not write them (except radclient for CoA).
  • The nas table is read by FreeRADIUS for the shared secret lookup. It must be kept in sync with actual routers.

15. MIKROTIK INTEGRATION

RouterOS API

  • Library: evilfreelancer/routeros-api-php
  • Connection: TCP port 8728 to router IP
  • Credentials: nas.api_user and decrypted nas.api_password
  • Used for: reading active PPPoE sessions, reading interface stats, making changes if needed

SNMP Queries

  • Used for: interface traffic stats, uptime, system info
  • Protocol: SNMPv2c with community string from NAS settings
  • MIB: IF-MIB for interface counters
  • PHP function: snmp_get() and snmp_walk()

CoA Session Disconnect (radclient)

  • When a customer is suspended, disabled, or manually kicked, RadiusService::kickSession() is called.
  • It looks up active radacct rows (acctstoptime IS NULL) for the customer.
  • For each session, it finds the NAS record, decrypts the shared secret, writes it to a temp file (chmod 600).
  • It shells out: radclient -x -r 1 -t 2 -S /tmp/secret nasip:3799 disconnect
  • CoA port 3799 is tried first; if no response, port 1700 (some older MikroTik firmware).
  • The temp file is always deleted in a finally block.
  • Input sanitization via sanitizeRadiusString() prevents shell injection from the NAS IP or username.

16. OLT/GPON INTEGRATION

Components

  • OltDevice model: stores OLT IP, SSH credentials, device type (ZTE, Huawei, etc.)
  • OntCache model: cached ONT data from last poll (sn_decoded, description, rx_raw)
  • PollOltOnts command: runs every minute, SSHes to OLT, parses ONT list, upserts ont_cache
  • SyncOntAssignments command: runs every minute, matches ont_cache.description to customers.pppoe_username (case-insensitive), bulk UPDATEs rx_power

Signal Data Flow

  1. OLT device stores ONT serial number (sn_decoded) and a description field (usually set to the customer's PPPoE username by the field engineer)
  2. PollOltOnts SSHes into the OLT and retrieves this list
  3. ont_cache is upserted — rx_raw = rx_power × 100 (stored as integer to avoid float issues)
  4. SyncOntAssignments runs a single bulk SQL UPDATE matching on LOWER(TRIM(description)) = LOWER(pppoe_username)
  5. Customers table gets ont_serial, rx_power (decimal dBm), and rx_power_at (timestamp of last power change)

2026-07-13 Performance Fix

Before fix: UPDATE ran on all 2,144 rows unconditionally → 14 seconds → held row locks, blocked recharge operations. After fix: UPDATE has IS DISTINCT FROM guard → only touches changed rows → 418ms. Functional indexes on LOWER(TRIM(description)) and LOWER(pppoe_username) support the join.

Signal Thresholds (display logic in frontend)

  • Above -25 dBm: Good (green)
  • -25 to -27 dBm: Marginal (yellow)
  • Below -27 dBm: Poor (red)
  • NULL: No ONT data (grey)

17. WHATSAPP INTEGRATION

Driver

  • Primary driver: openwa — self-hosted OpenWA session
  • Fallback driver: log — writes to Laravel log file (used in development)
  • Driver set in config/whatsapp.php or via Settings table

Template System

  1. Templates stored in whatsapp_templates table
  2. Each template has a key (e.g., payment_received, account_expired, welcome)
  3. Template can be dealer-specific (dealer_id = X) or global (dealer_id = NULL)
  4. Lookup order: dealer-specific first, then global, then null → no send

Placeholder Rendering

Templates contain {placeholders} that are replaced at send time: - {customer_name} — customer full name - {amount} — payment amount formatted as PKR - {receipt_no} — payment receipt number - {expiry_date} — new expiry date - {pppoe_username} — PPPoE username - {package_name} — package name - {dealer_name} — dealer name

Send Flow

  1. Check enabled()Setting::get('whatsapp_enabled')
  2. Lookup template (dealer-specific then global)
  3. If no template: return null, skip send
  4. Render body with placeholder substitution
  5. Determine destination: customer.whatsapp if set, else customer.mobile
  6. If driver = openwa: lookup WhatsappSession WHERE status=ready LIMIT 1, POST to OpenWA
  7. Insert row in whatsapp_logs with status and raw response
  8. Return log record

When Messages Are Sent

  • payment_received — after every successful payment
  • account_expired — during CheckExpiry command run
  • welcome — optionally on customer create (if template exists)
  • expiry_reminder — SendExpiryReminders command (3 days before expiry)
  • Manual sends from customer detail page if user has customers.send_whatsapp permission

18. THE PERMISSION CHECK PATTERN

PHP (Controller)

// Method 1: Gate/Policy
$this->authorize('recharge', $customer); // Uses CustomerPolicy

// Method 2: Direct permission check
if (! auth()->user()->can('billing.recharge')) {
    abort(403);
}

// Method 3: Route middleware (in web.php)
Route::post('/customers/{customer}/recharge', [CustomerController::class, 'recharge'])
    ->middleware('permission:billing.recharge');

// Method 4: User model helper
if (auth()->user()->isAdmin()) {
    // admin bypass — no permission check needed
}

User Model can() Method

// User.php
public function can($ability, $arguments = []): bool
{
    if ($this->isAdmin() || $this->isSuperAdmin()) {
        return true; // full bypass
    }
    return (bool) ($this->permissions[$ability] ?? false);
}

React (Frontend)

// usePermissions hook
const { can } = usePermissions();

// Inertia shared props via HandleInertiaRequests
// 'can' object is an array of all abilities the user has

// Usage in JSX
{can('billing.recharge') && (
    <Button onClick={openRechargeModal}>Recharge</Button>
)}

// For pages — check at render time
if (!can('customers.view')) {
    return <AccessDenied />;
}

Important Notes

  • Frontend permission checks are display-only guards. The backend ALWAYS re-checks permissions.
  • Never show a button that would hit a 403. Always guard both backend and frontend.
  • Admin and super_admin see all buttons regardless of permissions JSONB.

19. THE AUDITLOGGER PATTERN

When to Use

  • Any create/update/delete on: customers, payments, invoices, packages, NAS, dealers, users
  • Any RADIUS state change (sync, reject, delete)
  • Any status change (suspend, disable, restore)
  • Any permission change on a user

Usage Pattern

// Capture state BEFORE the change
$before = $customer->toArray();

// Make the change
$customer->update(['status' => 'suspended']);

// Log AFTER the change succeeds
AuditLogger::log(
    action: 'customer.suspended',
    subject: $customer,
    old: $before,
    new: $customer->fresh()->toArray()
);

AuditLogger::log() Signature

public static function log(
    string $action,          // e.g., 'customer.recharged', 'customer.status_changed'
    mixed $subject,          // Model instance (the thing being changed)
    array|null $old = null,  // State before (null for creates)
    array|null $new = null,  // State after (null for deletes)
    ?User $by = null         // Who did it (defaults to auth()->user())
): void

What Gets Stored

  • action — the action string
  • subject_type — model class (App\Models\Customer)
  • subject_id — model ID
  • old — JSON of previous state
  • new — JSON of new state
  • user_id — who performed the action
  • ip_address — request IP
  • created_at — timestamp

20. THE RETRY PATTERN

When PostgreSQL Throws Lock Errors

  • 40P01 — Deadlock detected (two transactions trying to lock each other's rows)
  • 55P03 — Lock not available (lock_timeout exceeded before acquiring a row lock)

The Retry Wrapper

// Used in BillingService and anywhere concurrent writes happen
$attempts = 0;
$maxAttempts = 3;

retry:
try {
    $attempts++;
    DB::transaction(function () use ($customer, $amount) {
        // ... your transaction logic
    });
} catch (\Illuminate\Database\QueryException $e) {
    $pgCode = $e->getCode();
    if (in_array($pgCode, ['40P01', '55P03']) && $attempts < $maxAttempts) {
        usleep(50000 * $attempts); // 50ms, 100ms, 150ms
        goto retry;
    }
    throw $e; // re-throw after max attempts or non-retryable error
}

Where This Pattern Is Used

  • BillingService::recordPayment() — concurrent recharge attempts
  • BillingService::applyExcessToUnpaid() — concurrent excess application
  • Any controller action that updates the customers table concurrently with SyncOntAssignments

lock_timeout Setting

PyroRadius sets SET lock_timeout = '5s' at the session level for transactions that involve customer rows. This prevents indefinite blocking if another transaction holds the lock.


21. DEPLOY PROCEDURE

PHP-Only Change (no new migrations, no JS changes)

cd /var/www/pyroradius
git pull origin main
php artisan config:cache
php artisan route:cache
php artisan view:cache
sudo supervisorctl restart all

JS/React Change (frontend rebuild needed)

cd /var/www/pyroradius
git pull origin main
npm ci
npm run build
php artisan config:cache
php artisan route:cache
# No supervisor restart needed for JS-only changes

Database Migration

cd /var/www/pyroradius
git pull origin main
# REVIEW THE MIGRATION FIRST
php artisan migrate --pretend   # dry run
php artisan migrate             # run with operator approval
php artisan config:cache
php artisan route:cache
sudo supervisorctl restart all

Combined Deploy (code + JS + migrations)

cd /var/www/pyroradius
git pull origin main
composer install --no-dev --optimize-autoloader
npm ci
npm run build
php artisan migrate
php artisan config:cache
php artisan route:cache
php artisan view:cache
sudo supervisorctl restart all

Post-Deploy Checks

  1. Visit /health — verify all green
  2. Check queue workers are running: sudo supervisorctl status
  3. Verify FreeRADIUS is responding: radtest testuser testpass localhost 0 testing123
  4. Check Laravel logs: tail -f /var/www/pyroradius/storage/logs/laravel.log

22. DATABASE REFERENCE

Key Tables

Table Rows (approx) Purpose
customers 2,100+ All customer accounts
users 50+ Staff, dealers, sub-dealers
packages 20+ Internet packages
payments 10,000+ All payment records
invoices 10,000+ All invoice records
ledger_entries 20,000+ Double-entry ledger
audit_logs 50,000+ All audit records
radcheck 4,000+ RADIUS per-user check attributes
radusergroup 2,100+ RADIUS user-group mapping
radgroupreply 80+ RADIUS group speed attributes (4 per package)
radacct 100,000+ RADIUS session accounting
whatsapp_logs 20,000+ All WA messages sent
ont_cache 2,100+ Latest OLT ONT data
cron_jobs 20+ Scheduled command configuration

Critical Columns

customers: - id, customer_code (PR prefix), name, mobile, whatsapp, email - pppoe_username, pppoe_password (cleartext) - package_id, dealer_id, sub_dealer_id, area_id - status (active/expired/suspended/disabled/temp_extended) - expiry_date (date, inclusive) - monthly_price (decimal) - static_ip (nullable, for CIR) - ont_serial, rx_power, rx_power_at - created_at, updated_at, deleted_at

users: - id, name, email, password (bcrypt) - role (super_admin/admin/noc_admin/billing_admin/support_agent/auditor/field_engineer/collection_officer/dealer/sub_dealer) - permissions (JSONB — ability keys with boolean values) - parent_id (for sub_dealer and field_engineer — points to parent dealer) - is_active (boolean)

payments: - id, receipt_no (from seq_receipt_no), customer_id, invoice_id - amount, payment_method, note - recorded_by (user_id), created_at

radcheck: - id, username, attribute, op, value - Key attributes: Cleartext-Password, Auth-Type

Indexes Worth Knowing

  • idx_customers_dealer_id — used by visibleTo() scope
  • idx_customers_status — used by CheckExpiry
  • idx_customers_expiry_date — used by CheckExpiry and expiry report
  • idx_customers_pppoe_lower — functional index on LOWER(pppoe_username) WHERE deleted_at IS NULL
  • idx_ont_cache_desc_lower — functional index on LOWER(TRIM(description))
  • idx_radcheck_username — critical for FreeRADIUS auth lookup speed
  • idx_radusergroup_username — critical for FreeRADIUS group lookup
  • idx_radacct_username_stop — used by kickSession lookup

23. POSTGRESQL-SPECIFIC FEATURES USED

Sequences

-- Receipt number sequence (never resets, never gaps allowed)
CREATE SEQUENCE seq_receipt_no START WITH 1000;
nextval('seq_receipt_no') -- called in payment insert

-- Customer code sequence
CREATE SEQUENCE customer_code_seq START WITH 1;
nextval('customer_code_seq') -- called in Customer::nextCode()

JSONB Column

-- users.permissions stores ability map
-- Example value: {"customers.view": true, "billing.recharge": false}
-- Query: WHERE permissions->>'customers.view' = 'true'
-- Laravel: $user->permissions['customers.view'] ?? false

Functional Indexes

-- Used by SyncOntAssignments case-insensitive join
CREATE INDEX idx_ont_cache_desc_lower ON ont_cache (LOWER(TRIM(description)));
CREATE INDEX idx_customers_pppoe_lower ON customers (LOWER(pppoe_username)) WHERE deleted_at IS NULL;

IS DISTINCT FROM

-- Used in SyncOntAssignments to only update changed rows
WHERE (c.ont_serial IS DISTINCT FROM o.sn_decoded
   OR c.rx_power IS DISTINCT FROM ROUND(o.rx_raw::decimal / 100, 2))
-- IS DISTINCT FROM handles NULL correctly (NULL IS DISTINCT FROM 'value' = true)
-- Regular != operator does not handle NULL correctly

SKIP LOCKED

-- Used in queue worker patterns to avoid competing for the same job
SELECT * FROM jobs WHERE ... FOR UPDATE SKIP LOCKED LIMIT 1;
-- Prevents multiple workers from picking the same queued job

lock_timeout

-- Set at session level before transactions on hot rows
SET lock_timeout = '5s';
-- If the row lock cannot be acquired in 5 seconds, raises 55P03
-- Allows retry logic to kick in rather than waiting indefinitely

Row Locking

// Laravel Eloquent
Invoice::lockForUpdate()->find($id);  // SELECT ... FOR UPDATE
// Used in recordPayment() to prevent double-payment race conditions

24. REDIS USAGE

Caching

  • Driver: Redis (configured in config/cache.php)
  • Default TTL: 60 minutes for most cached values
  • Cached items:
  • settings.* — System settings (TTL: 10 minutes, invalidated on settings save)
  • dashboard.stats.{dealer_id} — Dashboard counts (TTL: 5 minutes)
  • packages.active — Active package list (TTL: 30 minutes)
  • areas.all — Area list (TTL: 60 minutes)

Queue

  • Driver: Redis (configured in config/queue.php)
  • Queues: default, whatsapp, radius
  • Job serialization: Laravel's standard job serialization (PHP serialize)
  • Failed jobs: Stored in failed_jobs table in PostgreSQL

Cache Invalidation Pattern

// When settings are saved
Cache::forget('settings.whatsapp_enabled');
Cache::tags('settings')->flush(); // if using tags

// When a package is updated (cascades to clear speed-related caches)
Cache::forget('packages.active');

25. QUEUE ARCHITECTURE

Three Named Queues

Queue Workers Jobs Purpose
default 1-2 SendCampaignJob, general tasks General background work
whatsapp 1 SendWhatsAppNotificationJob WhatsApp sends (rate limited)
radius 1 SyncCustomerRadiusJob RADIUS sync operations

Supervisor Config (example)

[program:pyroradius-worker-default]
command=php /var/www/pyroradius/artisan queue:work redis --queue=default --tries=3 --timeout=90
autostart=true
autorestart=true
user=www-data

[program:pyroradius-worker-whatsapp]
command=php /var/www/pyroradius/artisan queue:work redis --queue=whatsapp --tries=3 --timeout=60
autostart=true
autorestart=true
user=www-data

[program:pyroradius-worker-radius]
command=php /var/www/pyroradius/artisan queue:work redis --queue=radius --tries=5 --timeout=30
autostart=true
autorestart=true
user=www-data

Failed Job Handling

  • Failed jobs go to the failed_jobs table in PostgreSQL
  • Retry with: php artisan queue:retry {id}
  • Retry all: php artisan queue:retry all
  • Delete all failed: php artisan queue:flush
  • After 3 failures, the job stays in failed_jobs and does not auto-retry

26. SCHEDULED COMMANDS

All commands are registered in cron_jobs table and loaded dynamically in routes/console.php. Below is the full list:

Command Schedule Purpose
app:check-expiry Daily 00:05 Find expired customers, reject in RADIUS, send WA
app:poll-olt-onts Every minute SSH to OLTs, upsert ont_cache
app:sync-ont-assignments Every minute Match ont_cache to customers, update rx_power
app:send-expiry-reminders Daily 08:00 Send WA to customers expiring in 3 days
app:record-sys-stats Every 5 minutes Record CPU/RAM/Disk to sys_stats_history
app:billing-archive Monthly, 1st, 02:00 Archive old invoices and payments
app:send-campaign-batch Hourly Process pending WhatsApp campaigns
app:cleanup-whatsapp-logs Weekly Purge WA logs older than 90 days
app:check-radius-sessions Every 10 minutes Reconcile active radacct with known NAS sessions
app:sync-nas-secrets Daily 03:00 Verify NAS shared secrets are consistent
app:cleanup-temp-files Daily 04:00 Remove any leftover /tmp secret files
app:export-daily-report Daily 06:00 Generate and email daily collection summary
app:refresh-dashboard-cache Every 15 minutes Warm dashboard statistics cache
app:check-olt-connectivity Every 5 minutes Ping OLT devices, update health status
app:generate-invoices-bulk Monthly, 1st, 01:00 Pre-generate next month's invoices
app:check-queue-health Every 2 minutes Alert if queue workers are not running
app:sync-all-radius Manual only Full re-sync of all customers to RADIUS
app:import-legacy-customers Manual only One-time migration tool
app:verify-radius-integrity Weekly Sunday 03:00 Check radcheck vs customers for orphans
app:cleanup-old-audit-logs Monthly Archive/purge audit logs older than 2 years

27. API ARCHITECTURE

Web API (Inertia)

  • Protocol: HTTP/HTTPS with Laravel session cookies
  • Auth: Laravel session (cookie-based)
  • CSRF: X-XSRF-TOKEN header (Inertia handles this automatically)
  • Response format: Inertia JSON response wrapping page component + props
  • Routes: All in routes/web.php, named routes, standard RESTful
  • No versioning — monolith, same version as backend

Mobile API (Sanctum Token)

  • Protocol: HTTP/HTTPS with Bearer token
  • Auth: Sanctum personal access tokens (stored in personal_access_tokens table)
  • Response format: JSON — {data: ..., message: ..., meta: ...} structure
  • Routes: All in routes/api.php, prefixed /api/mobile/
  • Versioning: v1 implied by prefix, no formal versioning yet
  • Throttling: Login 5/min, read endpoints 60/min, write endpoints 10/min

28. MOBILE API

Endpoints

Method Path Throttle Purpose
POST /api/mobile/login 5/min Login, returns Sanctum token
POST /api/mobile/logout - Revoke current token
GET /api/mobile/me 60/min Current user info
GET /api/mobile/customers 60/min Customer list (scoped to user)
GET /api/mobile/customers/{id} 60/min Customer detail
POST /api/mobile/customers 10/min Create customer
PUT /api/mobile/customers/{id} 10/min Update customer
POST /api/mobile/customers/{id}/recharge 10/min Process payment
GET /api/mobile/customers/{id}/invoices 60/min Invoice history
GET /api/mobile/packages 60/min Package list
GET /api/mobile/areas 60/min Area list
GET /api/mobile/dashboard 60/min Dashboard stats
POST /api/mobile/customers/{id}/kick 10/min Kick PPPoE session

Response Format

{
  "data": { ... },
  "message": "Success",
  "meta": {
    "current_page": 1,
    "last_page": 5,
    "per_page": 20,
    "total": 97
  }
}

Error Responses

{
  "message": "The email field is required.",
  "errors": {
    "email": ["The email field is required."]
  }
}

29. FRONTEND ARCHITECTURE

Inertia Pattern

  • Server renders page component name + props
  • Browser receives JSON on subsequent navigations (no full page reload)
  • app.blade.php bootstraps the React app once
  • HandleInertiaRequests::share() passes auth.user, flash, can, settings to EVERY page

Shared Props (available on every page)

// In any React component, via usePage()
const { auth, flash, can, settings } = usePage().props;

auth.user      // current user object (id, name, role, etc.)
flash.success  // flash success message from Laravel redirect
flash.error    // flash error message
can            // object of ability keys → boolean (from user.permissions)
settings       // key-value system settings

The can() Helper

// resources/js/Hooks/usePermissions.js
export function usePermissions() {
  const { can } = usePage().props;
  return {
    can: (ability) => Boolean(can[ability]),
  };
}

// Usage in any component
const { can } = usePermissions();
if (can('billing.recharge')) { ... }
  • Inertia router.post(), router.put(), router.delete() for mutations
  • Inertia Link component for navigation (no <a href> for internal links)
  • Flash messages shown via flash.success / flash.error in the layout

30. REACT COMPONENT CONVENTIONS

File Organization

  • Pages in resources/js/Pages/ — one file per route (e.g., Customers/Index.jsx, Customers/Show.jsx)
  • Shared components in resources/js/Components/
  • Layouts in resources/js/Layouts/
  • Hooks in resources/js/Hooks/
  • Utilities in resources/js/lib/

Component Structure

// Standard page component structure
import { Head, Link, router, usePage } from '@inertiajs/react';
import AppLayout from '@/Layouts/AppLayout';
import { usePermissions } from '@/Hooks/usePermissions';

export default function CustomersIndex({ customers, filters }) {
    const { can } = usePermissions();
    const { flash } = usePage().props;

    // local state
    // handlers
    // render
    return (
        <AppLayout title="Customers">
            <Head title="Customers" />
            {/* content */}
        </AppLayout>
    );
}

Dark Mode

  • Tailwind dark: prefix for dark mode styles
  • useDarkMode() hook reads system preference and user preference from localStorage
  • Background: bg-white dark:bg-gray-900
  • Text: text-gray-900 dark:text-gray-100
  • Borders: border-gray-200 dark:border-gray-700

State Management

  • Local useState for UI state (modal open, form values)
  • Inertia form helper useForm() for form state (handles errors, processing flag)
  • No global state manager (no Redux, no Zustand) — rely on Inertia page props

31. SECURITY PATTERNS

CSRF Protection

  • Web routes: Inertia automatically sends X-XSRF-TOKEN on every mutation
  • API routes: Sanctum token in Authorization header (CSRF not required for token auth)
  • Never disable CSRF middleware on web routes

IDOR Prevention

  • All resource routes use {customer} Eloquent route model binding
  • The model binding automatically 404s if the ID doesn't exist
  • The visibleTo() scope must be applied BEFORE findOrFail() on any customer operation from a non-admin user
  • Pattern: load the customer, check $customer->dealer_id === auth()->id() OR use a Policy

Shell Injection Prevention

  • sanitizeRadiusString() strips characters that could escape shell context from NAS IP, username
  • Never pass user input directly to shell_exec(), exec(), or Process
  • radclient secret is passed via file (-S /tmp/file), never inline in the command string

API Rate Limiting

  • Mobile login: 5 attempts per minute (prevents brute force)
  • Write operations: 10 per minute (prevents abuse)
  • Read operations: 60 per minute

Encrypted Fields

  • nas.api_password — encrypted via Laravel's Crypt::encrypt() and Crypt::decrypt()
  • whatsapp_sessions.session_data — encrypted if contains credentials
  • APP_KEY rotation would break all encrypted values — never rotate without a migration plan

32. PERFORMANCE PATTERNS

Avoiding N+1 Queries

// ALWAYS eager load in index queries
Customer::query()
    ->visibleTo($user)
    ->with(['package', 'area', 'dealer'])
    ->paginate(50);

// NEVER in a loop without eager loading:
foreach ($customers as $customer) {
    echo $customer->package->name; // N+1!
}

IS DISTINCT FROM for Conditional Updates

-- GOOD: only updates rows where value actually changed
UPDATE customers
SET rx_power = new_value, rx_power_at = NOW()
WHERE rx_power IS DISTINCT FROM new_value;

-- BAD: updates all rows, holds locks on all rows
UPDATE customers SET rx_power = new_value;

Functional Indexes for Case-Insensitive Lookups

-- Create once; used every minute by SyncOntAssignments
CREATE INDEX idx_customers_pppoe_lower ON customers (LOWER(pppoe_username)) WHERE deleted_at IS NULL;
-- Query must match exactly:
WHERE LOWER(c.pppoe_username) = LOWER(o.description)

Caching for Repeated Reads

// Settings read on every request — cache them
$value = Cache::remember('settings.whatsapp_enabled', 600, function () {
    return Setting::where('key', 'whatsapp_enabled')->value('value');
});

Chunking for Large Datasets

// When processing all customers in a command
Customer::query()->chunk(200, function ($customers) {
    foreach ($customers as $customer) {
        // process
    }
});

Pagination

  • All list endpoints paginate — default 50 per page
  • Never load all customers into memory without chunking
  • Reports that aggregate data use SQL GROUP BY, not PHP collection aggregation

33. KNOWN ISSUES (as of 2026-07-13)

  1. Expiry date edit bug (PENDING FIX): Editing a customer's expiry date directly via the edit form sometimes does not persist correctly when SyncOntAssignments is running concurrently. Root cause under investigation — suspected lock contention. Workaround: retry the save if it appears to not take effect.

  2. WhatsApp bulk resume: If the bulk WhatsApp campaign is interrupted (worker restart), resuming may resend some messages that were already sent. No deduplication check exists for campaign jobs. Workaround: track sent count before and after restart.

  3. SyncOntAssignments git branch sync: The 2026-07-13 fix (IS DISTINCT FROM guard + functional indexes) exists in the working directory but has not been committed and pushed to the main branch. Must be committed and deployed.

  4. SubDealer creating customer sets dealer_id incorrectly in edge case: When a sub-dealer creates a customer while the parent dealer's ID has changed (rare edge case), the dealer_id may be stale. Impact: low frequency.

  5. radacct cleanup: Old radacct rows are never archived. Table will grow indefinitely. No archive command exists yet. Will impact query performance on large deployments.


34. RECENTLY FIXED ISSUES

SyncOntAssignments Performance (Fixed 2026-07-13)

Problem: SyncOntAssignments was updating ALL 2,144 customer rows every minute, holding PostgreSQL row locks for 14 seconds. This caused lock contention with concurrent recharge operations, leading to 55P03 errors and failed payment saves.

Root cause: The UPDATE statement had no condition to skip rows where values had not changed. PostgreSQL still acquires row locks and writes WAL for unchanged rows.

Fix: 1. Added IS DISTINCT FROM guard in the UPDATE WHERE clause — only rows with actual changes are updated. 2. Added rx_power_at conditional update — timestamp only changes when rx_power value changes. 3. Created functional index idx_ont_cache_desc_lower on LOWER(TRIM(description)). 4. Created functional index idx_customers_pppoe_lower on LOWER(pppoe_username) WHERE deleted_at IS NULL.

Result: Runtime dropped from 14 seconds to 418ms. Lock contention eliminated.

Customer Recharge Lock Timeout (Fixed previously)

Problem: Concurrent recharge attempts for the same customer caused 40P01 deadlock errors.

Fix: Added retry loop with exponential backoff (50ms × attempt, max 3 retries) in BillingService::recordPayment().


35. FUTURE ROADMAP

  • Mobile app (Flutter/React Native): The mobile API exists and is production-ready. A native app is planned.
  • Multi-ISP support: Architecture currently assumes single ISP. Adding tenancy (multiple ISPs in one installation) would require schema changes.
  • SMS gateway integration: Alternative to WhatsApp for areas with poor data connectivity.
  • PPPoE profile management from UI: Currently package speed changes require RADIUS group re-sync. A UI to manage MikroTik PPPoE profiles directly is planned.
  • Automated billing collection: Integration with Easypaisa/JazzCash payment gateway for self-service recharge.
  • Customer self-service portal: Web portal where customers can view their own invoices and signal strength.
  • radacct archiving command: Routine archival of sessions older than 6 months to a separate archive table.
  • Enhanced OLT support: Add ZTE C300/C600 and Huawei MA5800 SSH command parsers.
  • WhatsApp deduplication: Add idempotency check on campaign sends to prevent double messages on worker restart.
  • Expiry date edit concurrency fix: Investigate and resolve the lock contention issue with direct expiry date edits.

36. COMMON MISTAKES TO AVOID

Mistake 1: Forgetting visibleTo() on customer queries

WHY: Dealers see other dealers' data. This is a P0 security bug.

// WRONG
$customers = Customer::where('status', 'active')->get();

// CORRECT
$customers = Customer::query()->visibleTo(auth()->user())->where('status', 'active')->get();

Mistake 2: Hashing pppoe_password

WHY: FreeRADIUS uses PAP — it needs the cleartext password to compare. Hashing it breaks authentication for all customers.

Mistake 3: Sending WhatsApp inside a DB transaction

WHY: If the transaction rolls back, the message is already sent. Customer receives confirmation for a failed payment.

Mistake 4: Adding a new command without a cron_jobs row

WHY: The scheduler reads cron_jobs at runtime. A command not in that table never executes automatically.

Mistake 5: Using Customer::all() in a controller

WHY: No scope applied → dealer isolation broken. Also loads all 2,000+ customers into memory. Always query + paginate.

Mistake 6: Directly modifying radgroupreply without going through syncPackage()

WHY: syncPackage creates exactly 4 rows per package (one per vendor). Manual edits create orphaned or duplicate rows, breaking specific NAS vendor authentication.

Mistake 7: Calling radclient without sanitizing input

WHY: If NAS IP or username contains shell metacharacters, a command injection is possible. Always call sanitizeRadiusString().

Mistake 8: Assuming user.permissions is always set

WHY: For admin and super_admin, permissions JSONB may be null or empty — they bypass permission checks entirely. Never call $user->permissions['key'] without null check. Use $user->can('key').

Mistake 9: Not running AuditLogger on new financial actions

WHY: Billing disputes happen. Without an audit trail, there is no way to prove what happened. Every billing action needs a log entry.

Mistake 10: Using date() or Carbon::now() without timezone awareness

WHY: PyroRadius operates in PKT (Pakistan Standard Time, UTC+5). Always use Carbon::now('Asia/Karachi') or ensure the app timezone is set correctly in config/app.php.

Mistake 11: Adding a migration that modifies radcheck, radusergroup, or radgroupreply columns

WHY: FreeRADIUS reads these tables directly using a hardcoded SQL schema (via rlm_sql). Column names must remain exactly as FreeRADIUS expects: username, attribute, op, value, groupname.

Mistake 12: Returning redirect()->back() without a flash message

WHY: The UI has no way to tell the user what happened. Always flash a success or error message.


37. STANDARD CODE PATTERNS

Create a Customer (Controller)

public function store(StoreCustomerRequest $request): RedirectResponse
{
    $user = auth()->user();

    $customer = Customer::create([
        'customer_code' => Customer::nextCode(),
        'name'          => $request->name,
        'mobile'        => $request->mobile,
        'pppoe_username'=> $request->pppoe_username,
        'pppoe_password'=> $request->pppoe_password,
        'package_id'    => $request->package_id,
        'dealer_id'     => $user->isDealer() ? $user->id : $request->dealer_id,
        'sub_dealer_id' => $user->isSubDealer() ? $user->id : null,
        'area_id'       => $request->area_id,
        'expiry_date'   => $request->expiry_date,
        'monthly_price' => $request->monthly_price,
        'status'        => 'active',
    ]);

    app(RadiusService::class)->syncCustomer($customer);
    AuditLogger::log('customer.created', null, $customer->toArray(), $customer);

    return redirect()->route('customers.show', $customer)
        ->with('success', 'Customer created successfully.');
}

Check Permission in Controller

public function destroy(Customer $customer): RedirectResponse
{
    // Authorization check
    if (! auth()->user()->can('customers.delete')) {
        abort(403);
    }

    // Scope check (prevent IDOR)
    abort_unless(
        auth()->user()->isAdmin() || $customer->dealer_id === auth()->id(),
        403
    );

    $before = $customer->toArray();
    app(RadiusService::class)->deleteCustomerRadius($customer);
    $customer->delete();
    AuditLogger::log('customer.deleted', $before, null, $customer);

    return redirect()->route('customers.index')
        ->with('success', 'Customer deleted.');
}

Inertia Page Return with Eager Loading

public function index(Request $request): Response
{
    $customers = Customer::query()
        ->visibleTo(auth()->user())
        ->with(['package', 'area', 'dealer'])
        ->when($request->search, fn($q, $s) => $q->where('name', 'like', "%$s%")
            ->orWhere('pppoe_username', 'like', "%$s%"))
        ->when($request->status, fn($q, $s) => $q->where('status', $s))
        ->latest()
        ->paginate(50)
        ->withQueryString();

    return Inertia::render('Customers/Index', [
        'customers' => $customers,
        'filters'   => $request->only(['search', 'status']),
    ]);
}

Retry Wrapper for Concurrent Writes

$attempts = 0;
retry_transaction:
try {
    $attempts++;
    DB::transaction(function () use ($customer, $amount, $method) {
        // billing logic
    });
} catch (\Illuminate\Database\QueryException $e) {
    if (in_array($e->getCode(), ['40P01', '55P03']) && $attempts < 3) {
        usleep(50000 * $attempts);
        goto retry_transaction;
    }
    throw $e;
}

React Inertia Form with Permissions

import { useForm } from '@inertiajs/react';
import { usePermissions } from '@/Hooks/usePermissions';

export default function RechargeButton({ customer }) {
    const { can } = usePermissions();
    const { data, setData, post, processing, errors, reset } = useForm({
        amount: customer.monthly_price,
        payment_method: 'cash',
        note: '',
    });

    if (!can('billing.recharge')) return null;

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('customers.recharge', customer.id), {
            onSuccess: () => reset(),
        });
    };

    return (
        <form onSubmit={handleSubmit}>
            {/* form fields */}
            <button type="submit" disabled={processing}>
                {processing ? 'Processing...' : 'Recharge'}
            </button>
            {errors.amount && <p className="text-red-500">{errors.amount}</p>}
        </form>
    );
}

38. AI NAVIGATION INDEX

Use this table to quickly find which code to look at for any question.

Question Look At
How does recharge work? BillingService::recordPayment(), CustomerController::recharge()
How does expiry happen? CheckExpiry command, RadiusService::rejectCustomerRadius()
Why is expiry date not saving? Lock contention with SyncOntAssignments, CustomerController::update() retry loop
How does a customer authenticate on PPPoE? radcheck table, radusergroup, FreeRADIUS rlm_sql config
How is RADIUS synced after a change? RadiusService::syncCustomer(), called from BillingService::renew()
How to add a new permission? User::ALL_PERMISSIONS constant, User::defaultPermissions(), then migrate
How does dealer scoping work? Customer::scopeVisibleTo() in Customer.php
How does View As (impersonate dealer) work? DashboardController::viewAs(), session('view_as_dealer')
How are WhatsApp messages sent? WhatsAppService::sendToCustomer(), WhatsappTemplate model
How do templates work (placeholders)? WhatsAppService::render()
How is OLT signal data collected? PollOltOnts command, OntCache model
How is ONT matched to customer? SyncOntAssignments command — LOWER(TRIM(description)) join
Why did SyncOntAssignments run slowly? See Section 16 (IS DISTINCT FROM fix), Section 34 (fixed issues)
How to kick a PPPoE session? RadiusService::kickSession(), radclient binary
How do packages map to RADIUS speed? RadiusService::syncPackage(), radgroupreply table
What vendor RADIUS groups exist? syncPackage() — MikroTik, Cisco, Juniper, Other suffixes
How does mobile auth work? Sanctum tokens, routes/api.php, Api/AuthController
How are invoices generated? BillingService::generateInvoice()
How are ledger entries created? BillingService::recordPayment() — double-entry INSERT
What is seq_receipt_no? PostgreSQL sequence for unique receipt numbers
How is customer code generated? Customer::nextCode() using customer_code_seq
How are NAS secrets stored? nas.api_password encrypted with Crypt::encrypt()
How to add a new scheduled command? Create command, add row to cron_jobs table
How does the scheduler work? routes/console.php reads cron_jobs dynamically
What roles bypass permission checks? admin and super_admin in User::can()
How are sub-dealers scoped? Customer::scopeVisibleTo() — WHERE sub_dealer_id = user.id
How do field engineers see customers? WHERE dealer_id = user.parent_id
How do collection officers see customers? WHERE dealer_id IN assigned OR area_id IN assigned
How does the health dashboard work? HealthController::index() — collects at request time
Where are cron jobs managed? cron_jobs table, CronJobController, Health/CronJobs page
How are audit logs written? AuditLogger::log() static method, audit_logs table
How to add an audit log for a new action? Call AuditLogger::log('action.name', $old, $new, $subject)
What tables does FreeRADIUS use? radcheck, radusergroup, radgroupreply, radacct, nas
Why is pppoe_password stored as cleartext? FreeRADIUS PAP auth requires plaintext — this is intentional
How does the retry pattern work? Section 20, BillingService::recordPayment()
What PostgreSQL error codes trigger retry? 40P01 (deadlock), 55P03 (lock timeout)
How are dark mode styles applied? Tailwind dark: prefix, useDarkMode() hook
How does Inertia share data to all pages? HandleInertiaRequests::share() middleware
What is shared to every Inertia page? auth.user, flash, can (permissions), settings
How to check permission in React? usePermissions() hook → can('ability.name')
How to check permission in PHP? auth()->user()->can('ability.name')
How to add a new API endpoint for mobile? Add route in routes/api.php, create controller in Api/, use Sanctum middleware
How are customer imports done? CustomerController::import(), PhpSpreadsheet, per-row syncCustomer()
Where is the dealer package pricing stored? dealer_package table: dealer_id, package_id, cost_price, sale_price
What is the default app timezone? Asia/Karachi (PKT, UTC+5) — check config/app.php
Where are encrypted values stored? nas.api_password, optionally whatsapp_sessions.session_data
How to deploy PHP-only changes? git pull → config:cache → route:cache → supervisorctl restart
How to deploy JS changes? npm ci → npm run build (no supervisor restart needed)
How to deploy with migrations? See Section 21 Combined Deploy
Where are failed queue jobs stored? failed_jobs table in PostgreSQL
How to retry a failed job? php artisan queue:retry {id} or queue:retry all
What queues exist? default, whatsapp, radius (each with dedicated Supervisor worker)
How is the campaign WhatsApp sent? SendCampaignJob dispatched to default queue, one message per job
What triggers WhatsApp on payment? CustomerController::recharge() calls WhatsAppService::sendToCustomer() AFTER transaction
How to prevent sending WA inside a transaction? Call sendToCustomer() after DB::transaction() returns, not inside the closure
What is the mobile API base URL? /api/mobile/ — all mobile endpoints live here
What is the login throttle for mobile? 5 requests per minute
How are Inertia form submissions made? useForm() hook from @inertiajs/react, then post(), put(), delete()
How to add a new system setting? Insert row in settings table, read via Setting::get('key'), cache it
How is the dashboard online-now count calculated? See project_pyroradius_online_count.md memory entry
How to add a new package vendor type? Add suffix and attributes in RadiusService::syncPackage(), update NAS vendor detection
How do functional indexes help SyncOntAssignments? JOIN on LOWER() can use the index — without it, full sequential scan on 2,000+ rows every minute
What does IS DISTINCT FROM do differently than !=? Handles NULL correctly: NULL IS DISTINCT FROM 'value' is TRUE; NULL != 'value' is NULL (falsy)
Where are route names defined? routes/web.php and routes/api.php
How to redirect with a flash message? return redirect()->route('x')->with('success', 'message')
How to get flash in React? const { flash } = usePage().props; flash.success / flash.error
What is the customer code prefix? PR (e.g., PR02744) — defined in config/pyronet.php
How is the receipt number prefix set? config/pyronet.php receipt_prefix setting
What is the CIR customer type? package.type = 'cir', has static_ip, RADIUS returns Framed-IP-Address
How do sub-dealer customers appear to parent dealer? dealer_id = parent dealer ID, sub_dealer_id = sub-dealer ID — parent sees them via dealer_id scope
Where are the OLT SSH credentials? OltDevice model: ip, ssh_user, ssh_password (encrypted)
What is rx_raw in ont_cache? rx_power × 100 stored as integer to avoid float precision issues
How is rx_power_at updated? Only when rx_power value actually changes (IS DISTINCT FROM guard in SyncOntAssignments)
What signal level is considered poor? Below -27 dBm (red in UI), -25 to -27 marginal (yellow), above -25 good (green)