Skip to content

Commit 536533b

Browse files
committed
Phases 156-160: Multi-Currency, Multi-Company, HR Reports
Phase 156 — Multi-Currency: - CurrencyController: added create() + edit() methods, rounding validation - Currency routes: enabled create/edit (was excluded) Phase 157 — Multi-Company (Multi-Business): - companies table: tenant-scoped, parent_company_id hierarchy, fiscal_year_start, currency_code, tax_id, address/contact fields - company_user pivot table for user-company membership - Company model: fullName accessor, parent/subsidiaries/users relations - CompanyController: full CRUD - Core routes: /core/companies resource - React pages: Companies/Index, Create, Edit, Show - Sidebar: Companies added under System section Phase 158 — Finance Reports: - FinanceReportController: profitLoss, agedReceivables, agedPayables, invoiceSummary, expenseSummary Phase 160 — HR Reports: - HRReportController: headcount, leaveSummary, departmentSummary, employeeTenure - HR routes: /hr/reports/* group - React pages: HR/Reports/Headcount, LeaveSummary, DepartmentSummary, EmployeeTenure - Sidebar: HR Reports links added 1587 tests passing https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent e9af40d commit 536533b

15 files changed

Lines changed: 1768 additions & 20 deletions

File tree

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ public function index(): Response
2525
]);
2626
}
2727

28+
public function create(): Response
29+
{
30+
$this->authorize('create', Currency::class);
31+
32+
return Inertia::render('Finance/Currencies/Create');
33+
}
34+
2835
public function store(Request $request): RedirectResponse
2936
{
3037
$this->authorize('create', Currency::class);
@@ -34,6 +41,7 @@ public function store(Request $request): RedirectResponse
3441
'name' => ['required', 'string', 'max:100'],
3542
'symbol' => ['required', 'string', 'max:10'],
3643
'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:4'],
44+
'rounding' => ['sometimes', 'numeric', 'min:0'],
3745
'is_base' => ['sometimes', 'boolean'],
3846
'is_active' => ['sometimes', 'boolean'],
3947
]);
@@ -51,7 +59,16 @@ public function store(Request $request): RedirectResponse
5159
$currency->setAsBase();
5260
}
5361

54-
return redirect()->back()->with('success', 'Currency created.');
62+
return redirect()->route('finance.currencies.index')->with('success', 'Currency created.');
63+
}
64+
65+
public function edit(Currency $currency): Response
66+
{
67+
$this->authorize('update', $currency);
68+
69+
return Inertia::render('Finance/Currencies/Edit', [
70+
'currency' => $currency,
71+
]);
5572
}
5673

5774
public function update(Request $request, Currency $currency): RedirectResponse
@@ -63,6 +80,7 @@ public function update(Request $request, Currency $currency): RedirectResponse
6380
'name' => ['required', 'string', 'max:100'],
6481
'symbol' => ['required', 'string', 'max:10'],
6582
'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:4'],
83+
'rounding' => ['sometimes', 'numeric', 'min:0'],
6684
'is_base' => ['sometimes', 'boolean'],
6785
'is_active' => ['sometimes', 'boolean'],
6886
]);
@@ -76,7 +94,7 @@ public function update(Request $request, Currency $currency): RedirectResponse
7694
$currency->setAsBase();
7795
}
7896

79-
return redirect()->back()->with('success', 'Currency updated.');
97+
return redirect()->route('finance.currencies.index')->with('success', 'Currency updated.');
8098
}
8199

82100
public function destroy(Currency $currency): RedirectResponse
@@ -89,7 +107,7 @@ public function destroy(Currency $currency): RedirectResponse
89107

90108
$currency->delete();
91109

92-
return redirect()->back()->with('success', 'Currency deleted.');
110+
return redirect()->route('finance.currencies.index')->with('success', 'Currency deleted.');
93111
}
94112

95113
public function setBase(Request $request, Currency $currency): RedirectResponse
@@ -98,6 +116,6 @@ public function setBase(Request $request, Currency $currency): RedirectResponse
98116

99117
$currency->setAsBase();
100118

101-
return redirect()->back()->with('success', 'Base currency updated.');
119+
return redirect()->route('finance.currencies.index')->with('success', 'Base currency updated.');
102120
}
103121
}
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Bill;
7+
use App\Modules\Finance\Models\Invoice;
8+
use Carbon\Carbon;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\DB;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class FinanceReportController extends Controller
15+
{
16+
// GET /finance/reports/profit-loss
17+
public function profitLoss(Request $request): Response
18+
{
19+
$this->authorize('viewAny', Invoice::class);
20+
21+
$tenantId = app('tenant')->id;
22+
$dateFrom = $request->get('date_from', now()->startOfYear()->toDateString());
23+
$dateTo = $request->get('date_to', now()->toDateString());
24+
25+
// Revenue: sum payments for paid invoices
26+
$revenueRows = DB::table('invoices')
27+
->join('payments', 'payments.invoice_id', '=', 'invoices.id')
28+
->where('invoices.tenant_id', $tenantId)
29+
->where('invoices.status', 'paid')
30+
->whereNull('invoices.deleted_at')
31+
->whereBetween('payments.payment_date', [$dateFrom, $dateTo])
32+
->select(
33+
DB::raw('YEAR(payments.payment_date) as year'),
34+
DB::raw('MONTH(payments.payment_date) as month'),
35+
DB::raw('SUM(payments.amount) as revenue')
36+
)
37+
->groupBy('year', 'month')
38+
->orderBy('year')
39+
->orderBy('month')
40+
->get();
41+
42+
// Expenses: sum bill payments for paid bills
43+
$expenseRows = DB::table('bills')
44+
->join('bill_payments', 'bill_payments.bill_id', '=', 'bills.id')
45+
->where('bills.tenant_id', $tenantId)
46+
->where('bills.status', 'paid')
47+
->whereNull('bills.deleted_at')
48+
->whereBetween('bill_payments.payment_date', [$dateFrom, $dateTo])
49+
->select(
50+
DB::raw('YEAR(bill_payments.payment_date) as year'),
51+
DB::raw('MONTH(bill_payments.payment_date) as month'),
52+
DB::raw('SUM(bill_payments.amount) as expenses')
53+
)
54+
->groupBy('year', 'month')
55+
->orderBy('year')
56+
->orderBy('month')
57+
->get()
58+
->keyBy(fn ($r) => $r->year . '-' . $r->month);
59+
60+
$totalRevenue = (float) $revenueRows->sum('revenue');
61+
$totalExpenses = (float) $expenseRows->sum('expenses');
62+
$netProfit = $totalRevenue - $totalExpenses;
63+
64+
$breakdown = $revenueRows->map(function ($row) use ($expenseRows) {
65+
$key = $row->year . '-' . $row->month;
66+
$expenses = (float) ($expenseRows->get($key)?->expenses ?? 0);
67+
$revenue = (float) $row->revenue;
68+
$net = $revenue - $expenses;
69+
$margin = $revenue > 0 ? round($net / $revenue * 100, 2) : 0;
70+
return [
71+
'year' => $row->year,
72+
'month' => $row->month,
73+
'label' => Carbon::createFromDate($row->year, $row->month, 1)->format('M Y'),
74+
'revenue' => $revenue,
75+
'expenses' => $expenses,
76+
'net' => $net,
77+
'margin' => $margin,
78+
];
79+
})->values();
80+
81+
return Inertia::render('Finance/Reports/ProfitLossSummary', [
82+
'date_from' => $dateFrom,
83+
'date_to' => $dateTo,
84+
'total_revenue' => $totalRevenue,
85+
'total_expenses' => $totalExpenses,
86+
'net_profit' => $netProfit,
87+
'breakdown' => $breakdown,
88+
]);
89+
}
90+
91+
// GET /finance/reports/aged-receivables
92+
public function agedReceivables(Request $request): Response
93+
{
94+
$this->authorize('viewAny', Invoice::class);
95+
96+
$tenantId = app('tenant')->id;
97+
$asOf = $request->get('as_of', now()->toDateString());
98+
$asOfDate = Carbon::parse($asOf);
99+
100+
$invoices = Invoice::with(['contact', 'items', 'payments'])
101+
->where('tenant_id', $tenantId)
102+
->whereNotIn('status', ['draft', 'paid', 'cancelled'])
103+
->get()
104+
->map(function ($inv) use ($asOfDate) {
105+
$daysOverdue = 0;
106+
$bucket = 'current';
107+
if ($inv->due_date) {
108+
$diff = $asOfDate->diffInDays($inv->due_date, false);
109+
$daysOverdue = (int) max(0, $diff * -1);
110+
$bucket = match (true) {
111+
$daysOverdue === 0 => 'current',
112+
$daysOverdue <= 30 => '1-30',
113+
$daysOverdue <= 60 => '31-60',
114+
$daysOverdue <= 90 => '61-90',
115+
default => '91+',
116+
};
117+
}
118+
return [
119+
'id' => $inv->id,
120+
'number' => $inv->number,
121+
'customer' => $inv->contact?->name ?? '',
122+
'due_date' => $inv->due_date?->toDateString(),
123+
'amount' => (float) $inv->total,
124+
'amount_due' => (float) $inv->amount_due,
125+
'days_overdue' => $daysOverdue,
126+
'bucket' => $bucket,
127+
];
128+
});
129+
130+
$bucketKeys = ['current', '1-30', '31-60', '61-90', '91+'];
131+
$totals = collect($bucketKeys)->mapWithKeys(fn ($k) => [
132+
$k => (float) $invoices->where('bucket', $k)->sum('amount_due'),
133+
])->all();
134+
135+
return Inertia::render('Finance/Reports/AgedReceivablesSummary', [
136+
'rows' => $invoices->values(),
137+
'totals' => $totals,
138+
'grand_total' => (float) $invoices->sum('amount_due'),
139+
'as_of' => $asOf,
140+
]);
141+
}
142+
143+
// GET /finance/reports/aged-payables
144+
public function agedPayables(Request $request): Response
145+
{
146+
$this->authorize('viewAny', Bill::class);
147+
148+
$tenantId = app('tenant')->id;
149+
$asOf = $request->get('as_of', now()->toDateString());
150+
$asOfDate = Carbon::parse($asOf);
151+
152+
$bills = Bill::with(['contact', 'items', 'payments'])
153+
->where('tenant_id', $tenantId)
154+
->whereNotIn('status', ['draft', 'paid', 'cancelled'])
155+
->get()
156+
->map(function ($bill) use ($asOfDate) {
157+
$daysOverdue = 0;
158+
$bucket = 'current';
159+
if ($bill->due_date) {
160+
$diff = $asOfDate->diffInDays($bill->due_date, false);
161+
$daysOverdue = (int) max(0, $diff * -1);
162+
$bucket = match (true) {
163+
$daysOverdue === 0 => 'current',
164+
$daysOverdue <= 30 => '1-30',
165+
$daysOverdue <= 60 => '31-60',
166+
$daysOverdue <= 90 => '61-90',
167+
default => '91+',
168+
};
169+
}
170+
return [
171+
'id' => $bill->id,
172+
'number' => $bill->number,
173+
'supplier' => $bill->contact?->name ?? '',
174+
'due_date' => $bill->due_date?->toDateString(),
175+
'amount' => (float) $bill->total,
176+
'amount_due' => (float) $bill->amount_due,
177+
'days_overdue' => $daysOverdue,
178+
'bucket' => $bucket,
179+
];
180+
});
181+
182+
$bucketKeys = ['current', '1-30', '31-60', '61-90', '91+'];
183+
$totals = collect($bucketKeys)->mapWithKeys(fn ($k) => [
184+
$k => (float) $bills->where('bucket', $k)->sum('amount_due'),
185+
])->all();
186+
187+
return Inertia::render('Finance/Reports/AgedPayablesSummary', [
188+
'rows' => $bills->values(),
189+
'totals' => $totals,
190+
'grand_total' => (float) $bills->sum('amount_due'),
191+
'as_of' => $asOf,
192+
]);
193+
}
194+
195+
// GET /finance/reports/invoice-summary
196+
public function invoiceSummary(Request $request): Response
197+
{
198+
$this->authorize('viewAny', Invoice::class);
199+
200+
$tenantId = app('tenant')->id;
201+
$dateFrom = $request->get('date_from', now()->startOfMonth()->toDateString());
202+
$dateTo = $request->get('date_to', now()->toDateString());
203+
204+
$invoices = Invoice::with(['contact', 'items', 'payments'])
205+
->where('tenant_id', $tenantId)
206+
->whereBetween('issue_date', [$dateFrom, $dateTo])
207+
->get();
208+
209+
$statuses = ['draft', 'sent', 'partial', 'paid', 'cancelled'];
210+
211+
$summary = collect($statuses)->mapWithKeys(function ($status) use ($invoices) {
212+
$group = $invoices->where('status', $status);
213+
return [$status => [
214+
'count' => $group->count(),
215+
'amount' => (float) $group->sum(fn ($i) => $i->total),
216+
]];
217+
})->all();
218+
219+
// Overdue: sent/partial with due_date in the past
220+
$overdueInvoices = $invoices->filter(fn ($i) =>
221+
in_array($i->status, ['sent', 'partial']) &&
222+
$i->due_date !== null &&
223+
$i->due_date->isPast()
224+
);
225+
226+
$summary['overdue'] = [
227+
'count' => $overdueInvoices->count(),
228+
'amount' => (float) $overdueInvoices->sum(fn ($i) => $i->amount_due),
229+
];
230+
231+
$table = $invoices->map(fn ($inv) => [
232+
'id' => $inv->id,
233+
'number' => $inv->number,
234+
'customer' => $inv->contact?->name ?? '',
235+
'issue_date'=> $inv->issue_date?->toDateString(),
236+
'due_date' => $inv->due_date?->toDateString(),
237+
'status' => $inv->status,
238+
'total' => (float) $inv->total,
239+
'amount_due'=> (float) $inv->amount_due,
240+
])->values();
241+
242+
return Inertia::render('Finance/Reports/InvoiceSummary', [
243+
'date_from' => $dateFrom,
244+
'date_to' => $dateTo,
245+
'summary' => $summary,
246+
'total_count' => $invoices->count(),
247+
'total_amount'=> (float) $invoices->sum(fn ($i) => $i->total),
248+
'invoices' => $table,
249+
]);
250+
}
251+
252+
// GET /finance/reports/expense-summary
253+
public function expenseSummary(Request $request): Response
254+
{
255+
$this->authorize('viewAny', Bill::class);
256+
257+
$tenantId = app('tenant')->id;
258+
$dateFrom = $request->get('date_from', now()->startOfMonth()->toDateString());
259+
$dateTo = $request->get('date_to', now()->toDateString());
260+
261+
$bills = Bill::with(['contact', 'items', 'payments'])
262+
->where('tenant_id', $tenantId)
263+
->whereBetween('issue_date', [$dateFrom, $dateTo])
264+
->get();
265+
266+
$statuses = ['draft', 'received', 'partial', 'paid', 'cancelled'];
267+
268+
$summary = collect($statuses)->mapWithKeys(function ($status) use ($bills) {
269+
$group = $bills->where('status', $status);
270+
return [$status => [
271+
'count' => $group->count(),
272+
'amount' => (float) $group->sum(fn ($b) => $b->total),
273+
]];
274+
})->all();
275+
276+
$overdueBills = $bills->filter(fn ($b) =>
277+
in_array($b->status, ['received', 'partial']) &&
278+
$b->due_date !== null &&
279+
$b->due_date->isPast()
280+
);
281+
282+
$summary['overdue'] = [
283+
'count' => $overdueBills->count(),
284+
'amount' => (float) $overdueBills->sum(fn ($b) => $b->amount_due),
285+
];
286+
287+
$table = $bills->map(fn ($bill) => [
288+
'id' => $bill->id,
289+
'number' => $bill->number,
290+
'supplier' => $bill->contact?->name ?? '',
291+
'issue_date'=> $bill->issue_date?->toDateString(),
292+
'due_date' => $bill->due_date?->toDateString(),
293+
'status' => $bill->status,
294+
'total' => (float) $bill->total,
295+
'amount_due'=> (float) $bill->amount_due,
296+
])->values();
297+
298+
return Inertia::render('Finance/Reports/ExpenseSummary', [
299+
'date_from' => $dateFrom,
300+
'date_to' => $dateTo,
301+
'summary' => $summary,
302+
'total_count' => $bills->count(),
303+
'total_amount'=> (float) $bills->sum(fn ($b) => $b->total),
304+
'bills' => $table,
305+
]);
306+
}
307+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@
162162

163163
// Currencies — set-base BEFORE resource
164164
Route::post('currencies/{currency}/set-base', [CurrencyController::class, 'setBase'])->name('currencies.set-base');
165-
Route::resource('currencies', CurrencyController::class)->except(['show', 'create', 'edit']);
165+
Route::resource('currencies', CurrencyController::class)->except(['show']);
166166

167167
// Exchange Rates — convert and report BEFORE resource to avoid them being treated as IDs
168168
Route::get('exchange-rates/convert', [ExchangeRateController::class, 'convert'])->name('exchange-rates.convert');

0 commit comments

Comments
 (0)