Skip to content

Commit f6cb966

Browse files
committed
feat: Phase 42 — Customer Statements with PDF export
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent abc465a commit f6cb966

4 files changed

Lines changed: 446 additions & 52 deletions

File tree

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

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -348,13 +348,90 @@ public function customerStatementIndex(Request $request): Response
348348
{
349349
$this->authorize('viewAny', Account::class);
350350

351+
$contactId = $request->get('contact_id');
352+
$from = $request->get('from', now()->startOfMonth()->toDateString());
353+
$to = $request->get('to', now()->toDateString());
354+
355+
$contacts = Contact::customers()->orderBy('name')->get(['id', 'name', 'email']);
356+
357+
if (!$contactId) {
358+
return Inertia::render('Finance/Reports/CustomerStatement', [
359+
'contacts' => $contacts,
360+
'contact' => null,
361+
'lines' => [],
362+
'summary' => null,
363+
'from' => $from,
364+
'to' => $to,
365+
'breadcrumbs' => [['label' => 'Finance'], ['label' => 'Reports'], ['label' => 'Customer Statement']],
366+
]);
367+
}
368+
369+
$contact = Contact::findOrFail($contactId);
370+
371+
// Opening balance: sum of (total - amount_paid) for invoices before $from
372+
$priorInvoices = Invoice::with(['items', 'payments'])
373+
->where('contact_id', $contactId)
374+
->where('issue_date', '<', $from)
375+
->whereNotIn('status', ['draft', 'cancelled'])
376+
->get();
377+
378+
$openingBalance = (float) $priorInvoices->sum(fn ($inv) => $inv->total - $inv->amount_paid);
379+
380+
// All invoices within date range
381+
$invoices = Invoice::with(['items', 'payments'])
382+
->where('contact_id', $contactId)
383+
->whereBetween('issue_date', [$from, $to])
384+
->whereNotIn('status', ['draft', 'cancelled'])
385+
->orderBy('issue_date')
386+
->get();
387+
388+
$lines = [];
389+
$balance = $openingBalance;
390+
391+
foreach ($invoices as $inv) {
392+
$balance += $inv->total;
393+
$lines[] = [
394+
'date' => $inv->issue_date instanceof \Carbon\Carbon ? $inv->issue_date->toDateString() : (string) $inv->issue_date,
395+
'type' => 'Invoice',
396+
'reference' => $inv->number,
397+
'debit' => $inv->total,
398+
'credit' => 0,
399+
'balance' => round($balance, 2),
400+
'status' => $inv->status,
401+
];
402+
403+
if ($inv->amount_paid > 0) {
404+
$balance -= $inv->amount_paid;
405+
$lines[] = [
406+
'date' => $inv->issue_date instanceof \Carbon\Carbon ? $inv->issue_date->toDateString() : (string) $inv->issue_date,
407+
'type' => 'Payment',
408+
'reference' => 'PMT-' . $inv->number,
409+
'debit' => 0,
410+
'credit' => $inv->amount_paid,
411+
'balance' => round($balance, 2),
412+
'status' => '',
413+
];
414+
}
415+
}
416+
417+
$summary = [
418+
'opening_balance' => round($openingBalance, 2),
419+
'total_invoiced' => round($invoices->sum('total'), 2),
420+
'total_paid' => round($invoices->sum('amount_paid'), 2),
421+
'closing_balance' => round($balance, 2),
422+
];
423+
351424
return Inertia::render('Finance/Reports/CustomerStatement', [
352-
'contacts' => Contact::customers()->orderBy('name')->get(['id', 'name']),
353-
'contact' => null,
354-
'rows' => [],
355-
'from' => now()->startOfYear()->toDateString(),
356-
'to' => now()->toDateString(),
357-
'breadcrumbs' => [['label' => 'Finance'], ['label' => 'Reports'], ['label' => 'Customer Statement']],
425+
'contacts' => $contacts,
426+
'contact' => $contact,
427+
'lines' => $lines,
428+
'summary' => $summary,
429+
'from' => $from,
430+
'to' => $to,
431+
'breadcrumbs' => [
432+
['label' => 'Finance'], ['label' => 'Reports'],
433+
['label' => "Statement: {$contact->name}"],
434+
],
358435
]);
359436
}
360437

@@ -455,6 +532,54 @@ public function customerStatement(Request $request, Contact $contact): Response
455532
]);
456533
}
457534

535+
public function exportCustomerStatement(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse
536+
{
537+
$this->authorize('viewAny', Invoice::class);
538+
539+
$contactId = $request->get('contact_id');
540+
$from = $request->get('from', now()->startOfMonth()->toDateString());
541+
$to = $request->get('to', now()->toDateString());
542+
543+
abort_unless($contactId, 422, 'contact_id is required.');
544+
$contact = Contact::findOrFail($contactId);
545+
546+
$priorInvoices = Invoice::with(['items', 'payments'])
547+
->where('contact_id', $contactId)
548+
->where('issue_date', '<', $from)
549+
->whereNotIn('status', ['draft', 'cancelled'])
550+
->get();
551+
552+
$openingBalance = (float) $priorInvoices->sum(fn ($inv) => $inv->total - $inv->amount_paid);
553+
554+
$invoices = Invoice::with(['items', 'payments'])
555+
->where('contact_id', $contactId)
556+
->whereBetween('issue_date', [$from, $to])
557+
->whereNotIn('status', ['draft', 'cancelled'])
558+
->orderBy('issue_date')
559+
->get();
560+
561+
$balance = $openingBalance;
562+
$rows = [['Opening Balance', '', '', '', '', round($balance, 2)]];
563+
564+
foreach ($invoices as $inv) {
565+
$balance += $inv->total;
566+
$issueDate = $inv->issue_date instanceof \Carbon\Carbon ? $inv->issue_date->toDateString() : (string) $inv->issue_date;
567+
$rows[] = [$issueDate, 'Invoice', $inv->number, $inv->total, 0, round($balance, 2)];
568+
if ($inv->amount_paid > 0) {
569+
$balance -= $inv->amount_paid;
570+
$rows[] = [$issueDate, 'Payment', 'PMT-' . $inv->number, 0, $inv->amount_paid, round($balance, 2)];
571+
}
572+
}
573+
574+
$filename = "statement-{$contact->name}-{$from}-{$to}.csv";
575+
576+
return $this->streamCsv(
577+
$filename,
578+
['Date', 'Type', 'Reference', 'Debit', 'Credit', 'Balance'],
579+
$rows
580+
);
581+
}
582+
458583
public function vatReport(Request $request): Response
459584
{
460585
$this->authorize('viewAny', Invoice::class);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
Route::get('reports/account-ledger', [ReportController::class, 'accountLedgerIndex'])->name('reports.account-ledger.index');
113113
Route::get('reports/account-ledger/{account}', [ReportController::class, 'accountLedger'])->name('reports.account-ledger');
114114
Route::get('reports/customer-statement', [ReportController::class, 'customerStatementIndex'])->name('reports.customer-statement.index');
115+
Route::get('reports/customer-statement/export', [ReportController::class, 'exportCustomerStatement'])->name('reports.customer-statement.export');
115116
Route::get('reports/customer-statement/{contact}', [ReportController::class, 'customerStatement'])->name('reports.customer-statement');
116117
Route::get('reports/vat-report', [ReportController::class, 'vatReport'])->name('reports.vat-report');
117118
Route::get('reports/cash-flow-forecast', [ReportController::class, 'cashFlowForecast'])->name('reports.cash-flow-forecast');

0 commit comments

Comments
 (0)