Skip to content

Commit 9cb73e1

Browse files
committed
Phases 186-190: Accounting Module — 21 tests passing
5 migrations (chart_of_accounts, accounting_periods, accounting_journal_entries, journal_entry_lines, account_balances), 5 models (Account with 22-entry default COA seeder, JournalEntry with post/reverse/isBalanced, AccountingPeriod/Line/Balance), AccountingPolicy, 4 controllers (Account/JournalEntry/Period/Reports), 11 React pages (Accounts CRUD, Journal Entries CRUD+Show, Periods, TrialBalance/BalanceSheet/ IncomeStatement/GeneralLedger), Sidebar Accounting section. 21/21 tests passing. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 2adc2bb commit 9cb73e1

31 files changed

Lines changed: 3042 additions & 0 deletions
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Accounting\Models\Account;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class AccountController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$accounts = Account::withoutGlobalScopes()
17+
->where('tenant_id', auth()->user()->tenant_id)
18+
->with('parent')
19+
->orderBy('code')
20+
->get();
21+
22+
$grouped = $accounts->groupBy('type');
23+
24+
return Inertia::render('Accounting/Accounts/Index', [
25+
'accounts' => $accounts,
26+
'grouped' => $grouped,
27+
]);
28+
}
29+
30+
public function create(): Response
31+
{
32+
$parentOptions = Account::withoutGlobalScopes()
33+
->where('tenant_id', auth()->user()->tenant_id)
34+
->orderBy('code')
35+
->get(['id', 'code', 'name', 'type']);
36+
37+
return Inertia::render('Accounting/Accounts/Create', [
38+
'parentOptions' => $parentOptions,
39+
]);
40+
}
41+
42+
public function store(Request $request): RedirectResponse
43+
{
44+
$data = $request->validate([
45+
'code' => 'required|string|max:20',
46+
'name' => 'required|string|max:255',
47+
'type' => 'required|in:asset,liability,equity,revenue,expense',
48+
'sub_type' => 'nullable|string|max:100',
49+
'parent_id' => 'nullable|exists:chart_of_accounts,id',
50+
'normal_balance' => 'required|in:debit,credit',
51+
'description' => 'nullable|string',
52+
'is_active' => 'boolean',
53+
]);
54+
55+
$data['tenant_id'] = auth()->user()->tenant_id;
56+
57+
Account::create($data);
58+
59+
return redirect()->route('accounting.accounts.index')
60+
->with('success', 'Account created successfully.');
61+
}
62+
63+
public function edit(Account $account): Response
64+
{
65+
$parentOptions = Account::withoutGlobalScopes()
66+
->where('tenant_id', auth()->user()->tenant_id)
67+
->where('id', '!=', $account->id)
68+
->orderBy('code')
69+
->get(['id', 'code', 'name', 'type']);
70+
71+
return Inertia::render('Accounting/Accounts/Edit', [
72+
'account' => $account,
73+
'parentOptions' => $parentOptions,
74+
]);
75+
}
76+
77+
public function update(Request $request, Account $account): RedirectResponse
78+
{
79+
$data = $request->validate([
80+
'code' => 'required|string|max:20',
81+
'name' => 'required|string|max:255',
82+
'type' => 'required|in:asset,liability,equity,revenue,expense',
83+
'sub_type' => 'nullable|string|max:100',
84+
'parent_id' => 'nullable|exists:chart_of_accounts,id',
85+
'normal_balance' => 'required|in:debit,credit',
86+
'description' => 'nullable|string',
87+
'is_active' => 'boolean',
88+
]);
89+
90+
$account->update($data);
91+
92+
return redirect()->route('accounting.accounts.index')
93+
->with('success', 'Account updated successfully.');
94+
}
95+
96+
public function destroy(Account $account): RedirectResponse
97+
{
98+
if ($account->lines()->exists()) {
99+
return back()->with('error', 'Cannot delete account with journal entry lines.');
100+
}
101+
102+
$account->delete();
103+
104+
return back()->with('success', 'Account deleted.');
105+
}
106+
107+
public function seedDefaults(): RedirectResponse
108+
{
109+
Account::seedDefaults(auth()->user()->tenant_id);
110+
111+
return back()->with('success', 'Default chart of accounts seeded successfully.');
112+
}
113+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Accounting\Models\AccountingPeriod;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class AccountingPeriodController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$periods = AccountingPeriod::withoutGlobalScopes()
17+
->where('tenant_id', auth()->user()->tenant_id)
18+
->orderByDesc('start_date')
19+
->get();
20+
21+
return Inertia::render('Accounting/Periods/Index', [
22+
'periods' => $periods,
23+
]);
24+
}
25+
26+
public function store(Request $request): RedirectResponse
27+
{
28+
$data = $request->validate([
29+
'name' => 'required|string|max:100',
30+
'start_date' => 'required|date',
31+
'end_date' => 'required|date|after_or_equal:start_date',
32+
'fiscal_year' => 'required|integer|min:2000|max:2100',
33+
'quarter' => 'nullable|integer|min:1|max:4',
34+
'status' => 'in:open,closed,locked',
35+
]);
36+
37+
$data['tenant_id'] = auth()->user()->tenant_id;
38+
$data['status'] = $data['status'] ?? 'open';
39+
40+
AccountingPeriod::create($data);
41+
42+
return back()->with('success', 'Accounting period created.');
43+
}
44+
45+
public function close(AccountingPeriod $period): RedirectResponse
46+
{
47+
$period->close();
48+
49+
return back()->with('success', 'Period closed successfully.');
50+
}
51+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Accounting\Models\Account;
7+
use App\Modules\Accounting\Models\AccountingPeriod;
8+
use App\Modules\Accounting\Models\JournalEntryLine;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\DB;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class AccountingReportController extends Controller
15+
{
16+
public function trialBalance(Request $request): Response
17+
{
18+
$tenantId = auth()->user()->tenant_id;
19+
$asOf = $request->as_of ?? now()->toDateString();
20+
21+
$accounts = Account::withoutGlobalScopes()
22+
->where('chart_of_accounts.tenant_id', $tenantId)
23+
->select('chart_of_accounts.*')
24+
->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.debit), 0) as total_debit')
25+
->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.credit), 0) as total_credit')
26+
->leftJoin('accounting_journal_entry_lines', 'accounting_journal_entry_lines.account_id', '=', 'chart_of_accounts.id')
27+
->leftJoin('accounting_journal_entries', function ($join) use ($asOf) {
28+
$join->on('accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id')
29+
->where('accounting_journal_entries.status', 'posted')
30+
->where('accounting_journal_entries.entry_date', '<=', $asOf);
31+
})
32+
->groupBy('chart_of_accounts.id')
33+
->havingRaw('total_debit != 0 OR total_credit != 0')
34+
->orderBy('code')
35+
->get();
36+
37+
$totalDebits = $accounts->sum('total_debit');
38+
$totalCredits = $accounts->sum('total_credit');
39+
$isBalanced = abs($totalDebits - $totalCredits) < 0.01;
40+
41+
return Inertia::render('Accounting/Reports/TrialBalance', [
42+
'accounts' => $accounts,
43+
'totalDebits' => $totalDebits,
44+
'totalCredits' => $totalCredits,
45+
'isBalanced' => $isBalanced,
46+
'asOf' => $asOf,
47+
]);
48+
}
49+
50+
public function balanceSheet(Request $request): Response
51+
{
52+
$tenantId = auth()->user()->tenant_id;
53+
$asOf = $request->as_of ?? now()->toDateString();
54+
55+
$accounts = $this->getAccountBalances($tenantId, $asOf);
56+
57+
$assets = $accounts->where('type', 'asset');
58+
$liabilities = $accounts->where('type', 'liability');
59+
$equity = $accounts->where('type', 'equity');
60+
61+
$totalAssets = $assets->sum(fn ($a) => $a->total_debit - $a->total_credit);
62+
$totalLiabilities = $liabilities->sum(fn ($a) => $a->total_credit - $a->total_debit);
63+
$totalEquity = $equity->sum(fn ($a) => $a->total_credit - $a->total_debit);
64+
65+
return Inertia::render('Accounting/Reports/BalanceSheet', [
66+
'assets' => $assets->values(),
67+
'liabilities' => $liabilities->values(),
68+
'equity' => $equity->values(),
69+
'totalAssets' => $totalAssets,
70+
'totalLiabilities' => $totalLiabilities,
71+
'totalEquity' => $totalEquity,
72+
'asOf' => $asOf,
73+
]);
74+
}
75+
76+
public function incomeStatement(Request $request): Response
77+
{
78+
$tenantId = auth()->user()->tenant_id;
79+
$startDate = $request->start_date ?? now()->startOfYear()->toDateString();
80+
$endDate = $request->end_date ?? now()->toDateString();
81+
82+
$accounts = Account::withoutGlobalScopes()
83+
->where('chart_of_accounts.tenant_id', $tenantId)
84+
->whereIn('chart_of_accounts.type', ['revenue', 'expense'])
85+
->select('chart_of_accounts.*')
86+
->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.debit), 0) as total_debit')
87+
->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.credit), 0) as total_credit')
88+
->leftJoin('accounting_journal_entry_lines', 'accounting_journal_entry_lines.account_id', '=', 'chart_of_accounts.id')
89+
->leftJoin('accounting_journal_entries', function ($join) use ($startDate, $endDate) {
90+
$join->on('accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id')
91+
->where('accounting_journal_entries.status', 'posted')
92+
->whereBetween('accounting_journal_entries.entry_date', [$startDate, $endDate]);
93+
})
94+
->groupBy('chart_of_accounts.id')
95+
->orderBy('code')
96+
->get();
97+
98+
$revenue = $accounts->where('type', 'revenue');
99+
$expenses = $accounts->where('type', 'expense');
100+
101+
$totalRevenue = $revenue->sum(fn ($a) => $a->total_credit - $a->total_debit);
102+
$totalExpenses = $expenses->sum(fn ($a) => $a->total_debit - $a->total_credit);
103+
$netIncome = $totalRevenue - $totalExpenses;
104+
105+
return Inertia::render('Accounting/Reports/IncomeStatement', [
106+
'revenue' => $revenue->values(),
107+
'expenses' => $expenses->values(),
108+
'totalRevenue' => $totalRevenue,
109+
'totalExpenses' => $totalExpenses,
110+
'netIncome' => $netIncome,
111+
'startDate' => $startDate,
112+
'endDate' => $endDate,
113+
]);
114+
}
115+
116+
public function generalLedger(Request $request, Account $account): Response
117+
{
118+
$tenantId = auth()->user()->tenant_id;
119+
120+
$lines = JournalEntryLine::with('journalEntry')
121+
->where('accounting_journal_entry_lines.account_id', $account->id)
122+
->whereHas('journalEntry', fn ($q) => $q->where('status', 'posted')
123+
->where('tenant_id', $tenantId))
124+
->join('accounting_journal_entries', 'accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id')
125+
->orderBy('accounting_journal_entries.entry_date')
126+
->orderBy('accounting_journal_entries.id')
127+
->select('accounting_journal_entry_lines.*')
128+
->get();
129+
130+
// Compute running balance
131+
$runningBalance = 0.0;
132+
$linesWithBalance = $lines->map(function ($line) use (&$runningBalance, $account) {
133+
if ($account->isDebitNormal()) {
134+
$runningBalance += $line->debit - $line->credit;
135+
} else {
136+
$runningBalance += $line->credit - $line->debit;
137+
}
138+
return array_merge($line->toArray(), ['running_balance' => $runningBalance]);
139+
});
140+
141+
$allAccounts = Account::withoutGlobalScopes()
142+
->where('tenant_id', $tenantId)
143+
->where('is_active', true)
144+
->orderBy('code')
145+
->get(['id', 'code', 'name']);
146+
147+
return Inertia::render('Accounting/Reports/GeneralLedger', [
148+
'account' => $account,
149+
'lines' => $linesWithBalance,
150+
'allAccounts' => $allAccounts,
151+
]);
152+
}
153+
154+
private function getAccountBalances(int $tenantId, string $asOf)
155+
{
156+
return Account::withoutGlobalScopes()
157+
->where('chart_of_accounts.tenant_id', $tenantId)
158+
->select('chart_of_accounts.*')
159+
->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.debit), 0) as total_debit')
160+
->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.credit), 0) as total_credit')
161+
->leftJoin('accounting_journal_entry_lines', 'accounting_journal_entry_lines.account_id', '=', 'chart_of_accounts.id')
162+
->leftJoin('accounting_journal_entries', function ($join) use ($asOf) {
163+
$join->on('accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id')
164+
->where('accounting_journal_entries.status', 'posted')
165+
->where('accounting_journal_entries.entry_date', '<=', $asOf);
166+
})
167+
->groupBy('chart_of_accounts.id')
168+
->orderBy('code')
169+
->get();
170+
}
171+
}

0 commit comments

Comments
 (0)