Compare commits

...
172 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 8164abb2b8 fix: period close sweeps only through the period end; partnerships close into members' capital
The close zeroed every revenue and expense account's all-time movement,
so closing a period after later entries were posted swept those too:
close January in March and February's and March's income landed in
January's closing entry. The preview had the same bug and never showed
where the net went. Now:

- close_period_to(id, to) zeroes balances as of the period end; entries
  dated later stay open for their own period. Periods must close in
  order (LedgerError::CloseOutOfOrder): a later close has already swept
  the earlier period.
- CloseTo::Members: for a partnership (multi-member LLC) the net goes to
  each member's capital account by share, to the cent, the last member
  absorbing rounding, which is what K-1 capital accounts track.
  CloseTo::RetainedEarnings (3999) stays the default; close_period is
  shorthand for it. books.close_period picks Members when
  wanderbooks.toml lists members.
- The journal records the close target; replay re-derives the closing
  entry and now compares every line with the journal, not just the id,
  so a change to closing math can never silently rewrite history.
- The tool's preview is the real close run on a clone of the ledger
  (Ledger now derives Clone), so preview and post can't disagree.
- Members' equity: closing entries into a capital account are income,
  not contributions. Balance sheet for a partnership: capital accounts
  (with allocated earnings), then only unallocated earnings —
  "Undistributed earnings, prior years" if any, and this year's unclosed
  net income (loss). periods::allocate is shared by the close and the
  statement.

Tests: January closed after February is posted sweeps exactly Dr 4000
2,500 / Cr 6100 800 / Cr 3999 1,700 (mutation-checked: the old code gives
3,500/120/800/2,580); out-of-order close refused; a partnership close
splits 666.67 into 333.34/333.33 with no 3999, and the members' equity
statement reads correctly before and after; store replay of a members
close (reopen, rebuild, verify); partnership and corporate balance sheet
layouts; stdio close preview equals the posted lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:39:17 -07:00
swaitsandClaude Opus 5.5 f591264533 docs: more ledger-gui design references
Records three more dashboard references Stephen shared: the QuickBooks
Online dashboard cards, KPI tiles with period-over-period change, and a
cash-flow dashboard (runway, forecast, A/P and A/R aging, burn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:31:08 -07:00
swaitsandClaude Opus 5.5 8f623ba810 feat: canonical report set in QuickBooks-style presentation
Every report a CPA expects, laid out the way US small-business books
read (QuickBooks as the reference):

- Exports: books.export gains general_ledger (by account, running
  balances, splits), general_journal (the entry-ordered listing, which
  the CPA package used to mislabel "general ledger"; alias journal),
  members_equity, and trial_balance as of a date, in xlsx/csv/json, and
  PDF for all five statements (P&L, balance sheet, cash flow, trial
  balance, members' equity). books.trial_balance takes as_of.
- CPA package: every statement as PDF and XLSX, plus the general ledger
  and general journal, all for the fiscal year to date.
- Layout (ledger-import::report): multi-column statements (Total;
  Debit/Credit; one column per member plus Total), "$" on subtotals and
  totals, a rule above every sum (none over an empty section), a double
  rule under statement totals, zero as 0.00, negatives in parentheses
  with the closing paren hanging so decimal points align.
- Terms: "Net income (loss)"; for a multi-member LLC, "Members' equity",
  member contributions and distributions named in the cash flow, and the
  balance sheet splitting retained earnings (the Retained Earnings
  account plus earnings not yet closed from earlier years) from the
  fiscal year's net income (loss); the cash flow's "Adjustments to
  reconcile net income (loss) to net cash from operating activities" and
  "Net increase (decrease) in cash".
- PDF: Inter replaces the typst-assets serif fonts (bundled, SIL OFL 1.1,
  license in fonts/; from the official v4.1 release), centered title
  block, column-header row, full-width rules, footer "Accrual basis ·
  Prepared <date> · Journal seq N" with page numbers. Amounts on total
  rows stay regular weight so bold figures can't leave the decimal
  column. typst-assets dependency removed.
- XLSX: centered title block, header row, Excel accounting formats with
  "$" attached on sums, general ledger and general journal workbooks.
- Config: wanderbooks.toml [[company.members]] (name, share "50%" or
  "0.5", capital_account; shares must total 100%) and [books]
  accounting_basis (accrual | cash).
- Core: RETAINED_EARNINGS_CODE names the "3999" that period close uses.
- Fix: "today" is local time, not UTC; in US evenings UTC is tomorrow,
  so reports defaulting to "through today" included the next day and
  footers carried the wrong date.

Tests: layout (currency and rule placement, retained earnings vs. this
year's net income for a member LLC, footer text); exact template data;
deterministic PDFs; config parsing (members, shares, basis); stdio:
trial balance as of, February general ledger (beginning 1,004.00, splits,
ending 1,003.50), members' equity (contributions 1,000.00, net income
3.50, ending 1,003.50), members' equity PDF, the CPA package's ten files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:29:12 -07:00
swaitsandClaude Opus 5.5 02b3b9c053 feat: general ledger by account, trial balance as of, members' equity
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
2026-10-11 20:16:25 -07:00
swaitsandClaude Opus 5.5 f4cb01b3f9 fix: money strings always carry cents
money() printed whatever scale a Decimal happened to have, so an empty
sum came back as "0" while every other amount read "207.68". Amounts now
have at least two places ("0.00", "1000.00"), padded and never rounded:
an exact FX-converted amount with more places keeps them. Negative zero
normalizes to "0.00".

Test: money_always_has_cents_and_never_rounds; three stdio assertions
that pinned the old "0" now expect "0.00".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:38:01 -07:00
swaitsandClaude Opus 5.5 42e600bbf2 chore: clippy clean across the workspace, all targets
Zero warnings from `cargo clippy --workspace --all-targets`, which is now
a hard rule rather than "clean except pre-existing":

- ChartOfAccounts::add takes the Account it registers instead of eight
  loose parameters (too_many_arguments); Ledger::add_account builds it.
  Same validation, same errors.
- close_period tool: collapse the nested if-let into a let chain.
- iif.rs docs: the IIF layout example shows tabs as `\t` instead of
  literal tab characters (tabs_in_doc_comments).
- Tests: drop an unused binding; assert! instead of assert_eq! against a
  literal bool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:24:10 -07:00
swaitsandClaude Opus 5.5 03212265ce feat: Typst-typeset statements in US accounting format
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
2026-10-11 19:23:01 -07:00
swaitsandClaude Opus 5.5 b5af21bb0e feat: real dates and per-account detail in every statement export
Every statement export rendered life-of-ledger totals under whatever date
range or as-of label was asked for, and the P&L and balance sheet XLSX
were totals-only. Now:

- books.profit_and_loss / books.balance_sheet and the csv, json, xlsx,
  pdf exports and CPA package all use core's profit_and_loss_for_period
  and balance_sheet_as_of, with a line per account. The tools keep their
  totals and add revenue/expense/asset/liability/equity account lines.
  An omitted start is the first posted entry, never "0001-01-01".
- ledger-import::report lays a statement out once (section, line,
  subtotal, total) for every renderer, with one exact amount formatter:
  thousands separators, negatives in parentheses, no floats. XLSX keeps
  numeric cells with an accounting number format, named sheets, company
  header, totals with borders; the trial balance gains its totals row.
- ledger-mcp reads wanderbooks.toml (next to --data-dir, or
  --books-toml): company name for headers, fiscal_year_start for the CPA
  package's fiscal year to date (end derived; Feb 29 rejected), and
  [books] receipt_threshold, which the server had been ignoring.
  config.json's receipt_policy still wins when set. New dependency: toml
  1.x (already in the lockfile via another crate).
- Store::journal_seq(); every report notes the journal seq it reflects.

Tests: company.rs unit tests (fiscal years incl. a July start, scaffold
parsing, bad settings); report.rs formatting tests; an MCP stdio test with
--books-toml (threshold 5.00 enforced, February-only P&L with account
lines, start defaults to the first entry, as-of balance sheet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:11:36 -07:00
swaitsandClaude Opus 5.5 c6faeeb827 feat: period-scoped P&L and as-of balance sheet; closes don't zero periods
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
2026-10-11 19:04:18 -07:00
swaitsandClaude Opus 5.5 5548902f27 feat: cash flow statement PDF, pdf export format, cash flow in CPA package
The cash flow statement was computed and exportable as csv/json/xlsx but
missing from every PDF path, so the CPA package carried two of the three
financial statements. Now:

- ledger-import: cash_flow_pdf renders the indirect-method statement
  (operating with net income, depreciation add-back, working-capital
  lines; investing; financing; net change reconciled to beginning and
  ending cash) from the computed CashFlowStatement, so the period and
  cash accounts are the caller's.
- books.export gains format "pdf" for profit_and_loss, balance_sheet, and
  cash_flow (base64, like xlsx), plus financing_accounts for cash_flow.
- books.export_cpa_package adds cash-flow.pdf (fiscal year to date, like
  the P&L) and requires cash_accounts: without them the statement has
  nothing to reconcile to.
- One cash_flow_statement helper resolves codes and dates for xlsx, pdf,
  and the package.

Test: MCP stdio test exports all three statement PDFs, checks the
cash_accounts and bad-report refusals, and unzips the CPA package to find
all five files. Known report gaps are logged in plan/STATUS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:57:12 -07:00
swaitsandClaude Opus 5.5 e59e8001c6 feat: receipt unlink and attachment withdrawal
Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.

- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
  and leaves the receipt filed, unlinked. The receipt policy holds: an
  entry that requires a receipt can't lose its last one (structured
  receipt_required error), so a replacement is attach-new, then
  unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
  attachment.withdrawn for an unlinked receipt or a filed document: hidden
  from list_receipts (unless include_withdrawn), never linkable again. The
  blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
  Before, re-linking silently moved a receipt off a posted entry,
  bypassing the policy; evidence now leaves an entry only through a
  recorded unlink. dispose_asset checks its receipt ids before posting
  instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
  stores migrate on open and replay rebuilds the projection.

Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the Wanderland Labs books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:40:51 -07:00
swaits 3bfea47a59 docs: STATUS entry for wanderbooks init 2026-10-12 01:07:13 +00:00
swaits 52674af74f docs: README quickstart covers wanderbooks init 2026-10-12 01:07:11 +00:00
swaits 296818f73c feat: lockfile for ledger-cli deps 2026-10-12 01:07:07 +00:00
swaits a016af9cd4 feat: add ledger-cli to workspace members 2026-10-12 01:07:05 +00:00
swaits 3aae8cd2a6 test: init scaffold integration tests (5 green) 2026-10-12 01:07:02 +00:00
swaits e96b5672e9 feat: wanderbooks init scaffolds mise.toml + AGENT.md + books layout 2026-10-12 01:07:01 +00:00
swaits 94c874a56f feat: ledger-cli crate (wanderbooks init binary) 2026-10-12 01:06:59 +00:00
swaits 6540bceac1 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:27 +00:00
swaits da4a9cb0ee Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:26 +00:00
swaits 623d9462fe Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:24 +00:00
swaits 15a9b0961a Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:23 +00:00
swaits 48ef705185 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:21 +00:00
swaits 98015b6c58 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:19 +00:00
swaits 09d1537225 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:18 +00:00
swaits 0a90a72962 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:16 +00:00
swaits 22cfc37bbd Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:14 +00:00
swaits 0d91d5839f Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:13 +00:00
swaits 72f7143aa9 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:11 +00:00
swaits fe50b38616 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:09 +00:00
swaits 4969cb557e Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:07 +00:00
swaits bad75ede4d Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:05 +00:00
swaits 2dd23df7ae Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:03 +00:00
swaits 61b6e48df7 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:02 +00:00
swaits ab67dccf8e Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:00 +00:00
swaits 005dcbb803 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:58 +00:00
swaits d50b844476 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:56 +00:00
swaits 3d80a270ca Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:55 +00:00
swaits f521d7d8c6 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:53 +00:00
swaits 6fdd8898a9 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:51 +00:00
swaits c7a47ce76f Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:41 +00:00
swaits 134c77ae41 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:38 +00:00
swaits d201b71d45 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:36 +00:00
swaits 15c177850b Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:34 +00:00
swaits 4a4d03c329 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:32 +00:00
swaits 91368eda18 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:30 +00:00
swaits 6b6e88516d Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:28 +00:00
swaits eb30becb95 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:25 +00:00
swaits 71d415d2a8 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:24 +00:00
swaits 727bdc61e6 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:22 +00:00
swaits 747f0c951e Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:20 +00:00
swaits 26f15eb867 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:18 +00:00
swaits dc852d5f7e Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:16 +00:00
swaits bb0243c78e Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:24 +00:00
swaits 78af7e201d Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:22 +00:00
swaits 3039625805 Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:20 +00:00
swaits 608788bbe4 Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:18 +00:00
swaits 5e9388e6ad README: remove 'private while it cooks' — repo is now public 2026-10-11 07:32:59 +00:00
swaits 343a10a1ac Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:55 +00:00
swaits a4b6a41ce9 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:53 +00:00
swaits fc9960eb83 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:48 +00:00
swaits 92921d8a72 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:45 +00:00
swaits 96a7727f92 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:41 +00:00
swaits 7a1c3a2531 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:33 +00:00
swaits d6de58bb41 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:29 +00:00
swaits 42088e06d5 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:24 +00:00
swaits df376d760f Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:21 +00:00
swaits a27c17c025 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:18 +00:00
swaits fa6173be84 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:11 +00:00
swaits 377756ff94 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:02 +00:00
swaits 90656055cc Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:51 +00:00
swaits 870a4d359b Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:44 +00:00
swaits 77a51cfd6f Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:39 +00:00
swaits 9a2705d055 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:37 +00:00
swaits 7025056125 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:32 +00:00
swaits 0e4ec80cd8 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:30 +00:00
swaits 5baaa12bf7 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:28 +00:00
swaits 6313c50b11 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:26 +00:00
swaits 9ba4917899 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:24 +00:00
swaits 78915fc3bb Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:22 +00:00
swaits 9fa4232664 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:20 +00:00
swaits 5225abb9dd Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:19 +00:00
swaits ac1604ed3f Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:17 +00:00
swaits 792a0dce19 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:03 +00:00
swaits 9ff4b5261a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:02 +00:00
swaits 65dd11d26b asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:00 +00:00
swaits d703874f87 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:59 +00:00
swaits 2a72f37a9d asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:58 +00:00
swaits 349ccd1ccf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:56 +00:00
swaits 1c6524ad20 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:55 +00:00
swaits 8177e6aee3 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:54 +00:00
swaits 793c4e8ab6 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:52 +00:00
swaits 6c052141a0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:51 +00:00
swaits 686306544a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:50 +00:00
swaits 50c45e5229 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:49 +00:00
swaits d36d718be0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:48 +00:00
swaits 284e53feb7 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:46 +00:00
swaits 87b5b90baf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:45 +00:00
swaits 8c115f1b9e asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:44 +00:00
swaits 1674ce70b4 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:42 +00:00
swaits f92bb4c39c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:40 +00:00
swaits 325eb1003a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:38 +00:00
swaits 9126e33f77 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:37 +00:00
swaits 0d3a6d18ad asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:36 +00:00
swaits 1d211c8faa asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:34 +00:00
swaits 7098ac08e5 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:33 +00:00
swaits 3790b4e0b6 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:31 +00:00
swaits 5054268474 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:29 +00:00
swaits 95d46c27d5 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:28 +00:00
swaits d1e3a9525b asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:26 +00:00
swaits ad0c99853f asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:25 +00:00
swaits fcdd002fe0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:23 +00:00
swaits afcbc4bbdf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:22 +00:00
swaits 41acb73b5b asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:21 +00:00
swaits a7a8a70496 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:19 +00:00
swaits 99b5b5d0b8 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:18 +00:00
swaits 31d3c61e32 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:16 +00:00
swaits 2fcb50c2c4 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:15 +00:00
swaits 008b5d2d52 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:13 +00:00
swaits eb28e87662 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:12 +00:00
swaits a44f14a275 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:10 +00:00
swaits 1f9f441a16 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:08 +00:00
swaits e0214fcc4c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:07 +00:00
swaits fc5bb190a1 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:06 +00:00
swaits 291451f725 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:04 +00:00
swaits 090be37107 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:03 +00:00
swaits 01d109e80e asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:01 +00:00
swaits 877e6ed503 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:00 +00:00
swaits 7a26fd2fb1 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:58 +00:00
swaits fac7d681e4 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:57 +00:00
swaits fae0827246 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:55 +00:00
swaits 4884fea21a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:53 +00:00
swaits cbaa858380 test push 2026-10-11 02:24:46 +00:00
swaits 7d23f42185 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:36 +00:00
swaits 9be8398519 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:34 +00:00
swaits a65d10c78e First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:32 +00:00
swaits 7d41b99707 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:31 +00:00
swaits 0ff778526a First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:29 +00:00
swaits e5f63b0d48 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:27 +00:00
swaits 08b53d2e55 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:24 +00:00
swaits fcf6e5743d First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:23 +00:00
swaits 57c28679d5 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:21 +00:00
swaits 25df4962ea First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:19 +00:00
swaits 283e2b4a51 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:17 +00:00
swaits 922e5d3128 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:15 +00:00
swaits 794b18e6ab First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:13 +00:00
swaits 6979a20414 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:12 +00:00
swaits 1b1a1791f1 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:10 +00:00
swaits b23e0a9011 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:08 +00:00
swaits e72b3542d2 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:07 +00:00
swaits e313fec903 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:05 +00:00
swaits 3c33c79a75 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:03 +00:00
swaits 2c22024abd First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:01 +00:00
swaits 1fa76aeecd First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:36:59 +00:00
swaits 11397e9c24 Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:25 +00:00
swaits 440c10534d Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:23 +00:00
swaits 57feb81698 Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:21 +00:00
swaits fd72f1969e QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:52 +00:00
swaits 6df4020c3f QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:51 +00:00
swaits 3eaa669ab9 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:49 +00:00
swaits b866e601e7 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:47 +00:00
swaits 47743bea79 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:45 +00:00
swaits 7b77a437f1 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:43 +00:00
swaits 1b8da0a477 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:41 +00:00
swaits 2a1a7512ae QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:40 +00:00
swaits f8393d961a QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:38 +00:00
swaits d18f68464f Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:53 +00:00
swaits 437977f848 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:51 +00:00
swaits a3fdc37f1d Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:49 +00:00
swaits ae61f02eb8 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:47 +00:00
swaits 3a9d83ec6c Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:45 +00:00
swaits da5715e215 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:43 +00:00
68 changed files with 25987 additions and 228 deletions

No files matched your search

+3
View File
@@ -104,6 +104,9 @@ clone, with status. Rules:
| `docs/architecture.md` | Crate layout, command pattern, dependency rules | | `docs/architecture.md` | Crate layout, command pattern, dependency rules |
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing | | `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
| `docs/parity.md` | Feature catalog and the non-regression floor | | `docs/parity.md` | Feature catalog and the non-regression floor |
| `docs/capability-parity.md` | Parity vs the GSheet+GDrive system + the cutover rule |
| `docs/mcp-interface.md` | The MCP tool surface: the primary interface spec |
| `docs/receipt-store.md` | Blob store + `ledger-store` SQLite persistence spec |
| `docs/glossary.md` | Debit/credit and friends, for agents new to accounting | | `docs/glossary.md` | Debit/credit and friends, for agents new to accounting |
| `plan/STATUS.md` | Where the project stands right now | | `plan/STATUS.md` | Where the project stands right now |
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity | | `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity |
Generated
+4779
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/ledger-core"] members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
+48 -26
View File
@@ -1,50 +1,72 @@
# wanderbooks # wanderbooks
Canonical double-entry bookkeeping, rebuilt from scratch — the storytold way: Canonical double-entry bookkeeping, rebuilt from scratch. The engine is
an engine verified against oracles, with automated parity tracking, on its verified against oracles, parity is tracked automatically, and the primary
way to becoming a self-hosted bookkeeping app that never charges rent. interface is AI-first: you talk to an agent, the agent drives the books
through MCP. Self-hosted, no rent.
Private while it cooks. ## What it is
- **`ledger-core`** — the semantics engine. Exact decimal money, five
account types, balanced journal entries, immutable posted log with
reversing entries, trial balance, P&L, balance sheet, indirect-method
cash flow, fixed-asset register with straight-line depreciation.
- **`ledger-store`** — durable storage. The source of truth is an
append-only, hash-chained JSONL journal; SQLite is a disposable
projection rebuilt from it; receipts live in a content-addressed blob
store. Drafts are durable, corrupt projections self-heal.
- **`ledger-mcp`** — the AI-first interface. 27 MCP tools over stdio or
Streamable HTTP: draft → preview → post workflow, server-side receipt
policy (>$25), anti-splitting detection, asset management, digest for
human review, plus read-only resources for dashboards.
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks
Desktop) and XLSX reports.
## Verification
This is the storytold way applied to accounting: agents verify against
oracles, never ship what they haven't demonstrated.
- 70 workspace tests: invariant tests encoding accounting law, golden-file
report snapshots, property tests (proptest), end-to-end MCP stdio tests.
- Mutation testing via cargo-mutants; fuzzing via cargo-fuzz (11.5M+
executions, zero crashes).
- GnuCash is the behavioral oracle (never a code source — clean room,
MIT OR Apache-2.0).
## Status ## Status
Phase 1: the semantics engine. `ledger-core` implements the accounting laws Parity is 12 of 12 against the spreadsheet workflow it replaces
in Rust — exact decimal money, five account types, balanced journal entries, (`docs/capability-parity.md`). The parallel-run plan
immutable posted log with reversing entries, trial balance, P&L, and balance (`docs/parallel-run.md`) is written; the Google Sheet stays the system of
sheet — with 14 invariant tests and golden-file report snapshots. record until a clean month and explicit cutover approval.
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
[`plan/roadmap.md`](plan/roadmap.md) for where they're going. [`plan/roadmap.md`](plan/roadmap.md) for where it's going.
## Quickstart ## Quickstart
```bash ```bash
cargo test cargo test # whole suite
cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
``` ```
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
That's the whole test suite: validation rejections, immutability, the `mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
accounting equation on a fixture company, a 300-entry randomized invariant
test, and the golden oracle test.
## Layout ## Layout
``` ```
crates/ledger-core/ # the engine: money, accounts, journal, ledger, reports crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
docs/ # requirements, architecture, oracle design, parity, glossary crates/ledger-store/ # journal + SQLite projection + blob store
crates/ledger-mcp/ # MCP server: 27 tools, resources, receipt policy
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
docs/ # requirements, architecture, oracle, parity, glossary
plan/ # STATUS.md, roadmap.md plan/ # STATUS.md, roadmap.md
AGENTS.md # operating manual for agents working here AGENTS.md # operating manual for agents working here
``` ```
## The idea, briefly
storytold/ArtCraft showed that agents can clone full applications with the
right machinery: screenshot-in-the-loop verification, render oracles, and
automated parity floors. `wanderbooks` adapts that pipeline to bookkeeping,
where the oracle is semantic instead of visual — GnuCash (open source, open
formats) is both the spec and the reference implementation we diff against,
and two engines fed the same books must produce byte-identical reports.
Details in [`docs/oracle.md`](docs/oracle.md).
## License ## License
MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`. MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`.
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "ledger-cli"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "wanderbooks CLI: scaffold new books directories (mise.toml + AGENT.md)."
[[bin]]
name = "wanderbooks"
path = "src/main.rs"
[dev-dependencies]
toml = "1"
tempfile = "3"
[lints]
workspace = true
+269
View File
@@ -0,0 +1,269 @@
//! `wanderbooks` CLI — the bootstrap for AI-native bookkeeping.
//!
//! Usage: `wanderbooks init [DIR] [--force]`
//!
//! Scaffolds a new books directory: `mise.toml` (pinned toolchain + tasks),
//! `AGENT.md` (the agent operating contract), `wanderbooks.toml` (company
//! profile, filled in during onboarding), and an empty `books-data/` dir.
//! From there: `mise trust && mise install`, `mise run build`,
//! `mise run mcp-add`, then start `claude`/`codex` in the dir and let the
//! agent take the onboarding from AGENT.md.
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Absolute path of the wanderbooks repo checkout this binary was built from.
fn repo_root() -> PathBuf {
let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
raw.canonicalize().unwrap_or(raw)
}
const MISE_TOML: &str = r#"# wanderbooks books dir — provisioned by `mise`.
# Bootstrap: `mise trust && mise install` then `mise run build && mise run mcp-add`.
[tools]
rust = "stable"
[tasks.build]
description = "Build ledger-mcp from the wanderbooks repo checkout"
dir = "{{REPO}}"
run = "cargo build --release -p ledger-mcp"
[tasks.mcp-add]
description = "Register wanderbooks with Claude Code (stdio transport; OS user boundary is the auth)"
run = 'claude mcp add wanderbooks -- "{{REPO}}/target/release/ledger-mcp" --data-dir "{{BOOKS}}/books-data"'
[tasks.backup]
description = "Commit the journal — the git history of this dir is the archive"
run = 'git add -A && (git commit -m "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true)'
"#;
const AGENT_MD: &str = r#"# AGENT.md — wanderbooks books dir
This directory is a **wanderbooks** books dir: AI-native double-entry
bookkeeping. You (the AI agent) are the bookkeeper. The human talks to you;
you drive the books through the `wanderbooks` MCP server. There is no
bookkeeping UI; the numbers you show the human *are* the interface.
## Bootstrap (once, in order)
1. `mise trust && mise install` — installs the pinned Rust toolchain.
2. `mise run build` — builds `ledger-mcp` from the wanderbooks repo.
3. `mise run mcp-add` — registers the server with Claude Code over stdio.
(Codex: add the same command + `--data-dir` to your MCP config instead.)
4. Restart the session so the MCP tools load, then continue below.
## The MCP contract
- **Amounts are decimal strings** (`"123.45"`). Never floats, never
locale-formatted. Money is exact or it is wrong.
- **Draft → add lines → preview → confirmed post.** `post_entry` without
confirmation returns a preview and posts nothing: show the preview to the
human before confirming. Same pattern for `reverse_entry`.
- **Every entry balances.** Debits equal credits exactly. No epsilon.
- **Posted is forever.** Corrections are reversing entries linked to the
original, never edits, never deletes.
- **Receipts:** attach with `attach_receipt` and link to the entry. Anything
over $25 requires a receipt — the server enforces this, don't argue with it.
- **Show the numbers.** After every mutation, show the affected balances.
When the human questions a figure, trace it to journal entries.
## First run: onboarding
If `books-data/journal.jsonl` is missing or empty, onboard before recording
anything:
1. Ask: company legal name, entity type, owners and equity split, base
currency, fiscal year start.
2. Set up the chart of accounts: propose a sensible default for the entity
type and let the human adjust, or import one they hand you.
3. Record opening balances as one balanced opening entry. Verify the trial
balance balances before moving on.
4. Write the answers into `wanderbooks.toml` under `[company]`.
5. If they're migrating from another system, import from wherever the books
live now — QBO/CSV via `ledger-import`, bank or card CSVs, email receipts,
a spreadsheet, whatever they point you at. Diff trial balance, P&L,
balance sheet, and cash flow against the old system **to the cent**
before calling the migration done.
## Ongoing
- Every transaction: draft, preview, confirm with the human, post, attach
the receipt.
- `books.digest` is the review surface — "what did you do with my books
lately." Offer it; don't make the human ask.
- Reports are pure functions of the journal. If a number looks wrong, the
journal is the evidence — read it, don't guess.
- Back up: run `mise run backup` after every session that posted entries.
The git history of this directory is the archive; the journal file is the
system of record and the SQLite database is disposable.
"#;
const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding —
# the agent asks, the human answers, the agent writes it here.
[company]
name = ""
entity_type = ""
owners = ""
base_currency = "USD"
fiscal_year_start = "01-01"
[books]
data_dir = "books-data"
receipt_threshold = "25.00"
"#;
const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl.
books-data/wanderbooks.db
books-data/wanderbooks.db-*
"#;
fn print_usage() {
eprintln!("usage: wanderbooks init [DIR] [--force]");
eprintln!(" DIR books directory to create (default: current directory)");
eprintln!(" --force overwrite an existing wanderbooks.toml");
}
fn render(template: &str, repo: &str, books: &str) -> String {
template
.replace("{{REPO}}", repo)
.replace("{{BOOKS}}", books)
}
/// `git init` the dir unless already inside a repo; commit the scaffold.
/// Failures are warnings, not errors — the scaffold is what matters.
fn git_bootstrap(dir: &Path) {
let inside = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(dir)
.output()
.is_ok_and(|o| o.status.success());
if !inside {
let init_ok = Command::new("git")
.arg("init")
.current_dir(dir)
.output()
.is_ok_and(|o| o.status.success());
if !init_ok {
eprintln!("warning: git init failed; skipping scaffold commit");
return;
}
}
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output();
if !add.is_ok_and(|o| o.status.success()) {
eprintln!("warning: git add failed; skipping scaffold commit");
return;
}
let _ = Command::new("git")
.args([
"-c",
"user.name=wanderbooks",
"-c",
"user.email=wanderbooks@localhost",
"commit",
"-m",
"init: wanderbooks books dir scaffold",
])
.current_dir(dir)
.output();
}
/// Scaffold a books directory. Returns the dir on success.
fn init(dir: &Path, force: bool) -> Result<PathBuf, String> {
fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let dir = dir
.canonicalize()
.map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?;
let marker = dir.join("wanderbooks.toml");
if marker.exists() && !force {
return Err(format!(
"{} is already a books dir (wanderbooks.toml exists); use --force to overwrite",
dir.display()
));
}
let repo = repo_root();
let repo_s = repo.to_string_lossy();
let books_s = dir.to_string_lossy();
let files: &[(&str, String)] = &[
("mise.toml", render(MISE_TOML, &repo_s, &books_s)),
("AGENT.md", AGENT_MD.to_string()),
("wanderbooks.toml", WANDERBOOKS_TOML.to_string()),
(".gitignore", GITIGNORE.to_string()),
];
for (name, content) in files {
fs::write(dir.join(name), content).map_err(|e| format!("cannot write {name}: {e}"))?;
}
fs::create_dir_all(dir.join("books-data"))
.map_err(|e| format!("cannot create books-data: {e}"))?;
fs::write(dir.join("books-data").join(".gitkeep"), "")
.map_err(|e| format!("cannot write .gitkeep: {e}"))?;
git_bootstrap(&dir);
Ok(dir)
}
fn main() {
let args = env::args().skip(1);
let mut dir: Option<PathBuf> = None;
let mut force = false;
let mut cmd: Option<String> = None;
for arg in args {
match arg.as_str() {
"--help" | "-h" => {
print_usage();
return;
}
"--force" => force = true,
s if s.starts_with('-') => {
eprintln!("unknown flag: {s}");
print_usage();
std::process::exit(2);
}
s => {
if cmd.is_none() {
cmd = Some(s.to_string());
} else if dir.is_none() {
dir = Some(PathBuf::from(s));
} else {
eprintln!("unexpected argument: {s}");
print_usage();
std::process::exit(2);
}
}
}
}
match cmd.as_deref() {
Some("init") => {
let dir = dir.unwrap_or_else(|| PathBuf::from("."));
match init(&dir, force) {
Ok(d) => {
println!("initialized books dir: {}", d.display());
println!("next:");
println!(" cd {}", d.display());
println!(" mise trust && mise install");
println!(" mise run build && mise run mcp-add");
println!("then start claude (or codex) here and follow AGENT.md");
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
_ => {
print_usage();
std::process::exit(2);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Integration tests for `wanderbooks init`: the scaffold lands complete,
//! the TOML parses, re-init is refused without `--force`.
use std::fs;
use std::path::PathBuf;
use std::process::Command;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
}
fn run_init(dir: &std::path::Path, extra: &[&str]) -> (bool, String) {
let mut cmd = Command::new(bin());
cmd.arg("init").arg(dir);
for e in extra {
cmd.arg(e);
}
let out = cmd.output().expect("run wanderbooks");
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), s)
}
#[test]
fn scaffold_lands_complete() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("acme-books");
let (ok, _) = run_init(&books, &[]);
assert!(ok, "init should succeed");
for f in ["mise.toml", "AGENT.md", "wanderbooks.toml", ".gitignore"] {
let p = books.join(f);
assert!(p.exists(), "{f} exists");
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
}
assert!(books.join("books-data").is_dir(), "books-data dir exists");
assert!(
books.join(".git").is_dir(),
"fresh dir gets git init for the journal archive"
);
}
#[test]
fn mise_toml_parses_and_names_repo_and_books() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
let (ok, _) = run_init(&books, &[]);
assert!(ok);
let raw = fs::read_to_string(books.join("mise.toml")).unwrap();
let doc: toml::Value = toml::from_str(&raw).expect("mise.toml parses");
assert!(doc.get("tools").is_some(), "[tools] present");
let tasks = doc.get("tasks").expect("[tasks] present");
for t in ["build", "mcp-add", "backup"] {
assert!(tasks.get(t).is_some(), "task {t} present");
}
// The build task must point at the repo checkout; mcp-add at the books dir.
let build_run = tasks["build"]["run"].as_str().unwrap();
assert!(
build_run.contains("cargo build"),
"build runs cargo: {build_run}"
);
let mcp_run = tasks["mcp-add"]["run"].as_str().unwrap();
assert!(
mcp_run.contains("ledger-mcp") && mcp_run.contains("books-data"),
"mcp-add wires the server to this books dir: {mcp_run}"
);
let books_s = books.to_string_lossy();
assert!(
mcp_run.contains(books_s.as_ref()),
"mcp-add references the books dir path"
);
}
#[test]
fn agent_md_carries_the_contract() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
let (ok, _) = run_init(&books, &[]);
assert!(ok);
let md = fs::read_to_string(books.join("AGENT.md")).unwrap();
for phrase in [
"preview",
"Posted is forever",
"reversing entries",
"receipt",
"journal.jsonl",
"onboard",
"to the cent",
] {
assert!(md.contains(phrase), "AGENT.md mentions {phrase:?}");
}
}
#[test]
fn reinit_refused_without_force() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(run_init(&books, &[]).0);
let (ok, out) = run_init(&books, &[]);
assert!(!ok, "second init without --force fails");
assert!(out.contains("--force"), "error points at --force: {out}");
let (ok, _) = run_init(&books, &["--force"]);
assert!(ok, "second init with --force succeeds");
}
#[test]
fn init_inside_existing_repo_skips_git_init() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
fs::create_dir_all(&books).unwrap();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(&books)
.status()
.unwrap()
.success()
);
let (ok, _) = run_init(&books, &[]);
assert!(ok);
// Still a valid repo (we didn't nest another one) and scaffold landed.
assert!(books.join("wanderbooks.toml").exists());
}
+3
View File
@@ -15,3 +15,6 @@ serde_json = "1"
[lints] [lints]
workspace = true workspace = true
[dev-dependencies]
proptest = "1"
+9 -29
View File
@@ -84,40 +84,20 @@ pub struct ChartOfAccounts {
impl ChartOfAccounts { impl ChartOfAccounts {
/// Register an account. Codes must be unique and non-empty; a parent, if /// Register an account. Codes must be unique and non-empty; a parent, if
/// given, must already exist. /// given, must already exist.
pub fn add( pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
&mut self, if account.code.is_empty() {
id: AccountId,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
currency: Currency,
) -> Result<(), LedgerError> {
if code.is_empty() {
return Err(LedgerError::EmptyAccountCode); return Err(LedgerError::EmptyAccountCode);
} }
if self.by_code.contains_key(code) { if self.by_code.contains_key(&account.code) {
return Err(LedgerError::DuplicateAccountCode(code.to_string())); return Err(LedgerError::DuplicateAccountCode(account.code));
} }
if let Some(p) = parent { if let Some(p) = account.parent
if !self.accounts.contains_key(&p) { && !self.accounts.contains_key(&p)
{
return Err(LedgerError::UnknownParentAccount(p)); return Err(LedgerError::UnknownParentAccount(p));
} }
} self.by_code.insert(account.code.clone(), account.id);
self.by_code.insert(code.to_string(), id); self.accounts.insert(account.id, account);
self.accounts.insert(
id,
Account {
id,
code: code.to_string(),
name: name.to_string(),
account_type,
parent,
placeholder,
currency,
},
);
Ok(()) Ok(())
} }
+399
View File
@@ -0,0 +1,399 @@
//! Accounts receivable and payable: customers, vendors, invoices, bills.
//!
//! Invoices and bills are business documents that auto-post journal entries:
//!
//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue.
//! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable.
//! * `receive_payment`: Dr Cash, Cr A/R. Updates the invoice's paid amount.
//! * `pay_bill`: Dr A/P, Cr Cash. Updates the bill's paid amount.
//!
//! The control accounts (`1200`, `2000`) and the default revenue/expense
//! accounts (`4000`, `6999`) are created on demand if missing. Invoices and
//! bills respect closed periods: they cannot be created with dates in a
//! closed period.
use crate::LedgerError;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Opaque customer handle.
pub type CustomerId = u64;
/// Opaque vendor handle.
pub type VendorId = u64;
/// Opaque invoice handle.
pub type InvoiceId = u64;
/// Opaque bill handle.
pub type BillId = u64;
/// A customer: someone we invoice.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Customer {
/// Opaque handle.
pub id: CustomerId,
/// Display name.
pub name: String,
}
/// A vendor: someone who bills us.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Vendor {
/// Opaque handle.
pub id: VendorId,
/// Display name.
pub name: String,
}
/// Payment status of an invoice or bill.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DocumentStatus {
/// Nothing paid yet.
Open,
/// Partially paid.
Partial,
/// Fully paid.
Paid,
}
/// One line on an invoice or bill.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DocumentLine {
/// Description of the goods/services.
pub description: String,
/// Line amount (positive).
pub amount: Decimal,
}
/// A sales invoice: we billed a customer.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invoice {
/// Opaque handle.
pub id: InvoiceId,
/// The customer.
pub customer_id: CustomerId,
/// Invoice date.
pub date: NaiveDate,
/// When payment is due.
pub due_date: NaiveDate,
/// Line items.
pub lines: Vec<DocumentLine>,
/// How much has been paid.
pub amount_paid: Decimal,
/// Derived from amount_paid vs total.
pub status: DocumentStatus,
/// The auto-posted journal entry.
pub entry_id: u64,
}
impl Invoice {
/// Total of all lines.
pub fn total(&self) -> Decimal {
self.lines.iter().map(|l| l.amount).sum()
}
/// Amount still owed.
pub fn balance(&self) -> Decimal {
self.total() - self.amount_paid
}
}
/// A vendor bill: we owe a vendor.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bill {
/// Opaque handle.
pub id: BillId,
/// The vendor.
pub vendor_id: VendorId,
/// Bill date.
pub date: NaiveDate,
/// When payment is due.
pub due_date: NaiveDate,
/// Line items.
pub lines: Vec<DocumentLine>,
/// How much has been paid.
pub amount_paid: Decimal,
/// Derived from amount_paid vs total.
pub status: DocumentStatus,
/// The auto-posted journal entry.
pub entry_id: u64,
}
impl Bill {
/// Total of all lines.
pub fn total(&self) -> Decimal {
self.lines.iter().map(|l| l.amount).sum()
}
/// Amount still owed.
pub fn balance(&self) -> Decimal {
self.total() - self.amount_paid
}
}
/// Validate document lines: at least one, all positive amounts.
pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
if lines.is_empty() {
return Err(LedgerError::EmptyDocumentLines);
}
for line in lines {
if line.amount <= Decimal::ZERO {
return Err(LedgerError::InvalidDocumentLineAmount(line.amount));
}
}
Ok(())
}
/// Validate that due date is not before the document date.
pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), LedgerError> {
if due_date < date {
return Err(LedgerError::DueBeforeDate(due_date.to_string()));
}
Ok(())
}
/// Derive status from paid vs total.
pub fn derive_status(paid: Decimal, total: Decimal) -> DocumentStatus {
if paid.is_zero() {
DocumentStatus::Open
} else if paid >= total {
DocumentStatus::Paid
} else {
DocumentStatus::Partial
}
}
/// One row of an aging report.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgingRow {
/// Customer or vendor name.
pub name: String,
/// Not yet due.
pub current: Decimal,
/// 1-30 days past due.
pub days_30: Decimal,
/// 31-60 days past due.
pub days_60: Decimal,
/// 61-90 days past due.
pub days_90: Decimal,
/// Over 90 days past due.
pub days_90_plus: Decimal,
}
impl AgingRow {
/// Total outstanding.
pub fn total(&self) -> Decimal {
self.current + self.days_30 + self.days_60 + self.days_90 + self.days_90_plus
}
}
/// Bucket a past-due balance by days overdue.
pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow {
let mut row = AgingRow {
name: String::new(),
current: Decimal::ZERO,
days_30: Decimal::ZERO,
days_60: Decimal::ZERO,
days_90: Decimal::ZERO,
days_90_plus: Decimal::ZERO,
};
if due_date > as_of {
row.current = balance;
} else {
let days = (as_of - due_date).num_days();
if days <= 30 {
row.days_30 = balance;
} else if days <= 60 {
row.days_60 = balance;
} else if days <= 90 {
row.days_90 = balance;
} else {
row.days_90_plus = balance;
}
}
row
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Currency, Ledger, Money, Side};
fn arap_ledger() -> Ledger {
let mut l = Ledger::new(Currency::USD);
l.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
l
}
#[test]
fn invoice_lifecycle() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme Corp").unwrap();
let inv = l
.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
vec![DocumentLine {
description: "Widgets".to_string(),
amount: "1000.00".parse().unwrap(),
}],
)
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.total(), "1000.00".parse().unwrap());
assert_eq!(invoice.status, DocumentStatus::Open);
// A/R has a debit balance of 1000.
let ar = l.chart().find_by_code("1200").unwrap().id;
assert_eq!(l.account_balance(ar).unwrap(), "1000".parse().unwrap());
// Partial payment.
let cash = l.chart().find_by_code("1000").unwrap().id;
l.receive_payment(
inv,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"400.00".parse().unwrap(),
cash,
)
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.status, DocumentStatus::Partial);
assert_eq!(invoice.balance(), "600.00".parse().unwrap());
// Full payment.
l.receive_payment(
inv,
NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
"600.00".parse().unwrap(),
cash,
)
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.status, DocumentStatus::Paid);
assert_eq!(l.account_balance(ar).unwrap(), Decimal::ZERO);
}
#[test]
fn bill_lifecycle() {
let mut l = arap_ledger();
let vend = l.add_vendor("Supply Co").unwrap();
let bill = l
.create_bill(
vend,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
vec![DocumentLine {
description: "Paper".to_string(),
amount: "250.00".parse().unwrap(),
}],
)
.unwrap();
let ap = l.chart().find_by_code("2000").unwrap().id;
// A/P is credit-normal; balance should be 250 (credit).
assert_eq!(l.account_balance(ap).unwrap(), "250".parse().unwrap());
let cash = l.chart().find_by_code("1000").unwrap().id;
// Seed cash so the payment doesn't go negative (not required, but tidy).
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), "seed");
let m = Money::new("1000.00".parse().unwrap(), Currency::USD);
l.add_line(e, cash, Side::Debit, m).unwrap();
let eq = l
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
l.add_line(e, eq, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.pay_bill(
bill,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"250.00".parse().unwrap(),
cash,
)
.unwrap();
assert_eq!(l.get_bill(bill).unwrap().status, DocumentStatus::Paid);
assert_eq!(l.account_balance(ap).unwrap(), Decimal::ZERO);
}
#[test]
fn overpayment_rejected() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme").unwrap();
let inv = l
.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
vec![DocumentLine {
description: "X".to_string(),
amount: "100.00".parse().unwrap(),
}],
)
.unwrap();
let cash = l.chart().find_by_code("1000").unwrap().id;
let err = l.receive_payment(
inv,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"150.00".parse().unwrap(),
cash,
);
assert!(matches!(err, Err(LedgerError::Overpayment(_, _))));
}
#[test]
fn ar_aging_buckets_correctly() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme").unwrap();
// Due in the future: current.
l.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
vec![DocumentLine {
description: "A".to_string(),
amount: "100.00".parse().unwrap(),
}],
)
.unwrap();
// 45 days past due: 60 bucket.
l.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 8, 17).unwrap(),
vec![DocumentLine {
description: "B".to_string(),
amount: "200.00".parse().unwrap(),
}],
)
.unwrap();
let as_of = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
let aging = l.ar_aging(as_of);
assert_eq!(aging.len(), 1);
assert_eq!(aging[0].current, "100".parse().unwrap());
assert_eq!(aging[0].days_60, "200".parse().unwrap());
}
#[test]
fn invoice_in_closed_period_rejected() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme").unwrap();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
l.close_period(pid).unwrap();
let err = l.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
vec![DocumentLine {
description: "X".to_string(),
amount: "100.00".parse().unwrap(),
}],
);
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
}
}
+313
View File
@@ -0,0 +1,313 @@
//! Fixed assets and depreciation: the asset register and the math.
//!
//! Scope is deliberate: **book depreciation only** (straight-line, GAAP
//! style). Tax depreciation — MACRS, Section 179, bonus depreciation — is a
//! separate set of books with its own rules and stays with the CPA. This
//! module never pretends to do tax.
//!
//! The register is master data owned by [`Ledger`]: assets are journaled
//! like accounts (see `ledger-store`), and ids are allocator-deterministic
//! for replay. Depreciation itself is pure math — [`depreciation_schedule`]
//! — and postings go through the normal entry machinery, so every
//! accounting invariant holds.
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money};
use chrono::{Datelike, Months, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Opaque handle for a fixed asset. Never forged: ids come from the ledger's
/// allocator.
pub type AssetId = u64;
/// Depreciation method. Straight-line is the whole v1; the enum reserves
/// room for declining-balance later.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DepreciationMethod {
/// Even allocation over the useful life: (cost − salvage) / months,
/// prorated by days for partial first and last months.
StraightLine,
}
/// Lifecycle state of an asset.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetStatus {
/// In service.
Active,
/// Retired or sold.
Disposed {
/// Disposal date.
date: NaiveDate,
/// Cash (or receivable) received.
proceeds: Money,
},
}
/// A fixed asset on the books.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Asset {
/// Opaque handle.
pub id: AssetId,
/// What it is, e.g. "2023 Sprinter van".
pub description: String,
/// Purchase date. Depreciation starts here.
pub purchase_date: NaiveDate,
/// Capitalized cost.
pub cost: Money,
/// Expected residual value at end of life.
pub salvage_value: Money,
/// Useful life in months.
pub useful_life_months: u32,
/// Depreciation method.
pub method: DepreciationMethod,
/// Asset account carrying the cost.
pub cost_account: AccountId,
/// Contra-asset account carrying accumulated depreciation.
pub accum_depr_account: AccountId,
/// Expense account for the periodic charge.
pub expense_account: AccountId,
/// Lifecycle state.
pub status: AssetStatus,
}
/// Parameters for registering an asset. A struct (not eight arguments)
/// because the list is long and stable.
#[derive(Clone, Debug)]
pub struct NewAsset {
/// What it is.
pub description: String,
/// Purchase date.
pub purchase_date: NaiveDate,
/// Capitalized cost. Must be positive, in the ledger's base currency.
pub cost: Money,
/// Residual value. Non-negative, less than cost, base currency.
pub salvage_value: Money,
/// Useful life in months, at least 1.
pub useful_life_months: u32,
/// Asset account for the cost.
pub cost_account: AccountId,
/// Contra-asset account for accumulated depreciation.
pub accum_depr_account: AccountId,
/// Expense account for the charge.
pub expense_account: AccountId,
}
/// One month of a depreciation schedule.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScheduleLine {
/// Calendar month, "YYYY-MM".
pub period: String,
/// Depreciation for the month, exact cents.
pub amount: Decimal,
/// Accumulated depreciation through this month, exact cents.
pub accumulated: Decimal,
/// Book value after this month: cost − accumulated.
pub book_value: Decimal,
}
fn days_in_month(year: i32, month: u32) -> u32 {
let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
let next = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid month")
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid month")
};
(next - first).num_days() as u32
}
/// The last day depreciation accrues: purchase + life, or disposal, whichever
/// is earlier. The accrual window is [purchase_date, depreciation_end).
fn depreciation_end(asset: &Asset) -> NaiveDate {
let natural = asset
.purchase_date
.checked_add_months(Months::new(asset.useful_life_months))
.expect("useful life does not overflow dates");
match &asset.status {
AssetStatus::Active => natural,
AssetStatus::Disposed { date, .. } => (*date).min(natural),
}
}
/// Straight-line depreciation schedule from the purchase month through the
/// month containing `through` (clamped to the depreciation end).
///
/// Partial first and last months are prorated by days held. Each month is
/// rounded to cents via the running-total method, and the final month
/// trues up: partial months in different-length calendar months don't
/// complement exactly (17/31 + 14/30 ≠ 1), so the last month absorbs the
/// drift and the lifetime total is exactly (cost − salvage) — never a
/// penny off.
pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleLine> {
if through < asset.purchase_date {
return Vec::new();
}
let depreciable = asset.cost.amount - asset.salvage_value.amount;
if depreciable <= Decimal::ZERO {
return Vec::new();
}
let end = depreciation_end(asset);
// Accrual is inclusive of `through`: work with an exclusive end.
let accrual_end = through.succ_opt().unwrap_or(through).min(end);
if accrual_end <= asset.purchase_date {
return Vec::new();
}
let monthly_rate = depreciable / Decimal::from(asset.useful_life_months);
let mut out = Vec::new();
let mut cumulative_exact = Decimal::ZERO;
let mut accumulated_rounded = Decimal::ZERO;
// Walk calendar months from the purchase month.
let (mut year, mut month) = (asset.purchase_date.year(), asset.purchase_date.month());
loop {
let month_start = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
if month_start >= accrual_end {
break;
}
let dim = days_in_month(year, month);
let month_end_excl = month_start
.checked_add_months(Months::new(1))
.expect("month arithmetic");
// Days held this month: overlap of [purchase_date, accrual_end) with the month.
let held_start = asset.purchase_date.max(month_start);
let held_end = accrual_end.min(month_end_excl);
let days_held = (held_end - held_start).num_days().max(0) as u32;
if days_held > 0 {
let exact = monthly_rate * Decimal::from(days_held) / Decimal::from(dim);
cumulative_exact += exact;
let rounded_cumulative = cumulative_exact
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
let amount = rounded_cumulative - accumulated_rounded;
accumulated_rounded = rounded_cumulative;
out.push(ScheduleLine {
period: format!("{year:04}-{month:02}"),
amount,
accumulated: accumulated_rounded,
book_value: asset.cost.amount - accumulated_rounded,
});
}
if month == 12 {
year += 1;
month = 1;
} else {
month += 1;
}
}
// True-up: if the schedule reaches the final month of the asset's life,
// absorb any month-length drift so the lifetime total is exactly
// (cost − salvage). The drift is bounded by a fraction of one month's
// charge, so the final month stays positive.
if let Some(last) = out.last_mut()
&& let Some(day_before_end) = end.pred_opt()
&& last.period == day_before_end.format("%Y-%m").to_string()
{
let plug = depreciable - accumulated_rounded;
last.amount += plug;
last.accumulated = depreciable;
last.book_value = asset.cost.amount - depreciable;
}
out
}
/// Accumulated depreciation through `as_of` (inclusive), exact cents.
pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Decimal {
depreciation_schedule(asset, as_of)
.last()
.map(|l| l.accumulated)
.unwrap_or(Decimal::ZERO)
}
/// Book value (cost − accumulated depreciation) as of a date.
pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Decimal {
asset.cost.amount - accumulated_depreciation(asset, as_of)
}
/// The amounts for a disposal entry: what to debit/credit where.
/// Gain (proceeds > book value) is positive; loss is negative.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DisposalAmounts {
/// Book value at disposal.
pub book_value: Decimal,
/// Accumulated depreciation at disposal.
pub accumulated: Decimal,
/// proceeds − book_value. Positive is a gain.
pub gain_loss: Decimal,
}
/// Compute disposal amounts. Pure math; posting is the caller's job.
pub fn disposal_amounts(
asset: &Asset,
date: NaiveDate,
proceeds: Money,
) -> Result<DisposalAmounts, LedgerError> {
if asset.status != AssetStatus::Active {
let id = asset.id;
return Err(LedgerError::AssetAlreadyDisposed(id));
}
if proceeds.amount < Decimal::ZERO {
return Err(LedgerError::NegativeProceeds(proceeds.amount));
}
if date < asset.purchase_date {
return Err(LedgerError::DisposalBeforePurchase(date.to_string()));
}
let accumulated = accumulated_depreciation(asset, date);
let book = asset.cost.amount - accumulated;
Ok(DisposalAmounts {
book_value: book,
accumulated,
gain_loss: proceeds.amount - book,
})
}
/// Validate asset account links against the chart: cost and accumulated
/// depreciation must be asset accounts, the expense account an expense
/// account, and all three distinct.
pub(crate) fn validate_asset_accounts(
chart: &ChartOfAccounts,
cost_account: AccountId,
accum_depr_account: AccountId,
expense_account: AccountId,
) -> Result<(), LedgerError> {
for (id, role, expected) in [
(cost_account, "cost", AccountType::Asset),
(
accum_depr_account,
"accumulated depreciation",
AccountType::Asset,
),
(
expense_account,
"depreciation expense",
AccountType::Expense,
),
] {
let account = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
if account.account_type != expected {
return Err(LedgerError::AssetAccountWrongType {
code: account.code.clone(),
role,
expected: match expected {
AccountType::Asset => "asset",
AccountType::Expense => "expense",
_ => unreachable!("asset links are asset or expense accounts"),
},
found: match account.account_type {
AccountType::Asset => "asset",
AccountType::Liability => "liability",
AccountType::Equity => "equity",
AccountType::Revenue => "revenue",
AccountType::Expense => "expense",
},
});
}
}
for dup in [cost_account, accum_depr_account, expense_account] {
let uses = [cost_account, accum_depr_account, expense_account]
.iter()
.filter(|&&id| id == dup)
.count();
if uses > 1 {
return Err(LedgerError::DuplicateAssetAccount(dup));
}
}
Ok(())
}
+270
View File
@@ -0,0 +1,270 @@
//! Classes: divisional P&L tracking.
//!
//! A class is a label on a journal line — a department, location, product
//! line, or job. Reports can filter the P&L to one class or break it down
//! by class, so one ledger serves several divisions without splitting the
//! chart of accounts. Classes live on lines, not accounts: one entry can
//! touch several divisions.
//!
//! Classes are master data, journaled like accounts. Tagging a line happens
//! on drafts only; posted entries are immutable, so their classes are frozen.
use serde::{Deserialize, Serialize};
/// Opaque class handle. Created by [`crate::Ledger`]; never forged.
pub type ClassId = u32;
/// A named division for line-level P&L tracking.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Class {
/// Class id (the allocator's).
pub id: ClassId,
/// Human name, e.g. `"Retail"` or `"Wholesale"`.
pub name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Currency, Ledger, LedgerError, Money, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
fn d(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
fn test_ledger() -> Ledger {
let mut ledger = Ledger::new(Currency::USD);
ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
ledger
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
ledger
.add_account("6000", "Expenses", AccountType::Expense, None, false)
.unwrap();
ledger
}
fn post_sale(
ledger: &mut Ledger,
date: NaiveDate,
revenue: Decimal,
expense: Decimal,
class: Option<ClassId>,
) {
let cash = ledger.chart().find_by_code("1000").unwrap().id;
let rev = ledger.chart().find_by_code("4000").unwrap().id;
let exp = ledger.chart().find_by_code("6000").unwrap().id;
let base = Currency::USD;
let e = ledger.create_entry(date, "sale");
ledger
.add_line(e, cash, Side::Debit, Money::new(revenue, base))
.unwrap();
ledger
.add_line(e, rev, Side::Credit, Money::new(revenue, base))
.unwrap();
if !expense.is_zero() {
ledger
.add_line(e, exp, Side::Debit, Money::new(expense, base))
.unwrap();
ledger
.add_line(e, cash, Side::Credit, Money::new(expense, base))
.unwrap();
}
if let Some(c) = class {
// Tag the revenue and expense lines (indices 1 and 2).
ledger.set_line_class(e, 1, Some(c)).unwrap();
if !expense.is_zero() {
ledger.set_line_class(e, 2, Some(c)).unwrap();
}
}
ledger.post_entry(e).unwrap();
}
#[test]
fn class_crud() {
let mut ledger = test_ledger();
assert_eq!(ledger.next_class_id(), 1);
let retail = ledger.add_class("Retail").unwrap();
let wholesale = ledger.add_class("Wholesale").unwrap();
assert_eq!((retail, wholesale), (1, 2));
assert_eq!(ledger.next_class_id(), 3);
assert_eq!(ledger.get_class(retail).unwrap().name, "Retail");
assert!(ledger.get_class(99).is_none());
let names: Vec<_> = ledger
.list_classes()
.iter()
.map(|c| c.name.clone())
.collect();
assert_eq!(names, vec!["Retail", "Wholesale"]);
// Case-insensitive lookup.
assert_eq!(ledger.find_class_by_name("retail").unwrap().id, retail);
assert_eq!(ledger.find_class_by_name("RETAIL").unwrap().id, retail);
assert!(ledger.find_class_by_name("nope").is_none());
// Empty and whitespace names rejected.
assert_eq!(ledger.add_class(""), Err(LedgerError::EmptyClassName));
assert_eq!(ledger.add_class(" "), Err(LedgerError::EmptyClassName));
// The failed adds did not move the allocator.
assert_eq!(ledger.next_class_id(), 3);
}
#[test]
fn set_line_class_draft_only() {
let mut ledger = test_ledger();
let class = ledger.add_class("Retail").unwrap();
let cash = ledger.chart().find_by_code("1000").unwrap().id;
let rev = ledger.chart().find_by_code("4000").unwrap().id;
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
let e = ledger.create_entry(date, "draft");
ledger
.add_line(e, cash, Side::Debit, Money::new(d(10_000), Currency::USD))
.unwrap();
ledger
.add_line(e, rev, Side::Credit, Money::new(d(10_000), Currency::USD))
.unwrap();
// Unknown class rejected.
assert_eq!(
ledger.set_line_class(e, 0, Some(99)),
Err(LedgerError::UnknownClass(99))
);
// Line index out of range rejected.
assert_eq!(
ledger.set_line_class(e, 7, Some(class)),
Err(LedgerError::LineIndexOutOfRange(7, e))
);
// Set, then clear.
ledger.set_line_class(e, 0, Some(class)).unwrap();
assert_eq!(
ledger.get_entry(e).unwrap().lines()[0].class_id,
Some(class)
);
ledger.set_line_class(e, 0, None).unwrap();
assert_eq!(ledger.get_entry(e).unwrap().lines()[0].class_id, None);
// Post: classes freeze. Tagging a posted entry is rejected.
ledger.post_entry(e).unwrap();
assert_eq!(
ledger.set_line_class(e, 0, Some(class)),
Err(LedgerError::PostedEntryImmutable)
);
// Unknown entry rejected.
assert_eq!(
ledger.set_line_class(999, 0, Some(class)),
Err(LedgerError::UnknownEntry(999))
);
}
#[test]
fn filtered_pl_math() {
let mut ledger = test_ledger();
let retail = ledger.add_class("Retail").unwrap();
let wholesale = ledger.add_class("Wholesale").unwrap();
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
post_sale(&mut ledger, date, d(100_000), d(40_000), Some(retail));
post_sale(&mut ledger, date, d(50_000), d(10_000), Some(wholesale));
post_sale(&mut ledger, date, d(20_000), d(5_000), None);
let r = crate::reports::profit_and_loss_filtered(&ledger, Some(retail)).unwrap();
assert_eq!(r.revenue, d(100_000));
assert_eq!(r.expenses, d(40_000));
assert_eq!(r.net_income(), d(60_000));
let w = crate::reports::profit_and_loss_filtered(&ledger, Some(wholesale)).unwrap();
assert_eq!(w.revenue, d(50_000));
assert_eq!(w.expenses, d(10_000));
// A class with no lines reports zeros.
let empty = ledger.add_class("Empty").unwrap();
let z = crate::reports::profit_and_loss_filtered(&ledger, Some(empty)).unwrap();
assert_eq!((z.revenue, z.expenses), (Decimal::ZERO, Decimal::ZERO));
// None counts every line: identical to the account-based P&L.
let all = crate::reports::profit_and_loss_filtered(&ledger, None).unwrap();
let plain = crate::reports::profit_and_loss(&ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (plain.revenue, plain.expenses));
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
}
#[test]
fn by_class_partitions_total_pl() {
let mut ledger = test_ledger();
let retail = ledger.add_class("Retail").unwrap();
let wholesale = ledger.add_class("Wholesale").unwrap();
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
post_sale(&mut ledger, date, d(100_000), d(40_000), Some(retail));
post_sale(&mut ledger, date, d(50_000), d(10_000), Some(wholesale));
post_sale(&mut ledger, date, d(20_000), d(5_000), None);
let rows = crate::reports::profit_and_loss_by_class(&ledger).unwrap();
assert_eq!(rows.len(), 3);
// Sorted by class name; unclassified last.
assert_eq!(rows[0].class.as_ref().unwrap().name, "Retail");
assert_eq!(rows[1].class.as_ref().unwrap().name, "Wholesale");
assert!(rows[2].class.is_none());
assert_eq!(rows[0].profit_and_loss.net_income(), d(60_000));
assert_eq!(rows[1].profit_and_loss.net_income(), d(40_000));
assert_eq!(rows[2].profit_and_loss.net_income(), d(15_000));
// The rows partition the ledger: they sum to the total P&L.
let total = crate::reports::profit_and_loss(&ledger).unwrap();
let rev: Decimal = rows.iter().map(|r| r.profit_and_loss.revenue).sum();
let exp: Decimal = rows.iter().map(|r| r.profit_and_loss.expenses).sum();
assert_eq!((rev, exp), (total.revenue, total.expenses));
// A class with only balance-sheet lines gets no row.
let cash_only = ledger.add_class("CashOnly").unwrap();
let cash = ledger.chart().find_by_code("1000").unwrap().id;
let e = ledger.create_entry(date, "cash move");
ledger
.add_line(e, cash, Side::Debit, Money::new(d(1_000), Currency::USD))
.unwrap();
// Need a second cash-ish line to balance; use revenue... no — use a
// second asset account so no P&L line is tagged.
let bank = ledger
.add_account("1010", "Bank", AccountType::Asset, None, false)
.unwrap();
ledger
.add_line(e, bank, Side::Credit, Money::new(d(1_000), Currency::USD))
.unwrap();
ledger.set_line_class(e, 0, Some(cash_only)).unwrap();
ledger.set_line_class(e, 1, Some(cash_only)).unwrap();
ledger.post_entry(e).unwrap();
let rows = crate::reports::profit_and_loss_by_class(&ledger).unwrap();
assert_eq!(rows.len(), 3, "balance-sheet-only class must not add a row");
// Empty ledger: no rows at all.
let empty = test_ledger();
assert!(
crate::reports::profit_and_loss_by_class(&empty)
.unwrap()
.is_empty()
);
}
#[test]
fn replay_class_checks_id() {
let mut ledger = test_ledger();
let err = ledger.replay_class(5, "Retail".to_string());
assert_eq!(err, Err(LedgerError::ReplayIdMismatch(5, 1)));
assert_eq!(ledger.next_class_id(), 1);
ledger.replay_class(1, "Retail".to_string()).unwrap();
assert_eq!(ledger.next_class_id(), 2);
assert_eq!(ledger.get_class(1).unwrap().name, "Retail");
}
#[test]
fn reversal_carries_class() {
let mut ledger = test_ledger();
let retail = ledger.add_class("Retail").unwrap();
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
post_sale(&mut ledger, date, d(100_000), Decimal::ZERO, Some(retail));
let entry_id = ledger.posted_entries().map(|e| e.id()).next().unwrap();
let rev = ledger.reverse_entry(entry_id, date, "reversal").unwrap();
let rev_entry = ledger.get_entry(rev).unwrap();
// The flipped revenue line keeps its class; the cash lines were
// never tagged.
assert_eq!(rev_entry.lines()[0].class_id, None);
assert_eq!(rev_entry.lines()[1].class_id, Some(retail));
// The class P&L nets to zero after the reversal.
let pl = crate::reports::profit_and_loss_filtered(&ledger, Some(retail)).unwrap();
assert_eq!((pl.revenue, pl.expenses), (Decimal::ZERO, Decimal::ZERO));
}
}
+183
View File
@@ -75,4 +75,187 @@ pub enum LedgerError {
/// Any attempt to mutate a posted entry: add lines, delete it, edit it. /// Any attempt to mutate a posted entry: add lines, delete it, edit it.
#[error("cannot modify a posted entry; post a reversing entry instead")] #[error("cannot modify a posted entry; post a reversing entry instead")]
PostedEntryImmutable, PostedEntryImmutable,
/// The referenced fixed asset does not exist.
#[error("unknown asset id {0}")]
UnknownAsset(u64),
/// Asset descriptions must not be empty.
#[error("asset description must not be empty")]
EmptyAssetDescription,
/// Asset cost must be positive.
#[error("asset cost must be positive, got {0}")]
InvalidAssetCost(Decimal),
/// Salvage value must be non-negative and less than cost.
#[error("salvage value must be between zero and cost, got {0}")]
InvalidSalvageValue(Decimal),
/// Useful life must be at least one month.
#[error("useful life must be at least one month, got {0}")]
InvalidUsefulLife(u32),
/// The asset was already disposed.
#[error("asset {0} is already disposed")]
AssetAlreadyDisposed(u64),
/// Asset account links must have the right types: cost and accumulated
/// depreciation are asset accounts, the depreciation expense account is
/// an expense account.
#[error("account '{code}' has type {found}, but the asset {role} account must be {expected}")]
AssetAccountWrongType {
/// Account code.
code: String,
/// Role the account was given: "cost", "accumulated depreciation", or
/// "depreciation expense".
role: &'static str,
/// The type it must be.
expected: &'static str,
/// The type it has.
found: &'static str,
},
/// Disposal proceeds must not be negative.
#[error("disposal proceeds must not be negative, got {0}")]
NegativeProceeds(Decimal),
/// Disposal date must not precede the purchase date.
#[error("disposal date {0} is before the purchase date")]
DisposalBeforePurchase(String),
/// The three asset account links must be three distinct accounts.
#[error("asset account links must be distinct; account id {0} is linked twice")]
DuplicateAssetAccount(u64),
/// purchase_date + useful life overflows the date range.
#[error("purchase date plus useful life overflows the calendar")]
AssetDateOverflow,
/// Partnership members are misconfigured (shares, capital accounts).
#[error("invalid members: {0}")]
InvalidMembers(String),
/// Cash-flow period end is before period start.
#[error("invalid date range: end {end} is before start {start}")]
InvalidDateRange {
/// Period start, YYYY-MM-DD.
start: String,
/// Period end, YYYY-MM-DD.
end: String,
},
/// The referenced accounting period does not exist.
#[error("unknown period id {0}")]
UnknownPeriod(u64),
/// The accounting period is already closed.
#[error("period {0} is already closed")]
PeriodAlreadyClosed(u64),
/// Period end is before period start.
#[error("invalid period: end {end} is before start {start}")]
InvalidPeriodRange {
/// Period start, YYYY-MM-DD.
start: String,
/// Period end, YYYY-MM-DD.
end: String,
},
/// A draft entry exists with a date inside the period being closed.
#[error("cannot close period {0}: draft entries exist with dates in the period")]
DraftsInPeriod(u64),
/// A period can't close while a later period is already closed.
#[error("period {0} can't close: later period {1} is already closed (close in order)")]
CloseOutOfOrder(u64, u64),
/// Entries cannot be created or posted in a closed period.
#[error("period {0} is closed; entries dated {1} are rejected")]
ClosedPeriod(u64, String),
/// The referenced customer does not exist.
#[error("unknown customer id {0}")]
UnknownCustomer(u64),
/// The referenced vendor does not exist.
#[error("unknown vendor id {0}")]
UnknownVendor(u64),
/// The referenced invoice does not exist.
#[error("unknown invoice id {0}")]
UnknownInvoice(u64),
/// The referenced bill does not exist.
#[error("unknown bill id {0}")]
UnknownBill(u64),
/// Invoice/bill must have at least one line.
#[error("invoice/bill must have at least one line")]
EmptyDocumentLines,
/// Invoice/bill line amounts must be positive.
#[error("document line amount must be positive, got {0}")]
InvalidDocumentLineAmount(Decimal),
/// Payment amount must be positive.
#[error("payment amount must be positive, got {0}")]
InvalidPaymentAmount(Decimal),
/// Payment exceeds the remaining balance.
#[error("payment of {0} exceeds remaining balance of {1}")]
Overpayment(Decimal, Decimal),
/// Invoice/bill due date is before its date.
#[error("due date {0} is before document date")]
DueBeforeDate(String),
/// Line index out of range for cleared-flag update.
#[error("line index {0} out of range for entry {1}")]
LineIndexOutOfRange(usize, u64),
/// Cannot set a cleared flag on a draft entry's line.
#[error("cannot mark cleared on draft entry {0}; only posted lines can clear")]
ClearedOnDraft(u64),
/// FX rate must be positive.
#[error("fx rate must be positive, got {0}")]
InvalidFxRate(Decimal),
/// FX line requires both foreign currency and foreign amount.
#[error("fx line requires foreign currency, foreign amount, and fx rate")]
IncompleteFxLine,
/// Replay recorded an id the allocator was not expecting: the journal
/// and the engine disagree about event ordering.
#[error("replay id mismatch: journal says {0}, allocator expects {1}")]
ReplayIdMismatch(u64, u64),
/// A payroll run must have at least one employee line.
#[error("payroll run must have at least one line")]
EmptyPayroll,
/// Payroll amounts must not be negative.
#[error("payroll amount must not be negative, got {0}")]
NegativePayrollAmount(Decimal),
/// Net pay went negative: withholdings exceed gross for this employee.
#[error("payroll net pay for '{0}' is negative")]
NegativeNetPay(String),
/// No payroll tax payables are outstanding: nothing to remit.
#[error("no payroll tax payables to remit")]
NothingToRemit,
/// The referenced payroll run does not exist.
#[error("unknown payroll run id {0}")]
UnknownPayrollRun(u64),
/// Class names must not be empty.
#[error("class name must not be empty")]
EmptyClassName,
/// The referenced class does not exist.
#[error("unknown class id {0}")]
UnknownClass(u32),
} }
+24 -2
View File
@@ -4,7 +4,7 @@
//! total credits. Entries are born as drafts, validated, then posted. Posting //! total credits. Entries are born as drafts, validated, then posted. Posting
//! is one-way; a posted entry can only be corrected by a reversing entry. //! is one-way; a posted entry can only be corrected by a reversing entry.
use crate::{AccountId, ChartOfAccounts, Currency, LedgerError, Money, Side}; use crate::{AccountId, ChartOfAccounts, ClassId, Currency, LedgerError, Money, Side};
use chrono::NaiveDate; use chrono::NaiveDate;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -29,8 +29,24 @@ pub struct JournalLine {
pub account: AccountId, pub account: AccountId,
/// Which side of the entry this line is on. /// Which side of the entry this line is on.
pub side: Side, pub side: Side,
/// The exact amount. Never zero (rejected at validation). /// The exact amount, in the ledger's base currency. Never zero (rejected
/// at validation).
pub amount: Money, pub amount: Money,
/// For foreign-currency lines: the original currency. `None` for base-
/// currency lines. When set, `amount` is the base-currency equivalent
/// converted at the line's rate.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreign_currency: Option<Currency>,
/// For foreign-currency lines: the original foreign amount. `None` for
/// base-currency lines.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreign_amount: Option<Decimal>,
/// Divisional tracking: which class (department, location, job) this
/// line belongs to. `None` means unclassified. Set on drafts only via
/// [`crate::Ledger::set_line_class`]; posted lines are immutable, so
/// their classes are frozen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class_id: Option<ClassId>,
} }
/// A balanced set of journal lines with a date and a description. /// A balanced set of journal lines with a date and a description.
@@ -93,6 +109,12 @@ impl JournalEntry {
self.lines.push(line); self.lines.push(line);
} }
/// Tag a line with a class (or clear it). Only while drafted; only via
/// [`crate::Ledger::set_line_class`].
pub(crate) fn set_line_class(&mut self, index: usize, class_id: Option<ClassId>) {
self.lines[index].class_id = class_id;
}
/// Mark posted. Only via [`crate::Ledger`], after [`Self::validate`]. /// Mark posted. Only via [`crate::Ledger`], after [`Self::validate`].
pub(crate) fn mark_posted(&mut self) { pub(crate) fn mark_posted(&mut self) {
self.status = EntryStatus::Posted; self.status = EntryStatus::Posted;
File diff suppressed because it is too large. Load diff
+24 -2
View File
@@ -17,15 +17,37 @@
//! (`docs/oracle.md`) possible. //! (`docs/oracle.md`) possible.
pub mod account; pub mod account;
pub mod arap;
pub mod assets;
pub mod classes;
pub mod error; pub mod error;
pub mod journal; pub mod journal;
pub mod ledger; pub mod ledger;
pub mod money; pub mod money;
pub mod payroll;
pub mod periods;
pub mod reports; pub mod reports;
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
pub use arap::{
AgingRow, Bill, BillId, Customer, CustomerId, DocumentLine, DocumentStatus, Invoice, InvoiceId,
Vendor, VendorId,
};
pub use assets::{
Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine,
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
};
pub use classes::{Class, ClassId};
pub use error::LedgerError; pub use error::LedgerError;
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::Ledger; pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money}; pub use money::{Currency, Money};
pub use reports::{BalanceSheet, ProfitAndLoss, TrialBalance, TrialBalanceLine}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
MemberEquityColumn, MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement,
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
members_equity, profit_and_loss_for_period, trial_balance_as_of,
};
+42 -5
View File
@@ -15,15 +15,15 @@ pub struct Currency([u8; 3]);
impl Currency { impl Currency {
/// US dollar. /// US dollar.
pub const USD: Currency = Currency([b'U', b'S', b'D']); pub const USD: Currency = Currency(*b"USD");
/// Euro. /// Euro.
pub const EUR: Currency = Currency([b'E', b'U', b'R']); pub const EUR: Currency = Currency(*b"EUR");
/// British pound. /// British pound.
pub const GBP: Currency = Currency([b'G', b'B', b'P']); pub const GBP: Currency = Currency(*b"GBP");
/// Swiss franc. /// Swiss franc.
pub const CHF: Currency = Currency([b'C', b'H', b'F']); pub const CHF: Currency = Currency(*b"CHF");
/// Japanese yen. /// Japanese yen.
pub const JPY: Currency = Currency([b'J', b'P', b'Y']); pub const JPY: Currency = Currency(*b"JPY");
/// Parse and validate a three-letter code. Input is uppercased, so /// Parse and validate a three-letter code. Input is uppercased, so
/// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail. /// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail.
@@ -123,3 +123,40 @@ impl fmt::Display for Money {
write!(f, "{} {}", self.amount, self.currency) write!(f, "{} {}", self.amount, self.currency)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn usd(amount: Decimal) -> Money {
Money::new(amount, Currency::USD)
}
fn d(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
#[test]
fn checked_arithmetic() {
assert_eq!(
usd(d(1050)).checked_add(usd(d(425))).unwrap().amount,
d(1475)
);
assert_eq!(
usd(d(1050)).checked_sub(usd(d(425))).unwrap().amount,
d(625)
);
// Subtraction is not division, and not addition.
assert_ne!(
usd(d(1000)).checked_sub(usd(d(200))).unwrap().amount,
d(500)
);
}
#[test]
fn checked_arithmetic_rejects_currency_mismatch() {
let eur = Money::new(d(100), Currency::EUR);
assert!(usd(d(100)).checked_add(eur).is_err());
assert!(usd(d(100)).checked_sub(eur).is_err());
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Payroll: accounting-only payroll runs (no tax tables).
//!
//! A payroll run posts one balanced entry: gross wages hit Salary & Wage
//! Expense, the employer share of FICA hits Payroll Tax Expense, employee
//! withholdings and deductions accumulate in the 2110–2150 payable accounts,
//! and net pay leaves cash. A later [`crate::Ledger::remit_payroll_taxes`]
//! clears the payables when the tax payments go out.
//!
//! There is no tax law here: every withholding number is supplied by the
//! caller. All amounts are exact [`rust_decimal::Decimal`], never floats.
use crate::{AccountId, Currency, EntryId, LedgerError, Money, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Opaque payroll run handle. Created by [`crate::Ledger`]; never forged.
pub type PayrollRunId = u64;
/// One employee's payroll line: gross pay and every withholding and
/// deduction. All amounts must be non-negative; the engine rejects
/// negatives, and rejects the line when withholdings exceed gross.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PayrollLine {
/// Employee name, for the run record.
pub employee_name: String,
/// Gross pay for the period.
pub gross_pay: Decimal,
/// Federal income tax withheld.
pub federal_withholding: Decimal,
/// State income tax withheld.
pub state_withholding: Decimal,
/// Employee share of Social Security tax.
pub ss_employee: Decimal,
/// Employee share of Medicare tax.
pub medicare_employee: Decimal,
/// Employer share of Social Security tax.
pub ss_employer: Decimal,
/// Employer share of Medicare tax.
pub medicare_employer: Decimal,
/// Other deductions (retirement, insurance, garnishments, ...).
pub other_deductions: Decimal,
}
impl PayrollLine {
/// Gross minus all employee withholdings and deductions. Must be >= 0:
/// you cannot withhold more than the employee earned.
pub fn net_pay(&self) -> Decimal {
self.gross_pay
- self.federal_withholding
- self.state_withholding
- self.ss_employee
- self.medicare_employee
- self.other_deductions
}
}
/// A posted payroll run: the inputs plus the journal entry it posted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PayrollRun {
/// Run id (the allocator's).
pub id: PayrollRunId,
/// Pay date: the date of the posted entry.
pub date: NaiveDate,
/// One line per employee.
pub lines: Vec<PayrollLine>,
/// The posted journal entry.
pub entry_id: EntryId,
}
/// The chart accounts a payroll run posts to, resolved by code.
#[derive(Clone, Copy, Debug)]
pub struct PayrollAccounts {
/// `6100` Salary & Wage Expense.
pub salary_expense: AccountId,
/// `6110` Payroll Tax Expense.
pub payroll_tax_expense: AccountId,
/// `2110` Federal Withholding Payable.
pub federal_payable: AccountId,
/// `2120` State Withholding Payable.
pub state_payable: AccountId,
/// `2130` SS Tax Payable.
pub ss_payable: AccountId,
/// `2140` Medicare Tax Payable.
pub medicare_payable: AccountId,
/// `2150` Other Deductions Payable.
pub other_payable: AccountId,
}
/// Validate a payroll run's lines without mutating: non-empty, no negative
/// amounts, no negative net pay. Journaling stores run this before writing
/// anything, so a doomed run never leaves journal lines behind.
pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError> {
if lines.is_empty() {
return Err(LedgerError::EmptyPayroll);
}
for line in lines {
for amount in [
line.gross_pay,
line.federal_withholding,
line.state_withholding,
line.ss_employee,
line.medicare_employee,
line.ss_employer,
line.medicare_employer,
line.other_deductions,
] {
if amount < Decimal::ZERO {
return Err(LedgerError::NegativePayrollAmount(amount));
}
}
if line.net_pay() < Decimal::ZERO {
return Err(LedgerError::NegativeNetPay(line.employee_name.clone()));
}
}
Ok(())
}
/// The auto-posted payroll entry's lines, in posting order: Dr 6100 gross,
/// Dr 6110 the employer FICA share, Cr 2110–2150 the payables, Cr cash net
/// pay. Zero totals are skipped — the engine rejects zero-amount lines.
///
/// Shared by [`crate::Ledger::run_payroll`] and the journaling store, so the
/// journaled entry and the posted entry are built from the same math. The
/// plan always balances: debits are gross + employer FICA, credits are the
/// payables + net, and net is defined as gross minus employee withholdings.
pub fn payroll_entry_plan(
lines: &[PayrollLine],
accounts: PayrollAccounts,
cash_account: AccountId,
base: Currency,
) -> Vec<(AccountId, Side, Money)> {
let sum = |f: fn(&PayrollLine) -> Decimal| lines.iter().map(f).sum::<Decimal>();
let gross = sum(|l| l.gross_pay);
let employer = sum(|l| l.ss_employer + l.medicare_employer);
let federal = sum(|l| l.federal_withholding);
let state = sum(|l| l.state_withholding);
let ss = sum(|l| l.ss_employee + l.ss_employer);
let medicare = sum(|l| l.medicare_employee + l.medicare_employer);
let other = sum(|l| l.other_deductions);
let net = sum(|l| l.net_pay());
let mut plan = Vec::new();
let mut push = |account: AccountId, side: Side, amount: Decimal| {
if !amount.is_zero() {
plan.push((account, side, Money::new(amount, base)));
}
};
push(accounts.salary_expense, Side::Debit, gross);
push(accounts.payroll_tax_expense, Side::Debit, employer);
push(accounts.federal_payable, Side::Credit, federal);
push(accounts.state_payable, Side::Credit, state);
push(accounts.ss_payable, Side::Credit, ss);
push(accounts.medicare_payable, Side::Credit, medicare);
push(accounts.other_payable, Side::Credit, other);
push(cash_account, Side::Credit, net);
plan
}
/// The remittance entry's lines: Dr each payable with a non-zero balance,
/// Cr cash for the total. Balances are normal-side (positive = owed), so
/// the plan balances by construction.
pub fn payroll_remit_plan(
payables: &[(AccountId, Decimal)],
cash_account: AccountId,
base: Currency,
) -> Vec<(AccountId, Side, Money)> {
let mut plan = Vec::new();
let mut total = Decimal::ZERO;
for (account, balance) in payables {
if balance.is_zero() {
continue;
}
plan.push((*account, Side::Debit, Money::new(*balance, base)));
total += *balance;
}
if !total.is_zero() {
plan.push((cash_account, Side::Credit, Money::new(total, base)));
}
plan
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Ledger};
fn d(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
fn line(name: &str) -> PayrollLine {
PayrollLine {
employee_name: name.to_string(),
gross_pay: d(500_000), // 5000.00
federal_withholding: d(75_000), // 750.00
state_withholding: d(25_000), // 250.00
ss_employee: d(31_000), // 310.00
medicare_employee: d(7_250), // 72.50
ss_employer: d(31_000),
medicare_employer: d(7_250),
other_deductions: d(10_000), // 100.00
}
}
fn ledger_with_cash() -> (Ledger, AccountId) {
let mut ledger = Ledger::new(Currency::USD);
let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
(ledger, cash)
}
#[test]
fn net_pay_math() {
let l = line("Ada");
// 5000 - 750 - 250 - 310 - 72.50 - 100 = 3517.50
assert_eq!(l.net_pay(), d(351_750));
}
#[test]
fn validation_rejects_empty_negative_and_overwithheld() {
assert_eq!(validate_payroll_lines(&[]), Err(LedgerError::EmptyPayroll));
let mut bad = line("Bob");
bad.ss_employee = d(-1);
assert_eq!(
validate_payroll_lines(std::slice::from_ref(&bad)),
Err(LedgerError::NegativePayrollAmount(d(-1)))
);
let mut over = line("Cat");
over.other_deductions = d(99_999_999);
assert!(over.net_pay() < Decimal::ZERO);
assert_eq!(
validate_payroll_lines(std::slice::from_ref(&over)),
Err(LedgerError::NegativeNetPay("Cat".to_string()))
);
assert!(validate_payroll_lines(&[line("Ada")]).is_ok());
}
#[test]
fn entry_plan_balances_and_skips_zeros() {
let cash = ledger_with_cash().1;
let accounts = PayrollAccounts {
salary_expense: 101,
payroll_tax_expense: 102,
federal_payable: 103,
state_payable: 104,
ss_payable: 105,
medicare_payable: 106,
other_payable: 107,
};
// No state withholding and no other deductions: those credits vanish.
let mut l = line("Ada");
l.state_withholding = Decimal::ZERO;
l.other_deductions = Decimal::ZERO;
let plan = payroll_entry_plan(&[l], accounts, cash, Currency::USD);
assert_eq!(plan.len(), 6); // gross, employer tax, fed, ss, medicare, cash
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
for (_, side, amount) in &plan {
match side {
Side::Debit => debits += amount.amount,
Side::Credit => credits += amount.amount,
}
}
assert_eq!(debits, credits);
assert!(debits > Decimal::ZERO);
for (_, _, amount) in &plan {
assert!(!amount.amount.is_zero());
}
}
#[test]
fn run_payroll_posts_balanced_entry_and_records_run() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
let id = ledger
.run_payroll(date, vec![line("Ada"), line("Bob")], cash)
.unwrap();
assert_eq!(id, 1);
assert_eq!(ledger.next_payroll_run_id(), 2);
let run = ledger.get_payroll_run(id).unwrap();
assert_eq!(run.date, date);
assert_eq!(run.lines.len(), 2);
let entry = ledger.get_entry(run.entry_id).unwrap();
assert_eq!(entry.status(), crate::EntryStatus::Posted);
// Debits equal credits, exactly.
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
for l in entry.lines() {
match l.side {
Side::Debit => debits += l.amount.amount,
Side::Credit => credits += l.amount.amount,
}
}
assert_eq!(debits, credits);
// Gross x2 = 10000 hits Salary & Wage Expense (debit-normal).
let salary = ledger.chart().find_by_code("6100").unwrap().id;
assert_eq!(ledger.account_balance(salary).unwrap(), d(1_000_000));
// Net x2 = 7035 leaves cash (debit-normal): -7035.
assert_eq!(ledger.account_balance(cash).unwrap(), -d(703_500));
// The seven payroll accounts were auto-created.
for code in ["6100", "6110", "2110", "2120", "2130", "2140", "2150"] {
assert!(
ledger.chart().find_by_code(code).is_some(),
"missing {code}"
);
}
// Runs list in id order.
let id2 = ledger.run_payroll(date, vec![line("Cat")], cash).unwrap();
let ids: Vec<_> = ledger.list_payroll_runs().iter().map(|r| r.id).collect();
assert_eq!(ids, vec![id, id2]);
}
#[test]
fn run_payroll_rejects_closed_period() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
let pid = ledger
.create_period("Sep", NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), date)
.unwrap();
ledger.close_period(pid).unwrap();
let err = ledger.run_payroll(date, vec![line("Ada")], cash);
assert_eq!(err, Err(LedgerError::ClosedPeriod(pid, date.to_string())));
}
#[test]
fn run_payroll_rejects_bad_inputs() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
assert_eq!(
ledger.run_payroll(date, vec![], cash),
Err(LedgerError::EmptyPayroll)
);
let mut bad = line("Ada");
bad.gross_pay = d(-100);
assert_eq!(
ledger.run_payroll(date, vec![bad], cash),
Err(LedgerError::NegativePayrollAmount(d(-100)))
);
assert_eq!(
ledger.run_payroll(date, vec![line("Ada")], 999),
Err(LedgerError::UnknownAccount(999))
);
}
#[test]
fn remit_clears_payables() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
ledger.run_payroll(date, vec![line("Ada")], cash).unwrap();
// Withholdings owed: 750 + 250 + (310+310) + (72.50+72.50) + 100 = 1865.
let fed = ledger.chart().find_by_code("2110").unwrap().id;
assert_eq!(ledger.account_balance(fed).unwrap(), d(75_000));
// Nothing owed before any payroll: fresh ledger remits nothing.
let (mut fresh, fresh_cash) = ledger_with_cash();
assert_eq!(
fresh.remit_payroll_taxes(date, fresh_cash),
Err(LedgerError::NothingToRemit)
);
// Remit: payables go to zero, cash drops by the total.
let cash_before = ledger.account_balance(cash).unwrap();
let entry = ledger.remit_payroll_taxes(date, cash).unwrap();
let posted = ledger.get_entry(entry).unwrap();
assert_eq!(posted.status(), crate::EntryStatus::Posted);
for code in ["2110", "2120", "2130", "2140", "2150"] {
let id = ledger.chart().find_by_code(code).unwrap().id;
assert_eq!(
ledger.account_balance(id).unwrap(),
Decimal::ZERO,
"payable {code} not cleared"
);
}
assert_eq!(
ledger.account_balance(cash).unwrap(),
cash_before - d(186_500)
);
// Second remit finds nothing left.
assert_eq!(
ledger.remit_payroll_taxes(date, cash),
Err(LedgerError::NothingToRemit)
);
}
#[test]
fn remit_rejects_closed_period() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
ledger
.run_payroll(
NaiveDate::from_ymd_opt(2026, 9, 15).unwrap(),
vec![line("Ada")],
cash,
)
.unwrap();
let pid = ledger
.create_period("Sep", NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), date)
.unwrap();
ledger.close_period(pid).unwrap();
assert_eq!(
ledger.remit_payroll_taxes(date, cash),
Err(LedgerError::ClosedPeriod(pid, date.to_string()))
);
}
#[test]
fn replay_payroll_run_checks_id() {
let (mut ledger, _cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
let entry = ledger.create_entry(date, "x");
let err = ledger.replay_payroll_run(7, date, vec![line("Ada")], entry);
assert_eq!(err, Err(LedgerError::ReplayIdMismatch(7, 1)));
// The allocator did not move on failure.
assert_eq!(ledger.next_payroll_run_id(), 1);
ledger
.replay_payroll_run(1, date, vec![line("Ada")], entry)
.unwrap();
assert_eq!(ledger.next_payroll_run_id(), 2);
assert!(ledger.get_payroll_run(1).is_some());
assert!(ledger.get_payroll_run(2).is_none());
}
}
+236
View File
@@ -0,0 +1,236 @@
//! Accounting periods and period close.
//!
//! A [`Period`] is a named date range (a month, a quarter, a year). Periods
//! start open; closing one is a one-way operation that:
//!
//! 1. Rejects the close if any draft entries have dates inside the period.
//! 2. Posts a closing entry dated at period end: revenue accounts are
//! debited to zero, expense accounts are credited to zero, and the net
//! difference lands in Retained Earnings (created as `3999` if missing).
//! 3. Marks the period closed.
//!
//! Once closed, [`Ledger::create_entry`] and [`Ledger::post_entry`] reject
//! any entry dated inside the period. Corrections to closed periods go
//! through the *next* open period (or a reversal dated in an open period).
use crate::LedgerError;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
/// Opaque period handle. Created by [`crate::Ledger`]; never forged.
pub type PeriodId = u64;
/// Lifecycle of an accounting period. `Open -> Closed` is one-way.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum PeriodStatus {
/// Accepting entries.
Open,
/// Closed. Entries dated inside are rejected.
Closed,
}
/// Where a period close puts the period's net income.
#[derive(Clone, Debug, PartialEq)]
pub enum CloseTo {
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
/// corporate convention.
RetainedEarnings,
/// Members' capital accounts, by share (shares sum to 1): the
/// partnership convention, where each member's capital account carries
/// their allocated earnings.
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
}
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
/// so the parts always sum to `amount` exactly.
pub fn allocate(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
) -> Vec<rust_decimal::Decimal> {
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
let mut parts: Vec<_> = shares
.iter()
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
.collect();
if let Some((last, rest)) = parts.split_last_mut() {
*last = amount - rest.iter().copied().sum::<rust_decimal::Decimal>();
}
parts
}
/// Code of the Retained Earnings account that closing entries credit (or
/// debit), created on the first close if the chart lacks it.
pub const RETAINED_EARNINGS_CODE: &str = "3999";
/// A named accounting period: a month, quarter, or year.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Period {
/// Opaque handle.
pub id: PeriodId,
/// Human name, e.g. `"2026-10"` or `"Q4 2026"`.
pub name: String,
/// First date in the period (inclusive).
pub start: NaiveDate,
/// Last date in the period (inclusive).
pub end: NaiveDate,
/// Open or closed.
pub status: PeriodStatus,
/// The closing entry's id, once closed.
pub closing_entry: Option<u64>,
}
impl Period {
/// True when `date` falls inside the period (inclusive).
pub fn contains(&self, date: NaiveDate) -> bool {
date >= self.start && date <= self.end
}
}
/// Validate a period's date range before creation.
pub fn validate_period_range(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
if end < start {
return Err(LedgerError::InvalidPeriodRange {
start: start.to_string(),
end: end.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Currency, Ledger};
fn period_ledger() -> Ledger {
let mut l = Ledger::new(Currency::USD);
l.add_account("4000", "Revenue", crate::AccountType::Revenue, None, false)
.unwrap();
l.add_account("6000", "Expenses", crate::AccountType::Expense, None, false)
.unwrap();
l.add_account("1000", "Cash", crate::AccountType::Asset, None, false)
.unwrap();
l
}
fn post_sale(l: &mut Ledger, date: NaiveDate, amount: &str) {
let e = l.create_entry(date, "sale");
let rev = l.chart().find_by_code("4000").unwrap().id;
let cash = l.chart().find_by_code("1000").unwrap().id;
l.add_line(
e,
cash,
crate::Side::Debit,
crate::Money::new(amount.parse().unwrap(), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
crate::Side::Credit,
crate::Money::new(amount.parse().unwrap(), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
}
#[test]
fn close_zeroes_revenue_and_expense_to_retained_earnings() {
let mut l = period_ledger();
let start = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 10, 31).unwrap();
let pid = l.create_period("2026-10", start, end).unwrap();
post_sale(
&mut l,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"1000.00",
);
// An expense: debit expense, credit cash.
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(), "rent");
let exp = l.chart().find_by_code("6000").unwrap().id;
let cash = l.chart().find_by_code("1000").unwrap().id;
let m = crate::Money::new("400.00".parse().unwrap(), Currency::USD);
l.add_line(e, exp, crate::Side::Debit, m).unwrap();
l.add_line(e, cash, crate::Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.close_period(pid).unwrap();
// Revenue and expense are zeroed.
let rev = l.chart().find_by_code("4000").unwrap().id;
let exp = l.chart().find_by_code("6000").unwrap().id;
assert_eq!(l.account_balance(rev).unwrap(), rust_decimal::Decimal::ZERO);
assert_eq!(l.account_balance(exp).unwrap(), rust_decimal::Decimal::ZERO);
// Retained earnings holds the net: 1000 - 400 = 600 (credit-normal).
let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id;
assert_eq!(
l.account_balance(re).unwrap(),
"600".parse::<rust_decimal::Decimal>().unwrap()
);
// Period is closed.
assert_eq!(l.get_period(pid).unwrap().status, PeriodStatus::Closed);
}
#[test]
fn close_rejects_when_already_closed() {
let mut l = period_ledger();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
l.close_period(pid).unwrap();
assert_eq!(
l.close_period(pid).unwrap_err(),
LedgerError::PeriodAlreadyClosed(pid)
);
}
#[test]
fn close_rejects_with_drafts_in_period() {
let mut l = period_ledger();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
// A draft dated inside the period blocks the close.
l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "draft");
assert_eq!(
l.close_period(pid).unwrap_err(),
LedgerError::DraftsInPeriod(pid)
);
}
#[test]
fn closed_period_rejects_new_entries() {
let mut l = period_ledger();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
l.close_period(pid).unwrap();
// create_entry with a date in the closed period fails.
let err = l.try_create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "late");
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
}
#[test]
fn invalid_range_rejected() {
let mut l = period_ledger();
let err = l.create_period(
"bad",
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
);
assert!(matches!(err, Err(LedgerError::InvalidPeriodRange { .. })));
}
}
File diff suppressed because it is too large. Load diff
+381
View File
@@ -0,0 +1,381 @@
//! Fixed-asset tests: the depreciation math must be exact to the penny.
use chrono::NaiveDate;
use ledger_core::{
AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset,
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
};
use rust_decimal::Decimal;
fn usd(amount: &str) -> Money {
Money::new(amount.parse().unwrap(), Currency::USD)
}
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn test_ledger() -> (Ledger, u64, u64, u64) {
let mut ledger = Ledger::new(Currency::USD);
let cost = ledger
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let accum = ledger
.add_account(
"1501",
"Accumulated Depreciation",
AccountType::Asset,
None,
false,
)
.unwrap();
let expense = ledger
.add_account(
"6100",
"Depreciation Expense",
AccountType::Expense,
None,
false,
)
.unwrap();
(ledger, cost, accum, expense)
}
/// Register the standard test asset: bought 2026-01-15, 60-month life,
/// zero salvage, straight-line.
fn van_asset(
ledger: &mut Ledger,
dollars: u64,
cost_acct: u64,
accum_acct: u64,
expense_acct: u64,
) -> u64 {
ledger
.register_asset(NewAsset {
description: "Sprinter van".to_string(),
purchase_date: date(2026, 1, 15),
cost: usd(&dollars.to_string()),
salvage_value: usd("0"),
useful_life_months: 60,
cost_account: cost_acct,
accum_depr_account: accum_acct,
expense_account: expense_acct,
})
.unwrap()
}
#[test]
fn schedule_sums_exactly_to_depreciable_amount() {
let (mut ledger, cost, accum, expense) = test_ledger();
// Odd numbers chosen to not divide evenly: 100000 / 60.
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2031, 12, 31));
// 61 calendar months touched (partial first and last), 60 months' worth.
assert_eq!(sched.len(), 61);
let total: Decimal = sched.iter().map(|l| l.amount).sum();
assert_eq!(
total,
Decimal::from(100_000),
"lifetime total must be exact"
);
assert_eq!(sched.last().unwrap().accumulated, Decimal::from(100_000));
assert_eq!(sched.last().unwrap().book_value, Decimal::ZERO);
// First month prorated: 17 of 31 days → 100000/60 * 17/31 = 913.98.
let first = &sched[0];
assert_eq!(first.period, "2026-01");
assert_eq!(first.amount, Decimal::new(91398, 2));
// Last month is the complement: 14 of 31 days.
assert_eq!(sched[60].period, "2031-01");
}
#[test]
fn partial_months_complement_to_whole() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2031, 12, 31));
// Jan 2026 (17/31) + Jan 2031 (14/31) = exactly one month's charge.
let monthly = Decimal::from(100_000) / Decimal::from(60);
let monthly_rounded =
monthly.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
let pair = sched[0].amount + sched[60].amount;
assert!(
(pair - monthly_rounded).abs() <= Decimal::new(1, 2),
"partial months should complement: {pair} vs {monthly_rounded}"
);
}
#[test]
fn salvage_value_is_never_depreciated() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = ledger
.register_asset(NewAsset {
description: "Machine".to_string(),
purchase_date: date(2026, 3, 1),
cost: usd("50000"),
salvage_value: usd("5000"),
useful_life_months: 36,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2030, 12, 31));
let total: Decimal = sched.iter().map(|l| l.amount).sum();
assert_eq!(total, Decimal::from(45_000));
assert_eq!(sched.last().unwrap().book_value, Decimal::from(5_000));
}
#[test]
fn book_value_tracks_accumulated() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
let acc = accumulated_depreciation(asset, date(2026, 12, 31));
// Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000.
assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000));
assert_eq!(
book_value(asset, date(2026, 12, 31)),
Decimal::from(60_000) - acc
);
}
#[test]
fn disposal_math() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
// Sell after one year for 55,000. Book value is 60000 − 11548.39 =
// 48451.61, so this is a gain of 6548.39.
let d = disposal_amounts(asset, date(2026, 12, 31), usd("55000")).unwrap();
assert_eq!(d.book_value, Decimal::new(4845161, 2));
assert_eq!(d.accumulated, Decimal::new(1154839, 2));
assert_eq!(d.gain_loss, Decimal::new(654839, 2));
// Disposing twice fails.
ledger
.dispose_asset(id, date(2026, 12, 31), usd("55000"))
.unwrap();
assert!(
ledger
.dispose_asset(id, date(2027, 1, 1), usd("1"))
.is_err()
);
let asset = ledger.get_asset(id).unwrap();
assert!(matches!(asset.status, AssetStatus::Disposed { .. }));
}
#[test]
fn registration_validation() {
let (mut ledger, cost, accum, expense) = test_ledger();
let liability = ledger
.add_account("2000", "Payable", AccountType::Liability, None, false)
.unwrap();
let base = NewAsset {
description: "X".to_string(),
purchase_date: date(2026, 1, 1),
cost: usd("1000"),
salvage_value: usd("0"),
useful_life_months: 12,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
};
// Wrong account type for the expense link.
let mut bad = base.clone();
bad.expense_account = liability;
assert!(ledger.register_asset(bad).is_err());
// Salvage >= cost.
let mut bad = base.clone();
bad.salvage_value = usd("1000");
assert!(ledger.register_asset(bad).is_err());
// Zero life.
let mut bad = base.clone();
bad.useful_life_months = 0;
assert!(ledger.register_asset(bad).is_err());
// Duplicate account links.
let mut bad = base.clone();
bad.accum_depr_account = cost;
assert!(ledger.register_asset(bad).is_err());
// Unknown account.
let mut bad = base.clone();
bad.cost_account = 999;
assert!(ledger.register_asset(bad).is_err());
// The good one works.
assert!(ledger.register_asset(base).is_ok());
}
#[test]
fn method_is_straight_line() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = van_asset(&mut ledger, 12_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
assert_eq!(asset.method, DepreciationMethod::StraightLine);
}
#[test]
fn registration_rejects_date_overflow() {
let (mut ledger, cost, accum, expense) = test_ledger();
// A purchase date near the end of the calendar with a long life would
// overflow the schedule's month arithmetic; registration must refuse.
let err = ledger
.register_asset(NewAsset {
description: "Time machine".to_string(),
purchase_date: NaiveDate::MAX,
cost: usd("1000"),
salvage_value: usd("0"),
useful_life_months: 12,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap_err();
assert!(matches!(err, ledger_core::LedgerError::AssetDateOverflow));
}
#[test]
fn schedule_through_purchase_date_covers_one_day() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = ledger
.register_asset(NewAsset {
description: "X".to_string(),
purchase_date: date(2026, 1, 15),
cost: usd("62000"),
salvage_value: usd("0"),
useful_life_months: 62,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let asset = ledger.get_asset(id).unwrap();
// Through the day before purchase: nothing.
assert!(depreciation_schedule(asset, date(2026, 1, 14)).is_empty());
// Through the purchase date itself: one day of the first month.
let one_day = depreciation_schedule(asset, date(2026, 1, 15));
assert_eq!(one_day.len(), 1);
assert_eq!(one_day[0].period, "2026-01");
// 62000/62 = 1000/month; 1/31 of that = 32.26.
assert_eq!(one_day[0].amount, Decimal::new(3226, 2));
}
#[test]
fn golden_van_depreciation_schedule() {
// The canonical van's month-by-month schedule, pinned byte-for-value.
// Hand-verified: 61 months, first 548.39 = 1000 x 17/31, last 451.61 =
// 1000 x 14/31, lifetime total exactly 60000.00.
let (mut ledger, cost, accum, expense) = test_ledger();
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2032, 1, 1));
let got = serde_json::to_value(&sched).unwrap();
let golden: serde_json::Value =
serde_json::from_str(include_str!("golden/van_schedule.json")).unwrap();
assert_eq!(got, golden);
}
#[test]
fn registration_rejects_wrong_typed_accounts() {
let (mut ledger, cost, accum, expense) = test_ledger();
let base = || NewAsset {
description: "X".to_string(),
purchase_date: date(2026, 1, 15),
cost: usd("1000"),
salvage_value: usd("0"),
useful_life_months: 12,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
};
// An expense account cannot hold the cost.
let err = ledger
.register_asset(NewAsset {
cost_account: expense,
..base()
})
.unwrap_err();
assert!(matches!(
err,
ledger_core::LedgerError::AssetAccountWrongType { .. }
));
// An asset account cannot be the depreciation expense.
let err = ledger
.register_asset(NewAsset {
expense_account: cost,
..base()
})
.unwrap_err();
assert!(matches!(
err,
ledger_core::LedgerError::AssetAccountWrongType { .. }
));
// The three links must be distinct accounts.
let err = ledger
.register_asset(NewAsset {
accum_depr_account: cost,
..base()
})
.unwrap_err();
assert!(matches!(
err,
ledger_core::LedgerError::DuplicateAssetAccount(_)
));
// Unknown accounts are rejected.
let err = ledger
.register_asset(NewAsset {
cost_account: 999,
..base()
})
.unwrap_err();
assert!(matches!(err, ledger_core::LedgerError::UnknownAccount(999)));
}
#[test]
fn disposal_accepts_zero_proceeds() {
// Scrapping an asset for nothing is legitimate: full loss.
let (mut ledger, cost, accum, expense) = test_ledger();
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap().clone();
let d = disposal_amounts(&asset, date(2026, 6, 30), usd("0")).unwrap();
assert!(d.gain_loss < Decimal::ZERO);
assert_eq!(d.book_value + d.gain_loss, Decimal::ZERO);
}
#[test]
fn two_assets_get_distinct_ids_and_list() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id1 = van_asset(&mut ledger, 60_000, cost, accum, expense);
let id2 = van_asset(&mut ledger, 30_000, cost, accum, expense);
assert_ne!(id1, id2);
let listed: Vec<_> = ledger.registered_assets().collect();
assert_eq!(listed.len(), 2);
assert!(listed.iter().any(|a| a.id == id1));
assert!(listed.iter().any(|a| a.id == id2));
}
#[test]
fn one_month_useful_life_is_valid() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = ledger
.register_asset(NewAsset {
description: "Cheap tool".to_string(),
purchase_date: date(2026, 3, 10),
cost: usd("300"),
salvage_value: usd("0"),
useful_life_months: 1,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2026, 12, 31));
// A one-month life starting Mar 10 spans two calendar months.
assert_eq!(sched.len(), 2);
// March: 22/31 of 300 = 212.90.
assert_eq!(sched[0].amount, Decimal::new(21290, 2));
// Lifetime total is exactly 300.00.
let total: Decimal = sched.iter().map(|l| l.amount).sum();
assert_eq!(total, Decimal::new(30000, 2));
}
@@ -0,0 +1,368 @@
[
{
"accumulated": "548.39",
"amount": "548.39",
"book_value": "59451.61",
"period": "2026-01"
},
{
"accumulated": "1548.39",
"amount": "1000.00",
"book_value": "58451.61",
"period": "2026-02"
},
{
"accumulated": "2548.39",
"amount": "1000.00",
"book_value": "57451.61",
"period": "2026-03"
},
{
"accumulated": "3548.39",
"amount": "1000.00",
"book_value": "56451.61",
"period": "2026-04"
},
{
"accumulated": "4548.39",
"amount": "1000.00",
"book_value": "55451.61",
"period": "2026-05"
},
{
"accumulated": "5548.39",
"amount": "1000.00",
"book_value": "54451.61",
"period": "2026-06"
},
{
"accumulated": "6548.39",
"amount": "1000.00",
"book_value": "53451.61",
"period": "2026-07"
},
{
"accumulated": "7548.39",
"amount": "1000.00",
"book_value": "52451.61",
"period": "2026-08"
},
{
"accumulated": "8548.39",
"amount": "1000.00",
"book_value": "51451.61",
"period": "2026-09"
},
{
"accumulated": "9548.39",
"amount": "1000.00",
"book_value": "50451.61",
"period": "2026-10"
},
{
"accumulated": "10548.39",
"amount": "1000.00",
"book_value": "49451.61",
"period": "2026-11"
},
{
"accumulated": "11548.39",
"amount": "1000.00",
"book_value": "48451.61",
"period": "2026-12"
},
{
"accumulated": "12548.39",
"amount": "1000.00",
"book_value": "47451.61",
"period": "2027-01"
},
{
"accumulated": "13548.39",
"amount": "1000.00",
"book_value": "46451.61",
"period": "2027-02"
},
{
"accumulated": "14548.39",
"amount": "1000.00",
"book_value": "45451.61",
"period": "2027-03"
},
{
"accumulated": "15548.39",
"amount": "1000.00",
"book_value": "44451.61",
"period": "2027-04"
},
{
"accumulated": "16548.39",
"amount": "1000.00",
"book_value": "43451.61",
"period": "2027-05"
},
{
"accumulated": "17548.39",
"amount": "1000.00",
"book_value": "42451.61",
"period": "2027-06"
},
{
"accumulated": "18548.39",
"amount": "1000.00",
"book_value": "41451.61",
"period": "2027-07"
},
{
"accumulated": "19548.39",
"amount": "1000.00",
"book_value": "40451.61",
"period": "2027-08"
},
{
"accumulated": "20548.39",
"amount": "1000.00",
"book_value": "39451.61",
"period": "2027-09"
},
{
"accumulated": "21548.39",
"amount": "1000.00",
"book_value": "38451.61",
"period": "2027-10"
},
{
"accumulated": "22548.39",
"amount": "1000.00",
"book_value": "37451.61",
"period": "2027-11"
},
{
"accumulated": "23548.39",
"amount": "1000.00",
"book_value": "36451.61",
"period": "2027-12"
},
{
"accumulated": "24548.39",
"amount": "1000.00",
"book_value": "35451.61",
"period": "2028-01"
},
{
"accumulated": "25548.39",
"amount": "1000.00",
"book_value": "34451.61",
"period": "2028-02"
},
{
"accumulated": "26548.39",
"amount": "1000.00",
"book_value": "33451.61",
"period": "2028-03"
},
{
"accumulated": "27548.39",
"amount": "1000.00",
"book_value": "32451.61",
"period": "2028-04"
},
{
"accumulated": "28548.39",
"amount": "1000.00",
"book_value": "31451.61",
"period": "2028-05"
},
{
"accumulated": "29548.39",
"amount": "1000.00",
"book_value": "30451.61",
"period": "2028-06"
},
{
"accumulated": "30548.39",
"amount": "1000.00",
"book_value": "29451.61",
"period": "2028-07"
},
{
"accumulated": "31548.39",
"amount": "1000.00",
"book_value": "28451.61",
"period": "2028-08"
},
{
"accumulated": "32548.39",
"amount": "1000.00",
"book_value": "27451.61",
"period": "2028-09"
},
{
"accumulated": "33548.39",
"amount": "1000.00",
"book_value": "26451.61",
"period": "2028-10"
},
{
"accumulated": "34548.39",
"amount": "1000.00",
"book_value": "25451.61",
"period": "2028-11"
},
{
"accumulated": "35548.39",
"amount": "1000.00",
"book_value": "24451.61",
"period": "2028-12"
},
{
"accumulated": "36548.39",
"amount": "1000.00",
"book_value": "23451.61",
"period": "2029-01"
},
{
"accumulated": "37548.39",
"amount": "1000.00",
"book_value": "22451.61",
"period": "2029-02"
},
{
"accumulated": "38548.39",
"amount": "1000.00",
"book_value": "21451.61",
"period": "2029-03"
},
{
"accumulated": "39548.39",
"amount": "1000.00",
"book_value": "20451.61",
"period": "2029-04"
},
{
"accumulated": "40548.39",
"amount": "1000.00",
"book_value": "19451.61",
"period": "2029-05"
},
{
"accumulated": "41548.39",
"amount": "1000.00",
"book_value": "18451.61",
"period": "2029-06"
},
{
"accumulated": "42548.39",
"amount": "1000.00",
"book_value": "17451.61",
"period": "2029-07"
},
{
"accumulated": "43548.39",
"amount": "1000.00",
"book_value": "16451.61",
"period": "2029-08"
},
{
"accumulated": "44548.39",
"amount": "1000.00",
"book_value": "15451.61",
"period": "2029-09"
},
{
"accumulated": "45548.39",
"amount": "1000.00",
"book_value": "14451.61",
"period": "2029-10"
},
{
"accumulated": "46548.39",
"amount": "1000.00",
"book_value": "13451.61",
"period": "2029-11"
},
{
"accumulated": "47548.39",
"amount": "1000.00",
"book_value": "12451.61",
"period": "2029-12"
},
{
"accumulated": "48548.39",
"amount": "1000.00",
"book_value": "11451.61",
"period": "2030-01"
},
{
"accumulated": "49548.39",
"amount": "1000.00",
"book_value": "10451.61",
"period": "2030-02"
},
{
"accumulated": "50548.39",
"amount": "1000.00",
"book_value": "9451.61",
"period": "2030-03"
},
{
"accumulated": "51548.39",
"amount": "1000.00",
"book_value": "8451.61",
"period": "2030-04"
},
{
"accumulated": "52548.39",
"amount": "1000.00",
"book_value": "7451.61",
"period": "2030-05"
},
{
"accumulated": "53548.39",
"amount": "1000.00",
"book_value": "6451.61",
"period": "2030-06"
},
{
"accumulated": "54548.39",
"amount": "1000.00",
"book_value": "5451.61",
"period": "2030-07"
},
{
"accumulated": "55548.39",
"amount": "1000.00",
"book_value": "4451.61",
"period": "2030-08"
},
{
"accumulated": "56548.39",
"amount": "1000.00",
"book_value": "3451.61",
"period": "2030-09"
},
{
"accumulated": "57548.39",
"amount": "1000.00",
"book_value": "2451.61",
"period": "2030-10"
},
{
"accumulated": "58548.39",
"amount": "1000.00",
"book_value": "1451.61",
"period": "2030-11"
},
{
"accumulated": "59548.39",
"amount": "1000.00",
"book_value": "451.61",
"period": "2030-12"
},
{
"accumulated": "60000",
"amount": "451.61",
"book_value": "0",
"period": "2031-01"
}
]
+304 -33
View File
@@ -11,7 +11,9 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance}; use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
use ledger_core::{AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side}; use ledger_core::{
AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side,
};
use rust_decimal::Decimal; use rust_decimal::Decimal;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -49,15 +51,29 @@ struct Company {
fn fixture_company() -> Company { fn fixture_company() -> Company {
let mut ledger = Ledger::new(Currency::USD); let mut ledger = Ledger::new(Currency::USD);
let cash = ledger.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap(); let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let ar = ledger let ar = ledger
.add_account("1200", "Accounts Receivable", AccountType::Asset, None, false) .add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap(); .unwrap();
let equipment = ledger let equipment = ledger
.add_account("1500", "Equipment", AccountType::Asset, None, false) .add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap(); .unwrap();
let ap = ledger let ap = ledger
.add_account("2000", "Accounts Payable", AccountType::Liability, None, false) .add_account(
"2000",
"Accounts Payable",
AccountType::Liability,
None,
false,
)
.unwrap(); .unwrap();
let equity = ledger let equity = ledger
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false) .add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
@@ -70,32 +86,51 @@ fn fixture_company() -> Company {
.unwrap(); .unwrap();
// Owner invests $10,000 cash. // Owner invests $10,000 cash.
post(&mut ledger, date(2), "Owner investment", &[ post(
(cash, Side::Debit, 10_000), &mut ledger,
(equity, Side::Credit, 10_000), date(2),
]); "Owner investment",
&[(cash, Side::Debit, 10_000), (equity, Side::Credit, 10_000)],
);
// Buy equipment for $2,500 cash. // Buy equipment for $2,500 cash.
post(&mut ledger, date(5), "Buy equipment", &[ post(
(equipment, Side::Debit, 2_500), &mut ledger,
(cash, Side::Credit, 2_500), date(5),
]); "Buy equipment",
&[(equipment, Side::Debit, 2_500), (cash, Side::Credit, 2_500)],
);
// Bill a client $4,000. // Bill a client $4,000.
post(&mut ledger, date(10), "Invoice client", &[ post(
(ar, Side::Debit, 4_000), &mut ledger,
(revenue, Side::Credit, 4_000), date(10),
]); "Invoice client",
&[(ar, Side::Debit, 4_000), (revenue, Side::Credit, 4_000)],
);
// Pay January rent, $800. // Pay January rent, $800.
post(&mut ledger, date(15), "January rent", &[ post(
(rent, Side::Debit, 800), &mut ledger,
(cash, Side::Credit, 800), date(15),
]); "January rent",
&[(rent, Side::Debit, 800), (cash, Side::Credit, 800)],
);
// Collect $1,500 of receivables. // Collect $1,500 of receivables.
post(&mut ledger, date(20), "Client payment", &[ post(
(cash, Side::Debit, 1_500), &mut ledger,
(ar, Side::Credit, 1_500), date(20),
]); "Client payment",
&[(cash, Side::Debit, 1_500), (ar, Side::Credit, 1_500)],
);
Company { ledger, cash, ar, equipment, _ap: ap, equity, revenue, rent } Company {
ledger,
cash,
ar,
equipment,
_ap: ap,
equity,
revenue,
rent,
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -108,7 +143,9 @@ fn unbalanced_entry_is_rejected() {
let mut ledger = c.ledger; let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Bad entry"); let id = ledger.create_entry(date(21), "Bad entry");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(99)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(99))
.unwrap();
let err = ledger.post_entry(id).unwrap_err(); let err = ledger.post_entry(id).unwrap_err();
assert_eq!( assert_eq!(
err, err,
@@ -135,7 +172,9 @@ fn zero_amount_line_is_rejected() {
let id = ledger.create_entry(date(21), "Zero line"); let id = ledger.create_entry(date(21), "Zero line");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, c.cash, Side::Debit, usd(0)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(0)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
assert_eq!( assert_eq!(
ledger.post_entry(id).unwrap_err(), ledger.post_entry(id).unwrap_err(),
LedgerError::ZeroAmountLine LedgerError::ZeroAmountLine
@@ -148,7 +187,9 @@ fn unknown_account_is_rejected() {
let mut ledger = c.ledger; let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Ghost account"); let id = ledger.create_entry(date(21), "Ghost account");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, 999_999, Side::Credit, usd(100)).unwrap(); ledger
.add_line(id, 999_999, Side::Credit, usd(100))
.unwrap();
assert_eq!( assert_eq!(
ledger.post_entry(id).unwrap_err(), ledger.post_entry(id).unwrap_err(),
LedgerError::UnknownAccount(999_999) LedgerError::UnknownAccount(999_999)
@@ -164,7 +205,9 @@ fn placeholder_account_is_rejected() {
.unwrap(); .unwrap();
let id = ledger.create_entry(date(21), "Post to header"); let id = ledger.create_entry(date(21), "Post to header");
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
assert_eq!( assert_eq!(
ledger.post_entry(id).unwrap_err(), ledger.post_entry(id).unwrap_err(),
LedgerError::PlaceholderAccount("100".to_string()) LedgerError::PlaceholderAccount("100".to_string())
@@ -207,12 +250,16 @@ fn posted_entry_is_immutable() {
let mut ledger = c.ledger; let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "To post"); let id = ledger.create_entry(date(21), "To post");
ledger.add_line(id, c.cash, Side::Debit, usd(50)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(50)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(50)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(50))
.unwrap();
ledger.post_entry(id).unwrap(); ledger.post_entry(id).unwrap();
// Cannot add lines after posting. // Cannot add lines after posting.
assert_eq!( assert_eq!(
ledger.add_line(id, c.cash, Side::Debit, usd(1)).unwrap_err(), ledger
.add_line(id, c.cash, Side::Debit, usd(1))
.unwrap_err(),
LedgerError::PostedEntryImmutable LedgerError::PostedEntryImmutable
); );
// Cannot post twice. // Cannot post twice.
@@ -233,7 +280,9 @@ fn reversal_nets_to_zero_and_links_back() {
ledger.post_entry(id).unwrap(); ledger.post_entry(id).unwrap();
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300)); assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
let rev = ledger.reverse_entry(id, date(22), "Reverse mistake").unwrap(); let rev = ledger
.reverse_entry(id, date(22), "Reverse mistake")
.unwrap();
let rev_entry = ledger.get_entry(rev).unwrap(); let rev_entry = ledger.get_entry(rev).unwrap();
assert_eq!(rev_entry.reversal_of(), Some(id)); assert_eq!(rev_entry.reversal_of(), Some(id));
assert_eq!(rev_entry.status(), EntryStatus::Posted); assert_eq!(rev_entry.status(), EntryStatus::Posted);
@@ -319,7 +368,15 @@ fn random_balanced_journals_preserve_invariants() {
let mut rng = Lcg(0x1234_5678_9abc_def0); let mut rng = Lcg(0x1234_5678_9abc_def0);
let c = fixture_company(); let c = fixture_company();
let mut ledger = c.ledger; let mut ledger = c.ledger;
let accounts = [c.cash, c.ar, c.equipment, c._ap, c.equity, c.revenue, c.rent]; let accounts = [
c.cash,
c.ar,
c.equipment,
c._ap,
c.equity,
c.revenue,
c.rent,
];
for i in 0..300u32 { for i in 0..300u32 {
let total = rng.next(100_000) + 100; let total = rng.next(100_000) + 100;
@@ -362,3 +419,217 @@ fn golden_fixture_company_reports() {
serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap(); serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap();
assert_eq!(snap, golden); assert_eq!(snap, golden);
} }
#[test]
fn parent_account_validation() {
let mut ledger = Ledger::new(Currency::USD);
// Unknown parent is rejected.
let err = ledger
.add_account("1100", "Child", AccountType::Asset, Some(999), false)
.unwrap_err();
assert_eq!(err, LedgerError::UnknownParentAccount(999));
// A real parent works, and the child records it.
let parent = ledger
.add_account("1000", "Parent", AccountType::Asset, None, true)
.unwrap();
let child = ledger
.add_account("1100", "Child", AccountType::Asset, Some(parent), false)
.unwrap();
assert_eq!(ledger.chart().get(child).unwrap().parent, Some(parent));
// Chart accessors report honestly.
assert!(Ledger::new(Currency::USD).chart().is_empty());
assert!(!ledger.chart().is_empty());
assert_eq!(ledger.chart().len(), 2);
assert!(ledger.chart().find_by_code("1000").is_some());
assert!(ledger.chart().find_by_code("nope").is_none());
}
#[test]
fn account_type_balance_sheet_predicate() {
assert!(AccountType::Asset.is_balance_sheet());
assert!(AccountType::Liability.is_balance_sheet());
assert!(AccountType::Equity.is_balance_sheet());
assert!(!AccountType::Revenue.is_balance_sheet());
assert!(!AccountType::Expense.is_balance_sheet());
}
#[test]
fn entry_movements_sum_to_zero() {
let mut c = fixture_company();
let cash = c.cash;
let revenue = c.revenue;
let id = c.ledger.create_entry(date(15), "sale");
c.ledger.add_line(id, cash, Side::Debit, usd(1000)).unwrap();
c.ledger
.add_line(id, revenue, Side::Credit, usd(1000))
.unwrap();
c.ledger.post_entry(id).unwrap();
let entry = c.ledger.get_entry(id).unwrap();
let m = entry.movements();
assert_eq!(m[&cash], Decimal::new(100000, 2));
assert_eq!(m[&revenue], Decimal::new(-100000, 2));
let total: Decimal = m.values().copied().sum();
assert_eq!(total, Decimal::ZERO);
}
// ---------------------------------------------------------------------------
// Cash flow statement (indirect method).
// ---------------------------------------------------------------------------
use ledger_core::NewAsset;
use ledger_core::reports::{CashFlowParams, cash_flow};
fn cash_flow_params(cash: AccountId) -> CashFlowParams {
CashFlowParams {
start: date(1),
end: date(31),
cash_accounts: vec![cash],
financing_accounts: vec![],
}
}
#[test]
fn cash_flow_reconciles_to_change_in_cash() {
let c = fixture_company();
let cf = cash_flow(&c.ledger, &cash_flow_params(c.cash)).unwrap();
// The three sections must sum to the net change in cash. This is the
// accounting law the statement exists to uphold.
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
assert_eq!(sections, cf.net_change_in_cash);
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
// Hand-computed from the fixture:
// Cash: +10,000 (invest) - 2,500 (equipment) - 800 (rent) + 1,500 (collect)
// = 8,200. Beginning 0.
assert_eq!(cf.cash_beginning, Decimal::ZERO);
assert_eq!(cf.cash_ending, Decimal::new(820000, 2));
assert_eq!(cf.net_change_in_cash, Decimal::new(820000, 2));
// Net income: 4,000 revenue - 800 rent = 3,200.
assert_eq!(cf.net_income, Decimal::new(320000, 2));
// No depreciation: the fixture's equipment is not a registered asset.
assert_eq!(cf.depreciation, Decimal::ZERO);
// Operating: 3,200 net income
// - 2,500 increase in AR (4,000 billed - 1,500 collected)
// - 2,500 equipment purchase (not a registered asset: working capital)
// + 10,000 owner investment is equity -> financing, not operating.
// Financing: +10,000 owner contribution.
// => operating = 3,200 - 2,500 - 2,500 = -1,800
// => investing = 0, financing = 10,000. Sum = 8,200. Reconciles.
assert_eq!(cf.cash_from_operations, Decimal::new(-180000, 2));
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
assert_eq!(cf.cash_from_financing, Decimal::new(1000000, 2));
}
#[test]
fn cash_flow_with_registered_asset_and_disposal() {
let mut ledger = Ledger::new(Currency::USD);
let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let cost = ledger
.add_account("1500", "Equipment at cost", AccountType::Asset, None, false)
.unwrap();
let accum = ledger
.add_account(
"1501",
"Accum. depr. — equip.",
AccountType::Asset,
None,
false,
)
.unwrap();
let depr_exp = ledger
.add_account(
"6000",
"Depreciation expense",
AccountType::Expense,
None,
false,
)
.unwrap();
ledger
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
.unwrap();
let gain = ledger
.add_account("4100", "Gain on sale", AccountType::Revenue, None, false)
.unwrap();
let equity = ledger
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
.unwrap();
// Owner invests $100,000.
post(
&mut ledger,
date(2),
"Owner investment",
&[
(cash, Side::Debit, 100_000),
(equity, Side::Credit, 100_000),
],
);
// Register a $60,000 van, 60-month life, bought Jan 15.
let asset = ledger
.register_asset(NewAsset {
description: "Van".to_string(),
purchase_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
cost: usd(60_000),
salvage_value: usd(0),
useful_life_months: 60,
cost_account: cost,
accum_depr_account: accum,
expense_account: depr_exp,
})
.unwrap();
// Buy the van for cash.
post(
&mut ledger,
date(15),
"Buy van",
&[(cost, Side::Debit, 60_000), (cash, Side::Credit, 60_000)],
);
// January depreciation: $1,000 * 17/31 = $548.39.
post(
&mut ledger,
date(31),
"January depreciation",
&[(depr_exp, Side::Debit, 548), (accum, Side::Credit, 548)],
);
// Sell the van Jan 31 for $59,600 cash. Book value 60,000 - 548 = 59,452.
// Gain = 59,600 - 59,452 = 148.
// Note: amounts are whole dollars here to keep the fixture simple.
let _ = asset;
post(
&mut ledger,
date(31),
"Sell van",
&[
(cash, Side::Debit, 59_600),
(accum, Side::Debit, 548),
(cost, Side::Credit, 60_000),
(gain, Side::Credit, 148),
],
);
let cf = cash_flow(&ledger, &cash_flow_params(cash)).unwrap();
// Reconciliation is the law.
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
assert_eq!(sections, cf.net_change_in_cash);
// Cash: 100,000 - 60,000 + 59,600 = 99,600.
assert_eq!(cf.cash_ending, Decimal::new(9960000, 2));
// Net income: 148 gain - 548 depreciation = -400.
assert_eq!(cf.net_income, Decimal::new(-40000, 2));
// Depreciation added back: 548.
assert_eq!(cf.depreciation, Decimal::new(54800, 2));
// Operating: -400 + 548 - 148 (gain removed) = 0.
assert_eq!(cf.cash_from_operations, Decimal::ZERO);
// Investing: -60,000 purchase + 59,600 proceeds = -400.
assert_eq!(cf.cash_from_investing, Decimal::new(-40000, 2));
// Financing: +100,000 owner investment.
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
}
+321
View File
@@ -0,0 +1,321 @@
//! Property tests for the ledger engine: invariants that must hold for
//! *every* input, not just the hand-picked examples in `invariants.rs`.
//!
//! These are the properties mutation testing will try to break: if a
//! mutant survives the example tests but breaks a property, the property
//! catches it.
use chrono::NaiveDate;
use ledger_core::{
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
depreciation_schedule,
reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
trial_balance,
},
};
use proptest::prelude::*;
use rust_decimal::Decimal;
fn arb_account_type() -> impl Strategy<Value = AccountType> {
prop_oneof![
Just(AccountType::Asset),
Just(AccountType::Liability),
Just(AccountType::Equity),
Just(AccountType::Revenue),
Just(AccountType::Expense),
]
}
/// A random chart: 2..=6 accounts with unique codes, no placeholders.
fn arb_chart() -> impl Strategy<Value = Vec<(String, String, AccountType)>> {
prop::collection::vec((1u32..9999u32, arb_account_type()), 2..=6).prop_map(|v| {
v.into_iter()
.enumerate()
.map(|(i, (_, t))| (format!("{:04}", 1000 + i), format!("Account {i}"), t))
.collect()
})
}
#[derive(Clone, Debug)]
struct GenEntry {
lines: Vec<(u64, Side, Decimal)>,
date: NaiveDate,
}
/// A random *balanced* entry over `num_accounts` (1-based ids): 2..=4
/// random lines plus a plug line for the imbalance. Amounts are whole
/// cents, so the plug is always exact.
fn arb_entry(num_accounts: usize) -> impl Strategy<Value = GenEntry> {
(
prop::collection::vec(
(
1u64..=(num_accounts as u64),
prop_oneof![Just(Side::Debit), Just(Side::Credit)],
1u32..1_000_000u32,
),
2..=4,
),
1u32..=12u32,
1u32..=28u32,
)
.prop_map(|(raw, month, day)| {
let mut lines: Vec<(u64, Side, Decimal)> = raw
.into_iter()
.map(|(a, s, cents)| (a, s, Decimal::new(cents as i64, 2)))
.collect();
let net: Decimal = lines
.iter()
.map(|(_, s, amt)| match s {
Side::Debit => *amt,
Side::Credit => -*amt,
})
.sum();
if net != Decimal::ZERO {
let (side, amt) = if net > Decimal::ZERO {
(Side::Credit, net)
} else {
(Side::Debit, -net)
};
lines.push((1, side, amt));
}
GenEntry {
lines,
date: NaiveDate::from_ymd_opt(2026, month, day).unwrap(),
}
})
}
fn arb_ops() -> impl Strategy<Value = (Vec<(String, String, AccountType)>, Vec<GenEntry>)> {
arb_chart().prop_flat_map(|chart| {
let n = chart.len();
(Just(chart), prop::collection::vec(arb_entry(n), 0..=10))
})
}
fn build_ledger(
chart: &[(String, String, AccountType)],
entries: &[GenEntry],
) -> (Ledger, Vec<u64>) {
let mut ledger = Ledger::new(Currency::USD);
for (code, name, typ) in chart {
ledger.add_account(code, name, *typ, None, false).unwrap();
}
let mut ids = Vec::new();
for e in entries {
let id = ledger.create_entry(e.date, "prop");
for (a, s, amt) in &e.lines {
ledger
.add_line(id, *a, *s, Money::new(*amt, Currency::USD))
.unwrap();
}
ledger.post_entry(id).unwrap();
ids.push(id);
}
(ledger, ids)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// The trial balance always balances, no matter the operations.
#[test]
fn prop_trial_balance_balances((chart, entries) in arb_ops()) {
let (ledger, _) = build_ledger(&chart, &entries);
let tb = trial_balance(&ledger);
prop_assert_eq!(tb.total_debits, tb.total_credits);
let debit_sum: Decimal = tb.lines.iter().map(|l| l.debit).sum();
let credit_sum: Decimal = tb.lines.iter().map(|l| l.credit).sum();
prop_assert_eq!(debit_sum, tb.total_debits);
prop_assert_eq!(credit_sum, tb.total_credits);
}
/// The accounting equation holds for every ledger state:
/// assets == liabilities + equity (with current earnings folded in).
#[test]
fn prop_balance_sheet_balances((chart, entries) in arb_ops()) {
let (ledger, _) = build_ledger(&chart, &entries);
let bs = balance_sheet(&ledger).unwrap();
prop_assert_eq!(bs.assets, bs.liabilities + bs.equity);
}
/// Period P&Ls partition the year: split anywhere, the two halves sum
/// to the whole, and the whole is the life-of-ledger P&L.
#[test]
fn prop_period_pnl_partitions((chart, entries) in arb_ops(), split in 0u32..365) {
let (ledger, _) = build_ledger(&chart, &entries);
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let dec31 = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
let cut = jan1 + chrono::Days::new(split as u64);
let first = profit_and_loss_for_period(&ledger, jan1, cut, None).unwrap();
let rest = if cut < dec31 {
Some(profit_and_loss_for_period(&ledger, cut.succ_opt().unwrap(), dec31, None).unwrap())
} else {
None
};
let whole = profit_and_loss_for_period(&ledger, jan1, dec31, None).unwrap();
let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses));
prop_assert_eq!(first.total_revenue + r2, whole.total_revenue);
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
let all = profit_and_loss(&ledger).unwrap();
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
}
/// The accounting equation holds as of every date, and a period's net
/// income is exactly the change in current earnings across it.
#[test]
fn prop_balance_sheet_as_of_balances((chart, entries) in arb_ops(), a in 0u32..365, b in 0u32..365) {
let (ledger, _) = build_ledger(&chart, &entries);
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let (lo, hi) = (a.min(b), a.max(b));
let start = jan1 + chrono::Days::new(lo as u64);
let end = jan1 + chrono::Days::new(hi as u64);
let before = balance_sheet_as_of(&ledger, start.pred_opt().unwrap()).unwrap();
let after = balance_sheet_as_of(&ledger, end).unwrap();
prop_assert!(before.balanced());
prop_assert!(after.balanced());
let pnl = profit_and_loss_for_period(&ledger, start, end, None).unwrap();
prop_assert_eq!(after.current_earnings - before.current_earnings, pnl.net_income());
// Past the last entry, as-of agrees with the life-of-ledger sheet.
let all = balance_sheet(&ledger).unwrap();
let last = balance_sheet_as_of(&ledger, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()).unwrap();
prop_assert_eq!(
(last.total_assets, last.total_liabilities, last.total_equity),
(all.assets, all.liabilities, all.equity)
);
}
/// Every posted entry balances on its own, exactly.
#[test]
fn prop_posted_entries_balance((chart, entries) in arb_ops()) {
let (ledger, ids) = build_ledger(&chart, &entries);
for id in ids {
let e = ledger.get_entry(id).unwrap();
let debits: Decimal = e.lines().iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount).sum();
let credits: Decimal = e.lines().iter()
.filter(|l| l.side == Side::Credit)
.map(|l| l.amount.amount).sum();
prop_assert_eq!(debits, credits);
}
}
/// Reversing every entry returns the trial balance to zero:
/// reversals are exact inverses.
#[test]
fn prop_reversals_restore_zero((chart, entries) in arb_ops()) {
let (mut ledger, ids) = build_ledger(&chart, &entries);
for id in &ids {
ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "reversal").unwrap();
}
let tb = trial_balance(&ledger);
prop_assert_eq!(tb.total_debits, Decimal::ZERO);
prop_assert_eq!(tb.total_credits, Decimal::ZERO);
}
/// Posted entries are immutable: serializing them before and after
/// further operations yields identical bytes.
#[test]
fn prop_posted_entries_immutable((chart, entries) in arb_ops(), extra in prop::collection::vec(arb_entry(2), 0..=5)) {
prop_assume!(chart.len() >= 2);
let (mut ledger, ids) = build_ledger(&chart, &entries);
let before: Vec<String> = ids.iter().map(|id| {
serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap()
}).collect();
// More operations: entries sized for a 2-account chart would break
// on bigger charts, so rebuild them against this chart.
let n = chart.len();
let extra_entries: Vec<GenEntry> = extra.into_iter().map(|mut e| {
for (a, _, _) in e.lines.iter_mut() {
*a = (*a % n as u64) + 1;
}
// Rebalance after remapping accounts (net may have changed).
let net: Decimal = e.lines.iter().map(|(_, s, amt)| match s {
Side::Debit => *amt, Side::Credit => -*amt,
}).sum();
if net != Decimal::ZERO {
let (side, amt) = if net > Decimal::ZERO { (Side::Credit, net) } else { (Side::Debit, -net) };
e.lines.push((1, side, amt));
}
e
}).collect();
for e in &extra_entries {
let id = ledger.create_entry(e.date, "extra");
for (a, s, amt) in &e.lines {
ledger.add_line(id, *a, *s, Money::new(*amt, Currency::USD)).unwrap();
}
ledger.post_entry(id).unwrap();
}
for id in &ids {
ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "rev").unwrap();
}
let after: Vec<String> = ids.iter().map(|id| {
serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap()
}).collect();
prop_assert_eq!(before, after);
}
/// A journal entry survives a JSON round-trip unchanged.
#[test]
fn prop_entry_json_roundtrip((chart, entries) in arb_ops()) {
let (ledger, ids) = build_ledger(&chart, &entries);
for id in ids {
let e = ledger.get_entry(id).unwrap();
let v = serde_json::to_value(e).unwrap();
let s = serde_json::to_string(&v).unwrap();
let v2: serde_json::Value = serde_json::from_str(&s).unwrap();
prop_assert_eq!(v, v2);
}
}
/// Depreciation schedules are exact: the lifetime total is precisely
/// cost minus salvage, book value plus accumulated always equals cost,
/// and amounts are never negative.
#[test]
fn prop_depreciation_schedule_exact(
cost_cents in 100u64..10_000_000u64,
salvage_cents in 0u64..5_000_000u64,
life in 1u32..360u32,
y in 2000i32..2027i32,
m in 1u32..=12u32,
d in 1u32..=28u32,
) {
let cost_cents = cost_cents.max(salvage_cents + 1);
let cost = Decimal::new(cost_cents as i64, 2);
let salvage = Decimal::new(salvage_cents as i64, 2);
let mut ledger = Ledger::new(Currency::USD);
let ca = ledger.add_account("1500", "Equipment", AccountType::Asset, None, false).unwrap();
let aa = ledger.add_account("1501", "Accum", AccountType::Asset, None, false).unwrap();
let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap();
let id = ledger.register_asset(NewAsset {
description: "prop".to_string(),
purchase_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(),
cost: Money::new(cost, Currency::USD),
salvage_value: Money::new(salvage, Currency::USD),
useful_life_months: life,
cost_account: ca,
accum_depr_account: aa,
expense_account: ex,
}).unwrap();
let asset = ledger.get_asset(id).unwrap();
let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
let sched = depreciation_schedule(asset, far);
// The lifetime total is exactly cost minus salvage: never a penny off.
let total: Decimal = sched.iter().map(|l| l.amount).sum();
prop_assert_eq!(total, cost - salvage);
for l in &sched {
prop_assert!(l.amount >= Decimal::ZERO);
prop_assert_eq!(l.book_value + l.accumulated, cost);
}
// Accumulated is monotone non-decreasing.
for w in sched.windows(2) {
prop_assert!(w[1].accumulated >= w[0].accumulated);
}
// Book value at any date matches the schedule.
if let Some(last) = sched.last() {
prop_assert_eq!(book_value(asset, far), last.book_value);
prop_assert_eq!(accumulated_depreciation(asset, far), last.accumulated);
}
}
}
+667
View File
@@ -0,0 +1,667 @@
//! Period-scoped financial statements: profit and loss for a date range and
//! the balance sheet as of a date, line per account.
//!
//! Every expected number below is computed by hand from the entries, not
//! read off the engine. The fixture is two months of a small company:
//!
//! | date | entry | Dr | Cr |
//! |--------|-------------------------------|---------------|---------------|
//! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital |
//! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue |
//! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash |
//! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card |
//! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue |
//! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash |
use chrono::NaiveDate;
use ledger_core::reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
};
use ledger_core::{
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
Money, Side, StatementLine, cash_flow, general_ledger, members_equity, trial_balance_as_of,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn usd(n: i64) -> Money {
Money::new(Decimal::from(n), Currency::USD)
}
fn dec(n: i64) -> Decimal {
Decimal::from(n)
}
struct Books {
ledger: Ledger,
cash: AccountId,
software_class: ClassId,
}
fn post(ledger: &mut Ledger, date: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, n: i64) {
let e = ledger.create_entry(date, desc);
ledger.add_line(e, dr, Side::Debit, usd(n)).unwrap();
ledger.add_line(e, cr, Side::Credit, usd(n)).unwrap();
ledger.post_entry(e).unwrap();
}
/// The fixture. With `close_january`, January is closed before any February
/// entry exists (the close sweeps every revenue and expense balance).
fn books(close_january: bool) -> Books {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2000", "Card", AccountType::Liability, None, false)
.unwrap();
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let software = l
.add_account("6000", "Software", AccountType::Expense, None, false)
.unwrap();
let rent = l
.add_account("6100", "Rent", AccountType::Expense, None, false)
.unwrap();
let software_class = l.add_class("Apps").unwrap();
post(&mut l, d(1, 2), "Owner contribution", cash, capital, 10_000);
post(&mut l, d(1, 15), "Client payment", cash, revenue, 2_500);
post(&mut l, d(1, 20), "Rent", rent, cash, 800);
if close_january {
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.close_period(jan).unwrap();
}
// Software on the card, tagged to the Apps class.
let e = l.create_entry(d(2, 3), "Software");
l.add_line(e, software, Side::Debit, usd(120)).unwrap();
l.add_line(e, card, Side::Credit, usd(120)).unwrap();
l.set_line_class(e, 0, Some(software_class)).unwrap();
l.post_entry(e).unwrap();
post(&mut l, d(2, 10), "Client payment", cash, revenue, 1_000);
post(&mut l, d(2, 25), "Pay card", card, cash, 120);
Books {
ledger: l,
cash,
software_class,
}
}
fn line(code: &str, name: &str, n: i64) -> StatementLine {
StatementLine {
code: code.to_string(),
name: name.to_string(),
amount: dec(n),
}
}
#[test]
fn profit_and_loss_is_scoped_to_the_period() {
let b = books(false);
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
assert_eq!(jan.expenses, vec![line("6100", "Rent", 800)]);
assert_eq!(jan.net_income(), dec(1_700));
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.revenue, vec![line("4000", "Revenue", 1_000)]);
assert_eq!(feb.expenses, vec![line("6000", "Software", 120)]);
assert_eq!(feb.net_income(), dec(880));
// Both months: 3,500 revenue, 920 expenses, lines sorted by code.
let both = profit_and_loss_for_period(&b.ledger, d(1, 1), d(2, 28), None).unwrap();
assert_eq!(
both.expenses,
vec![line("6000", "Software", 120), line("6100", "Rent", 800)]
);
assert_eq!(
(both.total_revenue, both.total_expenses),
(dec(3_500), dec(920))
);
// ... which is the life-of-ledger P&L, because nothing is outside it.
let all = profit_and_loss(&b.ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
// One class: only the tagged software line.
let apps =
profit_and_loss_for_period(&b.ledger, d(1, 1), d(12, 31), Some(b.software_class)).unwrap();
assert!(apps.revenue.is_empty());
assert_eq!(apps.expenses, vec![line("6000", "Software", 120)]);
// A backwards range is rejected, not silently empty.
assert!(profit_and_loss_for_period(&b.ledger, d(2, 1), d(1, 1), None).is_err());
}
#[test]
fn balance_sheet_is_as_of_the_date() {
let b = books(false);
// Before anything happened: empty and balanced.
let start = balance_sheet_as_of(&b.ledger, d(1, 1)).unwrap();
assert!(start.assets.is_empty() && start.equity.is_empty());
assert!(start.balanced());
// Jan 31: cash 10,000 + 2,500 − 800 = 11,700; equity 10,000 + 1,700.
let jan = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
assert_eq!(jan.assets, vec![line("1000", "Cash", 11_700)]);
assert!(jan.liabilities.is_empty());
assert_eq!(jan.equity, vec![line("3000", "Capital", 10_000)]);
assert_eq!(jan.current_earnings, dec(1_700));
assert_eq!(jan.total_equity, dec(11_700));
assert!(jan.balanced());
// Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880.
let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap();
assert_eq!(mid.total_assets, dec(12_700));
assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]);
assert_eq!(mid.current_earnings, dec(2_580));
assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700));
// Feb 28: card paid off, so it drops off the statement.
let end = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert_eq!(end.total_assets, dec(12_580));
assert!(end.liabilities.is_empty());
assert!(end.balanced());
// ... and agrees with the life-of-ledger balance sheet.
let all = balance_sheet(&b.ledger).unwrap();
assert_eq!(
(all.assets, all.liabilities, all.equity),
(end.total_assets, end.total_liabilities, end.total_equity)
);
}
#[test]
fn a_closed_period_still_reports_its_earnings() {
let b = books(true);
// The close posted Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 on Jan
// 31. The January P&L must not count it: January earned 1,700.
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
assert_eq!(jan.net_income(), dec(1_700));
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.net_income(), dec(880));
// After the close, January's earnings sit in retained earnings, not in
// current earnings; February's are current.
let jan_bs = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
assert_eq!(
jan_bs.equity,
vec![
line("3000", "Capital", 10_000),
line("3999", "Retained Earnings", 1_700)
]
);
assert_eq!(jan_bs.current_earnings, Decimal::ZERO);
assert!(jan_bs.balanced());
let feb_bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert_eq!(feb_bs.current_earnings, dec(880));
assert_eq!(feb_bs.total_equity, dec(12_580));
assert!(feb_bs.balanced());
// The cash flow for January: net income 1,700, and the only financing
// flow is the owner's 10,000. The close is not an owner contribution.
let cf = cash_flow(
&b.ledger,
&CashFlowParams {
start: d(1, 1),
end: d(1, 31),
cash_accounts: vec![b.cash],
financing_accounts: vec![],
},
)
.unwrap();
assert_eq!(cf.net_income, dec(1_700));
assert_eq!(cf.financing.len(), 1);
assert_eq!(cf.cash_from_financing, dec(10_000));
assert_eq!(cf.net_change_in_cash, dec(11_700));
}
/// Cash flow lines come out in account-code order, every time. (The chart
/// iterates in hash order; the statement must not.)
#[test]
fn cash_flow_lines_are_in_code_order() {
for _ in 0..20 {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let mut owners = Vec::new();
for code in ["3005", "3001", "3009", "3000", "3003"] {
owners.push(
l.add_account(code, code, AccountType::Equity, None, false)
.unwrap(),
);
}
for o in &owners {
post(&mut l, d(3, 1), "Contribution", cash, *o, 100);
}
let cf = cash_flow(
&l,
&CashFlowParams {
start: d(1, 1),
end: d(12, 31),
cash_accounts: vec![cash],
financing_accounts: vec![],
},
)
.unwrap();
// Labels read "Owner contributions — 3000 3000" (code, then name).
let codes: Vec<&str> = cf
.financing
.iter()
.map(|f| f.label.rsplit(' ').next().unwrap())
.collect();
assert_eq!(
codes,
["3000", "3001", "3003", "3005", "3009"],
"{:?}",
cf.financing
);
}
}
fn cents(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
#[test]
fn trial_balance_is_as_of_the_date() {
let b = books(false);
// Jan 31: Dr cash 11,700 and rent 800; Cr capital 10,000 and revenue 2,500.
let tb = trial_balance_as_of(&b.ledger, d(1, 31));
let rows: Vec<(&str, Decimal, Decimal)> = tb
.lines
.iter()
.map(|l| (l.code.as_str(), l.debit, l.credit))
.collect();
assert_eq!(
rows,
[
("1000", dec(11_700), dec(0)),
("3000", dec(0), dec(10_000)),
("4000", dec(0), dec(2_500)),
("6100", dec(800), dec(0)),
]
);
assert_eq!(
(tb.total_debits, tb.total_credits),
(dec(12_500), dec(12_500))
);
}
#[test]
fn general_ledger_runs_balances_by_account() {
let b = books(false);
let gl = general_ledger(&b.ledger, d(2, 1), d(2, 28)).unwrap();
let codes: Vec<&str> = gl.accounts.iter().map(|a| a.code.as_str()).collect();
// Every account with a beginning balance or February activity, by code.
assert_eq!(codes, ["1000", "2000", "3000", "4000", "6000", "6100"]);
let cash = &gl.accounts[0];
assert_eq!(cash.beginning_balance, dec(11_700));
let p: Vec<(Decimal, Decimal, &str, Decimal)> = cash
.postings
.iter()
.map(|p| (p.debit, p.credit, p.split.as_str(), p.balance))
.collect();
assert_eq!(
p,
[
(dec(1_000), dec(0), "4000 Revenue", dec(12_700)),
(dec(0), dec(120), "2000 Card", dec(12_580)),
]
);
assert_eq!(cash.ending_balance, dec(12_580));
// The card: borrowed 120, paid 120, back to zero (credit-normal).
let card = &gl.accounts[1];
assert_eq!(card.beginning_balance, dec(0));
assert_eq!(card.postings[0].balance, dec(120));
assert_eq!(card.ending_balance, dec(0));
// Capital: no February activity, still listed with its balance.
assert!(gl.accounts[2].postings.is_empty());
assert_eq!(gl.accounts[2].ending_balance, dec(10_000));
// Every ending balance ties to the trial balance as of the end.
let tb = trial_balance_as_of(&b.ledger, d(2, 28));
for a in &gl.accounts {
let tb_line = tb.lines.iter().find(|l| l.code == a.code);
let tb_amount = tb_line.map_or(Decimal::ZERO, |l| l.debit - l.credit);
let normal = match a.account_type {
AccountType::Asset | AccountType::Expense => a.ending_balance,
_ => -a.ending_balance,
};
assert_eq!(tb_amount, normal, "account {}", a.code);
}
}
#[test]
fn general_ledger_marks_compound_entries_as_split() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2000", "Card", AccountType::Liability, None, false)
.unwrap();
let soft = l
.add_account("6000", "Software", AccountType::Expense, None, false)
.unwrap();
// Dr software 100 / Cr cash 60 / Cr card 40: one debit, split credits.
let e = l.create_entry(d(3, 1), "Annual license, paid two ways");
l.add_line(e, soft, Side::Debit, usd(100)).unwrap();
l.add_line(e, cash, Side::Credit, usd(60)).unwrap();
l.add_line(e, card, Side::Credit, usd(40)).unwrap();
l.post_entry(e).unwrap();
let gl = general_ledger(&l, d(1, 1), d(12, 31)).unwrap();
let split_of = |code: &str| {
let a = gl.accounts.iter().find(|a| a.code == code).unwrap();
(a.postings[0].split.clone(), a.postings[0].entry_id)
};
// The debit side has two other accounts; each credit has one.
assert_eq!(split_of("6000"), ("-Split-".to_string(), e));
assert_eq!(split_of("1000"), ("6000 Software".to_string(), e));
assert_eq!(split_of("2000"), ("6000 Software".to_string(), e));
}
/// Two members, 50/50: Alice and Bob. January: Alice puts in 600, Bob 400;
/// revenue 1,000; expense 333.33, so net income 666.67, which doesn't split
/// evenly. February: Bob takes a 100 distribution; revenue 100.01.
fn partnership(close_january: bool) -> (Ledger, Vec<MemberCapital>) {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let alice = l
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
.unwrap();
let bob = l
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
.unwrap();
let rev = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let exp = l
.add_account("6000", "Expense", AccountType::Expense, None, false)
.unwrap();
let money = |s: &str| Money::new(cents(s), Currency::USD);
let post = |l: &mut Ledger, date, dr, cr, amt: &str| {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, money(amt)).unwrap();
l.add_line(e, cr, Side::Credit, money(amt)).unwrap();
l.post_entry(e).unwrap();
};
post(&mut l, d(1, 2), cash, alice, "600");
post(&mut l, d(1, 2), cash, bob, "400");
post(&mut l, d(1, 10), cash, rev, "1000");
post(&mut l, d(1, 20), exp, cash, "333.33");
if close_january {
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.close_period(jan).unwrap();
}
post(&mut l, d(2, 5), bob, cash, "100");
post(&mut l, d(2, 9), cash, rev, "100.01");
let half = cents("0.5");
let members = vec![
MemberCapital {
name: "Alice".into(),
capital_account: alice,
share: half,
},
MemberCapital {
name: "Bob".into(),
capital_account: bob,
share: half,
},
];
(l, members)
}
#[test]
fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
for close in [false, true] {
let (l, members) = partnership(close);
// January: 666.67 splits 333.34 / 333.33 (half a cent rounds up for
// Alice; Bob gets the remainder so the total is exact).
let jan = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
let row = |c: &ledger_core::MemberEquityColumn| {
(
c.beginning,
c.contributions,
c.net_income,
c.distributions,
c.ending,
)
};
assert_eq!(
row(&jan.members[0]),
(
cents("0"),
cents("600"),
cents("333.34"),
cents("0"),
cents("933.34")
)
);
assert_eq!(
row(&jan.members[1]),
(
cents("0"),
cents("400"),
cents("333.33"),
cents("0"),
cents("733.33")
)
);
assert_eq!(jan.total.ending, cents("1666.67"));
assert_eq!(
jan.total.ending,
balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
);
// February begins where January ended, whether or not January was
// closed to retained earnings. 100.01 splits 50.01 / 50.00.
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(
row(&feb.members[0]),
(
cents("933.34"),
cents("0"),
cents("50.01"),
cents("0"),
cents("983.35")
)
);
assert_eq!(
row(&feb.members[1]),
(
cents("733.33"),
cents("0"),
cents("50.00"),
cents("100"),
cents("683.33")
)
);
assert_eq!(feb.total.ending, cents("1666.68"));
assert_eq!(
feb.total.ending,
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
);
assert!(
feb.members.iter().all(|m| m.other.is_zero()),
"close={close}"
);
}
}
#[test]
fn members_must_be_configured_correctly() {
let (l, mut members) = partnership(false);
members[1].share = cents("0.4");
assert!(matches!(
members_equity(&l, d(1, 1), d(1, 31), &members),
Err(LedgerError::InvalidMembers(_))
));
members[1].share = cents("0.5");
members[1].capital_account = members[0].capital_account;
assert!(matches!(
members_equity(&l, d(1, 1), d(1, 31), &members),
Err(LedgerError::InvalidMembers(_))
));
}
#[test]
fn cash_flow_lines_name_their_account() {
let (l, _) = partnership(false);
let cf = cash_flow(
&l,
&CashFlowParams {
start: d(1, 1),
end: d(12, 31),
cash_accounts: vec![1],
financing_accounts: vec![],
},
)
.unwrap();
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
assert_eq!(accounts, [Some("3000"), Some("3001")]);
}
#[test]
fn a_close_sweeps_only_through_the_period_end() {
// February is already posted when January closes: the close must take
// January's 2,500 revenue and 800 rent (net 1,700) and leave
// February's 1,000 revenue and 120 software open.
let mut b = books(false);
let jan = b
.ledger
.create_period("2026-01", d(1, 1), d(1, 31))
.unwrap();
let closing = b.ledger.close_period(jan).unwrap().unwrap();
let lines: Vec<(String, Side, Decimal)> = b
.ledger
.get_entry(closing)
.unwrap()
.lines()
.iter()
.map(|l| {
(
b.ledger.chart().get(l.account).unwrap().code.clone(),
l.side,
l.amount.amount,
)
})
.collect();
assert_eq!(
lines,
[
("4000".to_string(), Side::Debit, dec(2_500)),
("6100".to_string(), Side::Credit, dec(800)),
("3999".to_string(), Side::Credit, dec(1_700)),
]
);
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.net_income(), dec(880));
let bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert_eq!(bs.current_earnings, dec(880));
assert_eq!(
bs.equity.last().unwrap(),
&line("3999", "Retained Earnings", 1_700)
);
assert!(bs.balanced());
}
#[test]
fn periods_close_in_order() {
let mut b = books(false);
let jan = b
.ledger
.create_period("2026-01", d(1, 1), d(1, 31))
.unwrap();
let feb = b
.ledger
.create_period("2026-02", d(2, 1), d(2, 28))
.unwrap();
b.ledger.close_period(feb).unwrap();
// February swept everything through Feb 28, January included.
assert!(matches!(
b.ledger.close_period(jan),
Err(LedgerError::CloseOutOfOrder(j, f)) if j == jan && f == feb
));
}
#[test]
fn a_partnership_closes_into_members_capital() {
let (mut l, members) = partnership(false);
let to = ledger_core::CloseTo::Members(
members
.iter()
.map(|m| (m.capital_account, m.share))
.collect(),
);
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.close_period_to(jan, &to).unwrap();
// January earned 666.67: Alice's capital gets 333.34, Bob's 333.33,
// and there is no Retained Earnings account at all.
assert!(l.chart().find_by_code("3999").is_none());
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
let capital: Vec<Decimal> = bs.equity.iter().map(|e| e.amount).collect();
assert_eq!(capital, [cents("933.34"), cents("733.33")]);
assert_eq!(bs.current_earnings, Decimal::ZERO);
// The statement still reads: contributions are contributions (the
// close is income, not a contribution), and February starts where
// January ended.
let row = |c: &ledger_core::MemberEquityColumn| {
(
c.beginning,
c.contributions,
c.net_income,
c.distributions,
c.ending,
)
};
let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
assert_eq!(
row(&jan_me.members[0]),
(
cents("0"),
cents("600"),
cents("333.34"),
cents("0"),
cents("933.34")
)
);
assert_eq!(
row(&jan_me.members[1]),
(
cents("0"),
cents("400"),
cents("333.33"),
cents("0"),
cents("733.33")
)
);
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(feb_me.members[0].beginning, cents("933.34"));
assert_eq!(feb_me.members[1].ending, cents("683.33"));
assert_eq!(
feb_me.total.ending,
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
);
// Shares must still add up.
let feb = l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
let bad = ledger_core::CloseTo::Members(vec![(members[0].capital_account, cents("0.5"))]);
assert!(matches!(
l.close_period_to(feb, &bad),
Err(LedgerError::InvalidMembers(_))
));
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "ledger-import"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
[dependencies]
ledger-core = { path = "../ledger-core" }
csv = "1"
rust_decimal = "1"
chrono = "0.4"
serde = { version = "1", features = ["derive"] }
thiserror = "2"
rust_xlsxwriter = "0.90"
typst-as-lib = { version = "0.17", default-features = false }
typst-pdf = "0.15.1"
typst = "0.15.1"
serde_json = "1"
typst-layout = "0.15.1"
Binary file not shown.
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+167
View File
@@ -0,0 +1,167 @@
//! Parse QuickBooks Online bank/credit-card upload CSVs.
//!
//! QBO accepts two layouts:
//!
//! - 3 columns: `Date, Description, Amount` (positive = money in).
//! - 4 columns: `Date, Description, Expense, Income` (or `Debit, Credit`).
//!
//! Headers are optional; the layout is detected from the column count.
//! Dates accept `YYYY-MM-DD`, `MM/DD/YYYY`, and `MM/DD/YY`.
use chrono::NaiveDate;
use rust_decimal::Decimal;
use std::str::FromStr;
use thiserror::Error;
/// One parsed bank transaction.
#[derive(Clone, Debug, PartialEq)]
pub struct BankTransaction {
/// Transaction date.
pub date: NaiveDate,
/// Bank's description.
pub description: String,
/// Signed amount: positive = money in, negative = money out.
pub amount: Decimal,
}
/// Parse errors.
#[derive(Debug, Error, PartialEq)]
pub enum BankCsvError {
/// The CSV has neither 3 nor 4 columns.
#[error("expected 3 or 4 columns, found {0}")]
BadColumnCount(usize),
/// A date did not parse.
#[error("bad date {0:?}")]
BadDate(String),
/// An amount did not parse.
#[error("bad amount {0:?}")]
BadAmount(String),
/// The CSV itself is malformed.
#[error("csv error: {0}")]
Csv(String),
}
/// Parse a QBO bank CSV. Returns transactions in file order.
pub fn parse_bank_csv(text: &str) -> Result<Vec<BankTransaction>, BankCsvError> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.trim(csv::Trim::All)
.from_reader(text.as_bytes());
let mut out = Vec::new();
for (idx, record) in reader.records().enumerate() {
let record = record.map_err(|e| BankCsvError::Csv(e.to_string()))?;
let fields: Vec<&str> = record.iter().collect();
if fields.iter().all(|f| f.is_empty()) {
continue; // skip blank lines
}
// Skip a header row.
if idx == 0 && fields[0].eq_ignore_ascii_case("date") {
continue;
}
let (date, description, amount) = match fields.len() {
3 => {
let amount = parse_amount(fields[2])?;
(fields[0], fields[1], amount)
}
4 => {
// Expense/Debit in col 2, Income/Credit in col 3. Exactly one
// should be non-empty; a row with both is ambiguous.
let out_amt = fields[2].trim();
let in_amt = fields[3].trim();
let amount = match (!out_amt.is_empty(), !in_amt.is_empty()) {
(true, false) => -parse_amount(out_amt)?,
(false, true) => parse_amount(in_amt)?,
(false, false) => Decimal::ZERO,
(true, true) => {
return Err(BankCsvError::Csv(format!(
"row {} has both expense and income amounts",
idx + 1
)));
}
};
(fields[0], fields[1], amount)
}
n => return Err(BankCsvError::BadColumnCount(n)),
};
if amount.is_zero() {
continue; // zero-amount rows carry no money
}
out.push(BankTransaction {
date: parse_date(date)?,
description: description.to_string(),
amount,
});
}
Ok(out)
}
fn parse_date(s: &str) -> Result<NaiveDate, BankCsvError> {
let s = s.trim();
// YYYY-MM-DD
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Ok(d);
}
// MM/DD/YYYY
if let Ok(d) = NaiveDate::parse_from_str(s, "%m/%d/%Y") {
return Ok(d);
}
// MM/DD/YY (assume 2000s)
if let Ok(d) = NaiveDate::parse_from_str(s, "%m/%d/%y") {
return Ok(d);
}
Err(BankCsvError::BadDate(s.to_string()))
}
fn parse_amount(s: &str) -> Result<Decimal, BankCsvError> {
let cleaned: String = s.trim().replace(['$', ','], "");
// Parentheses mean negative: (123.45).
let (neg, inner) = match cleaned.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
Some(inner) => (true, inner),
None => (false, cleaned.as_str()),
};
let mut amount =
Decimal::from_str(inner).map_err(|_| BankCsvError::BadAmount(s.to_string()))?;
if neg {
amount = -amount;
}
Ok(amount)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn three_column_with_header() {
let csv =
"Date,Description,Amount\n2026-01-15,Whole Foods,-84.32\n01/16/2026,Paycheck,2500.00\n";
let txns = parse_bank_csv(csv).unwrap();
assert_eq!(txns.len(), 2);
assert_eq!(txns[0].description, "Whole Foods");
assert_eq!(txns[0].amount, Decimal::new(-8432, 2));
assert_eq!(txns[1].amount, Decimal::new(250000, 2));
}
#[test]
fn four_column_expense_income() {
let csv =
"Date,Description,Expense,Income\n2026-01-15,Coffee,4.50,\n2026-01-16,Refund,,12.00\n";
let txns = parse_bank_csv(csv).unwrap();
assert_eq!(txns.len(), 2);
assert_eq!(txns[0].amount, Decimal::new(-450, 2));
assert_eq!(txns[1].amount, Decimal::new(1200, 2));
}
#[test]
fn parenthesized_negatives_and_dollar_signs() {
let csv = "01/15/2026,Store,\"($1,234.56)\"\n";
let txns = parse_bank_csv(csv).unwrap();
assert_eq!(txns[0].amount, Decimal::new(-123456, 2));
}
#[test]
fn bad_column_count_rejected() {
let csv = "a,b\n";
assert_eq!(parse_bank_csv(csv), Err(BankCsvError::BadColumnCount(2)));
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Parse chart-of-accounts CSVs.
//!
//! Expected layout (header row required):
//!
//! ```csv
//! code,name,type,parent_code
//! 1000,Cash,asset,
//! 1010,Checking,asset,1000
//! ```
//!
//! `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`
//! (case-insensitive). `parent_code` is optional; empty means top-level.
use thiserror::Error;
/// One chart row.
#[derive(Clone, Debug, PartialEq)]
pub struct ChartRow {
/// Account code.
pub code: String,
/// Account name.
pub name: String,
/// Account type, lowercase: asset, liability, equity, revenue, expense.
pub account_type: String,
/// Parent account code, if any.
pub parent_code: Option<String>,
}
/// Parse errors.
#[derive(Debug, Error, PartialEq)]
pub enum ChartCsvError {
/// Wrong column count.
#[error("expected 4 columns (code,name,type,parent_code), found {0}")]
BadColumnCount(usize),
/// Missing or bad header.
#[error("bad header: expected code,name,type,parent_code")]
BadHeader,
/// Unknown account type.
#[error("bad account type {0:?}: expected asset, liability, equity, revenue, or expense")]
BadType(String),
/// Malformed CSV.
#[error("csv error: {0}")]
Csv(String),
}
/// Parse a chart CSV. Returns rows in file order (parents should come first,
/// but order is not validated here).
pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.trim(csv::Trim::All)
.from_reader(text.as_bytes());
let mut out = Vec::new();
for (idx, record) in reader.records().enumerate() {
let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?;
let fields: Vec<&str> = record.iter().collect();
if fields.iter().all(|f| f.is_empty()) {
continue;
}
if idx == 0 {
let lower: Vec<String> = fields.iter().map(|f| f.to_lowercase()).collect();
if lower != ["code", "name", "type", "parent_code"] {
return Err(ChartCsvError::BadHeader);
}
continue;
}
if fields.len() != 4 {
return Err(ChartCsvError::BadColumnCount(fields.len()));
}
let account_type = fields[2].to_lowercase();
match account_type.as_str() {
"asset" | "liability" | "equity" | "revenue" | "expense" => {}
_ => return Err(ChartCsvError::BadType(fields[2].to_string())),
}
let parent_code = fields[3].trim();
out.push(ChartRow {
code: fields[0].to_string(),
name: fields[1].to_string(),
account_type,
parent_code: if parent_code.is_empty() {
None
} else {
Some(parent_code.to_string())
},
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_chart() {
let csv = "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n4000,Revenue,revenue,\n";
let rows = parse_chart_csv(csv).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].parent_code, None);
assert_eq!(rows[1].parent_code, Some("1000".to_string()));
assert_eq!(rows[2].account_type, "revenue");
}
#[test]
fn bad_type_rejected() {
let csv = "code,name,type,parent_code\n1000,Cash,money,\n";
assert!(matches!(
parse_chart_csv(csv),
Err(ChartCsvError::BadType(_))
));
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks
//! Desktop, which many CPAs still run.
//!
//! Layout per entry (fields are tab-separated; `\t` marks each tab):
//!
//! ```text
//! !TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO
//! !SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO
//! !ENDTRNS
//! TRNS\tGENERAL JOURNAL\t01/15/2026\tCash\t\t1000.00\t1\tMemo
//! SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00\t1\tMemo
//! ENDTRNS
//! ```
//!
//! Amounts are signed from the entry's perspective: debits positive,
//! credits negative. Dates are `MM/DD/YYYY`. Accounts are referenced by
//! name (QuickBooks matches on name).
use ledger_core::{JournalEntry, Side};
use rust_decimal::Decimal;
/// One entry's data for IIF export.
pub struct IifEntry<'a> {
/// The journal entry.
pub entry: &'a JournalEntry,
/// Account id → account name, for resolving line accounts.
pub account_names: &'a dyn Fn(ledger_core::AccountId) -> String,
}
/// Render posted entries as IIF text. `docnum` numbers entries sequentially
/// from 1 in the order given.
pub fn journal_to_iif(entries: &[IifEntry<'_>]) -> String {
let mut out = String::new();
out.push_str("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO\n");
out.push_str("!SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO\n");
out.push_str("!ENDTRNS\n");
for (idx, item) in entries.iter().enumerate() {
let docnum = idx + 1;
let date = item.entry.date().format("%m/%d/%Y").to_string();
let memo = sanitize(&item.entry.description().replace('\t', " "));
let mut lines: Vec<_> = item.entry.lines().iter().collect();
// TRNS carries the first debit (or first line); the rest are SPLs.
lines.sort_by_key(|l| match l.side {
Side::Debit => 0,
Side::Credit => 1,
});
let (first, rest) = lines.split_first().expect("entry has lines");
let first_acct = sanitize(&(item.account_names)(first.account));
let first_amount = signed(first.side, first.amount.amount);
out.push_str(&format!(
"TRNS\tGENERAL JOURNAL\t{date}\t{first_acct}\t\t{}\t{docnum}\t{memo}\n",
format_amount(first_amount),
));
for line in rest {
let acct = sanitize(&(item.account_names)(line.account));
let amount = signed(line.side, line.amount.amount);
out.push_str(&format!(
"SPL\tGENERAL JOURNAL\t{date}\t{acct}\t{}\t{docnum}\t{memo}\n",
format_amount(amount),
));
}
out.push_str("ENDTRNS\n");
}
out
}
/// Debit positive, credit negative.
fn signed(side: Side, amount: Decimal) -> Decimal {
match side {
Side::Debit => amount,
Side::Credit => -amount,
}
}
fn format_amount(d: Decimal) -> String {
// Two decimal places, no thousands separators.
format!("{d:.2}")
}
/// Strip tabs/newlines that would corrupt the tab-delimited format.
fn sanitize(s: &str) -> String {
s.replace(['\t', '\n', '\r'], " ")
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::{AccountType, Currency, Ledger, Money};
use rust_decimal::Decimal;
#[test]
fn iif_round_trip_shape() {
let mut ledger = Ledger::new(Currency::USD);
let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let rev = ledger
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let id = ledger.create_entry(
chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
"Sale",
);
ledger
.add_line(
id,
cash,
Side::Debit,
Money::new(Decimal::new(100000, 2), Currency::USD),
)
.unwrap();
ledger
.add_line(
id,
rev,
Side::Credit,
Money::new(Decimal::new(100000, 2), Currency::USD),
)
.unwrap();
ledger.post_entry(id).unwrap();
let entry = ledger.get_entry(id).unwrap();
let names = |a: ledger_core::AccountId| {
ledger
.chart()
.get(a)
.map(|ac| ac.name.clone())
.unwrap_or_default()
};
let text = journal_to_iif(&[IifEntry {
entry,
account_names: &names,
}]);
assert!(text.contains("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO"));
assert!(text.contains("TRNS\tGENERAL JOURNAL\t01/15/2026\tCash"));
assert!(text.contains("\t1000.00\t"));
assert!(text.contains("SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00"));
assert!(text.contains("ENDTRNS"));
}
}
+24
View File
@@ -0,0 +1,24 @@
//! QuickBooks-compatible import/export for wanderbooks.
//!
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
//! - [`report`]: the statement layout every renderer draws from.
//! - [`xlsx`]: generate XLSX reports.
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
//!
//! All money stays `Decimal`; no floats in money paths.
pub mod bank_csv;
pub mod chart_csv;
pub mod iif;
pub mod pdf;
pub mod report;
pub mod xlsx;
pub use bank_csv::{BankTransaction, parse_bank_csv};
pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifEntry, journal_to_iif};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
pub use xlsx::{XlsxReport, report_to_xlsx};
+188
View File
@@ -0,0 +1,188 @@
//! PDF financial statements, typeset with Typst.
//!
//! [`crate::report`] lays a statement out as rows; this module hands those
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
//! renders the PDF. Amounts arrive already split by
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
//! what's installed, and it is deterministic: the same statement and
//! report metadata render to the same bytes.
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
use std::sync::OnceLock;
use thiserror::Error;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
use typst_layout::PagedDocument;
/// PDF rendering errors.
#[derive(Debug, Error)]
pub enum PdfError {
/// Typst failed to compile the statement.
#[error("pdf typesetting failed: {0}")]
Compile(String),
/// The PDF writer failed.
#[error("pdf save failed: {0}")]
Save(String),
}
static TEMPLATE: &str = include_str!("../templates/statement.typ");
static INTER: [&[u8]; 3] = [
include_bytes!("../fonts/Inter-Regular.ttf"),
include_bytes!("../fonts/Inter-SemiBold.ttf"),
include_bytes!("../fonts/Inter-Bold.ttf"),
];
/// The engine, built once: parsing the bundled fonts is the expensive part.
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.build()
})
}
/// The template's input: the statement with every amount split for
/// alignment.
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
let kind = |k: RowKind| match k {
RowKind::Section => "section",
RowKind::Subheading => "subheading",
RowKind::Line => "line",
RowKind::Subtotal => "subtotal",
RowKind::Total => "total",
RowKind::Spacer => "spacer",
};
serde_json::json!({
"company": meta.company,
"title": st.title,
"period": st.period,
"footer": meta.footer(),
"columns": st.columns,
"rows": st.rows.iter().map(|r| serde_json::json!({
"kind": kind(r.kind),
"label": r.label,
"rule": r.rule_above,
"currency": r.currency,
"amounts": r.amounts.iter().map(|a| a.map(|a| {
let p = amount_parts(a);
serde_json::json!({ "digits": p.digits, "negative": p.negative })
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
/// Typeset a statement into a document (pages, before PDF encoding).
fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError> {
let mut inputs = Dict::new();
inputs.insert(
"data".into(),
template_data(st, meta).to_string().into_value(),
);
engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))
}
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let doc = typeset(st, meta)?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{Row, profit_and_loss};
use chrono::NaiveDate;
use ledger_core::reports::{ProfitAndLossStatement, StatementLine};
use rust_decimal::Decimal;
fn meta() -> ReportMeta {
ReportMeta {
company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
journal_seq: 42,
}
}
fn pl() -> ProfitAndLossStatement {
let line = |code: &str, name: &str, cents| StatementLine {
code: code.to_string(),
name: name.to_string(),
amount: Decimal::new(cents, 2),
};
ProfitAndLossStatement {
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
revenue: vec![],
expenses: vec![
line("6000", "Organizational Costs", 5900),
line("6400", "Memberships & Licenses", 10638),
],
total_revenue: Decimal::ZERO,
total_expenses: Decimal::new(16538, 2),
}
}
/// The exact data the template typesets: hand-checked, like a golden.
#[test]
fn template_data_is_exact() {
let amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
let data = template_data(&profit_and_loss(&pl()), &meta());
assert_eq!(
data,
serde_json::json!({
"company": "Test Co",
"title": "Profit and Loss",
"period": "October 4 – October 11, 2026",
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
"columns": ["Total"],
"rows": [
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
],
})
);
}
#[test]
fn renders_a_pdf_deterministically() {
let st = profit_and_loss(&pl());
let a = statement_pdf(&st, &meta()).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, statement_pdf(&st, &meta()).unwrap());
}
/// Labels are data: Typst markup characters print literally, and a
/// statement too long for one page flows onto more instead of being cut.
#[test]
fn markup_is_inert_and_long_statements_paginate() {
let mut st = profit_and_loss(&pl());
st.rows = (0..200)
.map(|i| Row {
kind: RowKind::Line,
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
amounts: vec![Some(Decimal::new(i, 0))],
currency: false,
rule_above: false,
})
.collect();
let doc = typeset(&st, &meta()).unwrap();
assert!(doc.pages().len() > 1, "200 rows should span pages");
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
}
}
+635
View File
@@ -0,0 +1,635 @@
//! The presentation model every statement renderer (XLSX, PDF) draws from.
//!
//! Core computes the statements; this module lays them out once — title,
//! period, column headers, and rows (sections, account lines, subtotals,
//! totals) — so the formats can't drift apart. Amounts stay exact
//! `Decimal`s here; [`format_amount`] and [`amount_parts`] are the only
//! places they become text.
//!
//! Presentation follows US small-business convention (QuickBooks-style):
//! a currency sign on subtotals and totals, a rule above every sum, a
//! double rule under statement totals, "Net income (loss)", and — for a
//! multi-member LLC — "Members' equity" with each member named.
use chrono::{Datelike, NaiveDate};
use ledger_core::RETAINED_EARNINGS_CODE;
use ledger_core::reports::{
BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement,
ProfitAndLossStatement, StatementLine, TrialBalance,
};
use rust_decimal::Decimal;
use serde::Serialize;
/// Who the report is for and exactly what it was prepared from.
#[derive(Clone, Debug, Serialize)]
pub struct ReportMeta {
/// Company name for the header, e.g. "Wanderland Labs LLC".
pub company: String,
/// Accounting basis for the footer: "Accrual" or "Cash".
pub basis: String,
/// The day the report was prepared.
pub prepared_on: NaiveDate,
/// Sequence number of the last journal line the report reflects.
pub journal_seq: u64,
}
impl ReportMeta {
/// "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114".
pub fn footer(&self) -> String {
format!(
"{} basis · Prepared {}, {} · Journal seq {}",
self.basis,
self.prepared_on.format("%A"),
long_date(self.prepared_on),
self.journal_seq
)
}
}
/// A member of a multi-member LLC, for naming capital accounts on reports.
#[derive(Clone, Debug)]
pub struct Member {
/// The member's name.
pub name: String,
/// Code of their capital account.
pub capital_code: String,
}
/// What a row is, which decides how renderers style it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RowKind {
/// A section heading ("Revenue"); no amounts.
Section,
/// An explanatory heading inside a section; no amounts.
Subheading,
/// One account or one flow, indented under its section.
Line,
/// A section total ("Total revenue").
Subtotal,
/// A statement total ("Net income (loss)"); double rule beneath.
Total,
/// Vertical space between sections.
Spacer,
}
/// One row of a statement.
#[derive(Clone, Debug, Serialize)]
pub struct Row {
/// Row kind.
pub kind: RowKind,
/// Label text.
pub label: String,
/// One amount per column; `None` leaves the cell blank.
pub amounts: Vec<Option<Decimal>>,
/// Print the currency sign with the amounts: subtotals and totals.
pub currency: bool,
/// Draw a rule above the row: the "sum of the above" line. Totals
/// always have one; a subtotal only when amounts sit above it.
pub rule_above: bool,
}
/// A laid-out financial statement.
#[derive(Clone, Debug, Serialize)]
pub struct Statement {
/// "Profit and Loss", "Balance Sheet", ...
pub title: String,
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
pub period: String,
/// Amount column headings ("Total"; "Debit", "Credit"; member names).
pub columns: Vec<String>,
/// The rows, top to bottom.
pub rows: Vec<Row>,
}
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
Row {
kind,
label: label.into(),
amounts,
currency: false,
rule_above: false,
}
}
fn one(kind: RowKind, label: impl Into<String>, amount: Decimal) -> Row {
row(kind, label, vec![Some(amount)])
}
fn section(label: &str) -> Row {
row(RowKind::Section, label, vec![])
}
fn subheading(label: &str) -> Row {
row(RowKind::Subheading, label, vec![])
}
fn spacer() -> Row {
row(RowKind::Spacer, "", vec![])
}
/// Finish a statement: currency signs on sums, rules above them.
fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
let mut above: Option<RowKind> = None;
for r in &mut rows {
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
r.rule_above = match r.kind {
RowKind::Total => true,
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
_ => false,
};
if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) {
above = Some(r.kind);
}
}
Statement {
title: title.to_string(),
period,
columns: columns.iter().map(|c| c.to_string()).collect(),
rows,
}
}
fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
lines
.iter()
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount))
}
/// Lay out a profit and loss statement.
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
let mut rows = vec![section("Revenue")];
rows.extend(account_lines(&pl.revenue));
rows.push(one(RowKind::Subtotal, "Total revenue", pl.total_revenue));
rows.push(spacer());
rows.push(section("Expenses"));
rows.extend(account_lines(&pl.expenses));
rows.push(one(RowKind::Subtotal, "Total expenses", pl.total_expenses));
rows.push(spacer());
rows.push(one(RowKind::Total, "Net income (loss)", pl.net_income()));
statement(
"Profit and Loss",
period_label(pl.start, pl.end),
&["Total"],
rows,
)
}
/// The fiscal year's earnings, as the balance sheet presents them.
#[derive(Clone, Copy, Debug)]
pub struct YearEarnings {
/// Net income (loss) for the fiscal year through the as-of date.
pub net_income_ytd: Decimal,
/// The part of it not yet closed into equity accounts.
pub unclosed: Decimal,
}
/// Lay out a balance sheet.
///
/// Without members (corporate convention), equity ends with "Retained
/// earnings" — the Retained Earnings account plus earlier years' earnings
/// not yet closed — and the fiscal year's "Net income (loss)".
///
/// With members (a partnership), each member's capital account already
/// carries the earnings closed into it, so equity ends with only what
/// hasn't been allocated: "Undistributed earnings, prior years" (if any)
/// and this year's unclosed "Net income (loss)". Equity is "Members'
/// equity".
pub fn balance_sheet(
bs: &BalanceSheetStatement,
earnings: YearEarnings,
members: &[Member],
) -> Statement {
let equity_name = if members.is_empty() {
"Equity"
} else {
"Members' equity"
};
let mut rows = vec![section("Assets")];
rows.extend(account_lines(&bs.assets));
rows.push(one(RowKind::Subtotal, "Total assets", bs.total_assets));
rows.push(spacer());
rows.push(section("Liabilities"));
rows.extend(account_lines(&bs.liabilities));
rows.push(one(
RowKind::Subtotal,
"Total liabilities",
bs.total_liabilities,
));
rows.push(spacer());
rows.push(section(equity_name));
if members.is_empty() {
let (retained, other): (Vec<_>, Vec<_>) = bs
.equity
.iter()
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
rows.extend(
other
.iter()
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
);
let retained: Decimal = retained.iter().map(|l| l.amount).sum::<Decimal>()
+ bs.current_earnings
- earnings.net_income_ytd;
if !retained.is_zero() {
rows.push(one(RowKind::Line, "Retained earnings", retained));
}
rows.push(one(
RowKind::Line,
"Net income (loss)",
earnings.net_income_ytd,
));
} else {
rows.extend(account_lines(&bs.equity));
let prior = bs.current_earnings - earnings.unclosed;
if !prior.is_zero() {
rows.push(one(
RowKind::Line,
"Undistributed earnings, prior years",
prior,
));
}
rows.push(one(RowKind::Line, "Net income (loss)", earnings.unclosed));
}
rows.push(one(
RowKind::Subtotal,
format!("Total {}", equity_name.to_lowercase()),
bs.total_equity,
));
rows.push(spacer());
rows.push(one(
RowKind::Total,
format!("Total liabilities and {}", equity_name.to_lowercase()),
bs.total_liabilities + bs.total_equity,
));
statement(
"Balance Sheet",
format!("As of {}", long_date(bs.as_of)),
&["Total"],
rows,
)
}
/// Cash flow lines, naming members on their capital accounts.
fn flow_lines<'a>(
lines: &'a [CashFlowLine],
members: &'a [Member],
) -> impl Iterator<Item = Row> + 'a {
lines.iter().map(move |l| {
let member = members
.iter()
.find(|m| l.account.as_deref() == Some(m.capital_code.as_str()));
let label = match member {
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
Some(m) => format!("Member distributions — {}", m.name),
None => l.label.clone(),
};
one(RowKind::Line, label, l.amount)
})
}
/// Lay out a cash flow statement (indirect method).
pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement {
let mut rows = vec![section("Operating activities")];
rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income));
if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() {
rows.push(subheading(
"Adjustments to reconcile net income (loss) to net cash from operating activities:",
));
}
if !cf.depreciation.is_zero() {
rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
}
rows.extend(flow_lines(&cf.working_capital, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from operating activities",
cf.cash_from_operations,
));
rows.push(spacer());
rows.push(section("Investing activities"));
rows.extend(flow_lines(&cf.investing, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from investing activities",
cf.cash_from_investing,
));
rows.push(spacer());
rows.push(section("Financing activities"));
rows.extend(flow_lines(&cf.financing, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from financing activities",
cf.cash_from_financing,
));
rows.push(spacer());
rows.push(one(
RowKind::Subtotal,
"Net increase (decrease) in cash",
cf.net_change_in_cash,
));
rows.push(one(
RowKind::Line,
"Cash at beginning of period",
cf.cash_beginning,
));
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending));
statement(
"Statement of Cash Flows",
period_label(cf.start, cf.end),
&["Total"],
rows,
)
}
/// Lay out a trial balance as of a date: Debit and Credit columns, a zero
/// side left blank.
pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement {
let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) };
let mut rows: Vec<Row> = tb
.lines
.iter()
.map(|l| {
row(
RowKind::Line,
format!("{} {}", l.code, l.name),
vec![nz(l.debit), nz(l.credit)],
)
})
.collect();
rows.push(row(
RowKind::Total,
"Total",
vec![Some(tb.total_debits), Some(tb.total_credits)],
));
statement(
"Trial Balance",
format!("As of {}", long_date(as_of)),
&["Debit", "Credit"],
rows,
)
}
/// Lay out the statement of changes in members' equity: one column per
/// member and a total. Distributions print as reductions.
pub fn members_equity(me: &MembersEquityStatement) -> Statement {
let cols: Vec<_> = me
.members
.iter()
.chain(std::iter::once(&me.total))
.collect();
let pick = |f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| {
cols.iter().map(|c| Some(f(c))).collect::<Vec<_>>()
};
let mut rows = vec![
row(RowKind::Line, "Beginning balance", pick(&|c| c.beginning)),
row(RowKind::Line, "Contributions", pick(&|c| c.contributions)),
row(RowKind::Line, "Net income (loss)", pick(&|c| c.net_income)),
row(RowKind::Line, "Distributions", pick(&|c| -c.distributions)),
];
if !me.total.other.is_zero() {
rows.push(row(RowKind::Line, "Other changes", pick(&|c| c.other)));
}
rows.push(row(RowKind::Total, "Ending balance", pick(&|c| c.ending)));
let names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
statement(
"Statement of Changes in Members' Equity",
period_label(me.start, me.end),
&names,
rows,
)
}
/// "October 11, 2026".
pub fn long_date(d: NaiveDate) -> String {
d.format("%B %-d, %Y").to_string()
}
/// "October 4 – October 11, 2026", or with both years when they differ.
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
if start.year() == end.year() {
format!("{} – {}", start.format("%B %-d"), long_date(end))
} else {
format!("{} – {}", long_date(start), long_date(end))
}
}
/// Exact accounting format: thousands separators, two decimals, negatives
/// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float.
pub fn format_amount(d: Decimal) -> String {
let negative = d.is_sign_negative() && !d.is_zero();
let fixed = format!("{:.2}", d.abs());
let (int, frac) = fixed.split_once('.').expect("two decimals");
let mut grouped = String::new();
for (i, c) in int.chars().enumerate() {
if i > 0 && (int.len() - i) % 3 == 0 {
grouped.push(',');
}
grouped.push(c);
}
if negative {
format!("({grouped}.{frac})")
} else {
format!("{grouped}.{frac}")
}
}
/// An amount split for column alignment: the digits, and whether it is a
/// negative (printed in parentheses) or zero (printed as 0.00). Renderers
/// right-align `digits` and reserve the closing parenthesis's width on every
/// row, so decimal points line up whether or not a value is negative.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AmountParts {
/// "1,234.56" — absolute value, grouped, two decimals.
pub digits: String,
/// Below zero.
pub negative: bool,
}
/// Split an amount for aligned accounting display. Exact: no floats.
pub fn amount_parts(d: Decimal) -> AmountParts {
let rounded = d.round_dp(2);
AmountParts {
digits: format_amount(rounded.abs()),
negative: rounded.is_sign_negative() && !rounded.is_zero(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
#[test]
fn amounts_format_exactly() {
assert_eq!(format_amount(dec("0")), "0.00");
assert_eq!(format_amount(dec("-0")), "0.00");
assert_eq!(format_amount(dec("5.8")), "5.80");
assert_eq!(format_amount(dec("207.68")), "207.68");
assert_eq!(format_amount(dec("-207.68")), "(207.68)");
assert_eq!(format_amount(dec("1000")), "1,000.00");
assert_eq!(format_amount(dec("1234567.891")), "1,234,567.89");
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
}
#[test]
fn amounts_split_for_alignment() {
let p = amount_parts(dec("-1234.5"));
assert_eq!((p.digits.as_str(), p.negative), ("1,234.50", true));
let z = amount_parts(dec("-0.004"));
assert_eq!((z.digits.as_str(), z.negative), ("0.00", false));
}
#[test]
fn currency_signs_and_rules_go_on_sums() {
let rows = vec![
section("Assets"),
one(RowKind::Line, "a", dec("1")),
one(RowKind::Line, "b", dec("2")),
one(RowKind::Subtotal, "t", dec("3")),
spacer(),
section("Investing"),
one(RowKind::Subtotal, "empty", dec("0")),
one(RowKind::Total, "T", dec("3")),
];
let st = statement("X", String::new(), &["Total"], rows);
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
assert_eq!(signs, [false, false, false, true, false, false, true, true]);
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
// No rule over an empty section's subtotal; always one over a total.
assert_eq!(
rules,
[false, false, false, true, false, false, false, true]
);
}
fn sl(code: &str, name: &str, a: &str) -> StatementLine {
StatementLine {
code: code.into(),
name: name.into(),
amount: dec(a),
}
}
fn equity_rows(st: &Statement, from: &str) -> Vec<(String, Option<Decimal>)> {
st.rows
.iter()
.skip_while(|r| r.label != from)
.map(|r| (r.label.clone(), r.amounts.first().copied().flatten()))
.collect()
}
#[test]
fn corporate_balance_sheet_splits_retained_earnings_from_this_year() {
// Prior years closed 1,000 to Retained Earnings; 200 more unclosed
// from last year; this year's net income is 300 (current earnings
// 500 = 200 + 300).
let bs = BalanceSheetStatement {
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
assets: vec![sl("1000", "Cash", "2500")],
liabilities: vec![],
equity: vec![
sl("3000", "Common Stock", "1000"),
sl("3999", "Retained Earnings", "1000"),
],
current_earnings: dec("500"),
total_assets: dec("2500"),
total_liabilities: dec("0"),
total_equity: dec("2500"),
};
let e = YearEarnings {
net_income_ytd: dec("300"),
unclosed: dec("300"),
};
let st = balance_sheet(&bs, e, &[]);
let some = |s: &str| Some(dec(s));
assert_eq!(
equity_rows(&st, "Equity"),
[
("Equity".into(), None),
("3000 Common Stock".into(), some("1000")),
("Retained earnings".into(), some("1200")),
("Net income (loss)".into(), some("300")),
("Total equity".into(), some("2500")),
("".into(), None),
("Total liabilities and equity".into(), some("2500")),
]
);
}
#[test]
fn partnership_balance_sheet_shows_only_unallocated_earnings() {
// January (100) was closed into the capital accounts; February and
// March (200) are this year's unclosed income; 50 from last year
// was never closed.
let bs = BalanceSheetStatement {
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
assets: vec![sl("1000", "Cash", "1350")],
liabilities: vec![],
equity: vec![
sl("3000", "Capital — Alice", "550"),
sl("3001", "Capital — Bob", "550"),
],
current_earnings: dec("250"),
total_assets: dec("1350"),
total_liabilities: dec("0"),
total_equity: dec("1350"),
};
let e = YearEarnings {
net_income_ytd: dec("300"),
unclosed: dec("200"),
};
let members = [
Member {
name: "Alice".into(),
capital_code: "3000".into(),
},
Member {
name: "Bob".into(),
capital_code: "3001".into(),
},
];
let st = balance_sheet(&bs, e, &members);
let some = |s: &str| Some(dec(s));
assert_eq!(
equity_rows(&st, "Members' equity"),
[
("Members' equity".into(), None),
("3000 Capital — Alice".into(), some("550")),
("3001 Capital — Bob".into(), some("550")),
("Undistributed earnings, prior years".into(), some("50")),
("Net income (loss)".into(), some("200")),
("Total members' equity".into(), some("1350")),
("".into(), None),
("Total liabilities and members' equity".into(), some("1350")),
]
);
}
#[test]
fn periods_read_like_english() {
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
assert_eq!(
period_label(d(2026, 10, 4), d(2026, 10, 11)),
"October 4 – October 11, 2026"
);
assert_eq!(
period_label(d(2026, 12, 1), d(2027, 1, 31)),
"December 1, 2026 – January 31, 2027"
);
let meta = ReportMeta {
company: "X".into(),
basis: "Accrual".into(),
prepared_on: d(2026, 10, 11),
journal_seq: 114,
};
assert_eq!(
meta.footer(),
"Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114"
);
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Generate XLSX reports with `rust_xlsxwriter` (MIT).
//!
//! Money is converted to f64 ONLY at this presentation boundary — never in
//! a money path. All accounting math stays `Decimal`; the conversion happens
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
//! sum them) with accounting number formats: thousands separators,
//! negatives in parentheses with the closing parenthesis hanging (so
//! decimal points align), "$" on subtotals and totals.
use crate::report::{ReportMeta, RowKind, Statement};
use chrono::NaiveDate;
use ledger_core::Ledger;
use ledger_core::reports::GeneralLedger;
use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook, Worksheet};
use thiserror::Error;
/// Which report to render.
#[derive(Clone, Debug)]
pub enum XlsxReport<'a> {
/// A laid-out statement (P&L, balance sheet, cash flow, trial balance,
/// members' equity); see [`crate::report`].
Statement(&'a Statement),
/// The general ledger: by account, with running balances and splits.
GeneralLedger(&'a GeneralLedger),
/// The general journal: every posted entry in `[start, end]`, line by
/// line, in date order.
GeneralJournal(&'a Ledger, NaiveDate, NaiveDate),
}
/// XLSX errors.
#[derive(Debug, Error)]
pub enum XlsxError {
/// The writer failed.
#[error("xlsx write failed: {0}")]
Write(String),
}
impl From<rust_xlsxwriter::XlsxError> for XlsxError {
fn from(e: rust_xlsxwriter::XlsxError) -> Self {
XlsxError::Write(e.to_string())
}
}
/// Accounting number formats: the `_)` reserves a closing parenthesis's
/// width on positives so decimal points line up with negatives.
const PLAIN: &str = "#,##0.00_);(#,##0.00)";
const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
struct Formats {
bold: Format,
company: Format,
centered: Format,
header: Format,
header_right: Format,
plain: Format,
dollars_sum: Format,
dollars_total: Format,
line_label: Format,
sub_label: Format,
note: Format,
}
impl Formats {
fn new() -> Self {
let bold = Format::new().set_bold();
let ruled = |f: Format| f.set_border_top(FormatBorder::Thin);
Self {
company: Format::new()
.set_font_size(14)
.set_align(FormatAlign::Center),
centered: Format::new().set_align(FormatAlign::Center),
header: bold
.clone()
.set_font_size(9)
.set_border_bottom(FormatBorder::Thin),
header_right: bold
.clone()
.set_font_size(9)
.set_border_bottom(FormatBorder::Thin)
.set_align(FormatAlign::Right),
plain: Format::new().set_num_format(PLAIN),
dollars_sum: ruled(bold.clone().set_num_format(DOLLARS)),
dollars_total: ruled(bold.clone().set_num_format(DOLLARS))
.set_border_bottom(FormatBorder::Double),
line_label: Format::new().set_indent(1),
sub_label: Format::new().set_italic().set_indent(1),
note: Format::new()
.set_italic()
.set_font_size(9)
.set_font_color("#666666")
.set_align(FormatAlign::Center),
bold,
}
}
}
/// Company, title, and period centered over `width` columns; returns the
/// next free row.
fn header(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
title: &str,
period: &str,
width: u16,
) -> Result<u32, XlsxError> {
let last = width.saturating_sub(1);
sheet.merge_range(0, 0, 0, last, &meta.company, &f.company)?;
sheet.merge_range(
1,
0,
1,
last,
title,
&f.bold.clone().set_align(FormatAlign::Center),
)?;
sheet.merge_range(2, 0, 2, last, period, &f.centered)?;
Ok(4)
}
/// Basis, preparation date, and journal seq under the last row.
fn footer(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
row: u32,
width: u16,
) -> Result<(), XlsxError> {
sheet.merge_range(
row + 1,
0,
row + 1,
width.saturating_sub(1),
&meta.footer(),
&f.note,
)?;
Ok(())
}
fn number(
sheet: &mut Worksheet,
row: u32,
col: u16,
d: Decimal,
fmt: &Format,
) -> Result<(), XlsxError> {
sheet.write_number_with_format(row, col, dec_to_f64(d), fmt)?;
Ok(())
}
/// Render a report to XLSX bytes.
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
let f = Formats::new();
match report {
XlsxReport::Statement(st) => statement(sheet, &f, meta, st)?,
XlsxReport::GeneralLedger(gl) => general_ledger(sheet, &f, meta, gl)?,
XlsxReport::GeneralJournal(ledger, start, end) => {
general_journal(sheet, &f, meta, ledger, *start, *end)?
}
}
Ok(workbook.save_to_buffer()?)
}
fn statement(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
st: &Statement,
) -> Result<(), XlsxError> {
// Excel sheet names: at most 31 characters, no apostrophes at the ends.
let name: String = st.title.replace('\'', "").chars().take(31).collect();
sheet.set_name(name.trim())?;
let width = 1 + st.columns.len() as u16;
let mut row = header(sheet, f, meta, &st.title, &st.period, width)?;
sheet.write_string_with_format(row, 0, "", &f.header)?;
for (i, c) in st.columns.iter().enumerate() {
sheet.write_string_with_format(row, 1 + i as u16, c.to_uppercase(), &f.header_right)?;
}
row += 1;
let dollars_unruled = f.bold.clone().set_num_format(DOLLARS);
for r in &st.rows {
let (label_fmt, amount_fmt) = match r.kind {
RowKind::Spacer => {
row += 1;
continue;
}
RowKind::Section => (&f.bold, &f.plain),
RowKind::Subheading => (&f.sub_label, &f.plain),
RowKind::Line => (&f.line_label, &f.plain),
RowKind::Subtotal if r.rule_above => (&f.bold, &f.dollars_sum),
RowKind::Subtotal => (&f.bold, &dollars_unruled),
RowKind::Total => (&f.bold, &f.dollars_total),
};
sheet.write_string_with_format(row, 0, &r.label, label_fmt)?;
for (i, a) in r.amounts.iter().enumerate() {
match a {
Some(d) => number(sheet, row, 1 + i as u16, *d, amount_fmt)?,
None if r.rule_above => {
sheet.write_blank(row, 1 + i as u16, amount_fmt)?;
}
None => {}
}
}
row += 1;
}
sheet.set_column_width(0, 58)?;
for i in 0..st.columns.len() as u16 {
sheet.set_column_width(1 + i, 16)?;
}
footer(sheet, f, meta, row, width)
}
fn general_ledger(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
gl: &GeneralLedger,
) -> Result<(), XlsxError> {
sheet.set_name("General Ledger")?;
let period = crate::report::period_label(gl.start, gl.end);
let cols = [
"Date",
"Entry",
"Description",
"Split",
"Debit",
"Credit",
"Balance",
];
let mut row = header(sheet, f, meta, "General Ledger", &period, cols.len() as u16)?;
for (i, h) in cols.iter().enumerate() {
let fmt = if i >= 4 { &f.header_right } else { &f.header };
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
}
row += 1;
for a in &gl.accounts {
let name = format!("{} {}", a.code, a.name);
sheet.write_string_with_format(row, 0, &name, &f.bold)?;
row += 1;
sheet.write_string_with_format(row, 2, "Beginning balance", &f.line_label)?;
number(sheet, row, 6, a.beginning_balance, &f.plain)?;
row += 1;
for p in &a.postings {
sheet.write_string(row, 0, p.date.to_string())?;
sheet.write_number(row, 1, p.entry_id as f64)?;
sheet.write_string(row, 2, &p.description)?;
sheet.write_string(row, 3, &p.split)?;
if !p.debit.is_zero() {
number(sheet, row, 4, p.debit, &f.plain)?;
}
if !p.credit.is_zero() {
number(sheet, row, 5, p.credit, &f.plain)?;
}
number(sheet, row, 6, p.balance, &f.plain)?;
row += 1;
}
sheet.write_string_with_format(row, 0, format!("Total for {name}"), &f.bold)?;
for c in 1..4 {
sheet.write_blank(row, c, &f.dollars_sum)?;
}
number(sheet, row, 4, a.total_debits, &f.dollars_sum)?;
number(sheet, row, 5, a.total_credits, &f.dollars_sum)?;
number(sheet, row, 6, a.ending_balance, &f.dollars_sum)?;
row += 2;
}
for (c, w) in [
(0, 12.0),
(1, 7.0),
(2, 46.0),
(3, 34.0),
(4, 13.0),
(5, 13.0),
(6, 13.0),
] {
sheet.set_column_width(c, w)?;
}
footer(sheet, f, meta, row, cols.len() as u16)
}
fn general_journal(
sheet: &mut Worksheet,
f: &Formats,
meta: &ReportMeta,
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
) -> Result<(), XlsxError> {
sheet.set_name("General Journal")?;
let period = crate::report::period_label(start, end);
let cols = [
"Date",
"Entry",
"Description",
"Account",
"Class",
"Debit",
"Credit",
];
let mut row = header(
sheet,
f,
meta,
"General Journal",
&period,
cols.len() as u16,
)?;
for (i, h) in cols.iter().enumerate() {
let fmt = if i >= 5 { &f.header_right } else { &f.header };
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
}
row += 1;
let mut entries: Vec<_> = ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end)
.collect();
entries.sort_by_key(|e| (e.date(), e.id()));
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
for entry in entries {
for line in entry.lines() {
let account = ledger
.chart()
.get(line.account)
.map(|a| format!("{} {}", a.code, a.name))
.unwrap_or_else(|| "?".to_string());
let class = line
.class_id
.and_then(|cid| ledger.get_class(cid))
.map(|c| c.name.as_str())
.unwrap_or("");
sheet.write_string(row, 0, entry.date().to_string())?;
sheet.write_number(row, 1, entry.id() as f64)?;
sheet.write_string(row, 2, entry.description())?;
sheet.write_string(row, 3, &account)?;
sheet.write_string(row, 4, class)?;
match line.side {
ledger_core::Side::Debit => {
number(sheet, row, 5, line.amount.amount, &f.plain)?;
debits += line.amount.amount;
}
ledger_core::Side::Credit => {
number(sheet, row, 6, line.amount.amount, &f.plain)?;
credits += line.amount.amount;
}
}
row += 1;
}
}
sheet.write_string_with_format(row, 0, "Total", &f.bold)?;
for c in 1..5 {
sheet.write_blank(row, c, &f.dollars_total)?;
}
number(sheet, row, 5, debits, &f.dollars_total)?;
number(sheet, row, 6, credits, &f.dollars_total)?;
for (c, w) in [
(0, 12.0),
(1, 7.0),
(2, 50.0),
(3, 38.0),
(4, 12.0),
(5, 13.0),
(6, 13.0),
] {
sheet.set_column_width(c, w)?;
}
footer(sheet, f, meta, row + 1, cols.len() as u16)
}
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
fn dec_to_f64(d: Decimal) -> f64 {
use rust_decimal::prelude::ToPrimitive;
d.round_dp(2).to_f64().unwrap_or(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report;
use ledger_core::reports::{TrialBalance, TrialBalanceLine};
#[test]
fn xlsx_is_a_zip() {
let tb = TrialBalance {
lines: vec![TrialBalanceLine {
code: "1000".to_string(),
name: "Cash".to_string(),
debit: Decimal::new(10000, 2),
credit: Decimal::ZERO,
}],
total_debits: Decimal::new(10000, 2),
total_credits: Decimal::new(10000, 2),
};
let meta = ReportMeta {
company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
journal_seq: 7,
};
let st = report::trial_balance(&tb, NaiveDate::from_ymd_opt(2026, 10, 11).unwrap());
let bytes = report_to_xlsx(&XlsxReport::Statement(&st), &meta).unwrap();
// XLSX is a ZIP: starts with PK.
assert_eq!(&bytes[0..2], b"PK");
assert!(bytes.len() > 1000);
}
}
@@ -0,0 +1,115 @@
// A financial statement: P&L, balance sheet, cash flow, trial balance, or
// members' equity, in US small-business (QuickBooks-style) presentation.
//
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
// ledger-import::report, with amounts already split into exact parts. The
// template only typesets: no arithmetic happens here, and no label is ever
// parsed as markup.
//
// Shape: { company, title, period, footer, columns: ["Total"] | [...],
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
// | "total" | "spacer",
// label, rule (rule above), currency ("$" on amounts),
// amounts: [null | { digits: "1,234.56", negative }] }] }
//
// Amounts: Inter's tabular figures; negatives in parentheses with the
// closing parenthesis hanging (its width is reserved on every row, so
// decimal points always line up); "$" attached on subtotals and totals.
#let d = json(bytes(sys.inputs.data))
#let ncols = d.columns.len()
#let ink = luma(25)
#let quiet = luma(110)
#let hair = 0.5pt + luma(160)
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
#set page(
paper: "us-letter",
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
header: context {
if counter(page).get().first() > 1 {
set text(7.5pt, fill: quiet)
[#d.company · #d.title #h(1fr) #d.period]
}
},
footer: context {
set text(7.5pt, fill: quiet)
grid(
columns: (1fr, auto, 1fr),
[],
d.footer,
align(right)[Page #counter(page).display() of #counter(page).final().first()],
)
},
)
#align(center)[
#text(15pt, fill: luma(45), d.company) \
#v(4pt)
#text(10.5pt, weight: "bold", d.title) \
#v(2pt)
#d.period
]
#v(12pt)
#let amount(a, currency) = {
if a == none { return [] }
let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] }
let sign = if currency [\$] else []
[#open#sign#a.digits#close]
}
#let full(body, ..args) = table.cell(colspan: ncols + 1, ..args, body)
#let row-cells(r) = {
let bold = r.kind == "subtotal" or r.kind == "total"
let stroke = if r.rule { (top: if r.kind == "total" { firm } else { hair }) } else { none }
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else { r.label }
let label = if bold { text(weight: "bold", label) } else { label }
(
table.cell(stroke: stroke, label),
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))),
)
}
#table(
columns: (1fr,) + (8em,) * ncols,
stroke: none,
inset: (x: 4pt, y: 3.6pt),
align: (left + horizon,) + (right + horizon,) * ncols,
table.header(
table.cell(stroke: (top: firm, bottom: hair), []),
..d.columns.map(c => table.cell(
stroke: (top: firm, bottom: hair),
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
)),
),
..d
.rows
.map(r => {
if r.kind == "spacer" {
(full(v(2pt), inset: 2pt),)
} else if r.kind == "section" {
(full(text(weight: "semibold", r.label)),)
} else if r.kind == "subheading" {
(full(pad(left: 1.4em, emph(r.label))),)
} else if r.kind == "total" {
// The double rule under a statement total, full width.
let double = full(
inset: (x: 0pt, y: 0pt),
stack(
spacing: 1.6pt,
line(length: 100%, stroke: firm),
line(length: 100%, stroke: firm),
),
)
row-cells(r) + (double,)
} else {
row-cells(r)
}
})
.flatten(),
)
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "ledger-mcp"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "MCP server for wanderbooks: the AI-first bookkeeping interface over ledger-store."
[[bin]]
name = "ledger-mcp"
path = "src/main.rs"
[dependencies]
ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
ledger-import = { path = "../ledger-import" }
base64 = "0.22"
mime_guess = "2"
rust_decimal = "1"
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
axum = "0.8"
tower = "0.5"
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
zip = "9.0.1"
toml = "1"
[lints]
workspace = true
+292
View File
@@ -0,0 +1,292 @@
//! Company settings from the books dir's `wanderbooks.toml`.
//!
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
//! fills in `[company]`. The server reads the company name (report headers),
//! the fiscal year start (fiscal-year-to-date reports), the members of a
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
//! for the statement of members' equity), `[books] accounting_basis`
//! (report footers), and `[books] receipt_threshold` (the receipt policy,
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before
//! the next start, so the two can't disagree.
use chrono::{Datelike, NaiveDate};
use rust_decimal::Decimal;
use serde::Deserialize;
use std::path::Path;
/// The company the books belong to.
#[derive(Clone, Debug, PartialEq)]
pub struct Company {
/// Legal name, for report headers.
pub name: String,
/// Month and day the fiscal year starts. Default January 1.
pub fiscal_year_start: (u32, u32),
/// Members of a multi-member LLC; empty for other entities.
pub members: Vec<Member>,
/// "Accrual" or "Cash", for report footers. Default "Accrual".
pub basis: String,
}
/// One member of a multi-member LLC.
#[derive(Clone, Debug, PartialEq)]
pub struct Member {
/// Name, as it appears on reports.
pub name: String,
/// Share of income, e.g. 0.50.
pub share: Decimal,
/// Code of their capital account in the chart.
pub capital_account: String,
}
impl Default for Company {
fn default() -> Self {
Self {
name: "Unnamed company".to_string(),
fiscal_year_start: (1, 1),
members: Vec::new(),
basis: "Accrual".to_string(),
}
}
}
impl Company {
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
let (m, day) = self.fiscal_year_start;
let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day");
let start = if on(d.year()) <= d {
on(d.year())
} else {
on(d.year() - 1)
};
let end = on(start.year() + 1)
.pred_opt()
.expect("not the minimum date");
(start, end)
}
}
/// What the server takes from `wanderbooks.toml`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BooksSettings {
/// `[company]`.
pub company: Company,
/// `[books] receipt_threshold`, validated as a decimal string.
pub receipt_threshold: Option<String>,
}
#[derive(Deserialize)]
struct BooksToml {
#[serde(default)]
company: CompanyToml,
#[serde(default)]
books: BooksSection,
}
#[derive(Default, Deserialize)]
struct CompanyToml {
name: Option<String>,
fiscal_year_start: Option<String>,
#[serde(default)]
members: Vec<MemberToml>,
}
#[derive(Deserialize)]
struct MemberToml {
name: String,
share: String,
capital_account: Option<String>,
}
#[derive(Default, Deserialize)]
struct BooksSection {
receipt_threshold: Option<String>,
accounting_basis: Option<String>,
}
/// Parse a share: "50%" or "0.5".
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
let s = s.trim();
let bad = || format!("member {name}: share {s:?} must be like \"50%\" or \"0.5\"");
let share = match s.strip_suffix('%') {
Some(pct) => pct.trim().parse::<Decimal>().map_err(|_| bad())? / Decimal::from(100),
None => s.parse::<Decimal>().map_err(|_| bad())?,
};
if share <= Decimal::ZERO || share > Decimal::ONE {
return Err(bad());
}
Ok(share)
}
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
/// day every year has.
fn parse_month_day(s: &str) -> Result<(u32, u32), String> {
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\"");
let (m, d) = s.split_once('-').ok_or_else(bad)?;
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
// 2001 is not a leap year: a valid date there exists every year.
NaiveDate::from_ymd_opt(2001, m, d)
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?;
Ok((m, d))
}
/// Parse `wanderbooks.toml` text.
pub fn parse(text: &str) -> Result<BooksSettings, String> {
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
let mut company = Company::default();
if let Some(name) = raw.company.name.filter(|n| !n.trim().is_empty()) {
company.name = name.trim().to_string();
}
if let Some(fy) = raw.company.fiscal_year_start {
company.fiscal_year_start = parse_month_day(fy.trim())?;
}
for m in raw.company.members {
let share = parse_share(&m.name, &m.share)?;
let capital_account = m
.capital_account
.filter(|c| !c.trim().is_empty())
.ok_or_else(|| format!("member {}: capital_account is required", m.name))?;
company.members.push(Member {
name: m.name.trim().to_string(),
share,
capital_account: capital_account.trim().to_string(),
});
}
if !company.members.is_empty() {
let total: Decimal = company.members.iter().map(|m| m.share).sum();
if total != Decimal::ONE {
return Err(format!("member shares sum to {total}, not 100%"));
}
}
if let Some(basis) = raw.books.accounting_basis {
company.basis = match basis.trim().to_lowercase().as_str() {
"accrual" => "Accrual".to_string(),
"cash" => "Cash".to_string(),
other => {
return Err(format!(
"accounting_basis {other:?}: expected accrual or cash"
));
}
};
}
let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => {
t.trim()
.parse::<Decimal>()
.map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?;
Some(t.trim().to_string())
}
None => None,
};
Ok(BooksSettings {
company,
receipt_threshold,
})
}
/// Load `wanderbooks.toml` from a path.
pub fn load(path: &Path) -> Result<BooksSettings, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn calendar_year_by_default() {
let c = parse("").unwrap().company;
assert_eq!(c.fiscal_year_start, (1, 1));
assert_eq!(
c.fiscal_year_containing(d(2026, 10, 11)),
(d(2026, 1, 1), d(2026, 12, 31))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 1, 1)),
(d(2026, 1, 1), d(2026, 12, 31))
);
}
#[test]
fn a_july_fiscal_year_spans_two_calendar_years() {
let c = parse("[company]\nfiscal_year_start = \"07-01\"\n")
.unwrap()
.company;
assert_eq!(
c.fiscal_year_containing(d(2026, 3, 15)),
(d(2025, 7, 1), d(2026, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 7, 1)),
(d(2026, 7, 1), d(2027, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 6, 30)),
(d(2025, 7, 1), d(2026, 6, 30))
);
}
#[test]
fn reads_the_scaffolded_file() {
let s = parse(
"[company]\nname = \"Wanderland Labs LLC\"\nentity_type = \"x\"\nfiscal_year_start = \"01-01\"\n\n[books]\ndata_dir = \"books-data\"\nreceipt_threshold = \"25.00\"\n",
)
.unwrap();
assert_eq!(s.company.name, "Wanderland Labs LLC");
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
// An empty name (fresh scaffold) keeps the placeholder.
assert_eq!(
parse("[company]\nname = \"\"\n").unwrap().company.name,
"Unnamed company"
);
}
#[test]
fn reads_members_and_basis() {
let s = parse(
"[company]\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"0.5\"\ncapital_account = \"3001\"\n[books]\naccounting_basis = \"cash\"\n",
)
.unwrap();
let half = Decimal::new(5, 1);
assert_eq!(
s.company.members,
vec![
Member {
name: "Stephen Waits".into(),
share: half,
capital_account: "3000".into()
},
Member {
name: "Katherine Pendergast".into(),
share: half,
capital_account: "3001".into()
},
]
);
assert_eq!(s.company.basis, "Cash");
assert_eq!(parse("").unwrap().company.basis, "Accrual");
// Shares must add up; every member needs a capital account.
let two = |a: &str, b: &str| {
format!(
"[company]\n[[company.members]]\nname = \"A\"\nshare = \"{a}\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"{b}\"\ncapital_account = \"3001\"\n"
)
};
assert!(parse(&two("50%", "40%")).is_err());
assert!(parse(&two("60%", "40%")).is_ok());
assert!(parse("[company]\n[[company.members]]\nname = \"A\"\nshare = \"100%\"\n").is_err());
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
}
#[test]
fn rejects_bad_settings() {
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
assert!(parse("[company]\nfiscal_year_start = \"13-01\"\n").is_err());
assert!(parse("[company]\nfiscal_year_start = \"Jan 1\"\n").is_err());
assert!(parse("[books]\nreceipt_threshold = \"25 dollars\"\n").is_err());
}
}
+178
View File
@@ -0,0 +1,178 @@
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
//!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//!
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
//! and optionally `config.json`. If no store exists, one is created with the
//! configured base currency. Company settings (name, fiscal year start,
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
//! default the one next to the data dir.
mod company;
mod policy;
mod server;
mod types;
use ledger_core::Currency;
use ledger_store::Store;
use policy::ReceiptPolicy;
use rmcp::{ServiceExt, transport::stdio};
use serde::Deserialize;
use server::BooksServer;
use std::path::PathBuf;
/// Server configuration, JSON. All fields optional.
#[derive(Debug, Default, Deserialize)]
struct Config {
/// ISO 4217 base currency for a new store. Default "USD".
#[serde(default)]
base_currency: Option<String>,
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
/// with neither, receipts are required above $25.00.
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
}
fn default_data_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".local/share/wanderbooks")
}
fn print_usage() {
eprintln!(
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR]"
);
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
eprintln!(
" --books-toml company settings (default <data-dir>/../wanderbooks.toml, optional)"
);
eprintln!(
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut data_dir = default_data_dir();
let mut config_path: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut http_addr: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--data-dir" => {
data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?);
}
"--config" => {
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
}
"--books-toml" => {
books_toml = Some(PathBuf::from(
args.next().ok_or("--books-toml needs a path")?,
));
}
"--http" => {
http_addr = Some(args.next().ok_or("--http needs an address")?);
}
"--help" | "-h" => {
print_usage();
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
print_usage();
std::process::exit(2);
}
}
}
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
let config: Config = if config_path.exists() {
let raw = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&raw)
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?
} else {
Config::default()
};
// Logs go to stderr: stdout is the MCP channel.
eprintln!("ledger-mcp: data dir {}", data_dir.display());
// An explicit --books-toml must exist; the default is optional.
let settings = match books_toml {
Some(path) => company::load(&path)?,
None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) {
Some(path) if path.exists() => company::load(&path)?,
_ => company::BooksSettings::default(),
},
};
eprintln!("ledger-mcp: company {:?}", settings.company.name);
let policy = match (config.receipt_policy, settings.receipt_threshold) {
(Some(policy), _) => policy,
(None, Some(threshold)) => ReceiptPolicy {
require_receipts_above: threshold,
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
let company = settings.company;
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
Store::open(&data_dir)?
} else {
let code = config.base_currency.as_deref().unwrap_or("USD");
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
eprintln!("ledger-mcp: creating new store (base currency {code})");
Store::create(&data_dir, currency)?
};
// The journal is the source of truth: verify the chain and the
// projection before serving a single tool call.
store
.verify()
.map_err(|e| format!("store verify failed: {e}"))?;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
if let Some(addr) = http_addr {
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
rt.block_on(serve_http(addr, store, policy, company))?;
return Ok(());
}
eprintln!("ledger-mcp: store verified, serving over stdio");
let server = BooksServer::new(store, policy).with_company(company);
rt.block_on(async {
let running = server.serve(stdio()).await?;
let _ = running.waiting().await;
Ok::<(), Box<dyn std::error::Error>>(())
})
}
/// Serve MCP over Streamable HTTP (for network clients).
async fn serve_http(
addr: String,
store: Store,
policy: ReceiptPolicy,
company: company::Company,
) -> Result<(), Box<dyn std::error::Error>> {
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use std::sync::Arc;
let store = Arc::new(std::sync::Mutex::new(store));
let service = StreamableHttpService::new(
move || {
let server = server::BooksServer::new_shared(store.clone(), policy.clone())
.with_company(company.clone());
Ok(server)
},
Arc::new(LocalSessionManager::default()),
Default::default(),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind(&addr).await?;
eprintln!("ledger-mcp: listening on http://{addr}/mcp");
axum::serve(listener, router).await?;
Ok(())
}
+176
View File
@@ -0,0 +1,176 @@
//! The receipt policy: server-side enforcement, not prose.
//!
//! Posting an entry whose debits are strictly greater than the configured
//! threshold requires at least one attached receipt, unless every line is in
//! an exempt account. Selected accounts can require receipts regardless of
//! amount. Reversals are exempt: the original entry carries the evidence.
use ledger_core::{JournalEntry, Ledger, Side};
use rust_decimal::Decimal;
use serde::Deserialize;
/// Server-side receipt policy, loaded from config.
#[derive(Debug, Clone, Deserialize)]
pub struct ReceiptPolicy {
/// Receipts required when an entry's debits are strictly greater than
/// this amount. Decimal string, e.g. `"25.00"`.
#[serde(default = "default_threshold")]
pub require_receipts_above: String,
/// Account codes that always require a receipt, regardless of amount.
#[serde(default)]
pub receipt_required_accounts: Vec<String>,
/// Account codes exempt from the policy. The exemption applies only when
/// *every* line of the entry is in an exempt account.
#[serde(default)]
pub receipt_exempt_accounts: Vec<String>,
}
fn default_threshold() -> String {
"25.00".to_string()
}
impl Default for ReceiptPolicy {
fn default() -> Self {
Self {
require_receipts_above: default_threshold(),
receipt_required_accounts: Vec::new(),
receipt_exempt_accounts: Vec::new(),
}
}
}
impl ReceiptPolicy {
fn threshold(&self) -> Decimal {
self.require_receipts_above
.parse()
.unwrap_or(Decimal::from(25))
}
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
entry
.lines()
.iter()
.map(|l| {
ledger
.chart()
.get(l.account)
.map(|a| a.code.clone())
.unwrap_or_default()
})
.collect()
}
/// Total debits of the entry (one side of the balanced entry).
pub fn entry_debits(entry: &JournalEntry) -> Decimal {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum()
}
/// True when posting this entry requires at least one receipt.
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
let codes = self.account_codes(ledger, entry);
let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect();
self.receipt_required_for(&refs, Self::entry_debits(entry))
}
/// True when an entry touching these account codes with this debit total
/// requires at least one receipt. The code-level check behind
/// [`Self::receipt_required`], for postings (like disposals) computed
/// without a draft entry in hand.
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
// Per-account override: any line in a required account → required.
if codes
.iter()
.any(|c| self.receipt_required_accounts.iter().any(|r| r == *c))
{
return true;
}
// Strictly greater than the threshold (Stephen: ">$25").
if debits <= self.threshold() {
return false;
}
// Exempt only when every line is in an exempt account.
!codes
.iter()
.all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c))
}
/// Human-readable policy summary for previews and errors.
pub fn describe(&self) -> String {
format!(
"receipts required when entry debits are strictly greater than {}",
self.threshold()
)
}
/// Normalize a description for split detection.
fn normalize_description(desc: &str) -> String {
desc.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
/// Debit account ids of an entry, sorted.
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
let mut ids: Vec<u64> = entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.account)
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
/// Detect a potential threshold split: posted entries on the same date
/// with the same normalized description and debit accounts, whose
/// combined debits with this entry exceed the threshold while this
/// entry alone does not. Returns the matching posted entry ids.
///
/// This is the anti-gaming rule: splitting a $100 purchase into four
/// $25 entries does not dodge the receipt policy. Same-day,
/// same-description, same-accounts is the signature; recurring spending
/// spread across days is not flagged.
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
let debits = Self::entry_debits(entry);
if debits > self.threshold() {
return Vec::new(); // Normal policy already applies.
}
let desc = Self::normalize_description(entry.description());
let accounts = Self::debit_accounts(entry);
let date = entry.date();
let mut group = Vec::new();
let mut total = debits;
for other in ledger.posted_entries() {
if other.id() == entry_id {
continue;
}
if other.date() != date {
continue;
}
if Self::normalize_description(other.description()) != desc {
continue;
}
if Self::debit_accounts(other) != accounts {
continue;
}
// Reversals are exempt from the policy; don't count them.
if other.reversal_of().is_some() {
continue;
}
group.push(other.id());
total += Self::entry_debits(other);
}
if !group.is_empty() && total > self.threshold() {
group
} else {
Vec::new()
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "ledger-store"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Durable persistence for wanderbooks: a hash-chained JSONL journal as the source of truth, content-addressed blob store, and a disposable SQLite projection."
[dependencies]
ledger-core = { path = "../ledger-core" }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
rust_decimal = "1"
thiserror = "2"
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
tempfile = "3"
rust_decimal = "1"
[lints]
workspace = true
+78
View File
@@ -0,0 +1,78 @@
//! The blob store: content-addressed, immutable bytes.
//!
//! Files are keyed by the SHA-256 of their exact bytes and laid out sharded
//! by hash prefix: `<root>/<aa>/<bb>/<full-hex>`. Reads re-hash and compare,
//! so corruption is detected, never served. Writes are atomic (temp file +
//! rename) and fsync'd. Blobs are never modified or deleted by the
//! application.
use crate::error::StoreError;
use crate::journal::sha256_hex;
use std::fs;
use std::path::{Path, PathBuf};
/// Content-addressed file store rooted at `root`.
pub struct BlobStore {
root: PathBuf,
}
impl BlobStore {
/// Open (creating) the blob store at `root`.
pub fn open(root: &Path) -> Result<Self, StoreError> {
fs::create_dir_all(root)?;
Ok(Self {
root: root.to_path_buf(),
})
}
fn path_for(&self, sha_hex: &str) -> PathBuf {
self.root
.join(&sha_hex[0..2])
.join(&sha_hex[2..4])
.join(sha_hex)
}
/// Store bytes. Returns `(sha256_hex, deduplicated)`. Identical bytes
/// store once: the second put is a no-op that reports `deduplicated`.
pub fn put(&self, bytes: &[u8]) -> Result<(String, bool), StoreError> {
let sha = sha256_hex(bytes);
let path = self.path_for(&sha);
if path.exists() {
return Ok((sha, true));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
// Atomic write: temp file in the same directory, fsync, rename.
let tmp = path.with_extension("tmp");
{
let mut f = fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, &path)?;
// Fsync the directory so the rename itself is durable.
let dir = fs::File::open(path.parent().unwrap())?;
dir.sync_all()?;
Ok((sha, false))
}
/// Read bytes, verifying the SHA-256. A mismatch is corruption, and it
/// is reported — never served.
pub fn get(&self, sha_hex: &str) -> Result<Vec<u8>, StoreError> {
let sha_hex = sha_hex.to_lowercase();
let bytes = fs::read(self.path_for(&sha_hex))?;
let actual = sha256_hex(&bytes);
if actual != sha_hex {
return Err(StoreError::CorruptBlob(sha_hex));
}
Ok(bytes)
}
/// True when the blob exists (content not verified; use [`Self::get`]
/// for a verifying read).
pub fn exists(&self, sha_hex: &str) -> bool {
self.path_for(&sha_hex.to_lowercase()).exists()
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Error type for the store.
use thiserror::Error;
/// Every way a store operation can fail.
#[derive(Debug, Error)]
pub enum StoreError {
/// Filesystem error.
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// SQLite error.
#[error("sqlite error: {0}")]
Rusqlite(#[from] rusqlite::Error),
/// JSON error.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// The engine rejected the operation. The journal is untouched.
#[error("ledger error: {0}")]
Ledger(#[from] ledger_core::LedgerError),
/// The journal failed integrity verification.
#[error("journal corrupt: {0}")]
JournalCorrupt(String),
/// Replay produced different ids than the journal recorded.
#[error("replay mismatch: {0}")]
ReplayMismatch(String),
/// [`crate::Store::verify`] found divergence.
#[error("verify failed: {0}")]
VerifyMismatch(String),
/// A blob's bytes did not match its SHA-256.
#[error("blob corrupt: hash mismatch for {0}")]
CorruptBlob(String),
/// Referenced attachment does not exist.
#[error("unknown attachment {0}")]
UnknownAttachment(u64),
/// The attachment is not linked to any entry.
#[error("attachment {0} is not linked to an entry")]
AttachmentNotLinked(u64),
/// The attachment is linked to an entry; unlink it first.
#[error("attachment {0} is linked to entry {1}; unlink it first with books.unlink_receipt")]
AttachmentLinked(u64, u64),
/// The attachment was withdrawn and can no longer be linked or changed.
#[error("attachment {0} is withdrawn")]
AttachmentWithdrawn(u64),
/// A correction to the evidence trail needs a reason.
#[error("a non-empty reason is required")]
ReasonRequired,
/// The store's schema is newer than this binary understands.
#[error("schema version {0} newer than supported {1}")]
SchemaTooNew(u32, u32),
/// `create` called where a store already exists.
#[error("store already exists at {0}")]
AlreadyExists(String),
/// `open` called where no store exists.
#[error("store not found at {0}")]
NotFound(String),
/// The requested base currency differs from the store's.
#[error("base currency mismatch: store is {0}, requested {1}")]
CurrencyMismatch(String, String),
}
+220
View File
@@ -0,0 +1,220 @@
//! The journal: the source of truth.
//!
//! An append-only file of JSON objects, one per line (JSONL), hash-chained:
//! each line carries the SHA-256 of the previous line's canonical bytes.
//! Bit rot, torn writes, and casual editing are detected on read — never
//! silently absorbed.
//!
//! Canonical form is keys-sorted, whitespace-free JSON. The writer always
//! emits canonical form, so parse → re-serialize round-trips byte-identically
//! and the chain verifies.
use crate::error::StoreError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
/// `prev` value of the first journal line: 64 zeros.
pub const GENESIS_PREV: &str = "0000000000000000000000000000000000000000000000000000000000000000";
/// One journal line: a sequenced, timestamped, hash-chained domain event.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JournalLine {
/// 1-based, gapless sequence number.
pub seq: u64,
/// UTC timestamp, RFC 3339. Recorded, never trusted for ordering.
pub ts: String,
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
/// `entry.posted`, `entry.reversed`, `entry.line_class_set`,
/// `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
/// `document.filed`, `attachment.withdrawn`,
/// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`,
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
/// `vendor.added`, `invoice.created`, `bill.created`,
/// `invoice.payment_received`, `bill.paid`, `fx.rate_set`,
/// `payroll.run`, `class.added`, `store.created`.
#[serde(rename = "type")]
pub line_type: String,
/// The full domain facts. Everything a replay needs.
pub payload: Value,
/// Hex SHA-256 of the previous line's canonical bytes.
pub prev: String,
}
impl JournalLine {
/// Canonical bytes of this line: keys sorted recursively, no whitespace.
pub fn to_canonical(&self) -> String {
canonical_json(&serde_json::to_value(self).expect("JournalLine serializes"))
}
}
/// Recursively key-sorted, whitespace-free JSON.
pub fn canonical_json(value: &Value) -> String {
serde_json::to_string(&sort_value(value)).expect("JSON serialization cannot fail")
}
fn sort_value(v: &Value) -> Value {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut sorted = serde_json::Map::with_capacity(map.len());
for k in keys {
sorted.insert(k.clone(), sort_value(&map[k]));
}
Value::Object(sorted)
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_value).collect()),
_ => v.clone(),
}
}
/// Hex SHA-256 of bytes.
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
hex::encode(h.finalize())
}
/// Append-only writer. Assigns `seq`, stamps `ts`, chains `prev`, fsyncs
/// every append. The sequence only advances after a successful fsync, so a
/// torn tail can never exist in a file written by this writer.
pub struct JournalWriter {
path: PathBuf,
file: File,
next_seq: u64,
prev_hash: String,
}
impl JournalWriter {
/// Open an existing journal (verifying its chain) or create a new one.
pub fn open(path: &Path) -> Result<Self, StoreError> {
let lines = if path.exists() {
read_and_verify_journal(path)?
} else {
Vec::new()
};
Self::from_lines(path, &lines)
}
/// Build a writer from already-verified lines (avoids re-scanning).
pub(crate) fn from_lines(path: &Path, lines: &[JournalLine]) -> Result<Self, StoreError> {
let (next_seq, prev_hash) = match lines.last() {
Some(last) => (last.seq + 1, sha256_hex(last.to_canonical().as_bytes())),
None => (1, GENESIS_PREV.to_string()),
};
if !path.exists()
&& let Some(parent) = path.parent()
{
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(Self {
path: path.to_path_buf(),
file,
next_seq,
prev_hash,
})
}
/// Append one event. Returns the journaled line.
pub fn append(&mut self, line_type: &str, payload: Value) -> Result<JournalLine, StoreError> {
let line = JournalLine {
seq: self.next_seq,
ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
line_type: line_type.to_string(),
payload,
prev: self.prev_hash.clone(),
};
let bytes = line.to_canonical();
self.file.write_all(bytes.as_bytes())?;
self.file.write_all(b"\n")?;
self.file.sync_all()?;
self.prev_hash = sha256_hex(bytes.as_bytes());
self.next_seq += 1;
Ok(line)
}
/// The seq the next append will use.
pub fn next_seq(&self) -> u64 {
self.next_seq
}
/// Path of the journal file.
pub fn path(&self) -> &Path {
&self.path
}
}
/// Read and fully verify a journal: every line parses, seqs are gapless from
/// 1, and the hash chain is intact. Any failure names the offending seq.
pub fn read_and_verify_journal(path: &Path) -> Result<Vec<JournalLine>, StoreError> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut lines = Vec::new();
let mut expected_prev = GENESIS_PREV.to_string();
for (i, raw) in reader.lines().enumerate() {
let raw = raw?;
let line: JournalLine = serde_json::from_str(&raw).map_err(|e| {
StoreError::JournalCorrupt(format!("seq {}: unparseable line: {e}", i + 1))
})?;
let want_seq = i as u64 + 1;
if line.seq != want_seq {
return Err(StoreError::JournalCorrupt(format!(
"seq gap: expected {want_seq}, found {}",
line.seq
)));
}
if line.prev != expected_prev {
return Err(StoreError::JournalCorrupt(format!(
"hash chain broken at seq {}",
line.seq
)));
}
expected_prev = sha256_hex(line.to_canonical().as_bytes());
lines.push(line);
}
Ok(lines)
}
/// Truncate a journal file to its last good line. For recovering *copies*
/// with a torn tail (e.g. a backup taken mid-write): the projection never
/// saw the torn line, so dropping it is safe. Returns the last good seq.
/// The live journal written by [`JournalWriter`] cannot have a torn tail.
pub fn truncate_to_last_good_seq(path: &Path) -> Result<u64, StoreError> {
let raw = std::fs::read(path)?;
let mut good_len = 0usize;
let mut last_good_seq = 0u64;
let mut expected_prev = GENESIS_PREV.to_string();
// Walk raw bytes: a line is bytes up to and including b'\n'. Trailing
// bytes without a newline are a torn tail and are dropped.
let mut start = 0usize;
for (i, &b) in raw.iter().enumerate() {
if b != b'\n' {
continue;
}
let line_bytes = &raw[start..i];
start = i + 1;
let line_str = std::str::from_utf8(line_bytes).map_err(|_| {
StoreError::JournalCorrupt(format!("seq {}: invalid utf-8", last_good_seq + 1))
})?;
if line_str.trim().is_empty() {
break; // blank line: our writer never emits these
}
match serde_json::from_str::<JournalLine>(line_str) {
Ok(line) if line.seq == last_good_seq + 1 && line.prev == expected_prev => {
expected_prev = sha256_hex(line.to_canonical().as_bytes());
last_good_seq = line.seq;
good_len = i + 1; // include the newline
}
_ => break,
}
}
let file = OpenOptions::new().write(true).open(path)?;
file.set_len(good_len as u64)?;
file.sync_all()?;
Ok(last_good_seq)
}
+33
View File
@@ -0,0 +1,33 @@
//! `ledger-store`: durable persistence for the books.
//!
//! Three pieces, one job — keep the books safe:
//!
//! * [`journal`] — the source of truth. An append-only, hash-chained JSONL
//! journal. Every state change is an immutable fact, fsync'd on append.
//! * [`blob`] — the bytes. Content-addressed (SHA-256), immutable,
//! deduplicated receipt and document storage.
//! * [`Store`] — the SQLite projection plus the write path. SQLite is the
//! query engine and is disposable by design: [`Store::rebuild`] drops it
//! and replays the journal. [`Store::verify`] replays the journal into a
//! fresh engine and diffs its reports against both the live ledger and
//! the projection.
//!
//! The write path is singular: **validate → journal → mutate → apply**.
//! Journal first means a failed write leaves nothing behind; a failed
//! SQLite apply leaves the journal ahead, and the next open heals it via
//! `last_applied_seq`. There is exactly one writer: `Store` is `!Sync` by
//! construction (it owns a `rusqlite::Connection`).
pub mod blob;
pub mod error;
pub mod journal;
pub mod schema;
pub mod store;
pub use blob::BlobStore;
pub use error::StoreError;
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
pub use store::{
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
PayrollLineInput, Store,
};
+183
View File
@@ -0,0 +1,183 @@
//! SQLite schema and migrations.
//!
//! The projection schema. Amounts are TEXT (decimal strings) — never REAL.
//! Migrations are numbered SQL scripts applied in order; `SCHEMA_VERSION`
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 5;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
r#"
CREATE TABLE IF NOT EXISTS meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
parent INTEGER NULL,
placeholder INTEGER NOT NULL,
currency TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entries(
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL,
reversal_of INTEGER NULL
);
CREATE TABLE IF NOT EXISTS lines(
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
side TEXT NOT NULL,
amount TEXT NOT NULL,
currency TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS blobs(
sha256 TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
mime_type TEXT NOT NULL,
original_name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS attachments(
id INTEGER PRIMARY KEY,
blob_sha TEXT NOT NULL,
entry_id INTEGER NULL,
category TEXT NOT NULL,
description TEXT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS events(
seq INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_lines_entry ON lines(entry_id);
CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date);
CREATE INDEX IF NOT EXISTS idx_attachments_entry ON attachments(entry_id);
"#,
r#"
-- v2: fixed-asset register and depreciation runs.
CREATE TABLE IF NOT EXISTS assets(
id INTEGER PRIMARY KEY,
description TEXT NOT NULL,
purchase_date TEXT NOT NULL,
cost_amount TEXT NOT NULL,
cost_currency TEXT NOT NULL,
salvage_amount TEXT NOT NULL,
salvage_currency TEXT NOT NULL,
useful_life_months INTEGER NOT NULL,
method TEXT NOT NULL,
cost_account INTEGER NOT NULL,
accum_depr_account INTEGER NOT NULL,
expense_account INTEGER NOT NULL,
status TEXT NOT NULL,
disposed_date TEXT NULL,
proceeds_amount TEXT NULL
);
CREATE TABLE IF NOT EXISTS asset_depreciation(
asset_id INTEGER NOT NULL,
period TEXT NOT NULL,
entry_id INTEGER NOT NULL,
amount TEXT NOT NULL,
PRIMARY KEY (asset_id, period)
);
"#,
r#"
-- v3: accounting periods, A/R + A/P, cleared-line flags, FX rates, and
-- foreign-currency columns on lines. Amounts are TEXT, never REAL.
CREATE TABLE IF NOT EXISTS periods(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
status TEXT NOT NULL,
closing_entry INTEGER NULL
);
CREATE TABLE IF NOT EXISTS customers(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS vendors(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invoices(
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
date TEXT NOT NULL,
due_date TEXT NOT NULL,
lines_json TEXT NOT NULL,
amount_paid TEXT NOT NULL,
status TEXT NOT NULL,
entry_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS bills(
id INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL,
date TEXT NOT NULL,
due_date TEXT NOT NULL,
lines_json TEXT NOT NULL,
amount_paid TEXT NOT NULL,
status TEXT NOT NULL,
entry_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cleared_lines(
entry_id INTEGER NOT NULL,
line_index INTEGER NOT NULL,
PRIMARY KEY (entry_id, line_index)
);
CREATE TABLE IF NOT EXISTS fx_rates(
currency TEXT NOT NULL,
date TEXT NOT NULL,
rate TEXT NOT NULL,
PRIMARY KEY (currency, date)
);
CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id);
CREATE INDEX IF NOT EXISTS idx_bills_vendor ON bills(vendor_id);
ALTER TABLE lines ADD COLUMN foreign_currency TEXT;
ALTER TABLE lines ADD COLUMN foreign_amount TEXT;
"#,
r#"
-- v4: classes, payroll runs, and class_id on lines. Amounts are TEXT,
-- never REAL.
CREATE TABLE IF NOT EXISTS classes(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS payroll_runs(
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
entry_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS payroll_lines(
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
employee_name TEXT NOT NULL,
gross_pay TEXT NOT NULL,
federal_withholding TEXT NOT NULL,
state_withholding TEXT NOT NULL,
ss_employee TEXT NOT NULL,
medicare_employee TEXT NOT NULL,
ss_employer TEXT NOT NULL,
medicare_employer TEXT NOT NULL,
other_deductions TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
ALTER TABLE lines ADD COLUMN class_id INTEGER;
"#,
r#"
-- v5: withdrawn attachments. Withdrawal retires a receipt or document from
-- listings and linking; the bytes stay in the blob store and the journal
-- keeps the filing and the withdrawal.
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
"#,
];
File diff suppressed because it is too large. Load diff
+235
View File
@@ -0,0 +1,235 @@
//! Fixed-asset store tests: journaling, idempotency, disposal, and verify.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, NewAsset, Side};
use ledger_store::Store;
use rust_decimal::Decimal;
use tempfile::tempdir;
fn usd(amount: &str) -> Money {
Money::new(amount.parse().unwrap(), Currency::USD)
}
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn open_store() -> (tempfile::TempDir, Store) {
let dir = tempdir().unwrap();
let store = Store::create(dir.path(), Currency::USD).unwrap();
(dir, store)
}
fn asset_accounts(store: &mut Store) -> (u64, u64, u64, u64, u64) {
let cost = store
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let accum = store
.add_account(
"1501",
"Accumulated Depreciation",
AccountType::Asset,
None,
false,
)
.unwrap();
let expense = store
.add_account(
"6100",
"Depreciation Expense",
AccountType::Expense,
None,
false,
)
.unwrap();
let bank = store
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let gain = store
.add_account(
"4900",
"Gain on Disposal",
AccountType::Revenue,
None,
false,
)
.unwrap();
(cost, accum, expense, bank, gain)
}
fn van(store: &mut Store, dollars: u64, cost_acct: u64, accum_acct: u64, expense_acct: u64) -> u64 {
store
.register_asset(NewAsset {
description: "Sprinter van".to_string(),
purchase_date: date(2026, 1, 15),
cost: usd(&dollars.to_string()),
salvage_value: usd("0"),
useful_life_months: 60,
cost_account: cost_acct,
accum_depr_account: accum_acct,
expense_account: expense_acct,
})
.unwrap()
}
#[test]
fn asset_survives_reopen_with_stable_ids() {
let (dir, mut store) = open_store();
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
let id = van(&mut store, 60_000, cost, accum, expense);
drop(store);
let mut store = Store::open(dir.path()).unwrap();
let asset = store.ledger().get_asset(id).expect("asset must survive");
assert_eq!(asset.description, "Sprinter van");
assert_eq!(asset.cost.amount, Decimal::from(60_000));
// Allocator stability: the next registration continues the sequence.
let id2 = van(&mut store, 1_000, cost, accum, expense);
assert_eq!(id2, id + 1);
store.verify().unwrap();
}
#[test]
fn depreciation_run_is_idempotent() {
let (_dir, mut store) = open_store();
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
let id = van(&mut store, 60_000, cost, accum, expense);
let run1 = store.run_depreciation(id, "2026-01").unwrap();
assert!(!run1.already_posted);
assert_eq!(run1.amount, Decimal::new(54839, 2)); // 1000 * 17/31
// Second run of the same period is a no-op returning the same entry.
let run2 = store.run_depreciation(id, "2026-01").unwrap();
assert!(run2.already_posted);
assert_eq!(run2.entry_id, run1.entry_id);
assert_eq!(run2.amount, run1.amount);
// A later period posts its own entry.
let run3 = store.run_depreciation(id, "2026-02").unwrap();
assert!(!run3.already_posted);
assert_eq!(run3.amount, Decimal::from(1_000));
// The depreciation entries hit the right accounts.
let entry = store.ledger().get_entry(run3.entry_id).unwrap();
assert!(entry.lines().iter().any(|l| l.account == expense
&& l.side == Side::Debit
&& l.amount.amount == Decimal::from(1_000)));
store.verify().unwrap();
}
#[test]
fn depreciation_rejects_period_outside_life() {
let (_dir, mut store) = open_store();
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
let id = van(&mut store, 60_000, cost, accum, expense);
// Before purchase.
assert!(store.run_depreciation(id, "2025-12").is_err());
// After the 60-month life ends (2031-01).
assert!(store.run_depreciation(id, "2031-06").is_err());
// Malformed period.
assert!(store.run_depreciation(id, "2026-13").is_err());
assert!(store.run_depreciation(id, "january").is_err());
// Unknown asset.
assert!(store.run_depreciation(999, "2026-01").is_err());
}
#[test]
fn disposal_posts_balanced_entry_and_marks_asset() {
let (_dir, mut store) = open_store();
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
let id = van(&mut store, 60_000, cost, accum, expense);
store.run_depreciation(id, "2026-01").unwrap();
store.run_depreciation(id, "2026-02").unwrap();
// Book value after two months: 60000 − (548.39 + 1000) = 58451.61.
// Sell for 60,000 → gain of 1548.39.
let entry_id = store
.dispose_asset(id, date(2026, 2, 28), usd("60000"), bank, gain)
.unwrap();
let entry = store.ledger().get_entry(entry_id).unwrap();
let debits: Decimal = entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
let credits: Decimal = entry
.lines()
.iter()
.filter(|l| l.side == Side::Credit)
.map(|l| l.amount.amount)
.sum();
assert_eq!(debits, credits, "disposal entry must balance");
assert_eq!(debits, Decimal::new(154839, 2) + Decimal::from(60_000));
// Asset is marked disposed; further depreciation is refused.
assert!(matches!(
store.ledger().get_asset(id).unwrap().status,
ledger_core::AssetStatus::Disposed { .. }
));
assert!(store.run_depreciation(id, "2026-03").is_err());
assert!(
store
.dispose_asset(id, date(2026, 3, 1), usd("1"), bank, gain)
.is_err()
);
store.verify().unwrap();
}
#[test]
fn disposal_at_loss_uses_debit_side() {
let (_dir, mut store) = open_store();
let (cost, accum, expense, bank, _) = asset_accounts(&mut store);
let loss = store
.add_account(
"6900",
"Loss on Disposal",
AccountType::Expense,
None,
false,
)
.unwrap();
let id = van(&mut store, 60_000, cost, accum, expense);
// Dispose 2026-01-31 with no prior depreciation runs: the final-period
// depreciation (Jan, 548.39) is auto-posted first. Book value 59451.61,
// sell for 50,000 → loss 9451.61.
let entry_id = store
.dispose_asset(id, date(2026, 1, 31), usd("50000"), bank, loss)
.unwrap();
let entry = store.ledger().get_entry(entry_id).unwrap();
let loss_line = entry.lines().iter().find(|l| l.account == loss).unwrap();
assert_eq!(loss_line.side, Side::Debit);
assert_eq!(loss_line.amount.amount, Decimal::new(945161, 2));
// The auto-posted depreciation entry exists for the disposal month.
let posted = store
.ledger()
.posted_entries()
.filter(|e| e.description().contains("(final)"))
.count();
assert_eq!(posted, 1);
store.verify().unwrap();
}
#[test]
fn rejected_asset_registration_journals_nothing() {
let (_dir, mut store) = open_store();
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
let bad = NewAsset {
description: "Bad".to_string(),
purchase_date: date(2026, 1, 1),
cost: usd("1000"),
salvage_value: usd("1000"), // salvage >= cost: invalid
useful_life_months: 12,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
};
assert!(store.register_asset(bad).is_err());
// Journal holds only the setup lines; no asset.registered.
let events = store.events_since(0, 100).unwrap();
assert!(events.iter().all(|e| e.event_type != "asset.registered"));
// And the allocator did not advance.
let id = van(&mut store, 1_000, cost, accum, expense);
assert_eq!(id, 1);
}
+528
View File
@@ -0,0 +1,528 @@
//! Store tests: the durability story, proven.
//!
//! Round-trip persistence, tamper detection, rebuild-from-journal healing,
//! blob deduplication and hash verification.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JournalWriter, Store, StoreError};
use rust_decimal::Decimal;
use std::path::PathBuf;
fn usd(amount: u64) -> Money {
Money::new(Decimal::from(amount), Currency::USD)
}
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wanderbooks-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn build_books(dir: &std::path::Path) -> Store {
let mut store = Store::create(dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
.unwrap();
let revenue = store
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
.unwrap();
let rent = store
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
.unwrap();
let e1 = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(),
"Owner investment",
)
.unwrap();
store.add_line(e1, cash, Side::Debit, usd(10_000)).unwrap();
store
.add_line(e1, equity, Side::Credit, usd(10_000))
.unwrap();
store.post_entry(e1).unwrap();
let e2 = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(),
"Invoice client",
)
.unwrap();
store.add_line(e2, cash, Side::Debit, usd(4_000)).unwrap();
store
.add_line(e2, revenue, Side::Credit, usd(4_000))
.unwrap();
store.post_entry(e2).unwrap();
let e3 = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
"January rent",
)
.unwrap();
store.add_line(e3, rent, Side::Debit, usd(800)).unwrap();
store.add_line(e3, cash, Side::Credit, usd(800)).unwrap();
store.post_entry(e3).unwrap();
// A receipt attached to the rent entry.
let (_rid, sha, dedup) = store
.attach_receipt(
Some(e3),
"rent-receipt.png",
"image/png",
b"fake-png-bytes",
Some("January rent receipt"),
)
.unwrap();
assert!(!dedup);
assert_eq!(sha.len(), 64);
// A reversal, to exercise the reversal path through the journal.
store
.reverse_entry(
e3,
NaiveDate::from_ymd_opt(2026, 1, 16).unwrap(),
"Reverse rent",
)
.unwrap();
store
}
#[test]
fn round_trip_survives_reopen() {
let dir = test_dir("roundtrip");
let store = build_books(&dir);
// Live ledger: rent was posted then reversed → net zero movement on rent.
assert_eq!(
store
.ledger()
.account_movement(store.ledger().chart().find_by_code("6000").unwrap().id),
Decimal::from(0)
);
drop(store);
let store = Store::open(&dir).unwrap();
// Same facts after reopen: cash = 10000 + 4000 = 14000.
let cash_id = store.ledger().chart().find_by_code("1000").unwrap().id;
assert_eq!(
store.ledger().account_movement(cash_id),
Decimal::from(14_000)
);
// Trial balance still balances.
let tb = ledger_core::reports::trial_balance(store.ledger());
assert!(tb.balanced());
assert_eq!(tb.total_debits, Decimal::from(14_000));
// Receipt survived with its bytes.
let receipts = store.list_attachments(None).unwrap();
assert_eq!(receipts.len(), 1);
assert_eq!(receipts[0].file_name, "rent-receipt.png");
assert_eq!(
store.attachment_bytes(receipts[0].id).unwrap(),
b"fake-png-bytes"
);
// Full verification passes.
store.verify().unwrap();
assert_eq!(store.verify_blobs().unwrap(), 1);
}
#[test]
fn journal_tamper_is_detected() {
let dir = test_dir("tamper");
let store = build_books(&dir);
drop(store);
// Flip a byte in the middle of the journal.
let journal_path = dir.join(ledger_store::JOURNAL_FILE);
let mut bytes = std::fs::read(&journal_path).unwrap();
let mid = bytes.len() / 2;
bytes[mid] = if bytes[mid] == b'X' { b'Y' } else { b'X' };
std::fs::write(&journal_path, &bytes).unwrap();
let err = match Store::open(&dir) {
Ok(_) => panic!("expected the tampered journal to be rejected"),
Err(e) => e,
};
assert!(
format!("{err:?}").contains("JournalCorrupt"),
"expected JournalCorrupt, got {err:?}"
);
}
#[test]
fn torn_tail_is_truncatable() {
let dir = test_dir("torntail");
let store = build_books(&dir);
let seq_before = {
let w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.next_seq() - 1
};
drop(store);
// Simulate a torn backup: append half a line.
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(dir.join(ledger_store::JOURNAL_FILE))
.unwrap();
f.write_all(b"{\"seq\": 999, \"trunca").unwrap();
f.sync_all().unwrap();
drop(f);
// Strict open now fails...
assert!(Store::open(&dir).is_err());
// ...but truncation recovers to the last good seq.
let last_good =
ledger_store::journal::truncate_to_last_good_seq(&dir.join(ledger_store::JOURNAL_FILE))
.unwrap();
assert_eq!(last_good, seq_before);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
}
#[test]
fn rebuild_heals_a_lost_database() {
let dir = test_dir("rebuild");
let mut store = build_books(&dir);
let tb_before = ledger_core::reports::trial_balance(store.ledger());
// Simulate total SQLite loss.
store.rebuild().unwrap();
let tb_after = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb_before.total_debits, tb_after.total_debits);
assert_eq!(tb_before.total_credits, tb_after.total_credits);
store.verify().unwrap();
// Attachments survived (metadata re-applied from the journal).
assert_eq!(store.list_attachments(None).unwrap().len(), 1);
}
#[test]
fn missing_database_heals_on_open() {
let dir = test_dir("healopen");
let store = build_books(&dir);
drop(store);
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
// No database at all: open rebuilds the projection from the journal.
let store = Store::open(&dir).unwrap();
let tb = ledger_core::reports::trial_balance(store.ledger());
assert!(tb.balanced());
store.verify().unwrap();
}
#[test]
fn blob_dedup_and_hash_verification() {
let dir = test_dir("blobs");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let bytes = b"same bytes twice";
let (_, sha1, dedup1) = store
.file_document("tax", "w2.pdf", "application/pdf", bytes, None)
.unwrap();
let (_, sha2, dedup2) = store
.file_document("tax", "w2-copy.pdf", "application/pdf", bytes, None)
.unwrap();
assert!(!dedup1);
assert!(dedup2);
assert_eq!(sha1, sha2);
// Only one blob row; two attachment rows.
assert_eq!(store.verify_blobs().unwrap(), 1);
assert_eq!(store.list_attachments(None).unwrap().len(), 2);
// Corrupt the blob file on disk → the next read detects it.
let shard = dir
.join("blobs")
.join(&sha1[0..2])
.join(&sha1[2..4])
.join(&sha1);
let mut raw = std::fs::read(&shard).unwrap();
raw[0] ^= 0xff;
std::fs::write(&shard, &raw).unwrap();
let err = match store.verify_blobs() {
Ok(_) => panic!("expected the corrupted blob to be detected"),
Err(e) => e,
};
assert!(format!("{err:?}").contains("CorruptBlob"));
}
#[test]
fn unbalanced_entry_never_reaches_the_journal() {
let dir = test_dir("unbalanced");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
.unwrap();
let seq_before = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE))
.unwrap()
.next_seq();
let e = store
.create_entry(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), "Bad")
.unwrap();
store.add_line(e, cash, Side::Debit, usd(100)).unwrap();
store.add_line(e, equity, Side::Credit, usd(99)).unwrap();
assert!(store.post_entry(e).is_err());
// The failed post journaled nothing new: the draft creation and its
// two lines were already journaled (durable drafts); entry.posted was not.
let seq_after = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE))
.unwrap()
.next_seq();
assert_eq!(seq_after, seq_before + 3);
// And a fresh open sees a clean, balanced book.
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
}
#[test]
fn draft_lines_survive_reopen() {
// Drafts are durable: build a draft, close the store, reopen, and the
// draft comes back with its lines intact.
let dir = test_dir("draft-durable");
let (cash, revenue);
let draft_id;
{
let mut store = build_books(&dir);
cash = store.ledger().chart().find_by_code("1000").unwrap().id;
revenue = store.ledger().chart().find_by_code("4000").unwrap().id;
draft_id = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
"unposted sale",
)
.unwrap();
store
.add_line(draft_id, cash, Side::Debit, usd(250))
.unwrap();
store
.add_line(draft_id, revenue, Side::Credit, usd(250))
.unwrap();
// Draft is not posted: trial balance only sees the posted entry.
let tb = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb.total_debits, Decimal::from(14000));
}
// Reopen: the draft and its lines are back.
let mut store = Store::open(&dir).unwrap();
let draft = store.ledger().get_entry(draft_id).unwrap();
assert_eq!(draft.lines().len(), 2);
assert_eq!(draft.status(), ledger_core::EntryStatus::Draft);
// Post it after reopen: works, and the books balance.
store.post_entry(draft_id).unwrap();
let tb = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb.total_debits, Decimal::from(14250));
assert_eq!(tb.total_credits, Decimal::from(14250));
store.verify().unwrap();
}
#[test]
fn corrupt_projection_is_quarantined_and_rebuilt() {
let dir = test_dir("corrupt-projection");
{
let _store = build_books(&dir);
}
// Corrupt the SQLite file with garbage.
let db_path = dir.join(ledger_store::DB_FILE);
std::fs::write(&db_path, b"this is not a sqlite database at all").unwrap();
// Reopen heals from the journal instead of failing.
let store = Store::open(&dir).unwrap();
// The damaged file was quarantined for forensics.
let quarantined: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("wanderbooks.corrupt.")
})
.collect();
assert_eq!(quarantined.len(), 1);
// The books are intact and verify clean.
let tb = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb.total_debits, Decimal::from(14000));
store.verify().unwrap();
}
#[test]
fn receipt_corrections_are_journaled_and_survive_rebuild() {
let dir = test_dir("receipt-corrections");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let rent = store
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
.unwrap();
let e = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
"February rent",
)
.unwrap();
store.add_line(e, rent, Side::Debit, usd(800)).unwrap();
store.add_line(e, cash, Side::Credit, usd(800)).unwrap();
store.post_entry(e).unwrap();
let (eml, _, _) = store
.attach_receipt(Some(e), "rent.eml", "message/rfc822", b"eml-bytes", None)
.unwrap();
let (pdf, _, _) = store
.attach_receipt(Some(e), "rent.pdf", "application/pdf", b"pdf-bytes", None)
.unwrap();
// Every correction needs a reason.
assert!(matches!(
store.unlink_receipt(eml, " "),
Err(StoreError::ReasonRequired)
));
// A linked receipt can't be withdrawn or moved: unlink it first.
assert!(matches!(
store.withdraw_attachment(eml, "dup"),
Err(StoreError::AttachmentLinked(a, b)) if a == eml && b == e
));
assert!(matches!(
store.link_receipt(pdf, e),
Err(StoreError::AttachmentLinked(_, _))
));
// Swap the .eml for the PDF: unlink, then withdraw.
assert_eq!(
store
.unlink_receipt(eml, "replaced by the PDF render")
.unwrap(),
e
);
assert!(matches!(
store.unlink_receipt(eml, "again"),
Err(StoreError::AttachmentNotLinked(_))
));
store
.withdraw_attachment(eml, "file PDFs, not .eml")
.unwrap();
assert!(matches!(
store.link_receipt(eml, e),
Err(StoreError::AttachmentWithdrawn(_))
));
assert!(matches!(
store.withdraw_attachment(eml, "again"),
Err(StoreError::AttachmentWithdrawn(_))
));
assert!(matches!(
store.unlink_receipt(999, "x"),
Err(StoreError::UnknownAttachment(999))
));
let check = |store: &Store| {
let linked: Vec<u64> = store
.list_attachments(Some(e))
.unwrap()
.iter()
.map(|a| a.id)
.collect();
assert_eq!(linked, vec![pdf]);
let all = store.list_attachments(None).unwrap();
let gone = all.iter().find(|a| a.id == eml).unwrap();
assert_eq!(gone.entry_id, None);
assert!(gone.withdrawn_at.is_some());
assert_eq!(
gone.withdrawn_reason.as_deref(),
Some("file PDFs, not .eml")
);
// Withdrawn is not deleted: the bytes are still there, hash-verified.
assert_eq!(store.attachment_bytes(eml).unwrap(), b"eml-bytes");
};
check(&store);
// The journal is the history: the attach, the unlink, and the withdrawal.
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
for t in [
"receipt.attached",
"receipt.unlinked",
"attachment.withdrawn",
] {
assert!(
journal.contains(&format!("\"type\":\"{t}\"")),
"missing {t}"
);
}
drop(store);
let mut store = Store::open(&dir).unwrap();
check(&store);
store.rebuild().unwrap();
check(&store);
store.verify().unwrap();
}
#[test]
fn a_close_into_members_capital_replays_exactly() {
let dir = test_dir("members-close");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let alice = store
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
.unwrap();
let bob = store
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
.unwrap();
let revenue = store
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let jan15 = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
let e = store.create_entry(jan15, "Sale").unwrap();
store
.add_line(
e,
cash,
Side::Debit,
Money::new(Decimal::new(10001, 2), Currency::USD),
)
.unwrap();
store
.add_line(
e,
revenue,
Side::Credit,
Money::new(Decimal::new(10001, 2), Currency::USD),
)
.unwrap();
store.post_entry(e).unwrap();
let jan = store
.create_period(
"2026-01",
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
)
.unwrap();
let half = Decimal::new(5, 1);
store
.close_period_to(
jan,
&ledger_core::CloseTo::Members(vec![(alice, half), (bob, half)]),
)
.unwrap();
// 100.01 splits 50.01 / 50.00 into the capital accounts.
let balances = |s: &Store| {
(
s.ledger().account_balance(alice).unwrap(),
s.ledger().account_balance(bob).unwrap(),
)
};
let want = (Decimal::new(5001, 2), Decimal::new(5000, 2));
assert_eq!(balances(&store), want);
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
assert!(journal.contains("\"close_to\":{\"members\""));
drop(store);
let mut store = Store::open(&dir).unwrap();
assert_eq!(balances(&store), want);
store.rebuild().unwrap();
assert_eq!(balances(&store), want);
store.verify().unwrap();
}
+3 -2
View File
@@ -54,8 +54,9 @@ The core is a pure domain library and stays that way.
| Crate | Purpose | Depends on | | Crate | Purpose | Depends on |
|---|---|---| |---|---|---|
| `ledger-store` | SQLite persistence of the entry log | `ledger-core` | | `ledger-store` | Journal (hash-chained JSONL) + SQLite projection, blob store | `ledger-core` |
| `ledger-import` | GnuCash XML read-only import | `ledger-core` | | `ledger-import` | QuickBooks CSV read-only import (bank transactions, chart of accounts) | `ledger-core` |
| `ledger-export` | Format renderers: CSV, XLSX, IIF journal entries (QuickBooks Desktop) | `ledger-core` |
| `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` | | `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` |
| `books-app` | egui UI (native + WASM), command-driven | `ledger-core`, `ledger-store` | | `books-app` | egui UI (native + WASM), command-driven | `ledger-core`, `ledger-store` |
+52
View File
@@ -0,0 +1,52 @@
# Capability parity — GSheet+GDrive → wanderbooks
The parity target for the AI-first phase is **not QuickBooks**. It is the
system actually in use today: the "Wanderland Labs LLC — Books" Google Sheet
(double-entry accounts with live P&L, Balance Sheet, and Cash Flow tabs, plus
the Transaction Journal) together with the Drive folder, operated through an
AI assistant. Every capability below must be reachable through MCP tools
before the sheet retires.
Statuses: `covered` (built and tested), `partial` (built, known gaps listed),
`specced` (designed, not yet built), `future` (on the roadmap).
| # | Today's capability | wanderbooks equivalent | Status |
|---|---|---|---|
| 1 | Record a double-entry transaction (Transaction Journal row) | `books.draft_entry` / `add_line` / `post_entry` (engine: `Ledger`) | covered |
| 2 | Chart of accounts | engine + `books.chart` (with live balances) | covered |
| 3 | Live P&L tab | engine + `books.profit_and_loss` | covered |
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered |
| 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered |
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered |
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
| 10 | Human review of what the assistant recorded | `books.digest` | covered |
| 11 | CPA-ready export of the books | `books.export` (csv/json/xlsx/iif; xlsx is base64) | covered |
| 12 | Company email filing (every company email → Drive folder) | out of books scope; the existing mail-filing automation keeps running unchanged | — |
## Notes
- Row 9 is the quiet superpower of the AI-first design. The sheet answers
questions with formulas the human must write; the MCP design answers them
with an agent that already knows how to read a trial balance. The
capability is "ask anything about the books," and the tool surface above
is what makes it true.
- Row 10 (`books.digest`) has no sheet equivalent — the sheet can't tell you
what changed. This is parity *plus*: the new system is more reviewable
than the old one, which is the point of the exercise.
- Row 12 is deliberately excluded. Email filing is a mail problem, not a
books problem; it stays where it is.
## Cutover rule
The sheet does not retire on a date. It retires when **all three** hold:
1. Every `specced` row above is `covered` (built, tested, golden-covered
where reports are involved).
2. A full month of parallel run — every real company transaction recorded in
both systems — diffs clean.
3. Stephen says so.
The parallel run is the acceptance test. Until then the sheet is the system
of record and wanderbooks is the shadow ledger; after, it inverts.
+254 -14
View File
@@ -21,6 +21,19 @@ If a behavior can't be expressed through `ledger-core`'s public API, the
core is missing something — fix the core, don't work around it in the MCP core is missing something — fix the core, don't work around it in the MCP
layer. layer.
## Company settings
`ledger-mcp` reads the books dir's `wanderbooks.toml` (default: next to
`--data-dir`; override with `--books-toml PATH`): `[company] name` heads
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01";
the year's end is derived, never stored) defines fiscal-year-to-date for the
CPA package and the balance sheet's net income line, `[[company.members]]`
(`name`, `share` as "50%" or "0.5", `capital_account` code; shares must sum
to 100%) drives the statement of members' equity, `[books]
accounting_basis` ("accrual" or "cash", default accrual) labels report
footers, and `[books] receipt_threshold` sets the receipt policy unless
`config.json` sets `receipt_policy`.
## Transports ## Transports
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents. 1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
@@ -29,6 +42,17 @@ layer.
laptop). Bearer token, binds 127.0.0.1 or the tailnet interface only. laptop). Bearer token, binds 127.0.0.1 or the tailnet interface only.
Binding 0.0.0.0 is an explicit human decision, never a default. Binding 0.0.0.0 is an explicit human decision, never a default.
## Server instructions
The MCP `initialize` response carries an `instructions` string: the agent
operating contract (below) in condensed form. The model reads it at the start
of every session, before any tool call. Tool descriptions repeat the relevant
fragment at the point of use (`post_entry`'s description states the
preview-before-post rule; `attach_receipt`'s states the receipt rule). Prose
guidance is therefore delivered twice — once at session start, once at the
tool — but it is guidance, not enforcement. Enforcement is the receipt policy
below.
## Tool design principles ## Tool design principles
- **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent - **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent
@@ -56,52 +80,176 @@ layer.
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals → `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
after every line: the agent watches the entry balance in real time. after every line: the agent watches the entry balance in real time.
- `books.post_entry { entry_id, confirmed }` → without confirmation: - `books.post_entry { entry_id, confirmed }` → without confirmation:
`{ preview: { lines, resulting_balances } }`; with confirmation: validates, `{ preview: { lines, resulting_balances, receipts: { attached: [...],
posts, returns `{ entry, balances_changed: [{ code, before, after }] }`. required_but_missing: bool } } }`; with confirmation: validates (including
the receipt policy), posts, returns `{ entry, balances_changed: [{ code,
before, after }] }`.
- `books.reverse_entry { entry_id, date, description, confirmed }` → same - `books.reverse_entry { entry_id, date, description, confirmed }` → same
preview/confirm pattern as post. preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines. - `books.get_entry { entry_id }` → full entry with lines.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100. → entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance {}` → canonical JSON (same shape as - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
`reports::snapshot` trial-balance section). (the trial balance as of the date, default today).
- `books.balance_sheet {}` → canonical JSON. - `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
- `books.profit_and_loss {}` → canonical JSON. `equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
the date (default today).
- `books.profit_and_loss { start?, end?, class? }` → totals plus
`revenue_accounts` and `expense_accounts` for the range. `end` defaults to
today, `start` to the first posted entry. Closing entries are excluded, so
a closed period still shows what it earned.
- `books.cash_flow { start?, end?, cash_accounts: ["1000"], financing_accounts? }`
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
accounts (required); the three sections always sum to their net change.
Disposal gains/losses move from operating to investing automatically.
- `books.digest { since: "2026-10-01" }` - `books.digest { since: "2026-10-01" }`
→ `{ entries_posted: [...], totals_by_account: [...] }`. The human review → `{ entries_posted: [...], totals_by_account: [...] }`. The human review
surface: "what did the AI do with my books lately." The digest is the surface: "what did the AI do with my books lately." The digest is the
answer to "trust, but verify." answer to "trust, but verify."
## Tools — fixed assets
Straight-line **book** depreciation only; tax depreciation stays with the
CPA. Partial first/final months prorated by days; the final month trues up
so the lifetime total is exactly (cost − salvage).
- `books.register_asset { description, purchase_date, cost, salvage_value?,
useful_life_months, cost_account, accum_depr_account, expense_account }`
→ `{ asset_id }`. Accounts resolved by id or code.
- `books.asset_schedule { asset_id }`
→ `{ status, schedule: [{ period, amount, accumulated, book_value }] }`.
- `books.list_assets {}` → assets with status and current book values.
- `books.run_depreciation { period: "YYYY-MM", asset_id? }`
→ per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for
assets outside their useful life. Idempotent per asset and period.
- `books.dispose_asset { asset_id, date, proceeds, proceeds_account,
gain_loss_account, confirmed?, receipt_attachment_ids? }` → without
confirmation: `{ status: "preview", ... }` and posts nothing; with
`confirmed: true`: auto-posts missing final-period depreciation, then
posts the balanced disposal entry. Subject to receipt policy (bill of
sale); monthly depreciation is exempt as an allocation.
## Tools — receipts & documents ## Tools — receipts & documents
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }` - `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is → `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
optional: a receipt can be filed before its entry exists. optional: a receipt can be filed before its entry exists.
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed - `books.link_receipt { receipt_id, entry_id }` → link a previously filed
receipt to its entry. (unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
- `books.list_receipts { entry_id? }` → metadata list (no bytes). must be unlinked first.
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
receipt policy: an entry that requires a receipt cannot be left with none
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
receipt is attach-new, then unlink-old.
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
an unlinked receipt or a filed document: hidden from listings, never
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
blob store. Nothing in the evidence trail is ever erased — receipts change
by new events, the same way entries change by reversals.
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
(no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn
attachments are omitted unless `include_withdrawn` is true.
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`. - `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
- `books.file_document { category: "formation" | "legal" | "tax" | - `books.file_document { category: "formation" | "legal" | "tax" |
"accounting" | "other", file_name, mime_type, content_base64, "accounting" | "other", file_name, mime_type, content_base64,
description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder
replacement (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting). replacement (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting).
## Receipt enforcement
Prose tells the model to attach receipts; the policy makes it true. The
engine stays policy-free (it knows nothing of receipts) — enforcement lives
in the MCP layer, which reads the `attachments` table before confirming a
posting.
- **Policy config** (server config file, not per-call): `require_receipts_above`
(default `"25.00"` in base currency), plus `receipt_required_accounts`
(account codes that always require receipts, e.g. meals and travel) and
`receipt_exempt_accounts` (e.g. automated bank fees). Human-edited and
versioned with the deployment.
- **Preview.** Every `post_entry` / `reverse_entry` preview includes the
receipt checklist (`attached: [...]`, `required_but_missing: bool`), so the
human sees it before confirming.
- **Hard error.** Confirming a posting that violates the policy fails with a
structured, machine-readable error (JSON in the tool-error string):
`{ "code": "receipt_required", "message": "…", "entry_id": 5,
"debits": "100.00", "policy": "strictly greater than $25.00",
"remedy_tool": "books.attach_receipt",
"remedy_hint": "…" }`. Clients match on `code`, not prose. The agent's job
then is to get the receipt attached (ask the human for it) and re-confirm
— not to shrink the amount or split the entry to dodge the threshold. The
tool description says this explicitly; gaming the policy is a correctness
violation, and the append-only audit log would show it anyway.
- **Anti-splitting.** Posting an entry triggers a split check: posted
entries on the same date with the same normalized description and debit
accounts are grouped, and if the group total exceeds the threshold while
the entry alone does not, a receipt is required. The preview surfaces
`split_detected: [...]`, and the error names the group. Same-day,
same-description, same-accounts is the signature; recurring spending
spread across days is not flagged.
- **Below the threshold**, receipts are still expected per the operating
contract ("a material entry without its source document is incomplete")
but not hard-required — the preview checklist keeps them visible.
## Tools — export ## Tools — export
- `books.export { format: "csv" | "json", scope: "journal" | "trial-balance" | - `books.export { format, report, start?, end?, as_of?, cash_accounts?,
"profit-and-loss" | "balance-sheet" | "chart" }` → `{ content_base64, financing_accounts? }` → `{ content, content_encoding? }`.
file_name }`. CPA-ready output. GnuCash XML arrives with R-FUTURE-4. - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
`cash_accounts`), `trial_balance`, `members_equity` (statement of
changes in members' equity; members from `wanderbooks.toml`),
`general_ledger` (by account: beginning balance, postings with running
balance and split, ending balance), `general_journal` (alias
`journal`: every entry, line by line, in date order).
- Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`,
`csv`, `json` for every report; `iif` for the journal (QuickBooks
Desktop).
- Dates: period reports take `start` (default: first entry) and `end`
(default: today, local time); `balance_sheet` and `trial_balance`
take `as_of`.
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
title, and period; a column-header row; "$" on subtotals and totals;
negatives in parentheses with decimal points aligned; a rule above every
sum and a double rule under statement totals; a footer with the
accounting basis, preparation date, and journal seq. Multi-member LLCs
get "Members' equity" with members named; the balance sheet separates
retained earnings from the fiscal year's net income (loss).
## Tools — import
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code,
rules? }` → creates one draft per transaction (QBO upload layout).
`rules` maps description substrings to account codes.
- `books.import_chart_csv { csv_text }` → creates accounts from
`code,name,type,parent_code` CSV. All-or-nothing.
- `books.import_local_file { path, category: "receipt" | "document", ... }`
→ imports a file from the server's filesystem. The Drive backfill path:
the agent downloads from Google Drive via the CLI to a temp path, then
calls this. Keeps Drive credentials out of the server.
## Transports
- **stdio** (default): `ledger-mcp --data-dir PATH`. For local agents.
- **Streamable HTTP**: `ledger-mcp --data-dir PATH --http 127.0.0.1:8080`.
Serves MCP at `http://127.0.0.1:8080/mcp` via rmcp's tower integration.
For network clients; bind to loopback unless you know what you're doing.
## Resources (read-only) ## Resources (read-only)
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
`list_resources` advertises, `read_resource` renders.
- `books://chart` - `books://chart`
- `books://reports/trial-balance`, `books://reports/balance-sheet`, - `books://reports/trial-balance`, `books://reports/balance-sheet`,
`books://reports/profit-and-loss` `books://reports/profit-and-loss`
- `books://digest?since=2026-10-01` - `books://digest?since=2026-10-01`
- `books://receipts/{id}` — receipt bytes with metadata - `books://receipts/{id}` — receipt metadata + base64 bytes
Resources exist so read-only clients (dashboards, the future read-only GUI, Resources exist so read-only clients (dashboards, the future read-only GUI,
auditors) never need tool credentials. auditors) never need tool credentials. Tested end-to-end over stdio.
## The agent operating contract ## The agent operating contract
@@ -113,7 +261,8 @@ in the server's tool descriptions, so the model reads it at use time):
in prose. in prose.
3. **Attach receipts at record time.** A material entry without its source 3. **Attach receipts at record time.** A material entry without its source
document is an incomplete entry. File first, link later if the entry document is an incomplete entry. File first, link later if the entry
isn't ready. isn't ready. Postings at or above the receipt policy threshold *cannot*
post without one — the server refuses with `receipt_required`.
4. **When unsure of categorization, draft and ask.** A draft is cheap; a 4. **When unsure of categorization, draft and ask.** A draft is cheap; a
wrong posting is a reversal. Never guess-post. wrong posting is a reversal. Never guess-post.
5. **The digest is the receipt for your work.** After a batch of bookkeeping, 5. **The digest is the receipt for your work.** After a batch of bookkeeping,
@@ -124,3 +273,94 @@ in the server's tool descriptions, so the model reads it at use time):
Bank feeds, payment processing, payroll, tax filing, multi-user authN/Z, the Bank feeds, payment processing, payroll, tax filing, multi-user authN/Z, the
GUI (it consumes the event log in a later phase). Cash flow reporting is GUI (it consumes the event log in a later phase). Cash flow reporting is
R-FUTURE-3 and rides the engine roadmap, not the MCP layer. R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
## Tools — periods
- `books.create_period { name, start, end }` → `{ period_id }`.
Dates are YYYY-MM-DD, inclusive.
- `books.close_period { period_id, confirmed? }` → without confirmation:
`{ status: "preview", lines: [...] }`: the exact closing entry (the close
run on a copy of the ledger), posting nothing; with `confirmed: true`:
posts it and marks the period closed. The entry zeroes revenue and
expense balances as of the period end; the net goes to `3999` Retained
Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each
member's capital account by share. Periods close in order. Closed periods
reject new entries.
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
closing_entry }] }`.
## Tools — bank reconciliation
- `books.mark_cleared { entry_id, line_index, cleared }` → `{ ok }`.
Only posted lines can be marked; the flag is workflow metadata, not
accounting data (posted entries stay immutable).
- `books.reconcile { account, statement_date, statement_balance }` →
`{ account_code, book_balance, cleared_balance, statement_balance,
difference, uncleared_lines: [{ entry_id, line_index, date, description,
side, amount }] }`. `difference` is cleared − statement; zero means
reconciled.
## Tools — A/R and A/P
- `books.add_customer { name }` → `{ id }`.
- `books.add_vendor { name }` → `{ id }`.
- `books.create_invoice { party_id, date, due_date, lines: [{ description,
amount }] }` → `{ id, total, entry_id }`. Auto-posts Dr `1200` Accounts
Receivable / Cr `4000` Revenue.
- `books.create_bill { party_id, date, due_date, lines }` → `{ id, total,
entry_id }`. Auto-posts Dr `6999` General Expenses / Cr `2000` Accounts
Payable.
- `books.receive_payment { document_id, date, amount, cash_account }` →
`{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R.
- `books.pay_bill { document_id, date, amount, cash_account }` →
`{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash.
- `books.ar_aging { as_of }` → `{ rows: [{ name, current, days_30, days_60,
days_90, days_90_plus, total }] }`.
- `books.ap_aging { as_of }` → same, per vendor.
## Tools — foreign exchange
- `books.add_line` gains optional `foreign_currency`, `foreign_amount`,
`fx_rate`. When set, the base amount is `foreign_amount * fx_rate`; the
foreign original is stored on the line for reference. Reports work in
base currency.
- `books.set_fx_rate { currency, date, rate }` → `{ ok }`. Records a
historical rate for reference; lines store their own rate at entry time.
## Tools — payroll (accounting only)
No tax tables: the caller supplies every number; the engine cuts correct
entries.
- `books.run_payroll { date, cash_account, lines: [{ employee_name,
gross_pay, federal_withholding, state_withholding, ss_employee,
medicare_employee, ss_employer, medicare_employer, other_deductions }] }`
→ `{ run_id, entry_id, total_gross, total_net }`. One balanced entry:
Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense
(employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash
(net). Auto-creates the 61xx/21xx accounts. Respects closed periods.
- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`.
Clears every payroll payable to cash in one entry.
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
total_gross, entry_id }] }`.
## Tools — classes (divisional tracking)
- `books.add_class { name }` → `{ id }`. E.g. "Rondo", "Coach vs Coach".
- `books.list_classes` → `{ classes: [{ id, name }] }`.
- `books.add_line` gains optional `class` (name or id): tags the new line.
Shared costs are split explicitly across lines at entry time.
- `books.profit_and_loss` gains optional `class` filter.
- `books.profit_and_loss_by_class { start, end }` → per-class revenue,
expenses, net income, plus an unclassified row. Reversals carry their
class so divisional P&L nets correctly.
## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
→ `{ file_name, content_base64 }`. A zip with the full set, each
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
`general-journal.xlsx`. Period reports cover the fiscal year to date.
What the CPA needs; they never log into the system.
+36 -11
View File
@@ -26,18 +26,43 @@ recomputing the goldens *by hand from accounting principles* and documenting
the recomputation in the commit. This is the seed of everything below. the recomputation in the commit. This is the seed of everything below.
**Layer 2 — Property tests (live now, growing).** **Layer 2 — Property tests (live now, growing).**
`random_balanced_journals_preserve_invariants` posts hundreds of random `crates/ledger-core/tests/properties.rs` uses `proptest` (with shrinking)
balanced entries and asserts the accounting laws still hold: trial balance to assert the accounting laws over hundreds of random inputs: trial balance
balances, the accounting equation holds, and the sum of all account movements always balances, the accounting equation holds, reversals restore zero,
is exactly zero. New invariants get new properties. (Later: graduate to posted entries are immutable and JSON-round-trip clean, and depreciation
`proptest` with shrinking.) schedules are exact to the penny. This layer caught a real bug on its second
case: partial first/final months in different-length calendar months don't
complement, so the lifetime total drifted by cents (fixed with the
final-month true-up in `depreciation_schedule`).
**Layer 3 — GnuCash diff harness (roadmap phase 3).** A `ledger-oracle` crate **Layer 3 — Mutation testing (live now).** `cargo mutants -p ledger-core`
will: import fixture books into GnuCash's XML format (or drive GnuCash (228 mutants as of 2026-10-10: 159 caught, 28 unviable, 2 timeouts from
headless), run both engines, and diff `snapshot()` output automatically — induced infinite loops, 39 missed). Every *missed* mutant is triaged:
`cargo xtask oracle`. This is the direct analog of storytold's real gaps get new tests (this pass added parent-account validation,
`cargo xtask corpus` / `oracle_diff`. Until it exists, Layers 1 and 2 are wrong-typed asset links, zero-proceeds disposal, two-asset ids, 1-month
the oracle, and they are not optional. life, `Money` arithmetic, `is_balance_sheet`, `movements()`), equivalent
mutants are documented as such (e.g. `days_held > 0` vs `>=`: the zero
case is unreachable), and untestable-by-construction mutants are noted
(e.g. `TrialBalance::balanced -> true`: the engine cannot produce an
unbalanced ledger to falsify it).
**Layer 4 — Fuzzing (live now).** `cargo fuzz` targets in `fuzz/`
(nightly, libfuzzer): `fuzz_journal_line` (journal parsing never panics;
canonical form is a fixed point), `fuzz_entry_validate` (a validated entry
always balances exactly), `fuzz_op_sequence` (arbitrary op sequences via
`arbitrary`; the trial balance always balances). 11.5M execs, zero crashes
as of 2026-10-10. Corpora persist in `fuzz/corpus/` for regression.
**Layer 5 — GnuCash diff harness (roadmap phase 3).** A `ledger-oracle` crate
will run fixture books through our engine and through GnuCash headless, then
diff `snapshot()` output automatically — `cargo xtask oracle`. This is the
direct analog of storytold's `cargo xtask corpus` / `oracle_diff`. Note the
distinction: GnuCash is the *behavioral* oracle, not a file-format target
(Stephen: "for file format import/export I could not give a single shit
about GnuCash XML — QBO is all that matters"). The harness feeds fixtures to
GnuCash via whatever it imports reliably (QIF/OFX) or its Python bindings;
the exact mechanism is decided at implementation time. Until the harness
exists, Layers 1 and 2 are the oracle, and they are not optional.
## What the oracle does NOT cover (yet) ## What the oracle does NOT cover (yet)
+42
View File
@@ -0,0 +1,42 @@
# Parallel-run readiness
The Google Sheet stays the system of record until the parallel run passes
and Stephen explicitly approves cutover. This doc is the plan; the run
itself needs Stephen's go-ahead.
## Prerequisites (all met)
- Capability parity 12 of 12 rows covered (`docs/capability-parity.md`).
- Engine semantics verified: 70 workspace tests, property tests, mutation
testing, 11.5M fuzz executions with zero crashes.
- Storage: hash-chained JSONL journal (source of truth), SQLite as
disposable projection with auto-rebuild, content-addressed blob store.
- Export: CSV/JSON/XLSX/IIF for CPA handoff and QuickBooks Desktop.
## The run
1. **Duration:** one full calendar month.
2. **Dual entry:** every transaction goes into BOTH the Sheet and
wanderbooks (via the MCP tools). Receipts attach in both.
3. **Month-end diff:** export trial balance, P&L, balance sheet, and cash
flow from wanderbooks (XLSX); compare against the Sheet's tabs.
Every line must match to the cent.
4. **Discrepancy protocol:** any mismatch is investigated before the next
month starts. If the engine is wrong, fix the engine (with a test).
If the Sheet is wrong, note it — the Sheet is the record until cutover.
5. **Cutover:** only after a clean month AND Stephen's explicit approval.
Cutover means: the Sheet becomes read-only history; wanderbooks becomes
the system of record. The journal's git backup becomes the archive.
## What the agent does during the run
- Daily: `books.digest` review of what was recorded.
- Weekly: trial-balance export, eyeball against the Sheet.
- Month-end: full four-statement export + line-by-line diff.
- Every discrepancy gets a written explanation before proceeding.
## Rollback
If the run fails: the Sheet never stopped being the record. Delete the
wanderbooks data dir, fix the issues, restart the month. No data loss —
the Sheet has everything.
+28 -7
View File
@@ -8,7 +8,7 @@ Statuses: `done` = implemented + invariant-tested + golden-covered.
`partial` = implemented but missing tests or goldens. `todo` = not started. `partial` = implemented but missing tests or goldens. `todo` = not started.
No other statuses. No other statuses.
**Floor: 6 done** (2026-10-10, initial commit). **Floor: 10 done** (2026-10-10; fixed-asset register added).
| Area | GnuCash reference | Status | Notes | | Area | GnuCash reference | Status | Notes |
|---|---|---|---| |---|---|---|---|
@@ -19,17 +19,38 @@ No other statuses.
| Trial balance | Reports → Trial Balance | done | golden-covered | | Trial balance | Reports → Trial Balance | done | golden-covered |
| Balance sheet (A = L + E) | Reports → Balance Sheet | done | current earnings folded in pre-close; golden-covered | | Balance sheet (A = L + E) | Reports → Balance Sheet | done | current earnings folded in pre-close; golden-covered |
| Profit & loss | Reports → Income Statement | done | golden-covered | | Profit & loss | Reports → Income Statement | done | golden-covered |
| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet |
| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet |
| General ledger by account (running balance, split) | Reports → General Ledger | partial | hand-verified fixture, ties to trial balance; no golden file yet |
| Trial balance as of a date | Reports → Trial Balance (as of) | partial | hand-verified fixture; no golden file yet |
| Statement of changes in members' equity | Reports → Statement of Changes in Equity | partial | hand-verified two-member fixture incl. rounding and a close; no golden file yet |
| Reversing entries | Reverse transaction | done | linked via `reversal_of` | | Reversing entries | Reverse transaction | done | linked via `reversal_of` |
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
| Cash flow statement (indirect) | Reports → Cash Flow | todo | R-FUTURE-3 | | Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
| Cash flow statement (indirect) | Reports → Cash Flow | done | R-FUTURE-3 |
| Period close / retained earnings | Close books | todo | R-FUTURE-2 | | Period close / retained earnings | Close books | todo | R-FUTURE-2 |
| Multi-currency + FX translation | Multi-currency accts | todo | R-FUTURE-1 | | Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 |
| GnuCash XML import (read-only) | File format | todo | feeds oracle harness; R-FUTURE-4 | | Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
| A/R invoices + payments + aging | Business → Invoices | todo | R-FUTURE-5 | | Bank reconciliation (cleared flags + report) | Reconcile | done | — |
| A/P bills + payments + aging | Business → Bills | todo | R-FUTURE-5 | | QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 |
| Budgets | Budgets | todo | R-FUTURE-6 | | QuickBooks IIF + XLSX export | Export / reports | done | R-FUTURE-4 |
| A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5 |
| A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5 |
| Payroll runs (accounting only, no tax tables) + tax remittance | Payroll | done | — |
| Tracking classes (divisional P&L) | Class tracking | done | — |
| CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — |
| Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) |
| Scheduled transactions | Scheduled txns | todo | R-FUTURE-6 | | Scheduled transactions | Scheduled txns | todo | R-FUTURE-6 |
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 | | Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
egui phase starts; semantics come first. egui phase starts; semantics come first.
## Capability parity (AI-first phase)
Semantic parity (above) is about *correctness*: the engine computes the
right numbers. Capability parity is about *usefulness*: can the new system
do everything the GSheet+GDrive system does today? Tracked separately in
`docs/capability-parity.md`, with its own cutover rule (parallel run +
Stephen's approval). The two catalogs converge: when both are green, the
sheet retires.
+156
View File
@@ -0,0 +1,156 @@
# Receipt & document store + persistence spec
Three pieces: the **journal** (the source of truth), the **blob store**
(the bytes), and **SQLite** (the queryable projection). All immutable-first,
matching the ledger's philosophy: the books never forget, and neither does
the filing cabinet.
## The journal: the source of truth
SQLite is the query engine, not the source of truth. The source of truth is
an **append-only journal of domain events**: one JSON object per line
(JSONL), hash-chained. This is the deliberate answer to "insanely durable,
easily backupable, journable":
- **Durable.** Every state change is an immutable fact appended to
`journal.jsonl`, fsync'd per append. Each line carries the SHA-256 of the
previous line's canonical bytes (`prev`), forming a tamper-evident chain:
bit rot, torn writes, and casual editing are detected on read/verify, not
discovered at tax time. Honest scope: this detects corruption and casual
tampering. It does not stop a determined attacker with full write access
from rewriting the chain — nothing local does.
- **Backupable.** The backup is `cp -r $WANDERBOOKS_DATA /backup`: the
journal file plus the blob directory. Plain files, no WAL-file timing
windows, no special backup API. A torn tail from a mid-copy is detectable
(the last line fails hash/parse) and recoverable (truncate to the last
good seq — the projection never saw it). Better: the journal is
git-friendly, so an optional `wanderbooks backup` that commits it to a
private git repo gives versioned, distributed, deduplicated backup with
full history.
- **Journable.** It literally is a journal: `{seq, ts, type, payload, prev}`.
Greppable, diffable, replayable. The audit trail isn't a feature bolted
on — it's the storage format.
### Write path
There is exactly one write path: **append to the journal, fsync, then apply
to SQLite.** No dual writes, no background applier thread (the volume
doesn't justify it): the MCP server appends, fsyncs, applies the new lines
to the projection synchronously, and returns. A crash between append and
apply is safe — on startup the applier replays every journal line with
`seq > last_applied_seq`.
### Line format
```json
{"seq": 42, "ts": "2026-10-10T18:20:00Z", "type": "entry.posted",
"payload": { "...": "..." }, "prev": "abc123..."}
```
- `seq`: 1-based, gapless, assigned by the writer.
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
`receipt.attached`, `receipt.linked`, `receipt.unlinked`,
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
period, payroll, and class events listed on `JournalLine`).
- `payload`: the full domain facts (account fields, entry with lines, blob
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
whitespace). The genesis line uses `"prev": "000...0"` (64 zeros).
### Drafts are journaled (durable)
Draft entries are durable: `entry.created` and `entry.line_added` journal
lines record the draft as the agent builds it, so drafts (and receipts
attached to them) survive server restarts. Drafts never touch the SQLite
projection until posted — reports only see posted entries. A draft that
never posts leaves its `created`/`line_added` lines in the journal, which
is correct: the journal records what happened, including abandoned work.
### Verification
`wanderbooks verify` (a `ledger-store` API, later a CLI subcommand):
re-read the journal, recompute the hash chain, replay every line into a fresh
in-memory `Ledger`, and compare its reports against the SQLite projection's.
Any mismatch is a loud failure. Run it on startup (cheap at this scale) and
before backups.
## Blob store
- **Content-addressed.** The key is SHA-256 of the exact bytes. Path:
`$DATA/blobs/<first2>/<next2>/<full-hex>`. Same bytes, same path, forever.
- **Immutable.** Blobs are never modified or deleted by the application. A
retention/deletion policy is an explicit future human decision, not an
accident of implementation.
- **Deduplicated.** Attaching the same receipt twice stores one blob and two
attachment rows. `attach_receipt` / `file_document` return
`deduplicated: true` when the blob already existed.
- **Limits.** 25 MB per file by default, configurable. MIME type is accepted
from the client and stored; server-side sniffing is future work.
- **Metadata** lives in SQLite (`blobs` table), not in the filesystem:
`sha256 PK, size_bytes, mime_type, original_name, created_at`.
## Attachments & documents
- `attachments`: `id PK, blob_sha FK, entry_id FK NULL, category,
description, created_at`.
- `entry_id` is nullable: a receipt can be filed before its journal entry
exists, then linked with `books.link_receipt`.
- `category` mirrors today's Drive layout: `formation`, `legal`, `tax`,
`accounting` (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting),
plus `receipts`.
- Documents filed via `books.file_document` and receipts via
`books.attach_receipt` share the blob store; they differ only in metadata.
- **Receipts change by events, never by edits.** `receipt.unlinked`
(`attachment_id`, `entry_id`, `reason`) detaches a receipt from its entry;
`attachment.withdrawn` (`attachment_id`, `reason`) retires an unlinked
receipt or a document (`withdrawn_at`, `withdrawn_reason` columns, schema
v5). Linking requires an unlinked, non-withdrawn attachment, so evidence
only leaves an entry through a recorded unlink. Blobs are untouched: a
withdrawn file is still hash-verified and readable, and the journal shows
when it was filed, linked, unlinked, and withdrawn, and why.
- The store is policy-free: it records attachments faithfully. Whether a
posting *requires* a receipt is decided by the MCP layer's receipt policy
(`docs/mcp-interface.md`), which reads the `attachments` table.
## `ledger-store`: the SQLite projection
SQLite is the read-optimized projection of the journal. Queries, reports,
the MCP server's reads, and the future GUI all hit SQLite — and SQLite is
**disposable by design**: `rebuild` drops it and replays the journal.
Suspected corruption is fixed by rebuild, never by forensics.
- **Location.** `$WANDERBOOKS_DATA/wanderbooks.db`, falling back to
`~/.local/share/wanderbooks/wanderbooks.db`. The journal lives next to it
at `$WANDERBOOKS_DATA/journal.jsonl`; blobs under
`$WANDERBOOKS_DATA/blobs/`. WAL mode on, `synchronous=FULL`,
`integrity_check` on startup.
- **Schema.** `meta(key, value)` — `schema_version`, `base_currency`,
`last_applied_seq`; `accounts`, `entries`, `lines`, `blobs`, `attachments`
as above; `events(seq, type, payload, created_at)` — the applier's output,
a queryable mirror of the journal stream and the feed the future GUI
tails. (Poll `WHERE seq > ?`; later SSE over the HTTP transport. This is
the correct answer to "watch its files to update live" — watch the event
stream, not the files.)
- **Amounts as TEXT, never REAL.** Unchanged and still load-bearing: a REAL
column would silently reintroduce binary floats into the money path.
- **Append-only, three layers.** (1) The engine's API never mutates posted
state. (2) The journal is append-only by file discipline plus the hash
chain. (3) The applier only ever INSERTs into the projection. The SQLite
triggers from the earlier draft are deliberately dropped: they protected
the projection, which is disposable — the journal is the trust anchor, and
piling mechanisms on the disposable layer adds complexity without adding
safety. Simplicity is a durability feature too.
- **Migrations.** Numbered SQL files, applied in order, bumping
`meta.schema_version`. Every migration is tested by rebuilding a
projection from the journal and comparing reports before/after. A newer
schema version than the binary understands is a hard error, not a guess.
## Migration from Google Drive
Backfill walks the Drive `Wanderland Labs LLC` folders and files each
document via the equivalent of `books.file_document` with its category.
The Drive folders stay exactly as they are until the capability-parity
cutover rule (see `docs/capability-parity.md`) is satisfied. Migration is an
implementation task, not part of this spec — but the spec is written so the
migration is a straightforward script, not a redesign.
+89 -12
View File
@@ -34,10 +34,37 @@ requirement conflict, the requirement wins.
total credits (the `balanced()` check). total credits (the `balanced()` check).
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account - **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
type. The accounting equation holds exactly: assets = liabilities + equity. type. The accounting equation holds exactly: assets = liabilities + equity.
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net Current earnings (revenue minus expenses not yet closed to retained
income to date) are folded into equity so the equation holds on a live book. earnings) are folded into equity so the equation holds on a live book.
- **R-9 — Profit and loss.** Revenue and expenses for the life of the ledger, `balance_sheet_as_of` gives the statement as of any date, a line per
with net income. Period filtering arrives with R-FUTURE-2. account, counting every entry dated on or before it (closing entries
included: after a close the earnings sit in retained earnings).
- **R-9 — Profit and loss.** Revenue and expenses with net income.
`profit_and_loss_for_period` scopes it to a date range (optionally one
class), a line per account, and **excludes closing entries**: a close moves
earnings into retained earnings and must not make a closed period read as
break-even. The cash flow statement excludes closing entries for the same
reason. Period P&Ls partition: split a range anywhere and the halves sum
to the whole, and a range's net income equals the change in current
earnings across it (property-tested).
- **R-9a — General ledger.** `general_ledger(start, end)`: by account in
code order, each with its beginning balance, every posting in the period
(date, entry, description, debit, credit, running balance on the account's
normal side), and its ending balance, which ties to the trial balance as
of `end`. Each posting names its *split*: the one account on the entry's
opposite side, or "-Split-" when that side has several (the QuickBooks
convention). The entry-ordered listing is the *general journal*, a
different report.
- **R-9b — Trial balance as of a date.** `trial_balance_as_of(as_of)`.
- **R-9c — Statement of changes in members' equity.** For a multi-member LLC
(partnership): each member's capital roll-forward — beginning,
contributions (credits to their capital account), allocated net income
(loss), distributions (debits), ending — plus a total that equals
balance-sheet equity. Earnings not yet distributed (retained earnings and
unclosed income) and the period's income are allocated by ownership share
to the cent, the last member absorbing rounding, so every row's total is
exact. Any equity change the roll-forward doesn't explain is shown as
"other", never hidden.
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three - **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
core reports as deterministic JSON (decimal strings, sorted lines, fixed core reports as deterministic JSON (decimal strings, sorted lines, fixed
keys). Two engines fed the same books must produce identical output. This keys). Two engines fed the same books must produce identical output. This
@@ -50,16 +77,66 @@ requirement conflict, the requirement wins.
- **R-12 — Command surface.** All mutations go through `Ledger` methods. - **R-12 — Command surface.** All mutations go through `Ledger` methods.
There is no second way to change the books. There is no second way to change the books.
## Receipt policy (MCP phase)
- **R-13a — Receipt corrections.** Posted entries are immutable (R-3); their
evidence is not frozen but is append-only. A receipt is replaced by
attaching the new one and unlinking the old with a reason; unwanted
receipts and documents are withdrawn with a reason. Unlinking may never
leave an entry the policy (R-13) requires a receipt for with none. Every
step is a journal event; no blob is deleted.
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
stays policy-free) enforces a configurable receipt policy: postings at or
above a threshold (`require_receipts_above`, default `"25.00"` in base
currency) must have at least one attached receipt, with per-account
overrides. `post_entry` with `confirmed: true` refuses with a typed
`receipt_required` error otherwise. The draft preview always includes the
receipt checklist (`attached: [...]`, `required_but_missing: bool`), so the
human sees it before confirming. Full spec: `docs/mcp-interface.md`.
## Explicitly future work ## Explicitly future work
- **R-FUTURE-1 — Multi-currency.** Per-account currencies, entry-level FX - **R-FUTURE-6 — Budgets and scheduled transactions.** (Budgets explicitly
rates, translation gains/losses, reporting in a presentation currency. declined as bloat; scheduled transactions remain future.)
- **R-FUTURE-2 — Periods and close.** Fiscal years/periods, closing entries
that move P&L balances to retained earnings, locked periods. ## Promoted from future (done)
- **R-FUTURE-3 — Cash flow statement** (indirect method).
- **R-FUTURE-4 — GnuCash XML import** (read-only; feeds the oracle harness). - **R-? — Cash flow statement** (indirect method). Was R-FUTURE-3.
- **R-FUTURE-5 — A/R and A/P workflows** (invoices, bills, payments, aging). `ledger-core::reports::cash_flow` + `books.cash_flow`.
- **R-FUTURE-6 — Budgets and scheduled transactions.** - **R-? — QuickBooks-compatible import/export.** Was R-FUTURE-4.
`ledger-import` crate: CSV bank/chart import, IIF and XLSX export.
- **R-? — Multi-currency (foreign-currency lines).** Was R-FUTURE-1.
`JournalLine` carries optional foreign currency/amount; `amount` is the
base-currency equivalent. `Ledger::add_line_fx`, `Ledger::set_fx_rate`.
Reports work in base currency. (Full per-account currencies and translation
remain future.)
- **R-? — Periods and close.** Was R-FUTURE-2.
`ledger-core::periods`: `create_period`, `close_period_to(id, to)`; closed
periods reject new entries. The closing entry, dated at period end,
zeroes each revenue and expense account's balance **as of the period
end** (later-dated entries belong to later periods) and puts the net in
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
account by share, to the cent, the last absorbing rounding: the
partnership convention). Periods close in order (`CloseOutOfOrder`). The
journal records the target, and replay re-derives the closing entry and
checks every line against the journal.
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
AR/AP aging. MCP tools for the full workflow.
- **R-? — Bank reconciliation.** Cleared flags on posted lines,
`reconciliation_report` with uncleared-line detail.
- **R-? — Payroll (accounting only).** `ledger-core::payroll`:
`run_payroll` posts one balanced entry (Dr 6100 gross, Dr 6110 employer
FICA, Cr 2110–2150 payables, Cr cash net); `remit_payroll_taxes` clears
the payables. No tax tables — the caller supplies every number.
- **R-? — Tracking classes (divisional P&L).** `ledger-core::classes`:
`JournalLine.class_id`, `add_class`, `set_line_class` (drafts only),
`profit_and_loss_filtered`, `profit_and_loss_by_class`. Shared costs are
split explicitly across lines at entry time.
- **R-? — CPA handoff package.** `books.export_cpa_package`: zip with
trial-balance XLSX, general-ledger XLSX (every posted line), P&L and
balance-sheet PDFs (printpdf, MIT).
## Non-goals (all phases of the engine) ## Non-goals (all phases of the engine)
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "ledger-core-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }
serde_json = "1"
rust_decimal = "1"
chrono = "0.4"
[dependencies.ledger-core]
path = "../crates/ledger-core"
[dependencies.ledger-store]
path = "../crates/ledger-store"
[[bin]]
name = "fuzz_journal_line"
path = "fuzz_targets/fuzz_journal_line.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_entry_validate"
path = "fuzz_targets/fuzz_entry_validate.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_op_sequence"
path = "fuzz_targets/fuzz_op_sequence.rs"
test = false
doc = false
bench = false
[workspace]
+51
View File
@@ -0,0 +1,51 @@
// Fuzz target: arbitrary JSON as a journal entry. If validation accepts
// it, the entry MUST balance exactly — validation is the enforcement
// point, and this pins it. Staged here; `cargo fuzz init` wires it up.
#![no_main]
use libfuzzer_sys::fuzz_target;
use ledger_core::{AccountType, Currency, JournalEntry, Ledger, Side};
use rust_decimal::Decimal;
fn test_chart() -> Ledger {
let mut ledger = Ledger::new(Currency::USD);
for (code, name, typ) in [
("1000", "Cash", AccountType::Asset),
("2000", "Payable", AccountType::Liability),
("3000", "Equity", AccountType::Equity),
("4000", "Revenue", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
] {
ledger.add_account(code, name, typ, None, false).unwrap();
}
ledger
}
fuzz_target!(|data: &[u8]| {
if let Ok(entry) = serde_json::from_slice::<JournalEntry>(data) {
let ledger = test_chart();
// Validation must never panic on hostile input.
if entry.validate(ledger.chart()).is_ok() {
let debits: Decimal = entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
let credits: Decimal = entry
.lines()
.iter()
.filter(|l| l.side == Side::Credit)
.map(|l| l.amount.amount)
.sum();
assert_eq!(
debits, credits,
"validate() accepted an unbalanced entry: {entry:?}"
);
assert!(
debits > Decimal::ZERO,
"validate() accepted a zero-total entry"
);
}
}
});
+24
View File
@@ -0,0 +1,24 @@
// Fuzz target: journal line parsing must never panic, and the canonical
// form must be a fixed point: parse(canonical(x)) re-serializes to the
// same canonical bytes. Staged here; `cargo fuzz init` wires it up.
#![no_main]
use libfuzzer_sys::fuzz_target;
use ledger_store::JournalLine;
fuzz_target!(|data: &[u8]| {
// Garbage must return Err, never panic.
if let Ok(line) = serde_json::from_slice::<JournalLine>(data) {
let canon = line.to_canonical();
let line2: JournalLine =
serde_json::from_str(&canon).expect("canonical form must re-parse");
assert_eq!(
line2.to_canonical(),
canon,
"canonical form is not a fixed point"
);
// Sequence numbers survive the round trip.
assert_eq!(line.seq, line2.seq);
assert_eq!(line.line_type, line2.line_type);
}
});
+101
View File
@@ -0,0 +1,101 @@
// Fuzz target: arbitrary operation sequences against a Ledger. Errors
// are fine (invalid ops are rejected); the invariant is that the trial
// balance ALWAYS balances afterward, no matter the sequence. This is the
// stateful counterpart of the proptest properties — libfuzzer explores
// deeper interleavings. Staged here; `cargo fuzz init` wires it up.
#![no_main]
use arbitrary::Arbitrary;
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use libfuzzer_sys::fuzz_target;
use rust_decimal::Decimal;
#[derive(Arbitrary, Debug, Clone, Copy)]
enum Op {
AddAccount { code: u8, typ: u8 },
CreateEntry { day: u8 },
AddLine {
entry: u8,
account: u8,
debit: bool,
cents: u32,
},
PostEntry { entry: u8 },
ReverseEntry { entry: u8 },
}
fn account_type(t: u8) -> AccountType {
match t % 5 {
0 => AccountType::Asset,
1 => AccountType::Liability,
2 => AccountType::Equity,
3 => AccountType::Revenue,
_ => AccountType::Expense,
}
}
fuzz_target!(|ops: Vec<Op>| {
let mut ledger = Ledger::new(Currency::USD);
let mut entry_ids: Vec<u64> = Vec::new();
let mut next_code: u32 = 1000;
for op in ops {
match op {
Op::AddAccount { code, typ } => {
// Unique codes by construction; duplicates are rejected.
let c = format!("{:04}", next_code + (code as u32 % 50));
next_code += 1;
let _ = ledger.add_account(&c, "fuzz", account_type(typ), None, false);
}
Op::CreateEntry { day } => {
let date =
NaiveDate::from_ymd_opt(2026, 1, 1 + (day as u32 % 28)).unwrap();
let id = ledger.create_entry(date, "fuzz");
entry_ids.push(id);
}
Op::AddLine {
entry,
account,
debit,
cents,
} => {
if entry_ids.is_empty() {
continue;
}
let eid = entry_ids[entry as usize % entry_ids.len()];
// Account ids are 1-based; may not exist — rejection is fine.
let aid = (account as u64 % 60) + 1;
let side = if debit { Side::Debit } else { Side::Credit };
let amount = Decimal::new((cents % 1_000_000) as i64, 2);
let _ = ledger.add_line(eid, aid, side, Money::new(amount, Currency::USD));
}
Op::PostEntry { entry } => {
if entry_ids.is_empty() {
continue;
}
let eid = entry_ids[entry as usize % entry_ids.len()];
let _ = ledger.post_entry(eid);
}
Op::ReverseEntry { entry } => {
if entry_ids.is_empty() {
continue;
}
let eid = entry_ids[entry as usize % entry_ids.len()];
let _ = ledger.reverse_entry(
eid,
NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(),
"fuzz reversal",
);
}
}
}
// THE invariant: no sequence of operations, valid or rejected, may
// leave the trial balance out of balance.
let tb = ledger_core::reports::trial_balance(&ledger);
assert_eq!(
tb.total_debits, tb.total_credits,
"trial balance broken after fuzzed op sequence"
);
});
+185 -21
View File
@@ -1,12 +1,12 @@
# STATUS — 2026-10-10 # STATUS — 2026-10-11 (all 10 natural next steps done; repo now public)
## Where we are ## Where we are
Phase 1 (semantics core) is underway. The repo exists, is private, and the All ten natural next steps are done, tested, and pushed. The repo is now
first engine slice is built, tested, and green. public. Parity is 12 of 12 against the spreadsheet workflow.
**Done:** **Done:**
- Repo `swaits/wanderbooks` created on git.swaits.com (private). - Repo `swaits/wanderbooks` on git.swaits.com (public as of 2026-10-11).
- `ledger-core` 0.1.0: `Money` (exact Decimal + ISO currency), five account - `ledger-core` 0.1.0: `Money` (exact Decimal + ISO currency), five account
types with normal sides, chart of accounts (codes, hierarchy, types with normal sides, chart of accounts (codes, hierarchy,
placeholders), journal entries with full validation, `Ledger` command placeholders), journal entries with full validation, `Ledger` command
@@ -17,29 +17,67 @@ first engine slice is built, tested, and green.
- 14 tests green: validation rejections, posted-entry immutability, - 14 tests green: validation rejections, posted-entry immutability,
reversal semantics, the accounting laws on a fixture company, a 300-entry reversal semantics, the accounting laws on a fixture company, a 300-entry
randomized invariant test, and the first golden file. randomized invariant test, and the first golden file.
- `ledger-store` 0.1.0: the source of truth is an append-only, hash-chained
JSONL journal (fsync per append; tamper-evident); content-addressed
SHA-256 blob store (immutable, deduplicated, hash-verified on read);
SQLite as a *disposable* projection (TEXT amounts, WAL, `synchronous=FULL`,
`integrity_check` on open). Single write path: validate → journal →
mutate → apply. `verify` replays the journal and diffs reports three ways;
`rebuild` drops SQLite and replays; a missing database heals on open.
7 tests green: round-trip, tamper detection, torn-tail truncation,
rebuild, blob dedup/corruption detection, unbalanced-post rejection.
- `ledger-mcp` 0.1.0: stdio server on official rmcp 3.5 (23 narrow typed
tools, amounts as decimal strings, `initialize.instructions` carrying the
agent operating contract). Draft → add lines → preview → confirmed post;
server-side receipt policy (strictly greater than $25, per-account
overrides, typed `receipt_required` rejection); digest; search; CSV/JSON
export; period P&L and as-of balance sheet; fixed-asset tools (register,
schedule, list, run-depreciation, dispose with preview). End-to-end stdio
protocol tests green (initialize → tools → draft → preview →
receipt_required → attach → post → reports → digest → reverse; and a full
asset flow: register → schedule → idempotent runs → disposal preview →
receipt_required → bill-of-sale → confirmed disposal → gain posted).
- **Fixed assets** (`ledger-core` + `ledger-store` + `ledger-mcp`):
fixed-asset register (description, purchase date, cost, salvage, useful
life, linked cost/accumulated-depreciation/expense accounts), straight-line
book depreciation with day-prorated partial months and a final-month
true-up so the lifetime total is exactly (cost − salvage), idempotent
monthly runs, disposal with auto-posted final depreciation and gain/loss.
Tax depreciation stays with the CPA. Tests: 10 asset unit tests
(ledger-core), 6 store tests, 7 property tests, MCP e2e.
- **Testing initiative** (in progress): `proptest` property suite on
`ledger-core` (trial balance always balances, A = L + E, reversals restore
zero, posted-entry immutability, JSON round-trip, depreciation exactness —
the last caught a real month-length-drift bug, fixed with the final-month
true-up); `cargo-mutants` run on `ledger-core` (228 mutants); `cargo-fuzz`
targets (journal-line parsing, entry validation, op sequences) scaffolded.
- 120+ tests green workspace-wide; `cargo fmt` clean; `cargo clippy
--workspace --all-targets` clean, zero warnings (a hard rule: Stephen,
2026-10-11).
- Docs: AGENTS.md (operating manual), requirements, architecture, oracle - Docs: AGENTS.md (operating manual), requirements, architecture, oracle
design, parity catalog (floor: 6 done), glossary. Plan: this file + roadmap. design, parity catalog (floor: 10 done), glossary, mcp-interface (incl. the
5 asset tools), receipt-store (journal design), capability-parity
(10 of 12 rows covered). Plan: this file + roadmap.
**In progress:** nothing — the next arcs are queued below, unstarted. **In progress:** nothing — the next arcs are queued below, unstarted.
## Next arcs (in order) ## Next arcs (in order)
1. **Period close** (R-FUTURE-2): fiscal periods, closing entries moving P&L The project went AI-first on 2026-10-10 (Stephen's call): the primary
to retained earnings, locked periods. Parity: balance sheet then reports interface is MCP, not a cloned GUI. The first storage + MCP slice is done
retained earnings instead of folded current earnings. (arcs 1–3 below). What remains:
2. **Cash flow, indirect method** (R-FUTURE-3): from P&L + balance-sheet
deltas. Golden-covered. 1. ~~**`ledger-store`**~~ done — journal + blob store + SQLite projection.
3. **Property tests graduate**: `proptest` with shrinking for the randomized 2. ~~**Blob store**~~ done — folded into `ledger-store`.
invariant test; new properties for close and cash flow. 3. ~~**`ledger-mcp`**~~ done — 18 tools, receipt policy, digest, export.
4. **GnuCash XML import** (R-FUTURE-4, read-only): parse GnuCash files into Remaining from the spec: MCP *resources* (rmcp 3.x has no resource
fixture books. This unblocks the oracle harness. macros; hand-roll `list_resources`/`read_resource` next), Streamable HTTP
5. **Oracle harness** (`ledger-oracle`, `cargo xtask oracle`): run fixture transport, XLSX/IIF export formats.
books through our engine and GnuCash headless, diff snapshots 4. **Capability parity** (`docs/capability-parity.md`): 11 of 12 rows
automatically. Parity catalog becomes machine-checked; CI enforces the covered; full CPA export (xlsx/iif) remains. Then the month-long parallel
floor. run against the GSheet; cutover per the cutover rule.
6. **egui app shell** (`books-app`): command-driven UI, native + WASM, 5. Then: oracle harness (GnuCash diff), read-only GUI on the event log,
screenshot-in-the-loop per the storytold protocol. Workflow parity catalog workflow parity.
(QuickBooks/Xero reference) starts here.
## Decisions log ## Decisions log
@@ -51,3 +89,129 @@ first engine slice is built, tested, and green.
never prints "-0". (Caught by the golden test on day one; the oracle works.) never prints "-0". (Caught by the golden test on day one; the oracle works.)
- 2026-10-10: License MIT OR Apache-2.0, matching the storytold projects. - 2026-10-10: License MIT OR Apache-2.0, matching the storytold projects.
GnuCash clean-room boundary documented in `docs/oracle.md`. GnuCash clean-room boundary documented in `docs/oracle.md`.
- 2026-10-10: AI-first pivot (Stephen). Primary interface is MCP
(`docs/mcp-interface.md`), not a cloned GUI. Receipts/documents become a
content-addressed blob store (`docs/receipt-store.md`). Parity target for
the phase is the GSheet+GDrive system (`docs/capability-parity.md`), with
a parallel-run cutover rule. GUI drops to read-only, event-log-driven,
phase 4.
- 2026-10-10: Import/export targets QuickBooks formats (CSV, IIF, XLSX) —
"could not give a single shit about GnuCash XML." GnuCash stays the
*behavioral* oracle only. Receipt enforcement is server-side policy in the
MCP layer (threshold + per-account overrides, typed `receipt_required`
error, checklist in every preview); the engine stays policy-free.
- 2026-10-10: Storage durability (Stephen). SQLite is the query engine, not
the source of truth: the source of truth is an append-only, hash-chained
JSONL journal (fsync per append; tamper-evident; git-backup-friendly),
SQLite is a disposable projection rebuilt from it, blobs stay
content-addressed. Single write path: journal → fsync → apply to SQLite.
- 2026-10-10: Draft durability (house decision). Drafts are **durable**, not
ephemeral: `entry.created` + `entry.line_added` journal lines record the
draft as the agent builds it, so drafts and their attached receipts survive
restarts. Drafts never touch the SQLite projection until posted. Rationale:
the journal is the source of truth (phantom `entry.created` lines without
draft lines were the worst of both worlds), and the draft → attach receipts
→ post workflow spans multiple MCP calls.
- 2026-10-10: Corrupt SQLite heals itself. If the projection fails
`integrity_check` (or isn't a database at all), `Store::open` quarantines
it to `wanderbooks.corrupt.<timestamp>` and rebuilds from the journal,
instead of failing open. WAL/SHM are dropped as derived state.
- 2026-10-10: Cash flow statement (R-FUTURE-3) done. Engine
`reports::cash_flow` (indirect method) + `books.cash_flow` MCP tool (24
tools total). Disposals identified structurally; gains/losses move from
operating to investing; the three sections always reconcile to the net
change in the designated cash accounts. Two invariant tests, including a
full purchase → depreciate → dispose cycle.
- 2026-10-10: MCP resources done (hand-rolled `list_resources` /
`read_resource`: chart, trial-balance, balance-sheet, profit-and-loss,
digest, receipts). `#[tool_handler]` must sit on the `impl ServerHandler`
block, not the inherent impl — misplacing it silently drops all tools.
- 2026-10-10: QuickBooks interop (R-FUTURE-4) done. New `ledger-import`
crate: QBO bank CSV parse (3-col and 4-col layouts), chart CSV parse,
IIF journal generation, XLSX report generation (rust_xlsxwriter, MIT).
MCP: `books.import_bank_csv` (drafts + categorization rules),
`books.import_chart_csv` (all-or-nothing), `books.export` gains `xlsx`
(base64) and `iif` formats plus `cash_flow` report. 26 tools total.
Capability parity 12 of 12 rows covered.
- 2026-10-10: Streamable HTTP transport done. `ledger-mcp --http ADDR`
serves MCP at `/mcp` via rmcp's tower StreamableHttpService (axum).
27 tools total. Verified with curl: initialize returns SSE stream.
- 2026-10-10: Drive backfill path done. `books.import_local_file` reads a
file from the server's filesystem into the blob store (receipt/document).
The agent downloads from Drive via the CLI, then calls this — Drive
credentials never enter the server.
- 2026-10-11: `wanderbooks init` done. New `ledger-cli` crate (binary
`wanderbooks`, std-only): `init [DIR]` scaffolds a books dir — `mise.toml`
(pinned Rust toolchain; tasks: build, mcp-add, backup), `AGENT.md` (agent
operating contract: bootstrap, MCP contract, first-run onboarding incl.
migration via conversation, ongoing rules), `wanderbooks.toml` (company
profile, filled during onboarding), `books-data/` + `.gitignore` (SQLite
projection ignored; journal is the record). Fresh dirs get `git init` and
a scaffold commit so the journal archive starts on day one. One generic
init by design — no per-situation flags; the agent is the import layer
(QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
`mise run mcp-add`, then `claude` in the dir.
- 2026-10-11: Receipts are mutable through events, never edits (Stephen:
"the journal is just a history of every single thing that happens").
`books.unlink_receipt` and `books.withdraw_attachment` (both require a
reason) journal `receipt.unlinked` / `attachment.withdrawn`; nothing is
erased from the journal or blob store. Unlinking can't strand an entry the
receipt policy requires evidence for (attach the replacement first).
`link_receipt` now refuses an already-linked or withdrawn attachment, which
closes a silent path for moving a receipt off a posted entry; disposals
check their receipt ids before posting. Schema v5. First real use: swapping
the migrated Wanderland Labs `.eml` receipts for their PDF renders.
- 2026-10-11: The cash flow statement is a first-class output everywhere the
other two statements are: `cash_flow_pdf` in ledger-import, a `pdf`
format on `books.export` (P&L, balance sheet, cash flow), and
`cash-flow.pdf` in the CPA package, which now requires `cash_accounts`
(the statement reconciles to them). Known gaps, next: the P&L and balance
sheet PDF/XLSX ignore start/end/as_of (all-time balances under the
requested label; core needs period-scoped statements); P&L and balance
sheet XLSX are totals-only; PDF amounts don't align (padding in a
proportional font), no company name, negatives as "-x".
- 2026-10-11: Every statement export (csv, json, xlsx, pdf, CPA package)
now renders core's period statements: the date range and as-of date are
real, with a line per account. One layout (`ledger-import::report`:
sections, lines, subtotals, totals; exact "(1,234.56)" formatting) feeds
every renderer. The server reads `wanderbooks.toml`: company name for
report headers, `fiscal_year_start` for fiscal YTD (end derived), and
`receipt_threshold` (it was silently ignored before). Reports carry
"Prepared from journal through seq N".
- 2026-10-11: PDFs are typeset with Typst (typst 0.15 via typst-as-lib,
fonts bundled by typst-assets; printpdf removed). One template,
`ledger-import/templates/statement.typ`, takes the laid-out statement as
JSON data (labels are never markup). US accounting presentation in PDF
and XLSX alike: tabular figures, "$" on each section's first amount and
on totals, negatives in parentheses with the closing paren hanging so
decimal points align, zero as a dash, single rule over sums, double rule
under totals, amounts never bold (bold figures are wider). Pages flow
with a repeated header and "Page n of m". Core's cash flow now orders
lines by account code (it followed HashMap order, so line order could
change between runs).
- 2026-10-11: The canonical report set. Core gained the general ledger by
account (running balances; QuickBooks' "split" = the opposite side of the
entry, "-Split-" when several accounts), the trial balance as of a date,
and the statement of changes in members' equity (capital roll-forward per
member, allocation by share to the cent, ties to balance-sheet equity).
The entry-ordered listing is now correctly called the general journal.
Reports render QuickBooks-style (Stephen's reference): Inter (bundled,
OFL) instead of the typst-assets serif fonts, centered title block, a
column-header row, multi-column statements (trial balance Debit/Credit,
one column per member), "$" on sums, rules above sums, a double rule
under totals, zero as 0.00, entity terms (Members' equity, Net income
(loss), standard cash-flow wording) and an "Accrual basis · Prepared …"
footer. "Today" is local time, not UTC (evening exports were dated
tomorrow).
- 2026-10-11: Period close fixed and made partnership-aware. The close swept
every revenue/expense posting ever made, so a period closed after later
entries existed swallowed them (closing January in March closed February
and March too); it now zeroes balances as of the period end, refuses to
close out of order, and its preview is the real close run on a copy of
the ledger. A multi-member LLC closes into each member's capital account
by share (CloseTo::Members), the partnership convention, so capital
accounts carry allocated earnings; the members'-equity statement treats
those closes as income, not contributions, and the balance sheet shows
only unallocated earnings. Replay now checks closing lines, not just ids.
+68 -30
View File
@@ -1,49 +1,87 @@
# Roadmap # Roadmap
## Phase 1 — Semantics core (now) ## Phase 1 — Semantics core (done)
The engine: money, accounts, journal, ledger commands, trial balance, P&L, The engine: money, accounts, journal, ledger commands, trial balance, P&L,
balance sheet, golden files, property tests, and the docs an agent needs to balance sheet, cash flow, fixed assets with depreciation, golden files,
work here (AGENTS.md, requirements, architecture, oracle, parity, glossary). property tests, mutation testing, fuzzing, and the docs an agent needs to
Done when the parity floor is 6+ and every `done` item is tested + work here. Parity floor: 12 done.
golden-covered.
## Phase 2 — Close the accounting loop ## Phase 2 — AI-first interface (done)
- Period close (fiscal periods, closing entries, locked periods) The primary interface is MCP, not a cloned GUI (Stephen's call, 2026-10-10).
- Cash flow statement, indirect method
- `proptest` with shrinking; properties for close and cash flow
- Parity floor moves; goldens grow
## Phase 3 — Oracle harness - `ledger-store`: append-only hash-chained JSONL journal (source of truth),
SQLite as disposable projection with auto-rebuild, content-addressed
blob store, durable drafts.
- `ledger-mcp`: 27 tools over stdio and Streamable HTTP, preview-before-post,
server-side receipt policy (>$25) with anti-splitting, asset management,
digest for human review, read-only resources.
- `ledger-import`: QuickBooks CSV import (bank, chart), IIF and XLSX export.
- Capability parity 12 of 12 vs the GSheet+GDrive system.
- Parallel run with the sheet; cutover per the cutover rule
(`docs/parallel-run.md`).
## Phase 3 — Oracle harness (next)
- `ledger-import`: GnuCash XML read-only import
- `ledger-oracle`: fixture books through our engine and GnuCash headless, - `ledger-oracle`: fixture books through our engine and GnuCash headless,
automatic `snapshot()` diffing via `cargo xtask oracle` automatic `snapshot()` diffing.
- Parity catalog becomes machine-readable; CI enforces the non-regression - Parity catalog becomes machine-readable; CI enforces the non-regression
floor floor.
- Done when: a fixture book produces byte-identical snapshots from both - Done when: a fixture book produces byte-identical snapshots from both
engines with zero manual steps engines with zero manual steps.
## Phase 4 — App shell (egui) ## Phase 4 — Read-only GUI
- `books-app`: one Rust codebase, native desktop + WASM (the storytold - Consumes the `ledger-store` event log: live updates — the answer to
stack: egui/eframe, no Electron, no React) "watch its files to update live."
- Command-driven UI: every UI action goes through the `Ledger` command - egui or web; a dumb client holding no accounting logic.
surface; the UI holds no accounting logic - This is the trust layer for humans (Stephen, Kat, the CPA), not the
- storytold's screenshot-in-the-loop returns: agents verify UI changes primary interface. Read-only at first; editing stays with the agent.
visually, against reference screenshots of GnuCash/QuickBooks kept - Design references (Stephen, 2026-10-11; screenshots shared in session,
maintainer-local (never committed) described here):
- Workflow parity catalog starts (QuickBooks/Xero reference): invoices, - **Reports with a date range and a column interval** (QuickBooks
bills, bank rec, chart management Desktop's P&L: From/To plus Columns = Year/Quarter/Month/...): any
statement split into one column per interval plus TOTAL, to see the
books over time. The multi-column statement layout in
`ledger-import::report` already renders this shape; the GUI (and the
exports) need a per-interval builder over the period-scoped core
statements.
- **Financial dashboard** (Coupler.io's QuickBooks dashboard): P&L,
balance sheet, and cash flow overview tables with Last month / Month to
date / Year to date columns, each beside charts — income vs. COGS +
expenses vs. earnings bars; stacked assets vs. liabilities + equity;
cash at end of period; net cash change.
- **Business overview** (QuickBooks Online home): cards for cash balance
with a trend, expenses by category (donut), profit and loss (income
vs. expenses), invoices (unpaid, overdue, paid), sales trend, and bank
account balances.
- **Revenue dashboard** (Coupler.io): KPI tiles (paid, overdue, open
invoices this month), an invoice table (number, customer, dates,
currency, home and foreign amounts, balance, totals), expenses by
category by month, invoices by status per month.
- **QuickBooks Online dashboard**: invoices (unpaid in the last year,
overdue vs. not yet due; paid in the last 30 days, deposited vs. not),
expenses by category (donut, last month), bank account balances (book
vs. bank, items to review), profit and loss (net income, income vs.
expenses bars), sales trend (line, by quarter).
- **KPI tiles with comparison** (QuickBooks accounting dashboard): income,
cost of goods sold, expenses, net profit, each with % change vs. the
previous 30 days; an auto date-range picker; cash balance over time
with a trend line; revenue by month, stacked.
- **Cash-flow dashboard**: bank balance, debtors, burn rate, cash runway
(days) tiles; income vs. expenses for the last six months; a net cash
flow forecast with a confidence band; A/P and A/R aging by days past
due (the engine already computes both agings); total burn over time.
## Phase 5 — Workflow parity ## Phase 5 — Workflow parity
- A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions - A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions.
- Multi-currency with FX translation - Multi-currency with FX translation.
- The goal: a self-hosted bookkeeping app with a tested claim to - Period close.
semantic parity with GnuCash and workflow parity with QuickBooks — - The goal: a self-hosted bookkeeping system with a tested claim to
no SaaS subscription required semantic parity with GnuCash and capability parity with the old
GSheet+GDrive workflow — no SaaS subscription required.
## Explicitly not on the roadmap ## Explicitly not on the roadmap