Skip to content

Commit 2c266dc

Browse files
committed
feat: Phase 26 — Budget Management with variance reporting
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 265728b commit 2c266dc

14 files changed

Lines changed: 1007 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Account;
7+
use App\Modules\Finance\Models\Budget;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\DB;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class BudgetController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', Budget::class);
19+
20+
$budgets = Budget::withCount('lines')
21+
->orderByDesc('year')
22+
->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,
31+
]);
32+
33+
return Inertia::render('Finance/Budgets/Index', [
34+
'budgets' => $budgets,
35+
'breadcrumbs' => [
36+
['label' => 'Finance'],
37+
['label' => 'Budgets'],
38+
],
39+
]);
40+
}
41+
42+
public function create(): Response
43+
{
44+
$this->authorize('create', Budget::class);
45+
46+
$accounts = Account::whereIn('type', ['income', 'expense'])
47+
->where('is_active', true)
48+
->orderBy('code')
49+
->get(['id', 'code', 'name', 'type']);
50+
51+
return Inertia::render('Finance/Budgets/Create', [
52+
'accounts' => $accounts,
53+
'breadcrumbs' => [
54+
['label' => 'Finance'],
55+
['label' => 'Budgets', 'href' => '/finance/budgets'],
56+
['label' => 'New Budget'],
57+
],
58+
]);
59+
}
60+
61+
public function store(Request $request): RedirectResponse
62+
{
63+
$this->authorize('create', Budget::class);
64+
65+
$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',
74+
]);
75+
76+
DB::transaction(function () use ($validated, $request) {
77+
$budget = Budget::create([
78+
'tenant_id' => $request->user()->tenant_id,
79+
'name' => $validated['name'],
80+
'year' => $validated['year'],
81+
'period_type' => $validated['period_type'],
82+
'notes' => $validated['notes'] ?? null,
83+
'status' => 'draft',
84+
'created_by' => $request->user()->id,
85+
]);
86+
87+
foreach ($validated['lines'] as $line) {
88+
$budget->lines()->create([
89+
'account_id' => $line['account_id'],
90+
'period' => $line['period'],
91+
'amount' => $line['amount'],
92+
'notes' => $line['notes'] ?? null,
93+
]);
94+
}
95+
});
96+
97+
return redirect()->route('finance.budgets.index')
98+
->with('success', 'Budget created successfully.');
99+
}
100+
101+
public function show(Budget $budget): Response
102+
{
103+
$this->authorize('view', $budget);
104+
$budget->load(['lines.account']);
105+
106+
$tenantId = request()->user()->tenant_id;
107+
$year = $budget->year;
108+
109+
// Compute actuals from posted journal entries for this year
110+
$actuals = DB::table('journal_lines')
111+
->join('journal_entries', 'journal_lines.journal_entry_id', '=', 'journal_entries.id')
112+
->join('accounts', 'journal_lines.account_id', '=', 'accounts.id')
113+
->where('journal_entries.tenant_id', $tenantId)
114+
->where('journal_entries.status', 'posted')
115+
->whereYear('journal_entries.date', $year)
116+
->whereIn('accounts.type', ['income', 'expense'])
117+
->select(
118+
'journal_lines.account_id',
119+
'accounts.type',
120+
DB::raw('SUM(journal_lines.debit) as total_debit'),
121+
DB::raw('SUM(journal_lines.credit) as total_credit'),
122+
)
123+
->groupBy('journal_lines.account_id', 'accounts.type')
124+
->get()
125+
->keyBy('account_id');
126+
127+
$lines = $budget->lines->map(function ($line) use ($actuals) {
128+
$actual = $actuals->get($line->account_id);
129+
$actualAmount = 0;
130+
if ($actual) {
131+
$actualAmount = $actual->type === 'income'
132+
? (float)$actual->total_credit - (float)$actual->total_debit
133+
: (float)$actual->total_debit - (float)$actual->total_credit;
134+
}
135+
$variance = $actualAmount - (float)$line->amount;
136+
$variancePct = $line->amount != 0 ? round($variance / $line->amount * 100, 1) : null;
137+
138+
return [
139+
'id' => $line->id,
140+
'account_id' => $line->account_id,
141+
'account_code' => $line->account->code,
142+
'account_name' => $line->account->name,
143+
'account_type' => $line->account->type,
144+
'period' => $line->period,
145+
'budget' => round((float)$line->amount, 2),
146+
'actual' => round($actualAmount, 2),
147+
'variance' => round($variance, 2),
148+
'variance_pct' => $variancePct,
149+
];
150+
});
151+
152+
return Inertia::render('Finance/Budgets/Show', [
153+
'budget' => [
154+
'id' => $budget->id,
155+
'name' => $budget->name,
156+
'year' => $budget->year,
157+
'period_type' => $budget->period_type,
158+
'status' => $budget->status,
159+
'notes' => $budget->notes,
160+
],
161+
'lines' => $lines->values(),
162+
'total_budget' => $lines->sum('budget'),
163+
'total_actual' => $lines->sum('actual'),
164+
'total_variance' => round($lines->sum('variance'), 2),
165+
'breadcrumbs' => [
166+
['label' => 'Finance'],
167+
['label' => 'Budgets', 'href' => '/finance/budgets'],
168+
['label' => $budget->name],
169+
],
170+
]);
171+
}
172+
173+
public function destroy(Budget $budget): RedirectResponse
174+
{
175+
$this->authorize('delete', $budget);
176+
177+
$budget->delete();
178+
179+
return redirect()->route('finance.budgets.index')
180+
->with('success', 'Budget deleted.');
181+
}
182+
}
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\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use App\Models\User;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class Budget extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id', 'name', 'year', 'period_type', 'notes', 'status', 'created_by',
19+
];
20+
21+
protected $casts = [
22+
'year' => 'integer',
23+
];
24+
25+
public function lines(): HasMany
26+
{
27+
return $this->hasMany(BudgetLine::class);
28+
}
29+
30+
public function creator(): BelongsTo
31+
{
32+
return $this->belongsTo(User::class, 'created_by');
33+
}
34+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class BudgetLine extends Model
9+
{
10+
protected $fillable = [
11+
'budget_id', 'account_id', 'period', 'amount', 'notes',
12+
];
13+
14+
protected $casts = [
15+
'amount' => 'float',
16+
'period' => 'integer',
17+
];
18+
19+
public function budget(): BelongsTo
20+
{
21+
return $this->belongsTo(Budget::class);
22+
}
23+
24+
public function account(): BelongsTo
25+
{
26+
return $this->belongsTo(Account::class);
27+
}
28+
}
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\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\Budget;
7+
8+
class BudgetPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, Budget $budget): bool
16+
{
17+
return $user->can('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('finance.create');
23+
}
24+
25+
public function update(User $user, Budget $budget): bool
26+
{
27+
return $user->can('finance.create');
28+
}
29+
30+
public function delete(User $user, Budget $budget): bool
31+
{
32+
return $user->can('finance.delete') && $budget->status === 'draft';
33+
}
34+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
use App\Modules\Finance\Models\Quote;
1515
use App\Modules\Finance\Models\RecurringInvoice;
1616
use App\Modules\Finance\Models\SalesOrder;
17+
use App\Modules\Finance\Models\Budget;
1718
use App\Modules\Finance\Policies\AccountPolicy;
19+
use App\Modules\Finance\Policies\BudgetPolicy;
1820
use App\Modules\Finance\Policies\BankAccountPolicy;
1921
use App\Modules\Finance\Policies\BankTransactionPolicy;
2022
use App\Modules\Finance\Policies\BillPolicy;
@@ -38,6 +40,7 @@ public function boot(): void
3840
$this->loadRoutesFrom(__DIR__ . '/../routes/finance.php');
3941

4042
Gate::policy(Account::class, AccountPolicy::class);
43+
Gate::policy(Budget::class, BudgetPolicy::class);
4144
Gate::policy(Contact::class, ContactPolicy::class);
4245
Gate::policy(JournalEntry::class, JournalEntryPolicy::class);
4346
Gate::policy(Invoice::class, InvoicePolicy::class);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use App\Modules\Finance\Http\Controllers\AccountController;
4+
use App\Modules\Finance\Http\Controllers\BudgetController;
45
use App\Modules\Finance\Http\Controllers\BankAccountController;
56
use App\Modules\Finance\Http\Controllers\BankStatementController;
67
use App\Modules\Finance\Http\Controllers\BillController;
@@ -123,6 +124,9 @@
123124
Route::post('/exchange-rates', [ExchangeRateController::class, 'store'])->name('exchange-rates.store');
124125
Route::delete('/exchange-rates/{exchangeRate}', [ExchangeRateController::class, 'destroy'])->name('exchange-rates.destroy');
125126

127+
// Budgets
128+
Route::resource('budgets', BudgetController::class)->except(['edit', 'update']);
129+
126130
// Bank Accounts
127131
Route::resource('bank-accounts', BankAccountController::class);
128132
Route::post('bank-accounts/{bankAccount}/import', [BankStatementController::class, 'import'])->name('bank-accounts.import');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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::create('budget_lines', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('budget_id')->constrained()->cascadeOnDelete();
14+
$table->foreignId('account_id')->constrained()->cascadeOnDelete();
15+
$table->integer('period')->default(0); // 0=annual, 1-12=month, 1-4=quarter
16+
$table->decimal('amount', 14, 2)->default(0);
17+
$table->text('notes')->nullable();
18+
$table->timestamps();
19+
$table->unique(['budget_id', 'account_id', 'period']);
20+
});
21+
}
22+
23+
public function down(): void
24+
{
25+
Schema::dropIfExists('budget_lines');
26+
}
27+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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::create('budgets', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->string('name');
15+
$table->integer('year');
16+
$table->enum('period_type', ['annual', 'monthly', 'quarterly'])->default('annual');
17+
$table->text('notes')->nullable();
18+
$table->enum('status', ['draft', 'active', 'archived'])->default('draft');
19+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
20+
$table->timestamps();
21+
$table->softDeletes();
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('budgets');
28+
}
29+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
type BudgetStatus = 'draft' | 'active' | 'archived';
2+
3+
const map: Record<BudgetStatus, string> = {
4+
draft: 'bg-slate-100 text-slate-600',
5+
active: 'bg-green-100 text-green-700',
6+
archived: 'bg-slate-100 text-slate-500',
7+
};
8+
9+
export function BudgetStatusBadge({ status }: { status: BudgetStatus }) {
10+
return (
11+
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize ${map[status] ?? 'bg-slate-100 text-slate-500'}`}>
12+
{status}
13+
</span>
14+
);
15+
}

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const navItems: NavItem[] = [
8181
{ label: 'VAT Report', href: '/finance/reports/vat-report', icon: <span /> },
8282
{ label: 'Exchange Rates', href: '/finance/exchange-rates', icon: <span /> },
8383
{ label: 'Bank Accounts', href: '/finance/bank-accounts', icon: <span /> },
84+
{ label: 'Budgets', href: '/finance/budgets', icon: <span /> },
8485
{ label: 'Reconciliation', href: '/finance/reconciliation', icon: <span /> },
8586
],
8687
},

0 commit comments

Comments
 (0)