Three reports a CPA expects that the engine could not produce:
- general_ledger(start, end): the canonical general ledger, organized by
account in code order. Each account has its beginning balance, every
posting in the period (date, entry, description, debit, credit, running
balance on the normal side) and its ending balance, which ties to the
trial balance. Each posting names its split: the one account on the
opposite side of its entry, or "-Split-" when that side has several,
so a compound entry (one debit, several credits) reads correctly from
every account it touches. The entry-ordered listing the exports called a
"general ledger" is really the general journal.
- trial_balance_as_of(as_of): a trial balance is always as of a date.
- members_equity(start, end, members): the statement of changes in
members' equity for a multi-member LLC taxed as a partnership. Each
member's capital roll-forward: beginning (capital plus their share of
undistributed earnings), contributions, allocated net income (loss),
distributions, ending. Allocation is by ownership share to the cent,
the last member absorbing rounding, so totals are exact and the ending
total equals balance-sheet equity; any equity change the roll-forward
can't explain shows as "other" instead of disappearing. Shares must be
positive and sum to exactly 1; capital accounts must be distinct
equity accounts (LedgerError::InvalidMembers).
- CashFlowLine.account: the account a line is about, so renderers can
name members instead of account codes.
Tests (every expectation computed by hand): trial balance as of Jan 31;
February general ledger with beginning balances, running balances, a
card that nets to zero, an inactive account still listed, and every
ending balance tied to the trial balance; a compound entry's splits from
all three sides; a two-member partnership where 666.67 and 100.01 split
unevenly (333.34/333.33, 50.01/50.00), with and without closing January,
tying to the balance sheet each month; bad member configuration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
The printpdf statements padded columns with spaces in a proportional font
(amounts didn't line up), had no company name, printed negatives as
"-207.68", and put everything on one page (a long statement was silently
cut off past ~70 lines). Now:
- PDFs are typeset by Typst from one template,
ledger-import/templates/statement.typ. The laid-out statement goes in as
JSON data (sys.inputs), so account names are never parsed as markup.
Fonts are bundled (typst-assets: Libertinus Serif), so output doesn't
depend on the machine and is byte-for-byte deterministic. Statements
paginate, with the company/title repeated on later pages and "Page n of
m" plus "Prepared from journal through seq N" in the footer.
- US accounting presentation, decided once in ledger-import::report and
applied by both PDF and XLSX: tabular figures; "$" on each section's
first amount and on every subtotal and total, pinned to the column's
left edge; negatives in parentheses with the closing paren hanging (its
width reserved on every row, so decimal points align); zero as a dash;
a single rule above sums (none over an empty section) and a double rule
under statement totals; amounts never bold, because bold figures are
wider and would leave the decimal column. XLSX uses Excel's own
Accounting formats for the same result.
- Cash flow: "Net change in cash" is a subtotal; only "Cash at end of
period" takes the double rule.
- Core fix: cash_flow walked the chart in HashMap order, so the order of
working-capital and financing lines could change between runs. Lines
now follow account code. Test: five owners, twenty runs, always
3000/3001/3003/3005/3009; fails without the sort.
New dependencies (ledger-import): typst, typst-layout, typst-pdf,
typst-assets (fonts), typst-as-lib (World/font plumbing), serde_json (the
template's data). Removed: printpdf. Release ledger-mcp grows by the
bundled fonts.
Tests: exact template data for a hand-built P&L (like a golden); same
statement renders to identical bytes; 200 rows of Typst markup
characters render literally and span several pages; currency-sign and
rule placement; amount splitting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
The engine only had life-of-ledger P&L and balance sheet totals, so every
export labeled with a date range or as-of date (PDF, XLSX, CPA package)
printed all-time numbers under that label. Core now has:
- profit_and_loss_for_period(start, end, class?) and
balance_sheet_as_of(as_of), each with a line per account
(StatementLine: code, name, normal-side amount) and exact totals.
- Ledger::closing_entry_ids(). Period statements exclude closing entries.
A close (Dr revenue / Cr expense / net to Retained Earnings, dated at
period end) moves earnings; it doesn't earn or spend. Counting it made a
closed period's P&L read as break-even, and made the cash flow show zero
net income plus a fake "owner contribution" to Retained Earnings. The
as-of balance sheet keeps closing entries: after a close the earnings
belong in Retained Earnings, not current earnings.
Tests: tests/statements.rs, a two-month fixture with every expected number
computed by hand (Jan NI 1,700; Feb 880; balance sheets on four dates; a
closed January still reporting 1,700 and a cash flow with one financing
line). Mutation-checked: dropping the closing exclusion fails it (0 vs
1,700). Properties: period P&Ls partition a year at any split, and a
range's net income equals the change in current earnings across it, with
A = L + E on both dates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu