Skip to content

Commit 357e671

Browse files
committed
feat: Phase 8 — Aged Reports, Account Ledger, PO Receive UI, Stock History
- Add agedReceivables, agedPayables, accountLedgerIndex, accountLedger methods to ReportController with bucket-based aging logic - Register new finance report routes (aged-receivables, aged-payables, account-ledger index + per-account) - Add PO receiveForm GET route and controller method rendering Inventory/PurchaseOrders/Receive Inertia page - New React pages: AgedReceivables, AgedPayables, AccountLedger, PO Receive form - Update Products/Show with stock movement history and adjustment form - Update PurchaseOrders/Show with "Receive Items" button for approved POs - Add Aged Receivables, Aged Payables, Account Ledger to sidebar - 19 new tests across Finance ReportTest and Inventory PurchaseOrderReceiveTest; all 206 tests passing https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 7759bc3 commit 357e671

14 files changed

Lines changed: 1055 additions & 23 deletions

File tree

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

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Modules\Finance\Models\Account;
7+
use App\Modules\Finance\Models\Bill;
8+
use App\Modules\Finance\Models\Invoice;
79
use App\Modules\Finance\Models\JournalLine;
810
use Illuminate\Http\Request;
911
use Illuminate\Support\Facades\DB;
@@ -172,6 +174,174 @@ public function balanceSheet(Request $request): Response
172174
]);
173175
}
174176

