# M07 — Finance: Ledger, Trust & Disbursements

Phase 3. The accounting engine underneath everything financial: chart of
accounts, double-entry journals, bank accounts, trust/retainer accounting, firm
expenses, and appearance fee payouts. Build this alongside M06 — they're
tightly coupled.

## Dependencies
M01 (numbering, settings), M03 (matters), M04 (hearings, for appearance fees).

## Chart of Accounts

Standard ranges, seeded on firm creation:
```
1000–1999  Assets       (1100 Bank, 1310 Client Disbursements Recoverable)
2000–2999  Liabilities  (2310 Client Retainers Held)
3000–3999  Equity
4000–4999  Revenue      (4100 Legal Fees Revenue)
5000–5999  Cost of Service
6000–7999  Operating Expenses (6210 Appearance Fees, 6220 Court Disbursements)
```

### `accounts`
```php
$table->id();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->string('code', 10); $table->string('name');
$table->string('type');    // asset/liability/equity/revenue/expense
$table->boolean('is_active')->default(true);
$table->unique(['firm_id', 'code']);
```

### `journals` / `journal_lines`
```php
// journals
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->date('journal_date');
$table->string('reference')->nullable();     // invoice/receipt/claim number
$table->string('source_type')->nullable();   // Invoice/Receipt/AppearanceFeeClaim/RetainerEntry/ManualEntry
$table->unsignedBigInteger('source_id')->nullable();
$table->text('narration');
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->index(['source_type', 'source_id']);

// journal_lines
$table->id();
$table->foreignId('journal_id')->constrained()->cascadeOnDelete();
$table->foreignId('account_id')->constrained()->restrictOnDelete();
$table->decimal('debit', 18, 2)->default(0);
$table->decimal('credit', 18, 2)->default(0);
$table->timestamps();
$table->index(['account_id', 'created_at']);
```
**Invariant**: every `journals` row's child `journal_lines` must sum
`debit = credit`. Enforced in `LedgerService::post()` (throws if unbalanced) —
never bypassable, since it's the only writer.

### `bank_accounts` / `bank_transactions`
```php
// bank_accounts
$table->id();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('account_id')->constrained()->restrictOnDelete();  // links to COA 1100-range
$table->string('bank_name'); $table->string('account_name');
$table->string('account_number');
$table->decimal('opening_balance', 18, 2)->default(0);
$table->timestamps();

// bank_transactions — reconciliation staging, matched against journal_lines
$table->id();
$table->foreignId('bank_account_id')->constrained()->cascadeOnDelete();
$table->date('transaction_date'); $table->decimal('amount', 18, 2);
$table->text('description')->nullable();
$table->boolean('is_reconciled')->default(false);
$table->foreignId('journal_line_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
```

## `LedgerService` — the only writer to `journal_lines`

```php
LedgerService::post(
    date: $date,
    narration: string,
    lines: [[account_id, debit, credit], ...],
    source: Model $source = null,
): Journal
```
Validates balance, wraps in `DB::transaction`, writes journal + lines, returns
the `Journal`. `LedgerService::reverse(Journal $journal, string $reason)` posts
an equal-and-opposite counter-journal — the ONLY way to "undo" a posting;
originals are never edited or deleted.

## Trust / Retainer Accounting (highest-scrutiny feature)

**Principle**: client money held is a liability (`2310`) until earned, never
revenue.

