# Time & Billing — BAMS Legal

> Time capture, fee arrangements, and invoice generation — VAT/WHT aware for
> Nigerian legal billing. Invoices/receipts are also readable (never writable)
> through the M11 Client Portal in sanitized form.

---

## Table of Contents

1. [Fee Arrangement](#fee-arrangement)
2. [Time Entry](#time-entry)
3. [Invoice](#invoice)
4. [Receipt](#receipt)
5. [Invoice Lifecycle](#invoice-lifecycle)
6. [Portal Routes](#portal-routes)
7. [Notes & Guidelines](#notes--guidelines)

---

## Fee Arrangement

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `matter_id` | FK | Yes | — | |
| `type` | Enum | Yes | — | `fixed`, `hourly`, `retainer`, `contingency`, `appearance_based`, `pro_bono` |
| `fixed_amount` | Decimal(18,2) | No | `null` | |
| `hourly_rate` | Decimal(18,2) | No | `null` | |
| `retainer_amount` | Decimal(18,2) | No | `null` | |
| `retainer_period` | Enum | No | `null` | `monthly`, `quarterly`, `annual` |
| `contingency_percent` | Decimal(8,2) | No | `null` | |
| `cap_amount` | Decimal(18,2) | No | `null` | |
| `status` | Enum | No | `active` | `active`, `superseded` |

### Admin Routes

**Base:** `/admin/matters/:matterId/fee-arrangement` · **Auth:** session, permission `billing.invoice`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/matters/:id/fee-arrangement` | Get the active arrangement |
| `POST` | `/admin/matters/:id/fee-arrangement` | Create/replace (supersedes any existing active one) |

### Validation Rules

| Field | Rules |
|---|---|
| `type` | Required. |
| `hourly_rate` | Required if `type = hourly`. |
| `retainer_amount` + `retainer_period` | Both required if `type = retainer`. |
| Only one `active` arrangement per matter — creating a new one auto-supersedes the prior. |

---

## Time Entry

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `firm_id` | FK | Yes | — | |
| `matter_id` | FK | Yes | — | |
| `user_id` | FK | Yes | — | |
| `worked_on` | Date | Yes | — | |
| `minutes` | Integer | Yes | — | Rendered as `h:mm` in UI |
| `description` | Text | Yes | — | |
| `rate` | Decimal(18,2) | No | `null` | Snapshotted at entry time |
| `billable` | Boolean | No | `true` | |
| `invoice_item_id` | FK | No | `null` | Set once billed — **prevents double-billing** |

### Admin Routes

**Base:** `/admin/matters/:matterId/time-entries` · **Auth:** session, permission `time.view` / `time.create`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/matters/:id/time-entries` | List a matter's time entries |
| `GET` | `/admin/time-entries/mine` | "My Time" personal weekly timesheet |
| `POST` | `/admin/matters/:id/time-entries` | Log time |
| `PATCH` | `/admin/time-entries/:id` | Update (only if not yet billed) |
| `DELETE` | `/admin/time-entries/:id` | Delete (only if not yet billed) |

### Request & Response Examples

**Log time** — `POST /admin/matters/:id/time-entries`

```json
{
  "worked_on": "2026-07-04",
  "minutes": 90,
  "description": "Reviewed pleadings and drafted response to motion",
  "billable": true
}
```

```json
{
  "success": true,
  "data": { "id": 1420, "minutes": 90, "rate": 25000, "billable": true },
  "message": "Time entry logged."
}
```

### Validation Rules

| Field | Rules |
|---|---|
| `worked_on` | Required. Cannot be in the future. |
| `minutes` | Required. Integer, min 1. |
| `description` | Required. Min 5 characters. |
| Edit/delete | Blocked once `invoice_item_id` is set — a billed entry is immutable. |

---

## Invoice

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `firm_id` | FK | Yes | — | |
| `invoice_number` | String | Auto | — | `INV/2026/0001` |
| `client_id` | FK | Yes | — | |
| `matter_id` | FK | No | `null` | |
| `issue_date` / `due_date` | Date | Yes | — | |
| `subtotal` | Decimal(18,2) | Yes | — | |
| `vat_amount` | Decimal(18,2) | No | `0` | |
| `wht_amount` | Decimal(18,2) | No | `0` | |
| `total` | Decimal(18,2) | Yes | — | `subtotal + vat_amount - wht_amount` |
| `amount_paid` | Decimal(18,2) | No | `0` | |
| `status` | Enum | No | `draft` | `draft`, `sent`, `partially_paid`, `paid`, `overdue`, `void` |

### Admin Routes

**Base:** `/admin/invoices` · **Auth:** session, permission `billing.invoice` / `billing.viewAll`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/invoices` | List (filters: client, matter, status, date range) |
| `GET` | `/admin/invoices/:id` | Get invoice detail + line items |
| `GET` | `/admin/matters/:id/billing/unbilled` | View unbilled time entries + expenses |
| `POST` | `/admin/matters/:id/billing/generate` | Generate a draft invoice from selected unbilled items |
| `PATCH` | `/admin/invoices/:id/send` | Transition draft → sent |
| `POST` | `/admin/invoices/:id/void` | Void an unpaid invoice (reason required) |
| `GET` | `/admin/invoices/:id/pdf` | Download invoice PDF |

### Request & Response Examples

**Generate from unbilled time** — `POST /admin/matters/:id/billing/generate`

```json
{ "time_entry_ids": [1420, 1421, 1425], "expense_ids": [] }
```

```json
{
  "success": true,
  "data": {
    "id": 640, "invoice_number": "INV/2026/0102", "status": "draft",
    "subtotal": 112500, "vat_amount": 8437.50, "wht_amount": 5625, "total": 115312.50
  },
  "message": "Draft invoice generated from 3 time entries."
}
```

**Void an invoice** — `POST /admin/invoices/:id/void`

```json
{ "reason": "Duplicate invoice raised in error" }
```

```json
{ "success": false, "error": { "code": "INVOICE_HAS_PAYMENTS", "message": "This invoice has receipts applied and cannot be voided. Reverse the receipt first." } }
```

### Validation Rules

| Field | Rules |
|---|---|
| `time_entry_ids` / `expense_ids` | Must belong to the matter and be unbilled — locked at generation time to prevent double-billing under concurrent requests. |
| Void | Only allowed if `status != paid` and `amount_paid = 0`. Reason required, logged. |
| Once `sent` | Line items become immutable — corrections are a credit note (new negative-line invoice), never an edit. |

---

## Receipt

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `firm_id` | FK | Yes | — | |
| `receipt_number` | String | Auto | — | |
| `invoice_id` | FK | Yes | — | |
| `bank_account_id` | FK | No | `null` | |
| `amount` | Decimal(18,2) | Yes | — | |
| `received_on` | Date | Yes | — | |
| `method` | Enum | No | `null` | `transfer`, `cash`, `cheque`, `pos` |
| `reference` | Text | No | `null` | |

### Admin Routes

**Base:** `/admin/receipts` · **Auth:** session, permission `billing.invoice`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/receipts` | List |
| `POST` | `/admin/receipts` | Record a receipt and allocate to an invoice (posts to the ledger — see M07) |
| `GET` | `/admin/receipts/:id/pdf` | Download receipt PDF |

### Validation Rules

| Field | Rules |
|---|---|
| `amount` | Required, > 0, cannot exceed the invoice's outstanding balance. |
| `invoice_id` | Must not be `void`. |

---

## Invoice Lifecycle

```
Time entries logged (billable = true)
        │
        ▼
POST /admin/matters/:id/billing/generate
        │
        ▼
  status = "draft"  →  entries locked to invoice_item_id (no double-billing)
        │
        ▼
PATCH /admin/invoices/:id/send
        │
        ▼
  status = "sent"  →  visible to the client in the Portal (M11)
        │
        ▼ (receipt recorded, partial or full)
POST /admin/receipts
        │
        ▼
  status = "partially_paid" or "paid"
        │
        ▼ (if unpaid and no longer needed)
POST /admin/invoices/:id/void
```

---

## Portal Routes

**Base:** `/portal/invoices` · **Auth:** portal session, scoped to the logged-in client's `client_id` only

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/portal/invoices` | List the client's own invoices |
| `GET` | `/portal/invoices/:ulid/pdf` | Download own invoice PDF |
| `GET` | `/portal/receipts` | List own receipts |

> There are no portal `POST`/`PATCH`/`DELETE` routes on invoices or receipts —
> the portal is strictly read-only for financial records.

---

## Notes & Guidelines

- **⚠️ VAT/WHT rates are firm-configurable** (M01 Settings) but snapshotted
  onto the invoice at generation time — a later rate change never retroactively
  alters an issued invoice.
- **Double-billing is structurally impossible**: a time entry's
  `invoice_item_id` is set inside the same transaction that creates the
  invoice, under a row lock, so two concurrent "generate invoice" calls can
  never both claim the same entry.
- `invoices.amount_paid` is **only ever changed by the receipt-allocation
  Action**, which posts a corresponding ledger entry (M07) in the same
  transaction — there is no direct field-edit path.
- Portal invoice visibility begins only at `status = sent` — a `draft`
  invoice is never visible to the client, even if they somehow guessed its ID.