177+
public function agedReceivables(Request $request): Response
178+
{
179+
$this->authorize('viewAny', Account::class);
180+
$asOf = $request->as_of ?? now()->toDateString();
181+
182+
$invoices = Invoice::with(['contact', 'items', 'payments'])
183+
->whereNotIn('status', ['paid', 'cancelled'])
184+
->get()
185+
->map(function ($inv) use ($asOf) {
186+
$daysOverdue = 0;
187+
if ($inv->due_date) {
188+
$diff = \Carbon\Carbon::parse($asOf)->diffInDays($inv->due_date, false);
189+
$daysOverdue = (int) max(0, $diff * -1);
190+
}
191+
$bucket = match(true) {
192+
$daysOverdue === 0 => 'current',
193+
$daysOverdue <= 30 => '1-30',
194+
$daysOverdue <= 60 => '31-60',
195+
$daysOverdue <= 90 => '61-90',
196+
default => '90+',
197+
};
198+
return [
199+
'id' => $inv->id,
200+
'number' => $inv->number,
201+
'contact' => $inv->contact?->name ?? '',
202+
'due_date' => $inv->due_date?->toDateString(),
203+
'amount_due' => (float) $inv->amount_due,
204+
'days_overdue'=> $daysOverdue,
205+
'bucket' => $bucket,
206+
];
207+
});
208+
209+
$bucketKeys = ['current', '1-30', '31-60', '61-90', '90+'];
210+
$totals = collect($bucketKeys)->mapWithKeys(fn ($k) =>
211+
[$k => (float) $invoices->where('bucket', $k)->sum('amount_due')]
212+
)->all();
213+
214+
return Inertia::render('Finance/Reports/AgedReceivables', [
215+
'rows' => $invoices->values(),
216+
'totals' => $totals,
217+
'grand_total' => (float) $invoices->sum('amount_due'),
218+
'as_of' => $asOf,
219+
'breadcrumbs' => [
220+
['label' => 'Finance'],
221+
['label' => 'Reports'],
222+
['label' => 'Aged Receivables'],
223+
],
224+
]);
225+
}
226+
227+
public function agedPayables(Request $request): Response
228+
{
229+
$this->authorize('viewAny', Account::class);
230+
$asOf = $request->as_of ?? now()->toDateString();
231+
232+
$bills = Bill::with(['contact', 'items', 'payments'])
233+
->whereNotIn('status', ['paid', 'cancelled'])
234+
->get()
235+
->map(function ($bill) use ($asOf) {
236+
$daysOverdue = 0;
237+
if ($bill->due_date) {
238+
$diff = \Carbon\Carbon::parse($asOf)->diffInDays($bill->due_date, false);
239+
$daysOverdue = (int) max(0, $diff * -1);
240+
}
241+
$bucket = match(true) {
242+
$daysOverdue === 0 => 'current',
243+
$daysOverdue <= 30 => '1-30',
244+
$daysOverdue <= 60 => '31-60',
245+
$daysOverdue <= 90 => '61-90',
246+
default => '90+',
247+
};
248+
return [
249+
'id' => $bill->id,
250+
'number' => $bill->number,
251+
'contact' => $bill->contact?->name ?? '',
252+
'due_date' => $bill->due_date?->toDateString(),
253+
'amount_due' => (float) $bill->amount_due,
254+
'days_overdue'=> $daysOverdue,
255+
'bucket' => $bucket,
256+
];
257+
});
258+
259+
$bucketKeys = ['current', '1-30', '31-60', '61-90', '90+'];
260+
$totals = collect($bucketKeys)->mapWithKeys(fn ($k) =>
261+
[$k => (float) $bills->where('bucket', $k)->sum('amount_due')]
262+
)->all();
263+
264+
return Inertia::render('Finance/Reports/AgedPayables', [
265+
'rows' => $bills->values(),
266+
'totals' => $totals,
267+
'grand_total' => (float) $bills->sum('amount_due'),
268+
'as_of' => $asOf,
269+
'breadcrumbs' => [
270+
['label' => 'Finance'],
271+
['label' => 'Reports'],
272+
['label' => 'Aged Payables'],
273+
],
274+
]);
275+
}
276+
277+
public function accountLedgerIndex(Request $request): Response
278+
{
279+
$this->authorize('viewAny', Account::class);
280+
$accounts = Account::orderBy('code')->get(['id', 'code', 'name', 'type']);
281+
return Inertia::render('Finance/Reports/AccountLedger', [
282+
'accounts' => $accounts,
283+
'account' => null,
284+
'rows' => [],
285+
'from' => now()->startOfYear()->toDateString(),
286+
'to' => now()->toDateString(),
287+
'breadcrumbs' => [['label' => 'Finance'], ['label' => 'Reports'], ['label' => 'Account Ledger']],
288+
]);
289+
}
290+
291+
public function accountLedger(Request $request, Account $account): Response
292+
{
293+
$this->authorize('viewAny', Account::class);
294+
$from = $request->from ?? now()->startOfYear()->toDateString();
295+
$to = $request->to ?? now()->toDateString();
296+
297+
// Use JOIN (not whereHas) so ordering by journal_entries.date works correctly
298+
$lines = JournalLine::join('journal_entries', 'journal_entries.id', '=', 'journal_lines.journal_entry_id')
299+
->where('journal_lines.account_id', $account->id)
300+
->where('journal_entries.status', 'posted')
301+
->when($from, fn ($q) => $q->whereDate('journal_entries.date', '>=', $from))
302+
->when($to, fn ($q) => $q->whereDate('journal_entries.date', '<=', $to))
303+
->orderBy('journal_entries.date')
304+
->orderBy('journal_lines.id')
305+
->select('journal_lines.*', 'journal_entries.date as entry_date',
306+
'journal_entries.reference as entry_reference',
307+
'journal_entries.description as entry_description')
308+
->get();
309+
310+
$isDebitNormal = in_array($account->type, ['asset', 'expense'], true);
311+
$runningBalance = 0.0;
312+
$rows = [];
313+
foreach ($lines as $line) {
314+
$debit = (float) $line->debit;
315+
$credit = (float) $line->credit;
316+
$runningBalance += $isDebitNormal ? ($debit - $credit) : ($credit - $debit);
317+
$rows[] = [
318+
'id' => $line->id,
319+
'date' => $line->entry_date instanceof \Carbon\Carbon
320+
? $line->entry_date->toDateString()
321+
: (string) $line->entry_date,
322+
'reference' => $line->entry_reference,
323+
'description' => $line->description ?? $line->entry_description,
324+
'debit' => $debit,
325+
'credit' => $credit,
326+
'balance' => $runningBalance,
327+
];
328+
}
329+
330+
$accounts = Account::orderBy('code')->get(['id', 'code', 'name', 'type']);
331+
332+
return Inertia::render('Finance/Reports/AccountLedger', [
333+
'accounts' => $accounts,
334+
'account' => ['id' => $account->id, 'code' => $account->code, 'name' => $account->name, 'type' => $account->type],
335+
'rows' => $rows,
336+
'from' => $from,
337+
'to' => $to,
338+
'breadcrumbs' => [
339+
['label' => 'Finance'], ['label' => 'Reports'],
340+
['label' => "Ledger: {$account->name}"],
341+
],
342+
]);
343+
}
344+
175345
private function aggregateJournalLines(?string $from = null, ?string $to = null): \Illuminate\Support\Collection
176346
{
177347
return JournalLine::select('account_id',

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,8 @@
4444
->name('reports.profit-loss');
4545
Route::get('reports/balance-sheet', [ReportController::class, 'balanceSheet'])
4646
->name('reports.balance-sheet');
47+
Route::get('reports/aged-receivables', [ReportController::class, 'agedReceivables'])->name('reports.aged-receivables');
48+
Route::get('reports/aged-payables', [ReportController::class, 'agedPayables'])->name('reports.aged-payables');
49+
Route::get('reports/account-ledger', [ReportController::class, 'accountLedgerIndex'])->name('reports.account-ledger.index');
50+
Route::get('reports/account-ledger/{account}', [ReportController::class, 'accountLedger'])->name('reports.account-ledger');
4751
});

erp/app/Modules/Inventory/Http/Controllers/PurchaseOrderController.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,26 @@ public function show(PurchaseOrder $purchaseOrder): Response
9292
]);
9393
}
9494

