ExpenseRow captures one expense receipt with provenance back to the originating ingest.submitted_records row. Stored in ledgers.expenses. Migration: 004_expenses.sql.
"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:
_strict_fields_present_when_not_needs_attention— Pydanticmodel_validatorraises at row construction.- DB CHECK constraint mirrors the same rule (defense in depth).
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
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.
Related
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.