Skip to content

Commit 7767ef9

Browse files
committed
feat: Phase 37 — Cash Flow Forecast report
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 44b1fbf commit 7767ef9

5 files changed

Lines changed: 437 additions & 0 deletions

File tree

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

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,165 @@ public function vatReport(Request $request): Response
520520
]);
521521
}
522522

523+
public function cashFlowForecast(Request $request): Response
524+
{
525+
$this->authorize('viewAny', Invoice::class);
526+
527+
$weeks = (int) $request->get('weeks', 12);
528+
$openingBalance = (float) $request->get('opening_balance', 0);
529+
$from = now()->startOfDay();
530+
$to = now()->addWeeks($weeks)->endOfDay();
531+
532+
// Collect open invoices (inflows) due within horizon
533+
$invoices = Invoice::with('contact')
534+
->whereIn('status', ['sent', 'partial'])
535+
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])
536+
->get();
537+
538+
// Collect open bills (outflows) due within horizon
539+
$bills = Bill::with('contact')
540+
->whereIn('status', ['received', 'partial'])
541+
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])
542+
->get();
543+
544+
// Build weekly buckets
545+
$buckets = [];
546+
for ($i = 0; $i < $weeks; $i++) {
547+
$weekStart = now()->addWeeks($i)->startOfWeek()->toDateString();
548+
$weekEnd = now()->addWeeks($i)->endOfWeek()->toDateString();
549+
$buckets[$weekStart] = [
550+
'week_start' => $weekStart,
551+
'week_end' => $weekEnd,
552+
'inflows' => [],
553+
'outflows' => [],
554+
];
555+
}
556+
557+
// Place invoices into their week bucket
558+
foreach ($invoices as $inv) {
559+
$due = $inv->due_date instanceof \Carbon\Carbon
560+
? $inv->due_date->toDateString()
561+
: (string) $inv->due_date;
562+
foreach ($buckets as $weekStart => $bucket) {
563+
if ($due >= $bucket['week_start'] && $due <= $bucket['week_end']) {
564+
$buckets[$weekStart]['inflows'][] = [
565+
'reference' => $inv->reference,
566+
'contact' => $inv->contact?->name ?? '',
567+
'due_date' => $due,
568+
'amount' => $inv->total - $inv->amount_paid,
569+
];
570+
break;
571+
}
572+
}
573+
}
574+
575+
// Place bills into their week bucket
576+
foreach ($bills as $bill) {
577+
$due = $bill->due_date instanceof \Carbon\Carbon
578+
? $bill->due_date->toDateString()
579+
: (string) $bill->due_date;
580+
foreach ($buckets as $weekStart => $bucket) {
581+
if ($due >= $bucket['week_start'] && $due <= $bucket['week_end']) {
582+
$buckets[$weekStart]['outflows'][] = [
583+
'reference' => $bill->reference,
584+
'contact' => $bill->contact?->name ?? '',
585+
'due_date' => $due,
586+
'amount' => $bill->total - $bill->amount_paid,
587+
];
588+
break;
589+
}
590+
}
591+
}
592+
593+
// Compute running balance per week
594+
$balance = $openingBalance;
595+
$result = [];
596+
foreach ($buckets as $bucket) {
597+
$inflow = array_sum(array_column($bucket['inflows'], 'amount'));
598+
$outflow = array_sum(array_column($bucket['outflows'], 'amount'));
599+
$balance += $inflow - $outflow;
600+
$result[] = [
601+
'week_start' => $bucket['week_start'],
602+
'week_end' => $bucket['week_end'],
603+
'inflows' => $bucket['inflows'],
604+
'outflows' => $bucket['outflows'],
605+
'total_inflow' => round($inflow, 2),
606+
'total_outflow' => round($outflow, 2),
607+
'net' => round($inflow - $outflow, 2),
608+
'closing_balance' => round($balance, 2),
609+
];
610+
}
611+
612+
return Inertia::render('Finance/Reports/CashFlowForecast', [
613+
'buckets' => $result,
614+
'openingBalance' => $openingBalance,
615+
'weeks' => $weeks,
616+
'totalInflow' => round(array_sum(array_column($result, 'total_inflow')), 2),
617+
'totalOutflow' => round(array_sum(array_column($result, 'total_outflow')), 2),
618+
]);
619+
}
620+
621+
public function exportCashFlowForecast(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
622+
{
623+
$this->authorize('viewAny', Invoice::class);
624+
625+
$weeks = (int) $request->get('weeks', 12);
626+
$openingBalance = (float) $request->get('opening_balance', 0);
627+
$from = now()->startOfDay();
628+
$to = now()->addWeeks($weeks)->endOfDay();
629+
630+
$invoices = Invoice::whereIn('status', ['sent', 'partial'])
631+
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])->get();
632+
$bills = Bill::whereIn('status', ['received', 'partial'])
633+
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])->get();
634+
635+
// Rebuild buckets same as above, minimal version for export
636+
$buckets = [];
637+
for ($i = 0; $i < $weeks; $i++) {
638+
$ws = now()->addWeeks($i)->startOfWeek()->toDateString();
639+
$we = now()->addWeeks($i)->endOfWeek()->toDateString();
640+
$buckets[$ws] = ['week_start' => $ws, 'week_end' => $we, 'inflow' => 0.0, 'outflow' => 0.0];
641+
}
642+
foreach ($invoices as $inv) {
643+
$due = $inv->due_date instanceof \Carbon\Carbon ? $inv->due_date->toDateString() : (string) $inv->due_date;
644+
foreach ($buckets as $ws => &$b) {
645+
if ($due >= $b['week_start'] && $due <= $b['week_end']) {
646+
$b['inflow'] += $inv->total - $inv->amount_paid;
647+
break;
648+
}
649+
}
650+
}
651+
foreach ($bills as $bill) {
652+
$due = $bill->due_date instanceof \Carbon\Carbon ? $bill->due_date->toDateString() : (string) $bill->due_date;
653+
foreach ($buckets as $ws => &$b) {
654+
if ($due >= $b['week_start'] && $due <= $b['week_end']) {
655+
$b['outflow'] += $bill->total - $bill->amount_paid;
656+
break;
657+
}
658+
}
659+
}
660+
661+
$balance = $openingBalance;
662+
$rows = [];
663+
foreach ($buckets as $b) {
664+
$balance += $b['inflow'] - $b['outflow'];
665+
$rows[] = [
666+
$b['week_start'],
667+
$b['week_end'],
668+
round($b['inflow'], 2),
669+
round($b['outflow'], 2),
670+
round($b['inflow'] - $b['outflow'], 2),
671+
round($balance, 2),
672+
];
673+
}
674+
675+
return $this->streamCsv(
676+
'cash-flow-forecast.csv',
677+
['Week Start', 'Week End', 'Inflows', 'Outflows', 'Net', 'Closing Balance'],
678+
$rows
679+
);
680+
}
681+
523682
// ─── CSV Export Methods ───────────────────────────────────────────────────
524683

