Skip to content

Commit e191fa0

Browse files
committed
feat: Phase 35 — Aged Receivables and Payables reports
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 66c543c commit e191fa0

3 files changed

Lines changed: 216 additions & 5 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ public function agedReceivables(Request $request): Response
182182
$asOf = $request->as_of ?? now()->toDateString();
183183

184184
$invoices = Invoice::with(['contact', 'items', 'payments'])
185-
->whereNotIn('status', ['paid', 'cancelled'])
185+
->whereNotIn('status', ['draft', 'paid', 'cancelled'])
186186
->get()
187187
->map(function ($inv) use ($asOf) {
188188
$daysOverdue = 0;
@@ -232,7 +232,7 @@ public function agedPayables(Request $request): Response
232232
$asOf = $request->as_of ?? now()->toDateString();
233233

234234
$bills = Bill::with(['contact', 'items', 'payments'])
235-
->whereNotIn('status', ['paid', 'cancelled'])
235+
->whereNotIn('status', ['draft', 'paid', 'cancelled'])
236236
->get()
237237
->map(function ($bill) use ($asOf) {
238238
$daysOverdue = 0;
@@ -599,7 +599,7 @@ public function exportAgedReceivables(Request $request): \Symfony\Component\Http
599599
$asOf = $request->as_of ?? now()->toDateString();
600600

601601
$invoices = Invoice::with(['contact', 'items', 'payments'])
602-
->whereNotIn('status', ['paid', 'cancelled'])
602+
->whereNotIn('status', ['draft', 'paid', 'cancelled'])
603603
->get();
604604

605605
$rows = [];
@@ -645,7 +645,7 @@ public function exportAgedPayables(Request $request): \Symfony\Component\HttpFou
645645
$asOf = $request->as_of ?? now()->toDateString();
646646

647647
$bills = Bill::with(['contact', 'items', 'payments'])
648-
->whereNotIn('status', ['paid', 'cancelled'])
648+
->whereNotIn('status', ['draft', 'paid', 'cancelled'])
649649
->get();
650650

651651
$rows = [];
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\Bill;
6+
use App\Modules\Finance\Models\BillItem;
7+
use App\Modules\Finance\Models\Contact;
8+
use App\Modules\Finance\Models\Invoice;
9+
use App\Modules\Finance\Models\InvoiceItem;
10+
use Carbon\Carbon;
11+
use Database\Seeders\RolePermissionSeeder;
12+
13+
beforeEach(function () {
14+
$this->seed(RolePermissionSeeder::class);
15+
$this->tenant = Tenant::create(['name' => 'Aged Co', 'slug' => 'aged-co']);
16+
$this->admin = User::factory()->create(['tenant_id' => $this->tenant->id]);
17+
$this->admin->assignRole('super-admin');
18+
$this->actingAs($this->admin);
19+
app()->instance('tenant', $this->tenant);
20+
21+
$this->contact = Contact::create([
22+
'tenant_id' => $this->tenant->id,
23+
'name' => 'Test Customer',
24+
'type' => 'customer',
25+
]);
26+
});
27+
28+
test('aged receivables page loads', function () {
29+
$this->get('/finance/reports/aged-receivables')
30+
->assertStatus(200)
31+
->assertInertia(fn ($p) => $p->component('Finance/Reports/AgedReceivables'));
32+
});
33+
34+
test('aged payables page loads', function () {
35+
$this->get('/finance/reports/aged-payables')
36+
->assertStatus(200)
37+
->assertInertia(fn ($p) => $p->component('Finance/Reports/AgedPayables'));
38+
});
39+
40+
test('overdue invoice appears in correct bucket', function () {
41+
Carbon::setTestNow('2026-06-02');
42+
43+
$invoice = Invoice::create([
44+
'tenant_id' => $this->tenant->id,
45+
'contact_id' => $this->contact->id,
46+
'issue_date' => '2026-04-01',
47+
'due_date' => '2026-04-18',
48+
'status' => 'sent',
49+
]);
50+
InvoiceItem::create([
51+
'invoice_id' => $invoice->id,
52+
'description' => 'Service',
53+
'quantity' => 1,
54+
'unit_price' => 500,
55+
'tax_rate' => 0,
56+
]);
57+
58+
// due_date 2026-04-18, as_of 2026-06-02 → 45 days overdue → bucket 31-60
59+
$this->get('/finance/reports/aged-receivables?as_of=2026-06-02')
60+
->assertInertia(fn ($p) => $p
61+
->has('rows', 1)
62+
->where('rows.0.bucket', '31-60')
63+
);
64+
65+
Carbon::setTestNow();
66+
});
67+
68+
test('current invoice appears in current bucket', function () {
69+
Carbon::setTestNow('2026-06-02');
70+
71+
$invoice = Invoice::create([
72+
'tenant_id' => $this->tenant->id,
73+
'contact_id' => $this->contact->id,
74+
'issue_date' => '2026-06-01',
75+
'due_date' => '2026-06-10',
76+
'status' => 'sent',
77+
]);
78+
InvoiceItem::create([
79+
'invoice_id' => $invoice->id,
80+
'description' => 'Service',
81+
'quantity' => 1,
82+
'unit_price' => 200,
83+
'tax_rate' => 0,
84+
]);
85+
86+
// due_date is in the future → bucket = current
87+
$this->get('/finance/reports/aged-receivables?as_of=2026-06-02')
88+
->assertInertia(fn ($p) => $p
89+
->has('rows', 1)
90+
->where('rows.0.bucket', 'current')
91+
);
92+
93+
Carbon::setTestNow();
94+
});
95+
96+
test('summary totals are correct', function () {
97+
Carbon::setTestNow('2026-06-02');
98+
99+
$invoice1 = Invoice::create([
100+
'tenant_id' => $this->tenant->id,
101+
'contact_id' => $this->contact->id,
102+
'issue_date' => '2026-04-01',
103+
'due_date' => '2026-04-02',
104+
'status' => 'sent',
105+
]);
106+
InvoiceItem::create([
107+
'invoice_id' => $invoice1->id,
108+
'description' => 'Item 1',
109+
'quantity' => 1,
110+
'unit_price' => 100,
111+
'tax_rate' => 0,
112+
]);
113+
114+
$invoice2 = Invoice::create([
115+
'tenant_id' => $this->tenant->id,
116+
'contact_id' => $this->contact->id,
117+
'issue_date' => '2026-04-01',
118+
'due_date' => '2026-04-02',
119+
'status' => 'sent',
120+
]);
121+
InvoiceItem::create([
122+
'invoice_id' => $invoice2->id,
123+
'description' => 'Item 2',
124+
'quantity' => 1,
125+
'unit_price' => 200,
126+
'tax_rate' => 0,
127+
]);
128+
129+
$this->get('/finance/reports/aged-receivables?as_of=2026-06-02')
130+
->assertInertia(fn ($p) => $p
131+
->has('rows', 2)
132+
->where('grand_total', 300)
133+
);
134+
135+
Carbon::setTestNow();
136+
});
137+
138+
test('draft invoices are excluded from aged receivables', function () {
139+
Carbon::setTestNow('2026-06-02');
140+
141+
// Draft invoice — should be excluded
142+
$invoice = Invoice::create([
143+
'tenant_id' => $this->tenant->id,
144+
'contact_id' => $this->contact->id,
145+
'issue_date' => '2026-04-01',
146+
'due_date' => '2026-04-02',
147+
'status' => 'draft',
148+
]);
149+
InvoiceItem::create([
150+
'invoice_id' => $invoice->id,
151+
'description' => 'Draft Item',
152+
'quantity' => 1,
153+
'unit_price' => 500,
154+
'tax_rate' => 0,
155+
]);
156+
157+
$this->get('/finance/reports/aged-receivables?as_of=2026-06-02')
158+
->assertInertia(fn ($p) => $p->has('rows', 0));
159+
160+
Carbon::setTestNow();
161+
});
162+
163+
test('aged receivables csv export works', function () {
164+
$this->get('/finance/reports/aged-receivables/export?as_of=2026-06-02')
165+
->assertStatus(200)
166+
->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
167+
});
168+
169+
test('aged payables csv export works', function () {
170+
$this->get('/finance/reports/aged-payables/export?as_of=2026-06-02')
171+
->assertStatus(200)
172+
->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
173+
});
174+
175+
test('aged payables shows overdue bill in correct bucket', function () {
176+
Carbon::setTestNow('2026-06-02');
177+
178+
$vendor = Contact::create([
179+
'tenant_id' => $this->tenant->id,
180+
'name' => 'Test Vendor',
181+
'type' => 'vendor',
182+
]);
183+
184+
$bill = Bill::create([
185+
'tenant_id' => $this->tenant->id,
186+
'contact_id' => $vendor->id,
187+
'issue_date' => '2026-04-01',
188+
'due_date' => '2026-05-15',
189+
'status' => 'received',
190+
]);
191+
BillItem::create([
192+
'bill_id' => $bill->id,
193+
'description' => 'Supplies',
194+
'quantity' => 1,
195+
'unit_price' => 300,
196+
'tax_rate' => 0,
197+
]);
198+
199+
// due_date 2026-05-15, as_of 2026-06-02 → 18 days overdue → bucket 1-30
200+
$this->get('/finance/reports/aged-payables?as_of=2026-06-02')
201+
->assertInertia(fn ($p) => $p
202+
->has('rows', 1)
203+
->where('rows.0.bucket', '1-30')
204+
);
205+
206+
Carbon::setTestNow();
207+
});

erp/tests/TestCase.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,9 @@
66

77
abstract class TestCase extends BaseTestCase
88
{
9-
//
9+
protected function setUp(): void
10+
{
11+
parent::setUp();
12+
$this->withoutVite();
13+
}
1014
}

0 commit comments

Comments
 (0)