# Finance Module — Technical & Code Documentation

## Routes

All finance routes live inside the `auth:admin` middleware group in `routes/admin.php`. Routes are prefixed with the active locale (e.g., `/en/admin/...`). Permission middleware is noted where applied.

### Fiscal Years — prefix: `finance/fiscal-years`, name prefix: `admin.dashboard.finance.fiscal_years.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/fiscal-years` | `FiscalYearController@index` | `finance.fiscal_years.view` |
| GET | `finance/fiscal-years/create` | `FiscalYearController@create` | `finance.fiscal_years.create` |
| POST | `finance/fiscal-years` | `FiscalYearController@store` | `finance.fiscal_years.create` |
| GET | `finance/fiscal-years/{fiscalYear}` | `FiscalYearController@show` | `finance.fiscal_years.view` |
| PATCH | `finance/fiscal-years/{fiscalYear}/close` | `FiscalYearController@closeYear` | `finance.fiscal_years.close` |
| PATCH | `finance/fiscal-years/{fiscalYear}/periods/{period}/close` | `FiscalYearController@closePeriod` | `finance.fiscal_years.close` |

### Cost Centers — prefix: `finance/cost-centers`, name prefix: `admin.finance.cost-centers.`

| Method | URI | Controller@Method |
|---|---|---|
| GET | `finance/cost-centers` | `CostCenterController@index` |
| GET | `finance/cost-centers/create` | `CostCenterController@create` |
| POST | `finance/cost-centers` | `CostCenterController@store` |
| GET | `finance/cost-centers/{costCenter}` | `CostCenterController@show` |
| GET | `finance/cost-centers/{costCenter}/edit` | `CostCenterController@edit` |
| PUT | `finance/cost-centers/{costCenter}` | `CostCenterController@update` |
| DELETE | `finance/cost-centers/{costCenter}` | `CostCenterController@destroy` |

### Accounts — prefix: `finance/accounts`, name prefix: `admin.dashboard.finance.accounts.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/accounts` | `AccountController@index` | `finance.accounts.view` |
| GET | `finance/accounts/create` | `AccountController@create` | `finance.accounts.create` |
| POST | `finance/accounts` | `AccountController@store` | `finance.accounts.create` |
| GET | `finance/accounts/{account}/edit` | `AccountController@edit` | `finance.accounts.update` |
| PUT | `finance/accounts/{account}` | `AccountController@update` | `finance.accounts.update` |
| DELETE | `finance/accounts/{account}` | `AccountController@destroy` | `finance.accounts.delete` |

### Journals — prefix: `finance/journals`, name prefix: `admin.dashboard.finance.journals.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/journals` | `JournalController@index` | `finance.journals.view` |
| GET | `finance/journals/create` | `JournalController@create` | `finance.journals.create` |
| POST | `finance/journals` | `JournalController@store` | `finance.journals.create` |
| GET | `finance/journals/{journal}/edit` | `JournalController@edit` | `finance.journals.update` |
| PUT | `finance/journals/{journal}` | `JournalController@update` | `finance.journals.update` |
| DELETE | `finance/journals/{journal}` | `JournalController@destroy` | `finance.journals.delete` |

### Journal Entries — prefix: `finance/journal-entries`, name prefix: `admin.dashboard.finance.journal_entries.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/journal-entries` | `JournalEntryController@index` | `finance.entries.view` |
| GET | `finance/journal-entries/create` | `JournalEntryController@create` | `finance.entries.create` |
| POST | `finance/journal-entries` | `JournalEntryController@store` | `finance.entries.create` |
| PATCH | `finance/journal-entries/{journalEntry}/post` | `JournalEntryPostController` (invokable) | `finance.entries.post` |
| GET | `finance/journal-entries/{journalEntry}/edit` | `JournalEntryController@edit` | `finance.entries.update` |
| PUT | `finance/journal-entries/{journalEntry}` | `JournalEntryController@update` | `finance.entries.update` |
| GET | `finance/journal-entries/{journalEntry}` | `JournalEntryController@show` | `finance.entries.view` |
| DELETE | `finance/journal-entries/{journalEntry}` | `JournalEntryController@destroy` | `finance.entries.delete` |

### Expenses — prefix: `finance/expenses`, name prefix: `admin.dashboard.finance.expenses.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/expenses` | `ExpenseController@index` | `finance.expenses.view` |
| GET | `finance/expenses/create` | `ExpenseController@create` | `finance.expenses.create` |
| POST | `finance/expenses` | `ExpenseController@store` | `finance.expenses.create` |
| GET | `finance/expenses/{expense}` | `ExpenseController@show` | `finance.expenses.view` |
| GET | `finance/expenses/{expense}/edit` | `ExpenseController@edit` | `finance.expenses.edit` |
| PUT | `finance/expenses/{expense}` | `ExpenseController@update` | `finance.expenses.edit` |
| DELETE | `finance/expenses/{expense}` | `ExpenseController@destroy` | `finance.expenses.delete` |
| POST | `finance/expenses/{expense}/submit` | `ExpenseController@submit` | `finance.expenses.create` |
| POST | `finance/expenses/{expense}/approve` | `ExpenseController@approve` | `finance.expenses.approve` |
| POST | `finance/expenses/{expense}/reject` | `ExpenseController@reject` | `finance.expenses.approve` |
| POST | `finance/expenses/{expense}/cancel` | `ExpenseController@cancel` | `finance.expenses.edit` |

### Receipt Vouchers — prefix: `finance/receipts`, name prefix: `admin.dashboard.finance.receipts.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/receipts` | `ReceiptVoucherController@index` | `finance.receipts.view` |
| GET | `finance/receipts/create` | `ReceiptVoucherController@create` | `finance.receipts.create` |
| POST | `finance/receipts` | `ReceiptVoucherController@store` | `finance.receipts.create` |
| GET | `finance/receipts/{receipt}` | `ReceiptVoucherController@show` | `finance.receipts.view` |
| GET | `finance/receipts/{receipt}/edit` | `ReceiptVoucherController@edit` | `finance.receipts.edit` |
| PUT | `finance/receipts/{receipt}` | `ReceiptVoucherController@update` | `finance.receipts.edit` |
| POST | `finance/receipts/{receipt}/approve` | `ReceiptVoucherController@approve` | `finance.receipts.approve` |
| POST | `finance/receipts/{receipt}/post` | `ReceiptVoucherController@post` | `finance.receipts.post` |
| POST | `finance/receipts/{receipt}/reverse` | `ReceiptVoucherController@reverse` | `finance.receipts.reverse` |
| POST | `finance/receipts/{receipt}/cancel` | `ReceiptVoucherController@cancel` | `finance.receipts.edit` |

