Skip to content

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

  1. This handbook (you are here)
  2. PHASE2_01_Architecture_Overview.md — system architecture and tech stack
  3. PHASE2_03_Billing_Engine.md — billing, invoices, payments, expiry logic
  4. PHASE2_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

Remote:   github.com/PyroNet-Solutions/PyroRadius
Default:  main branch (production)

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):

{can('delete-customer') && <DeleteButton />}

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

git checkout -b feature/my-change

2. Locate the relevant controller and React page

Start from the route:

grep -r "my-route-name" routes/web.php
Find the controller method, then find the Inertia page component name from Inertia::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:

php artisan optimize:clear
php artisan optimize
sudo systemctl restart php8.3-fpm

5. Rebuild frontend (if you changed JS/React)

npm run build
sudo systemctl restart php8.3-fpm

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 → partial → paid → archived
  • pending — invoice created, no payment applied
  • partial — some payment applied, balance remaining
  • paid — fully paid; triggers BillingService::renew() which extends expiry
  • archived — moved to billing_archives after retention period

Payment Flow

  1. BillingService::generateInvoice() is called — creates invoice if one does not exist for this billing period (idempotent)
  2. BillingService::recordPayment() — creates Payment row, creates two LedgerEntry rows (customer account + dealer account), locks and updates invoice paid_amount
  3. If invoice reaches full payment: BillingService::renew() is called — updates customers.expiry_date and customers.status, then calls RadiusService::syncCustomer()
  4. 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: Upserts Cleartext-Password with current pppoe_password
  • radusergroup: Deletes existing group link, inserts new one for current package
  • radgroupreply: Verifies speed group rows exist for the package's radius_group

When syncCustomer is Called

  • Customer creation
  • Customer recharge (via BillingService::renew())
  • Customer package change
  • Customer password reset
  • SyncAllRadius Artisan 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

# Always connect through PgBouncer on port 6432
psql -h 127.0.0.1 -p 6432 -U pyroradius pyroradius

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

redis-cli GET online_usernames
# Returns a JSON array of currently online PPPoE usernames

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.