Skip to main content
ExpenseRow captures one expense receipt with provenance back to the originating ingest.submitted_records row. Stored in ledgers.expenses. Migration: 004_expenses.sql.
The class is auto-registered as the "expenses" subledger type on import.

Type-specific fields

GL-handoff fields

Populated by the post step, never written by runbook code directly.

Loose-on-NEEDS_ATTENTION contract

vendor, amount_gross, currency are typed Optional so the insert_needs_attention path can build a “ghost row” with NULLs in the typed columns. Once the row leaves NEEDS_ATTENTION (i.e. HITL fixed the issue), these MUST be populated. Enforced two ways:
  1. _strict_fields_present_when_not_needs_attention — Pydantic model_validator raises at row construction.
  2. DB CHECK constraint mirrors the same rule (defense in depth).
A second cross-field invariant — vat_amount <= amount_gross — catches the common extraction bug where the agent confused gross with net somewhere on the receipt. Skipped on NEEDS_ATTENTION rows.

GL handoff — ExpenseRow.propose_for_gl

Converts APPROVED expense rows to a list of BillProposals — one bill per row (no merging — each receipt is its own bill in the GL). Defaults to the unified Bill primitive (AP / supplier invoice); Xero’s deprecated Expense Claims module isn’t used. Field mapping: Idempotency key follows the canonical convention: "expenses:{task_id}:{row.id}". Rejects non-APPROVED rows — only signed-off bills go to the GL.

Mutation helpers — ntro.subledger.types.expenses_mutations

Pure-domain helpers for HITL row actions. No DB access — transports reuse them consistently.
EditableExpenseField enumerates which columns the UI can edit per cell: vendor, currency, expense_date, payment_method, notes, category, category_source, amount_gross, vat_amount. Anything else returns INVALID_FIELD. can_reject only allows reject from NEEDS_ATTENTION (or no-op from REJECTED); other statuses return INVALID_TRANSITION.

Canonicalisation — canonicalize_tabular_expense_row

Maps loose extraction keys onto the strict ExpenseRow field names — handles the common alias variants (amount, total, gross_total → amount_gross; tax, vat → vat_amount; etc.). Used by tabular ingest (expense-processor parsing a CSV) so runbook authors don’t re-implement column-name normalisation.

Subledgers overview

Row base + SubledgerStatus lifecycle.

journal_proposals

The other shipped platform type.

General ledgers

Posting BillProposal to the external GL.

Typing

CurrencyCode, ForgivingDate, Period — used in ExpenseRow field types.