95+
public function receiveForm(PurchaseOrder $purchaseOrder): Response
96+
{
97+
if (! $purchaseOrder->canTransitionTo('received')) {
98+
return redirect()->route('inventory.purchase-orders.show', $purchaseOrder)
99+
->withErrors(['status' => 'This purchase order cannot be received in its current status.']);
100+
}
101+
102+
$purchaseOrder->load(['supplier', 'warehouse', 'items.product']);
103+
104+
return Inertia::render('Inventory/PurchaseOrders/Receive', [
105+
'order' => new PurchaseOrderResource($purchaseOrder),
106+
'breadcrumbs' => [
107+
['label' => 'Inventory'],
108+
['label' => 'Purchase Orders', 'href' => route('inventory.purchase-orders.index')],
109+
['label' => "PO-" . str_pad($purchaseOrder->id, 4, '0', STR_PAD_LEFT), 'href' => route('inventory.purchase-orders.show', $purchaseOrder)],
110+
['label' => 'Receive Items'],
111+
],
112+
]);
113+
}
114+
95115
public function transition(Request $request, PurchaseOrder $purchaseOrder): RedirectResponse
96116
{
97117
$status = $request->validate(['status' => ['required', 'string']])['status'];
@@ -143,7 +163,8 @@ public function receive(ReceivePurchaseOrderRequest $request, PurchaseOrder $pur
143163
return back()->withErrors(['status' => $e->getMessage()]);
144164
}
145165

146-
return back()->with('success', 'Items received and stock updated.');
166+
return redirect()->route('inventory.purchase-orders.show', $purchaseOrder)
167+
->with('success', 'Items received and stock updated.');
147168
}
148169

149170
public function cancel(PurchaseOrder $purchaseOrder): RedirectResponse

erp/app/Modules/Inventory/routes/inventory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
Route::get('purchase-orders/create', [PurchaseOrderController::class, 'create'])->name('purchase-orders.create');
4141
Route::post('purchase-orders', [PurchaseOrderController::class, 'store'])->name('purchase-orders.store');
4242
Route::get('purchase-orders/{purchaseOrder}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show');
43+
Route::get('purchase-orders/{purchaseOrder}/receive', [PurchaseOrderController::class, 'receiveForm'])->name('purchase-orders.receive-form');
4344
Route::patch('purchase-orders/{purchaseOrder}/transition', [PurchaseOrderController::class, 'transition'])->name('purchase-orders.transition');
4445
Route::post('purchase-orders/{purchaseOrder}/submit', [PurchaseOrderController::class, 'submit'])->name('purchase-orders.submit');
4546
Route::post('purchase-orders/{purchaseOrder}/approve', [PurchaseOrderController::class, 'approve'])->name('purchase-orders.approve');

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,17 @@ const navItems: NavItem[] = [
6161
),
6262
permission: 'finance.view',
6363
children: [
64-
{ label: 'Invoices', href: '/finance/invoices', icon: <span /> },
65-
{ label: 'Contacts', href: '/finance/contacts', icon: <span /> },
66-
{ label: 'Journal Entries', href: '/finance/journal-entries', icon: <span /> },
67-
{ label: 'Chart of Accounts', href: '/finance/accounts', icon: <span /> },
68-
{ label: 'Trial Balance', href: '/finance/reports/trial-balance', icon: <span /> },
69-
{ label: 'Bills (AP)', href: '/finance/bills', icon: <span /> },
70-
{ label: 'Profit & Loss', href: '/finance/reports/profit-loss', icon: <span /> },
71-
{ label: 'Balance Sheet', href: '/finance/reports/balance-sheet', icon: <span /> },
64+
{ label: 'Invoices', href: '/finance/invoices', icon: <span /> },
65+
{ label: 'Contacts', href: '/finance/contacts', icon: <span /> },
66+
{ label: 'Journal Entries', href: '/finance/journal-entries', icon: <span /> },
67+
{ label: 'Chart of Accounts', href: '/finance/accounts', icon: <span /> },
68+
{ label: 'Bills (AP)', href: '/finance/bills', icon: <span /> },
69+
{ label: 'Trial Balance', href: '/finance/reports/trial-balance', icon: <span /> },
70+
{ label: 'Profit & Loss', href: '/finance/reports/profit-loss', icon: <span /> },
71+
{ label: 'Balance Sheet', href: '/finance/reports/balance-sheet', icon: <span /> },
72+
{ label: 'Aged Receivables', href: '/finance/reports/aged-receivables', icon: <span /> },
73+
{ label: 'Aged Payables', href: '/finance/reports/aged-payables', icon: <span /> },
74+
{ label: 'Account Ledger', href: '/finance/reports/account-ledger', icon: <span /> },
7275
],
7376
},
7477
{

0 commit comments

Comments
 (0)