### Payment Vouchers — prefix: `finance/payments`, name prefix: `admin.dashboard.finance.payments.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/payments` | `PaymentVoucherController@index` | `finance.payments.view` |
| GET | `finance/payments/create` | `PaymentVoucherController@create` | `finance.payments.create` |
| POST | `finance/payments` | `PaymentVoucherController@store` | `finance.payments.create` |
| GET | `finance/payments/{payment}` | `PaymentVoucherController@show` | `finance.payments.view` |
| GET | `finance/payments/{payment}/edit` | `PaymentVoucherController@edit` | `finance.payments.edit` |
| PUT | `finance/payments/{payment}` | `PaymentVoucherController@update` | `finance.payments.edit` |
| POST | `finance/payments/{payment}/approve` | `PaymentVoucherController@approve` | `finance.payments.approve` |
| POST | `finance/payments/{payment}/post` | `PaymentVoucherController@post` | `finance.payments.post` |
| POST | `finance/payments/{payment}/reverse` | `PaymentVoucherController@reverse` | `finance.payments.reverse` |
| POST | `finance/payments/{payment}/cancel` | `PaymentVoucherController@cancel` | `finance.payments.edit` |

### Finance Reports — prefix: `finance/reports`, name prefix: `admin.dashboard.finance.reports.`

| Method | URI | Controller@Method | Permission |
|---|---|---|---|
| GET | `finance/reports/trial-balance` | `TrialBalanceController@index` | `reports.trial_balance.view` |
| GET | `finance/reports/general-ledger` | `GeneralLedgerController@index` | `reports.general_ledger.view` |
| GET | `finance/reports/income-statement` | `IncomeStatementController@index` | `reports.income_statement.view` |
| GET | `finance/reports/balance-sheet` | `BalanceSheetController@index` | `reports.balance_sheet.view` |
| GET | `finance/reports/receipts` | `ReceiptRegisterController@index` | `reports.receipts.view` |
| GET | `finance/reports/payments` | `PaymentRegisterController@index` | `reports.payments.view` |
| GET | `finance/reports/expenses` | `ExpenseRegisterController@index` | `reports.expenses.view` |
| GET | `finance/reports/cash-bank-movement` | `CashBankMovementController@index` | `reports.cash_movement.view` |
| GET | `finance/reports/expense-analytics` | `ExpenseAnalyticsController@index` | `reports.expense_analytics.view` |
| GET | `finance/reports/ar-aging` | `ARAPController@arAging` | `reports.ar_aging.view` |
| GET | `finance/reports/ap-aging` | `ARAPController@apAging` | `reports.ap_aging.view` |

### Finance Setup Routes

#### Payment Methods — prefix: `finance/setup/payment-methods`
Standard CRUD via `FinanceSetupMasterController` with `->defaults('entity', 'payment-methods')`. Permissions are implicit from the route group.

#### Expense Categories — prefix: `finance/setup/expense-categories`
Standard CRUD via `FinanceSetupMasterController` with `->defaults('entity', 'expense-categories')`.

#### Expense Items — prefix: `finance/setup/expense-items`
Standard CRUD via `ExpenseItemController`.

#### Voucher Posting Profiles — prefix: `finance/setup/voucher-posting-profiles`
Standard CRUD via `VoucherPostingProfileController`.

#### Receipt Voucher Types — prefix: `finance/setup/receipt-voucher-types`
Standard CRUD via `VoucherTypeController` with `->defaults('direction', 'receipt')`.

#### Payment Voucher Types — prefix: `finance/setup/payment-voucher-types`
Standard CRUD via `VoucherTypeController` with `->defaults('direction', 'payment')`.

---

## Controllers

### `AccountController`

Namespace: `App\Http\Controllers\Admin\Finance`
Injects: `AccountService`

#### `index(Request $request): View`
- Calls `$accountService->getAll(15)` to get paginated accounts (with parent and creator eager-loaded).
- Returns `dashboard.admin.finance.accounts.index` with `$accounts`.

#### `create(): View`
- Calls `$accountService->getActive()` to populate the parent account dropdown.
- Passes `$parentAccounts`, `$types` (asset/liability/equity/income/expense), `$balances` (debit/credit).
- Returns `dashboard.admin.finance.accounts.create`.

#### `store(StoreAccountRequest $request): RedirectResponse`
- Validates input. Builds the `name` JSON from `name_ar` and `name_en` fields.
- Calls `$accountService->create($data)`.
- Redirects to index with success flash.

#### `edit(Account $account): View`
- Loads active accounts excluding the current account to prevent self-parenting.
- Returns `dashboard.admin.finance.accounts.edit`.

#### `update(UpdateAccountRequest $request, Account $account): RedirectResponse`
- Rebuilds `name` JSON, calls `$accountService->update($account, $data)`.
- Redirects to index.

#### `destroy(Account $account): RedirectResponse`
- Calls `$accountService->delete($account)` (soft delete).
- Redirects to index.

---

### `FiscalYearController`

Namespace: `App\Http\Controllers\Admin\Finance`
Injects: `FiscalYearService`, `FiscalPeriodService`

#### `index(Request $request): View`
- Queries `FiscalYear::query()->latest('date_from')->paginate(15)` directly.
- Returns `dashboard.admin.finance.fiscal_years.index`.

#### `create(): View`
- Returns create view with no additional data.

#### `store(StoreFiscalYearRequest $request): RedirectResponse`
- Calls `$fiscalYearService->create($request->validated(), auth('admin')->id())`.
- Redirects to show view for the newly created year.

#### `show(FiscalYear $fiscalYear): View`
- Eager-loads `periods.closer` and `closer`.
- Returns `dashboard.admin.finance.fiscal_years.show`.

#### `closePeriod(FiscalYear $fiscalYear, FiscalPeriod $period): RedirectResponse`
- Verifies `$period->fiscal_year_id === $fiscalYear->id`, aborts 404 if not.
- Calls `$fiscalPeriodService->close($period, auth('admin')->id())`.
- Redirects to show.

#### `closeYear(FiscalYear $fiscalYear): RedirectResponse`
- Calls `$fiscalYearService->close($fiscalYear, auth('admin')->id())`.
- Redirects to show.

---

### `JournalController`

Namespace: `App\Http\Controllers\Admin\Finance`
Injects: `JournalService`

Standard CRUD: `index`, `create`, `store`, `edit`, `update`, `destroy`. All delegate immediately to service methods and redirect to index with success flash. No complex logic in the controller.

---

### `JournalEntryController`

Namespace: `App\Http\Controllers\Admin\Finance`
Injects: `JournalEntryService`

#### `index(): View`
- Calls `$journalEntryService->getAll()` (paginated 15, with journal and branch eager-loaded).
- Returns index view.

#### `create(): View`
- Calls `$journalEntryService->getFormOptions()` to populate dropdowns.
- Returns create view.

#### `store(StoreJournalEntryRequest $request): RedirectResponse`
- Calls `$journalEntryService->create($request->validated())`.
- Redirects to show view for the created entry.

#### `show(JournalEntry $journalEntry): View`
- Eager-loads: `journal`, `branch`, `fiscalYear`, `fiscalPeriod`, `creator`, `updater`, `poster`, `lines.account`, `lines.costCenter`.
- Returns show view.