### `retainer_ledger_entries`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('client_id')->constrained()->restrictOnDelete();
$table->foreignId('matter_id')->nullable()->constrained()->nullOnDelete();
$table->string('type');   // deposit / drawdown / refund
$table->decimal('amount', 18, 2);
$table->foreignId('receipt_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete();
$table->text('narration')->nullable();
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->index(['client_id', 'matter_id']);
```

`RetainerService`:
- `deposit()` → DR Bank (1100) / CR Client Retainers Held (2310); entry `deposit`.
- `applyToInvoice(Client, Invoice, amount)` → **must** `lockForUpdate` the
  client's retainer entries when computing balance, then check
  `amount <= balance` before posting DR 2310 / CR 4100 (Legal Fees Revenue) +
  entry `drawdown`. Throws `TrustOverdrawException` if insufficient — this
  exception and its test (concurrent drawdown requests, only one should
  succeed if balance covers exactly one) are the single most important test in
  this module.
- `refund()` → DR 2310 / CR Bank; entry `refund`; also blocked beyond balance.
- `balance(Client $client, ?Matter $matter = null): string` (decimal-safe).

## Appearance Fee Claims (integrates M04's hearings)

### `appearance_fee_scales`
```php
$table->id();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->string('court_type'); $table->string('seniority')->nullable();
$table->decimal('amount', 18, 2);
$table->unique(['firm_id', 'court_type', 'seniority']);
```

### `appearance_fee_claims`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('hearing_id')->constrained()->restrictOnDelete();
$table->foreignId('user_id')->constrained()->restrictOnDelete();
$table->foreignId('matter_id')->constrained()->restrictOnDelete();
$table->decimal('fee_amount', 18, 2);
$table->decimal('expense_amount', 18, 2)->default(0);
$table->text('expense_details')->nullable();
$table->string('status')->default('requested');  // requested/approved/rejected/paid
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
$table->dateTime('approved_at')->nullable();
$table->foreignId('journal_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
$table->unique(['hearing_id', 'user_id']);
```

**Segregation of duties**: `AppearanceFeeClaimPolicy::approve` explicitly denies
`$claim->user_id === $actor->id` regardless of permissions — self-approval is
structurally impossible, tested.

`AppearanceFeeService::payBatch(Collection $claims, BankAccount $from, User
$actor)`: groups by lawyer, one journal per lawyer per batch: DR 6210
(fees) + DR 6220 (expenses) / CR 1100 Bank; marks claims `paid`, links
`journal_id`.

## Firm expenses

### `expenses`
```php
$table->id();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('matter_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('account_id')->constrained()->restrictOnDelete();  // expense account
$table->decimal('amount', 18, 2); $table->date('incurred_on');
$table->text('description');
$table->boolean('is_client_recoverable')->default(false);
$table->string('status')->default('recorded');
$table->timestamps();
```
Client-recoverable expenses post to `1310 Client Disbursements Recoverable`
until billed (then flow into `invoice_items` via M06 with `source_type =
expense`), non-recoverable post straight to the expense account.

## Reports (built here, surfaced via M10)

- **Aging**: outstanding invoice balances bucketed 0-30/31-60/61-90/90+, by
  client and matter.
- **Profitability per matter**: (invoiced + retainer drawdowns) − appearance
  fees − case expenses.
- **Trial balance**: sum of all account balances, must net to zero (a
  standing invariant test, not just a report).

## Acceptance criteria

- [ ] `LedgerService::post()` rejects unbalanced entries (unit test)
- [ ] Every M06/M07 financial Action posts through `LedgerService` only (static
      review: grep for direct `journal_lines` inserts outside the service —
      should return zero)
- [ ] Retainer drawdown beyond balance blocked under concurrent load (the key test)
- [ ] Appearance fee claim self-approval structurally blocked (Policy test)
- [ ] Batch payout produces correct per-lawyer journals; claims linked and marked paid
- [ ] Client-recoverable expenses correctly move from receivable to billed
- [ ] Trial balance nets to zero after a scripted sequence of the above operations
      (integration/invariant test)
- [ ] Aging report buckets match invoice due dates exactly

## Claude Code kickoff prompt

> Read CLAUDE.md, docs/03-DATA-MODEL.md, docs/04-SECURITY.md, and this file.
> Build in order: COA seeder → LedgerService (test the balance-invariant first)
> → bank accounts → RetainerService (write the concurrent-overdraw test BEFORE
> the implementation — this is the module's crux) → appearance fee scales/claims
> + AppearanceFeeClaimPolicy (self-approval-blocked test first) →
> AppearanceFeeService::payBatch → expenses → aging/profitability/trial-balance
> reports. Coordinate with M06 since invoices and receipts call into
> LedgerService directly.
