Skip to content

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

  1. CustomerController
  2. index()
  3. create()
  4. store()
  5. show()
  6. edit()
  7. update()
  8. destroy()
  9. recharge()
  10. kick()
  11. DashboardController
  12. index()
  13. viewAs()
  14. resolveContext()
  15. Online Count Logic
  16. BillingService Deep Dive
  17. generateInvoice()
  18. recordPayment()
  19. renew()
  20. applyExcessToUnpaid()
  21. 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:

  1. Cache online PPPoE usernames for 30 seconds from RouterOS via radacct table (whereNull('acctstoptime')). Cache key: online_usernames.
  2. Apply Customer::visibleTo($me) scope — restricts rows based on authenticated user's role (dealers see only their customers, etc.).
  3. Apply any active query string filters using conditional when() clauses.
  4. Eager-load relationships: package:id,name,rate_limit, dealer:id,name, customerType:id,name, connectionType:id,name,form_mode, areaRef:id,name.
  5. Paginate results (15 per page by default).
  6. Merge is_online flag onto each customer by checking pppoe_username against the cached set.
  7. 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:

  1. Load all active packages (filtered by dealer's accessible packages if user is a dealer).
  2. Load areas, NAS devices, connection types, customer types, service types, payment methods from Settings/DB.
  3. If authenticated user is admin/super_admin, load dealers list for assignment dropdown.
  4. 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 name
  • mobile (string, required) — Primary phone number
  • whatsapp (string, optional) — WhatsApp number if different from mobile
  • address (string, optional) — Physical address
  • pppoe_username (string, required) — Must be unique in customers table
  • pppoe_password (string, optional) — Auto-generated if blank
  • package_id (integer, required) — FK to packages
  • dealer_id (integer, optional) — FK to users (dealer role)
  • expiry_date (date, optional) — Initial expiry; defaults to today + 1 month if omitted
  • static_ip (string, optional) — Static IP for Framed-IP-Address RADIUS attribute
  • area_id (integer, optional)
  • connection_type_id (integer, optional)
  • customer_type_id (integer, optional)
  • monthly_price (numeric, optional) — Overrides package price
  • cnic (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:

  1. Validate all input.
  2. Auto-generate pppoe_password (random 8-character alphanumeric) if not provided.
  3. Generate customer_code via Customer::nextCode() — calls PostgreSQL sequence customer_code_seq, formats as CUS-NNNNNN.
  4. Set created_by = authenticated user's name.
  5. Create Customer record via Customer::create([...]).
  6. Call RadiusService::syncCustomer($customer) to write RadCheck, RadUserGroup records.
  7. Call AuditLogger::log('customer.created', $customer, null, $customer->toArray()).
  8. Return redirect to customers.show with ['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:

  1. Load customer with all relevant relationships: package, dealer, subDealer, nas, connectionType, areaRef, customerType.
  2. Load invoices (most recent 10, ordered by created_at DESC).
  3. Load payments (most recent 10, ordered by paid_at DESC).
  4. Check cached online_usernames set; set is_online = true if pppoe_username is in the set.
  5. 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:

  1. Load customer record.
  2. Load same form options as create() (packages, areas, dealers, NAS, connection types, customer types, payment methods).
  3. Pass customer as 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:

  1. Validate input.
  2. Detect if pppoe_username has changed: $oldUsername = $customer->pppoe_username.
  3. Fill and save the customer model.
  4. Retry loop (up to 3 attempts): Wrap the save() call in a try/catch for SQLSTATE 55P03 (PostgreSQL lock not available / lock timeout). On each failure, sleep 300ms before retrying. After 3 failures, rethrow the exception.
  5. Call RadiusService::syncCustomer($customer, $oldUsername):
  6. If username changed, RadiusService deletes the old radcheck/radusergroup rows and creates new ones with the new username.
  7. If username unchanged, updates password/group in place.
  8. Call AuditLogger::log('customer.updated', $customer, $oldValues, $newValues) with a diff of changed fields.
  9. 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 key
  • delete_reason (string, required, request body) — Reason for deletion (required for audit trail)

Business Logic:

  1. Authorize customers.delete permission check.
  2. Record deleted_by = authenticated user's ID, delete_reason = provided reason.
  3. Soft delete: set deleted_at = now (uses Laravel SoftDeletes trait — does NOT physically remove the row).
  4. Call RadiusService::deleteCustomerRadius($customer->pppoe_username):
  5. Deletes from radcheck, radusergroup where username = pppoe_username.
  6. Does NOT delete radacct (session history preserved).
  7. Call AuditLogger::log('customer.deleted', $customer, $customer->toArray(), null).
  8. 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: 1
  • method (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 invoice
  • notes (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:

  1. Authorize customers.recharge permission.
  2. Validate input. If invoice_id provided, additionally verify invoice.customer_id === $customer->id.
  3. If no invoice_id provided and the customer has no unpaid invoices, optionally auto-generate a new invoice via BillingService::generateInvoice($customer, $user->name) (controlled by a setting flag).
  4. Call BillingService::recordPayment($customer, $amount, $method, $user, $invoice, $notes, $collectedBy).
  5. This handles: payment record creation, ledger entries, invoice status update, renewal if paid-in-full.
  6. Call WhatsAppService::sendToCustomer($customer, 'payment_received', ['amount' => $amount, 'receipt_no' => $payment->receipt_no, ...]).
  7. Sends WhatsApp receipt to customer's whatsapp number (falls back to mobile).
  8. Failure is caught and logged; does not block the recharge response.
  9. Call AuditLogger::log('customer.recharged', $customer, null, ['amount' => $amount, 'method' => $method, 'receipt_no' => $payment->receipt_no]).
  10. 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:

  1. Authorize customers.disconnect permission.
  2. Load the customer; resolve their active NAS device.
  3. Call RadiusService::kickSession($customer):
  4. Queries radacct for all active sessions (acctstoptime IS NULL) for pppoe_username.
  5. For each active session, sends a RADIUS CoA Disconnect-Request packet via radclient.
  6. Port strategy: Tries port 3799 first (RFC 5176 standard). Falls back to port 1700 (RouterOS 7.x compatibility).
  7. Returns count of sessions that received an Ack response.
  8. 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:

  1. Call resolveContext() to determine which user's perspective to render (supports view_as admin override).
  2. Based on resolved context role:
  3. super_admin / admin / noc_admin / billing_admin: Full stats — total customers, active/expired/suspended counts, online count, revenue this month, outstanding balance, dealer count.
  4. dealer: Stats scoped to their own customers only — customer counts, collections this month.
  5. sub_dealer: Stats scoped to their parent dealer's customers where they are the sub_dealer.
  6. collection_officer: Outstanding balances and recent collections assigned to them.
  7. Online count: See Online Count Logic below.
  8. Load recent activity: last 10 audit log entries visible to this user.
  9. Load quick stats: new customers this month, renewals this month.
  10. 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 the view_as session.

Business Logic:

  1. Authorize: only super_admin may use this feature.
  2. If user_id provided: validate it exists, store session(['view_as_user_id' => $userId]).
  3. If user_id absent/null: session()->forget('view_as_user_id').
  4. 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:

  1. Check session for view_as_user_id. If present AND authenticated user is super_admin:
  2. Load the target user from DB.
  3. Set a flag $isViewingAs = true for the Inertia props (UI shows a banner "Viewing as: [name]").
  4. Return the target user as the resolved context.
  5. If no view_as session: return Auth::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:

  1. On every index() call, check Redis for online_usernames cache.
  2. Cache hit: Return cached set immediately (no DB query).
  3. Cache miss: Query radacct table: SELECT username FROM radacct WHERE acctstoptime IS NULL. Collect into a PHP array/Set. Store in Redis with 30-second TTL.
  4. Count of the set = "ONLINE NOW" number.
  5. For the customer list, each customer's pppoe_username is checked against this set to set is_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:

public function generateInvoice(Customer $customer, ?string $createdBy): Invoice

Purpose: Creates a new invoice for a customer's next billing period.

Business Logic:

  1. Amount resolution:

    amount = customer.monthly_price ?? package.price ?? 0
    
    Customer-level override (monthly_price) takes priority over the package's standard price.

  2. Period calculation:

  3. period_start = customer.expiry_date if set (invoice picks up from expiry), else today.
  4. period_end = period_start + 1 month using addMonthNoOverflow() — this prevents the January 31 → March 2 overflow bug (e.g., Jan 31 + 1 month = Feb 28, not Mar 2).

  5. Idempotency guard:

  6. Checks for an existing Invoice with the same customer_id, period_start, and period_end.
  7. If found, returns the existing invoice instead of creating a duplicate. Prevents double-invoicing on retry.

  8. Invoice creation:

  9. invoice_no = nextNumber('INV', Invoice::class) → e.g., INV202607-00042
  10. status = unpaid
  11. due_date = period_start + 7 days (configurable via Setting)
  12. dealer_id = customer.dealer_id
  13. created_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:

  1. Payment record creation:
  2. receipt_no = nextNumber('RCP', Payment::class) → e.g., RCP202607-00017
  3. received_by = $collectedBy if provided, else $user->name
  4. paid_at = now()
  5. dealer_id = customer.dealer_id

  6. Ledger double-entry (within same DB transaction):

  7. Creates a LedgerEntry for the customer account: type = credit, account_type = customer, account_id = customer.id.
  8. Creates a LedgerEntry for the dealer account: type = credit, account_type = dealer, account_id = customer.dealer_id.
  9. Both entries share the same invoice_id, effective_date = now(), and description.

  10. 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 == 0unpaid - 0 < paid_amount < amountpartial - paid_amount >= amountpaid - 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).

  1. Deadlock retry:
  2. The entire operation from step 1 through step 3 is wrapped in a DB::transaction().
  3. On SQLSTATE 40P01 (PostgreSQL deadlock detected), the transaction is retried up to 3 times.
  4. Backoff: 50ms → 100ms → 150ms (linear progression).
  5. 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:

public function renew(Customer $customer, ?Invoice $invoice): void

Purpose: Extends a customer's service expiry and sets them back to active status.

Business Logic:

  1. Calculate new expiry:
  2. If $invoice provided and invoice.period_end is set: expiry_date = invoice.period_end.
  3. If no invoice (e.g., unattributed payment renewal): expiry_date = customer.expiry_date + 1 month (using addMonthNoOverflow()), or today + 1 month if expiry was in the past.
  4. Set customer.status = 'active'.
  5. Set customer.expiry_date = <calculated date>.
  6. Save the customer.
  7. Call RadiusService::syncCustomer($customer) inside a try/catch:
  8. If RADIUS sync throws, catch the exception, log it, and continue. The renewal in the DB is committed regardless of RADIUS outcome.
  9. 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:

public function applyExcessToUnpaid(
    Customer $customer,
    float $remaining,
    ?int $excludeId
): void

Purpose: Applies a remaining payment balance to a customer's oldest unpaid/partial invoices in sequence.

Business Logic:

  1. Query all unpaid and partial invoices for this customer, ordered by period_start ASC (oldest first).
  2. If $excludeId is set, exclude that invoice (already being updated by the caller to prevent double-update).
  3. Acquire lockForUpdate() on all matching invoices — locks are taken in consistent PK order to prevent deadlocks (a deliberate design choice: always lock by ascending id).
  4. For each invoice (oldest to newest) while $remaining > 0:
  5. Calculate amount to apply: apply = min($remaining, invoice.amount - invoice.paid_amount).
  6. Add apply to invoice.paid_amount.
  7. Update invoice status (unpaidpartialpaid).
  8. If status becomes paid → call renew($customer, $invoice).
  9. Subtract apply from $remaining.
  10. If $remaining hits 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:

public function nextNumber(string $prefix, string $model): string

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