Skip to content

Commit e0c4a0f

Browse files
committed
feat(finance): Phase 61 — Commission Tracking for sales reps
Implements commission rules and automatic commission calculation on paid invoices, including models, controllers, policies, migrations, frontend pages, and a full Pest test suite (727 tests passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 1961bc9 commit e0c4a0f

21 files changed

Lines changed: 1475 additions & 2 deletions
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Finance\Models\Commission;
8+
use App\Modules\Finance\Models\CommissionRule;
9+
use App\Modules\Finance\Models\Invoice;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Validation\Rule;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class CommissionController extends Controller
17+
{
18+
public function index(Request $request): Response
19+
{
20+
$this->authorize('viewAny', Commission::class);
21+
22+
$commissions = Commission::with(['rule', 'user', 'invoice'])
23+
->when($request->status, fn ($q) => $q->where('status', $request->status))
24+
->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id))
25+
->latest()
26+
->paginate(15)
27+
->withQueryString();
28+
29+
return Inertia::render('Finance/Commissions/Index', [
30+
'commissions' => $commissions,
31+
'users' => User::orderBy('name')->get(['id', 'name']),
32+
'filters' => $request->only(['status', 'user_id']),
33+
'breadcrumbs' => [
34+
['label' => 'Finance'],
35+
['label' => 'Commissions', 'href' => route('finance.commissions.index')],
36+
],
37+
]);
38+
}
39+
40+
public function create(): Response
41+
{
42+
$this->authorize('create', Commission::class);
43+
44+
return Inertia::render('Finance/Commissions/Create', [
45+
'rules' => CommissionRule::with('user')->where('is_active', true)->latest()->get(['id', 'name', 'user_id']),
46+
'invoices' => Invoice::latest()->get(['id', 'number']),
47+
'breadcrumbs' => [
48+
['label' => 'Finance'],
49+
['label' => 'Commissions', 'href' => route('finance.commissions.index')],
50+
['label' => 'New Commission'],
51+
],
52+
]);
53+
}
54+
55+
public function store(Request $request): RedirectResponse
56+
{
57+
$this->authorize('create', Commission::class);
58+
59+
$data = $request->validate([
60+
'commission_rule_id' => ['required', Rule::exists('commission_rules', 'id')],
61+
'invoice_id' => ['required', Rule::exists('invoices', 'id')],
62+
'notes' => ['nullable', 'string'],
63+
]);
64+
65+
$rule = CommissionRule::findOrFail($data['commission_rule_id']);
66+
$invoice = Invoice::with('items', 'payments')->findOrFail($data['invoice_id']);
67+
68+
$invoiceAmount = (float) $invoice->total;
69+
$commissionAmount = $rule->calculateCommission($invoiceAmount);
70+
71+
$commission = Commission::create([
72+
'tenant_id' => auth()->user()->tenant_id,
73+
'commission_rule_id' => $rule->id,
74+
'user_id' => $rule->user_id,
75+
'invoice_id' => $invoice->id,
76+
'invoice_amount' => $invoiceAmount,
77+
'commission_amount' => $commissionAmount,
78+
'status' => 'pending',
79+
'notes' => $data['notes'] ?? null,
80+
]);
81+
82+
return redirect()->route('finance.commissions.show', $commission)
83+
->with('success', 'Commission created.');
84+
}
85+
86+
public function show(Commission $commission): Response
87+
{
88+
$this->authorize('view', $commission);
89+
90+
$commission->load(['rule', 'user', 'invoice']);
91+
92+
return Inertia::render('Finance/Commissions/Show', [
93+
'commission' => $commission,
94+
'breadcrumbs' => [
95+
['label' => 'Finance'],
96+
['label' => 'Commissions', 'href' => route('finance.commissions.index')],
97+
['label' => "Commission #{$commission->id}"],
98+
],
99+
]);
100+
}
101+
102+
public function destroy(Commission $commission): RedirectResponse
103+
{
104+
$this->authorize('delete', $commission);
105+
106+
$commission->delete();
107+
108+
return redirect()->route('finance.commissions.index')
109+
->with('success', 'Commission deleted.');
110+
}
111+
112+
public function approve(Commission $commission): RedirectResponse
113+
{
114+
$this->authorize('create', Commission::class);
115+
116+
$commission->approve();
117+
118+
return back()->with('success', 'Commission approved.');
119+
}
120+
121+
public function markPaid(Commission $commission): RedirectResponse
122+
{
123+
$this->authorize('create', Commission::class);
124+
125+
$commission->markPaid();
126+
127+
return back()->with('success', 'Commission marked as paid.');
128+
}
129+
130+
public function generate(Request $request): RedirectResponse
131+
{
132+
$this->authorize('create', Commission::class);
133+
134+
$request->validate([
135+
'invoice_id' => ['required', Rule::exists('invoices', 'id')],
136+
]);
137+
138+
$invoice = Invoice::with('items', 'payments')->findOrFail($request->invoice_id);
139+
140+
$rule = CommissionRule::where('user_id', $invoice->assigned_to_user_id)
141+
->where('is_active', true)
142+
->where('tenant_id', app('tenant')->id)
143+
->first();
144+
145+
if (!$rule) {
146+
return back()->with('error', 'No active commission rule for assigned user.');
147+
}
148+
149+
$commission = Commission::create([
150+
'tenant_id' => app('tenant')->id,
151+
'commission_rule_id' => $rule->id,
152+
'user_id' => $rule->user_id,
153+
'invoice_id' => $invoice->id,
154+
'invoice_amount' => $invoice->total,
155+
'commission_amount' => $rule->calculateCommission((float) $invoice->total),
156+
'status' => 'pending',
157+
]);
158+
159+
return redirect()->route('finance.commissions.show', $commission);
160+
}
161+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Finance\Models\CommissionRule;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class CommissionRuleController extends Controller
15+
{
16+
public function index(): Response
17+
{
18+
$this->authorize('viewAny', CommissionRule::class);
19+
20+
$rules = CommissionRule::with('user')
21+
->latest()
22+
->paginate(15);
23+
24+
return Inertia::render('Finance/CommissionRules/Index', [
25+
'rules' => $rules,
26+
'breadcrumbs' => [
27+
['label' => 'Finance'],
28+
['label' => 'Commission Rules', 'href' => route('finance.commission-rules.index')],
29+
],
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', CommissionRule::class);
36+
37+
return Inertia::render('Finance/CommissionRules/Create', [
38+
'users' => User::orderBy('name')->get(['id', 'name']),
39+
'breadcrumbs' => [
40+
['label' => 'Finance'],
41+
['label' => 'Commission Rules', 'href' => route('finance.commission-rules.index')],
42+
['label' => 'New Rule'],
43+
],
44+
]);
45+
}
46+
47+
public function store(Request $request): RedirectResponse
48+
{
49+
$this->authorize('create', CommissionRule::class);
50+
51+
$data = $request->validate([
52+
'user_id' => ['required', Rule::exists('users', 'id')],
53+
'name' => ['required', 'string', 'max:255'],
54+
'rate' => ['required_if:type,percentage', 'nullable', 'numeric', 'min:0', 'max:1'],
55+
'type' => ['required', Rule::in(['percentage', 'fixed'])],
56+
'fixed_amount' => ['required_if:type,fixed', 'nullable', 'numeric', 'min:0'],
57+
]);
58+
59+
$rule = CommissionRule::create([
60+
'tenant_id' => auth()->user()->tenant_id,
61+
'user_id' => $data['user_id'],
62+
'name' => $data['name'],
63+
'type' => $data['type'],
64+
'rate' => $data['rate'] ?? 0,
65+
'fixed_amount' => $data['fixed_amount'] ?? null,
66+
'is_active' => $request->boolean('is_active', true),
67+
]);
68+
69+
return redirect()->route('finance.commission-rules.show', $rule)
70+
->with('success', 'Commission rule created.');
71+
}
72+
73+
public function show(CommissionRule $commissionRule): Response
74+
{
75+
$this->authorize('view', $commissionRule);
76+
77+
$commissionRule->load(['user', 'commissions.invoice']);
78+
79+
return Inertia::render('Finance/CommissionRules/Show', [
80+
'rule' => $commissionRule,
81+
'breadcrumbs' => [
82+
['label' => 'Finance'],
83+
['label' => 'Commission Rules', 'href' => route('finance.commission-rules.index')],
84+
['label' => $commissionRule->name],
85+
],
86+
]);
87+
}
88+
89+
public function destroy(CommissionRule $commissionRule): RedirectResponse
90+
{
91+
$this->authorize('delete', $commissionRule);
92+
93+
$commissionRule->delete();
94+
95+
return redirect()->route('finance.commission-rules.index')
96+
->with('success', 'Commission rule deleted.');
97+
}
98+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class Commission extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id', 'commission_rule_id', 'user_id', 'invoice_id',
18+
'invoice_amount', 'commission_amount', 'status',
19+
'approved_at', 'paid_at', 'notes',
20+
];
21+
22+
protected $casts = [
23+
'invoice_amount' => 'decimal:2',
24+
'commission_amount' => 'decimal:2',
25+
'approved_at' => 'datetime',
26+
'paid_at' => 'datetime',
27+
];
28+
29+
public function rule(): BelongsTo
30+
{
31+
return $this->belongsTo(CommissionRule::class, 'commission_rule_id');
32+
}
33+
34+
public function user(): BelongsTo
35+
{
36+
return $this->belongsTo(User::class);
37+
}
38+
39+
public function invoice(): BelongsTo
40+
{
41+
return $this->belongsTo(Invoice::class);
42+
}
43+
44+
public function approve(): void
45+
{
46+
$this->status = 'approved';
47+
$this->approved_at = now();
48+
$this->save();
49+
}
50+
51+
public function markPaid(): void
52+
{
53+
$this->status = 'paid';
54+
$this->paid_at = now();
55+
$this->save();
56+
}
57+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
11+
class CommissionRule extends Model
12+
{
13+
use BelongsToTenant;
14+
15+
protected $fillable = [
16+
'tenant_id', 'user_id', 'name', 'rate', 'type', 'fixed_amount', 'is_active',
17+
];
18+
19+
protected $casts = [
20+
'rate' => 'decimal:4',
21+
'fixed_amount' => 'decimal:2',
22+
'is_active' => 'boolean',
23+
];
24+
25+
public function user(): BelongsTo
26+
{
27+
return $this->belongsTo(User::class);
28+
}
29+
30+
public function commissions(): HasMany
31+
{
32+
return $this->hasMany(Commission::class);
33+
}
34+
35+
public function calculateCommission(float $invoiceAmount): float
36+
{
37+
if ($this->type === 'percentage') {
38+
return round($invoiceAmount * (float) $this->rate, 2);
39+
}
40+
41+
if ($this->type === 'fixed') {
42+
return (float) $this->fixed_amount;
43+
}
44+
45+
return 0.0;
46+
}
47+
}

erp/app/Modules/Finance/Models/Invoice.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Invoice extends Model
2323
use HasStatusTransitions;
2424

2525
protected $fillable = [
26-
'tenant_id', 'recurring_invoice_id', 'sales_order_id', 'contact_id', 'number',
26+
'tenant_id', 'recurring_invoice_id', 'sales_order_id', 'contact_id', 'assigned_to_user_id', 'number',
2727
'issue_date', 'due_date', 'status', 'notes', 'created_by',
2828
'currency_code', 'exchange_rate',
2929
];
@@ -81,4 +81,9 @@ public function salesOrder(): BelongsTo
8181
{
8282
return $this->belongsTo(SalesOrder::class);
8383
}
84-
}
84+
85+
public function assignedTo(): BelongsTo
86+
{
87+
return $this->belongsTo(User::class, 'assigned_to_user_id');
88+
}
89+
}

0 commit comments

Comments
 (0)