Skip to content

Commit ec75800

Browse files
committed
feat: Phase 10 — Credit Notes module + Customer Statement report
Wire up Credit Notes (routes, policy registration, frontend pages, status badge, types, sidebar) and add the Customer Statement report (ReportController methods, page, routes, sidebar). Adds 18 tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent cef9cc1 commit ec75800

12 files changed

Lines changed: 1042 additions & 0 deletions

File tree

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use App\Http\Controllers\Controller;
66
use App\Modules\Finance\Models\Account;
77
use App\Modules\Finance\Models\Bill;
8+
use App\Modules\Finance\Models\Contact;
9+
use App\Modules\Finance\Models\CreditNote;
810
use App\Modules\Finance\Models\Invoice;
911
use App\Modules\Finance\Models\JournalLine;
1012
use Illuminate\Http\Request;
@@ -342,6 +344,117 @@ public function accountLedger(Request $request, Account $account): Response
342344
]);
343345
}
344346

347+
public function customerStatementIndex(Request $request): Response
348+
{
349+
$this->authorize('viewAny', Account::class);
350+
351+
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']],
358+
]);
359+
}
360+
361+
public function customerStatement(Request $request, Contact $contact): Response
362+
{
363+
$this->authorize('viewAny', Account::class);
364+
365+
$from = $request->from ?? now()->startOfYear()->toDateString();
366+
$to = $request->to ?? now()->toDateString();
367+
368+
$transactions = [];
369+
370+
$invoices = Invoice::with(['items', 'payments'])
371+
->where('contact_id', $contact->id)
372+
->where('status', '!=', 'cancelled')
373+
->whereDate('issue_date', '>=', $from)
374+
->whereDate('issue_date', '<=', $to)
375+
->get();
376+
377+
foreach ($invoices as $invoice) {
378+
$transactions[] = [
379+
'date' => $invoice->issue_date?->toDateString(),
380+
'type' => 'Invoice',
381+
'reference' => $invoice->number,
382+
'debit' => (float) $invoice->total,
383+
'credit' => 0.0,
384+
];
385+
}
386+
387+
$paymentInvoices = Invoice::with('payments')
388+
->where('contact_id', $contact->id)
389+
->where('status', '!=', 'cancelled')
390+
->get();
391+
392+
foreach ($paymentInvoices as $invoice) {
393+
foreach ($invoice->payments as $payment) {
394+
$paymentDate = $payment->payment_date instanceof \Carbon\Carbon
395+
? $payment->payment_date->toDateString()
396+
: (string) $payment->payment_date;
397+
398+
if ($paymentDate < $from || $paymentDate > $to) {
399+
continue;
400+
}
401+
402+
$transactions[] = [
403+
'date' => $paymentDate,
404+
'type' => 'Payment',
405+
'reference' => $invoice->number,
406+
'debit' => 0.0,
407+
'credit' => (float) $payment->amount,
408+
];
409+
}
410+
}
411+
412+
$creditNotes = CreditNote::with('items')
413+
->where('contact_id', $contact->id)
414+
->whereIn('status', ['issued', 'applied'])
415+
->whereDate('issue_date', '>=', $from)
416+
->whereDate('issue_date', '<=', $to)
417+
->get();
418+
419+
foreach ($creditNotes as $creditNote) {
420+
$transactions[] = [
421+
'date' => $creditNote->issue_date?->toDateString(),
422+
'type' => 'Credit Note',
423+
'reference' => $creditNote->number,
424+
'debit' => 0.0,
425+
'credit' => (float) $creditNote->total,
426+
];
427+
}
428+
429+
usort($transactions, fn ($a, $b) => strcmp((string) $a['date'], (string) $b['date']));
430+
431+
$balance = 0.0;
432+
$totalDebit = 0.0;
433+
$totalCredit = 0.0;
434+
$rows = [];
435+
foreach ($transactions as $txn) {
436+
$balance += $txn['debit'] - $txn['credit'];
437+
$totalDebit += $txn['debit'];
438+
$totalCredit += $txn['credit'];
439+
$rows[] = array_merge($txn, ['balance' => $balance]);
440+
}
441+
442+
return Inertia::render('Finance/Reports/CustomerStatement', [
443+
'contacts' => Contact::customers()->orderBy('name')->get(['id', 'name']),
444+
'contact' => ['id' => $contact->id, 'name' => $contact->name],
445+
'rows' => $rows,
446+
'from' => $from,
447+
'to' => $to,
448+
'total_debit' => $totalDebit,
449+
'total_credit' => $totalCredit,
450+
'closing_balance' => $balance,
451+
'breadcrumbs' => [
452+
['label' => 'Finance'], ['label' => 'Reports'],
453+
['label' => "Statement: {$contact->name}"],
454+
],
455+
]);
456+
}
457+
345458
private function aggregateJournalLines(?string $from = null, ?string $to = null): \Illuminate\Support\Collection
346459
{
347460
return JournalLine::select('account_id',

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
use App\Modules\Finance\Models\Account;
66
use App\Modules\Finance\Models\Bill;
77
use App\Modules\Finance\Models\Contact;
8+
use App\Modules\Finance\Models\CreditNote;
89
use App\Modules\Finance\Models\Invoice;
910
use App\Modules\Finance\Models\JournalEntry;
1011
use App\Modules\Finance\Models\Quote;
1112
use App\Modules\Finance\Policies\AccountPolicy;
1213
use App\Modules\Finance\Policies\BillPolicy;
1314
use App\Modules\Finance\Policies\ContactPolicy;
15+
use App\Modules\Finance\Policies\CreditNotePolicy;
1416
use App\Modules\Finance\Policies\InvoicePolicy;
1517
use App\Modules\Finance\Policies\JournalEntryPolicy;
1618
use App\Modules\Finance\Policies\QuotePolicy;
@@ -31,5 +33,6 @@ public function boot(): void
3133
Gate::policy(Invoice::class, InvoicePolicy::class);
3234
Gate::policy(Bill::class, BillPolicy::class);
3335
Gate::policy(Quote::class, QuotePolicy::class);
36+
Gate::policy(CreditNote::class, CreditNotePolicy::class);
3437
}
3538
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use App\Modules\Finance\Http\Controllers\AccountController;
44
use App\Modules\Finance\Http\Controllers\BillController;
55
use App\Modules\Finance\Http\Controllers\ContactController;
6+
use App\Modules\Finance\Http\Controllers\CreditNoteController;
67
use App\Modules\Finance\Http\Controllers\InvoiceController;
78
use App\Modules\Finance\Http\Controllers\JournalEntryController;
89
use App\Modules\Finance\Http\Controllers\QuoteController;
@@ -49,6 +50,16 @@
4950
Route::post('quotes/{quote}/convert', [QuoteController::class, 'convertToInvoice'])->name('quotes.convert');
5051
Route::delete('quotes/{quote}', [QuoteController::class, 'destroy'])->name('quotes.destroy');
5152

53+
// Credit Notes
54+
Route::get('credit-notes', [CreditNoteController::class, 'index'])->name('credit-notes.index');
55+
Route::get('credit-notes/create', [CreditNoteController::class, 'create'])->name('credit-notes.create');
56+
Route::post('credit-notes', [CreditNoteController::class, 'store'])->name('credit-notes.store');
57+
Route::get('credit-notes/{creditNote}', [CreditNoteController::class, 'show'])->name('credit-notes.show');
58+
Route::patch('credit-notes/{creditNote}/issue', [CreditNoteController::class, 'issue'])->name('credit-notes.issue');
59+
Route::patch('credit-notes/{creditNote}/apply', [CreditNoteController::class, 'apply'])->name('credit-notes.apply');
60+
Route::patch('credit-notes/{creditNote}/cancel', [CreditNoteController::class, 'cancel'])->name('credit-notes.cancel');
61+
Route::delete('credit-notes/{creditNote}', [CreditNoteController::class, 'destroy'])->name('credit-notes.destroy');
62+
5263
// Reports
5364
Route::get('reports/trial-balance', [ReportController::class, 'trialBalance'])
5465
->name('reports.trial-balance');
@@ -60,4 +71,6 @@
6071
Route::get('reports/aged-payables', [ReportController::class, 'agedPayables'])->name('reports.aged-payables');
6172
Route::get('reports/account-ledger', [ReportController::class, 'accountLedgerIndex'])->name('reports.account-ledger.index');
6273
Route::get('reports/account-ledger/{account}', [ReportController::class, 'accountLedger'])->name('reports.account-ledger');
74+
Route::get('reports/customer-statement', [ReportController::class, 'customerStatementIndex'])->name('reports.customer-statement.index');
75+
Route::get('reports/customer-statement/{contact}', [ReportController::class, 'customerStatement'])->name('reports.customer-statement');
6376
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { CreditNoteStatus } from '@/types/finance';
2+
3+
const COLORS: Record<CreditNoteStatus, string> = {
4+
draft: 'bg-slate-100 text-slate-600',
5+
issued: 'bg-blue-100 text-blue-700',
6+
applied: 'bg-green-100 text-green-700',
7+
cancelled: 'bg-red-100 text-red-700',
8+
};
9+
10+
export function CreditNoteStatusBadge({ status }: { status: CreditNoteStatus }) {
11+
return (
12+
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${COLORS[status] ?? 'bg-slate-100 text-slate-600'}`}>
13+
{status.charAt(0).toUpperCase() + status.slice(1)}
14+
</span>
15+
);
16+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const navItems: NavItem[] = [
6363
children: [
6464
{ label: 'Invoices', href: '/finance/invoices', icon: <span /> },
6565
{ label: 'Quotes', href: '/finance/quotes', icon: <span /> },
66+
{ label: 'Credit Notes', href: '/finance/credit-notes', icon: <span /> },
6667
{ label: 'Contacts', href: '/finance/contacts', icon: <span /> },
6768
{ label: 'Journal Entries', href: '/finance/journal-entries', icon: <span /> },
6869
{ label: 'Chart of Accounts', href: '/finance/accounts', icon: <span /> },
@@ -73,6 +74,7 @@ const navItems: NavItem[] = [
7374
{ label: 'Aged Receivables', href: '/finance/reports/aged-receivables', icon: <span /> },
7475
{ label: 'Aged Payables', href: '/finance/reports/aged-payables', icon: <span /> },
7576
{ label: 'Account Ledger', href: '/finance/reports/account-ledger', icon: <span /> },
77+
{ label: 'Customer Statement', href: '/finance/reports/customer-statement', icon: <span /> },
7678
],
7779
},
7880
{

0 commit comments

Comments
 (0)