PHASE2_06 — Developer Handbook¶
PyroRadius Enterprise Documentation | Phase 2 Last Updated: 2026-07-13 Audience: New and Existing Developers
CHAPTER 1: Welcome to PyroRadius¶
What PyroRadius Does¶
PyroRadius is the internal management platform for PyroNet Solutions — an ISP and IT services company based in Lahore, Pakistan. The system handles:
- Customer lifecycle — onboarding, billing, expiry, suspension, reconnection
- RADIUS integration — writes authentication credentials and speed limits directly to FreeRADIUS database tables so PPPoE sessions are authorized, shaped, and disconnected automatically
- Dealer network — multi-tier dealer hierarchy with isolated data scoping, custom package pricing, and per-dealer ledgers
- Network monitoring — MikroTik RouterOS API integration for live traffic, SNMP polling, OLT fiber signal monitoring
- WhatsApp communications — templated messages, bulk campaigns, automated recharge notifications
Who Uses It¶
| Role | What they do |
|---|---|
| Super Admin (PyroNet staff) | Full access — system config, all dealers, all customers |
| Admin | Broad access — manage customers, billing, reports |
| Dealer | Scoped to own customers only — recharge, add customers, view own ledger |
| Sub-Dealer | Scoped further — typically view only or limited billing |
| Staff | Role-based — defined per deployment |
Your Role¶
You are responsible for maintaining and extending the system that keeps PyroNet's internet infrastructure running. Mistakes in billing logic or RADIUS sync can result in customers losing internet, incorrect charges, or data leaks between dealers. Read this handbook fully before touching production.
First Things to Read¶
- This handbook (you are here)
PHASE2_01_Architecture_Overview.md— system architecture and tech stackPHASE2_03_Billing_Engine.md— billing, invoices, payments, expiry logicPHASE2_05_Dependency_Graph.md— what touches what
CHAPTER 2: Getting Access¶
Production Server¶
SSH: ssh -i ~/.ssh/your-key root@your-server-ip
App root: /var/www/pyroradius/
Logs: /var/www/pyroradius/storage/logs/laravel.log
Application Login¶
URL: https://pyroradius.pyronet.com.pk
Email: admin@pyronet.com.pk
Password: change-me-on-first-login
Role: super_admin
Change this password after your first login. Do not share it outside the development team.
Git Repository¶
Never push directly to main. Always work on a feature branch and submit a pull request. Production is deployed from main.
Database¶
Host: 127.0.0.1
Port: 6432 (PgBouncer — always use this, never connect to raw PostgreSQL port)
DB: pyroradius
User: pyroradius
Credentials are in /var/www/pyroradius/.env. Do not commit .env to git.
CHAPTER 3: Understanding the Codebase in 15 Minutes¶
The Inertia.js Pattern¶
PyroRadius is not a traditional REST API + SPA setup. It uses Inertia.js — the server renders data directly into React components without a separate API layer.
Browser request
→ Nginx (static assets served directly)
→ PHP-FPM (Laravel handles the request)
→ Controller (queries DB, applies scopes, returns Inertia response)
→ Inertia middleware (serializes props to JSON)
→ React component receives props (no fetch() needed for initial data)
→ React renders the page
When a user navigates within the app, Inertia intercepts the link click and makes an XHR request. Laravel returns only the updated props. The page component re-renders with new data. No full page reload.
What this means for you:
- There is no api/ route for page data. Data comes from Inertia controller responses.
- If you need to expose data for React, add it to the controller's Inertia::render() call.
- Form submissions go to standard Laravel routes (POST/PUT/DELETE) — Inertia handles them transparently.
Where to Find Things¶
routes/web.php — all application routes
app/Http/Controllers/ — request handlers
app/Services/ — business logic (BillingService, RadiusService, etc.)
app/Models/ — Eloquent models
resources/js/Pages/ — React page components (one per route)
resources/js/Components/ — shared React components
app/Console/Commands/ — Artisan commands (cron jobs)
database/migrations/ — schema history
The Dealer Scoping Pattern (MOST IMPORTANT)¶
Every query that touches customer data must use the visibleTo($user) scope. This is the primary data isolation mechanism for the dealer hierarchy.
// CORRECT — dealer only sees their own customers
Customer::visibleTo($user)->where('status', 'active')->get();
// WRONG — admin sees all, but dealer sees EVERYTHING (IDOR bug)
Customer::where('status', 'active')->get();
The visibleTo() scope applies dealer_id = $user->id for dealer-role users and returns all records for admin/super_admin. If you forget this scope on a route accessible to dealers, dealers can read each other's customer data.
Every new controller action that queries customers, invoices, payments, or tickets must use visibleTo(). No exceptions.
The Permission Pattern¶
Permissions are stored as a JSONB array on the users table. Checks happen in two places:
Server-side (in controllers):
$this->authorize('view-customers'); // throws 403 if not allowed
// or
if (!$user->can('delete-customer')) abort(403);
Client-side (in React, for UI only — not a security boundary):
Client-side can() only hides UI elements. All actual authorization must be enforced server-side.
CHAPTER 4: Making Your First Change¶
Step-by-Step Walkthrough¶
1. Create a feature branch
2. Locate the relevant controller and React page
Start from the route:
Find the controller method, then find the Inertia page component name fromInertia::render('PageName').
3. Make the backend change
Edit the controller or service. Keep business logic in services, not controllers.
4. Clear and rebuild PHP
After any PHP change:
5. Rebuild frontend (if you changed JS/React)
6. Test in browser
Log in as both a super_admin and a dealer to verify scoping is correct.
7. Commit and push
git add -p # review what you are staging
git commit -m "feat: describe the change clearly"
git push origin feature/my-change
Open a pull request on GitHub. Do not merge your own PR — have another team member review it.
CHAPTER 5: The Billing System (Must Know)¶
Invoice Lifecycle¶
pending— invoice created, no payment appliedpartial— some payment applied, balance remainingpaid— fully paid; triggersBillingService::renew()which extends expiryarchived— moved to billing_archives after retention period
Payment Flow¶
BillingService::generateInvoice()is called — creates invoice if one does not exist for this billing period (idempotent)BillingService::recordPayment()— creates Payment row, creates two LedgerEntry rows (customer account + dealer account), locks and updates invoicepaid_amount- If invoice reaches full payment:
BillingService::renew()is called — updatescustomers.expiry_dateandcustomers.status, then callsRadiusService::syncCustomer() - If payment exceeds invoice amount: excess is applied to the next oldest unpaid invoice via
applyExcessToUnpaid()
How Expiry Dates Work¶
expiry_date is a plain DATE column on customers. The CheckExpiry Artisan command runs daily, finds all customers whose expiry_date < today, and marks them expired. On expiry, Auth-Type = Reject is written to radcheck, which causes FreeRADIUS to reject the next PPPoE authentication for that username.
Recharge calculates the new expiry date from:
- If current expiry is in the future: expiry_date + package_days
- If already expired: today + package_days
Why pppoe_password is Plain Text¶
FreeRADIUS uses Cleartext-Password attribute in radcheck to authenticate PPPoE sessions using PAP/CHAP. PAP sends the password in cleartext over the PPPoE link. CHAP requires the server to know the original password to compute the challenge response. Hashing is not possible. The password is stored in plain text in radcheck by design. Do not attempt to hash it.
generateInvoice is Idempotent¶
BillingService::generateInvoice() includes a duplicate guard. Calling it twice for the same customer and billing period will not create two invoices. The second call returns the existing invoice. This means you can safely call it without checking whether an invoice exists first.
CHAPTER 6: The RADIUS System (Must Know)¶
What FreeRADIUS Reads¶
FreeRADIUS reads from three tables in the pyroradius database directly (via SQL module):
| Table | Purpose | Written by |
|---|---|---|
radcheck |
Per-user auth attributes (password, Auth-Type) | PyroRadius |
radusergroup |
User-to-group mapping (which speed group) | PyroRadius |
radgroupreply |
Group reply attributes (rate limits) | PyroRadius |
radacct |
Session accounting records | FreeRADIUS itself (read-only for us) |
PyroRadius never writes to radacct. FreeRADIUS writes accounting data there as sessions start and stop.
What PyroRadius Writes (via RadiusService)¶
RadiusService::syncCustomer($customer) ensures the three writable tables are consistent with the customer record:
radcheck: UpsertsCleartext-Passwordwith currentpppoe_passwordradusergroup: Deletes existing group link, inserts new one for current packageradgroupreply: Verifies speed group rows exist for the package'sradius_group
When syncCustomer is Called¶
- Customer creation
- Customer recharge (via
BillingService::renew()) - Customer package change
- Customer password reset
SyncAllRadiusArtisan command (bulk fix)
What Happens if RADIUS Sync Fails¶
RADIUS sync failure does not roll back the billing transaction. The payment is recorded, the expiry is extended, and the RADIUS failure is logged as an error. The customer's billing state is correct even if RADIUS is temporarily inconsistent.
To fix a RADIUS sync failure manually: find the customer and run php artisan radius:sync-customer {customer_id}, or use the "Sync RADIUS" button in the customer detail page if available.
CHAPTER 7: Adding a New Feature¶
Use this checklist every time you add a feature that involves a new page, route, or data mutation.
Backend
[ ] Add route to routes/web.php
[ ] Create or update controller method
[ ] Apply visibleTo($user) scope on all customer/invoice/ticket queries
[ ] Add permission key to User::ALL_PERMISSIONS constant (if new permission needed)
[ ] Add permission to appropriate role defaults in defaultPermissions()
[ ] Add AuditLogger::log() for every create / update / delete mutation
[ ] Add database migration if schema changes needed
[ ] Run: php artisan migrate (staging first, then production)
Frontend
[ ] Create or update React page in resources/js/Pages/
[ ] Add can() check in React for destructive actions (delete, bulk actions)
[ ] Wire up Inertia form helpers (useForm, router.post, etc.)
Build & Deploy
[ ] npm run build
[ ] php artisan optimize:clear
[ ] php artisan optimize
[ ] sudo systemctl restart php8.3-fpm
Testing
[ ] Test as super_admin — verify full functionality
[ ] Test as dealer — verify scoping (dealer cannot see other dealer's data)
[ ] Test as a role without the new permission — verify 403 is returned
CHAPTER 8: Common Pitfalls¶
Forgetting dealer scope¶
Missing visibleTo($user) on a query exposes all dealers' data to any dealer who can reach that route. This is an IDOR (Insecure Direct Object Reference) vulnerability. Check every route reachable by dealer-role users.
Forgetting AuditLogger¶
Every mutation (create, update, delete) must be logged. If something goes wrong in production, audit logs are the only source of truth for what changed and when.
Calling RADIUS sync in a loop¶
Do not call RadiusService::syncCustomer() in a loop over many customers. This hits the database for every customer individually. Use SyncAllRadius command instead — it batches the work and is designed for bulk operations.
Altering large tables in migrations¶
PostgreSQL will lock a table during ALTER TABLE operations that rewrite rows. On tables with millions of rows (like radacct), this can lock for minutes and block all application queries. Use ALTER TABLE ... ADD COLUMN ... DEFAULT NULL (no rewrite) or add columns with defaults after the fact using a separate UPDATE in batches.
PostgreSQL lock_timeout is 30 seconds¶
The application sets lock_timeout = 30s. Any transaction that waits more than 30 seconds for a lock is killed with 55P03. If you are writing code that might conflict with SyncOntAssignments (which updates customers every minute), add retry logic:
try {
DB::transaction(function () { ... });
} catch (\Illuminate\Database\QueryException $e) {
if (str_contains($e->getMessage(), '55P03')) {
// retry once
}
throw $e;
}
pppoe_password must stay plain text¶
Do not hash, encrypt, or encode pppoe_password. FreeRADIUS must read it in plain text. If you hash it, all PPPoE authentications for that customer will fail immediately.
CHAPTER 9: Debugging¶
Log Files¶
# Live tail of application log
tail -f /var/www/pyroradius/storage/logs/laravel.log
# Filter for errors only
grep -i "error\|exception\|failed" /var/www/pyroradius/storage/logs/laravel.log | tail -50
Queue Workers¶
# Check worker status
supervisorctl status
# Restart workers (if jobs are stuck)
supervisorctl restart pyroradius-worker:*
# View failed jobs
php artisan queue:failed
Direct Database Access¶
Laravel Tinker¶
php artisan tinker
# In tinker — always use fully qualified class names
$c = \App\Models\Customer::find(1);
$c->pppoe_username;
\App\Services\RadiusService::syncCustomer($c);
Checking RADIUS State for a Customer¶
-- Find auth credentials
SELECT * FROM radcheck WHERE username = 'pppoe_username_here';
-- Find group assignment
SELECT * FROM radusergroup WHERE username = 'pppoe_username_here';
-- Find speed limits for the group
SELECT * FROM radgroupreply WHERE groupname = 'group_name_here';
-- Find active sessions
SELECT * FROM radacct WHERE username = 'pppoe_username_here' AND acctstoptime IS NULL;
Checking Online Count in Redis¶
CHAPTER 10: Code Standards¶
Comments¶
Write comments only to explain why, never to explain what. Code should be self-documenting.
// BAD — explains what (obvious from reading the code)
// Get the customer by ID
$customer = Customer::find($id);
// GOOD — explains why (non-obvious constraint)
// pppoe_password must remain plain text — FreeRADIUS reads it directly for CHAP auth
Scoping (mandatory)¶
Every query on customer-related data must include visibleTo($user):
// Every controller that dealers can reach
$customers = Customer::visibleTo($request->user())
->with(['package', 'nas'])
->paginate(50);
Authorization in React (mandatory for destructive actions)¶
import { usePage } from '@inertiajs/react';
const { can } = usePage().props.auth;
// Hide destructive actions from users without permission
{can('delete-customer') && (
<Button variant="danger" onClick={handleDelete}>Delete</Button>
)}
Audit Logging (mandatory for all mutations)¶
AuditLogger::log(
subject: $customer,
event: 'customer.recharged',
description: "Recharged PKR {$amount} — expiry extended to {$customer->expiry_date}",
user: $request->user()
);
Retry on lock_timeout (55P03) for SyncOntAssignments conflicts¶
Any controller or command that updates the customers table should be written to handle lock_timeout gracefully, since SyncOntAssignments runs every minute and acquires row-level locks on customers whose ONT assignment changes.