Skip to content

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_password is 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_price overrides 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

POST /customers
Handled by CustomerController::store()

Form Fields (CustomerCreateForm in React)

Field Required Notes
Full Name Yes Min 3 chars
Mobile Yes Pakistani format validation
WhatsApp 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

  1. customers row — all fields persisted, customer_code assigned, status = active.

  2. radcheck row — RADIUS authentication entry:

    username = pppoe_username
    attribute = 'Cleartext-Password'
    op = ':='
    value = pppoe_password
    

  3. radusergroup row — assigns customer to RADIUS group matching their package:

    username = pppoe_username
    groupname = package.radius_group_name
    priority = 1
    

  4. radgroupreply rows (if not already present for the group) — defines speed limits for the group:

    groupname = package.radius_group_name
    attribute = 'Mikrotik-Rate-Limit'
    op = ':='
    value = '10M/10M 15M/15M 8M/8M 8M/8M 30'
    

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

PUT /customers/{id}
Handled by CustomerController::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 transactions table
  • 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_ip field is ignored
  • Speed limits come from radgroupreply Mikrotik-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_price override is common here
  • May use separate RADIUS group with different rate-limit attributes

Soft Delete

Deleting a customer does not remove the database row.

DELETE /customers/{id}  →  sets deleted_at = NOW()

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

  • CustomerbelongsTo Area, NAS, Package, Dealer (User), SubDealer (User), ConnectionType
  • CustomerhasMany Transaction, Ticket, SignalReading, AcsDevice
  • CustomerhasOne (virtual) active radacct session