#### `edit(JournalEntry $journalEntry): View|RedirectResponse`
- If `$journalEntry->isPosted()` redirects to show with error — posted entries cannot be edited.
- Otherwise eager-loads lines and returns edit view with form options merged in.

#### `update(UpdateJournalEntryRequest $request, JournalEntry $journalEntry): RedirectResponse`
- Wraps `$journalEntryService->update()` in a try/catch for `RuntimeException`.
- On exception, redirects to show with the exception message as an error flash.

#### `destroy(JournalEntry $journalEntry): RedirectResponse`
- Wraps `$journalEntryService->delete()` in try/catch.
- On success, redirects to index.

---

### `JournalEntryPostController` (Invokable)

Namespace: `App\Http\Controllers\Admin\Finance`
Injects: `JournalPostingService`

#### `__invoke(JournalEntry $journalEntry): RedirectResponse`
- Calls `$journalPostingService->post($journalEntry, auth('admin')->id())`.
- Wraps in try/catch for `RuntimeException`.
- Redirects to show with success or error flash.

---

### `ExpenseController`

Namespace: `App\Http\Controllers\Admin\Finance\Expenses`
Injects: `ExpenseService`, `ExpenseAttachmentService`

#### `index(Request $request): View`
- Filters: `branch_id`, `expense_item_id`, `status`, `paid_status`, `date_from`, `date_to`.
- Calls `$service->paginate($filters)` and `$service->getFilterOptions()`.

#### `store(CreateExpenseRequest $request): RedirectResponse`
- Calls `$service->create($request->validated())`.
- Iterates `$request->file('attachments', [])`, calling `$attachments->store($expense, $file)` for each.

#### `edit(Expense $expense): View`
- `abort_unless($expense->isEditable(), 403)` — blocks editing of non-draft/non-pending expenses.

#### `submit(Expense $expense): RedirectResponse`
- Calls `$service->submit($expense)`. Status: draft → pending.

#### `approve(Expense $expense): RedirectResponse`
- Calls `$service->approve($expense)`. Status: pending → approved.

#### `reject(RejectExpenseRequest $request, Expense $expense): RedirectResponse`
- Calls `$service->reject($expense, $request->validated()['notes'] ?? null)`. Status: pending → rejected.

#### `cancel(Expense $expense): RedirectResponse`
- Calls `$service->cancel($expense)`. Only works if `isEditable()` (draft or pending).

---

### `ReceiptVoucherController`

Namespace: `App\Http\Controllers\Admin\Finance\Vouchers`
Injects: `ReceiptVoucherService`, `ReceiptVoucherPostingService`, `VoucherReversalService`

#### `approve(ReceiptVoucher $receipt): RedirectResponse`
- Delegates to `$service->approve($receipt)`. Transitions draft → approved.

#### `post(ReceiptVoucher $receipt): RedirectResponse`
- Delegates to `$postingService->post($receipt)`. Creates GL entries.

#### `reverse(ReverseReceiptVoucherRequest $request, ReceiptVoucher $receipt): RedirectResponse`
- Delegates to `$reversalService->reverse($receipt, $request->validated('reason'))`.

#### `cancel(ReceiptVoucher $receipt): RedirectResponse`
- Delegates to `$service->cancel($receipt)`. Blocked if posted or reversed.

---

### `PaymentVoucherController`

Namespace: `App\Http\Controllers\Admin\Finance\Vouchers`
Injects: `PaymentVoucherService`, `PaymentVoucherPostingService`

Mirrors `ReceiptVoucherController`. The `reverse` action calls `$postingService->reverse()` (reversal is handled by `PaymentVoucherPostingService` directly rather than a separate reversal service).

---

### `TrialBalanceController`

#### `index(TrialBalanceFilterRequest $request): View`
- Only runs the report if `$request->hasFilters()` returns true; otherwise returns empty rows/totals.
- Calls `$trialBalanceService->getReport($request->validated())` and `getFilterOptions()`.
- Returns view with `rows`, `totals`, `selectedFilters`, `hasFilters`, plus dropdown data.

---

### `GeneralLedgerController`

#### `index(GeneralLedgerFilterRequest $request): View`
- Same lazy-load pattern as Trial Balance.
- Calls `$generalLedgerService->getReport()` and `getFilterOptions()`.

---

### `BalanceSheetController`

#### `index(BalanceSheetFilterRequest $request): View`
- Calls `$balanceSheetService->getReport()` returning `assets`, `liabilities`, `equity`, `summary`.

---

### `IncomeStatementController`

#### `index(IncomeStatementFilterRequest $request): View`
- Calls `$incomeStatementService->getReport()` returning `revenues`, `expenses`, `summary`.

---

### `ARAPController`

Namespace: `App\Http\Controllers\Admin\Finance\Reports`
Injects: `ARAPService`

#### `arAging(Request $request): View`
- Filters: `customer_id`, `branch_id`.
- Calls `$service->getARAgingReport($filters)`. Returns `dashboard.admin.finance.reports.ar-aging`.

#### `apAging(Request $request): View`
- Filters: `supplier_id`, `branch_id`.
- Calls `$service->getAPAgingReport($filters)`.

#### `customerStatement(Customer $customer, Request $request): View`
- Resolves date range (defaults to year-to-date).
- Calls `$service->getCustomerStatement($customer, $from, $to)`.

#### `supplierStatement(Supplier $supplier, Request $request): View`
- Calls `$service->getSupplierStatement($supplier, $from, $to)`.

---

## Services

### `AccountService`

File: `app/Services/Finance/AccountService.php`

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Queries `Account::with(['parent', 'creator'])->latest()->paginate($perPage)`.

#### `getActive(): Collection`
Returns `Account::where('is_active', true)->get()`.

#### `create(array $data): Account`
Sets `created_by` to `Auth::id()`. Calculates `level` via `calculateLevel($data['parent_id'] ?? null)`. Creates and returns the account.

#### `update(Account $account, array $data): bool`
Sets `updated_by`. If `parent_id` changed, recalculates `level`. Calls `$account->update($data)`.

#### `delete(Account $account): bool`
Calls `$account->delete()` (soft delete).

#### `getTree(): Collection`
Returns root accounts (null parent_id) with `children` eager-loaded.

#### `calculateLevel(?int $parentId): int` (private)
Returns 1 if no parent; otherwise finds the parent and returns `parent->level + 1`.

---

### `FiscalYearService`

File: `app/Services/Finance/FiscalYearService.php`

#### `create(array $data, int $adminId): FiscalYear`
Inside a DB transaction: creates the `FiscalYear` with status `open`, then calls `generateQuarterlyPeriods($year)`.

#### `generateQuarterlyPeriods(FiscalYear $year): void`
Loops i = 1 to 4. For each quarter, calculates `periodStart` as `date_from + (i-1)*3 months` and `periodEnd` as `periodStart + 3 months - 1 day` (or `date_to` for Q4 / if the calculated end overshoots). Creates a `FiscalPeriod` with name `Q{i}` and status `open`.

