Skip to content

Commit ec86ca7

Browse files
committed
feat: Phase 21 — CSV exports for all financial reports
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 9ba6746 commit ec86ca7

9 files changed

Lines changed: 454 additions & 0 deletions

File tree

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

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

523+
// ─── CSV Export Methods ───────────────────────────────────────────────────
524+
525+
public function exportProfitLoss(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
526+
{
527+
$this->authorize('viewAny', Account::class);
528+
529+
$from = $request->from ?? now()->startOfYear()->toDateString();
530+
$to = $request->to ?? now()->toDateString();
531+
532+
$totals = $this->aggregateJournalLines($from, $to);
533+
$accounts = Account::whereIn('type', ['income', 'expense'])->orderBy('code')->get();
534+
535+
$revenue = [];
536+
$expenses = [];
537+
538+
foreach ($accounts as $account) {
539+
$row = $totals->get($account->id);
540+
$debit = (float) ($row?->total_debit ?? 0);
541+
$credit = (float) ($row?->total_credit ?? 0);
542+
$net = $account->type === 'income' ? $credit - $debit : $debit - $credit;
543+
544+
$entry = ['type' => $account->type === 'income' ? 'Revenue' : 'Expense', 'name' => $account->name, 'net' => $net];
545+
546+
if ($account->type === 'income') {
547+
$revenue[] = $entry;
548+
} else {
549+
$expenses[] = $entry;
550+
}
551+
}
552+
553+
$totalRevenue = array_sum(array_column($revenue, 'net'));
554+
$totalExpenses = array_sum(array_column($expenses, 'net'));
555+
556+
$rows = [];
557+
foreach ($revenue as $r) { $rows[] = [$r['type'], $r['name'], number_format($r['net'], 2, '.', '')]; }
558+
foreach ($expenses as $r) { $rows[] = [$r['type'], $r['name'], number_format($r['net'], 2, '.', '')]; }
559+
$rows[] = ['Net', 'Net Profit / Loss', number_format($totalRevenue - $totalExpenses, 2, '.', '')];
560+
561+
return $this->streamCsv(
562+
"profit-loss-{$from}-{$to}.csv",
563+
['Type', 'Account', 'Amount'],
564+
$rows
565+
);
566+
}
567+
568+
public function exportBalanceSheet(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
569+
{
570+
$this->authorize('viewAny', Account::class);
571+
572+
$asOf = $request->as_of ?? now()->toDateString();
573+
$totals = $this->aggregateJournalLines(null, $asOf);
574+
575+
$accounts = Account::whereIn('type', ['asset', 'liability', 'equity'])->orderBy('code')->get();
576+
577+
$rows = [];
578+
foreach ($accounts as $account) {
579+
$row = $totals->get($account->id);
580+
$debit = (float) ($row?->total_debit ?? 0);
581+
$credit = (float) ($row?->total_credit ?? 0);
582+
$net = $account->type === 'asset' ? $debit - $credit : $credit - $debit;
583+
584+
$section = ucfirst($account->type);
585+
$rows[] = [$section, $account->name, number_format($net, 2, '.', '')];
586+
}
587+
588+
return $this->streamCsv(
589+
"balance-sheet-{$asOf}.csv",
590+
['Section', 'Account', 'Balance'],
591+
$rows
592+
);
593+
}
594+
595+
public function exportAgedReceivables(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
596+
{
597+
$this->authorize('viewAny', Account::class);
598+
599+
$asOf = $request->as_of ?? now()->toDateString();
600+
601+
$invoices = Invoice::with(['contact', 'items', 'payments'])
602+
->whereNotIn('status', ['paid', 'cancelled'])
603+
->get();
604+
605+
$rows = [];
606+
foreach ($invoices as $inv) {
607+
$daysOverdue = 0;
608+
if ($inv->due_date) {
609+
$diff = \Carbon\Carbon::parse($asOf)->diffInDays($inv->due_date, false);
610+
$daysOverdue = (int) max(0, $diff * -1);
611+
}
612+
$bucket = match (true) {
613+
$daysOverdue === 0 => 'current',
614+
$daysOverdue <= 30 => '1-30',
615+
$daysOverdue <= 60 => '31-60',
616+
$daysOverdue <= 90 => '61-90',
617+
default => '90+',
618+
};
619+
$amountDue = (float) $inv->amount_due;
620+
$rows[] = [
621+
$inv->contact?->name ?? '',
622+
$inv->number ?? '',
623+
$inv->issue_date?->toDateString() ?? '',
624+
$inv->due_date?->toDateString() ?? '',
625+
$bucket === 'current' ? number_format($amountDue, 2, '.', '') : '0.00',
626+
$bucket === '1-30' ? number_format($amountDue, 2, '.', '') : '0.00',
627+
$bucket === '31-60' ? number_format($amountDue, 2, '.', '') : '0.00',
628+
$bucket === '61-90' ? number_format($amountDue, 2, '.', '') : '0.00',
629+
$bucket === '90+' ? number_format($amountDue, 2, '.', '') : '0.00',
630+
number_format($amountDue, 2, '.', ''),
631+
];
632+
}
633+
634+
return $this->streamCsv(
635+
"aged-receivables-{$asOf}.csv",
636+
['Customer', 'Invoice #', 'Issue Date', 'Due Date', 'Current', '1-30', '31-60', '61-90', '90+', 'Total'],
637+
$rows
638+
);
639+
}
640+
641+
public function exportAgedPayables(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
642+
{
643+
$this->authorize('viewAny', Account::class);
644+
645+
$asOf = $request->as_of ?? now()->toDateString();
646+
647+
$bills = Bill::with(['contact', 'items', 'payments'])
648+
->whereNotIn('status', ['paid', 'cancelled'])
649+
->get();
650+
651+
$rows = [];
652+
foreach ($bills as $bill) {
653+
$daysOverdue = 0;
654+
if ($bill->due_date) {
655+
$diff = \Carbon\Carbon::parse($asOf)->diffInDays($bill->due_date, false);
656+
$daysOverdue = (int) max(0, $diff * -1);
657+
}
658+
$bucket = match (true) {
659+
$daysOverdue === 0 => 'current',
660+
$daysOverdue <= 30 => '1-30',
661+
$daysOverdue <= 60 => '31-60',
662+
$daysOverdue <= 90 => '61-90',
663+
default => '90+',
664+
};
665+
$amountDue = (float) $bill->amount_due;
666+
$rows[] = [
667+
$bill->contact?->name ?? '',
668+
$bill->number ?? '',
669+
$bill->issue_date?->toDateString() ?? '',
670+
$bill->due_date?->toDateString() ?? '',
671+
$bucket === 'current' ? number_format($amountDue, 2, '.', '') : '0.00',
672+
$bucket === '1-30' ? number_format($amountDue, 2, '.', '') : '0.00',
673+
$bucket === '31-60' ? number_format($amountDue, 2, '.', '') : '0.00',
674+
$bucket === '61-90' ? number_format($amountDue, 2, '.', '') : '0.00',
675+
$bucket === '90+' ? number_format($amountDue, 2, '.', '') : '0.00',
676+
number_format($amountDue, 2, '.', ''),
677+
];
678+
}
679+
680+
return $this->streamCsv(
681+
"aged-payables-{$asOf}.csv",
682+
['Vendor', 'Bill #', 'Issue Date', 'Due Date', 'Current', '1-30', '31-60', '61-90', '90+', 'Total'],
683+
$rows
684+
);
685+
}
686+
687+
public function exportAccountLedger(Request $request, Account $account): \Symfony\Component\HttpFoundation\StreamedResponse
688+
{
689+
$this->authorize('viewAny', Account::class);
690+
691+
$from = $request->from ?? now()->startOfYear()->toDateString();
692+
$to = $request->to ?? now()->toDateString();
693+
694+
$lines = JournalLine::join('journal_entries', 'journal_entries.id', '=', 'journal_lines.journal_entry_id')
695+
->where('journal_lines.account_id', $account->id)
696+
->where('journal_entries.status', 'posted')
697+
->when($from, fn ($q) => $q->whereDate('journal_entries.date', '>=', $from))
698+
->when($to, fn ($q) => $q->whereDate('journal_entries.date', '<=', $to))
699+
->orderBy('journal_entries.date')
700+
->orderBy('journal_lines.id')
701+
->select('journal_lines.*', 'journal_entries.date as entry_date',
702+
'journal_entries.reference as entry_reference',
703+
'journal_entries.description as entry_description')
704+
->get();
705+
706+
$isDebitNormal = in_array($account->type, ['asset', 'expense'], true);
707+
$runningBalance = 0.0;
708+
$rows = [];
709+
710+
foreach ($lines as $line) {
711+
$debit = (float) $line->debit;
712+
$credit = (float) $line->credit;
713+
$runningBalance += $isDebitNormal ? ($debit - $credit) : ($credit - $debit);
714+
$description = $line->description ?? $line->entry_description;
715+
$rows[] = [
716+
$line->entry_date instanceof \Carbon\Carbon
717+
? $line->entry_date->toDateString()
718+
: (string) $line->entry_date,
719+
$description ?? '',
720+
number_format($debit, 2, '.', ''),
721+
number_format($credit, 2, '.', ''),
722+
number_format($runningBalance, 2, '.', ''),
723+
];
724+
}
725+
726+
return $this->streamCsv(
727+
"ledger-{$account->code}-{$from}-{$to}.csv",
728+
['Date', 'Description', 'Debit', 'Credit', 'Balance'],
729+
$rows
730+
);
731+
}
732+
733+
public function exportVatReport(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
734+
{
735+
$this->authorize('viewAny', Invoice::class);
736+
737+
$tenantId = $request->user()->tenant_id;
738+
$from = $request->query('from', now()->startOfQuarter()->toDateString());
739+
$to = $request->query('to', now()->endOfQuarter()->toDateString());
740+
741+
$invoices = Invoice::where('tenant_id', $tenantId)
742+
->whereNotIn('status', ['cancelled'])
743+
->whereBetween('issue_date', [$from, $to])
744+
->with('items')
745+
->get();
746+
747+
$outputLines = $invoices->map(function ($invoice) {
748+
return [
749+
'number' => $invoice->number,
750+
'date' => $invoice->issue_date,
751+
'contact' => $invoice->contact?->name,
752+
'net' => round((float) $invoice->subtotal, 2),
753+
'tax' => round((float) $invoice->tax_total, 2),
754+
'type' => 'Output',
755+
];
756+
})->filter(fn ($l) => $l['tax'] != 0)->values();
757+
758+
$bills = Bill::where('tenant_id', $tenantId)
759+
->whereNotIn('status', ['cancelled'])
760+
->whereBetween('issue_date', [$from, $to])
761+
->with('items')
762+
->get();
763+
764+
$inputLines = $bills->map(function ($bill) {
765+
return [
766+
'number' => $bill->number,
767+
'date' => $bill->issue_date,
768+
'contact' => $bill->contact?->name,
769+
'net' => round((float) $bill->subtotal, 2),
770+
'tax' => round((float) $bill->tax_total, 2),
771+
'type' => 'Input',
772+
];
773+
})->filter(fn ($l) => $l['tax'] != 0)->values();
774+
775+
$totalOutputVat = round($outputLines->sum('tax'), 2);
776+
$totalInputVat = round($inputLines->sum('tax'), 2);
777+
$netVat = round($totalOutputVat - $totalInputVat, 2);
778+
779+
$rows = [];
780+
foreach ($outputLines as $line) {
781+
$date = $line['date'] instanceof \Carbon\Carbon ? $line['date']->toDateString() : (string) $line['date'];
782+
$rows[] = [$line['type'], $line['number'] ?? '', $date, $line['contact'] ?? '', number_format($line['net'], 2, '.', ''), number_format($line['tax'], 2, '.', '')];
783+
}
784+
$rows[] = ['', '', '', '', '', ''];
785+
foreach ($inputLines as $line) {
786+
$date = $line['date'] instanceof \Carbon\Carbon ? $line['date']->toDateString() : (string) $line['date'];
787+
$rows[] = [$line['type'], $line['number'] ?? '', $date, $line['contact'] ?? '', number_format($line['net'], 2, '.', ''), number_format($line['tax'], 2, '.', '')];
788+
}
789+
$rows[] = ['', '', '', '', '', ''];
790+
$rows[] = ['Total Output VAT', '', '', '', '', number_format($totalOutputVat, 2, '.', '')];
791+
$rows[] = ['Total Input VAT', '', '', '', '', number_format($totalInputVat, 2, '.', '')];
792+
$rows[] = ['Net VAT', '', '', '', '', number_format($netVat, 2, '.', '')];
793+
794+
return $this->streamCsv(
795+
"vat-report-{$from}-{$to}.csv",
796+
['Type', 'Document #', 'Date', 'Contact', 'Net', 'Tax'],
797+
$rows
798+
);
799+
}
800+
801+
// ─── Private Helpers ─────────────────────────────────────────────────────
802+
803+
private function streamCsv(string $filename, array $headers, iterable $rows): \Symfony\Component\HttpFoundation\StreamedResponse
804+
{
805+
return response()->streamDownload(function () use ($headers, $rows) {
806+
$handle = fopen('php://output', 'w');
807+
fputcsv($handle, $headers);
808+
foreach ($rows as $row) {
809+
fputcsv($handle, $row);
810+
}
811+
fclose($handle);
812+
}, $filename, [
813+
'Content-Type' => 'text/csv; charset=UTF-8',
814+
]);
815+
}
816+
523817
private function aggregateJournalLines(?string $from = null, ?string $to = null): \Illuminate\Support\Collection
524818
{
525819
return JournalLine::select('account_id',

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@
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');
112112

113+
// CSV exports
114+
Route::get('reports/profit-loss/export', [ReportController::class, 'exportProfitLoss'])->name('reports.profit-loss.export');
115+
Route::get('reports/balance-sheet/export', [ReportController::class, 'exportBalanceSheet'])->name('reports.balance-sheet.export');
116+
Route::get('reports/aged-receivables/export', [ReportController::class, 'exportAgedReceivables'])->name('reports.aged-receivables.export');
117+
Route::get('reports/aged-payables/export', [ReportController::class, 'exportAgedPayables'])->name('reports.aged-payables.export');
118+
Route::get('reports/account-ledger/{account}/export', [ReportController::class, 'exportAccountLedger'])->name('reports.account-ledger.export');
119+
Route::get('reports/vat-report/export', [ReportController::class, 'exportVatReport'])->name('reports.vat-report.export');
120+
113121
// Exchange Rates
114122
Route::get('/exchange-rates', [ExchangeRateController::class, 'index'])->name('exchange-rates.index');
115123
Route::post('/exchange-rates', [ExchangeRateController::class, 'store'])->name('exchange-rates.store');

erp/resources/js/Pages/Finance/Reports/AccountLedger.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ export default function AccountLedger({ accounts, account, rows, from, to }: Pro
6161
className="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">
6262
Apply
6363
</button>
64+
<a
65+
href={`/finance/reports/account-ledger/${account.id}/export?from=${fromDate}&to=${toDate}`}
66+
className="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-slate-50"
67+
>
68+
Export CSV
69+
</a>
6470
</>
6571
)}
6672
</div>

erp/resources/js/Pages/Finance/Reports/AgedPayables.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export default function AgedPayables({ rows, totals, grand_total, as_of }: Props
4444
className="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">
4545
Apply
4646
</button>
47+
<a
48+
href={`/finance/reports/aged-payables/export?as_of=${asOf}`}
49+
className="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-slate-50"
50+
>
51+
Export CSV
52+
</a>
4753
</div>
4854
</div>
4955

erp/resources/js/Pages/Finance/Reports/AgedReceivables.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export default function AgedReceivables({ rows, totals, grand_total, as_of }: Pr
4444
className="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">
4545
Apply
4646
</button>
47+
<a
48+
href={`/finance/reports/aged-receivables/export?as_of=${asOf}`}
49+
className="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-slate-50"
50+
>
51+
Export CSV
52+
</a>
4753
</div>
4854
</div>
4955

erp/resources/js/Pages/Finance/Reports/BalanceSheet.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ export default function BalanceSheet({ assets, liabilities, equity, total_assets
9696
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
9797
</div>
9898
<button type="submit" className="rounded-md bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">Apply</button>
99+
<a
100+
href={`/finance/reports/balance-sheet/export?as_of=${asOf}`}
101+
className="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-slate-50"
102+
>
103+
Export CSV
104+
</a>
99105
</form>
100106

101107
<AccountSection title="Assets" rows={assets} total={total_assets} colorClass="bg-blue-50 text-blue-800" />

erp/resources/js/Pages/Finance/Reports/ProfitLoss.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ export default function ProfitLoss({ revenue, expenses, total_revenue, total_exp
5353
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
5454
</div>
5555
<button type="submit" className="rounded-md bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">Apply</button>
56+
<a
57+
href={`/finance/reports/profit-loss/export?from=${dateFrom}&to=${dateTo}`}
58+
className="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-slate-50"
59+
>
60+
Export CSV
61+
</a>
5662
</form>
5763

5864
{/* Revenue section */}

erp/resources/js/Pages/Finance/Reports/VatReport.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ export default function VatReport({ output_lines, input_lines, total_output_vat,
105105
className="rounded-md bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50">
106106
Apply
107107
</button>
108+
<a
109+
href={`/finance/reports/vat-report/export?from=${data.from}&to=${data.to}`}
110+
className="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-slate-50"
111+
>
112+
Export CSV
113+
</a>
108114
</form>
109115

110116
{/* Summary cards */}

0 commit comments

Comments
 (0)