PyroRadius — Phase 2 Eloquent Model Reference¶
Version: 2.0.0 Date: 2026-07-13 Scope: Complete documentation for every Eloquent model in the PyroRadius application. Stack: Laravel 13 · PostgreSQL 16
Table of Contents¶
- Customer — Core entity; ISP subscriber record
- User — Authentication, roles, permissions
- Invoice — Billing period invoice
- Payment — Payment transaction record
- LedgerEntry — Double-entry financial ledger
- Package — PPPoE speed package / RADIUS group
- Nas — Network Access Server (router)
- RadCheck — FreeRADIUS check attributes
- RadGroupReply — FreeRADIUS group reply attributes
- RadUserGroup — FreeRADIUS user-to-group mapping
- RadAcct — FreeRADIUS accounting sessions (read-only)
- WhatsappTemplate — WhatsApp message templates
- WhatsappLog — WhatsApp send history
- WhatsappSession — WhatsApp connection state
- AuditLog — Immutable change audit trail
- Setting — Application key-value settings
- CompanyProfile — ISP branding and identity
- Olt — Optical Line Terminal device
- OntCache — ONT/ONU discovery cache
- CronJob — Schedulable command registry
- Ticket — Support / NOC ticket
- DealerPackage — Dealer-Package pivot with custom pricing
1. Customer¶
File: app/Models/Customer.php
Table: customers
Soft Deletes: Yes (deleted_at, deleted_by, delete_reason)
Auth: No
The central entity of PyroRadius. Every PPPoE subscriber is a Customer. All billing, RADIUS, and support flows reference this model.
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
customer_code |
string | — | Human-readable ID (e.g., CUS-000042); generated from PostgreSQL sequence |
full_name |
string | — | Subscriber display name |
mobile |
string | — | Primary phone number; used for SMS/WhatsApp fallback |
whatsapp |
string | — | WhatsApp number if different from mobile |
address |
string | — | Physical service address |
area |
string | — | Free-text area name (legacy field; prefer area_id) |
pppoe_username |
string | — | PPPoE login; must be unique; synced to FreeRADIUS |
pppoe_password |
string | hidden |
PPPoE password; excluded from JSON serialization |
package_id |
integer | — | FK → packages.id |
dealer_id |
integer | — | FK → users.id (dealer role) |
sub_dealer_id |
integer | — | FK → users.id (sub_dealer role) |
nas_id |
integer | — | FK → nas.id; which router authenticates this customer |
static_ip |
string | — | Static Framed-IP-Address sent via RADIUS; null = dynamic pool |
ont_serial |
string | — | ONT/ONU serial number (decoded format, e.g., HWTC1234ABCD) |
monthly_price |
decimal | float | Customer-specific price override; null = use package.price |
expiry_date |
date | Carbon | Service expiry; renewal extends this |
status |
string | — | active / expired / suspended / temp_extended / disabled |
comments |
string | — | Dealer-visible notes |
operator_note |
string | — | Internal staff notes (hidden from dealers) |
created_by |
string | — | Name of user who created the record |
pool_name |
string | — | RADIUS IP pool name for dynamic IP allocation |
rx_power |
decimal | float | Latest ONT RX optical power reading (dBm × 100 for precision) |
rx_power_at |
timestamp | Carbon | When rx_power was last updated |
cnic |
string | — | Customer's national ID number |
device_type |
string | — | CPE device model (e.g., TP-Link WR841N) |
device_ownership |
string | — | isp_owned / customer_owned |
country_id |
integer | — | FK → countries.id |
city_id |
integer | — | FK → cities.id |
area_id |
integer | — | FK → areas.id (structured area) |
sub_area_id |
integer | — | FK → areas.id (sub-area under area) |
customer_type_id |
integer | — | FK → customer_types.id |
connection_type_id |
integer | — | FK → connection_types.id |
grace_days |
integer | — | Extra days after expiry before auto-suspension |
gateway |
string | — | Static route gateway IP (for static IP setups) |
subnet_mask |
string | — | Subnet mask for static IP |
vlan |
integer | — | VLAN tag if applicable |
committed_bandwidth |
string | — | SLA guaranteed bandwidth (informational) |
burst_bandwidth |
string | — | SLA burst bandwidth (informational) |
sla_notes |
string | — | Free-text SLA terms for this customer |
static_ip_pool |
string | — | RADIUS pool to use when static IP is assigned |
contract_price |
decimal | float | Contracted package price (may differ from monthly_price) |
contract_expiry |
date | Carbon | When the current contract expires |
deleted_by |
integer | — | FK → users.id; who soft-deleted this record |
delete_reason |
string | — | Mandatory reason recorded at deletion |
restored_at |
timestamp | Carbon | When the customer was restored from soft-delete |
restored_by |
integer | — | FK → users.id; who restored the record |
Relationships¶
| Method | Type | Related Model | Foreign Key | Notes |
|---|---|---|---|---|
package |
BelongsTo | Package |
package_id |
Eager-loaded in most queries |
dealer |
BelongsTo | User |
dealer_id |
The primary dealer responsible |
subDealer |
BelongsTo | User |
sub_dealer_id |
Sub-dealer under the primary dealer |
invoices |
HasMany | Invoice |
customer_id |
All billing invoices |
payments |
HasMany | Payment |
customer_id |
All payment records |
country |
BelongsTo | Country |
country_id |
|
city |
BelongsTo | City |
city_id |
|
areaRef |
BelongsTo | Area |
area_id |
Named areaRef to avoid collision with free-text area field |
subArea |
BelongsTo | Area |
sub_area_id |
|
customerType |
BelongsTo | CustomerType |
customer_type_id |
|
connectionType |
BelongsTo | ConnectionType |
connection_type_id |
|
nas |
BelongsTo | Nas |
nas_id |
The authenticating router |
deletedBy |
BelongsTo | User |
deleted_by |
Who performed the soft delete |
restoredBy |
BelongsTo | User |
restored_by |
Who restored the record |
Scopes¶
| Scope | Parameters | Purpose |
|---|---|---|
visibleTo(User $user) |
$user |
Multi-role access control; the most complex scope in the codebase |
visibleTo() logic in detail:
super_admin, admin, noc_admin, billing_admin, auditor:
→ No filter; see all non-deleted customers
dealer:
→ WHERE dealer_id = $user->id
sub_dealer:
→ WHERE sub_dealer_id = $user->id
collection_officer:
→ WHERE dealer_id IN (SELECT id FROM users WHERE parent_id = $user->id)
OR assigned collection areas match
support_agent, field_engineer:
→ All customers (read-only access; editing restricted by permissions)
Business Methods¶
isExpired(): bool
- Returns true if status is expired OR (expiry_date < today AND status is not disabled or suspended).
- Important rule: disabled and suspended customers are not considered "expired" regardless of their expiry date — they have been administratively blocked, not merely lapsed.
nextCode(): string (static)
- Calls SELECT nextval('customer_code_seq') via DB::selectOne().
- Returns formatted string: 'CUS-' . str_pad($next, 6, '0', STR_PAD_LEFT).
- Example: CUS-000042.
- Called only from CustomerController::store().
Notes¶
pppoe_passwordis in the$hiddenarray — excluded fromtoArray(),toJson(), and API responses. Always access via direct property or usemakeVisible()when explicit password display is required.- The
area(free-text) andareaRef(FK) fields coexist for backward compatibility. New installs should usearea_id/areaRefexclusively. rx_poweris stored as dBm × 100 (integer-safe storage), so -23.5 dBm is stored as -2350. TheOntCachemodel follows the same convention (rx_raw).- Soft deletes: the
SoftDeletestrait addsdeleted_at. Records withdeleted_at IS NOT NULLare excluded from all standard queries unlesswithTrashed()is explicitly added.
2. User¶
File: app/Models/User.php
Table: users
Soft Deletes: No
Auth: Yes (extends Authenticatable)
Represents every type of system user: staff (admin, NOC, billing, support), dealers, sub-dealers, collection officers, and field engineers. Roles and permissions are stored in JSONB columns.
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
name |
string | — | Display name |
email |
string | — | Login email; unique |
password |
string | hidden |
Bcrypt-hashed |
role |
string | — | One of the ROLES constants below |
permissions |
array | array (JSONB) |
Array of granted permission strings |
parent_id |
integer | — | FK → users.id; for sub-dealers (parent = their dealer) |
phone |
string | — | Contact phone |
address |
string | — | Office/home address |
active |
boolean | bool | Account enabled/disabled |
cnic |
string | — | National ID |
commission_rate |
decimal | float | Dealer's collection commission percentage |
whatsapp |
string | — | WhatsApp contact number |
balance |
decimal | float | Dealer ledger balance (cached; authoritative data is in LedgerEntry) |
Constants¶
ROLES (array of role metadata):
public const ROLES = [
'super_admin' => ['label' => 'Super Admin', 'color' => 'red', 'assignable_by' => []],
'admin' => ['label' => 'Admin', 'color' => 'orange', 'assignable_by' => ['super_admin']],
'noc_admin' => ['label' => 'NOC Admin', 'color' => 'blue', 'assignable_by' => ['super_admin', 'admin']],
'billing_admin' => ['label' => 'Billing Admin', 'color' => 'green', 'assignable_by' => ['super_admin', 'admin']],
'support_agent' => ['label' => 'Support Agent', 'color' => 'cyan', 'assignable_by' => ['super_admin', 'admin']],
'dealer' => ['label' => 'Dealer', 'color' => 'purple', 'assignable_by' => ['super_admin', 'admin']],
'sub_dealer' => ['label' => 'Sub Dealer', 'color' => 'violet', 'assignable_by' => ['super_admin', 'admin', 'dealer']],
'field_engineer' => ['label' => 'Field Engineer', 'color' => 'yellow', 'assignable_by' => ['super_admin', 'admin']],
'auditor' => ['label' => 'Auditor', 'color' => 'gray', 'assignable_by' => ['super_admin']],
'collection_officer' => ['label' => 'Collection Officer', 'color' => 'teal', 'assignable_by' => ['super_admin', 'admin', 'dealer']],
];
ALL_PERMISSIONS (array ~80 permission strings):
Grouped by domain:
customers.* — view, create, edit, delete, recharge, disconnect, export
invoices.* — view, create, edit, delete, mark_paid
payments.* — view, create, delete, reverse
ledger.* — view, create, reverse
dealers.* — view, create, edit, delete, view_balance
packages.* — view, create, edit, delete, assign_dealers
nas.* — view, create, edit, delete, sync
tickets.* — view, create, edit, close, assign, delete
reports.* — view, export, financial, operational
audit.* — view
settings.* — view, edit
whatsapp.* — view, send, manage_templates
olt.* — view, sync, manage
users.* — view, create, edit, delete, manage_permissions
cron.* — view, edit, run
PERMISSION_GROUPS (10 groups):
Maps display group names to the permissions they contain (used by the permissions UI to group checkboxes).
Relationships¶
| Method | Type | Related Model | Foreign Key | Notes |
|---|---|---|---|---|
parent |
BelongsTo | User |
parent_id |
Sub-dealer's parent dealer |
children |
HasMany | User |
parent_id |
Dealer's sub-dealers |
engineerTeams |
BelongsToMany | EngineerTeam |
pivot: engineer_team_user |
Teams this engineer belongs to |
assignedTickets |
HasMany | Ticket |
assigned_to |
Tickets assigned to this user |
nasDevices |
BelongsToMany | Nas |
pivot: dealer_nas |
NAS devices a dealer has access to |
packages |
BelongsToMany | Package |
pivot: dealer_package |
Packages a dealer can sell; pivot includes rate, active |
Business Methods¶
can(string $ability): bool
- Overrides Laravel's default Gate check.
- super_admin role: always returns true (bypasses all permission checks).
- All other roles: checks if $ability is in $this->permissions array (JSONB column).
- This is the primary authorization method used throughout the app — all $user->can('permission.key') calls go here.
defaultPermissions(string $role): array
- Returns the default permission array for a given role.
- Called when creating a new user to pre-populate their permissions.
- Each role has a curated default set; super_admin by convention gets an empty array (since can() always returns true for them).
Role helper methods (all return bool):
| Method | Condition |
|---|---|
isSuperAdmin() |
role === 'super_admin' |
isAdmin() |
role === 'admin' |
isStaff() |
role in: super_admin, admin, noc_admin, billing_admin, support_agent, auditor |
isDealer() |
role === 'dealer' |
isSubDealer() |
role === 'sub_dealer' |
isCollectionOfficer() |
role === 'collection_officer' |
isFieldEngineer() |
role === 'field_engineer' |
accessibleNasIds(User $user): array (static)
- Returns array of NAS IDs the given user is allowed to access.
- super_admin / admin / noc_admin: returns all NAS IDs.
- dealer: returns IDs from the dealer_nas pivot table for $user->id.
- Other roles: returns [] (no direct NAS access).
Notes¶
- The
permissionscolumn is JSONB in PostgreSQL. Laravel casts it asarray. Always access via$user->permissions— never query the raw JSON column in application code. super_adminis a singleton role; only one super_admin account should exist per installation. The seeder createsadmin@pyronet.com.pk/change-me-on-first-login.- Role
assignable_bydefines who can create accounts of each role. Enforced at the UserController level, not at the model level. - Password hashing: handled by Laravel's
UsermodelAuthenticatabletrait using Bcrypt. Never store plain text passwords.
3. Invoice¶
File: app/Models/Invoice.php
Table: invoices
Soft Deletes: No
Represents a billing period invoice for a customer. Created by BillingService::generateInvoice().
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
invoice_no |
string | — | Unique reference number (e.g., INV202607-00042) |
customer_id |
integer | — | FK → customers.id |
dealer_id |
integer | — | FK → users.id; denormalized for reporting |
amount |
decimal | float | Total amount billed |
paid_amount |
decimal | float | Amount received to date (partial payments accumulate here) |
status |
string | — | unpaid / partial / paid |
period_start |
date | Carbon | Start of the billing period |
period_end |
date | Carbon | End of the billing period |
due_date |
date | Carbon | Payment due date (typically period_start + 7 days) |
notes |
string | — | Optional invoice notes or memo |
created_by |
string | — | Name of user who generated the invoice |
Relationships¶
| Method | Type | Related Model | Foreign Key | Notes |
|---|---|---|---|---|
customer |
BelongsTo | Customer |
customer_id |
|
dealer |
BelongsTo | User |
dealer_id |
Denormalized for dealer reporting |
payments |
HasMany | Payment |
invoice_id |
All payments applied to this invoice |
Notes¶
- Idempotency:
BillingService::generateInvoice()checks for an existing invoice with the samecustomer_id,period_start, andperiod_endbefore creating a new one. Duplicates are never created by the service layer. paid_amountis the running total of all payments applied. Status is derived:paid_amount == 0→unpaid;0 < paid_amount < amount→partial;paid_amount >= amount→paid.dealer_idis denormalized (copied fromcustomer.dealer_idat invoice creation time). It does not update if the customer changes dealers later.
4. Payment¶
File: app/Models/Payment.php
Table: payments
Soft Deletes: No
Records a single payment transaction. Created by BillingService::recordPayment().
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
receipt_no |
string | — | Unique receipt number (e.g., RCP202607-00017) |
customer_id |
integer | — | FK → customers.id |
invoice_id |
integer | — | FK → invoices.id; nullable for unattributed payments |
dealer_id |
integer | — | FK → users.id; denormalized |
amount |
decimal | float | Amount paid |
method |
string | — | Payment method (e.g., cash, easypaisa, jazzcash, bank_transfer) |
received_by |
string | — | Name of collector (staff name or dealer name) |
notes |
string | — | Reference number, bank slip number, or memo |
paid_at |
timestamp | Carbon | When the payment was collected |
Relationships¶
| Method | Type | Related Model | Foreign Key | Notes |
|---|---|---|---|---|
customer |
BelongsTo | Customer |
customer_id |
|
invoice |
BelongsTo | Invoice |
invoice_id |
Nullable; null = unattributed payment |
dealer |
BelongsTo | User |
dealer_id |
Notes¶
- Payments are immutable after creation. Reversals are handled via
LedgerEntryreversal, not by modifying the payment record. invoice_idmay be null for advance payments or unattributed cash collections. These are applied to outstanding invoices byapplyExcessToUnpaid().receipt_nouses theseq_receipt_noPostgreSQL sequence — globally unique across all dealers.
5. LedgerEntry¶
File: app/Models/LedgerEntry.php
Table: ledger_entries
Soft Deletes: No
Implements a simplified double-entry ledger. Every financial movement (charge or payment) creates at least one ledger entry. Used for dealer balance tracking and financial reporting.
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
customer_id |
integer | — | FK → customers.id |
account_type |
string | — | customer / dealer — which ledger account this entry belongs to |
account_id |
integer | — | ID of the account (customer ID or dealer/user ID) |
type |
string | — | Entry type (see CHARGE_TYPES / CREDIT_TYPES below) |
status |
string | — | active / reversed |
description |
string | — | Human-readable description of the transaction |
debit |
decimal | float | Debit amount (money owed by account) |
credit |
decimal | float | Credit amount (money paid or credited to account) |
effective_date |
date | Carbon | Date this entry is effective for |
created_by |
string | — | Name of creating user |
invoice_id |
integer | — | FK → invoices.id; links entry to an invoice |
remarks |
string | — | Additional notes |
reversed_by |
string | — | Name of user who reversed this entry |
reversed_at |
timestamp | Carbon | When the reversal occurred |
LedgerService Constants¶
CHARGE_TYPES (debit-creating entry types):
| Type Key | Display Label |
|---|---|
monthly_charge |
Monthly Subscription |
installation |
Installation Fee |
device |
Device/Equipment |
repair |
Repair Charge |
fiber |
Fiber Pulling |
activation |
Activation Fee |
late_fee |
Late Payment Fee |
penalty |
Penalty |
other_charge |
Other Charge |
CREDIT_TYPES (credit-creating entry types):
| Type Key | Display Label |
|---|---|
credit |
Payment / Credit |
waiver |
Fee Waiver |
discount |
Discount |
write_off |
Write-Off |
adjustment |
Adjustment |
refund |
Refund |
Notes¶
- Each payment recorded by
BillingService::recordPayment()creates two ledger entries: one for the customer account (account_type = customer) and one for the dealer account (account_type = dealer). - Reversal sets
status = 'reversed'and recordsreversed_by/reversed_at. The reversed entry is never deleted. debitandcreditare not mutually exclusive per row — a row will have one non-zero and one zero. This matches simplified bookkeeping (not strict double-entry where each entry is a separate row).- Dealer balance is derived by summing
credit - debitforaccount_type = 'dealer'andaccount_id = dealer.idacross all active entries.
6. Package¶
File: app/Models/Package.php
Table: packages
Soft Deletes: No
Represents a PPPoE speed tier. Each package corresponds to a FreeRADIUS group (radgroupreply) that delivers rate-limit attributes to the router.
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
name |
string | — | Display name (e.g., 10 Mbps Basic) |
radius_group |
string | — | FreeRADIUS group name; must match radgroupreply.groupname exactly |
download_speed |
integer | — | Committed download speed (Mbps) |
upload_speed |
integer | — | Committed upload speed (Mbps) |
burst_download |
integer | — | Burst download limit (Mbps); 0 = no burst |
burst_upload |
integer | — | Burst upload limit (Mbps) |
burst_threshold_down |
integer | — | Download threshold before burst kicks in |
burst_threshold_up |
integer | — | Upload threshold before burst kicks in |
burst_time |
integer | — | Seconds of burst allowed per burst cycle |
rate_limit |
string | — | Computed MikroTik rate-limit string (e.g., 10M/10M 20M/20M 10M/10M 8/8 10) |
price |
decimal | float | Standard monthly price; overridable per customer or per dealer |
active |
boolean | bool | Whether new customers can be assigned this package |
description |
string | — | Optional notes about the package |
Relationships¶
| Method | Type | Related Model | Foreign Key | Notes |
|---|---|---|---|---|
customers |
HasMany | Customer |
package_id |
All customers on this package |
dealers |
BelongsToMany | User |
pivot: dealer_package |
Dealers authorized to sell this package; pivot has rate, active |
Notes¶
rate_limitis the MikroTikMikrotik-Rate-Limitattribute value. Format:rx/tx burst_rx/burst_tx burst_threshold_rx/burst_threshold_tx burst_time/burst_time priority. This is written toradgroupreplybyRadiusService::syncPackage().radius_groupmust exactly match thegroupnameinradgroupreplyandradusergroup. Changing it without a RADIUS sync will break authentication for all customers on this package.active = falsehides the package from dealer and staff creation forms; existing customers on the package are unaffected.
7. Nas¶
File: app/Models/Nas.php
Table: nas
Soft Deletes: No
Represents a Network Access Server — a MikroTik (or compatible) router that authenticates PPPoE sessions via FreeRADIUS.
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
nasname |
string | — | NAS IP address (IPv4); used by FreeRADIUS for NAS lookup |
shortname |
string | — | Display name (e.g., Core-Router-1) |
type |
string | — | Device type (e.g., mikrotik, cisco) |
ports |
integer | — | Number of ports (informational; FreeRADIUS default: 1812) |
community |
string | — | SNMP community string for read access |
description |
string | — | Human-readable description |
status |
string | — | active / inactive |
api_user |
string | — | MikroTik RouterOS API username |
api_password |
string | encrypted | RouterOS API password; stored encrypted via Laravel cast |
api_port |
integer | — | RouterOS API port (default: 8728 plaintext, 8729 SSL) |
device_class |
string | — | router / olt / switch |
Casts¶
| Field | Cast |
|---|---|
api_password |
encrypted — uses Laravel's built-in encryption (APP_KEY) |
Relationships¶
- Dealers access NAS via
dealer_naspivot (User::nasDevices()). - Customers reference NAS via
nas_idFK.
Notes¶
- The
nastable is also read by FreeRADIUS itself (via therlm_sqlmodule) to validate that the NAS IP is authorized. Thenasnamefield must match the router's actual IP. api_passworduses Laravel encryption — never stored in plaintext. Decrypted in memory only when making RouterOS API calls.- RADIUS secret for this NAS is stored in
/etc/freeradius/3.0/clients.confon the server, NOT in this table. The DBnastable does not contain RADIUS shared secrets.
8. RadCheck¶
File: app/Models/RadCheck.php
Table: radcheck
Soft Deletes: No
FreeRADIUS check attribute table. Stores the credential that FreeRADIUS verifies during PPPoE authentication.
Fields¶
| Field | Type | Purpose |
|---|---|---|
id |
integer | Primary key |
username |
string | PPPoE username (matches customers.pppoe_username) |
attribute |
string | RADIUS attribute name |
op |
string | Operator (==, :=, =~, etc.) |
value |
string | Attribute value |
How it is Used¶
For each customer, RadiusService::syncCustomer() writes one row:
Optionally, for disabled/suspended customers, it may write:
This immediately blocks authentication without deleting the account.
Notes¶
- This table is shared between the PyroRadius application and FreeRADIUS. FreeRADIUS reads it directly during every authentication attempt.
- The PyroRadius app reads it only during RADIUS sync operations; all authentication is handled by FreeRADIUS.
- No Laravel soft deletes — rows are hard-deleted when a customer is removed.
9. RadGroupReply¶
File: app/Models/RadGroupReply.php
Table: radgroupreply
Soft Deletes: No
FreeRADIUS group reply attribute table. Stores RADIUS attributes that are sent back to the NAS (router) when a PPPoE session is accepted.
Fields¶
| Field | Type | Purpose |
|---|---|---|
id |
integer | Primary key |
groupname |
string | Group name (matches packages.radius_group) |
attribute |
string | RADIUS reply attribute |
op |
string | Operator (typically :=) |
value |
string | Attribute value |
How it is Used¶
For each package, RadiusService::syncPackage() writes rows like:
groupname = 'pkg_10mbps_basic'
attribute = 'Mikrotik-Rate-Limit'
op = ':='
value = '10M/10M 20M/20M 10M/10M 8/8 10'
Multiple attributes per group are possible (e.g., Session-Timeout, Idle-Timeout, Framed-IP-Netmask).
Notes¶
- Group names are defined by
packages.radius_group. They must be consistent betweenradgroupreplyandradusergroup. RadiusService::syncPackage()is called when a package's speed is edited — it updates theradgroupreplyrows, which affects ALL customers on that package immediately (on their next authentication or CoA refresh).
10. RadUserGroup¶
File: app/Models/RadUserGroup.php
Table: radusergroup
Soft Deletes: No
Links a PPPoE username to a FreeRADIUS group. This is how a customer is assigned their speed package.
Fields¶
| Field | Type | Purpose |
|---|---|---|
id |
integer | Primary key |
username |
string | PPPoE username |
groupname |
string | RADIUS group name (matches packages.radius_group) |
priority |
integer | Priority when multiple groups apply (lower = higher priority) |
How it is Used¶
RadiusService::syncCustomer() writes one row:
When FreeRADIUS authenticates a PPPoE session, it looks up the group from radusergroup and fetches reply attributes from radgroupreply for that group.
Notes¶
- Changing a customer's package updates this row to the new package's
radius_group. - Username renames (via
CustomerController::update()) delete the old row and insert a new one with the new username.
11. RadAcct¶
File: app/Models/RadAcct.php
Table: radacct
Soft Deletes: No
Application Access: Read-only
FreeRADIUS accounting table. Written exclusively by FreeRADIUS; the PyroRadius application only reads it.
Key Fields¶
| Field | Type | Purpose |
|---|---|---|
radacctid |
bigint | Primary key (FreeRADIUS auto-increments) |
acctsessionid |
string | FreeRADIUS session ID |
username |
string | PPPoE username |
nasipaddress |
string | NAS (router) IP address |
framedipaddress |
string | IP assigned to the PPPoE client |
acctstarttime |
timestamp | Session start time |
acctstoptime |
timestamp | Session end time; NULL = session is active |
acctinputoctets |
bigint | Bytes uploaded by client |
acctoutputoctets |
bigint | Bytes downloaded by client |
acctterminatecause |
string | How the session ended (e.g., User-Request, Admin-Reset) |
callingstationid |
string | MAC address of the PPPoE client (if provided by router) |
How it is Used by PyroRadius¶
| Use Case | Query |
|---|---|
| Online detection (dashboard, customer list) | WHERE acctstoptime IS NULL → collect username set |
| Session kick | WHERE username = ? AND acctstoptime IS NULL → get nasipaddress + acctsessionid |
| Session history on customer show | WHERE username = ? ORDER BY acctstarttime DESC LIMIT 20 |
Notes¶
- Never write to this table from the application. It is owned by FreeRADIUS.
acctstoptime IS NULLis the reliable online indicator. Do not use session IDs or start times alone.- On very busy systems, this table can have millions of rows. Queries against it should always use indexed columns (
username,acctstoptime,nasipaddress). - FreeRADIUS does not expire or archive old sessions automatically. Old rows must be pruned via the
F7 Prunecron job (Batch A fix).
12. WhatsappTemplate¶
File: app/Models/WhatsappTemplate.php
Table: whatsapp_templates
Soft Deletes: No
Stores the body text for WhatsApp messages. Supports placeholder substitution.
Fillable Fields¶
| Field | Type | Purpose |
|---|---|---|
key |
string | Unique template key (e.g., payment_received, expiry_reminder) |
body |
string | Message body with {placeholder} variables |
dealer_id |
integer | FK → users.id; null = global template; non-null = dealer override |
enabled |
boolean | Whether this template is active |
How Placeholders Work¶
The {placeholder} tokens in body are replaced at send time. Common placeholders:
{customer_name} → customer.full_name
{amount} → payment amount
{receipt_no} → payment.receipt_no
{expiry_date} → customer.expiry_date
{package_name} → package.name
{company_name} → CompanyProfile.name
Notes¶
- Dealer overrides: if a
WhatsappTemplateexists withdealer_id = Xandkey = 'payment_received', it is used for that dealer's customers instead of the global template. - Templates are resolved by
WhatsAppServiceat send time: dealer-specific first, global fallback.
13. WhatsappLog¶
File: app/Models/WhatsappLog.php
Table: whatsapp_logs
Soft Deletes: No
Audit trail for every WhatsApp message sent.
Fillable Fields¶
| Field | Type | Purpose |
|---|---|---|
to |
string | Recipient WhatsApp number (international format) |
body |
string | Actual message body after placeholder substitution |
status |
string | sent / failed / pending |
customer_id |
integer | FK → customers.id; nullable |
template_key |
string | Which template was used |
driver |
string | Which WhatsApp driver was used (e.g., baileys, ultramsg) |
response |
string | Raw API response or error message |
sent_at |
timestamp | When the send was attempted |
14. WhatsappSession¶
File: app/Models/WhatsappSession.php
Table: whatsapp_sessions
Soft Deletes: No
Tracks the connection state of WhatsApp session(s).
Fillable Fields¶
| Field | Type | Purpose |
|---|---|---|
session_id |
string | Unique session identifier |
name |
string | Human label for this session |
status |
string | ready / connecting / disconnected |
Notes¶
status = 'ready'means QR code has been scanned and the session is authenticated.WhatsAppServicechecks this before attempting to send; messages are queued or dropped ifstatus != 'ready'.
15. AuditLog¶
File: app/Models/AuditLog.php
Table: audit_logs
Soft Deletes: No (audit logs are immutable — never deleted in normal operation)
Immutable record of every significant action taken in the system. Written by AuditLogger::log().
Fillable Fields¶
| Field | Type | Cast | Purpose |
|---|---|---|---|
user_id |
integer | — | FK → users.id; who performed the action |
user_name |
string | — | Denormalized user name (preserves name if user is later deleted) |
role |
string | — | User's role at time of action |
action |
string | — | Dot-notation action key (e.g., customer.created, customer.recharged) |
subject_type |
string | — | Eloquent model class name (e.g., App\Models\Customer) |
subject_id |
integer | — | Primary key of the affected record |
old_values |
array | JSONB | State before the change; null for create actions |
new_values |
array | JSONB | State after the change; null for delete actions |
ip |
string | — | Client IP address of the requesting session |
created_at |
timestamp | Carbon | When the action occurred |
Common Action Keys¶
| Action | Trigger |
|---|---|
customer.created |
CustomerController::store() |
customer.updated |
CustomerController::update() |
customer.deleted |
CustomerController::destroy() |
customer.recharged |
CustomerController::recharge() |
customer.restored |
Customer restore action |
invoice.created |
BillingService::generateInvoice() |
payment.reversed |
Payment reversal action |
user.login |
Authentication event |
settings.updated |
SettingController::update() |
Notes¶
old_valuesandnew_valuesare JSONB columns. They store a snapshot of the relevant fields, not the entire model.- The
AuditLogtable should never have rows deleted by the application. Archival (moving old logs to cold storage) is acceptable but deletion is not. user_nameandroleare denormalized intentionally — the audit trail must remain meaningful even if the user account is later deleted or their role changes.
16. Setting¶
File: app/Models/Setting.php
Table: settings
Soft Deletes: No
Global application key-value configuration store.
Fields¶
| Field | Type | Purpose |
|---|---|---|
key |
string | Unique setting key |
value |
string | Setting value (always stored as string; cast in application code) |
Static Helper¶
Reads a setting by key. Returns $default if the key does not exist. Uses caching internally (TTL: 60 seconds) to avoid repeated DB hits.
Upserts a setting value and clears the cache.
Common Settings Keys¶
| Key | Purpose |
|---|---|
auto_generate_invoice |
1 / 0 — Auto-generate invoice on unattributed payment |
grace_days_default |
Default grace period in days after expiry |
invoice_due_days |
Days after period_start that invoice is due |
whatsapp_enabled |
Global toggle for WhatsApp notifications |
payment_methods |
Comma-separated list of accepted payment methods |
sms_enabled |
Toggle for SMS (if integrated) |
17. CompanyProfile¶
File: app/Models/CompanyProfile.php
Table: company_profiles
Soft Deletes: No
Stores ISP branding and contact information displayed throughout the application and on invoices/receipts.
Fillable Fields¶
| Field | Type | Purpose |
|---|---|---|
name |
string | ISP company name |
tagline |
string | Company tagline |
logo_path |
string | Path to uploaded logo file |
brand_color |
string | Primary brand color (hex, e.g., #E0330A) |
address |
string | Company address |
phone |
string | Main contact phone |
email |
string | Main contact email |
Notes¶
- Only one row exists in practice (singleton table). Access via
CompanyProfile::first(). logo_pathis a relative path from thestorage/app/publicdirectory; served viaStorage::url().brand_coloris injected into invoice PDFs and email templates.
18. Olt¶
File: app/Models/Olt.php
Table: olts
Soft Deletes: No
Represents an Optical Line Terminal — a GPON/EPON device that manages ONT/ONU subscribers.
Fillable Fields¶
| Field | Type | Purpose |
|---|---|---|
name |
string | Display name (e.g., OLT-Hub-1) |
host |
string | IP address of the OLT management interface |
port |
integer | SSH/Telnet port |
username |
string | Management login username |
password |
string | Management login password (stored encrypted) |
type |
string | OLT vendor/type (e.g., huawei, zte, bdcom) |
status |
string | active / inactive |
Notes¶
passwordshould use Laravelencryptedcast — verify this is applied in the actual model.- The
olt:sync-ont-assignmentsArtisan command reads from this table to connect to each OLT and refreshont_cache.
19. OntCache¶
File: app/Models/OntCache.php
Table: ont_cache
Soft Deletes: No
Ephemeral cache of ONT/ONU discovery data fetched from OLT devices. Refreshed by the olt:sync-ont-assignments command every minute.
Fields¶
| Field | Type | Purpose |
|---|---|---|
sn_decoded |
string | ONT serial number in human-readable decoded format (e.g., HWTC1234ABCD) |
description |
string | Description field from OLT — typically contains the PPPoE username |
rx_raw |
integer | RX optical power × 100 (so -23.5 dBm = -2350) |
port_alias |
string | OLT port name/alias (e.g., 0/1/3) |
olt_id |
integer | FK → olts.id |
How it is Used¶
The SyncOntAssignments command:
1. Connects to each OLT via SSH.
2. Fetches all ONT registrations (serial, description, RX power, port).
3. Upserts rows into ont_cache.
4. Then runs an UPDATE joining ont_cache.description to customers.pppoe_username (after LOWER/TRIM normalization) to populate customers.rx_power, customers.rx_power_at, and customers.ont_serial.
Performance Note¶
As of 2026-07-05, this join query takes 13-14 seconds due to missing functional index on LOWER(TRIM(description)). This is a known issue scheduled for optimization. See docs/SYNC_ONT_ASSIGNMENTS_OPTIMIZATION_AUDIT.md.
20. CronJob¶
File: app/Models/CronJob.php
Table: cron_jobs
Soft Deletes: No
Registry of scheduled Artisan commands, managed through the UI rather than hardcoded in app/Console/Kernel.php.
Fillable Fields¶
| Field | Type | Purpose |
|---|---|---|
command |
string | Artisan command signature (e.g., olt:sync-ont-assignments) |
expression |
string | Cron expression (e.g., * * * * * for every minute) |
enabled |
boolean | Whether this job is scheduled |
without_overlap |
boolean | If true, skips if a previous run is still executing |
log_file |
string | Optional path for command output logging |
Notes¶
- The Laravel scheduler reads this table at runtime to dynamically build the schedule. This allows admins to enable/disable or adjust cron timing from the UI without SSH access.
without_overlapmaps to Laravel's->withoutOverlapping()scheduler modifier — critical for long-running commands likesync-ont-assignments.
21. Ticket¶
File: app/Models/Ticket.php
Table: tickets
Soft Deletes: No
Support and NOC incident ticket.
Fillable Fields¶
| Field | Type | Purpose |
|---|---|---|
title |
string | Short description of the issue |
description |
string | Full problem description |
status |
string | open / in_progress / resolved / closed |
priority |
string | low / medium / high / critical |
customer_id |
integer | FK → customers.id; which customer is affected |
assigned_to |
integer | FK → users.id; assigned staff member |
due_date |
date | Expected resolution date |
category_id |
integer | FK → ticket_categories.id |
Relationships¶
| Method | Type | Related Model | Foreign Key | Notes |
|---|---|---|---|---|
customer |
BelongsTo | Customer |
customer_id |
|
assignee |
BelongsTo | User |
assigned_to |
|
category |
BelongsTo | TicketCategory |
category_id |
22. DealerPackage¶
File: app/Models/DealerPackage.php
Table: dealer_package
Type: Pivot model (intermediate table for User ↔ Package many-to-many)
Stores per-dealer custom pricing for packages, and controls which packages each dealer can offer to their customers.
Fields¶
| Field | Type | Purpose |
|---|---|---|
dealer_id |
integer | FK → users.id (dealer role) |
package_id |
integer | FK → packages.id |
rate |
decimal | Custom price for this dealer's customers (overrides package.price) |
active |
boolean | Whether this dealer can currently assign this package |
How it is Used¶
Package::dealers()returns dealers with pivotrateandactive.User::packages()returns packages with pivotrateandactive.- When a dealer creates a customer,
PackageAvailabilityServicechecks thatdealer_package.active = truefor the chosen package. - When generating an invoice,
BillingServiceusescustomer.monthly_price(if set) →dealer_package.rate(if set) →package.priceas the price resolution chain.
Notes¶
- This is a pivot model with extra columns. It extends
Pivot(notModel) in Laravel. - The
ratefield is optional (nullable). A null rate means the dealer sells at the package's standard price.
Appendix: Model Relationship Map¶
CompanyProfile (singleton)
User ──────────────────────────────────────────────────────┐
├── children (User, sub-dealers) │
├── packages (BelongsToMany Package via dealer_package) │
├── nasDevices (BelongsToMany Nas via dealer_nas) │
└── assignedTickets (Ticket) │
│
Customer ──────────────────────────────────────────────────┤
├── package (Package) │
├── dealer (User) ─────────────────────────────────────>─┘
├── subDealer (User)
├── nas (Nas)
├── areaRef (Area)
├── invoices (Invoice)
│ └── payments (Payment)
├── payments (Payment)
└── [RADIUS sync] radcheck, radusergroup
Package ──────────────────────────────────────────────────
└── [RADIUS sync] radgroupreply
Nas ───────────────────────────────────────────────────────
└── [FreeRADIUS auth/acct] radacct
Olt ───────────────────────────────────────────────────────
└── ont_cache (OntCache) ──→ Customer.rx_power
Setting (key-value)
AuditLog (immutable)
WhatsappTemplate → WhatsappLog
WhatsappSession
CronJob
Ticket
LedgerEntry
Appendix: Cast Reference¶
| Model | Field | Cast | Notes |
|---|---|---|---|
User |
permissions |
array |
JSONB column |
User |
active |
boolean |
|
Customer |
pppoe_password |
hidden |
Excluded from serialization |
Customer |
expiry_date |
date |
Carbon date |
Customer |
monthly_price |
float |
|
Customer |
rx_power |
float |
dBm × 100 stored as int |
Customer |
contract_expiry |
date |
Carbon date |
Nas |
api_password |
encrypted |
Laravel encryption |
LedgerEntry |
old_values |
array |
JSONB |
LedgerEntry |
new_values |
array |
JSONB |
AuditLog |
old_values |
array |
JSONB |
AuditLog |
new_values |
array |
JSONB |
Package |
active |
boolean |
|
CronJob |
enabled |
boolean |
|
CronJob |
without_overlap |
boolean |
End of PHASE2_04_Model_Reference.md