#### `close(FiscalYear $year, int $adminId): FiscalYear`
Updates `status = 'closed'`, `closed_at = now()`, `closed_by = $adminId`. Returns refreshed model.

---

### `FiscalPeriodService`

File: `app/Services/Finance/FiscalPeriodService.php`

#### `findByDate(string $date): ?FiscalPeriod`
Finds the period whose `date_from <= $date <= date_to`.

#### `close(FiscalPeriod $period, int $adminId): FiscalPeriod`
Updates `status = 'closed'`, `closed_at`, `closed_by`.

#### `reopen(FiscalPeriod $period, int $adminId): FiscalPeriod`
Resets `status = 'open'`, clears `closed_at` and `closed_by`.

#### `assertOpen(FiscalPeriod $period): void`
Throws `RuntimeException` with `finance.period_closed` translation key if `status !== 'open'`.

#### `findPreviousPeriod(FiscalPeriod $period): ?FiscalPeriod`
Finds the most recent period whose `date_to < $period->date_from`, ordered by `date_to DESC, id DESC`.

---

### `FiscalPeriodValidator`

File: `app/Services/Finance/FiscalPeriodValidator.php`

#### `assertOpen(?int $fiscalPeriodId): void`
If `$fiscalPeriodId` is null, returns silently. Otherwise loads the period and throws `RuntimeException` if `status === 'closed'`. Used by voucher posting services.

---

### `JournalService`

File: `app/Services/Finance/JournalService.php`

Standard CRUD service: `getAll()`, `create(array $data)`, `update(Journal $journal, array $data)`, `delete(Journal $journal)`. All operations are straightforward Eloquent calls; no complex business logic.

---

### `JournalEntryService`

File: `app/Services/Finance/JournalEntryService.php`
Injects: `NumberSequenceService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Returns `JournalEntry::with(['journal', 'branch'])->latest('entry_date')->latest('id')->paginate($perPage)`.

#### `getFormOptions(): array`
Returns arrays of active journals (ordered by code), active branches, all fiscal years, all fiscal periods (with year), active postable accounts, and active cost centers — used to populate create/edit form dropdowns.

#### `create(array $data): JournalEntry`
1. Calls `assertPeriodOpen($data['fiscal_period_id'])`.
2. Inside `DB::transaction`: generates entry number via `generateEntryNumber($data['branch_id'], $data['entry_date'])`, creates the `JournalEntry` with status `draft` and `created_by = auth()->id()`, then calls `$journalEntry->lines()->createMany($this->prepareLines($data['lines']))`.
3. Returns the entry with relationships eager-loaded.

#### `update(JournalEntry $journalEntry, array $data): JournalEntry`
1. Calls `assertMutable()` — throws if posted.
2. Calls `assertPeriodOpen()`.
3. Inside transaction: updates header fields, deletes all existing lines, re-creates them from `prepareLines()`.

#### `delete(JournalEntry $journalEntry): bool`
Calls `assertMutable()`, then inside transaction deletes lines and the entry.

#### `prepareLines(array $lines): array` (private)
Maps each line array to a normalized structure with `account_id`, `cost_center_id`, `description`, `debit`, `credit`, `currency_code`, `exchange_rate`.

#### `generateEntryNumber(int $branchId, string $entryDate): string` (private)
Parses year from `$entryDate`. Calls `$numberSequenceService->getOrCreate('finance', 'journal_entries', $branchId, $year)`. Increments the sequence, zero-pads to the sequence's `padding` width, prepends the sequence's `prefix` if set.

#### `assertMutable(JournalEntry $journalEntry): void`
Throws `RuntimeException(trans('finance.posted_entry_immutable'))` if `$journalEntry->isPosted()`.

#### `assertPeriodOpen(int $fiscalPeriodId): void` (private)
Loads the period, throws `RuntimeException(trans('finance.period_closed'))` if `status !== 'open'`.

---

### `JournalPostingService`

File: `app/Services/Finance/Posting/JournalPostingService.php`
Injects: `FiscalPeriodService`

#### `post(JournalEntry $journalEntry, int $adminId): JournalEntry`

The core GL posting method. Runs entirely inside `DB::transaction`.

1. Reloads the entry with `lockForUpdate()` to prevent concurrent posts.
2. Calls `assertPostable()`.
3. Iterates each line, calling `applyLineToBalance()`.
4. Updates the entry: `status = 'posted'`, `posted_at = now()`, `posted_by = $adminId`.
5. Returns the freshly loaded entry with all relationships.

#### `assertPostable(JournalEntry $journalEntry): void` (private)

Runs four checks, throwing `RuntimeException` on failure:
- `status !== 'draft'` → cannot post.
- `lines->isEmpty()` → cannot post.
- `fiscalPeriodService->assertOpen($journalEntry->fiscalPeriod)` → period must be open.
- `assertValidLines()` → each line must have exactly one positive value and a postable account.
- `assertBalanced()` → total debits must equal total credits (rounded to 2 decimal places).

#### `assertValidLines(Collection $lines): void` (private)

For each line:
- If both debit and credit are <= 0, or if both are > 0: throws `RuntimeException`.
- If the line's account is null or `is_postable === false`: throws `RuntimeException`.

#### `assertBalanced(Collection $lines): void` (private)

```php
$totalDebit  = round($lines->sum(fn($l) => (float)$l->debit), 2);
$totalCredit = round($lines->sum(fn($l) => (float)$l->credit), 2);
if ($totalDebit !== $totalCredit) throw new RuntimeException(trans('finance.unbalanced_entry'));
```

#### `applyLineToBalance(JournalEntry $entry, JournalEntryLine $line): void` (private)

1. `$costCenterKey = $line->cost_center_id ?? 0`
2. Calls `findOrCreateBalance()` to get or create the `AccountPeriodBalance` row.
3. `$balance->period_debit += (float)$line->debit` (rounded to 2dp).
4. `$balance->period_credit += (float)$line->credit` (rounded to 2dp).
5. Calls `recalculateClosingBalances($balance)`.
6. Saves the balance row.

#### `findOrCreateBalance(JournalEntry $entry, JournalEntryLine $line, int $costCenterKey): AccountPeriodBalance` (private)

Lookup attributes: `branch_id`, `fiscal_year_id`, `fiscal_period_id`, `account_id`, `cost_center_key`.

- Attempts to find an existing row with `lockForUpdate()`.
- If found, returns it.
- If not found, calls `resolveOpeningBalances()` to find the most recent prior period's closing balance for this account/cost center combination.
- Creates a new `AccountPeriodBalance` row with opening balances from the prior period (or zero if none), zeroed period columns, and closing columns initialized to the opening values.
- On `QueryException` (race condition), retries the `lockForUpdate()` read.

#### `resolveOpeningBalances(FiscalPeriod $period, int $branchId, int $accountId, int $costCenterKey): array` (private)

Walks backward through periods using `fiscalPeriodService->findPreviousPeriod()`, looking for an existing `AccountPeriodBalance` row. Returns `closing_debit` and `closing_credit` of the first match found, or `[0, 0]` if none exist.

#### `recalculateClosingBalances(AccountPeriodBalance $balance): void` (private)

```
openingNet = opening_debit - opening_credit
periodNet  = period_debit  - period_credit
closingNet = round(openingNet + periodNet, 2)