525684
public function exportProfitLoss(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
Route::get('reports/customer-statement', [ReportController::class, 'customerStatementIndex'])->name('reports.customer-statement.index');
110110
Route::get('reports/customer-statement/{contact}', [ReportController::class, 'customerStatement'])->name('reports.customer-statement');
111111
Route::get('reports/vat-report', [ReportController::class, 'vatReport'])->name('reports.vat-report');
112+
Route::get('reports/cash-flow-forecast', [ReportController::class, 'cashFlowForecast'])->name('reports.cash-flow-forecast');
112113

113114
// CSV exports
114115
Route::get('reports/profit-loss/export', [ReportController::class, 'exportProfitLoss'])->name('reports.profit-loss.export');
@@ -117,6 +118,7 @@
117118
Route::get('reports/aged-payables/export', [ReportController::class, 'exportAgedPayables'])->name('reports.aged-payables.export');
118119
Route::get('reports/account-ledger/{account}/export', [ReportController::class, 'exportAccountLedger'])->name('reports.account-ledger.export');
119120
Route::get('reports/vat-report/export', [ReportController::class, 'exportVatReport'])->name('reports.vat-report.export');
121+
Route::get('reports/cash-flow-forecast/export', [ReportController::class, 'exportCashFlowForecast'])->name('reports.cash-flow-forecast.export');
120122

121123
// Exchange Rates
122124
Route::get('/exchange-rates', [ExchangeRateController::class, 'index'])->name('exchange-rates.index');

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const navItems: NavItem[] = [
8282
{ label: 'Account Ledger', href: '/finance/reports/account-ledger', icon: <span /> },
8383
{ label: 'Customer Statement', href: '/finance/reports/customer-statement', icon: <span /> },
8484
{ label: 'VAT Report', href: '/finance/reports/vat-report', icon: <span /> },
85+
{ label: 'Cash Flow', href: '/finance/reports/cash-flow-forecast', icon: <span /> },
8586
{ label: 'Exchange Rates', href: '/finance/exchange-rates', icon: <span /> },
8687
{ label: 'Bank Accounts', href: '/finance/bank-accounts', icon: <span /> },
8788
{ label: 'Budgets', href: '/finance/budgets', icon: <span /> },
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import AppLayout from '@/Layouts/AppLayout';
2+
import { Head, router } from '@inertiajs/react';
3+
import { useState } from 'react';
4+
5+
interface CashItem { reference: string; contact: string; due_date: string; amount: number; }
6+
interface Bucket {
7+
week_start: string; week_end: string;
8+
inflows: CashItem[]; outflows: CashItem[];
9+
total_inflow: number; total_outflow: number; net: number; closing_balance: number;
10+
}
11+
interface Props {
12+
buckets: Bucket[]; openingBalance: number; weeks: number;
13+
totalInflow: number; totalOutflow: number;
14+
}
15+
16+
export default function CashFlowForecast({ buckets, openingBalance, weeks, totalInflow, totalOutflow }: Props) {
17+
const [weeksInput, setWeeksInput] = useState(weeks);
18+
const [balanceInput, setBalanceInput] = useState(openingBalance);
19+
const fmt = (n: number) => n.toLocaleString('en-US', { minimumFractionDigits: 2 });
20+
21+
function reload() {
22+
router.get('/finance/reports/cash-flow-forecast', {
23+
weeks: weeksInput,
24+
opening_balance: balanceInput,
25+
}, { preserveState: true });
26+
}
27+
28+
return (
29+
<AppLayout>
30+
<Head title="Cash Flow Forecast" />
31+
<div className="max-w-7xl mx-auto px-4 py-8 space-y-6">
32+
<div className="flex items-center justify-between flex-wrap gap-4">
33+
<h1 className="text-2xl font-bold text-slate-800">Cash Flow Forecast</h1>
34+
<div className="flex items-center gap-3">
35+
<label className="text-sm text-slate-600">Weeks:</label>
36+
<input type="number" min={1} max={52} value={weeksInput}
37+
onChange={e => setWeeksInput(+e.target.value)}
38+
className="w-20 rounded border border-slate-300 px-2 py-1 text-sm" />
39+
<label className="text-sm text-slate-600">Opening Balance:</label>
40+
<input type="number" step="0.01" value={balanceInput}
41+
onChange={e => setBalanceInput(+e.target.value)}
42+
className="w-32 rounded border border-slate-300 px-2 py-1 text-sm" />
43+
<button onClick={reload}
44+
className="rounded bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">
45+
Refresh
46+
</button>
47+
<a href={`/finance/reports/cash-flow-forecast/export?weeks=${weeksInput}&opening_balance=${balanceInput}`}
48+
className="text-sm font-medium text-indigo-600 hover:text-indigo-800">
49+
Export CSV
50+
</a>
51+
</div>
52+
</div>
53+
54+
{/* Summary */}
55+
<div className="grid grid-cols-3 gap-4">
56+
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
57+
<p className="text-xs text-green-600 font-medium uppercase tracking-wide">Total Inflows</p>
58+
<p className="text-2xl font-bold text-green-700 mt-1">{fmt(totalInflow)}</p>
59+
</div>
60+
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
61+
<p className="text-xs text-red-600 font-medium uppercase tracking-wide">Total Outflows</p>
62+
<p className="text-2xl font-bold text-red-700 mt-1">{fmt(totalOutflow)}</p>
63+
</div>
64+
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
65+
<p className="text-xs text-slate-600 font-medium uppercase tracking-wide">Net</p>
66+
<p className={`text-2xl font-bold mt-1 ${totalInflow - totalOutflow >= 0 ? 'text-green-700' : 'text-red-700'}`}>
67+
{fmt(totalInflow - totalOutflow)}
68+
</p>
69+
</div>
70+
</div>
71+
72+
{/* Weekly table */}
73+
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
74+
<table className="w-full text-sm">
75+
<thead className="bg-slate-50">
76+
<tr>
77+
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-600 uppercase">Week</th>
78+
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-600 uppercase">Inflows</th>
79+
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-600 uppercase">Outflows</th>
80+
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-600 uppercase">Net</th>
81+
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-600 uppercase">Balance</th>
82+
</tr>
83+
</thead>
84+
<tbody className="divide-y divide-slate-100">
85+
{buckets.length === 0 && (
86+
<tr><td colSpan={5} className="px-4 py-8 text-center text-slate-400">No data in forecast horizon.</td></tr>
87+
)}
88+
{buckets.map((b) => (
89+
<tr key={b.week_start} className="hover:bg-slate-50">
90+
<td className="px-4 py-3 text-slate-700 font-medium">
91+
{b.week_start} &ndash; {b.week_end}
92+
</td>
93+
<td className="px-4 py-3 text-right text-green-700 font-medium">{fmt(b.total_inflow)}</td>
94+
<td className="px-4 py-3 text-right text-red-700 font-medium">{fmt(b.total_outflow)}</td>
95+
<td className={`px-4 py-3 text-right font-semibold ${b.net >= 0 ? 'text-green-700' : 'text-red-700'}`}>
96+
{fmt(b.net)}
97+
</td>
98+
<td className={`px-4 py-3 text-right font-bold ${b.closing_balance >= 0 ? 'text-slate-800' : 'text-red-700'}`}>
99+
{fmt(b.closing_balance)}
100+
</td>
101+
</tr>
102+
))}
103+
</tbody>
104+
</table>
105+
</div>
106+
</div>
107+
</AppLayout>
108+
);
109+
}

0 commit comments

Comments
 (0)