Skip to content

Commit 0e65d5c

Browse files
committed
feat: Phase 7 — Accounts Payable (Bills) + Financial Statements
- Add Vendor Bills module (AP): bills/bill_items/bill_payments tables, Bill/BillItem/BillPayment models, BillController, BillPolicy, BillResource, StoreBillRequest - Extract HasLineItemTotals + HasStatusTransitions traits (shared by Invoice and Bill models) - Add ReportController::profitAndLoss() and ::balanceSheet() with shared aggregateJournalLines() helper - Add Bills/Index, Bills/Create, Bills/Show React pages - Add Reports/ProfitLoss and Reports/BalanceSheet React pages - Add BillStatusBadge component - Add bill routes (resource + receive/cancel/recordPayment) and report routes - Update Sidebar with Bills and new report links - Add BillTest (12 tests) and ReportTest (5 tests); all tests passing - npm run build passes with no TypeScript errors https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a5988af commit 0e65d5c

20 files changed

Lines changed: 1678 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Http\Requests\StoreBillRequest;
7+
use App\Modules\Finance\Http\Requests\StorePaymentRequest;
8+
use App\Modules\Finance\Http\Resources\BillResource;
9+
use App\Modules\Finance\Models\Bill;
10+
use App\Modules\Finance\Models\BillItem;
11+
use App\Modules\Finance\Models\BillPayment;
12+
use App\Modules\Finance\Models\Contact;
13+
use Illuminate\Http\RedirectResponse;
14+
use Illuminate\Http\Request;
15+
use Illuminate\Support\Facades\DB;
16+
use Inertia\Inertia;
17+
use Inertia\Response;
18+
19+
class BillController extends Controller
20+
{
21+
public function index(Request $request): Response
22+
{
23+
$this->authorize('viewAny', Bill::class);
24+
25+
$bills = Bill::with('contact')
26+
->when($request->status, fn ($q) => $q->where('status', $request->status))
27+
->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id))
28+
->when($request->search, fn ($q) => $q->where('number', 'like', "%{$request->search}%"))
29+
->latest('issue_date')
30+
->paginate(25)
31+
->withQueryString();
32+
33+
return Inertia::render('Finance/Bills/Index', [
34+
'bills' => BillResource::collection($bills),
35+
'contacts' => Contact::vendors()->active()->orderBy('name')->get(['id', 'name']),
36+
'filters' => $request->only(['status', 'contact_id', 'search']),
37+
'breadcrumbs' => [
38+
['label' => 'Finance'],
39+
['label' => 'Bills', 'href' => route('finance.bills.index')],
40+
],
41+
]);
42+
}
43+
44+
public function create(): Response
45+
{
46+
$this->authorize('create', Bill::class);
47+
48+
return Inertia::render('Finance/Bills/Create', [
49+
'contacts' => Contact::vendors()->active()->orderBy('name')->get(['id', 'name']),
50+
'breadcrumbs' => [
51+
['label' => 'Finance'],
52+
['label' => 'Bills', 'href' => route('finance.bills.index')],
53+
['label' => 'New Bill'],
54+
],
55+
]);
56+
}
57+
58+
public function store(StoreBillRequest $request): RedirectResponse
59+
{
60+
$this->authorize('create', Bill::class);
61+
62+
$data = $request->validated();
63+
64+
$bill = DB::transaction(function () use ($data) {
65+
$bill = Bill::create([
66+
'tenant_id' => auth()->user()->tenant_id,
67+
'contact_id' => $data['contact_id'] ?? null,
68+
'issue_date' => $data['issue_date'],
69+
'due_date' => $data['due_date'] ?? null,
70+
'notes' => $data['notes'] ?? null,
71+
'created_by' => auth()->id(),
72+
]);
73+
74+
$bill->update([
75+
'number' => 'BILL-' . now()->format('Y') . '-' . str_pad((string) $bill->id, 5, '0', STR_PAD_LEFT),
76+
]);
77+
78+
foreach ($data['items'] as $item) {
79+
BillItem::create([
80+
'bill_id' => $bill->id,
81+
'description' => $item['description'],
82+
'quantity' => $item['quantity'],
83+
'unit_price' => $item['unit_price'],
84+
'tax_rate' => $item['tax_rate'],
85+
]);
86+
}
87+
88+
return $bill;
89+
});
90+
91+
return redirect()->route('finance.bills.show', $bill)
92+
->with('success', 'Bill created.');
93+
}
94+
95+
public function show(Bill $bill): Response
96+
{
97+
$this->authorize('view', $bill);
98+
99+
$bill->load(['contact', 'items', 'payments', 'creator']);
100+
101+
return Inertia::render('Finance/Bills/Show', [
102+
'bill' => new BillResource($bill),
103+
'breadcrumbs' => [
104+
['label' => 'Finance'],
105+
['label' => 'Bills', 'href' => route('finance.bills.index')],
106+
['label' => $bill->number ?? "Bill #{$bill->id}"],
107+
],
108+
]);
109+
}
110+
111+
public function receive(Bill $bill): RedirectResponse
112+
{
113+
$this->authorize('update', $bill);
114+
115+
try {
116+
$bill->transitionTo('received');
117+
} catch (\DomainException $e) {
118+
return back()->withErrors(['status' => $e->getMessage()]);
119+
}
120+
121+
return back()->with('success', 'Bill marked as received.');
122+
}
123+
124+
public function cancel(Bill $bill): RedirectResponse
125+
{
126+
$this->authorize('update', $bill);
127+
128+
try {
129+
$bill->transitionTo('cancelled');
130+
} catch (\DomainException $e) {
131+
return back()->withErrors(['status' => $e->getMessage()]);
132+
}
133+
134+
return back()->with('success', 'Bill cancelled.');
135+
}
136+
137+
public function recordPayment(StorePaymentRequest $request, Bill $bill): RedirectResponse
138+
{
139+
$this->authorize('update', $bill);
140+
141+
$data = $request->validated();
142+
143+
DB::transaction(function () use ($data, $bill) {
144+
BillPayment::create([
145+
'tenant_id' => auth()->user()->tenant_id,
146+
'bill_id' => $bill->id,
147+
'amount' => $data['amount'],
148+
'payment_date' => $data['payment_date'],
149+
'method' => $data['method'],
150+
'reference' => $data['reference'] ?? null,
151+
'notes' => $data['notes'] ?? null,
152+
]);
153+
154+
$bill->load(['items', 'payments']);
155+
156+
if ($bill->amount_due <= 0 && $bill->canTransitionTo('paid')) {
157+
$bill->transitionTo('paid');
158+
}
159+
});
160+
161+
return back()->with('success', 'Payment recorded.');
162+
}
163+
164+
public function destroy(Bill $bill): RedirectResponse
165+
{
166+
$this->authorize('delete', $bill);
167+
168+
$bill->delete();
169+
170+
return redirect()->route('finance.bills.index')
171+
->with('success', 'Bill deleted.');
172+
}
173+
}

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,141 @@ public function trialBalance(Request $request): Response
5050
],
5151
]);
5252
}
53+
54+
public function profitAndLoss(Request $request): Response
55+
{
56+
$this->authorize('viewAny', Account::class);
57+
58+
$from = $request->from ?? now()->startOfYear()->toDateString();
59+
$to = $request->to ?? now()->toDateString();
60+
61+
$totals = $this->aggregateJournalLines($from, $to);
62+
63+
$accounts = Account::whereIn('type', ['income', 'expense'])
64+
->orderBy('code')
65+
->get();
66+
67+
$revenue = [];
68+
$expenses = [];
69+
70+
foreach ($accounts as $account) {
71+
$row = $totals->get($account->id);
72+
$debit = (float) ($row?->total_debit ?? 0);
73+
$credit = (float) ($row?->total_credit ?? 0);
74+
75+
$net = $account->type === 'income'
76+
? $credit - $debit
77+
: $debit - $credit;
78+
79+
$entry = [
80+
'id' => $account->id,
81+
'code' => $account->code,
82+
'name' => $account->name,
83+
'type' => $account->type,
84+
'net' => $net,
85+
];
86+
87+
if ($account->type === 'income') {
88+
$revenue[] = $entry;
89+
} else {
90+
$expenses[] = $entry;
91+
}
92+
}
93+
94+
$totalRevenue = (float) array_sum(array_column($revenue, 'net'));
95+
$totalExpenses = (float) array_sum(array_column($expenses, 'net'));
96+
97+
return Inertia::render('Finance/Reports/ProfitLoss', [
98+
'revenue' => $revenue,
99+
'expenses' => $expenses,
100+
'total_revenue' => $totalRevenue,
101+
'total_expenses' => $totalExpenses,
102+
'net' => $totalRevenue - $totalExpenses,
103+
'from' => $from,
104+
'to' => $to,
105+
'breadcrumbs' => [
106+
['label' => 'Finance'],
107+
['label' => 'Reports'],
108+
['label' => 'Profit & Loss'],
109+
],
110+
]);
111+
}
112+
113+
public function balanceSheet(Request $request): Response
114+
{
115+
$this->authorize('viewAny', Account::class);
116+
117+
$asOf = $request->as_of ?? now()->toDateString();
118+
119+
$totals = $this->aggregateJournalLines(null, $asOf);
120+
121+
$accounts = Account::whereIn('type', ['asset', 'liability', 'equity'])
122+
->orderBy('code')
123+
->get();
124+
125+
$assets = [];
126+
$liabilities = [];
127+
$equity = [];
128+
129+
foreach ($accounts as $account) {
130+
$row = $totals->get($account->id);
131+
$debit = (float) ($row?->total_debit ?? 0);
132+
$credit = (float) ($row?->total_credit ?? 0);
133+
134+
$net = $account->type === 'asset'
135+
? $debit - $credit
136+
: $credit - $debit;
137+
138+
$entry = [
139+
'id' => $account->id,
140+
'code' => $account->code,
141+
'name' => $account->name,
142+
'type' => $account->type,
143+
'net' => $net,
144+
];
145+
146+
if ($account->type === 'asset') {
147+
$assets[] = $entry;
148+
} elseif ($account->type === 'liability') {
149+
$liabilities[] = $entry;
150+
} else {
151+
$equity[] = $entry;
152+
}
153+
}
154+
155+
$totalAssets = (float) array_sum(array_column($assets, 'net'));
156+
$totalLiabilities = (float) array_sum(array_column($liabilities, 'net'));
157+
$totalEquity = (float) array_sum(array_column($equity, 'net'));
158+
159+
return Inertia::render('Finance/Reports/BalanceSheet', [
160+
'assets' => $assets,
161+
'liabilities' => $liabilities,
162+
'equity' => $equity,
163+
'total_assets' => $totalAssets,
164+
'total_liabilities' => $totalLiabilities,
165+
'total_equity' => $totalEquity,
166+
'as_of' => $asOf,
167+
'breadcrumbs' => [
168+
['label' => 'Finance'],
169+
['label' => 'Reports'],
170+
['label' => 'Balance Sheet'],
171+
],
172+
]);
173+
}
174+
175+
private function aggregateJournalLines(?string $from = null, ?string $to = null): \Illuminate\Support\Collection
176+
{
177+
return JournalLine::select('account_id',
178+
DB::raw('SUM(debit) as total_debit'),
179+
DB::raw('SUM(credit) as total_credit')
180+
)
181+
->whereHas('journalEntry', function ($q) use ($from, $to) {
182+
$q->where('status', 'posted');
183+
if ($from) $q->whereDate('date', '>=', $from);
184+
if ($to) $q->whereDate('date', '<=', $to);
185+
})
186+
->groupBy('account_id')
187+
->get()
188+
->keyBy('account_id');
189+
}
53190
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Requests;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
use Illuminate\Validation\Rule;
7+
8+
class StoreBillRequest extends FormRequest
9+
{
10+
public function authorize(): bool { return true; }
11+
12+
public function rules(): array
13+
{
14+
return [
15+
'contact_id' => ['nullable', 'integer', Rule::exists('contacts', 'id')
16+
->where(fn ($q) => $q->whereIn('type', ['vendor', 'both']))],
17+
'issue_date' => ['required', 'date'],
18+
'due_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
19+
'notes' => ['nullable', 'string'],
20+
'items' => ['required', 'array', 'min:1'],
21+
'items.*.description' => ['required', 'string', 'max:500'],
22+
'items.*.quantity' => ['required', 'numeric', 'min:0.01'],
23+
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
24+
'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'],
25+
];
26+
}
27+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Resources;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Http\Resources\Json\JsonResource;
7+
8+
class BillResource extends JsonResource
9+
{
10+
public function toArray(Request $request): array
11+
{
12+
return [
13+
'id' => $this->id,
14+
'number' => $this->number,
15+
'status' => $this->status,
16+
'issue_date' => $this->issue_date?->toDateString(),
17+
'due_date' => $this->due_date?->toDateString(),
18+
'notes' => $this->notes,
19+
'is_overdue' => $this->isOverdue(),
20+
'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [
21+
'id' => $this->contact->id, 'name' => $this->contact->name,
22+
] : null),
23+
'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($i) => [
24+
'id' => $i->id,
25+
'description' => $i->description,
26+
'quantity' => $i->quantity,
27+
'unit_price' => $i->unit_price,
28+
'tax_rate' => $i->tax_rate,
29+
'subtotal' => $i->subtotal,
30+
'tax' => $i->tax,
31+
'line_total' => $i->line_total,
32+
])),
33+
'payments' => $this->whenLoaded('payments', fn () => $this->payments->map(fn ($p) => [
34+
'id' => $p->id,
35+
'amount' => $p->amount,
36+
'payment_date' => $p->payment_date?->toDateString(),
37+
'method' => $p->method,
38+
'reference' => $p->reference,
39+
])),
40+
'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal),
41+
'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total),
42+
'total' => $this->whenLoaded('items', fn () => $this->total),
43+
'amount_paid' => $this->whenLoaded('payments', fn () => $this->amount_paid),
44+
'amount_due' => $this->when(
45+
$this->relationLoaded('items') && $this->relationLoaded('payments'),
46+
fn () => $this->amount_due
47+
),
48+
'transitions' => $this->availableTransitions(),
49+
'creator' => $this->whenLoaded('creator', fn () => $this->creator?->name),
50+
'created_at' => $this->created_at?->toDateString(),
51+
];
52+
}
53+
}

0 commit comments

Comments
 (0)