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¶
- Project Identity
- One-Paragraph Architecture Summary
- ABSOLUTE RULES — Never Violate These
- Stack and Versions
- Complete Folder Map
- Key Files Reference
- All 10 Roles and What They Can Do
- All ~80 Permissions
- The Dealer Scoping Pattern
- Business Rules
- Naming Conventions
- Customer Lifecycle
- Billing Flow
- RADIUS Architecture
- MikroTik Integration
- OLT/GPON Integration
- WhatsApp Integration
- The Permission Check Pattern
- The AuditLogger Pattern
- The Retry Pattern
- Deploy Procedure
- Database Reference
- PostgreSQL-Specific Features Used
- Redis Usage
- Queue Architecture
- Scheduled Commands
- API Architecture
- Mobile API
- Frontend Architecture
- React Component Conventions
- Security Patterns
- Performance Patterns
- Known Issues (as of 2026-07-13)
- Recently Fixed Issues
- Future Roadmap
- Common Mistakes to Avoid
- Standard Code Patterns
- 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¶
- Use it on EVERY
Customer::query()in controllers. No exceptions. - Do NOT use it in console commands that process all customers (CheckExpiry needs all customers).
- When displaying a customer count on the dashboard, apply the scope so dealers see only their count.
- 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.
-
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). -
Receipt numbers are sequential and never reused.
seq_receipt_noPostgreSQL sequence generates them. Never insert a payment with a manually chosen receipt number. -
Customer codes are sequential and never reused.
customer_code_seqgenerates them with prefix PR.Customer::nextCode()is the only way to get the next code. -
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.
-
Payments are double-entry. Every payment creates two ledger entries: one for the customer's account credit, one for the dealer's account credit.
-
Dealer margin is not directly stored. It is calculated as
sale_price - cost_pricefromdealer_package. Reports calculate it dynamically — do not add a margin column. -
PPPoE password is stored as cleartext. FreeRADIUS uses PAP authentication which requires the plaintext password in radcheck. This is intentional. Do not hash it.
-
Soft deletes everywhere. Customers, users, packages use
SoftDeletes. Never hard-delete a customer. Thedeleted_atcolumn marks deletion. -
Expiry dates are inclusive. A customer with
expiry_date = 2026-07-13is valid through end of day 2026-07-13. The CheckExpiry command uses< today(i.e.,< 2026-07-14) to find expired customers. -
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.
-
A customer belongs to exactly one dealer.
dealer_idis NOT NULL. If a sub-dealer creates a customer,dealer_id= the sub-dealer's parent dealer, andsub_dealer_id= the sub-dealer. -
CIR customers have static IPs. CIR package type = 'cir'. These customers have a
static_ipfield. RADIUS must returnFramed-IP-Addressfor them. -
WhatsApp messages are logged whether they succeed or fail. Every send attempt (including failures) inserts a
whatsapp_logsrow with status and response. -
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.
-
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.
-
Packages have RADIUS groups. A package without a
radius_groupset cannot be assigned to a customer (validation prevents it). -
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. -
OLT rx_power is stored as a decimal dBm value.
ont_cache.rx_rawstores power × 100 (integer).customers.rx_powerstores the decimal (e.g., -22.50). Conversion happens in SyncOntAssignments. -
Session kick requires NAS secret. The radclient command needs the NAS shared secret to send CoA. This is decrypted from
nas.api_passwordat runtime and written to a temp file that is immediately deleted after use. -
Bulk WhatsApp campaigns respect the session's rate limit.
SendCampaignJobdispatches one message per job, with a configured delay between jobs to avoid OpenWA rate limiting. -
Cron jobs are stored in the database. All scheduled commands are in the
cron_jobstable. 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. -
Health checks run on every health page load. The HealthController collects metrics at request time — there is no separate health daemon.
-
Dealer can see all their sub-dealers' customers. When a dealer queries customers, the scope includes both
dealer_id = selfAND sub-dealer customers (via thedealer_idfield which is always the top-level dealer). -
User passwords expire policy is not enforced in code. There is no forced password rotation. Document this as a security recommendation for the admin.
-
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).
-
The
temp_extendedstatus 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. -
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.
-
All monetary amounts are in PKR. No multi-currency support. Amounts stored as integer or decimal columns without currency code — PKR is implied.
-
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. -
Audit log captures both old and new state as JSON. The
oldandnewcolumns 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
useprefix —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}_id—dealer_id,package_id,area_id - Timestamps:
created_at,updated_at,deleted_at - Soft delete:
deleted_atnullable 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¶
-
Staff calls
POST /customers/{id}/rechargewithamount,payment_method,note. -
CustomerController::recharge() validates input and calls
BillingService::generateInvoice($customer). -
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 withstatus=unpaid. -
CustomerController calls
BillingService::recordPayment($customer, $invoice, $amount, $method). -
recordPayment() opens a
DB::transaction(): - Inserts into
payments—receipt_nofromnextval('seq_receipt_no'). - Inserts two rows into
ledger_entries(double-entry: customer credit, dealer credit). - Calls
Invoice::lockForUpdate()then updatespaid_amountandstatus. - If invoice fully paid: calls
renew($customer, $invoice). - If excess amount: calls
applyExcessToUnpaid()for any other unpaid invoices. - Commits transaction.
-
On 40P01 or 55P03 error: retries up to 3 times with 50ms × attempt exponential backoff.
-
renew() updates
customer.expiry_date(adds one month or sets fixed date), setscustomer.status = active. -
syncCustomer() updates all four RADIUS tables to reflect current customer state.
-
WhatsAppService::sendToCustomer('payment_received', $customer) is called AFTER the transaction commits.
-
AuditLogger::log('customer.recharged', $before, $after) is called.
-
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
nastable 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_userand decryptednas.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()andsnmp_walk()
CoA Session Disconnect (radclient)¶
- When a customer is suspended, disabled, or manually kicked,
RadiusService::kickSession()is called. - It looks up active
radacctrows (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
finallyblock. - Input sanitization via
sanitizeRadiusString()prevents shell injection from the NAS IP or username.
16. OLT/GPON INTEGRATION¶
Components¶
OltDevicemodel: stores OLT IP, SSH credentials, device type (ZTE, Huawei, etc.)OntCachemodel: cached ONT data from last poll (sn_decoded,description,rx_raw)PollOltOntscommand: runs every minute, SSHes to OLT, parses ONT list, upserts ont_cacheSyncOntAssignmentscommand: runs every minute, matches ont_cache.description to customers.pppoe_username (case-insensitive), bulk UPDATEs rx_power
Signal Data Flow¶
- OLT device stores ONT serial number (sn_decoded) and a description field (usually set to the customer's PPPoE username by the field engineer)
- PollOltOnts SSHes into the OLT and retrieves this list
ont_cacheis upserted —rx_raw= rx_power × 100 (stored as integer to avoid float issues)- SyncOntAssignments runs a single bulk SQL UPDATE matching on
LOWER(TRIM(description)) = LOWER(pppoe_username) - Customers table gets
ont_serial,rx_power(decimal dBm), andrx_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.phpor via Settings table
Template System¶
- Templates stored in
whatsapp_templatestable - Each template has a
key(e.g.,payment_received,account_expired,welcome) - Template can be dealer-specific (dealer_id = X) or global (dealer_id = NULL)
- 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¶
- Check
enabled()→Setting::get('whatsapp_enabled') - Lookup template (dealer-specific then global)
- If no template: return null, skip send
- Render body with placeholder substitution
- Determine destination:
customer.whatsappif set, elsecustomer.mobile - If driver = openwa: lookup
WhatsappSession WHERE status=ready LIMIT 1, POST to OpenWA - Insert row in
whatsapp_logswith status and raw response - Return log record
When Messages Are Sent¶
payment_received— after every successful paymentaccount_expired— during CheckExpiry command runwelcome— 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_whatsapppermission
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 stringsubject_type— model class (App\Models\Customer)subject_id— model IDold— JSON of previous statenew— JSON of new stateuser_id— who performed the actionip_address— request IPcreated_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 attemptsBillingService::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¶
- Visit
/health— verify all green - Check queue workers are running:
sudo supervisorctl status - Verify FreeRADIUS is responding:
radtest testuser testpass localhost 0 testing123 - 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() scopeidx_customers_status— used by CheckExpiryidx_customers_expiry_date— used by CheckExpiry and expiry reportidx_customers_pppoe_lower— functional index onLOWER(pppoe_username)WHERE deleted_at IS NULLidx_ont_cache_desc_lower— functional index onLOWER(TRIM(description))idx_radcheck_username— critical for FreeRADIUS auth lookup speedidx_radusergroup_username— critical for FreeRADIUS group lookupidx_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_jobstable 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_jobstable 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_tokenstable) - 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.phpbootstraps the React app onceHandleInertiaRequests::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')) { ... }
Navigation Pattern¶
- Inertia
router.post(),router.put(),router.delete()for mutations - Inertia
Linkcomponent for navigation (no<a href>for internal links) - Flash messages shown via
flash.success/flash.errorin 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
useStatefor 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 BEFOREfindOrFail()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(), orProcess - 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'sCrypt::encrypt()andCrypt::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)¶
-
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.
-
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.
-
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.
-
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.
-
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) |