if closingNet >= 0:
    closing_debit  = closingNet
    closing_credit = 0
else:
    closing_debit  = 0
    closing_credit = abs(closingNet)
```

---

### `ReceiptVoucherService`

File: `app/Services/Finance/Vouchers/ReceiptVoucherService.php`
Injects: `ReceiptVoucherNumberService`

#### `create(array $data): ReceiptVoucher`
Inside `DB::transaction`: generates code via `$numberService->generate($data['branch_id'], $data['voucher_date'])`. Creates voucher with `status = 'draft'`.

#### `update(ReceiptVoucher $voucher, array $data): ReceiptVoucher`
Throws `RuntimeException` if `!$voucher->isEditable()`. Updates all non-code fields inside a transaction.

#### `approve(ReceiptVoucher $voucher): ReceiptVoucher`
Throws `RuntimeException` if `status !== 'draft'`. Updates `status = 'approved'`, `approved_at`, `approved_by`.

#### `cancel(ReceiptVoucher $voucher): ReceiptVoucher`
Throws `RuntimeException` if `isPosted()` or `isReversed()`. Sets `status = 'cancelled'`.

#### `getFormOptions(): array`
Returns branches, fiscal years, fiscal periods, active journals, active customers, active postable accounts, active payment methods.

---

### `ReceiptVoucherPostingService`

File: `app/Services/Finance/Vouchers/ReceiptVoucherPostingService.php`
Injects: `NumberSequenceService`, `FiscalPeriodValidator`

#### `post(ReceiptVoucher $voucher): ReceiptVoucher`

Pre-flight checks:
- `$voucher->status !== 'approved'` → throw.
- `!$voucher->journal_id` → throw.
- `$periodValidator->assertOpen($voucher->fiscal_period_id)`.

Inside `DB::transaction`:

1. Generates `$entryNumber` using `NumberSequenceService::getOrCreate('finance', 'journal_entries', $voucher->branch_id, $year)`.
2. Creates a `JournalEntry` with `status = 'posted'`, `posted_at = now()`, `posted_by = $adminId`.
3. Creates line 1 — **Debit `received_in_account_id`** (cash/bank), amount = `$voucher->amount`, credit = 0.
4. If `from_account_id` is set, creates line 2 — **Credit `from_account_id`** (receivable/income), debit = 0, credit = `$voucher->amount`.
5. Updates voucher: `status = 'posted'`, `posted_journal_entry_id = $entry->id`, `posted_at`, `posted_by`.

---

### `VoucherReversalService`

File: `app/Services/Finance/Vouchers/VoucherReversalService.php`
Injects: `NumberSequenceService`, `ReceiptVoucherNumberService`

#### `reverse(ReceiptVoucher $voucher, string $reason): VoucherReversal`

Pre-flight: `!$voucher->isPosted()` → throw.

Inside `DB::transaction`:

1. Generates a new voucher code from `$numberService->generate()`.
2. Creates a reversal `ReceiptVoucher` with `status = 'posted'`, notes = `"Reversal of {code}: {reason}"`.
3. If journal is set, generates a new entry number and creates a `JournalEntry` (immediately posted).
4. Reversal entry lines — **swap Dr/Cr from the original**:
   - **Credit `received_in_account_id`** for `$voucher->amount` (reverses the original debit).
   - If `from_account_id` set: **Debit `from_account_id`** for `$voucher->amount` (reverses the original credit).
5. Links `reversalVoucher->posted_journal_entry_id = $reverseEntry->id`.
6. Creates `VoucherReversal` record with `original_voucher_id`, `reversal_voucher_id`, `reason`, `reversed_by`, `reversed_at`.
7. Updates original voucher `status = 'reversed'`.

---

### `PaymentVoucherService`

File: `app/Services/Finance/Vouchers/PaymentVoucherService.php`

Mirrors `ReceiptVoucherService`. Same create/update/approve/cancel pattern using `PaymentVoucherNumberService` for code generation.

---

### `PaymentVoucherPostingService`

File: `app/Services/Finance/Vouchers/PaymentVoucherPostingService.php`
Injects: `NumberSequenceService`, `PaymentVoucherNumberService`, `FiscalPeriodValidator`

#### `post(PaymentVoucher $voucher): PaymentVoucher`

Pre-flight: status must be `approved`, `journal_id` must be set, period must be open.

Inside transaction:

1. Generates entry number.
2. Creates `JournalEntry` (immediately posted).
3. If `offset_account_id` set: **Debit `offset_account_id`** (expense/payable) for `$voucher->amount`.
4. **Credit `paid_from_account_id`** (cash/bank) for `$voucher->amount`.
5. Updates voucher: `status = 'posted'`, `posted_journal_entry_id`, timestamps.

#### `reverse(PaymentVoucher $voucher, string $reason): PaymentReversal`

Pre-flight: `!$voucher->isPosted()` → throw.

Inside transaction:

1. Generates reversal voucher code.
2. Creates reversal `PaymentVoucher` with `status = 'posted'`.
3. If journal set, creates reversal `JournalEntry` (immediately posted) with swapped lines:
   - **Credit `offset_account_id`** (reverses original debit).
   - **Debit `paid_from_account_id`** (reverses original credit).
4. Creates `PaymentReversal` record.
5. Sets original voucher `status = 'reversed'`.

---

### `ExpenseService`

File: `app/Services/Finance/Expenses/ExpenseService.php`

#### `create(array $data): Expense`
Sets `code` via `generateCode()` (format `EXP-{YEAR}-{5-digit-seq}`), `status = 'draft'`, `paid_status = 'unpaid'`, `paid_amount = 0`.

#### `submit(Expense $expense): Expense`
`abort_unless($expense->status === 'draft', 422)`. Sets `status = 'pending'`.

#### `approve(Expense $expense): Expense`
`abort_unless($expense->status === 'pending', 422)`. Sets `status = 'approved'`, `approved_at`, `approved_by`.

#### `reject(Expense $expense, ?string $notes): Expense`
`abort_unless($expense->status === 'pending', 422)`. Sets `status = 'rejected'`, `rejected_at`, `rejected_by`, optionally updates `notes`.

#### `cancel(Expense $expense): Expense`
`abort_unless($expense->isEditable(), 422)` — only draft or pending. Sets `status = 'cancelled'`.

---

### `InvoiceCollectionService`

File: `app/Services/Finance/Integrations/InvoiceCollectionService.php`
Injects: `ReceiptVoucherNumberService`

#### `collect(Invoice $invoice, array $data): ReceiptVoucher`

Pre-flight:
- `$amount <= 0` → throw.
- `$amount > $invoice->remainingBalance()` → throw.
- `$invoice->isFullyPaid()` → throw.
- `$branchId` unresolvable → throw.

Inside `DB::transaction`:

1. Generates voucher code: `$numberService->generate($branchId, $collectionDate)`.
2. Creates `ReceiptVoucher` with `status = 'draft'`, linking `customer_id`, `invoice_reference`, `received_in_account_id` from `$data`.
3. Calculates new `paid_amount = round($invoice->paid_amount + $amount, 2)`.
4. Determines `payment_status`: `paid` if `newPaid >= total_amount`, `partially_paid` if `newPaid > 0`, else `unpaid`.
5. Updates `$invoice->paid_amount` and `$invoice->payment_status`.
6. Returns the created voucher.

---

### `ExpensePaymentService`

File: `app/Services/Finance/Integrations/ExpensePaymentService.php`
Injects: `PaymentVoucherNumberService`

#### `pay(Expense $expense, array $data): PaymentVoucher`

Pre-flight:
- `$expense->status !== 'approved'` → throw.
- `$expense->isPaid()` → throw.
- `$amount <= 0` → throw.
- `$amount > remaining` (where `remaining = expense->amount - expense->paid_amount`) → throw.

Inside `DB::transaction`:

1. Generates code: `$numberService->generate($branchId, $paymentDate)`.
2. Creates `PaymentVoucher` with `status = 'draft'`, links `expense_reference = $expense->code`, `paid_from_account_id` from `$data`.
3. Calculates new paid amount and `paid_status` (same logic as `InvoiceCollectionService`).
4. Updates expense `paid_amount` and `paid_status`.
5. Returns the created voucher.

---

### `TrialBalanceService`

File: `app/Services/Finance/Reports/TrialBalanceService.php`

#### `getReport(array $filters): array`

```
filters: branch_id, fiscal_year_id, fiscal_period_id, show_zero (bool)
```

Queries `account_period_balances` joined to `accounts`, filtered by branch, fiscal year, period, and `accounts.is_active = true`. Ordered by `accounts.code`. Selects all six balance columns plus account code and name (JSON).

If `show_zero` is false, adds a where clause requiring at least one of the six balance columns to be non-zero.

Maps rows to objects, translating account name via JSON decode with locale fallback (`app locale → 'en' → 'ar' → first`).

Returns:
```php
[
    'rows'   => Collection,   // per-account objects
    'totals' => array,        // summed opening/period/closing debit+credit across all rows
]
```

---

### `BalanceSheetService`

File: `app/Services/Finance/Reports/BalanceSheetService.php`

#### `getReport(array $filters): array`

Queries `account_period_balances` joined to `accounts`, filtered by branch/year/period, restricting to all five account types. Groups by account ID/code/name/type. Uses `SUM(closing_debit)` and `SUM(closing_credit)`.

Amount calculation per row:
- Asset or expense accounts: `closing_debit - closing_credit` (debit-normal).
- All others: `closing_credit - closing_debit` (credit-normal).

Splits rows into `assets`, `liabilities`, `equity` collections. Calculates `netResult` as sum of income account amounts minus sum of expense account amounts.

Summary:
```php
[
    'total_assets'             => float,
    'total_liabilities'        => float,
    'net_result'               => float,
    'total_equity'             => float (equity amounts + netResult),
    'total_liabilities_equity' => float,
    'is_balanced'              => bool (|total_assets - total_liabilities_equity| < 0.01),
]
```

---

### `IncomeStatementService`

File: `app/Services/Finance/Reports/IncomeStatementService.php`

#### `getReport(array $filters): array`

Queries only `income` and `expense` type accounts. Uses `SUM(period_debit)` and `SUM(period_credit)` (movement within the period, not closing balances).

Amount calculation:
- Income: `period_credit - period_debit`.
- Expense: `period_debit - period_credit`.

Returns:
```php
[
    'revenues' => Collection,
    'expenses' => Collection,
    'summary'  => [
        'total_revenues'   => float,
        'total_expenses'   => float,
        'net_result'       => float,
        'net_result_label' => 'net_profit' | 'net_loss',
    ],
]
```

---

### `GeneralLedgerService`

File: `app/Services/Finance/Reports/GeneralLedgerService.php`

#### `getReport(array $filters): array`

```
filters: account_id (required), branch_id (required),
         fiscal_year_id, fiscal_period_id, date_from, date_to
