PyroRadius — Phase 2 Controller & Service Reference¶
Version: 2.0.0 Date: 2026-07-13 Scope: Method-by-method documentation for all major controllers and the BillingService deep-dive. Stack: Laravel 13 · Inertia.js v2 · PostgreSQL 16 · FreeRADIUS 3.2.5
Table of Contents¶
- CustomerController
- index()
- create()
- store()
- show()
- edit()
- update()
- destroy()
- recharge()
- kick()
- DashboardController
- index()
- viewAs()
- resolveContext()
- Online Count Logic
- BillingService Deep Dive
- generateInvoice()
- recordPayment()
- renew()
- applyExcessToUnpaid()
- nextNumber()
1. CustomerController¶
File: app/Http/Controllers/CustomerController.php
Constructor Injections:
| Service | Variable | Purpose |
|---|---|---|
RadiusService |
$radiusService |
RADIUS sync, session kick |
WhatsAppService |
$whatsAppService |
Send WhatsApp notifications |
PackageAvailabilityService |
$packageAvailabilityService |
Check package eligibility per dealer |
index()¶
| Field | Value |
|---|---|
| HTTP | GET |
| Route | customers.index → /customers |
| Permission | customers.view |
| Returns | Inertia::render('Customers/Index', [...]) |
Parameters: None (all via query string)
Query String Filters:
| Parameter | Type | Purpose |
|---|---|---|
status |
string | all / active / expired / suspended / temp_extended / disabled |
search |
string | Searches full_name, pppoe_username, mobile, customer_code (case-insensitive ILIKE) |
dealer_id |
integer | Filter by dealer |
package_id |
integer | Filter by package |
area_id |
integer | Filter by area |
connection_type_id |
integer | Filter by connection type |
Business Logic:
- Cache online PPPoE usernames for 30 seconds from RouterOS via
radaccttable (whereNull('acctstoptime')). Cache key:online_usernames. - Apply
Customer::visibleTo($me)scope — restricts rows based on authenticated user's role (dealers see only their customers, etc.). - Apply any active query string filters using conditional
when()clauses. - Eager-load relationships:
package:id,name,rate_limit,dealer:id,name,customerType:id,name,connectionType:id,name,form_mode,areaRef:id,name. - Paginate results (15 per page by default).
- Merge
is_onlineflag onto each customer by checkingpppoe_usernameagainst the cached set. - Pass filter options (packages, dealers, areas, connection types) for the filter bar UI.
Services Called:
- Cache facade (30-second TTL for online_usernames)
Models Touched:
- Customer (read)
- RadAcct (read — online detection)
- Package, User (dealer), Area, ConnectionType (read — filter options)
Side Effects: None
Error Handling:
- RouterOS/radacct unavailability falls back to empty online set; customers display as offline rather than throwing.
create()¶
| Field | Value |
|---|---|
| HTTP | GET |
| Route | customers.create → /customers/create |
| Permission | customers.create |
| Returns | Inertia::render('Customers/Form', [...]) |
Parameters: None
Business Logic:
- Load all active packages (filtered by dealer's accessible packages if user is a dealer).
- Load areas, NAS devices, connection types, customer types, service types, payment methods from Settings/DB.
- If authenticated user is admin/super_admin, load dealers list for assignment dropdown.
- Return all data as props for the React form.
Models Touched:
- Package, Area, Nas, ConnectionType, CustomerType (read)
- User (read — dealer list, admin only)
Side Effects: None
Error Handling: - No packages available → form renders with empty package list; UI shows appropriate message.
store()¶
| Field | Value |
|---|---|
| HTTP | POST |
| Route | customers.store → /customers |
| Permission | customers.create |
| Returns | redirect()->route('customers.show', $customer) with success flash |
Parameters:
full_name(string, required) — Customer display namemobile(string, required) — Primary phone numberwhatsapp(string, optional) — WhatsApp number if different from mobileaddress(string, optional) — Physical addresspppoe_username(string, required) — Must be unique incustomerstablepppoe_password(string, optional) — Auto-generated if blankpackage_id(integer, required) — FK to packagesdealer_id(integer, optional) — FK to users (dealer role)expiry_date(date, optional) — Initial expiry; defaults to today + 1 month if omittedstatic_ip(string, optional) — Static IP for Framed-IP-Address RADIUS attributearea_id(integer, optional)connection_type_id(integer, optional)customer_type_id(integer, optional)monthly_price(numeric, optional) — Overrides package pricecnic(string, optional)ont_serial(string, optional)comments(string, optional)
Validation Rules:
[
'full_name' => ['required', 'string', 'max:255'],
'mobile' => ['required', 'string', 'max:20'],
'whatsapp' => ['nullable', 'string', 'max:20'],
'address' => ['nullable', 'string', 'max:500'],
'pppoe_username' => ['required', 'string', 'max:64', 'unique:customers,pppoe_username'],
'pppoe_password' => ['nullable', 'string', 'min:6', 'max:64'],
'package_id' => ['required', 'integer', 'exists:packages,id'],
'dealer_id' => ['nullable', 'integer', 'exists:users,id'],
'expiry_date' => ['nullable', 'date'],
'static_ip' => ['nullable', 'ip'],
'area_id' => ['nullable', 'integer', 'exists:areas,id'],
'connection_type_id' => ['nullable', 'integer', 'exists:connection_types,id'],
'customer_type_id' => ['nullable', 'integer', 'exists:customer_types,id'],
'monthly_price' => ['nullable', 'numeric', 'min:0'],
'cnic' => ['nullable', 'string', 'max:15'],
'ont_serial' => ['nullable', 'string', 'max:64'],
'comments' => ['nullable', 'string', 'max:1000'],
]
Business Logic:
- Validate all input.
- Auto-generate
pppoe_password(random 8-character alphanumeric) if not provided. - Generate
customer_codeviaCustomer::nextCode()— calls PostgreSQL sequencecustomer_code_seq, formats asCUS-NNNNNN. - Set
created_by= authenticated user's name. - Create
Customerrecord viaCustomer::create([...]). - Call
RadiusService::syncCustomer($customer)to writeRadCheck,RadUserGrouprecords. - Call
AuditLogger::log('customer.created', $customer, null, $customer->toArray()). - Return redirect to
customers.showwith['success' => 'Customer created successfully.']flash.
Services Called:
- RadiusService::syncCustomer()
- AuditLogger::log()
Models Touched:
- Customer (write)
- RadCheck, RadUserGroup (write — via RadiusService)
- AuditLog (write)
Side Effects:
- RADIUS sync: new radcheck row (Cleartext-Password), new radusergroup row linking username to package group.
- Audit log entry created.
Error Handling: - Validation failure → redirect back with errors (standard Laravel). - RADIUS sync failure → customer record already saved; error is logged but does not roll back the customer creation (RADIUS is eventually consistent).
show()¶
| Field | Value |
|---|---|
| HTTP | GET |
| Route | customers.show → /customers/{id} |
| Permission | customers.view |
| Returns | Inertia::render('Customers/Show', [...]) |
Parameters:
id(integer, required, route param) — Customer primary key
Business Logic:
- Load customer with all relevant relationships:
package,dealer,subDealer,nas,connectionType,areaRef,customerType. - Load
invoices(most recent 10, ordered bycreated_at DESC). - Load
payments(most recent 10, ordered bypaid_at DESC). - Check cached
online_usernamesset; setis_online = trueifpppoe_usernameis in the set. - Return all data as Inertia props.
Models Touched:
- Customer, Invoice, Payment (read)
- Package, User, Nas, ConnectionType, Area (read — eager loaded)
Side Effects: None
Error Handling:
- Customer not found → 404 via Laravel model binding.
- visibleTo scope enforced by middleware/policy; unauthorized → 403.
edit()¶
| Field | Value |
|---|---|
| HTTP | GET |
| Route | customers.edit → /customers/{id}/edit |
| Permission | customers.edit |
| Returns | Inertia::render('Customers/Form', [...]) |
Parameters:
id(integer, required, route param) — Customer primary key
Business Logic:
- Load customer record.
- Load same form options as
create()(packages, areas, dealers, NAS, connection types, customer types, payment methods). - Pass
customeras a prop alongside form options; React form pre-populates fields.
Models Touched:
- Customer (read)
- Package, Area, Nas, ConnectionType, CustomerType, User (read — form options)
Side Effects: None
update()¶
| Field | Value |
|---|---|
| HTTP | PUT |
| Route | customers.update → /customers/{id} |
| Permission | customers.edit |
| Returns | redirect()->route('customers.show', $customer) with success flash |
Parameters: Same fields as store(), with unique rule excluding current customer.
Validation Rules (key difference from store):
'pppoe_username' => [
'required', 'string', 'max:64',
Rule::unique('customers', 'pppoe_username')->ignore($customer->id),
],
Business Logic:
- Validate input.
- Detect if
pppoe_usernamehas changed:$oldUsername = $customer->pppoe_username. - Fill and save the customer model.
- Retry loop (up to 3 attempts): Wrap the
save()call in atry/catchforSQLSTATE 55P03(PostgreSQL lock not available / lock timeout). On each failure, sleep 300ms before retrying. After 3 failures, rethrow the exception. - Call
RadiusService::syncCustomer($customer, $oldUsername): - If username changed, RadiusService deletes the old
radcheck/radusergrouprows and creates new ones with the new username. - If username unchanged, updates password/group in place.
- Call
AuditLogger::log('customer.updated', $customer, $oldValues, $newValues)with a diff of changed fields. - Return redirect with success flash.
Services Called:
- RadiusService::syncCustomer($customer, ?string $oldUsername)
- AuditLogger::log()
Models Touched:
- Customer (write)
- RadCheck, RadUserGroup (write/delete — via RadiusService)
- AuditLog (write)
Side Effects: - RADIUS sync: handles username rename by removing old RADIUS records and creating new ones. - Audit log entry with old/new value diff.
Error Handling:
- Lock timeout (55P03): up to 3 retries with 300ms backoff. After 3 failures, returns 500 error to user.
- RADIUS sync failure: customer DB update is committed; RADIUS failure is logged but does not roll back. Operator must manually re-sync if RADIUS is unreachable.
destroy()¶
| Field | Value |
|---|---|
| HTTP | DELETE |
| Route | customers.destroy → /customers/{id} |
| Permission | customers.delete |
| Returns | redirect()->route('customers.index') with success flash |
Parameters:
id(integer, required, route param) — Customer primary keydelete_reason(string, required, request body) — Reason for deletion (required for audit trail)
Business Logic:
- Authorize
customers.deletepermission check. - Record
deleted_by= authenticated user's ID,delete_reason= provided reason. - Soft delete: set
deleted_at= now (uses LaravelSoftDeletestrait — does NOT physically remove the row). - Call
RadiusService::deleteCustomerRadius($customer->pppoe_username): - Deletes from
radcheck,radusergroupwhereusername = pppoe_username. - Does NOT delete
radacct(session history preserved). - Call
AuditLogger::log('customer.deleted', $customer, $customer->toArray(), null). - Return redirect to index with success flash.
Services Called:
- RadiusService::deleteCustomerRadius()
- AuditLogger::log()
Models Touched:
- Customer (soft delete — sets deleted_at)
- RadCheck, RadUserGroup (delete — via RadiusService)
- AuditLog (write)
Side Effects: - RADIUS records removed — customer immediately loses PPPoE access. - Soft delete preserves customer row; invoices, payments, and ledger entries remain intact for billing history. - Audit log entry.
Error Handling:
- Missing delete_reason → validation failure, redirect back with error.
- RADIUS deletion failure: customer soft-deleted in DB; RADIUS failure logged. Customer may retain RADIUS access until the next FreeRADIUS sync cycle or manual cleanup.
recharge()¶
| Field | Value |
|---|---|
| HTTP | POST |
| Route | customers.recharge → /customers/{id}/recharge |
| Permission | customers.recharge |
| Returns | redirect()->route('customers.show', $customer) with success flash |
Parameters:
amount(numeric, required) — Payment amount; minimum: 1method(string, required) — Must be one of the configured payment methods (e.g.,cash,easypaisa,jazzcash,bank_transfer)invoice_id(integer, optional) — If provided, must belong to this customer; payment is applied to this specific invoicenotes(string, optional) — Payment notes or reference number
Validation Rules:
[
'amount' => ['required', 'numeric', 'min:1'],
'method' => ['required', 'string', 'in:' . implode(',', $paymentMethods)],
'invoice_id' => ['nullable', 'integer', 'exists:invoices,id'],
'notes' => ['nullable', 'string', 'max:500'],
]
Business Logic:
- Authorize
customers.rechargepermission. - Validate input. If
invoice_idprovided, additionally verifyinvoice.customer_id === $customer->id. - If no
invoice_idprovided and the customer has no unpaid invoices, optionally auto-generate a new invoice viaBillingService::generateInvoice($customer, $user->name)(controlled by a setting flag). - Call
BillingService::recordPayment($customer, $amount, $method, $user, $invoice, $notes, $collectedBy). - This handles: payment record creation, ledger entries, invoice status update, renewal if paid-in-full.
- Call
WhatsAppService::sendToCustomer($customer, 'payment_received', ['amount' => $amount, 'receipt_no' => $payment->receipt_no, ...]). - Sends WhatsApp receipt to customer's
whatsappnumber (falls back tomobile). - Failure is caught and logged; does not block the recharge response.
- Call
AuditLogger::log('customer.recharged', $customer, null, ['amount' => $amount, 'method' => $method, 'receipt_no' => $payment->receipt_no]). - Return redirect with
['success' => 'Payment recorded. Receipt: ' . $payment->receipt_no]flash.
Services Called:
- BillingService::generateInvoice() (conditional)
- BillingService::recordPayment()
- WhatsAppService::sendToCustomer()
- AuditLogger::log()
Models Touched:
- Invoice (read/write)
- Payment (write)
- LedgerEntry (write — via BillingService)
- Customer (write — expiry_date, status updated via BillingService::renew)
- AuditLog (write)
- WhatsappLog (write — via WhatsAppService)
Side Effects:
- RADIUS sync triggered if renewal occurs (customer status changes to active, expiry extended).
- WhatsApp payment receipt sent to customer.
- Audit log entry.
- Ledger entries created for both customer account and dealer account.
Error Handling: - BillingService uses deadlock retry (40P01, 3 attempts). On exhaustion → 500 with error message. - WhatsApp failure → logged silently; payment still records successfully. - RADIUS sync failure during renewal → logged; renewal persists in DB.
kick()¶
| Field | Value |
|---|---|
| HTTP | POST |
| Route | customers.kick → /customers/{id}/kick |
| Permission | customers.disconnect |
| Returns | response()->json(['acked' => $count]) |
Parameters:
id(integer, required, route param) — Customer primary key
Business Logic:
- Authorize
customers.disconnectpermission. - Load the customer; resolve their active NAS device.
- Call
RadiusService::kickSession($customer): - Queries
radacctfor all active sessions (acctstoptime IS NULL) forpppoe_username. - For each active session, sends a RADIUS CoA Disconnect-Request packet via
radclient. - Port strategy: Tries port 3799 first (RFC 5176 standard). Falls back to port 1700 (RouterOS 7.x compatibility).
- Returns count of sessions that received an
Ackresponse. - Return
{'acked': N}where N is the number of disconnected sessions.
Security measures for radclient call:
- RADIUS shared secret is written to a temporary file and passed via file path (--secret-file), never as an argv argument (prevents exposure in ps aux).
- pppoe_username and NAS IP are sanitized against RADIUS injection characters before shell execution.
- Temporary file is always deleted in a finally block.
Services Called:
- RadiusService::kickSession()
Models Touched:
- Customer (read)
- Nas (read — IP, RADIUS secret)
- RadAcct (read — active session detection)
Side Effects:
- Customer's PPPoE session(s) forcibly disconnected on the router.
- RouterOS will immediately terminate the active PPP connection; customer device will attempt to reconnect if credentials are still valid.
- No RADIUS DB changes — radacct is updated by FreeRADIUS when session actually ends.
Error Handling:
- NAS unreachable: radclient returns no Ack; response is {'acked': 0} with no exception thrown.
- radclient binary not found: 500 error; admin must verify FreeRADIUS tools are installed.
2. DashboardController¶
File: app/Http/Controllers/DashboardController.php (~523 lines)
Constructor Injections: None (uses static helpers and facades directly)
dashboard/index()¶
| Field | Value |
|---|---|
| HTTP | GET |
| Route | dashboard → /dashboard |
| Permission | Authenticated users only (any role) |
| Returns | Inertia::render('Dashboard/Index', [...]) |
Business Logic:
- Call
resolveContext()to determine which user's perspective to render (supportsview_asadmin override). - Based on resolved context role:
- super_admin / admin / noc_admin / billing_admin: Full stats — total customers, active/expired/suspended counts, online count, revenue this month, outstanding balance, dealer count.
- dealer: Stats scoped to their own customers only — customer counts, collections this month.
- sub_dealer: Stats scoped to their parent dealer's customers where they are the sub_dealer.
- collection_officer: Outstanding balances and recent collections assigned to them.
- Online count: See Online Count Logic below.
- Load recent activity: last 10 audit log entries visible to this user.
- Load quick stats: new customers this month, renewals this month.
- Return all data as Inertia props.
Models Touched:
- Customer (read — counts, scoped by role)
- Invoice, Payment (read — revenue/outstanding stats)
- AuditLog (read — recent activity)
- RadAcct (read — online count, cached)
Side Effects: None
viewAs()¶
| Field | Value |
|---|---|
| HTTP | GET / POST |
| Route | dashboard.view-as → /dashboard/view-as |
| Permission | super_admin only |
| Returns | redirect()->route('dashboard') |
Parameters:
user_id(integer, optional) — ID of user to impersonate view as. If null/absent, clears theview_assession.
Business Logic:
- Authorize: only
super_adminmay use this feature. - If
user_idprovided: validate it exists, storesession(['view_as_user_id' => $userId]). - If
user_idabsent/null:session()->forget('view_as_user_id'). - Redirect to dashboard —
resolveContext()will pick up the session on next load.
Side Effects: - Session modification. No DB changes.
resolveContext()¶
Visibility: private — called internally by index()
Signature: resolveContext(): User
Business Logic:
- Check session for
view_as_user_id. If present AND authenticated user issuper_admin: - Load the target user from DB.
- Set a flag
$isViewingAs = truefor the Inertia props (UI shows a banner "Viewing as: [name]"). - Return the target user as the resolved context.
- If no
view_assession: returnAuth::user().
Purpose: Allows super_admin to see the dashboard exactly as any dealer or staff member sees it, without switching accounts.
Online Count Logic¶
The "ONLINE NOW" metric is a frequently-updated, performance-sensitive count.
Implementation:
Cache key: online_usernames
Cache TTL: 30 seconds
Cache driver: Redis (configured via CACHE_DRIVER=redis)
How it works:
- On every
index()call, check Redis foronline_usernamescache. - Cache hit: Return cached set immediately (no DB query).
- Cache miss: Query
radaccttable:SELECT username FROM radacct WHERE acctstoptime IS NULL. Collect into a PHP array/Set. Store in Redis with 30-second TTL. - Count of the set = "ONLINE NOW" number.
- For the customer list, each customer's
pppoe_usernameis checked against this set to setis_online.
Why 30 seconds: FreeRADIUS updates radacct.acctstoptime when a session ends. A 30-second cache gives a near-real-time count without hammering PostgreSQL on every dashboard load.
Failure mode: If Redis is unavailable, Laravel falls back to the array cache driver (process-memory). The count may be stale or 0 for the duration of the Redis outage, but no exception surfaces to the user.
Historical context: This metric had recurring inaccuracies due to NULLs in acctstoptime and multi-NAS session handling. The current implementation (Batch A fix) uses whereNull('acctstoptime') directly on radacct and has been stable since v0.9.6.
3. BillingService Deep Dive¶
File: app/Services/BillingService.php
This service is the financial core of PyroRadius. It handles all money movement — invoice generation, payment recording, ledger double-entry, and customer renewal. All database operations that modify financial records go through this service.
generateInvoice()¶
Signature:
Purpose: Creates a new invoice for a customer's next billing period.
Business Logic:
-
Amount resolution:
Customer-level override (monthly_price) takes priority over the package's standard price. -
Period calculation:
period_start=customer.expiry_dateif set (invoice picks up from expiry), elsetoday.-
period_end=period_start + 1 monthusingaddMonthNoOverflow()— this prevents the January 31 → March 2 overflow bug (e.g., Jan 31 + 1 month = Feb 28, not Mar 2). -
Idempotency guard:
- Checks for an existing
Invoicewith the samecustomer_id,period_start, andperiod_end. -
If found, returns the existing invoice instead of creating a duplicate. Prevents double-invoicing on retry.
-
Invoice creation:
invoice_no=nextNumber('INV', Invoice::class)→ e.g.,INV202607-00042status=unpaiddue_date=period_start + 7 days(configurable via Setting)dealer_id=customer.dealer_idcreated_by=$createdBy
Models Touched:
- Invoice (read — idempotency check; write — creation)
- Customer, Package (read — amount resolution)
Returns: Invoice — the newly created or already-existing invoice.
recordPayment()¶
Signature:
public function recordPayment(
Customer $customer,
float $amount,
string $method,
User $user,
?Invoice $invoice,
?string $notes,
?string $collectedBy
): Payment
Purpose: The central financial transaction method. Records a payment, creates double-entry ledger entries, updates invoice status, and triggers renewal if appropriate.
Business Logic:
- Payment record creation:
receipt_no=nextNumber('RCP', Payment::class)→ e.g.,RCP202607-00017received_by=$collectedByif provided, else$user->namepaid_at=now()-
dealer_id=customer.dealer_id -
Ledger double-entry (within same DB transaction):
- Creates a
LedgerEntryfor the customer account:type = credit,account_type = customer,account_id = customer.id. - Creates a
LedgerEntryfor the dealer account:type = credit,account_type = dealer,account_id = customer.dealer_id. -
Both entries share the same
invoice_id,effective_date = now(), anddescription. -
Invoice resolution:
If $invoice provided (targeted payment):
- Use lockForUpdate() on the invoice row (PostgreSQL row-level lock).
- Add $amount to invoice.paid_amount.
- Recalculate status:
- paid_amount == 0 → unpaid
- 0 < paid_amount < amount → partial
- paid_amount >= amount → paid
- If status becomes paid → call renew($customer, $invoice).
- If paid_amount > invoice.amount (excess payment) → call applyExcessToUnpaid($customer, $excess, $excludeId = $invoice->id).
If $invoice is null (unattributed payment):
- Call applyExcessToUnpaid($customer, $amount, null) — applies to oldest unpaid invoices first.
- If after applying to all unpaid invoices there is still remaining balance AND customer.status == 'temp_extended' → call renew($customer, null).
- Deadlock retry:
- The entire operation from step 1 through step 3 is wrapped in a
DB::transaction(). - On
SQLSTATE 40P01(PostgreSQL deadlock detected), the transaction is retried up to 3 times. - Backoff: 50ms → 100ms → 150ms (linear progression).
- After 3 failures, the deadlock exception propagates to the controller.
Models Touched:
- Payment (write)
- LedgerEntry (write — 2 entries per payment)
- Invoice (write — paid_amount, status; locked for update)
- Customer (write — via renew())
Returns: Payment — the newly created payment record.
renew()¶
Signature:
Purpose: Extends a customer's service expiry and sets them back to active status.
Business Logic:
- Calculate new expiry:
- If
$invoiceprovided andinvoice.period_endis set:expiry_date = invoice.period_end. - If no invoice (e.g., unattributed payment renewal):
expiry_date = customer.expiry_date + 1 month(usingaddMonthNoOverflow()), ortoday + 1 monthif expiry was in the past. - Set
customer.status = 'active'. - Set
customer.expiry_date = <calculated date>. - Save the customer.
- Call
RadiusService::syncCustomer($customer)inside atry/catch: - If RADIUS sync throws, catch the exception, log it, and continue. The renewal in the DB is committed regardless of RADIUS outcome.
- This prevents a RouterOS/FreeRADIUS outage from blocking billing operations.
Models Touched:
- Customer (write)
- RadCheck, RadUserGroup (write — via RadiusService)
Side Effects: - RADIUS sync: ensures customer's PPPoE credentials are active on FreeRADIUS. - Customer may re-authenticate immediately after renewal without manual intervention.
applyExcessToUnpaid()¶
Signature:
Purpose: Applies a remaining payment balance to a customer's oldest unpaid/partial invoices in sequence.
Business Logic:
- Query all
unpaidandpartialinvoices for this customer, ordered byperiod_start ASC(oldest first). - If
$excludeIdis set, exclude that invoice (already being updated by the caller to prevent double-update). - Acquire
lockForUpdate()on all matching invoices — locks are taken in consistent PK order to prevent deadlocks (a deliberate design choice: always lock by ascendingid). - For each invoice (oldest to newest) while
$remaining > 0: - Calculate amount to apply:
apply = min($remaining, invoice.amount - invoice.paid_amount). - Add
applytoinvoice.paid_amount. - Update invoice status (
unpaid→partial→paid). - If status becomes
paid→ callrenew($customer, $invoice). - Subtract
applyfrom$remaining. - If
$remaininghits 0, stop processing further invoices.
Models Touched:
- Invoice (write — locked for update, paid_amount/status updated)
- Customer (write — via renew())
Side Effects:
- Multiple renewals possible if customer had many outstanding invoices and paid in bulk.
- Each paid invoice triggers its own renew() call and RADIUS sync.
nextNumber()¶
Signature:
Purpose: Generates a unique, sequential, human-readable reference number using PostgreSQL sequences.
Implementation:
// Determines sequence name by model class:
// Invoice::class → 'seq_invoice_no'
// Payment::class → 'seq_receipt_no'
$seq = match ($model) {
Invoice::class => 'seq_invoice_no',
Payment::class => 'seq_receipt_no',
};
$next = DB::selectOne("SELECT nextval('{$seq}') AS val")->val;
$month = now()->format('Ym'); // e.g., "202607"
return $prefix . $month . '-' . str_pad($next, 5, '0', STR_PAD_LEFT);
Output format:
| Input | Example Output |
|---|---|
nextNumber('INV', Invoice::class) |
INV202607-00042 |
nextNumber('RCP', Payment::class) |
RCP202607-00017 |
Why PostgreSQL sequences: Sequences are atomic and gap-tolerant. They handle concurrent requests from multiple users (e.g., two dealers recording payments simultaneously) without producing duplicate numbers. Gaps in the sequence (due to failed transactions) are acceptable — the sequence only guarantees uniqueness and monotonic increase, not contiguity.
Sequence definitions (PostgreSQL):
CREATE SEQUENCE seq_invoice_no START 1;
CREATE SEQUENCE seq_receipt_no START 1;
CREATE SEQUENCE customer_code_seq START 1;
Appendix: Permission Keys Reference¶
| Permission Key | Used In |
|---|---|
customers.view |
index(), show() |
customers.create |
create(), store() |
customers.edit |
edit(), update() |
customers.delete |
destroy() |
customers.recharge |
recharge() |
customers.disconnect |
kick() |
Appendix: Key Error Codes¶
| Code | Meaning | Handling Strategy |
|---|---|---|
SQLSTATE 55P03 |
PostgreSQL lock not available (lock timeout) | Retry up to 3× with 300ms backoff |
SQLSTATE 40P01 |
PostgreSQL deadlock detected | Retry up to 3× with 50/100/150ms backoff |
RADIUS CoA NAK |
Router rejected the disconnect request | Log, return acked: 0 — no exception |
| WhatsApp send failure | HTTP/connection error to WhatsApp driver | Log, continue — non-blocking |
End of PHASE2_03_Controller_Reference.md