Skip to content

Commit a20b873

Browse files
committed
feat(finance): Phase 146 — Finance Expense Budgets
Add per-department expense budget allocation with spend tracking, utilization/over-budget detection, freeze/close lifecycle, and policy-gated CRUD. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 375323f commit a20b873

11 files changed

Lines changed: 722 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\ExpenseBudget;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class ExpenseBudgetController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', ExpenseBudget::class);
17+
18+
$query = ExpenseBudget::query();
19+
20+
if ($request->filled('department')) {
21+
$query->where('department', $request->input('department'));
22+
}
23+
24+
if ($request->filled('status')) {
25+
$query->where('status', $request->input('status'));
26+
}
27+
28+
$expenseBudgets = $query->orderByDesc('id')->paginate(20);
29+
30+
return Inertia::render('Finance/ExpenseBudgets/Index', [
31+
'expenseBudgets' => $expenseBudgets,
32+
'filters' => $request->only(['department', 'status']),
33+
]);
34+
}
35+
36+
public function create(): Response
37+
{
38+
$this->authorize('create', ExpenseBudget::class);
39+
40+
return Inertia::render('Finance/ExpenseBudgets/Create');
41+
}
42+
43+
public function store(Request $request): RedirectResponse
44+
{
45+
$this->authorize('create', ExpenseBudget::class);
46+
47+
$validated = $request->validate([
48+
'department' => ['required', 'string', 'max:255'],
49+
'period' => ['required', 'string', 'max:255'],
50+
'allocated_amount' => ['nullable', 'numeric', 'min:0'],
51+
'category' => ['nullable', 'string', 'max:255'],
52+
'currency' => ['nullable', 'string', 'max:10'],
53+
'status' => ['nullable', 'string'],
54+
'notes' => ['nullable', 'string'],
55+
'owner_id' => ['nullable', 'integer'],
56+
'budget_code' => ['nullable', 'string', 'max:255'],
57+
]);
58+
59+
ExpenseBudget::create([
60+
'tenant_id' => app('tenant')->id,
61+
'created_by' => auth()->id(),
62+
'department' => $validated['department'],
63+
'period' => $validated['period'],
64+
'allocated_amount' => $validated['allocated_amount'] ?? 0,
65+
'category' => $validated['category'] ?? null,
66+
'currency' => $validated['currency'] ?? 'USD',
67+
'status' => $validated['status'] ?? 'active',
68+
'notes' => $validated['notes'] ?? null,
69+
'owner_id' => $validated['owner_id'] ?? null,
70+
'budget_code' => $validated['budget_code'] ?? null,
71+
]);
72+
73+
return redirect()->route('finance.expense-budgets.index');
74+
}
75+
76+
public function show(ExpenseBudget $expenseBudget): Response
77+
{
78+
$this->authorize('view', $expenseBudget);
79+
80+
return Inertia::render('Finance/ExpenseBudgets/Show', [
81+
'expenseBudget' => array_merge($expenseBudget->toArray(), [
82+
'remaining_amount' => $expenseBudget->remaining_amount,
83+
'utilization_percent' => $expenseBudget->utilization_percent,
84+
'is_over_budget' => $expenseBudget->is_over_budget,
85+
'is_active' => $expenseBudget->is_active,
86+
]),
87+
]);
88+
}
89+
90+
public function edit(ExpenseBudget $expenseBudget): Response
91+
{
92+
$this->authorize('update', $expenseBudget);
93+
94+
return Inertia::render('Finance/ExpenseBudgets/Edit', [
95+
'expenseBudget' => $expenseBudget,
96+
]);
97+
}
98+
99+
public function update(Request $request, ExpenseBudget $expenseBudget): RedirectResponse
100+
{
101+
$this->authorize('update', $expenseBudget);
102+
103+
$validated = $request->validate([
104+
'department' => ['required', 'string', 'max:255'],
105+
'period' => ['required', 'string', 'max:255'],
106+
'allocated_amount' => ['nullable', 'numeric', 'min:0'],
107+
'category' => ['nullable', 'string', 'max:255'],
108+
'currency' => ['nullable', 'string', 'max:10'],
109+
'status' => ['nullable', 'string'],
110+
'notes' => ['nullable', 'string'],
111+
'owner_id' => ['nullable', 'integer'],
112+
'budget_code' => ['nullable', 'string', 'max:255'],
113+
]);
114+
115+
$expenseBudget->update($validated);
116+
117+
return redirect()->route('finance.expense-budgets.index');
118+
}
119+
120+
public function destroy(ExpenseBudget $expenseBudget): RedirectResponse
121+
{
122+
$this->authorize('delete', $expenseBudget);
123+
124+
$expenseBudget->delete();
125+
126+
return redirect()->route('finance.expense-budgets.index');
127+
}
128+
129+
public function freeze(ExpenseBudget $expenseBudget): RedirectResponse
130+
{
131+
$this->authorize('freeze', $expenseBudget);
132+
133+
$expenseBudget->freeze();
134+
135+
return redirect()->back();
136+
}
137+
138+
public function close(ExpenseBudget $expenseBudget): RedirectResponse
139+
{
140+
$this->authorize('close', $expenseBudget);
141+
142+
$expenseBudget->close();
143+
144+
return redirect()->back();
145+
}
146+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
9+
class ExpenseBudget extends Model
10+
{
11+
use BelongsToTenant;
12+
use SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'budget_code',
17+
'department',
18+
'category',
19+
'period',
20+
'allocated_amount',
21+
'spent_amount',
22+
'currency',
23+
'status',
24+
'notes',
25+
'owner_id',
26+
'created_by',
27+
];
28+
29+
protected $casts = [
30+
'allocated_amount' => 'decimal:2',
31+
'spent_amount' => 'decimal:2',
32+
];
33+
34+
protected $attributes = [
35+
'status' => 'active',
36+
'currency' => 'USD',
37+
'allocated_amount' => 0,
38+
'spent_amount' => 0,
39+
];
40+
41+
// ─── Actions ──────────────────────────────────────────────────────────────
42+
43+
public function freeze(): void
44+
{
45+
$this->status = 'frozen';
46+
$this->save();
47+
}
48+
49+
public function close(): void
50+
{
51+
$this->status = 'closed';
52+
$this->save();
53+
}
54+
55+
public function recordSpend(float $amount): void
56+
{
57+
$this->spent_amount = (float) $this->spent_amount + $amount;
58+
$this->save();
59+
}
60+
61+
public function generateBudgetCode(): string
62+
{
63+
return 'EB-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
64+
}
65+
66+
// ─── Accessors ────────────────────────────────────────────────────────────
67+
68+
public function getRemainingAmountAttribute(): float
69+
{
70+
return max(0, (float) $this->allocated_amount - (float) $this->spent_amount);
71+
}
72+
73+
public function getUtilizationPercentAttribute(): float
74+
{
75+
$allocated = (float) $this->allocated_amount;
76+
77+
if ($allocated <= 0) {
78+
return 0.0;
79+
}
80+
81+
return round(((float) $this->spent_amount / $allocated) * 100, 2);
82+
}
83+
84+
public function getIsOverBudgetAttribute(): bool
85+
{
86+
return (float) $this->spent_amount > (float) $this->allocated_amount;
87+
}
88+
89+
public function getIsActiveAttribute(): bool
90+
{
91+
return $this->status === 'active';
92+
}
93+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\ExpenseBudget;
7+
8+
class ExpenseBudgetPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('finance.view');
13+
}
14+
15+
public function view(User $user, ExpenseBudget $expenseBudget): bool
16+
{
17+
return $user->hasPermissionTo('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->hasPermissionTo('finance.create');
23+
}
24+
25+
public function update(User $user, ExpenseBudget $expenseBudget): bool
26+
{
27+
return $user->hasPermissionTo('finance.create');
28+
}
29+
30+
public function freeze(User $user, ExpenseBudget $expenseBudget): bool
31+
{
32+
return $user->hasPermissionTo('finance.create');
33+
}
34+
35+
public function close(User $user, ExpenseBudget $expenseBudget): bool
36+
{
37+
return $user->hasPermissionTo('finance.delete');
38+
}
39+
40+
public function delete(User $user, ExpenseBudget $expenseBudget): bool
41+
{
42+
return $user->hasPermissionTo('finance.delete');
43+
}
44+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@
117117
use App\Modules\Finance\Policies\PaymentSchedulePolicy;
118118
use App\Modules\Finance\Models\CustomerCredit;
119119
use App\Modules\Finance\Policies\CustomerCreditPolicy;
120+
use App\Modules\Finance\Models\ExpenseBudget;
121+
use App\Modules\Finance\Policies\ExpenseBudgetPolicy;
120122
use Illuminate\Support\Facades\Gate;
121123
use Illuminate\Support\ServiceProvider;
122124

@@ -204,6 +206,7 @@ public function boot(): void
204206
Gate::policy(PaymentSchedule::class, PaymentSchedulePolicy::class);
205207
Gate::policy(PaymentScheduleItem::class, PaymentSchedulePolicy::class);
206208
Gate::policy(CustomerCredit::class, CustomerCreditPolicy::class);
209+
Gate::policy(ExpenseBudget::class, ExpenseBudgetPolicy::class);
207210
if ($this->app->runningInConsole()) {
208211
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
209212
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,11 @@
437437
Route::post('customer-credits/{customer_credit}/cancel', [CustomerCreditController::class, 'cancel'])->name('customer-credits.cancel');
438438
Route::resource('customer-credits', CustomerCreditController::class);
439439
});
440+
441+
// Expense Budgets
442+
use App\Modules\Finance\Http\Controllers\ExpenseBudgetController;
443+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
444+
Route::post('expense-budgets/{expense_budget}/freeze', [ExpenseBudgetController::class, 'freeze'])->name('expense-budgets.freeze');
445+
Route::post('expense-budgets/{expense_budget}/close', [ExpenseBudgetController::class, 'close'])->name('expense-budgets.close');
446+
Route::resource('expense-budgets', ExpenseBudgetController::class);
447+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::dropIfExists('expense_budgets');
12+
Schema::create('expense_budgets', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->string('budget_code')->nullable();
16+
$table->string('department');
17+
$table->string('category')->nullable(); // travel/office/marketing/IT/etc.
18+
$table->string('period'); // e.g. "2026-Q1", "2026-01", "2026"
19+
$table->decimal('allocated_amount', 15, 2)->default(0);
20+
$table->decimal('spent_amount', 15, 2)->default(0);
21+
$table->string('currency')->default('USD');
22+
$table->string('status')->default('active'); // active/frozen/closed
23+
$table->text('notes')->nullable();
24+
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
25+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
26+
$table->timestamps();
27+
$table->softDeletes();
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('expense_budgets');
34+
}
35+
};

0 commit comments

Comments
 (0)