```

Queries `journal_entry_lines` joined to `journal_entries`, `accounts`, `journals`, and left-joined to `cost_centers`. Filters: `status = 'posted'`, `account_id`, `branch_id`, optional year/period/date range. Ordered by `entry_date ASC, entry_id ASC, line_id ASC`.

For each row maintains a `$runningNet` accumulator (debit minus credit). Maps to objects with:
- `running_debit_balance`: `$runningNet` if positive, else 0
- `running_credit_balance`: `abs($runningNet)` if negative, else 0

Returns `rows` collection and `totals` with total debit, total credit, and the final running balances.

---

### `ARAPService`

File: `app/Services/Finance/ARAPService.php`

Five aging buckets: `current` (not yet due), `b1_30` (1–30 days overdue), `b31_60`, `b61_90`, `over_90`.

#### `getARAgingReport(array $filters): array`

Queries `invoices` joined to `customers`. Filters to confirmed/posted invoices that are not fully paid. Calls `aggregateByEntity()` with:
- `amountResolver`: `total_amount - paid_amount` (remaining balance)
- `dateResolver`: `due_date ?: invoice_date`

Returns `rows`, `totals` (per-bucket sums), and `reconciliation` (see below).

#### `getAPAgingReport(array $filters): array`

Queries `purchase_receipts` with `status = 'posted'` and sums line totals. Same bucket logic.

#### `getCustomerStatement(Customer $customer, string $from, string $to): array`

1. Calculates opening balance = `customer->opening_balance` plus all pre-`$from` invoices minus all pre-`$from` posted payments minus all pre-`$from` posted returns.
2. Collects transactions between `$from` and `$to`: invoices (debit), payments (credit), returns (credit).
3. Calls `buildStatement()` which sorts by date and accumulates a running balance: `running += debit - credit`.
4. Returns `opening_balance`, `transactions` with running balance on each, `closing_balance`.

#### `getSupplierStatement(Supplier $supplier, string $from, string $to): array`

Similar pattern using posted purchase receipts as the only transaction type.

#### `arReconciliation(Collection $rows): array` (private)

Fetches all distinct `ar_account_id` values from `customers`. Sums `debit - credit` from posted `journal_entry_lines` for those accounts (the GL view of AR). Compares to the ERP-side open balance from the aging rows. Returns `ledger_balance`, `erp_balance`, `difference`.

#### `aggregateByEntity(...)` (private)

Groups rows by entity key. For each group initializes all bucket columns to 0.0. For each document in the group, calculates days overdue (`today - due_date`). Routes the remaining amount to the appropriate bucket. Filters out entities with zero total.

---

### `CashBankMovementService`

File: `app/Services/Finance/Reports/CashBankMovementService.php`

#### `getMovement(array $filters, int $perPage = 50): LengthAwarePaginator`
Builds a raw DB query (`journal_entry_lines` joined to `journal_entries`, `accounts`, `journals`, `branches`). Filters: `status = 'posted'`, optional `account_id`, `branch_id`, `date_from`, `date_to`. Returns paginated results ordered by date, entry ID.

#### `getTotals(array $filters): array`
Same base query but `selectRaw('SUM(debit), SUM(credit), COUNT(*)')`. Returns `total_debit`, `total_credit`, `net_movement`, `total_lines`.

---

### `ExpenseRegisterService`

File: `app/Services/Finance/Reports/ExpenseRegisterService.php`

#### `getRegister(array $filters, int $perPage = 50): LengthAwarePaginator`
Queries `Expense` excluding cancelled and rejected statuses. Optional filters: `branch_id`, `expense_item_id`, `cost_center_id`, `status`, `paid_status`, `date_from`, `date_to`. Eager-loads branch, expenseItem, costCenter, paymentMethod.

#### `getTotals(array $filters): array`
Returns `count`, `total_amount` (`SUM(amount)`), `total_paid` (`SUM(paid_amount)`).

---

### `ReceiptRegisterService`

File: `app/Services/Finance/Reports/ReceiptRegisterService.php`

#### `getRegister(array $filters, int $perPage = 50): LengthAwarePaginator`
Queries `ReceiptVoucher` excluding cancelled. Filters: `branch_id`, `customer_id`, `status`, `date_from`, `date_to`.

#### `getTotals(array $filters): array`
Returns `count`, `total_amount`.

---

### `PaymentRegisterService`

File: `app/Services/Finance/Reports/PaymentRegisterService.php`

#### `getRegister(array $filters, int $perPage = 50): LengthAwarePaginator`
Queries `PaymentVoucher` excluding cancelled. Filters: `branch_id`, `payment_method_id`, `beneficiary_name` (LIKE), `status`, `date_from`, `date_to`.

#### `getTotals(array $filters): array`
Returns `count`, `total_amount`.

---

### `ExpenseAnalyticsService`

File: `app/Services/Finance/Reports/ExpenseAnalyticsService.php`

All methods use a shared `baseQuery()` that queries `finance_expenses` excluding cancelled/rejected, with optional filters for `branch_id`, `expense_item_id`, `cost_center_id`, `date_from`, `date_to`.

#### `byItem(array $filters): Collection`
Joins `finance_expense_items`. Groups by item ID/code/name. Selects `COUNT(*)`, `SUM(amount)`, `SUM(paid_amount)`. Ordered by `total_amount DESC`.

#### `byBranch(array $filters): Collection`
Joins `branches`. Groups by branch ID/code/name.

#### `byCostCenter(array $filters): Collection`
Left-joins `cost_centers`. Groups by cost center (null cost center appears as a group with null values).

#### `getGrandTotals(array $filters): array`
Returns `expense_count`, `total_amount`, `total_paid`, `total_unpaid` across all filtered expenses.

---

## Models

### `Account`

Table: `accounts`
Traits: `HasTranslations`, `SoftDeletes`

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `code` | string | unique |
| `name` | json | translatable (ar/en) |
| `parent_id` | FK accounts nullable | cascade delete |
| `type` | string | asset/liability/equity/income/expense |
| `normal_balance` | string | debit/credit |
| `level` | tinyint | auto-calculated; root = 1 |
| `is_postable` | boolean | default true |
| `is_active` | boolean | default true |
| `created_by` | FK admins nullable | |
| `updated_by` | FK admins nullable | |

Casts: `is_postable → bool`, `is_active → bool`, `level → int`.
Relationships: `parent()`, `children()`, `creator()`, `updater()`.

---

### `Journal`

Table: `journals`
Traits: `HasTranslations`, `SoftDeletes`

Fillable: `code`, `name` (json/translatable), `is_active`, `created_by`, `updated_by`.
Relationships: `creator()`, `updater()`.

---

### `FiscalYear`

Table: `fiscal_years`

| Column | Type |
|---|---|
| `name` | string |
| `date_from` | date |
| `date_to` | date |
| `status` | string (open/closed) |
| `closed_at` | timestamp nullable |
| `closed_by` | FK admins nullable |

Relationships: `periods()` (hasMany FiscalPeriod), `closer()` (belongsTo Admin).

---

### `FiscalPeriod`

Table: `fiscal_periods`

| Column | Type |
|---|---|
| `fiscal_year_id` | FK fiscal_years |
| `name` | string (Q1–Q4) |
| `date_from` | date |
| `date_to` | date |
| `status` | string (open/closed) |
| `closed_at` | timestamp nullable |
| `closed_by` | FK admins nullable |

Relationships: `fiscalYear()`, `closer()`.

---

### `JournalEntry`

Table: `journal_entries`

| Column | Type |
|---|---|
| `journal_id` | FK journals |
| `entry_number` | string unique |
| `entry_date` | date |
| `branch_id` | FK branches |
| `fiscal_year_id` | FK fiscal_years |
| `fiscal_period_id` | FK fiscal_periods |
| `description` | text nullable |
| `status` | string (draft/posted) |
| `posted_at` | datetime nullable |
| `posted_by` | FK admins nullable |
| `created_by` | FK admins nullable |
| `updated_by` | FK admins nullable |

Casts: `entry_date → date`, `posted_at → datetime`.
Relationships: `journal()`, `branch()`, `fiscalYear()`, `fiscalPeriod()`, `lines()` (hasMany, ordered by id), `creator()`, `updater()`, `poster()`.
Methods: `isPosted(): bool` returns `status === 'posted'`.

---

### `JournalEntryLine`

Table: `journal_entry_lines`

| Column | Type |
|---|---|
| `journal_entry_id` | FK journal_entries |
| `account_id` | FK accounts |
| `cost_center_id` | FK cost_centers nullable |
| `description` | text nullable |
| `debit` | decimal(15,2) |
| `credit` | decimal(15,2) |
| `currency_code` | string nullable |
| `exchange_rate` | decimal(15,6) nullable |

Casts: `debit → decimal:2`, `credit → decimal:2`, `exchange_rate → decimal:6`.
Relationships: `entry()`, `account()`, `costCenter()`.

---

### `AccountPeriodBalance`

Table: `account_period_balances`
Unique constraint: `(branch_id, fiscal_period_id, account_id, cost_center_key)`.

| Column | Type |
|---|---|
| `branch_id` | FK branches |
| `fiscal_year_id` | FK fiscal_years |
| `fiscal_period_id` | FK fiscal_periods |
| `account_id` | FK accounts |
| `cost_center_id` | FK cost_centers nullable |
| `cost_center_key` | bigint unsigned (0 = none, else = cost_center_id) |
| `opening_debit` | decimal(18,2) default 0 |
| `opening_credit` | decimal(18,2) default 0 |
| `period_debit` | decimal(18,2) default 0 |
| `period_credit` | decimal(18,2) default 0 |
| `closing_debit` | decimal(18,2) default 0 |
| `closing_credit` | decimal(18,2) default 0 |

Relationships: `branch()`, `fiscalYear()`, `fiscalPeriod()`, `account()`, `costCenter()`.

---

### `CostCenter`

Table: `cost_centers`
Traits: `HasTranslations`, `SoftDeletes`

| Column | Type |
|---|---|
| `code` | string |
| `name` | json translatable |
| `parent_id` | FK cost_centers nullable |
| `branch_id` | FK branches |
| `is_active` | boolean |
| `created_by`, `updated_by` | FK admins nullable |

Relationships: `parent()`, `children()`, `branch()`, `creator()`, `updater()`.

---

### `ReceiptVoucher`

Table: `finance_receipt_vouchers`
Status enum: `draft | approved | posted | reversed | cancelled`

| Column | Type |
|---|---|
| `code` | string unique |
| `branch_id` | FK branches |
| `fiscal_year_id` | FK nullable |
| `fiscal_period_id` | FK nullable |
| `journal_id` | FK nullable |
| `customer_id` | FK customers nullable |
| `invoice_reference` | string nullable |
| `from_account_id` | FK accounts nullable |
| `received_in_account_id` | FK accounts NOT NULL |
| `payment_method_id` | FK nullable |
| `amount` | decimal(15,2) |
| `notes` | text nullable |
| `voucher_date` | date |
| `status` | enum |
| `posted_journal_entry_id` | FK journal_entries nullable |
| `approved_at`, `approved_by` | timestamp + FK admins |
| `posted_at`, `posted_by` | timestamp + FK admins |
| `created_by`, `updated_by` | FK admins |

Relationships: `branch()`, `fiscalYear()`, `fiscalPeriod()`, `journal()`, `customer()`, `fromAccount()`, `receivedInAccount()`, `paymentMethod()`, `postedJournalEntry()`, `approver()`, `poster()`, `creator()`, `updater()`, `reversal()` (hasOne VoucherReversal).
Methods: `isEditable()` (draft or approved), `isPosted()`, `isReversed()`.

---

### `PaymentVoucher`

Table: `finance_payment_vouchers`
Status enum: `draft | approved | posted | reversed | cancelled`

| Column | Type |
|---|---|
| `code` | string unique |
| `branch_id` | FK branches |
| `fiscal_year_id`, `fiscal_period_id`, `journal_id` | FK nullable |
| `beneficiary_name` | string nullable |
| `beneficiary_reference` | string nullable |
| `expense_reference` | string nullable (links to Expense.code) |
| `paid_from_account_id` | FK accounts NOT NULL |
| `offset_account_id` | FK accounts nullable |
| `payment_method_id` | FK nullable |
| `amount` | decimal(15,2) |
| `notes` | text nullable |
| `voucher_date` | date |
| `status` | enum |
| `posted_journal_entry_id` | FK journal_entries nullable |
| `approved_at`, `approved_by`, `posted_at`, `posted_by`, `created_by`, `updated_by` | timestamps + FK admins |

Relationships: mirrors ReceiptVoucher but replaces customer/AR fields with `paidFromAccount()`, `offsetAccount()`. Reversal: `reversal()` (hasOne PaymentReversal).
Methods: `isEditable()`, `isPosted()`, `isReversed()`.

---

### `Expense`

Table: `finance_expenses`
Status enum: `draft | pending | approved | rejected | cancelled`
Paid status enum: `unpaid | partially_paid | paid`

| Column | Type |
|---|---|
| `code` | string unique (format: EXP-YYYY-00001) |
| `branch_id` | FK branches |
| `fiscal_year_id`, `fiscal_period_id` | FK nullable |
| `expense_item_id` | FK finance_expense_items |
| `cost_center_id` | FK cost_centers nullable |
| `payment_method_id` | FK nullable |
| `amount` | decimal(15,2) |
| `description` | text nullable |
| `expense_date` | date |
| `status` | enum |
| `paid_status` | enum |
| `paid_amount` | decimal(15,2) default 0 |
| `notes` | text nullable |
| `approved_at`, `approved_by`, `rejected_at`, `rejected_by`, `created_by`, `updated_by` | timestamps + FK admins |

Relationships: `branch()`, `fiscalYear()`, `fiscalPeriod()`, `expenseItem()`, `costCenter()`, `paymentMethod()`, `attachments()`, `approver()`, `rejecter()`, `creator()`, `updater()`.
Methods: `isEditable()` (draft or pending), `isPaid()` (paid_status === 'paid').

---

### Setup Models

#### `ExpenseCategory` — table `finance_expense_categories`
Fields: `code`, `name`, `is_active`.

#### `ExpenseItem` — table `finance_expense_items`
Fields: `code`, `name`, `category_id` (FK), `gl_account_id` (optional FK accounts), `is_active`.
Relationships: `category()`, `glAccount()`.

#### `PaymentMethod` — table `finance_payment_methods`
Fields: `code`, `name`, `is_active`.

#### `VoucherPostingProfile` — table `finance_voucher_posting_profiles`
Stores pre-configured account assignments for automated voucher posting.

#### `ReceiptVoucherType` / `PaymentVoucherType`
Classify vouchers by direction (receipt/payment). Used when voucher types need to be pre-defined for a business process.

---

## Key Design Patterns

### Number Sequence Generation

All document numbers (journal entry numbers, voucher codes) use `NumberSequenceService::getOrCreate(module, docType, branchId, year)`. The service finds or creates a sequence record for that combination, atomically increments the counter, and returns the padded, prefixed string. Sequences are branch-scoped and year-scoped.

### Posting is a One-Way Door

Once a `JournalEntry`, `ReceiptVoucher`, or `PaymentVoucher` is posted, it cannot be updated or deleted. The only corrective action is reversal, which creates a new offsetting posted document. This preserves the integrity of the audit trail.

### Period Guard Points

Three different places enforce the "period must be open" rule:
- `JournalEntryService::assertPeriodOpen()` — on create/update of a journal entry.
- `JournalPostingService` via `FiscalPeriodService::assertOpen()` — on posting.
- `FiscalPeriodValidator::assertOpen()` — on voucher posting.

### Balance Table as Report Cache

The `account_period_balances` table is updated incrementally on each posting rather than being recalculated from scratch. The trial balance, balance sheet, and income statement read from this table directly. The general ledger reads from raw `journal_entry_lines` for drill-down detail.

### AR Tracking is Document-Based

The AR aging report and customer statement read directly from the `invoices`, `sales_payments`, and `sales_returns` tables rather than from GL lines. This is because the AR ledger account is shared across all customers. A reconciliation figure comparing the GL balance to the ERP document balance is provided on the AR aging report.
