# Billing API — v1

Base: `/api/v1` · Auth: Sanctum bearer token · Branch: session if present,
else `X-Branch` header. Every endpoint is policy-gated.

**No DELETE for an invoice, a payment or a credit note** — a financial
document is voided, reversed or credited, and the row always survives. The
one DELETE removes a line from a draft. Money serializes everywhere as
`{amount: minor units, currency, formatted}`; figures are the stored values,
computed by the service on the day and never recomputed at read time.
Catalogue/tax management and printing are web-only in v1.

## Endpoints

| Method | Path | Ability | Purpose |
|---|---|---|---|
| GET | `/billing/payment-methods` | `payments.take` | Ways of paying, for the payment form |
| GET | `/billing/catalogue/search` | `catalogue.view` | Charge picker (`q`, min 2 chars) |
| GET | `/invoices` | `invoices.view` | Paginated list, filterable (incl. `outstanding=1`) |
| POST | `/invoices` | `invoices.write` | Open — or reuse — the visit's draft |
| GET | `/invoices/{ulid}` | `invoices.view` | Full read: items+taxes, payments, credit notes |
| POST | `/invoices/{ulid}/issue` | `invoices.issue` | Allocate the number, fix the figures |
| POST | `/invoices/{ulid}/void` | `invoices.void` | Only before money moved |
| POST | `/invoices/{ulid}/discount` | `invoices.write` | Whole-bill discount (draft only) |
| POST | `/invoices/{ulid}/items` | `invoices.write` | Add a charge to a draft |
| DELETE | `/invoices/{ulid}/items/{ulid}` | `invoices.write` | Remove a draft line |
| POST | `/invoices/{ulid}/payments` | `payments.take` | Take money — `Idempotency-Key` header **required** |
| POST | `/payments/{ulid}/reverse` | `payments.reverse` | Negative row pointing at the original |
| POST | `/invoices/{ulid}/refund` | `payments.reverse` | Give back money correctly taken |
| POST | `/invoices/{ulid}/credit-notes` | `credit_notes.issue` | Credit lines (by ULID) or an amount |
| GET | `/cash-sessions` | `cash_sessions.operate` | Own shifts; all with `cash_sessions.oversee` |
| POST | `/cash-sessions` | `cash_sessions.operate` | Open a till with a float |
| GET | `/cash-sessions/{ulid}` | (own or oversee) | Shift + `meta.breakdown` + `meta.expected_cash` |
| POST | `/cash-sessions/{ulid}/close` | (own or oversee) | Reconcile: expected/counted/variance fixed |

### POST /invoices

Body: `patient` (ULID, required), `encounter`, `appointment` (ULIDs,
optional). **One bill per visit**: naming an encounter that already has a
draft returns that draft with `200` instead of `201` — the client can tell
which happened by status code. A visit belonging to a different patient is
`422 invoice_context_mismatch`; the guard lives in the service, so no door
can cross-attach a bill.

### POST /invoices/{ulid}/items

Body: `service` (catalogue ULID, optional — off-list charges are typed
freehand), `description` (required), `unit_price` (required decimal string —
never a float), `quantity`, `discount_type`/`discount_value`, `tax_rate`
(ULID), `performed_by` (user ULID). Catalogue values are copied onto the
line at write time; nothing reads back through the link later.

### Payments — idempotent by header

`POST /invoices/{ulid}/payments` requires the **`Idempotency-Key`** header
(a 36-char token, minted per attempt). Body: `amount` (decimal string),
`payment_method` (ULID), `external_reference`, `notes`. A retry with the
same key returns the first payment instead of charging the patient twice.

Refusals are `422 payment_refused` with the machine-readable why in
`meta.context.reason`: `exceeds_balance` (no overpayments — there is no
patient account for the excess), `invoice_not_issued`,
`cash_requires_open_session` (cash must reconcile to somebody's shift),
`not_positive`, `already_reversed`, `not_reversible`.

### The till

Open with a float; cash payments taken while the shift is open accrue to it.
`GET` shows `meta.expected_cash` live while open; closing stores
`expected_cash`, `counted_cash` and `variance` permanently — a short drawer
is recorded, not hidden. One open till per cashier
(`422 cash_session_error`, reason `already_open`).

### Corrections

Before money moves: `void` (keeps the number, `422/403` once anything was
paid or credited). After: **credit notes only** — by lines (item ULIDs,
credited at what they actually contributed) or by amount. A fully-paid,
fully-credited invoice shows a negative `balance`: what the patient is owed
back, settled via `refund`.

## Error codes

Registered permanently in [errors.md](errors.md) — see the Billing section.
Cross-branch reads are `404 not_found`, byte-identical to a nonexistent ULID.
