This page documents the contract of the current financials surface.
Companies are enabled on it progressively; a company not yet enabled gets
404 from these endpoints (they behave as if they do not exist yet) and is
served by the deprecated legacy endpoints
below until its cutover. The policy-invoice batch is the exception: its
separate policy-financials rollout gate returns 403 when disabled, never a
flag-derived 404.Design rules
These rules hold across the whole surface — each operation’s page restates only what is specific to it.Idempotency: client-supplied actionId
Every write takes a client-minted actionId (a uuid) that becomes the
recorded action’s journal id. Retrying the identical request replays the
original outcome — server-minted values (invoice id and number, the created
marks’ payment ids) included — instead of applying twice; duplicate
resolution runs before every other check, so a replay succeeds even with a
stale If-Match. Reusing an id with a different action or payload is
409 ACTION_ID_REUSED. A payment gesture becomes one action per per-line
mark: the actionId anchors the FIRST mark’s action, sibling marks mint their
own ids in the same transaction, and a replay reconstructs the full set.
Compound endpoints follow the same rule per client-authored action: the
event import takes one
actionId per create and per reserve set, commits atomically, and
short-circuits a retry when your ids are already journaled. The
payee merge takes no actionIds at
all — it is idempotent by convergence. The
policy invoice batch also
takes no actionId: its per-invoice watermarks protect the input snapshot and
the whole policy scope is serialized in one database transaction.
Authorship: the optional author label
Every write except the policy-invoice batch accepts an optional author
(1–255 characters) — a display label for the source system’s author, e.g. the
person acting in your system. The batch body is exactly its detached explicit
plan and has no author field. Where the app shows who recorded a change, a
labeled row reads as your label; an unlabeled row reads as the built-in
“External API” actor. Rows written through this API are always visibly marked
as API-written regardless of the label — author is display attribution,
never an identity: it cannot impersonate an in-app user, and the recorded
acting principal is always the API surface itself. On an idempotent replay the
original record — original label included — is returned and the retried
author is ignored.
Concurrency: the If-Match watermark
Every invoice row carries headJournalId — the id of the last action that
touched it. Every single-invoice write after creation must send it as
If-Match. A stale value is 409 IF_MATCH_CONFLICT with the invoice’s
current row in the body (error.currentInvoice): re-read, reconcile,
retry with its headJournalId. A missing or malformed header is 400.
Reserve writes and the compositions are exempt — they serialize on scope
locks instead.
Stable error codes (422)
A write whose payload is well-formed but rejected by the books fails with
422 and a stable machine-readable code (LIVE_PAYMENTS,
AMOUNT_EXCEEDS_BALANCE_DUE, INVALID_AMOUNT, INVOICE_VOIDED,
LINE_ITEMS_IMMUTABLE, UNKNOWN_ID, …— the full 18-value enum is on
every write’s 422 schema). Codes are contract; messages may be reworded.
Preconditions fail closed: a rejected write leaves no journal record, no
ledger change, no read-state change.
Malformed payloads (unknown fields, both links.event and links.policy,
both or neither payment mode) are schema-level 400s. Unknown path
resources (invoice, event) are 404. A valid API key naming another company
in the URL is 403.
Writes return refreshed read state
Every write’s response is the refreshed read state, not an acknowledgement: invoice writes return{journalIds, invoice, payments} (plus top-level
paymentIds and createdPayments — the created mark rows — when recording
a payment); reserve writes return the scope’s new expectedTotalCents.
journalIds carries every journal id the write emitted — a payment’s sibling
marks and companions included, e.g. the automatic reserve unwind composed
when a pre-horizon eroding payment is removed. There is one
invoice representation: reads and write responses share the same invoice
shape.
Amounts and dates
All amounts are integer cents. On line items, direction never rides the sign:amountCents is the magnitude a user would type, and a negative
amount is a credit within the item’s own frame — it posts opposite the line
item type’s expected direction (e.g. a reversal or refund); the posting rule
always comes from the type, never the sign.
The invoice’s document scalars are oriented net-cash figures:
totalAmountCents sums the line amounts oriented by their types’ directions
(payable +, receivable −) — the net cash the document commits to move;
amountPaidCents is the oriented sum of live payment marks — net cash moved
so far; balanceDueCents is their difference — net cash remaining, positive
= out, negative = in. A document whose oriented due is negative is an open
credit (money in), and the due may move non-monotonically as
opposite-direction lines settle.
A payment is a per-line settlement mark, signed in its line’s frame, and
settles that line’s remaining toward zero — per line, never as a
document scalar; an invoice is paid when every line is settled, never when
a scalar hits zero. The record-payment request names its marks in that same
line frame, or asks for the whole balance due (see
Record Payment). A reserve
scope’s remaining reserve is likewise a signed balance — the identity
Reserves + Paid = Expected Total is its only law, and the remaining may read
negative (over-paid against a standing estimate).
Balance reads return raw
signed cents in the ledger convention with each account’s role and
provenance direction (and each category’s expectedDirection) embedded;
the carrying side derives from role × direction (assets/expenses-side
accounts carry debit, liabilities/income-side carry credit), so clients
orient displays themselves. Dates are ISO YYYY-MM-DD; timestamps are ISO
8601.
Cursor pagination
List reads are cursor-paginated: pass the previous page’snextCursor back
as cursor until it is null. Cursors are opaque — echo them verbatim.
API Endpoints
Invoice reads
- List Invoices (
GET /financials/invoices) — global and entity-scoped lists in one endpoint: filter by status, category, linked event/policy/payee, invoice number, and date ranges; cursor-paginated. Deleted invoices are hidden. - Get Invoice (
GET /financials/invoices/{invoiceId}) — the whole current document with its live payments andheadJournalId. Returns deleted invoices too (a delete’s confirmable terminal state).
Invoice writes
- Create Invoice (
POST /financials/invoices) — the server mints the id and invoice number; links (event or policy, plus payee) are optional and can attach later. - Update Invoice (
PUT /financials/invoices/{invoiceId}) — once an invoice is POSTED its line items and category are immutable (echo the stored set verbatim; any deviation is422 LINE_ITEMS_IMMUTABLE, at any payment count): the mutable remainder is dates, memo, andfieldData, and corrections are void-and-recreate. Drafts still replace their document wholesale until finalize. Links excluded either way (they change via the endpoints below). - Delete Invoice (
DELETE /financials/invoices/{invoiceId}) — terminal; live payments are swept in the same action. - Re-link Invoice (
POST …/relink) — attach/detach/move the event or policy link; zero ledger rows; requires no live payments. - Change Invoice Payee (
POST …/payee) — set/clear/change the payee; requires no live payments. - Record Payment (
POST …/payments) — eitherpayBalanceDue: true(settle every open line, both directions) or explicit per-lineallocations, exactly one per request; returns the created mark rows;erodeReservessteers reserve erosion on reserved-category invoices. - Remove Payment (
DELETE …/payments/{paymentId}) — exact negation; the horizon rule’s companion reserve unwind composes automatically. - Void Invoice (
POST …/void) / Restore Invoice (POST …/restore) — write the charges down to zero and back; both have defined zero-row no-ops (re-void, restore of a non-voided invoice). - Approve Invoice (
POST …/approve) / Unapprove Invoice (POST …/unapprove) — flip the document’s approval state; zero ledger rows. Money may only post against an approved invoice (paid ⇒ approved), so approve before recording a payment — or let Record Payment approve for you (below). Both needcompany.payment:approve, which is also the whole authority: the company’s in-app approval rules never apply to API callers. Re-approving keeps the ORIGINAL approval; unapproving a paid or partially-paid invoice is422 UNAPPROVE_PAID.
Approval and payments. Recording a payment on an UNAPPROVED invoice
approves it automatically when the calling key holds
company.payment:approve (in the same transaction, before the payment
marks); a key without the permission gets 422 NOT_APPROVED instead. The
auto-approval shows up as approved: true on the response’s invoice row and
is deliberately absent from journalIds, so an idempotent actionId replay
returns a byte-identical body.Bill review
A bill review checks one invoice against the company’s own bill review rules and reports what it thinks is wrong with it. It is asynchronous: starting one queues background work and returns immediately, and the verdict arrives on the read.- Start Invoice Bill Review (
POST …/bill-review) —202with therunIdto poll. No request body, noactionId, noIf-Match: the rules are the company’s own (snapshotted when the run is created) and the invoice version is pinned from its current head, so there is nothing to send, and a review records a verdict beside the invoice rather than journaling an action against it. Re-posting while a review of this invoice is under way creates nothing and returnsoutcome: in_flightwith the run already running; once that run is terminal, the next call mints a fresh one. A company at its background-work capacity gets a429and nothing is queued. - Get Invoice Bill Review (
GET …/bill-review) — the latest run, its verdict and its findings. Poll untilstatusissucceededorfailed;passedisnulluntil then, and staysnullforever on afailedrun, so readerrorthere rather than reading an emptyfindingslist as a pass.billReviewisnullwhen the invoice has never been reviewed.
Findings can be stale, and you check that yourself.
invoiceHeadJournalId is the invoice version the run judged, pinned when the
review was started; a review is never re-run because the document changed. If
it differs from the invoice’s current headJournalId, the findings describe an
earlier version — start a fresh review. Dismissals (dismissedAt) are stamps
an operator applies in the app, not deletes, so dismissed findings are still
returned; filter on the field if you only want outstanding ones.Reserves
- Set Reserves (
PUT /financials/events/{eventId}/reserves/{categoryId}) — absolute “set expected total to X” per(event, reserved category)scope; previous total and delta are recorded server-side. X is unconstrained — a set below the scope’s paid-to-date is legal and leaves the remaining reserve negative. - Reset Reserve History (
POST …/reserves/{categoryId}/history-reset) — zero the scope’s remaining expectation and mark the reserve-update feed’s horizon; idempotent-shaped, legal on deprecated categories.
Compositions
- Import Event Financials (
POST /financials/events/{eventId}/import) — wholesale refresh in one transaction: sweep the event’s current invoices, create the new slate, set expected totals. Importing replaces, it does not append. - Merge Payee (
POST /financials/payees/{payeeId}/merge) — repoint every linked invoice at another payee, removing and re-recording payments so attribution follows; idempotent by convergence. - Update Policy Invoices
(
POST /financials/policies/{policyId}/invoices/batch) — the only document write door for policy-linked invoices. Submit the complete explicit keep/void/create plan; every void cites the invoice’sheadJournalId, and kept plus created invoices must conserve the policy’s current pricing contract. The batch is atomic and never infers dates, payees, or schedules.
Balances and feeds
- Get Balances (
GET /financials/balances) — company-wide per-category rollups with per-account detail, raw signed cents. - Get Entity Balances (
GET /financials/entities/{entityType}/{entityId}/balances) — one entity’s slice in the same shape (event,policy, orpayee); also the read for a scope’s current expected total before setting reserves. - List Reserve Updates (
GET /financials/events/{eventId}/reserve-updates) — an event’s reserve-update feed: user updates, automatic eroding-payment rows, and history-reset markers.
Configuration discovery
- List Transaction Categories (
GET /financials/config/categories) — read-only discovery of the category and line item type ids write payloads cite (includeDeprecated=trueto resolve old references). Categories and line item types are configured in the app — this surface has no configuration writes.
Validation
- Check Financials Read-State Consistency (
GET /financials/validation/check) — read-only: verify a scoped window of the derived read state against a replay of the journal; returns the mismatch list. - Rebuild Financials Read-State (
POST /financials/validation/sync) — rebuild the derived read state from the journal under an exclusive company-level lock; returns per-table rebuilt counts. Idempotent, safe at any time.
Operational reset (SUPER_ADMIN)
- Delete All Financial Data (
POST /financials/deleteAll) — hard-delete every financial record the company holds (invoices, payments, journal, ledger, balances) while keeping financial configuration (categories, line item types, approval config). The first rung of the breaking-config reset ladder: the per-type entity wipe (POST .../entities/{entityType}/deleteAll) rejects with409while financials hold a live claim, and this endpoint is its unblock path. SUPER_ADMIN-only. Both bulk wipes are additionally delete-guarded: a company whose delete guard isactiveoronboarding-activeis rejected with409 DeleteGuardConflict— the wipes require a non-active delete guard, set from the Control Plane.
