13 — Dealer System¶
Overview¶
PyroRadius supports a three-tier hierarchy: administrators manage dealers, dealers manage sub-dealers and customers, sub-dealers assist dealers in managing customers. All roles share the users table with different role values and different permission scopes enforced throughout every controller.
Hierarchy Structure¶
super_admin / admin
│
├─── Dealer A (role=dealer)
│ │
│ ├─── Sub-Dealer A1 (role=sub_dealer, parent_id=Dealer A)
│ │ └─── Customers (dealer_id=A, sub_dealer_id=A1)
│ │
│ ├─── Sub-Dealer A2 (role=sub_dealer, parent_id=Dealer A)
│ │ └─── Customers (dealer_id=A, sub_dealer_id=A2)
│ │
│ └─── Customers directly under Dealer A (sub_dealer_id=null)
│
└─── Dealer B (role=dealer)
└─── Customers (dealer_id=B)
Role Values in users.role¶
| Role | Description |
|---|---|
super_admin |
Full access to everything; can manage admins and dealers |
admin |
Same as super_admin operationally; created by super_admin |
dealer |
Manages own customers; limited to their own scope |
sub_dealer |
Acts under a dealer; scope further restricted to their own customers |
User Model — Relevant Fields¶
Table: users
| Column | Type | Description |
|---|---|---|
id |
bigint PK | |
name |
varchar | Display name |
email |
varchar | Login email (unique) |
password |
varchar | Bcrypt hashed |
role |
enum | super_admin / admin / dealer / sub_dealer |
parent_id |
bigint FK (self) | For sub_dealers: points to their parent dealer's user ID |
phone |
varchar | Contact number |
address |
text | Business address |
status |
enum | active / inactive |
balance |
decimal | Dealer's credit balance (for prepaid dealer billing model) |
deleted_at |
timestamp | Soft delete |
How Dealers Own Customers¶
Every customers row carries two foreign keys:
dealer_id— the dealer (User) responsible for this customersub_dealer_id— the sub-dealer who added/manages this customer (nullable)
Scope enforcement is applied in every query:
// Applied automatically via Customer model scope or controller filter:
if (auth()->user()->isDealer()) {
$query->where('dealer_id', auth()->id());
} elseif (auth()->user()->isSubDealer()) {
$query->where('sub_dealer_id', auth()->id());
}
Admins and super_admins see all customers across all dealers.
Sub-Dealer Relationship¶
Sub-dealers are User records with role = 'sub_dealer' and parent_id set to their dealer's user ID.
Key rules:
- A sub-dealer can only be created by their parent dealer (or admin).
- Customers owned by a sub-dealer still have dealer_id set to the parent dealer.
- A sub-dealer cannot see other sub-dealers' customers or the dealer's direct customers.
- When a sub-dealer is deactivated, their customers remain under the dealer.
Sub-Dealer Scope Query Example¶
Customer::where('dealer_id', $subDealer->parent_id)
->where('sub_dealer_id', $subDealer->id)
->get();
Creating Dealers¶
Route¶
Handled byUserController::store()
Required Fields¶
| Field | Notes |
|---|---|
| Name | Full business name |
| Unique login email | |
| Password | Min 8 chars |
| Phone | Contact number |
| Address | Business address |
| Status | active (default) |
| Role | Set to dealer |
After creation, the dealer can log in at the main login page. They immediately see a scoped dashboard showing only their customers.
Creating Sub-Dealers¶
Sub-dealers are created via the same UserController::store() but with:
- role = sub_dealer
- parent_id = <dealer's user ID>
Admin selects the parent dealer from a dropdown. When a dealer creates a sub-dealer, their own ID is automatically used as parent_id.
Dealer Package Assignments¶
The dealer_package Pivot Table¶
| Column | Type | Description |
|---|---|---|
id |
bigint PK | |
dealer_id |
bigint FK | References users.id |
package_id |
bigint FK | References packages.id |
cost_price |
decimal | What this dealer pays per month (dealer's cost) |
sale_price |
decimal | What this dealer charges customers (suggested retail) |
is_commercial |
boolean | True = commercial/corporate terms apply |
notes |
text | Free-form notes on the commercial arrangement |
created_at |
timestamp | |
updated_at |
timestamp |
Purpose¶
Not all packages are available to all dealers. This table defines: 1. Which packages a dealer can sell. 2. The dealer's cost price (what they owe PyroNet). 3. The suggested sale price (what customers are charged). 4. Whether commercial terms apply.
When a dealer creates a customer, the package dropdown is filtered to only show packages in this pivot table for that dealer.
DealerPackageController¶
| Method | Route | Description |
|---|---|---|
index() |
GET /dealer-packages | List all dealer-package assignments (admin) |
store() |
POST /dealer-packages | Assign a package to a dealer with pricing |
update() |
PUT /dealer-packages/{id} | Update cost/sale price or commercial flag |
destroy() |
DELETE /dealer-packages/{id} | Remove package from dealer (existing customers unaffected) |
Removing a dealer-package assignment does not affect existing customers on that package — it only prevents new customers from being enrolled in it.
Assigning New Packages to a Dealer¶
Steps (performed by admin):
1. Go to Dealers → select dealer → Packages tab.
2. Click "Assign Package."
3. Select the package from the global packages list.
4. Set cost_price (what dealer pays), sale_price (retail), check is_commercial if applicable.
5. Save — creates a dealer_package row.
The dealer can now see and select this package when creating customers.
View As Feature (Admin Impersonates Dealer)¶
Admins can switch their dashboard view to any dealer to see exactly what that dealer sees.
How It Works¶
- Admin visits Dealers list, clicks "View As" next to a dealer.
- A session variable
view_as_dealer_idis set to the dealer's user ID. - All subsequent queries use this ID as the scoping dealer.
- A banner appears at the top of every page: "Viewing as [Dealer Name] — Exit View."
- Clicking "Exit View" clears the session variable and returns to admin scope.
What Changes Under View As¶
- Customer list shows only that dealer's customers.
- Dashboard stats (active, expired, revenue) reflect only that dealer.
- Package dropdown shows only that dealer's assigned packages.
- NAS dropdown shows only that dealer's assigned NAS.
Admin privileges (edit/delete dealer, manage users) remain in admin scope and are hidden when in View As mode.
Dealer Scoping in Controllers¶
Every controller that works with customer or financial data applies dealer scoping. The pattern used throughout:
protected function scopeQuery(Builder $query): Builder
{
$user = auth()->user();
if ($user->isDealer()) {
return $query->where('dealer_id', $user->id);
}
if ($user->isSubDealer()) {
return $query->where('sub_dealer_id', $user->id);
}
// If admin is in View As mode
if (session()->has('view_as_dealer_id')) {
return $query->where('dealer_id', session('view_as_dealer_id'));
}
return $query; // admin/super_admin sees everything
}
This pattern is applied in: CustomerController, TransactionController, DashboardController, ReportController, TicketController.
Permission Matrix¶
| Action | super_admin | admin | dealer | sub_dealer |
|---|---|---|---|---|
| View all customers | Yes | Yes | Own only | Own only |
| Create customer | Yes | Yes | Yes | Yes (under their dealer) |
| Edit customer | Yes | Yes | Own only | Own only |
| Delete customer | Yes | Yes | No | No |
| View all dealers | Yes | Yes | No | No |
| Create dealer | Yes | Yes | No | No |
| Create sub-dealer | Yes | Yes | Yes (own) | No |
| Assign packages to dealer | Yes | Yes | No | No |
| View As dealer | Yes | Yes | No | No |
| Manage NAS | Yes | Yes | No | No |
| View financial reports (all) | Yes | Yes | No | No |
| View own revenue report | Yes | Yes | Yes | No |
| Manage packages (global) | Yes | Yes | No | No |
| Kick PPPoE session | Yes | Yes | Yes | No |
| Recharge customer | Yes | Yes | Yes | Yes |
| Access TR-069 management | Yes | Yes | No | No |
| Manage OLT | Yes | Yes | No | No |
Dealer Dashboard Stats¶
When a dealer logs in, their dashboard shows:
- Total customers (their scope)
- Active customers count
- Expired customers count
- Suspended customers count
- Revenue this month (sum of transactions where dealer_id = their ID)
- Customers expiring in next 7 days (needs action list)
- Recent payments
These numbers are fetched from CustomerController::statusCounts() and DashboardController::index(), both of which apply dealer scoping.
Dealer Balance (Prepaid Model)¶
If PyroNet operates dealers on a prepaid basis, the users.balance field tracks their credit.
- When a dealer recharges a customer, their balance is debited by
cost_pricefor that package. - If balance is insufficient, the recharge may be blocked (configurable).
- Admin can top up a dealer's balance via the Dealer edit form.
- Balance movements are logged in a
dealer_transactionstable (dealer_id, amount, type, note, created_by).
UserController — Dealer-Related Methods¶
| Method | Route | Description |
|---|---|---|
index() |
GET /users | List all users (admin filters by role) |
create() |
GET /users/create | Show user creation form |
store() |
POST /users | Create dealer, sub-dealer, or admin |
show() |
GET /users/{id} | User detail / dealer profile |
edit() |
GET /users/{id}/edit | Edit form |
update() |
PUT /users/{id} | Update user details |
destroy() |
DELETE /users/{id} | Soft delete user |
viewAs() |
POST /users/{id}/view-as | Set View As session (admin only) |
exitViewAs() |
POST /users/exit-view-as | Clear View As session |
topupBalance() |
POST /users/{id}/topup | Add dealer balance (admin only) |
Related Models¶
User→hasManyCustomer (as dealer)User→hasManyCustomer (as sub_dealer)User→hasManyUser (sub_dealers, via parent_id)User→belongsToUser (parent dealer, via parent_id)User→belongsToManyPackage (via dealer_package)User→belongsToManyNAS (via dealer_nas)