Skip to content

Commit dfd2ae5

Browse files
committed
feat(finance): Phase 53 — Budget Management
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 494e728 commit dfd2ae5

15 files changed

Lines changed: 549 additions & 187 deletions

File tree

erp/app/Modules/Finance/Http/Controllers/BudgetController.php

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Http\RedirectResponse;
99
use Illuminate\Http\Request;
1010
use Illuminate\Support\Facades\DB;
11+
use Illuminate\Validation\Rule;
1112
use Inertia\Inertia;
1213
use Inertia\Response;
1314

@@ -18,16 +19,18 @@ public function index(Request $request): Response
1819
$this->authorize('viewAny', Budget::class);
1920

2021
$budgets = Budget::withCount('lines')
21-
->orderByDesc('year')
22+
->orderByDesc('fiscal_year')
2223
->orderByDesc('id')
23-
->get()
24-
->map(fn ($b) => [
25-
'id' => $b->id,
26-
'name' => $b->name,
27-
'year' => $b->year,
28-
'period_type' => $b->period_type,
29-
'status' => $b->status,
30-
'lines_count' => $b->lines_count,
24+
->paginate(15)
25+
->through(fn ($b) => [
26+
'id' => $b->id,
27+
'name' => $b->name,
28+
'fiscal_year' => $b->fiscal_year ?? $b->year,
29+
'year' => $b->year,
30+
'period_type' => $b->period_type,
31+
'status' => $b->status,
32+
'lines_count' => $b->lines_count,
33+
'total_budgeted' => null,
3134
]);
3235

3336
return Inertia::render('Finance/Budgets/Index', [
@@ -62,22 +65,37 @@ public function store(Request $request): RedirectResponse
6265
{
6366
$this->authorize('create', Budget::class);
6467

68+
$tenantId = app('tenant')->id;
69+
$fiscalYear = $request->input('fiscal_year') ?? $request->input('year');
70+
6571
$validated = $request->validate([
66-
'name' => 'required|string|max:191',
67-
'year' => 'required|integer|min:2000|max:2100',
68-
'period_type' => 'required|in:annual,monthly,quarterly',
69-
'notes' => 'nullable|string',
70-
'lines' => 'required|array|min:1',
71-
'lines.*.account_id' => 'required|exists:accounts,id',
72-
'lines.*.period' => 'required|integer|min:0|max:12',
73-
'lines.*.amount' => 'required|numeric|min:0',
72+
'name' => [
73+
'required',
74+
'string',
75+
'max:255',
76+
Rule::unique('budgets')->where(fn ($q) => $q
77+
->where('fiscal_year', $fiscalYear)
78+
->where('tenant_id', $tenantId)
79+
->whereNull('deleted_at')
80+
),
81+
],
82+
'fiscal_year' => ['required', 'integer', 'min:2000', 'max:2100'],
83+
'period_type' => ['required', Rule::in(['annual', 'quarterly', 'monthly'])],
84+
'notes' => ['nullable', 'string'],
85+
'lines' => ['required', 'array', 'min:1'],
86+
'lines.*.account_id' => ['required', Rule::exists('accounts', 'id')],
87+
'lines.*.period' => ['required', 'integer', 'min:0', 'max:12'],
88+
'lines.*.amount' => ['required', 'numeric', 'min:0'],
7489
]);
7590

76-
DB::transaction(function () use ($validated, $request) {
91+
$fy = $validated['fiscal_year'];
92+
93+
$budget = DB::transaction(function () use ($validated, $request, $tenantId, $fy) {
7794
$budget = Budget::create([
78-
'tenant_id' => $request->user()->tenant_id,
95+
'tenant_id' => $tenantId,
7996
'name' => $validated['name'],
80-
'year' => $validated['year'],
97+
'fiscal_year' => $fy,
98+
'year' => $fy,
8199
'period_type' => $validated['period_type'],
82100
'notes' => $validated['notes'] ?? null,
83101
'status' => 'draft',
@@ -86,15 +104,18 @@ public function store(Request $request): RedirectResponse
86104

87105
foreach ($validated['lines'] as $line) {
88106
$budget->lines()->create([
107+
'tenant_id' => $tenantId,
89108
'account_id' => $line['account_id'],
90109
'period' => $line['period'],
91110
'amount' => $line['amount'],
92111
'notes' => $line['notes'] ?? null,
93112
]);
94113
}
114+
115+
return $budget;
95116
});
96117

97-
return redirect()->route('finance.budgets.index')
118+
return redirect()->route('finance.budgets.show', $budget)
98119
->with('success', 'Budget created successfully.');
99120
}
100121

@@ -104,9 +125,9 @@ public function show(Budget $budget): Response
104125
$budget->load(['lines.account']);
105126

106127
$tenantId = request()->user()->tenant_id;
107-
$year = $budget->year;
128+
$year = $budget->fiscal_year ?? $budget->year;
108129

109-
// Compute actuals from posted journal entries for this year
130+
// Compute actuals from posted journal entries for this fiscal year
110131
$actuals = DB::table('journal_lines')
111132
->join('journal_entries', 'journal_lines.journal_entry_id', '=', 'journal_entries.id')
112133
->join('accounts', 'journal_lines.account_id', '=', 'accounts.id')
@@ -129,10 +150,10 @@ public function show(Budget $budget): Response
129150
$actualAmount = 0;
130151
if ($actual) {
131152
$actualAmount = $actual->type === 'income'
132-
? (float)$actual->total_credit - (float)$actual->total_debit
133-
: (float)$actual->total_debit - (float)$actual->total_credit;
153+
? (float) $actual->total_credit - (float) $actual->total_debit
154+
: (float) $actual->total_debit - (float) $actual->total_credit;
134155
}
135-
$variance = $actualAmount - (float)$line->amount;
156+
$variance = $actualAmount - (float) $line->amount;
136157
$variancePct = $line->amount != 0 ? round($variance / $line->amount * 100, 1) : null;
137158

138159
return [
@@ -142,7 +163,7 @@ public function show(Budget $budget): Response
142163
'account_name' => $line->account->name,
143164
'account_type' => $line->account->type,
144165
'period' => $line->period,
145-
'budget' => round((float)$line->amount, 2),
166+
'budget' => round((float) $line->amount, 2),
146167
'actual' => round($actualAmount, 2),
147168
'variance' => round($variance, 2),
148169
'variance_pct' => $variancePct,
@@ -153,6 +174,7 @@ public function show(Budget $budget): Response
153174
'budget' => [
154175
'id' => $budget->id,
155176
'name' => $budget->name,
177+
'fiscal_year' => $budget->fiscal_year ?? $budget->year,
156178
'year' => $budget->year,
157179
'period_type' => $budget->period_type,
158180
'status' => $budget->status,
@@ -179,4 +201,22 @@ public function destroy(Budget $budget): RedirectResponse
179201
return redirect()->route('finance.budgets.index')
180202
->with('success', 'Budget deleted.');
181203
}
204+
205+
public function activate(Budget $budget): RedirectResponse
206+
{
207+
$this->authorize('update', $budget);
208+
209+
$budget->activate();
210+
211+
return redirect()->back()->with('success', 'Budget activated.');
212+
}
213+
214+
public function close(Budget $budget): RedirectResponse
215+
{
216+
$this->authorize('update', $budget);
217+
218+
$budget->close();
219+
220+
return redirect()->back()->with('success', 'Budget closed.');
221+
}
182222
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\BudgetLine;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
10+
class BudgetLineController extends Controller
11+
{
12+
public function update(Request $request, BudgetLine $budgetLine): RedirectResponse
13+
{
14+
$this->authorize('update', $budgetLine->budget);
15+
16+
$validated = $request->validate([
17+
'amount' => ['required', 'numeric', 'min:0'],
18+
'notes' => ['nullable', 'string'],
19+
]);
20+
21+
$budgetLine->update($validated);
22+
23+
return redirect()->back()->with('success', 'Budget line updated.');
24+
}
25+
26+
public function destroy(BudgetLine $budgetLine): RedirectResponse
27+
{
28+
$this->authorize('delete', $budgetLine->budget);
29+
30+
$budgetLine->delete();
31+
32+
return redirect()->back()->with('success', 'Budget line deleted.');
33+
}
34+
}

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ class Budget extends Model
1515
use SoftDeletes;
1616

1717
protected $fillable = [
18-
'tenant_id', 'name', 'year', 'period_type', 'notes', 'status', 'created_by',
18+
'tenant_id', 'name', 'fiscal_year', 'year', 'period_type', 'notes', 'status', 'created_by',
1919
];
2020

2121
protected $casts = [
22-
'year' => 'integer',
22+
'fiscal_year' => 'integer',
23+
'year' => 'integer',
2324
];
2425

2526
public function lines(): HasMany
@@ -31,4 +32,21 @@ public function creator(): BelongsTo
3132
{
3233
return $this->belongsTo(User::class, 'created_by');
3334
}
35+
36+
public function activate(): void
37+
{
38+
$this->status = 'active';
39+
$this->save();
40+
}
41+
42+
public function close(): void
43+
{
44+
$this->status = 'closed';
45+
$this->save();
46+
}
47+
48+
public function getTotalBudgetedAttribute(): float
49+
{
50+
return (float) $this->lines->sum('amount');
51+
}
3452
}

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@
22

33
namespace App\Modules\Finance\Models;
44

5+
use App\Modules\Core\Traits\BelongsToTenant;
56
use Illuminate\Database\Eloquent\Model;
67
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
79

810
class BudgetLine extends Model
911
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
1015
protected $fillable = [
11-
'budget_id', 'account_id', 'period', 'amount', 'notes',
16+
'tenant_id', 'budget_id', 'account_id', 'period', 'amount', 'notes',
1217
];
1318

1419
protected $casts = [
15-
'amount' => 'float',
20+
'amount' => 'decimal:2',
1621
'period' => 'integer',
1722
];
1823

@@ -25,4 +30,14 @@ public function account(): BelongsTo
2530
{
2631
return $this->belongsTo(Account::class);
2732
}
33+
34+
public function getActualAmountAttribute(): float
35+
{
36+
return 0.0;
37+
}
38+
39+
public function getVarianceAttribute(): float
40+
{
41+
return $this->actual_amount - (float) $this->amount;
42+
}
2843
}

erp/app/Modules/Finance/Policies/BudgetPolicy.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,26 @@ class BudgetPolicy
99
{
1010
public function viewAny(User $user): bool
1111
{
12-
return $user->can('finance.view');
12+
return $user->hasPermissionTo('finance.view');
1313
}
1414

1515
public function view(User $user, Budget $budget): bool
1616
{
17-
return $user->can('finance.view');
17+
return $user->hasPermissionTo('finance.view');
1818
}
1919

2020
public function create(User $user): bool
2121
{
22-
return $user->can('finance.create');
22+
return $user->hasPermissionTo('finance.create');
2323
}
2424

2525
public function update(User $user, Budget $budget): bool
2626
{
27-
return $user->can('finance.create');
27+
return $user->hasPermissionTo('finance.create');
2828
}
2929

3030
public function delete(User $user, Budget $budget): bool
3131
{
32-
return $user->can('finance.delete') && $budget->status === 'draft';
32+
return $user->hasPermissionTo('finance.delete') && $budget->status === 'draft';
3333
}
3434
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use App\Modules\Finance\Models\RecurringInvoice;
1717
use App\Modules\Finance\Models\SalesOrder;
1818
use App\Modules\Finance\Models\Budget;
19+
use App\Modules\Finance\Models\BudgetLine;
1920
use App\Modules\Finance\Models\PriceList;
2021
use App\Modules\Finance\Models\DepreciationEntry;
2122
use App\Modules\Finance\Models\FixedAsset;
@@ -53,7 +54,8 @@ public function boot(): void
5354
$this->loadRoutesFrom(__DIR__ . '/../routes/finance.php');
5455

5556
Gate::policy(Account::class, AccountPolicy::class);
56-
Gate::policy(Budget::class, BudgetPolicy::class);
57+
Gate::policy(Budget::class, BudgetPolicy::class);
58+
Gate::policy(BudgetLine::class, BudgetPolicy::class);
5759
Gate::policy(Contact::class, ContactPolicy::class);
5860
Gate::policy(DeliveryNote::class, DeliveryNotePolicy::class);
5961
Gate::policy(JournalEntry::class, JournalEntryPolicy::class);

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
use App\Modules\Finance\Http\Controllers\AccountController;
44
use App\Modules\Finance\Http\Controllers\BudgetController;
5+
use App\Modules\Finance\Http\Controllers\BudgetLineController;
56
use App\Modules\Finance\Http\Controllers\BankAccountController;
67
use App\Modules\Finance\Http\Controllers\BankStatementController;
78
use App\Modules\Finance\Http\Controllers\BillController;
@@ -138,8 +139,14 @@
138139
Route::delete('/exchange-rates/{exchangeRate}', [ExchangeRateController::class, 'destroy'])->name('exchange-rates.destroy');
139140

140141
// Budgets
142+
Route::post('budgets/{budget}/activate', [BudgetController::class, 'activate'])->name('budgets.activate');
143+
Route::post('budgets/{budget}/close', [BudgetController::class, 'close'])->name('budgets.close');
141144
Route::resource('budgets', BudgetController::class)->except(['edit', 'update']);
142145

146+
// Budget Lines
147+
Route::patch('budget-lines/{budgetLine}', [BudgetLineController::class, 'update'])->name('budget-lines.update');
148+
Route::delete('budget-lines/{budgetLine}', [BudgetLineController::class, 'destroy'])->name('budget-lines.destroy');
149+
143150
// Bank Accounts
144151
Route::resource('bank-accounts', BankAccountController::class);
145152
Route::post('bank-accounts/{bankAccount}/import', [BankStatementController::class, 'import'])->name('bank-accounts.import');

0 commit comments

Comments
 (0)