12 — Customer Management¶
Overview¶
Customers are the core entity in PyroRadius. Each customer record represents a subscriber connection — whether PPPoE, Static IP, or CIR. The system manages the full lifecycle: account creation, RADIUS provisioning, billing, renewal, suspension, and disconnection.
Customer Data Model¶
Table: customers
| Column | Type | Description |
|---|---|---|
id |
bigint PK | Auto-increment primary key |
customer_code |
varchar | Unique identifier, PR-prefixed sequential (e.g. PR-00042) |
full_name |
varchar | Customer's full legal name |
mobile |
varchar | Primary contact number |
whatsapp |
varchar | WhatsApp number (may differ from mobile) |
address |
text | Physical installation address |
area_id |
bigint FK | Links to areas table (geographical zone) |
cnic |
varchar | Pakistani CNIC (13-digit national identity number) |
pppoe_username |
varchar | RADIUS authentication username (unique); also used as ONT description key |
pppoe_password |
varchar | RADIUS authentication password (stored plain text — RADIUS protocol requirement) |
static_ip |
varchar | Fixed IP address assigned (only for Static/CIR connection types) |
connection_type_id |
tinyint | 1 = PPPoE, 2 = Static IP, 3 = CIR |
nas_id |
bigint FK | Links to nas table; which router authenticates this customer |
package_id |
bigint FK | Links to packages table |
monthly_price |
decimal | Billing amount; can override the package's default price |
dealer_id |
bigint FK | Owning dealer (User with role=dealer) |
sub_dealer_id |
bigint FK | Sub-dealer under the dealer (nullable) |
status |
enum | active, suspended, expired, temp_extended, disconnected |
expiry_date |
date | Date this billing cycle ends; checked daily by CheckExpiry |
ont_serial |
varchar | OLT fiber port ONT serial number (populated by SyncOntAssignments) |
rx_power |
decimal | Fiber Rx signal level in dBm (populated by SyncOntAssignments) |
rx_power_at |
timestamp | When Rx power was last measured |
ont_port |
varchar | OLT port identifier for this ONT |
device_mac |
varchar | CPE device MAC address |
operator_note |
text | Internal note visible to staff/admin |
created_by |
bigint FK | User who created this record |
deleted_at |
timestamp | Soft-delete timestamp; NULL = active record |
Notes on Sensitive Fields¶
pppoe_passwordis stored in plain text. This is a deliberate design decision because FreeRADIUS's CHAP and MS-CHAPv2 authentication methods require access to the cleartext password server-side. Hashing would break RADIUS auth.monthly_priceoverrides the package price at the customer level. Use this for custom commercial deals without changing the base package.
Customer Status Lifecycle¶
[Created] ──────────────────────────────────────────────┐
│
┌──────────────────────────────────────┐ │
│ ACTIVE │ ◄───────┘
│ • in RADIUS (radcheck entry) │
│ • can authenticate PPPoE │
└──────────────┬───────────────────────┘
│
(expiry_date passes)
CheckExpiry command runs daily
│
▼
┌──────────────────────────────────────┐
│ EXPIRED │
│ • removed from RADIUS │
│ • cannot authenticate │
│ • customer sees portal notice │
└──────────────┬───────────────────────┘
│
(payment received → renew())
│
▼
┌──────────────────────────────────────┐
│ ACTIVE again │
│ • re-added to RADIUS │
│ • expiry_date extended by 30 days │
└──────────────────────────────────────┘
Other transitions:
ACTIVE ──(manual suspend)──► SUSPENDED
SUSPENDED ──(manual unsuspend)──► ACTIVE
ACTIVE/EXPIRED ──(manual disconnect)──► DISCONNECTED
DISCONNECTED ──(reconnect)──► ACTIVE
EXPIRED ──(temp extension)──► TEMP_EXTENDED
TEMP_EXTENDED ──(payment)──► ACTIVE
The CheckExpiry artisan command runs daily (via scheduler). It finds all customers whose expiry_date < today and status = active, removes their RADIUS entries, and sets status to expired.
Customer Code Generation¶
Customer codes follow the format PR-NNNNN where N is a zero-padded sequential number.
Logic in CustomerService (or CustomerObserver):
1. Find the maximum existing numeric suffix across all customers (including soft-deleted).
2. Increment by 1.
3. Format with str_pad($next, 5, '0', STR_PAD_LEFT).
4. Prepend PR-.
Example sequence: PR-00001, PR-00002, … PR-00042.
The code is assigned on store() and is immutable thereafter.
Creating a Customer¶
Route¶
Handled byCustomerController::store()
Form Fields (CustomerCreateForm in React)¶
| Field | Required | Notes |
|---|---|---|
| Full Name | Yes | Min 3 chars |
| Mobile | Yes | Pakistani format validation |
| No | Defaults to mobile if blank | |
| CNIC | No | 13 digits, dashes stripped |
| Address | Yes | Free text |
| Area | Yes | Select from areas list |
| Connection Type | Yes | PPPoE / Static / CIR |
| NAS | Yes | Select from available NAS |
| Package | Yes | Filtered by dealer's assigned packages |
| Monthly Price | No | Overrides package price if set |
| PPPoE Username | Yes | Must be globally unique |
| PPPoE Password | Yes | Min 6 chars |
| Static IP | Conditional | Required if connection_type = Static or CIR |
| Expiry Date | Yes | First billing cycle end date |
| Dealer | Yes (admin only) | Dealers see themselves pre-selected |
| Sub Dealer | No | Must belong to the selected dealer |
| Operator Note | No | Internal only |
What Gets Created on Store¶
-
customersrow — all fields persisted,customer_codeassigned,status = active. -
radcheckrow — RADIUS authentication entry: -
radusergrouprow — assigns customer to RADIUS group matching their package: -
radgroupreplyrows (if not already present for the group) — defines speed limits for the group:
For Static IP customers: a radcheck entry for Framed-IP-Address is also added so FreeRADIUS returns the fixed IP on auth-accept.
For CIR customers: treated like Static but may carry additional group attributes for traffic shaping.
Editing a Customer¶
Route¶
Handled byCustomerController::update()
Lock-Retry Logic (PostgreSQL Concurrency)¶
PostgreSQL does not use optimistic locking natively. CustomerController::update() uses a SELECT FOR UPDATE with retry loop to prevent concurrent edits from conflicting:
// Pseudo-code of the pattern used:
DB::transaction(function () use ($id, $validated) {
$customer = Customer::lockForUpdate()->findOrFail($id);
// apply changes
$customer->update($validated);
// sync RADIUS if pppoe_username, pppoe_password, package_id changed
});
If a deadlock or lock timeout is detected, the controller retries up to 3 times with a short sleep between attempts before returning a 409 Conflict response to the frontend.
What Gets Updated in RADIUS on Edit¶
| Changed Field | RADIUS Action |
|---|---|
pppoe_password |
Updates radcheck.value where attribute=Cleartext-Password |
package_id |
Updates radusergroup.groupname to new package group |
pppoe_username |
Deletes old radcheck/radusergroup, inserts new ones |
static_ip (Static/CIR) |
Updates or inserts Framed-IP-Address in radcheck |
status → suspended |
Adds Auth-Type := Reject to radcheck |
status → active |
Removes reject entry |
Customer Search and Filters¶
The CustomerController::index() method returns a paginated list with the following filters available:
| Filter | Parameter | Description |
|---|---|---|
| Search | search |
Searches full_name, mobile, pppoe_username, customer_code |
| Status | status |
active / suspended / expired / temp_extended / disconnected |
| Dealer | dealer_id |
Filter by owning dealer |
| Sub Dealer | sub_dealer_id |
Filter by sub-dealer |
| Package | package_id |
Filter by subscribed package |
| Area | area_id |
Filter by geographical area |
| Connection Type | connection_type_id |
1/2/3 |
| NAS | nas_id |
Filter by authenticating NAS |
| Expiry | expiry_from, expiry_to |
Date range for expiry_date |
Results are paginated (default 25 per page). Dealer-scoped users only see customers where dealer_id = auth()->id() or customers under their sub-dealers.
Sorting¶
Default sort: created_at DESC. UI allows sorting by full_name, expiry_date, monthly_price, status.
Customer Detail Page¶
The customer detail page (/customers/{id}) is organized into tabs:
Tab 1: Overview¶
- Customer code, full name, status badge
- Contact info (mobile, WhatsApp, CNIC)
- Address and area
- Connection type, NAS, package
- Monthly price, expiry date
- Operator note
- Created by, created at
Tab 2: Billing / Recharge¶
- Current package and price
- Expiry date with days-remaining indicator
- Recharge form: select package, amount, months, payment method
- Recharge history (last 10 transactions)
Tab 3: Ledger¶
- Full payment history from
transactionstable - Columns: date, description, amount, balance, recorded by
- Running balance calculation
Tab 4: Tickets¶
- Support tickets linked to this customer
- Create new ticket shortcut
Tab 5: RADIUS¶
- Current radcheck entries (read-only view)
- Current radusergroup assignment
- Last session from
radacct(session start, stop, bytes in/out, IP assigned, NAS IP) - Kick session button (forces disconnect via MikroTik API)
Tab 6: OLT Signal¶
- ONT serial number
- Current Rx power in dBm with color indicator:
- Green: > -27 dBm (good)
- Yellow: -27 to -30 dBm (warning)
- Red: < -30 dBm (critical)
- Last measured timestamp
- Signal history chart (from signal_readings table)
Connection Type Handling¶
PPPoE (connection_type_id = 1)¶
- Standard dynamic IP assignment
- Customer router dials in with username/password
- RADIUS handles auth and returns IP from pool
static_ipfield is ignored- Speed limits come from
radgroupreplyMikrotik-Rate-Limit attribute
Static IP (connection_type_id = 2)¶
- Customer gets a fixed IP address stored in
static_ip - RADIUS still handles authentication (same username/password flow)
- On auth-accept, RADIUS returns
Framed-IP-Address = <static_ip> - FreeRADIUS config must have the IP pool disabled for this group or Framed-IP-Address takes precedence
CIR — Committed Information Rate (connection_type_id = 3)¶
- Used for corporate/enterprise customers
- Fixed IP assigned (same as Static)
- Speed limits are guaranteed (committed), not burst-based
- Billing is typically higher;
monthly_priceoverride is common here - May use separate RADIUS group with different rate-limit attributes
Soft Delete¶
Deleting a customer does not remove the database row.
Effects of soft delete:
1. Customer no longer appears in any list (Eloquent global scope excludes deleted_at IS NOT NULL).
2. RADIUS entries (radcheck, radusergroup) are immediately deleted so the customer cannot authenticate.
3. Active PPPoE sessions are optionally kicked (via MikroTik API) if a session is live at time of deletion.
4. radacct accounting records are retained for audit purposes.
5. The customer_code is permanently reserved (never reassigned).
To permanently purge (hard delete), a separate admin command is available but not exposed in the UI.
Customer Portal¶
Customers can log in at /portal using their PPPoE username and password.
Portal shows: - Current status and expiry date - Package details and speed - Payment history - Raise a support ticket - Contact dealer information
Portal access is read-only. Customers cannot change their credentials or package through the portal.
Bulk Operations¶
Currently supported: - Bulk Status Change — select multiple customers → set status (suspend/activate) - Bulk Export — export filtered customer list to CSV - Bulk recharge is not available in the UI (must be done individually to ensure payment records are accurate)
CustomerController Method Reference¶
| Method | Route | Description |
|---|---|---|
index() |
GET /customers | Paginated list with filters |
create() |
GET /customers/create | Show create form (Inertia page) |
store() |
POST /customers | Validate + persist + create RADIUS entries |
show() |
GET /customers/{id} | Customer detail page |
edit() |
GET /customers/{id}/edit | Show edit form pre-populated |
update() |
PUT /customers/{id} | Validate + update + sync RADIUS (lock-retry) |
destroy() |
DELETE /customers/{id} | Soft delete + remove RADIUS entries |
recharge() |
POST /customers/{id}/recharge | Record payment, extend expiry, restore RADIUS |
statusCounts() |
GET /customers/status-counts | Returns {active, suspended, expired, …} counts for dashboard |
portal() |
GET /portal | Customer self-service portal |
ledger() |
GET /customers/{id}/ledger | Full transaction history |
disconnect() |
POST /customers/{id}/disconnect | Remove from RADIUS (manual suspend) |
reconnect() |
POST /customers/{id}/reconnect | Restore to RADIUS (manual unsuspend) |
kick() |
POST /customers/{id}/kick | Force-disconnect live PPPoE session via MikroTik API |
Related Models¶
Customer→belongsToArea, NAS, Package, Dealer (User), SubDealer (User), ConnectionTypeCustomer→hasManyTransaction, Ticket, SignalReading, AcsDeviceCustomer→hasOne(virtual) active radacct session