# Finance: Ledger, Trust & Disbursements — BAMS Legal

> The double-entry accounting engine underneath everything financial: chart of
> accounts, journals, bank accounts, trust/retainer accounting, and appearance
> fee payouts. Staff-only — no portal or public routes anywhere in this module.

---

## Table of Contents

1. [Account (Chart of Accounts)](#account-chart-of-accounts)
2. [Journal](#journal)
3. [Bank Account](#bank-account)
4. [Retainer Ledger Entry (Trust Accounting)](#retainer-ledger-entry-trust-accounting)
5. [Appearance Fee Claim](#appearance-fee-claim)
6. [Expense](#expense)
7. [Notes & Guidelines](#notes--guidelines)

---

## Account (Chart of Accounts)

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `firm_id` | FK | Yes | — | |
| `code` | String(10) | Yes | — | e.g. `2310`, unique per firm |
| `name` | String | Yes | — | e.g. "Client Retainers Held" |
| `type` | Enum | Yes | — | `asset`, `liability`, `equity`, `revenue`, `expense` |
| `is_active` | Boolean | No | `true` | |

### Admin Routes

**Base:** `/admin/accounts` · **Auth:** session, permission `finance.view` / `finance.post`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/accounts` | List chart of accounts |
| `GET` | `/admin/accounts/:id/ledger` | Statement of a single account (journal lines) |
| `POST` | `/admin/accounts` | Create a custom account (rare — most are seeded) |

### Notes & Guidelines

- Seeded ranges: `1000–1999` Assets, `2000–2999` Liabilities, `3000–3999`
  Equity, `4000–4999` Revenue, `5000–5999` Cost of Service, `6000–7999`
  Operating Expenses. New accounts should respect these ranges.

---

## Journal

Immutable once posted — never edited or deleted, only reversed.

### Schema (Journal)

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | |
| `firm_id` | FK | Yes | — | |
| `journal_date` | Date | Yes | — | |
| `reference` | String | No | `null` | Invoice/receipt/claim number |
| `source_type` / `source_id` | Polymorphic | No | `null` | e.g. `Invoice`, `Receipt`, `AppearanceFeeClaim` |
| `narration` | Text | Yes | — | |

### Schema (Journal Line)

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `journal_id` | FK | Yes | — | |
| `account_id` | FK | Yes | — | |
| `debit` | Decimal(18,2) | No | `0` | |
| `credit` | Decimal(18,2) | No | `0` | |

### Admin Routes

**Base:** `/admin/journals` · **Auth:** session, permission `finance.view` / `finance.reverse`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/journals` | List journals (filter: date range, source type) |
| `GET` | `/admin/journals/:id` | Get journal + its lines |
| `POST` | `/admin/journals/:id/reverse` | Post an equal-and-opposite counter-journal (reason required) |
| `GET` | `/admin/reports/trial-balance` | Trial balance (must net to zero) |

### Request & Response Examples

**Reverse a journal** — `POST /admin/journals/:id/reverse`

```json
{ "reason": "Receipt was allocated to the wrong invoice" }
```

```json
{
  "success": true,
  "data": { "original_journal_id": 3301, "reversal_journal_id": 3315 },
  "message": "Reversal posted."
}
```

### Validation Rules

| Field | Rules |
|---|---|
| Every posting | Sum of `debit` must equal sum of `credit` across all lines in the journal — enforced server-side, not client-side; unbalanced postings are rejected outright, not just warned. |
| Reversal reason | Required, min 10 characters. |

### Notes & Guidelines

- **There is no direct create/edit/delete route for `journal_lines`.** Every
  journal is produced internally by a service call (invoice generation,
  receipt allocation, retainer deposit/drawdown, appearance fee payout) — this
  API surface is read + reverse only.

---

## Bank Account

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `firm_id` | FK | Yes | — | |
| `account_id` | FK | Yes | — | Links to a `1100`-range COA account |
| `bank_name` | String | Yes | — | |
| `account_name` | String | Yes | — | |
| `account_number` | String | Yes | — | |
| `opening_balance` | Decimal(18,2) | No | `0` | |

### Admin Routes

**Base:** `/admin/bank-accounts` · **Auth:** session, permission `finance.manage`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/bank-accounts` | List |
| `POST` | `/admin/bank-accounts` | Create |
| `GET` | `/admin/bank-accounts/:id/reconcile` | Reconciliation staging view |
| `POST` | `/admin/bank-accounts/:id/reconcile` | Match statement lines to journal entries |

### Notes & Guidelines

- `account_number` is a sensitive field — masked in list views
  (`****4521`), full number visible only on the single-record detail view.

---

## Retainer Ledger Entry (Trust Accounting)

**Principle: client money held is a liability, never revenue, until earned.**

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | |
| `firm_id` | FK | Yes | — | |
| `client_id` | FK | Yes | — | |
| `matter_id` | FK | No | `null` | Null = general (firm-wide) retainer |
| `type` | Enum | Yes | — | `deposit`, `drawdown`, `refund` |
| `amount` | Decimal(18,2) | Yes | — | |
| `receipt_id` / `invoice_id` | FK | No | `null` | |
| `narration` | Text | No | `null` | |

### Admin Routes

**Base:** `/admin/retainers` · **Auth:** session, permission `trust.manage`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/retainers` | Balances by client |
| `GET` | `/admin/retainers/:clientId/statement` | Full ledger for one client (+ optional matter filter) |
| `POST` | `/admin/retainers/deposit` | Record a client deposit |
| `POST` | `/admin/retainers/apply` | Draw down against an invoice |
| `POST` | `/admin/retainers/refund` | Refund unused retainer |

### Request & Response Examples

**Drawdown beyond balance (blocked)** — `POST /admin/retainers/apply`

```json
{ "client_id": 88, "invoice_id": 640, "amount": 200000 }
```

```json
{
  "success": false,
  "error": {
    "code": "TRUST_OVERDRAWN",
    "message": "Requested drawdown (₦200,000.00) exceeds the client's retainer balance (₦115,312.50)."
  }
}
```

### Validation Rules

| Field | Rules |
|---|---|
| `amount` | Required, > 0. |
| `drawdown` / `refund` | Amount must not exceed the current balance — checked under a row lock so concurrent requests can't both succeed against the same balance. |

### Notes & Guidelines

- **⚠️ This is the highest-scrutiny feature in the system.** Deposit posts
  DR Bank / CR `2310 Client Retainers Held`. Drawdown posts DR `2310` / CR
  `4100 Legal Fees Revenue`. Refund posts DR `2310` / CR Bank. Every posting
  goes through the same `LedgerService` as everything else — no special-case
  writer.
- Overdraw is blocked **even under concurrent requests** — this is tested
  explicitly, not just assumed from application-level validation.

---

## Appearance Fee Claim

### Schema (Fee Scale)

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `firm_id` | FK | Yes | — | |
| `court_type` | Enum | Yes | — | |
| `seniority` | String | No | `null` | Null = applies to any seniority |
| `amount` | Decimal(18,2) | Yes | — | |

### Schema (Claim)

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | |
| `firm_id` | FK | Yes | — | |
| `hearing_id` | FK | Yes | — | |
| `user_id` | FK | Yes | — | The claiming lawyer |
| `matter_id` | FK | Yes | — | Denormalized for reporting |
| `fee_amount` | Decimal(18,2) | Yes | — | |
| `expense_amount` | Decimal(18,2) | No | `0` | Transport etc. |
| `expense_details` | Text | No | `null` | |
| `status` | Enum | No | `requested` | `requested`, `approved`, `rejected`, `paid` |
| `approved_by` | FK (User) | No | `null` | |
| `journal_id` | FK | No | `null` | Set once paid |

### Admin Routes

**Base:** `/admin/appearance-fees` · **Auth:** session, permission `appearance_fees.create` / `.approve` / `.pay`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/appearance-fees` | List (filters: lawyer, status, date range) |
| `POST` | `/admin/appearance-fees` | Raise a claim for an attended hearing (amount pre-filled from fee scale) |
| `POST` | `/admin/appearance-fees/:id/approve` | Approve |
| `POST` | `/admin/appearance-fees/:id/reject` | Reject (reason required) |
| `POST` | `/admin/appearance-fees/pay` | Batch payout for selected approved claims |
| `GET` | `/admin/appearance-fees/scales` | Manage fee scales |

### Request & Response Examples

**Self-approval attempt (blocked)** — `POST /admin/appearance-fees/:id/approve`

```json
{ "error": { "code": "SELF_APPROVAL_DENIED", "message": "You cannot approve your own appearance fee claim." } }
```

**Batch payout** — `POST /admin/appearance-fees/pay`

```json
{ "claim_ids": [201, 202, 205], "bank_account_id": 1 }
```

```json
{
  "success": true,
  "data": { "payments_created": 2, "total_paid": 145000 },
  "message": "Batch payout complete. 2 lawyers paid."
}
```

### Validation Rules

| Field | Rules |
|---|---|
| Claim creation | Only allowed on a hearing where the claimant's attendance is `attended` or `covered`. One claim per lawyer per hearing (unique constraint). |
| Approval | `$claim->user_id !== $actor->id` enforced at the policy layer — structurally cannot approve your own claim. |
| Edit after approval | Blocked — a rejected claim can be revised and resubmitted, an approved one cannot be edited. |

---

## Expense

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `firm_id` | FK | Yes | — | |
| `matter_id` | FK | No | `null` | |
| `account_id` | FK | Yes | — | Expense account |
| `amount` | Decimal(18,2) | Yes | — | |
| `incurred_on` | Date | Yes | — | |
| `description` | Text | Yes | — | |
| `is_client_recoverable` | Boolean | No | `false` | |
| `status` | Enum | No | `recorded` | `recorded`, `billed` |

### Admin Routes

**Base:** `/admin/expenses` · **Auth:** session, permission `finance.post`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/expenses` | List |
| `POST` | `/admin/expenses` | Record an expense |

### Notes & Guidelines

- Client-recoverable expenses post to `1310 Client Disbursements Recoverable`
  (an asset) until billed, at which point M06's invoice generation picks them
  up as a line item and the status flips to `billed`.

---

## Cross-Module Reports

`GET /admin/reports/aging` and `GET /admin/reports/profitability` live here
conceptually but are surfaced through M10's Reports UI — see that module's
README for routes.

## Notes & Guidelines (module-wide)

- **`LedgerService::post()` is the only writer to `journal_lines`.** Every
  Action in M06/M07 calls it; there is no alternate code path. A grep for
  direct inserts to `journal_lines` outside the service should return nothing.
- Trial balance must net to zero after any sequence of operations — this is
  a standing invariant test, not just a report to eyeball.
