Skip to main content
JournalProposalRow captures one journal entry waiting on operator approval before it posts to the external GL. Stored in ledgers.journal_proposals. Migration: 005_journal_proposals_subledger.sql.
runbook builds JournalLine[]
    ↓ (commit step)
JournalProposalRow stored in ledgers.journal_proposals
    ↓ (post step, idempotent)
JournalProposalRow.propose_for_gl(...) → JournalProposal (GL handoff)

GLProvider.journal_entries.create(proposal, external_id=...)
from ntro.subledger.types.journal_proposals import JournalProposalRow
The class is auto-registered as the "journal_proposals" subledger type on import.

Type-specific fields

FieldTypePurpose
descriptionstr | NoneNarrative the GL stores as memo / description. Operator-facing.
posting_datedate | NoneDate the journal posts on. Distinct from period — a March journal might post on the 31st, a year-end adjustment on the prior day. Required for GL commit; if absent we derive last-day-of-period at handoff.
currencystr | NoneISO-4217. Optional — the GL provider uses the entity’s default when omitted.
lineslist[dict]List of {account_code, description, debit, credit, tax_code?} dicts. Stored as JSONB. Strict typing happens at GL handoff.

GL-handoff fields

Mirrors ExpenseRow. Populated by the post step, never written by runbook code directly.
FieldTypePurpose
approved_atdatetime | NoneSet by the HITL approval step.
posted_to_glboolTrue once the GL post succeeded.
posted_journal_refstr | NoneProvider-assigned journal id.

Why lines is a list of dicts (not a typed sub-model)

  1. Matches the existing ledgers.journal_proposals.lines JSONB column shape — keeps the migration cheap.
  2. Line shape evolves per-runbook. nav-monthly-journals carries account_code/description/debit/credit/tax_code today. Other runbooks may add cost_centre or tracking_categories. Loose dict here, strict typing happens at propose_for_gl time when we land in the unified JournalEntryLineItem.

Validation rules

Two model validators run at row construction:

_strict_fields_present_when_not_needs_attention

Outside NEEDS_ATTENTION, lines must be non-empty AND each line must:
  • Have a non-empty account_code.
  • Have non-negative debit and credit.
  • Have a non-zero amount on exactly one side (debit XOR credit > 0). Both zero → invalid; both positive → invalid.
NEEDS_ATTENTION rows are the only status allowed to be empty (placeholder for HITL fix-up).

_balanced_when_approved

A journal posted to the GL MUST balance (Σ debits == Σ credits). Enforced only on APPROVED / POSTED rows so the runbook can store an in-progress draft mid-HITL before the user adds the balancing line.

GL handoff — JournalProposalRow.propose_for_gl

from uuid import UUID
from ntro.subledger.types.journal_proposals import JournalProposalRow

proposal = JournalProposalRow.propose_for_gl(
    rows=[approved_row],
    task_id=UUID("…"),
)
# proposal: ntro.subledger.proposals.JournalProposal
# Hand off via your GLProvider:
result = await provider.journal_entries.create(
    proposal, external_id=proposal.idempotency_key
)
Converts one or more APPROVED subledger rows to a single JournalProposal for GL commit. nav-monthly-journals calls this with one row (the approved monthly allocation journal). Other runbooks may pass multiple rows when the GL provider’s batched-journal model suits — e.g. a period-close runbook posting all true-up journals as one batch. When multiple rows are passed:
  • lines are merged into a single journal entry.
  • description and posting_date come from the first row (caller’s responsibility to pre-validate they share these).
Field mapping:
RowGL (JournalEntry)
line account_codeJournalEntryLineItem.ledger_account.nominal_code
line debit / creditJournalEntryLineItem.type ("Debit" / "Credit") + total_amount
line descriptionJournalEntryLineItem.description
descriptionmemo
currencycurrency
posting_date (or last-day-of-period default)posted_at (datetime, midnight UTC)
Idempotency key follows the canonical convention: "journal_proposals:{task_id}:{row.id}" for single-row proposals. Multi-row uses the first row’s id (deterministic across re-invocations as long as the row order is stable). Rejects non-APPROVED rows — only signed-off journals go to the GL.

Subledgers overview

Row base + SubledgerStatus lifecycle.

expenses

The other shipped platform type.

Accounting

JournalLine / JournalProposal proposal-builder helpers.

General ledgers

Posting the JournalProposal via provider.journal_entries.create.