Compare commits

...
177 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 b48addd0e0 feat: 1099 tracking — vendor forms, payment tags, year-end worksheet
Vendors carry form_1099 (NEC/MISC) and tin_on_file (never the TIN).
Entries carry the vendor they paid; bills, bill payments, and reversals
are tagged automatically. books.vendor_1099 counts cash paid per vendor
per calendar year (card payments excluded) against $600 through 2025 and
$2,000 from 2026, or an override, and flags missing W-9s. Four new tools
(64), two journal events, schema v7. Docs: finished R-FUTURE work is no
longer listed as future.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:18:32 -07:00
swaitsandClaude Opus 5.5 d65f9aa840 feat: payout gross-up (books.record_payout)
A platform deposit becomes a draft entry with the deposit, fees,
adjustments, and gross revenue (by class), refused unless gross - fees
+/- adjustments equals the deposit to the cent. The payout statement is
attached as its receipt before posting. 60 tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:12:38 -07:00
swaitsandClaude Opus 5.5 ddb67a2ca2 feat: prepaid/deferred amortization schedules and recurring entries
Amortization schedules spread a prepaid expense or deferred revenue over
the months it covers, to the cent, posting on month ends; runs preview
first, are idempotent, honor closed periods, and are exempt from the
receipt policy like depreciation. Recurring entries generate drafts only,
never posts. Six new tools (59 total); four new journal events, replayed
and checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:09:43 -07:00
swaitsandClaude Opus 5.5 dbdc8e671f feat: entity-aware owner equity — chart templates, draws, distributions, dividends
Every business has owners, and how owners put money in and take it out
depends on what the business is. wanderbooks now knows the four common
US shapes:

- books.chart_template {entity_type, owners}: a starter chart for a sole
  proprietorship (owner's capital and draws), partnership or
  multi-member LLC (capital and distributions per partner, guaranteed
  payments), S corporation (stock, APIC, shareholder distributions,
  officer compensation, retained earnings) or C corporation (dividends,
  income tax expense), plus common income and expense accounts with
  tax categories, and the wanderbooks.toml settings that go with it
  (tax form, members with equal-share placeholders that sum to exactly
  100, close target). A proposal for the agent to review with the human.
- Closing: CloseTo is now RetainedEarnings { sweep }, Account { into,
  sweep } (a sole proprietor's capital) or Members([MemberClose { capital,
  share, distributions }]). Sweeps zero owner-pay equity accounts into
  their target at close: draws into owner's capital, dividends or
  distributions into retained earnings, each partner's distributions
  into their own capital. Validation (equity, distinct, shares sum to 1)
  runs in the pre-close preview, before anything is journaled. The
  journal records the target; journals with no target or the old
  "retained_earnings" string still replay.
- Members' equity: a partner's distributions account is part of their
  capital; its activity reports as distributions.
- Config: members' distribution_account; [books] close_into and
  close_sweep for single-owner businesses (rejected alongside members).
  The cash flow names members on their distribution accounts too.

Tests: a sole proprietor (net 2,000 and 500 of draws into capital: 6,500,
the cash); a corporation (dividends 300 into retained earnings: 600); a
partnership with distribution accounts (Bob 100 + 25 - 20 = 105, swept at
close, February begins there); bad sweep accounts; templates round-trip
through the chart parser, unique codes, equity per entity, every tax
category valid for the entity's return; stdio: template -> settings ->
import -> postings -> close with the exact closing lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:01:31 -07:00
swaitsandClaude Opus 5.5 f858fb4cfe feat: tax categories and the tax summary report
wanderbooks is a general MCP-first accounting application: every
business needs its accounts mapped to where they land on its income tax
return, and its CPA needs book-to-tax differences surfaced, not hidden.

- ledger_core::tax: a catalog of stable categories (gross_receipts,
  commissions_fees, meals, guaranteed_payments, officer_compensation,
  nondeductible, ...) with kind (income, cost of goods, deduction), the
  returns each appears on (Schedule C, 1065, 1120-S, 1120), a default
  deductible percent (meals 50, nondeductible 0), and guidance. Keys,
  not line numbers: form lines move from year to year.
- Accounts carry tax_category and an optional deductible_percent
  override, validated against the account type (and the return, at the
  MCP layer), journaled as account.tax_set, replayed, projected (schema
  v6).
- tax_summary(start, end, form): the P&L grouped by category, book and
  deductible amounts (half away from zero to the cent), contra income
  (refunds) reducing receipts, and every account still uncategorized.
- MCP: books.tax_categories (filtered to the configured return),
  books.set_tax_category, a tax_summary report in every format and in
  the CPA package, chart output with categories, and a fifth
  tax_category column in chart CSV imports, validated before anything is
  created. [company] tax_form, or derived from tax_classification.

Scope stays book basis: no tax depreciation, basis, or filing.

Tests: a hand-computed quarter (50% meals 333.33 -> 166.67, penalties at
0%, refunds as negative receipts, guaranteed payments, one uncategorized
account; book net 5,276.67 vs tax 5,483.33); overrides; category/account
/return validation; form parsing and derivation; CSV parsing; stdio:
filtered catalog, mapping, refusal, summary, all-or-nothing import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:53:56 -07:00
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
77 changed files with 31700 additions and 238 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/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
| `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 |
| `plan/STATUS.md` | Where the project stands right now |
| `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]
resolver = "2"
members = ["crates/ledger-core"]
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"]
[workspace.package]
edition = "2024"
+50 -28
View File
@@ -1,50 +1,72 @@
# wanderbooks
Canonical double-entry bookkeeping, rebuilt from scratch — the storytold way:
an engine verified against oracles, with automated parity tracking, on its
way to becoming a self-hosted bookkeeping app that never charges rent.
Canonical double-entry bookkeeping, rebuilt from scratch. The engine is
verified against oracles, parity is tracked automatically, and the primary
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
Phase 1: the semantics engine. `ledger-core` implements the accounting laws
in Rust — exact decimal money, five account types, balanced journal entries,
immutable posted log with reversing entries, trial balance, P&L, and balance
sheet — with 14 invariant tests and golden-file report snapshots.
Parity is 12 of 12 against the spreadsheet workflow it replaces
(`docs/capability-parity.md`). The parallel-run plan
(`docs/parallel-run.md`) is written; the Google Sheet stays the system of
record until a clean month and explicit cutover approval.
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
```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
```
That's the whole test suite: validation rejections, immutability, the
accounting equation on a fixture company, a 300-entry randomized invariant
test, and the golden oracle test.
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
## Layout
```
crates/ledger-core/ # the engine: money, accounts, journal, ledger, reports
docs/ # requirements, architecture, oracle design, parity, glossary
plan/ # STATUS.md, roadmap.md
AGENTS.md # operating manual for agents working here
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
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
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
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]
workspace = true
[dev-dependencies]
proptest = "1"
+35 -30
View File
@@ -6,6 +6,7 @@
//! grouping on type, so getting this right is the whole game.
use crate::{Currency, LedgerError};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -72,6 +73,14 @@ pub struct Account {
pub placeholder: bool,
/// Day one: always the ledger's base currency (see R-5).
pub currency: Currency,
/// Tax category key (see [`crate::tax`]), for revenue and expense
/// accounts.
#[serde(default)]
pub tax_category: Option<String>,
/// Percent deductible, overriding the category's default (e.g. 50 for
/// meals, 0 for penalties).
#[serde(default)]
pub deductible_percent: Option<Decimal>,
}
/// The registry of accounts. Owns id allocation for accounts it holds.
@@ -84,40 +93,20 @@ pub struct ChartOfAccounts {
impl ChartOfAccounts {
/// Register an account. Codes must be unique and non-empty; a parent, if
/// given, must already exist.
pub fn add(
&mut self,
id: AccountId,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
currency: Currency,
) -> Result<(), LedgerError> {
if code.is_empty() {
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
if account.code.is_empty() {
return Err(LedgerError::EmptyAccountCode);
}
if self.by_code.contains_key(code) {
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
if self.by_code.contains_key(&account.code) {
return Err(LedgerError::DuplicateAccountCode(account.code));
}
if let Some(p) = parent {
if !self.accounts.contains_key(&p) {
return Err(LedgerError::UnknownParentAccount(p));
}
if let Some(p) = account.parent
&& !self.accounts.contains_key(&p)
{
return Err(LedgerError::UnknownParentAccount(p));
}
self.by_code.insert(code.to_string(), id);
self.accounts.insert(
id,
Account {
id,
code: code.to_string(),
name: name.to_string(),
account_type,
parent,
placeholder,
currency,
},
);
self.by_code.insert(account.code.clone(), account.id);
self.accounts.insert(account.id, account);
Ok(())
}
@@ -136,6 +125,22 @@ impl ChartOfAccounts {
self.accounts.values()
}
/// Set (or clear) an account's tax category and deductible percent.
pub fn set_tax(
&mut self,
id: AccountId,
category: Option<String>,
deductible_percent: Option<Decimal>,
) -> Result<(), LedgerError> {
let a = self
.accounts
.get_mut(&id)
.ok_or(LedgerError::UnknownAccount(id))?;
a.tax_category = category;
a.deductible_percent = deductible_percent;
Ok(())
}
/// Number of accounts in the chart.
pub fn len(&self) -> usize {
self.accounts.len()
+407
View File
@@ -0,0 +1,407 @@
//! 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,
/// The information return this vendor gets, if any (see
/// [`crate::form1099`]).
#[serde(default)]
pub form_1099: Option<crate::form1099::Form1099>,
/// A W-9 (taxpayer identification number) is on file. The number
/// itself is never stored here.
#[serde(default)]
pub tin_on_file: bool,
}
/// 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));
}
}
+197
View File
@@ -75,4 +75,201 @@ pub enum LedgerError {
/// Any attempt to mutate a posted entry: add lines, delete it, edit it.
#[error("cannot modify a posted entry; post a reversing entry instead")]
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,
/// An amortization schedule or recurring entry is invalid, unknown, or
/// already posted for that date.
#[error("invalid schedule: {0}")]
InvalidSchedule(String),
/// A payout doesn't reconcile or uses the wrong accounts.
#[error("invalid payout: {0}")]
InvalidPayout(String),
/// A tax category doesn't exist, doesn't fit the account, or has a bad
/// deductible percent.
#[error("invalid tax category: {0}")]
InvalidTaxCategory(String),
/// 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),
}
+145
View File
@@ -0,0 +1,145 @@
//! 1099 tracking: what was paid to each vendor in a calendar year.
//!
//! A vendor is marked with the information return it gets (1099-NEC for
//! nonemployee compensation, 1099-MISC for rents and the like) and whether
//! its W-9 is on file. Entries are tagged with the vendor they paid
//! (`pay_bill` tags automatically). The amount paid is the cash that left
//! asset accounts on the vendor's tagged entries: credits minus debits to
//! asset accounts. A card payment (Dr expense, Cr credit card) moves no
//! asset, so it is excluded, as it should be: card payments are reported by
//! the card processor on 1099-K, not by the payer.
//!
//! Thresholds are set by law and change; [`default_threshold`] encodes
//! them and the report takes an override. Confirm with the CPA.
use crate::{AccountType, Ledger, VendorId};
use chrono::Datelike;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// The information return a vendor gets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Form1099 {
/// 1099-NEC: nonemployee compensation (contractors, attorneys' fees).
Nec,
/// 1099-MISC: rents, royalties, prizes, other income.
Misc,
}
impl Form1099 {
/// Parse "nec", "1099-NEC", "misc", "1099-MISC".
pub fn parse(s: &str) -> Option<Form1099> {
let s = s.trim().to_lowercase();
match s.strip_prefix("1099-").unwrap_or(&s) {
"nec" => Some(Form1099::Nec),
"misc" => Some(Form1099::Misc),
_ => None,
}
}
/// "1099-NEC" or "1099-MISC".
pub fn label(self) -> &'static str {
match self {
Form1099::Nec => "1099-NEC",
Form1099::Misc => "1099-MISC",
}
}
}
/// The reporting threshold for payments made in `year`: $600 through 2025;
/// $2,000 from 2026 (One Big Beautiful Bill Act), indexed for inflation
/// after 2026, so later years return $2,000 until updated. Royalties
/// (1099-MISC) have a $10 threshold this doesn't model. Pass an explicit
/// threshold to override.
pub fn default_threshold(year: i32) -> Decimal {
if year <= 2025 {
Decimal::new(600, 0)
} else {
Decimal::new(2000, 0)
}
}
/// One vendor's year.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Line {
/// The vendor.
pub vendor_id: VendorId,
/// Name.
pub name: String,
/// The return it gets, if marked.
pub form: Option<Form1099>,
/// W-9 on file.
pub tin_on_file: bool,
/// Cash paid in the year (net of refunds and reversals).
pub paid: Decimal,
/// Marked for a 1099 and paid at least the threshold.
pub reportable: bool,
}
/// The year's 1099 report.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Report {
/// Calendar year.
pub year: i32,
/// The threshold applied.
pub threshold: Decimal,
/// Every vendor marked for a 1099 or paid in the year, by name.
pub vendors: Vec<Vendor1099Line>,
}
/// Cash paid per vendor in calendar `year`, from posted entries tagged
/// with the vendor.
pub fn vendor_1099_report(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
) -> Vendor1099Report {
let threshold = threshold.unwrap_or_else(|| default_threshold(year));
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
for entry in ledger.posted_entries() {
if entry.date().year() != year {
continue;
}
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
continue;
};
let cash: Decimal = entry
.lines()
.iter()
.filter(|l| {
ledger
.chart()
.get(l.account)
.is_some_and(|a| a.account_type == AccountType::Asset)
})
.map(|l| match l.side {
crate::Side::Credit => l.amount.amount,
crate::Side::Debit => -l.amount.amount,
})
.sum();
*paid.entry(vendor).or_default() += cash;
}
let mut vendors: Vec<Vendor1099Line> = ledger
.list_vendors()
.into_iter()
.filter(|v| v.form_1099.is_some() || paid.get(&v.id).is_some_and(|p| !p.is_zero()))
.map(|v| {
let amount = paid.get(&v.id).copied().unwrap_or_default();
Vendor1099Line {
vendor_id: v.id,
name: v.name.clone(),
form: v.form_1099,
tin_on_file: v.tin_on_file,
paid: amount,
reportable: v.form_1099.is_some() && amount >= threshold,
}
})
.collect();
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
Vendor1099Report {
year,
threshold,
vendors,
}
}
+24 -2
View File
@@ -4,7 +4,7 @@
//! 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.
use crate::{AccountId, ChartOfAccounts, Currency, LedgerError, Money, Side};
use crate::{AccountId, ChartOfAccounts, ClassId, Currency, LedgerError, Money, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -29,8 +29,24 @@ pub struct JournalLine {
pub account: AccountId,
/// Which side of the entry this line is on.
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,
/// 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.
@@ -93,6 +109,12 @@ impl JournalEntry {
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`].
pub(crate) fn mark_posted(&mut self) {
self.status = EntryStatus::Posted;
File diff suppressed because it is too large. Load diff
+35 -2
View File
@@ -17,15 +17,48 @@
//! (`docs/oracle.md`) possible.
pub mod account;
pub mod arap;
pub mod assets;
pub mod classes;
pub mod error;
pub mod form1099;
pub mod journal;
pub mod ledger;
pub mod money;
pub mod payouts;
pub mod payroll;
pub mod periods;
pub mod reports;
pub mod schedules;
pub mod tax;
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 form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::Ledger;
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money};
pub use reports::{BalanceSheet, ProfitAndLoss, TrialBalance, TrialBalanceLine};
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{CloseTo, MemberClose, 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,
};
pub use schedules::{
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
ScheduleId, ScheduleKind, TemplateLine,
};
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
+42 -5
View File
@@ -15,15 +15,15 @@ pub struct Currency([u8; 3]);
impl Currency {
/// US dollar.
pub const USD: Currency = Currency([b'U', b'S', b'D']);
pub const USD: Currency = Currency(*b"USD");
/// Euro.
pub const EUR: Currency = Currency([b'E', b'U', b'R']);
pub const EUR: Currency = Currency(*b"EUR");
/// British pound.
pub const GBP: Currency = Currency([b'G', b'B', b'P']);
pub const GBP: Currency = Currency(*b"GBP");
/// Swiss franc.
pub const CHF: Currency = Currency([b'C', b'H', b'F']);
pub const CHF: Currency = Currency(*b"CHF");
/// 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
/// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail.
@@ -123,3 +123,40 @@ impl fmt::Display for Money {
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());
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Payouts: a platform deposit grossed up into what really happened.
//!
//! App stores, payment processors, and marketplaces collect revenue, keep
//! their fees, and deposit the difference. Booking only the deposit as
//! revenue understates income and hides the fees; the correct entry grosses
//! it up:
//!
//! ```text
//! Dr bank net deposit
//! Dr platform fees fees withheld
//! Dr/Cr adjustments refunds, chargebacks, reserves, tax withheld, ...
//! Cr revenue gross sales (one line per product or class)
//! ```
//!
//! [`payout_plan`] builds those lines and refuses unless gross − fees ±
//! adjustments equals the deposit exactly, naming the difference. It knows
//! no platform: callers (or a format-specific parser) supply the numbers
//! from the payout statement, which is the entry's receipt.
use crate::{AccountId, AccountType, ChartOfAccounts, ClassId, LedgerError, Side};
use rust_decimal::Decimal;
/// A revenue or fee line of a payout.
#[derive(Clone, Debug, PartialEq)]
pub struct PayoutLine {
/// The account.
pub account: AccountId,
/// Positive amount.
pub amount: Decimal,
/// Optional class (product line, app, store).
pub class: Option<ClassId>,
}
/// Anything else on the statement: refunds, chargebacks, reserves held or
/// released, taxes withheld, currency conversion. A debit reduces the
/// deposit; a credit increases it.
#[derive(Clone, Debug, PartialEq)]
pub struct PayoutAdjustment {
/// The account.
pub account: AccountId,
/// Debit (reduces the deposit) or credit (increases it).
pub side: Side,
/// Positive amount.
pub amount: Decimal,
/// Optional class.
pub class: Option<ClassId>,
}
/// A payout to gross up.
#[derive(Clone, Debug, PartialEq)]
pub struct Payout {
/// Where the money landed.
pub deposit_account: AccountId,
/// The net deposit. Negative when the platform debited the account
/// (fees and refunds exceeded sales).
pub deposit: Decimal,
/// Gross revenue, credited.
pub revenue: Vec<PayoutLine>,
/// Fees withheld, debited.
pub fees: Vec<PayoutLine>,
/// Everything else.
pub adjustments: Vec<PayoutAdjustment>,
}
/// The payout's totals.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayoutTotals {
/// Sum of revenue lines.
pub gross: Decimal,
/// Sum of fee lines.
pub fees: Decimal,
/// Net effect of adjustments on the deposit (credits − debits).
pub adjustments: Decimal,
/// The deposit.
pub deposit: Decimal,
}
/// One planned line: account, side, amount, class.
pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
/// Validate a payout and build its entry's lines: the deposit first, then
/// fees, adjustments, and revenue. Errors unless every amount is positive,
/// every account is postable and fits its role (revenue lines credit
/// revenue or liability accounts, e.g. deferred revenue or sales tax
/// collected; fees debit expense accounts), and the payout reconciles to
/// the cent.
pub fn payout_plan(
payout: &Payout,
chart: &ChartOfAccounts,
) -> Result<(Vec<PayoutPlanLine>, PayoutTotals), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidPayout(m));
if payout.revenue.is_empty() {
return bad("a payout needs at least one revenue line".into());
}
let check = |account: AccountId,
amount: Decimal,
role: &str,
allowed: &[AccountType]|
-> Result<(), LedgerError> {
let a = chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if a.placeholder {
return Err(LedgerError::InvalidPayout(format!(
"{role} account {} is a placeholder",
a.code
)));
}
if !allowed.is_empty() && !allowed.contains(&a.account_type) {
return Err(LedgerError::InvalidPayout(format!(
"{role} account {} is {:?}; expected {}",
a.code,
a.account_type,
allowed
.iter()
.map(|t| format!("{t:?}"))
.collect::<Vec<_>>()
.join(" or ")
)));
}
if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidPayout(format!(
"{role} amounts must be positive, got {amount} on {}",
a.code
)));
}
Ok(())
};
let deposit_account = chart
.get(payout.deposit_account)
.ok_or(LedgerError::UnknownAccount(payout.deposit_account))?;
if deposit_account.placeholder || deposit_account.account_type != AccountType::Asset {
return bad(format!(
"deposit account {} must be a postable asset account",
deposit_account.code
));
}
for l in &payout.revenue {
check(
l.account,
l.amount,
"revenue",
&[AccountType::Revenue, AccountType::Liability],
)?;
}
for l in &payout.fees {
check(l.account, l.amount, "fee", &[AccountType::Expense])?;
}
for a in &payout.adjustments {
check(a.account, a.amount, "adjustment", &[])?;
}
let gross: Decimal = payout.revenue.iter().map(|l| l.amount).sum();
let fees: Decimal = payout.fees.iter().map(|l| l.amount).sum();
let adjustments: Decimal = payout
.adjustments
.iter()
.map(|a| match a.side {
Side::Credit => a.amount,
Side::Debit => -a.amount,
})
.sum();
let expected = gross - fees + adjustments;
if expected != payout.deposit {
return bad(format!(
"payout doesn't reconcile: gross {gross} − fees {fees} + adjustments \
{adjustments} = {expected}, but the deposit is {} (off by {})",
payout.deposit,
payout.deposit - expected
));
}
let mut plan = Vec::new();
if payout.deposit > Decimal::ZERO {
plan.push((payout.deposit_account, Side::Debit, payout.deposit, None));
} else if payout.deposit < Decimal::ZERO {
plan.push((payout.deposit_account, Side::Credit, -payout.deposit, None));
}
plan.extend(
payout
.fees
.iter()
.map(|l| (l.account, Side::Debit, l.amount, l.class)),
);
plan.extend(
payout
.adjustments
.iter()
.map(|a| (a.account, a.side, a.amount, a.class)),
);
plan.extend(
payout
.revenue
.iter()
.map(|l| (l.account, Side::Credit, l.amount, l.class)),
);
Ok((
plan,
PayoutTotals {
gross,
fees,
adjustments,
deposit: payout.deposit,
},
))
}
+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());
}
}
+261
View File
@@ -0,0 +1,261 @@
//! 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, and which owner
/// accounts (draws, distributions, dividends) it zeroes into equity too.
#[derive(Clone, Debug, PartialEq)]
pub enum CloseTo {
/// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
/// missing): the corporate convention. `sweep` lists dividend or
/// distribution accounts to close into it.
RetainedEarnings {
/// Equity accounts zeroed into Retained Earnings.
sweep: Vec<crate::AccountId>,
},
/// One equity account, e.g. a sole proprietor's capital, with `sweep`
/// (e.g. the owner's draws) zeroed into it.
Account {
/// The equity account receiving the net.
into: crate::AccountId,
/// Equity accounts zeroed into it.
sweep: Vec<crate::AccountId>,
},
/// Members' capital accounts by share (shares sum to 1): the
/// partnership convention, where each member's capital account carries
/// their allocated earnings and absorbs their distributions.
Members(Vec<MemberClose>),
}
/// One member in a partnership close.
#[derive(Clone, Debug, PartialEq)]
pub struct MemberClose {
/// The member's capital account.
pub capital: crate::AccountId,
/// Their share of net income.
pub share: rust_decimal::Decimal,
/// Their distributions account, zeroed into their capital, if they
/// have one.
pub distributions: Option<crate::AccountId>,
}
/// 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
+341
View File
@@ -0,0 +1,341 @@
//! Things that happen on a schedule.
//!
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
//!
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
//! evenly over the months it covers: a year of insurance paid up front is
//! an asset expensed 1/12 a month; a year of subscription revenue
//! collected up front is a liability earned 1/12 a month. Like
//! depreciation, the schedule is the evidence for its postings, so they
//! need no receipts. Each month posts once (idempotent runs).
//! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts*
//! on their dates, never posted entries: the agent attaches the actual
//! receipt and posts through the normal preview, so the receipt policy is
//! never bypassed.
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side};
use chrono::{Datelike, Days, Months, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
/// Opaque handle for an amortization schedule.
pub type ScheduleId = u64;
/// Opaque handle for a recurring entry.
pub type RecurringId = u64;
/// One line of a template entry: account, side, amount.
pub type TemplateLine = (AccountId, Side, Money);
/// What an amortization schedule recognizes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleKind {
/// A prepaid expense: each month debits the expense account and
/// credits the prepaid (asset) account.
PrepaidExpense,
/// Deferred (unearned) revenue: each month debits the deferred revenue
/// (liability) account and credits the revenue account.
DeferredRevenue,
}
/// An amortization schedule.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AmortizationSchedule {
/// Opaque handle.
pub id: ScheduleId,
/// What it is, e.g. "2027 general liability insurance".
pub description: String,
/// Prepaid expense or deferred revenue.
pub kind: ScheduleKind,
/// The amount to spread.
pub total: Money,
/// Any date in the first month covered.
pub start: NaiveDate,
/// Months covered.
pub months: u32,
/// The balance-sheet account: prepaid (asset) or deferred revenue
/// (liability).
pub balance_account: AccountId,
/// The P&L account: expense or revenue.
pub pl_account: AccountId,
/// Months already posted, as their month-end dates.
pub posted: BTreeSet<NaiveDate>,
}
/// Parameters for a new schedule.
#[derive(Clone, Debug)]
pub struct NewSchedule {
/// What it is.
pub description: String,
/// Prepaid expense or deferred revenue.
pub kind: ScheduleKind,
/// The amount to spread (positive).
pub total: Money,
/// Any date in the first month covered.
pub start: NaiveDate,
/// Months covered (at least 1).
pub months: u32,
/// Prepaid (asset) or deferred revenue (liability) account.
pub balance_account: AccountId,
/// Expense or revenue account.
pub pl_account: AccountId,
}
/// The last day of `d`'s month.
pub fn month_end(d: NaiveDate) -> NaiveDate {
let first = NaiveDate::from_ymd_opt(d.year(), d.month(), 1).expect("valid");
first + Months::new(1) - Days::new(1)
}
/// The schedule's monthly postings: (month-end date, amount). Equal
/// amounts to the cent, the last month absorbing rounding, so they sum to
/// the total exactly.
pub fn amortization_plan(
total: Decimal,
start: NaiveDate,
months: u32,
) -> Vec<(NaiveDate, Decimal)> {
let each = (total / Decimal::from(months)).round_dp(2);
let first = NaiveDate::from_ymd_opt(start.year(), start.month(), 1).expect("valid");
(0..months)
.map(|i| {
let date = month_end(first + Months::new(i));
let amount = if i + 1 == months {
total - each * Decimal::from(months - 1)
} else {
each
};
(date, amount)
})
.collect()
}
/// Validate a new schedule against the chart.
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
if new.description.trim().is_empty() {
return bad("description is required".into());
}
if new.total.amount <= Decimal::ZERO {
return bad(format!("total must be positive, got {}", new.total.amount));
}
if new.months == 0 || new.months > 600 {
return bad(format!("months must be 1 to 600, got {}", new.months));
}
let (want_balance, want_pl) = match new.kind {
ScheduleKind::PrepaidExpense => (AccountType::Asset, AccountType::Expense),
ScheduleKind::DeferredRevenue => (AccountType::Liability, AccountType::Revenue),
};
for (id, want) in [
(new.balance_account, want_balance),
(new.pl_account, want_pl),
] {
let a = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
if a.account_type != want || a.placeholder {
return bad(format!(
"account {} must be a postable {want:?} account for a {:?} schedule",
a.code, new.kind
));
}
}
Ok(())
}
impl AmortizationSchedule {
/// The postings for one month: (debit account, credit account).
pub fn accounts(&self) -> (AccountId, AccountId) {
match self.kind {
ScheduleKind::PrepaidExpense => (self.pl_account, self.balance_account),
ScheduleKind::DeferredRevenue => (self.balance_account, self.pl_account),
}
}
/// Months due through `through` and not yet posted.
pub fn due(&self, through: NaiveDate) -> Vec<(NaiveDate, Decimal)> {
amortization_plan(self.total.amount, self.start, self.months)
.into_iter()
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
.collect()
}
/// The amount not yet recognized.
pub fn remaining(&self) -> Decimal {
amortization_plan(self.total.amount, self.start, self.months)
.into_iter()
.filter(|(d, _)| !self.posted.contains(d))
.map(|(_, a)| a)
.sum()
}
}
/// How often a recurring entry happens.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Frequency {
/// Every 7 days.
Weekly,
/// Every month, on the start date's day (clamped to month end).
Monthly,
/// Every three months.
Quarterly,
/// Every year.
Yearly,
}
impl Frequency {
/// Parse "weekly", "monthly", "quarterly", "yearly" ("annually").
pub fn parse(s: &str) -> Option<Frequency> {
match s.trim().to_lowercase().as_str() {
"weekly" => Some(Frequency::Weekly),
"monthly" => Some(Frequency::Monthly),
"quarterly" => Some(Frequency::Quarterly),
"yearly" | "annually" | "annual" => Some(Frequency::Yearly),
_ => None,
}
}
/// The `k`th occurrence from `start`, computed from the start each
/// time so month-end dates don't drift (Jan 31 → Feb 28 → Mar 31).
pub fn nth(&self, start: NaiveDate, k: u32) -> Option<NaiveDate> {
match self {
Frequency::Weekly => start.checked_add_days(Days::new(7 * k as u64)),
Frequency::Monthly => start.checked_add_months(Months::new(k)),
Frequency::Quarterly => start.checked_add_months(Months::new(3 * k)),
Frequency::Yearly => start.checked_add_months(Months::new(12 * k)),
}
}
}
/// A recurring entry: a balanced template and when it happens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RecurringEntry {
/// Opaque handle.
pub id: RecurringId,
/// Description for each generated entry.
pub description: String,
/// The balanced lines.
pub lines: Vec<TemplateLine>,
/// How often.
pub frequency: Frequency,
/// First occurrence.
pub start: NaiveDate,
/// Last date an occurrence may fall on, if it ends.
pub end: Option<NaiveDate>,
/// Occurrences already generated, by date.
pub generated: BTreeSet<NaiveDate>,
}
/// Parameters for a new recurring entry.
#[derive(Clone, Debug)]
pub struct NewRecurring {
/// Description for each generated entry.
pub description: String,
/// The balanced lines.
pub lines: Vec<TemplateLine>,
/// How often.
pub frequency: Frequency,
/// First occurrence.
pub start: NaiveDate,
/// Optional last date.
pub end: Option<NaiveDate>,
}
/// Validate a new recurring entry: at least two lines, positive amounts,
/// postable accounts, balanced, and an end not before the start.
pub fn validate_recurring(new: &NewRecurring, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
if new.description.trim().is_empty() {
return bad("description is required".into());
}
if new.lines.len() < 2 {
return bad("a recurring entry needs at least two lines".into());
}
if new.end.is_some_and(|e| e < new.start) {
return bad("end is before start".into());
}
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
for (account, side, money) in &new.lines {
let a = chart
.get(*account)
.ok_or(LedgerError::UnknownAccount(*account))?;
if a.placeholder {
return bad(format!("account {} is a placeholder", a.code));
}
if money.amount <= Decimal::ZERO {
return bad(format!(
"line amounts must be positive, got {}",
money.amount
));
}
match side {
Side::Debit => debits += money.amount,
Side::Credit => credits += money.amount,
}
}
if debits != credits {
return bad(format!(
"lines don't balance: debits {debits}, credits {credits}"
));
}
Ok(())
}
impl RecurringEntry {
/// Occurrences on or before `through` (and the end), not yet generated.
pub fn due(&self, through: NaiveDate) -> Vec<NaiveDate> {
let last = self.end.map_or(through, |e| e.min(through));
(0..)
.map_while(|k| self.frequency.nth(self.start, k).filter(|d| *d <= last))
.filter(|d| !self.generated.contains(d))
.collect()
}
}
#[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 plans_sum_exactly_and_land_on_month_ends() {
let plan = amortization_plan(Decimal::new(100_000, 2), d(2026, 11, 15), 3);
// 1,000.00 / 3 = 333.33, 333.33, 333.34.
assert_eq!(
plan,
[
(d(2026, 11, 30), Decimal::new(33_333, 2)),
(d(2026, 12, 31), Decimal::new(33_333, 2)),
(d(2027, 1, 31), Decimal::new(33_334, 2)),
]
);
let leap = amortization_plan(Decimal::new(1200, 0), d(2028, 2, 1), 1);
assert_eq!(leap, [(d(2028, 2, 29), Decimal::new(1200, 0))]);
}
#[test]
fn monthly_occurrences_do_not_drift() {
let f = Frequency::Monthly;
let dates: Vec<NaiveDate> = (0..4).map(|k| f.nth(d(2026, 1, 31), k).unwrap()).collect();
assert_eq!(
dates,
[
d(2026, 1, 31),
d(2026, 2, 28),
d(2026, 3, 31),
d(2026, 4, 30)
]
);
assert_eq!(
Frequency::Quarterly.nth(d(2026, 1, 15), 3),
Some(d(2026, 10, 15))
);
assert_eq!(
Frequency::Weekly.nth(d(2026, 1, 1), 2),
Some(d(2026, 1, 15))
);
assert_eq!(Frequency::parse("Annually"), Some(Frequency::Yearly));
}
}
+513
View File
@@ -0,0 +1,513 @@
//! Tax categories: where each revenue and expense account lands on the
//! business's income tax return, and how much of it is deductible.
//!
//! The books stay book-basis; this is the bridge a CPA (or tax software)
//! walks across. Categories are *stable semantic keys* ("meals",
//! "guaranteed_payments") rather than form line numbers, which move from
//! year to year; each category says which returns it applies to. A
//! deductible percentage (meals 50%, penalties 0%) turns book amounts into
//! deductible amounts and surfaces the book-to-tax differences (Schedule
//! M-1 items) instead of hiding them.
//!
//! Scope is deliberate, like fixed assets: no tax depreciation (MACRS,
//! Section 179, bonus), no basis computations, no filing. Not tax advice.
use crate::{AccountType, Ledger, LedgerError};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The federal income tax return a business files.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TaxForm {
/// Sole proprietorship or single-member LLC: Schedule C (Form 1040).
ScheduleC,
/// Partnership or multi-member LLC: Form 1065.
Form1065,
/// S corporation: Form 1120-S.
Form1120S,
/// C corporation: Form 1120.
Form1120,
}
impl TaxForm {
/// Every form.
pub const ALL: [TaxForm; 4] = [
TaxForm::ScheduleC,
TaxForm::Form1065,
TaxForm::Form1120S,
TaxForm::Form1120,
];
/// "Form 1065", "Schedule C", ...
pub fn label(&self) -> &'static str {
match self {
TaxForm::ScheduleC => "Schedule C",
TaxForm::Form1065 => "Form 1065",
TaxForm::Form1120S => "Form 1120-S",
TaxForm::Form1120 => "Form 1120",
}
}
/// Parse "schedule_c", "1065", "1120s", "1120" (and their labels).
pub fn parse(s: &str) -> Option<TaxForm> {
let k: String = s
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
match k.as_str() {
"schedulec" | "c" => Some(TaxForm::ScheduleC),
"1065" | "form1065" => Some(TaxForm::Form1065),
"1120s" | "form1120s" => Some(TaxForm::Form1120S),
"1120" | "form1120" => Some(TaxForm::Form1120),
_ => None,
}
}
}
/// Where a category sits on the return.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxKind {
/// Income (revenue accounts).
Income,
/// Cost of goods sold (expense accounts).
CostOfGoods,
/// A deduction (expense accounts).
Deduction,
}
/// One tax category.
#[derive(Clone, Copy, Debug, Serialize)]
pub struct TaxCategory {
/// Stable key, e.g. `"meals"`.
pub key: &'static str,
/// Human label, e.g. `"Meals"`.
pub label: &'static str,
/// Income, COGS, or deduction.
pub kind: TaxKind,
/// The returns it appears on.
pub forms: &'static [TaxForm],
/// Percent deductible by default (100 for most; meals 50).
pub default_deductible_percent: u32,
/// What to know about it, for the agent and the CPA.
pub note: &'static str,
}
const ALL: &[TaxForm] = &TaxForm::ALL;
const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120];
const fn cat(
key: &'static str,
label: &'static str,
kind: TaxKind,
forms: &'static [TaxForm],
pct: u32,
note: &'static str,
) -> TaxCategory {
TaxCategory {
key,
label,
kind,
forms,
default_deductible_percent: pct,
note,
}
}
use TaxKind::{CostOfGoods, Deduction, Income};
/// The catalog.
pub const CATEGORIES: &[TaxCategory] = &[
cat(
"gross_receipts",
"Gross receipts or sales",
Income,
ALL,
100,
"Sales recorded gross (platform and processor fees are a separate deduction).",
),
cat(
"returns_allowances",
"Returns and allowances",
Income,
ALL,
100,
"Refunds and allowances; reduces gross receipts.",
),
cat(
"interest_income",
"Interest income",
Income,
ALL,
100,
"Pass-throughs report it separately to owners (portfolio income).",
),
cat("other_income", "Other income", Income, ALL, 100, ""),
cat(
"cost_of_goods_sold",
"Cost of goods sold",
CostOfGoods,
ALL,
100,
"Inventory and direct costs of what was sold.",
),
cat("advertising", "Advertising", Deduction, ALL, 100, ""),
cat(
"bad_debts",
"Bad debts",
Deduction,
ALL,
100,
"Only debts previously included in income (accrual basis).",
),
cat(
"car_truck",
"Car and truck expenses",
Deduction,
ALL,
100,
"Actual costs or the standard mileage rate, not both.",
),
cat(
"charitable_contributions",
"Charitable contributions",
Deduction,
ALL,
100,
"Separately stated to owners for partnerships and S corps; limited for C corps; not a Schedule C deduction.",
),
cat(
"commissions_fees",
"Commissions and fees",
Deduction,
ALL,
100,
"Includes app store, marketplace, and payment processor fees.",
),
cat(
"contract_labor",
"Contract labor",
Deduction,
ALL,
100,
"Payments to non-employees; see 1099 reporting.",
),
cat(
"depreciation",
"Depreciation (book)",
Deduction,
ALL,
100,
"Book depreciation; tax depreciation (MACRS, Section 179, bonus) is computed separately by the CPA.",
),
cat(
"employee_benefits",
"Employee benefit programs",
Deduction,
ALL,
100,
"",
),
cat(
"guaranteed_payments",
"Guaranteed payments to partners",
Deduction,
&[TaxForm::Form1065],
100,
"Payments to partners for services or capital, regardless of profit; reported on each partner's K-1.",
),
cat(
"insurance",
"Insurance (other than health)",
Deduction,
ALL,
100,
"",
),
cat("interest_expense", "Interest", Deduction, ALL, 100, ""),
cat(
"legal_professional",
"Legal and professional services",
Deduction,
ALL,
100,
"Attorneys, accountants, consultants.",
),
cat(
"meals",
"Meals",
Deduction,
ALL,
50,
"Generally 50% deductible; the other half is a book-to-tax difference.",
),
cat("office_expense", "Office expense", Deduction, ALL, 100, ""),
cat(
"officer_compensation",
"Compensation of officers",
Deduction,
CORPS,
100,
"Wages paid to officer-shareholders through payroll.",
),
cat(
"organization_startup",
"Organizational and start-up costs",
Deduction,
ALL,
100,
"Tax: up to $5,000 each deductible in the first year, the rest amortized over 180 months; the CPA computes it.",
),
cat(
"pension_profit_sharing",
"Pension and profit-sharing plans",
Deduction,
ALL,
100,
"",
),
cat(
"rent_equipment",
"Rent or lease: vehicles, machinery, equipment",
Deduction,
ALL,
100,
"",
),
cat(
"rent_property",
"Rent or lease: other business property",
Deduction,
ALL,
100,
"",
),
cat(
"repairs",
"Repairs and maintenance",
Deduction,
ALL,
100,
"",
),
cat(
"salaries_wages",
"Salaries and wages",
Deduction,
ALL,
100,
"Employees (not owners of a partnership, whose pay is guaranteed payments).",
),
cat("supplies", "Supplies", Deduction, ALL, 100, ""),
cat(
"taxes_licenses",
"Taxes and licenses",
Deduction,
ALL,
100,
"Payroll taxes, business licenses, state fees; not federal income tax.",
),
cat(
"travel",
"Travel",
Deduction,
ALL,
100,
"Lodging and transportation away from home; meals go under Meals.",
),
cat("utilities", "Utilities", Deduction, ALL, 100, ""),
cat(
"other_deductions",
"Other deductions",
Deduction,
ALL,
100,
"Software, subscriptions, bank fees, dues, and the rest; the CPA itemizes.",
),
cat(
"nondeductible",
"Nondeductible expenses",
Deduction,
ALL,
0,
"Fines, penalties, political contributions, federal income tax: a book expense with no deduction.",
),
];
/// Look up a category by key.
pub fn category(key: &str) -> Option<&'static TaxCategory> {
CATEGORIES.iter().find(|c| c.key == key)
}
/// Check that `key` can tag an account of `account_type` and, when a form
/// is known, that it appears on that form.
pub fn check_category(
key: &str,
account_type: AccountType,
form: Option<TaxForm>,
) -> Result<&'static TaxCategory, LedgerError> {
let c = category(key)
.ok_or_else(|| LedgerError::InvalidTaxCategory(format!("unknown tax category {key:?}")))?;
let fits = match c.kind {
TaxKind::Income => matches!(account_type, AccountType::Revenue | AccountType::Expense),
TaxKind::CostOfGoods | TaxKind::Deduction => account_type == AccountType::Expense,
};
if !fits {
return Err(LedgerError::InvalidTaxCategory(format!(
"{key} is for {} accounts, not {account_type:?}",
if c.kind == TaxKind::Income {
"revenue"
} else {
"expense"
}
)));
}
if let Some(f) = form
&& !c.forms.contains(&f)
{
return Err(LedgerError::InvalidTaxCategory(format!(
"{key} does not appear on {}",
f.label()
)));
}
Ok(c)
}
/// One category's line on the tax summary.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TaxSummaryLine {
/// Category key.
pub category: String,
/// Category label.
pub label: String,
/// The accounts that roll into it, `"CODE Name"`.
pub accounts: Vec<String>,
/// Book amount: revenue for income, expense for COGS and deductions.
pub book: Decimal,
/// Deductible (or taxable) amount after the deductible percentage.
pub tax: Decimal,
}
/// An account with P&L activity and no tax category.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Uncategorized {
/// `"CODE Name"`.
pub account: String,
/// Revenue or Expense.
pub account_type: AccountType,
/// Book amount (normal side).
pub book: Decimal,
}
/// The tax summary for a period: book amounts by tax category, deductible
/// amounts, and every account still needing a category.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaxSummary {
/// First date, inclusive.
pub start: NaiveDate,
/// Last date, inclusive.
pub end: NaiveDate,
/// The return, when known.
pub form: Option<TaxForm>,
/// Income categories.
pub income: Vec<TaxSummaryLine>,
/// Cost of goods sold.
pub cost_of_goods: Vec<TaxSummaryLine>,
/// Deductions.
pub deductions: Vec<TaxSummaryLine>,
/// P&L accounts with activity and no category.
pub uncategorized: Vec<Uncategorized>,
}
impl TaxSummary {
/// Book net income of the categorized accounts.
pub fn book_net(&self) -> Decimal {
sum(&self.income, |l| l.book)
- sum(&self.cost_of_goods, |l| l.book)
- sum(&self.deductions, |l| l.book)
}
/// Income less deductible amounts, before the CPA's other adjustments.
pub fn tax_net(&self) -> Decimal {
sum(&self.income, |l| l.tax)
- sum(&self.cost_of_goods, |l| l.tax)
- sum(&self.deductions, |l| l.tax)
}
}
fn sum(lines: &[TaxSummaryLine], f: fn(&TaxSummaryLine) -> Decimal) -> Decimal {
lines.iter().map(f).sum()
}
/// Build the tax summary for `[start, end]`, closing entries excluded (like
/// the P&L). Deductible amounts round to the cent, half away from zero.
pub fn tax_summary(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
form: Option<TaxForm>,
) -> Result<TaxSummary, LedgerError> {
let pl = crate::reports::profit_and_loss_for_period(ledger, start, end, None)?;
let mut by_cat: BTreeMap<&'static str, TaxSummaryLine> = BTreeMap::new();
let mut uncategorized = Vec::new();
for line in pl.revenue.iter().chain(pl.expenses.iter()) {
let account = ledger.chart().find_by_code(&line.code).ok_or_else(|| {
LedgerError::InvalidTaxCategory(format!("unknown account {}", line.code))
})?;
let name = format!("{} {}", account.code, account.name);
let Some(c) = account.tax_category.as_deref().and_then(category) else {
uncategorized.push(Uncategorized {
account: name,
account_type: account.account_type,
book: line.amount,
});
continue;
};
// Income from an expense-type account (e.g. a contra) counts with
// its sign flipped, so returns reduce receipts.
let book = if c.kind == TaxKind::Income && account.account_type == AccountType::Expense {
-line.amount
} else {
line.amount
};
let pct = account
.deductible_percent
.unwrap_or_else(|| Decimal::from(c.default_deductible_percent));
let tax = (book * pct / Decimal::from(100))
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
let entry = by_cat.entry(c.key).or_insert_with(|| TaxSummaryLine {
category: c.key.to_string(),
label: c.label.to_string(),
accounts: vec![],
book: Decimal::ZERO,
tax: Decimal::ZERO,
});
entry.accounts.push(name);
entry.book += book;
entry.tax += tax;
}
let mut out = TaxSummary {
start,
end,
form,
income: vec![],
cost_of_goods: vec![],
deductions: vec![],
uncategorized,
};
// Catalog order, so the summary reads like the return.
for c in CATEGORIES {
if let Some(line) = by_cat.remove(c.key) {
match c.kind {
TaxKind::Income => out.income.push(line),
TaxKind::CostOfGoods => out.cost_of_goods.push(line),
TaxKind::Deduction => out.deductions.push(line),
}
}
}
Ok(out)
}
+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));
}
+147
View File
@@ -0,0 +1,147 @@
//! 1099 tracking. Numbers computed by hand.
//!
//! 2026 payments:
//! - Dana (1099-NEC, W-9): bill 1,500 paid by check + 700 direct = 2,200,
//! over the 2,000 threshold. A 300 payment on 2025-12-30 counts in 2025.
//! - Ace Rentals (1099-MISC): 1,200 twice, one reversed = 1,200, under.
//! - Sam (1099-NEC): 5,000 by credit card = 0 cash: the card processor's
//! 1099-K covers it.
//! - Bigco (no 1099): 9,000, listed but never reportable.
use chrono::NaiveDate;
use ledger_core::arap::DocumentLine;
use ledger_core::form1099::default_threshold;
use ledger_core::{
AccountType, Currency, Form1099, Ledger, LedgerError, Money, Side, vendor_1099_report,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn books() -> Ledger {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2100", "Credit Card", AccountType::Liability, None, false)
.unwrap();
let contract = l
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let rent = l
.add_account("6600", "Rent", AccountType::Expense, None, false)
.unwrap();
let dana = l.add_vendor("Dana").unwrap();
let ace = l.add_vendor("Ace Rentals").unwrap();
let sam = l.add_vendor("Sam").unwrap();
let bigco = l.add_vendor("Bigco").unwrap();
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
l.set_vendor_1099(ace, Some(Form1099::Misc), false).unwrap();
l.set_vendor_1099(sam, Some(Form1099::Nec), true).unwrap();
let pay = |l: &mut Ledger, date, vendor, dr, cr, amt: &str| {
let e = l.create_entry(date, "payment");
let m = Money::new(dec(amt), Currency::USD);
l.add_line(e, dr, Side::Debit, m).unwrap();
l.add_line(e, cr, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.set_entry_vendor(e, Some(vendor)).unwrap();
e
};
let bill = l
.create_bill(
dana,
d(2026, 3, 1),
d(2026, 3, 31),
vec![DocumentLine {
description: "Design work".into(),
amount: dec("1500"),
}],
)
.unwrap();
l.pay_bill(bill, d(2026, 3, 20), dec("1500"), bank).unwrap();
pay(&mut l, d(2026, 6, 1), dana, contract, bank, "700");
pay(&mut l, d(2025, 12, 30), dana, contract, bank, "300");
pay(&mut l, d(2026, 1, 1), ace, rent, bank, "1200");
let wrong = pay(&mut l, d(2026, 2, 1), ace, rent, bank, "1200");
l.reverse_entry(wrong, d(2026, 2, 2), "duplicate").unwrap();
pay(&mut l, d(2026, 4, 1), sam, contract, card, "5000");
pay(&mut l, d(2026, 5, 1), bigco, contract, bank, "9000");
// Untagged spending is not anyone's 1099.
let e = l.create_entry(d(2026, 7, 1), "untagged");
let m = Money::new(dec("50"), Currency::USD);
l.add_line(e, contract, Side::Debit, m).unwrap();
l.add_line(e, bank, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l
}
#[test]
fn cash_paid_per_vendor_and_who_is_reportable() {
let l = books();
let r = vendor_1099_report(&l, 2026, None);
assert_eq!(r.threshold, dec("2000"));
let rows: Vec<(&str, Decimal, bool)> = r
.vendors
.iter()
.map(|v| (v.name.as_str(), v.paid, v.reportable))
.collect();
assert_eq!(
rows,
[
("Ace Rentals", dec("1200"), false),
("Bigco", dec("9000"), false),
("Dana", dec("2200"), true),
("Sam", dec("0"), false),
]
);
let ace = &r.vendors[0];
assert_eq!(ace.form, Some(Form1099::Misc));
assert!(!ace.tin_on_file);
}
#[test]
fn years_and_thresholds() {
let l = books();
assert_eq!(default_threshold(2025), dec("600"));
assert_eq!(default_threshold(2026), dec("2000"));
let y2025 = vendor_1099_report(&l, 2025, None);
let dana = y2025.vendors.iter().find(|v| v.name == "Dana").unwrap();
assert_eq!(dana.paid, dec("300"));
assert!(!dana.reportable, "300 < 600");
// An override: at 1,000, Ace's 1,200 is reportable.
let r = vendor_1099_report(&l, 2026, Some(dec("1000")));
assert!(
r.vendors
.iter()
.any(|v| v.name == "Ace Rentals" && v.reportable)
);
}
#[test]
fn tags_need_real_entries_and_vendors() {
let mut l = books();
assert!(matches!(
l.set_entry_vendor(999, Some(1)),
Err(LedgerError::UnknownEntry(999))
));
assert!(matches!(
l.set_entry_vendor(1, Some(999)),
Err(LedgerError::UnknownVendor(999))
));
// The bill and its payment were tagged automatically.
assert_eq!(l.entry_vendor(1), Some(1));
assert_eq!(l.entry_vendor(2), Some(1));
l.set_entry_vendor(2, None).unwrap();
assert_eq!(l.entry_vendor(2), None);
assert_eq!(Form1099::parse("1099-NEC"), Some(Form1099::Nec));
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
assert_eq!(Form1099::parse("K"), None);
}
@@ -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 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;
// ---------------------------------------------------------------------------
@@ -49,15 +51,29 @@ struct Company {
fn fixture_company() -> Company {
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
.add_account("1200", "Accounts Receivable", AccountType::Asset, None, false)
.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let equipment = ledger
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let ap = ledger
.add_account("2000", "Accounts Payable", AccountType::Liability, None, false)
.add_account(
"2000",
"Accounts Payable",
AccountType::Liability,
None,
false,
)
.unwrap();
let equity = ledger
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
@@ -70,32 +86,51 @@ fn fixture_company() -> Company {
.unwrap();
// Owner invests $10,000 cash.
post(&mut ledger, date(2), "Owner investment", &[
(cash, Side::Debit, 10_000),
(equity, Side::Credit, 10_000),
]);
post(
&mut ledger,
date(2),
"Owner investment",
&[(cash, Side::Debit, 10_000), (equity, Side::Credit, 10_000)],
);
// Buy equipment for $2,500 cash.
post(&mut ledger, date(5), "Buy equipment", &[
(equipment, Side::Debit, 2_500),
(cash, Side::Credit, 2_500),
]);
post(
&mut ledger,
date(5),
"Buy equipment",
&[(equipment, Side::Debit, 2_500), (cash, Side::Credit, 2_500)],
);
// Bill a client $4,000.
post(&mut ledger, date(10), "Invoice client", &[
(ar, Side::Debit, 4_000),
(revenue, Side::Credit, 4_000),
]);
post(
&mut ledger,
date(10),
"Invoice client",
&[(ar, Side::Debit, 4_000), (revenue, Side::Credit, 4_000)],
);
// Pay January rent, $800.
post(&mut ledger, date(15), "January rent", &[
(rent, Side::Debit, 800),
(cash, Side::Credit, 800),
]);
post(
&mut ledger,
date(15),
"January rent",
&[(rent, Side::Debit, 800), (cash, Side::Credit, 800)],
);
// Collect $1,500 of receivables.
post(&mut ledger, date(20), "Client payment", &[
(cash, Side::Debit, 1_500),
(ar, Side::Credit, 1_500),
]);
post(
&mut ledger,
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 id = ledger.create_entry(date(21), "Bad entry");
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();
assert_eq!(
err,
@@ -135,7 +172,9 @@ fn zero_amount_line_is_rejected() {
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(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!(
ledger.post_entry(id).unwrap_err(),
LedgerError::ZeroAmountLine
@@ -148,7 +187,9 @@ fn unknown_account_is_rejected() {
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Ghost account");
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!(
ledger.post_entry(id).unwrap_err(),
LedgerError::UnknownAccount(999_999)
@@ -164,7 +205,9 @@ fn placeholder_account_is_rejected() {
.unwrap();
let id = ledger.create_entry(date(21), "Post to header");
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!(
ledger.post_entry(id).unwrap_err(),
LedgerError::PlaceholderAccount("100".to_string())
@@ -207,12 +250,16 @@ fn posted_entry_is_immutable() {
let mut ledger = c.ledger;
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.equity, Side::Credit, usd(50)).unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(50))
.unwrap();
ledger.post_entry(id).unwrap();
// Cannot add lines after posting.
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
);
// Cannot post twice.
@@ -233,7 +280,9 @@ fn reversal_nets_to_zero_and_links_back() {
ledger.post_entry(id).unwrap();
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();
assert_eq!(rev_entry.reversal_of(), Some(id));
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 c = fixture_company();
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 {
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();
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.
}
+155
View File
@@ -0,0 +1,155 @@
//! Payout gross-up. Numbers computed by hand.
//!
//! An app-store month: App A sold 1,000.00, App B 500.00; the store kept
//! 15% (225.00) and clawed back a 9.99 refund. Deposit: 1,500.00 − 225.00
//! − 9.99 = 1,265.01.
use chrono::NaiveDate;
use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{
AccountType, Currency, Ledger, LedgerError, Money, Payout, PayoutAdjustment, PayoutLine, Side,
payout_plan,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn d(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 11, day).unwrap()
}
struct Books {
l: Ledger,
bank: u64,
sales: u64,
fees: u64,
refunds: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = a("1000", "Bank", AccountType::Asset);
let sales = a("4000", "App Sales", AccountType::Revenue);
let fees = a("6000", "Platform Fees", AccountType::Expense);
let refunds = a("4100", "Refunds", AccountType::Expense);
Books {
l,
bank,
sales,
fees,
refunds,
}
}
fn line(account: u64, amount: &str) -> PayoutLine {
PayoutLine {
account,
amount: dec(amount),
class: None,
}
}
fn month(b: &Books, deposit: &str) -> Payout {
Payout {
deposit_account: b.bank,
deposit: dec(deposit),
revenue: vec![line(b.sales, "1000.00"), line(b.sales, "500.00")],
fees: vec![line(b.fees, "225.00")],
adjustments: vec![PayoutAdjustment {
account: b.refunds,
side: Side::Debit,
amount: dec("9.99"),
class: None,
}],
}
}
#[test]
fn a_payout_is_grossed_up_to_revenue_and_fees() {
let mut b = books();
let (plan, totals) = payout_plan(&month(&b, "1265.01"), b.l.chart()).unwrap();
assert_eq!(totals.gross, dec("1500.00"));
assert_eq!(totals.fees, dec("225.00"));
assert_eq!(totals.adjustments, dec("-9.99"));
assert_eq!(
plan.iter()
.map(|(a, s, m, _)| (*a, *s, *m))
.collect::<Vec<_>>(),
[
(b.bank, Side::Debit, dec("1265.01")),
(b.fees, Side::Debit, dec("225.00")),
(b.refunds, Side::Debit, dec("9.99")),
(b.sales, Side::Credit, dec("1000.00")),
(b.sales, Side::Credit, dec("500.00")),
]
);
let e = b.l.create_entry(d(30), "App store payout");
for (a, s, m, _) in plan {
b.l.add_line(e, a, s, Money::new(m, Currency::USD)).unwrap();
}
b.l.post_entry(e).unwrap();
let pl = profit_and_loss_for_period(&b.l, d(1), d(30), None).unwrap();
assert_eq!(pl.total_revenue, dec("1500.00"));
assert_eq!(pl.total_expenses, dec("234.99"));
}
#[test]
fn a_payout_must_reconcile_to_the_cent() {
let b = books();
let err = payout_plan(&month(&b, "1265.00"), b.l.chart()).unwrap_err();
let LedgerError::InvalidPayout(msg) = err else {
panic!("{err:?}")
};
assert!(msg.contains("= 1265.01"), "{msg}");
assert!(msg.contains("off by -0.01"), "{msg}");
}
#[test]
fn a_negative_payout_credits_the_bank() {
let b = books();
// 10.00 of sales, 3.00 of fees, a 50.00 refund: the store debits 43.00.
let p = Payout {
deposit_account: b.bank,
deposit: dec("-43.00"),
revenue: vec![line(b.sales, "10.00")],
fees: vec![line(b.fees, "3.00")],
adjustments: vec![PayoutAdjustment {
account: b.refunds,
side: Side::Debit,
amount: dec("50.00"),
class: None,
}],
};
let (plan, _) = payout_plan(&p, b.l.chart()).unwrap();
assert_eq!(plan[0], (b.bank, Side::Credit, dec("43.00"), None));
}
#[test]
fn accounts_must_fit_their_roles() {
let b = books();
let invalid = |p: Payout| {
matches!(
payout_plan(&p, b.l.chart()),
Err(LedgerError::InvalidPayout(_))
)
};
let mut p = month(&b, "1265.01");
p.fees[0].account = b.sales;
assert!(invalid(p), "fees must be expenses");
let mut p = month(&b, "1265.01");
p.revenue[0].account = b.fees;
assert!(invalid(p), "revenue must be revenue or a liability");
let mut p = month(&b, "1265.01");
p.deposit_account = b.sales;
assert!(invalid(p), "deposits land in an asset");
let mut p = month(&b, "1265.01");
p.fees[0].amount = dec("-225.00");
assert!(invalid(p), "amounts are positive");
let mut p = month(&b, "1265.01");
p.revenue.clear();
assert!(invalid(p), "needs revenue");
}
+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);
}
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Amortization schedules and recurring entries. Numbers computed by hand.
use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
use ledger_core::{
AccountType, Currency, EntryStatus, Frequency, Ledger, LedgerError, Money, NewRecurring,
NewSchedule, ScheduleKind, Side,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
struct Books {
l: Ledger,
cash: u64,
prepaid: u64,
deferred: u64,
insurance: u64,
revenue: u64,
rent: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = a("1000", "Cash", AccountType::Asset);
let prepaid = a("1300", "Prepaid Expenses", AccountType::Asset);
let deferred = a("2400", "Deferred Revenue", AccountType::Liability);
let insurance = a("6300", "Insurance", AccountType::Expense);
let revenue = a("4000", "Subscription Revenue", AccountType::Revenue);
let rent = a("6600", "Rent", AccountType::Expense);
Books {
l,
cash,
prepaid,
deferred,
insurance,
revenue,
rent,
}
}
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
l.post_entry(e).unwrap();
}
#[test]
fn a_prepaid_expense_is_expensed_month_by_month() {
let mut b = books();
// A year of insurance, paid March 15: an asset, not an expense yet.
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
let id =
b.l.register_schedule(NewSchedule {
description: "Liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("1200"),
start: d(3, 15),
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
})
.unwrap();
// Through May: March, April, May, at month end, 100 each.
let due: Vec<_> = b.l.get_schedule(id).unwrap().due(d(5, 31));
assert_eq!(
due,
[
(d(3, 31), dec("100")),
(d(4, 30), dec("100")),
(d(5, 31), dec("100"))
]
);
for (month, _) in due {
b.l.post_schedule_month(id, month).unwrap();
}
assert!(
b.l.get_schedule(id).unwrap().due(d(5, 31)).is_empty(),
"idempotent"
);
assert!(matches!(
b.l.post_schedule_month(id, d(4, 30)),
Err(LedgerError::InvalidSchedule(_))
));
assert_eq!(b.l.get_schedule(id).unwrap().remaining(), dec("900"));
let bs = balance_sheet_as_of(&b.l, d(5, 31)).unwrap();
let prepaid = bs.assets.iter().find(|a| a.code == "1300").unwrap();
assert_eq!(prepaid.amount, dec("900"));
let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(5, 31), None).unwrap();
assert_eq!(pl.total_expenses, dec("300"));
}
#[test]
fn deferred_revenue_is_earned_to_the_cent() {
let mut b = books();
// Three months of a subscription collected up front.
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
let id =
b.l.register_schedule(NewSchedule {
description: "Quarterly plan, Acme".into(),
kind: ScheduleKind::DeferredRevenue,
total: usd("1000"),
start: d(1, 5),
months: 3,
balance_account: b.deferred,
pl_account: b.revenue,
})
.unwrap();
let plan: Vec<Decimal> =
b.l.get_schedule(id)
.unwrap()
.due(d(12, 31))
.into_iter()
.map(|(_, a)| a)
.collect();
assert_eq!(plan, [dec("333.33"), dec("333.33"), dec("333.34")]);
for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)) {
b.l.post_schedule_month(id, m).unwrap();
}
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
assert_eq!(pl.total_revenue, dec("1000"));
let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap();
assert!(bs.liabilities.is_empty(), "fully earned");
}
#[test]
fn schedules_respect_closed_periods_and_account_types() {
let mut b = books();
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
b.l.close_period(jan).unwrap();
let id =
b.l.register_schedule(NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("120"),
start: d(1, 1),
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
})
.unwrap();
assert!(matches!(
b.l.post_schedule_month(id, d(1, 31)),
Err(LedgerError::ClosedPeriod(..))
));
b.l.post_schedule_month(id, d(2, 28)).unwrap();
// A prepaid schedule needs an asset and an expense, not a liability.
let bad = b.l.register_schedule(NewSchedule {
description: "x".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("120"),
start: d(1, 1),
months: 12,
balance_account: b.deferred,
pl_account: b.insurance,
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
}
#[test]
fn recurring_entries_generate_drafts_never_posts() {
let mut b = books();
let id =
b.l.register_recurring(NewRecurring {
description: "Office rent".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("2000")),
],
frequency: Frequency::Monthly,
start: d(1, 31),
end: Some(d(4, 30)),
})
.unwrap();
// Through March: Jan 31, Feb 28, Mar 31 (month ends, no drift).
let due = b.l.recurring_entries().next().unwrap().due(d(3, 31));
assert_eq!(due, [d(1, 31), d(2, 28), d(3, 31)]);
let drafts: Vec<_> = due
.iter()
.map(|date| b.l.generate_recurring(id, *date).unwrap())
.collect();
for e in &drafts {
assert_eq!(b.l.get_entry(*e).unwrap().status(), EntryStatus::Draft);
}
// Drafts don't touch the books until posted.
let q1 = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
assert_eq!(q1.total_expenses, Decimal::ZERO);
b.l.post_entry(drafts[1]).unwrap();
let feb = profit_and_loss_for_period(&b.l, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.total_expenses, dec("2000"));
// Never twice; nothing after the end date.
assert!(matches!(
b.l.generate_recurring(id, d(2, 28)),
Err(LedgerError::InvalidSchedule(_))
));
assert_eq!(
b.l.recurring_entries().next().unwrap().due(d(12, 31)),
[d(4, 30)]
);
// Templates must balance.
let bad = b.l.register_recurring(NewRecurring {
description: "x".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("1999")),
],
frequency: Frequency::Monthly,
start: d(1, 1),
end: None,
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
let _ = b.revenue;
}
+890
View File
@@ -0,0 +1,890 @@
//! 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,
distribution_account: None,
share: half,
},
MemberCapital {
name: "Bob".into(),
capital_account: bob,
distribution_account: None,
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| ledger_core::MemberClose {
capital: m.capital_account,
share: m.share,
distributions: None,
})
.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![ledger_core::MemberClose {
capital: members[0].capital_account,
share: cents("0.5"),
distributions: None,
}]);
assert!(matches!(
l.close_period_to(feb, &bad),
Err(LedgerError::InvalidMembers(_))
));
}
fn entry_lines(l: &Ledger, id: ledger_core::EntryId) -> Vec<(String, Side, Decimal)> {
l.get_entry(id)
.unwrap()
.lines()
.iter()
.map(|x| {
(
l.chart().get(x.account).unwrap().code.clone(),
x.side,
x.amount.amount,
)
})
.collect()
}
#[test]
fn a_sole_proprietor_closes_net_income_and_draws_into_capital() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let capital = l
.add_account("3000", "Owner's Capital", AccountType::Equity, None, false)
.unwrap();
let draws = l
.add_account("3100", "Owner's Draws", AccountType::Equity, None, false)
.unwrap();
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let rent = l
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
post(&mut l, d(1, 2), "Owner invests", cash, capital, 5_000);
post(&mut l, d(1, 10), "Sales", cash, sales, 3_000);
post(&mut l, d(1, 15), "Rent", rent, cash, 1_000);
post(&mut l, d(1, 20), "Owner draw", draws, cash, 500);
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
let to = ledger_core::CloseTo::Account {
into: capital,
sweep: vec![draws],
};
let closing = l.close_period_to(jan, &to).unwrap().unwrap();
// Net 2,000 to capital; the 500 of draws zeroed into capital.
assert_eq!(
entry_lines(&l, closing),
[
("4000".to_string(), Side::Debit, dec(3_000)),
("6000".to_string(), Side::Credit, dec(1_000)),
("3000".to_string(), Side::Credit, dec(2_000)),
("3100".to_string(), Side::Credit, dec(500)),
("3000".to_string(), Side::Debit, dec(500)),
]
);
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
// 5,000 + 2,000 − 500 = 6,500, which is exactly the cash.
assert_eq!(bs.equity, vec![line("3000", "Owner's Capital", 6_500)]);
assert_eq!(bs.total_assets, dec(6_500));
assert_eq!(bs.current_earnings, Decimal::ZERO);
}
#[test]
fn a_corporation_closes_dividends_into_retained_earnings() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let stock = l
.add_account("3000", "Common Stock", AccountType::Equity, None, false)
.unwrap();
let dividends = l
.add_account("3200", "Dividends Paid", AccountType::Equity, None, false)
.unwrap();
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
post(&mut l, d(1, 2), "Stock issued", cash, stock, 1_000);
post(&mut l, d(1, 10), "Sales", cash, sales, 900);
post(&mut l, d(1, 25), "Dividend", dividends, cash, 300);
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
let to = ledger_core::CloseTo::RetainedEarnings {
sweep: vec![dividends],
};
l.close_period_to(jan, &to).unwrap();
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
// Retained earnings: 900 earned − 300 paid out.
assert_eq!(
bs.equity,
vec![
line("3000", "Common Stock", 1_000),
line("3999", "Retained Earnings", 600)
]
);
assert!(bs.balanced());
}
#[test]
fn partner_distribution_accounts_roll_into_capital() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let cap_a = l
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
.unwrap();
let cap_b = l
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
.unwrap();
let dist_a = l
.add_account(
"3100",
"Distributions — Alice",
AccountType::Equity,
None,
false,
)
.unwrap();
let dist_b = l
.add_account(
"3101",
"Distributions — Bob",
AccountType::Equity,
None,
false,
)
.unwrap();
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100);
post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100);
post(&mut l, d(1, 10), "Sales", cash, sales, 50);
post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20);
let half = cents("0.5");
let members = vec![
MemberCapital {
name: "Alice".into(),
capital_account: cap_a,
distribution_account: Some(dist_a),
share: half,
},
MemberCapital {
name: "Bob".into(),
capital_account: cap_b,
distribution_account: Some(dist_b),
share: half,
},
];
// January: Bob's distribution comes from his distributions account.
let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
let row = |c: &ledger_core::MemberEquityColumn| {
(c.contributions, c.net_income, c.distributions, c.ending)
};
assert_eq!(
row(&jan_me.members[0]),
(dec(100), dec(25), dec(0), dec(125))
);
assert_eq!(
row(&jan_me.members[1]),
(dec(100), dec(25), dec(20), dec(105))
);
assert_eq!(
jan_me.total.ending,
balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
);
// The close sweeps Bob's distributions into his capital.
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
let to = ledger_core::CloseTo::Members(
members
.iter()
.map(|m| ledger_core::MemberClose {
capital: m.capital_account,
share: m.share,
distributions: m.distribution_account,
})
.collect(),
);
l.close_period_to(jan, &to).unwrap();
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
assert_eq!(
bs.equity,
vec![
line("3000", "Capital — Alice", 125),
line("3001", "Capital — Bob", 105)
]
);
// February starts there.
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(
(feb_me.members[0].beginning, feb_me.members[1].beginning),
(dec(125), dec(105))
);
// A sweep account must be equity.
let mut l2 = Ledger::new(Currency::USD);
let c = l2
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let e = l2
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let p = l2.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
let bad = ledger_core::CloseTo::Account {
into: e,
sweep: vec![c],
};
assert!(matches!(
l2.close_period_to(p, &bad),
Err(LedgerError::InvalidMembers(_))
));
}
+230
View File
@@ -0,0 +1,230 @@
//! Tax categories and the tax summary. Expected numbers computed by hand.
//!
//! The fixture: a partnership's first quarter.
//!
//! | account | category | % | book | deductible |
//! |-----------------------|-----------------------|---|---------|------------|
//! | 4000 Sales | gross_receipts | | 10,000 | 10,000 |
//! | 4100 Refunds (exp.) | returns_allowances | | 250 | (250) |
//! | 6000 Platform fees | commissions_fees | | 1,500 | 1,500 |
//! | 6100 Meals | meals |50%| 333.33 | 166.67 |
//! | 6200 Penalties | nondeductible | 0%| 40 | 0 |
//! | 6300 Software | other_deductions | | 600 | 600 |
//! | 6400 Partner pay | guaranteed_payments | | 2,000 | 2,000 |
//! | 6500 Misc | (none) | | 75 | uncategorized |
use chrono::NaiveDate;
use ledger_core::tax::{TaxForm, check_category, tax_summary};
use ledger_core::{AccountType, Currency, Ledger, LedgerError, Money, Side};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn books() -> Ledger {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let mut acct =
|code: &str, name: &str, t: AccountType, cat: Option<&str>, pct: Option<&str>| {
let id = l.add_account(code, name, t, None, false).unwrap();
if let Some(c) = cat {
l.set_account_tax(id, Some(c), pct.map(dec)).unwrap();
}
id
};
let sales = acct(
"4000",
"Sales",
AccountType::Revenue,
Some("gross_receipts"),
None,
);
let refunds = acct(
"4100",
"Refunds",
AccountType::Expense,
Some("returns_allowances"),
None,
);
let fees = acct(
"6000",
"Platform fees",
AccountType::Expense,
Some("commissions_fees"),
None,
);
let meals = acct("6100", "Meals", AccountType::Expense, Some("meals"), None);
let pen = acct(
"6200",
"Penalties",
AccountType::Expense,
Some("nondeductible"),
None,
);
let soft = acct(
"6300",
"Software",
AccountType::Expense,
Some("other_deductions"),
None,
);
let gp = acct(
"6400",
"Partner pay",
AccountType::Expense,
Some("guaranteed_payments"),
None,
);
let misc = acct("6500", "Misc", AccountType::Expense, None, None);
let mut post = |dr, cr, amt: &str| {
let e = l.create_entry(d(2, 1), "x");
l.add_line(e, dr, Side::Debit, Money::new(dec(amt), Currency::USD))
.unwrap();
l.add_line(e, cr, Side::Credit, Money::new(dec(amt), Currency::USD))
.unwrap();
l.post_entry(e).unwrap();
};
post(cash, sales, "10000");
for (a, amt) in [
(refunds, "250"),
(fees, "1500"),
(meals, "333.33"),
(pen, "40"),
(soft, "600"),
(gp, "2000"),
(misc, "75"),
] {
post(a, cash, amt);
}
l
}
#[test]
fn the_summary_groups_by_category_and_applies_deductible_percents() {
let l = books();
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
let rows = |lines: &[ledger_core::TaxSummaryLine]| -> Vec<(String, Decimal, Decimal)> {
lines
.iter()
.map(|l| (l.category.clone(), l.book, l.tax))
.collect()
};
// Refunds (an expense account) reduce receipts as negative income.
assert_eq!(
rows(&ts.income),
[
("gross_receipts".into(), dec("10000"), dec("10000")),
("returns_allowances".into(), dec("-250"), dec("-250")),
]
);
// Catalog order; meals at 50% (333.33 → 166.665 → 166.67, half away
// from zero); penalties at 0%.
assert_eq!(
rows(&ts.deductions),
[
("commissions_fees".into(), dec("1500"), dec("1500")),
("guaranteed_payments".into(), dec("2000"), dec("2000")),
("meals".into(), dec("333.33"), dec("166.67")),
("other_deductions".into(), dec("600"), dec("600")),
("nondeductible".into(), dec("40"), dec("0")),
]
);
assert_eq!(ts.uncategorized.len(), 1);
assert_eq!(ts.uncategorized[0].account, "6500 Misc");
assert_eq!(ts.uncategorized[0].book, dec("75"));
// Book: 9,750 − 4,473.33 = 5,276.67. Tax: 9,750 − 4,266.67 = 5,483.33.
assert_eq!(ts.book_net(), dec("5276.67"));
assert_eq!(ts.tax_net(), dec("5483.33"));
}
#[test]
fn an_override_beats_the_category_default() {
let mut l = books();
let meals = l.chart().find_by_code("6100").unwrap().id;
// A company party is 100% deductible: override the 50% default.
l.set_account_tax(meals, Some("meals"), Some(dec("100")))
.unwrap();
let ts = tax_summary(&l, d(1, 1), d(3, 31), None).unwrap();
let m = ts
.deductions
.iter()
.find(|x| x.category == "meals")
.unwrap();
assert_eq!(m.tax, dec("333.33"));
}
#[test]
fn categories_must_fit_the_account_and_the_return() {
let mut l = books();
let sales = l.chart().find_by_code("4000").unwrap().id;
let meals = l.chart().find_by_code("6100").unwrap().id;
let bad = |r: Result<(), LedgerError>| matches!(r, Err(LedgerError::InvalidTaxCategory(_)));
assert!(
bad(l.set_account_tax(sales, Some("meals"), None)),
"deduction on revenue"
);
assert!(
bad(l.set_account_tax(meals, Some("no_such"), None)),
"unknown key"
);
assert!(
bad(l.set_account_tax(meals, Some("meals"), Some(dec("101")))),
"over 100%"
);
assert!(
bad(l.set_account_tax(meals, None, Some(dec("50")))),
"percent without category"
);
// Guaranteed payments exist only on a partnership return.
assert!(
check_category(
"guaranteed_payments",
AccountType::Expense,
Some(TaxForm::Form1065)
)
.is_ok()
);
assert!(
check_category(
"guaranteed_payments",
AccountType::Expense,
Some(TaxForm::ScheduleC)
)
.is_err()
);
assert!(
check_category(
"officer_compensation",
AccountType::Expense,
Some(TaxForm::Form1120S)
)
.is_ok()
);
// Clearing works.
l.set_account_tax(meals, None, None).unwrap();
assert!(l.chart().get(meals).unwrap().tax_category.is_none());
}
#[test]
fn forms_parse_from_common_spellings() {
for (s, f) in [
("schedule_c", TaxForm::ScheduleC),
("Schedule C", TaxForm::ScheduleC),
("1065", TaxForm::Form1065),
("Form 1065", TaxForm::Form1065),
("1120s", TaxForm::Form1120S),
("1120-S", TaxForm::Form1120S),
("1120", TaxForm::Form1120),
] {
assert_eq!(TaxForm::parse(s), Some(f), "{s}");
}
assert_eq!(TaxForm::parse("990"), None);
}
+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)));
}
}
+140
View File
@@ -0,0 +1,140 @@
//! 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>,
/// Optional fifth column: the tax category key (see
/// `ledger_core::tax`).
pub tax_category: Option<String>,
}
/// Parse errors.
#[derive(Debug, Error, PartialEq)]
pub enum ChartCsvError {
/// Wrong column count: (found, expected by the header).
#[error("expected {1} columns to match the header, found {0}")]
BadColumnCount(usize, usize),
/// Missing or bad header.
#[error("bad header: expected code,name,type,parent_code[,tax_category]")]
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)
.flexible(true)
.trim(csv::Trim::All)
.from_reader(text.as_bytes());
let mut out = Vec::new();
let mut width = 4;
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();
width = if lower == ["code", "name", "type", "parent_code"] {
4
} else if lower == ["code", "name", "type", "parent_code", "tax_category"] {
5
} else {
return Err(ChartCsvError::BadHeader);
};
continue;
}
if fields.len() != width {
return Err(ChartCsvError::BadColumnCount(fields.len(), width));
}
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())
},
tax_category: fields
.get(4)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(str::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 parses_optional_tax_category_column() {
let csv = "code,name,type,parent_code,tax_category\n4000,Sales,revenue,,gross_receipts\n1000,Cash,asset,,\n";
let rows = parse_chart_csv(csv).unwrap();
assert_eq!(rows[0].tax_category.as_deref(), Some("gross_receipts"));
assert_eq!(rows[1].tax_category, None);
// Rows must match the header's width.
let short = "code,name,type,parent_code,tax_category\n1000,Cash,asset,\n";
assert_eq!(
parse_chart_csv(short),
Err(ChartCsvError::BadColumnCount(4, 5))
);
}
#[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"));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! 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 templates;
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 templates::{ChartTemplate, EntityType, chart_template};
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"));
}
}
+719
View File
@@ -0,0 +1,719 @@
//! 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,
/// Code of their distributions account, if they have one.
pub distribution_code: Option<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| {
let code = l.account.as_deref();
code == Some(m.capital_code.as_str()) || code == m.distribution_code.as_deref()
});
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,
)
}
/// Lay out the tax summary: book and tax amounts by tax category, the
/// difference where they disagree (meals at 50%, penalties at 0%), and any
/// account still needing a category.
pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement {
let diff = |b: Decimal, t: Decimal| if b == t { None } else { Some(b - t) };
let lines = |ls: &[ledger_core::TaxSummaryLine]| -> Vec<Row> {
ls.iter()
.map(|l| {
let codes: Vec<&str> = l
.accounts
.iter()
.map(|a| a.split_whitespace().next().unwrap_or(""))
.collect();
row(
RowKind::Line,
format!("{} ({})", l.label, codes.join(", ")),
vec![Some(l.book), Some(l.tax), diff(l.book, l.tax)],
)
})
.collect()
};
let total = |kind, label: &str, ls: &[ledger_core::TaxSummaryLine]| {
let b: Decimal = ls.iter().map(|l| l.book).sum();
let t: Decimal = ls.iter().map(|l| l.tax).sum();
row(kind, label, vec![Some(b), Some(t), diff(b, t)])
};
let mut rows = vec![section("Income")];
rows.extend(lines(&ts.income));
rows.push(total(RowKind::Subtotal, "Total income", &ts.income));
if !ts.cost_of_goods.is_empty() {
rows.push(spacer());
rows.push(section("Cost of goods sold"));
rows.extend(lines(&ts.cost_of_goods));
rows.push(total(
RowKind::Subtotal,
"Total cost of goods sold",
&ts.cost_of_goods,
));
}
rows.push(spacer());
rows.push(section("Deductions"));
rows.extend(lines(&ts.deductions));
rows.push(total(RowKind::Subtotal, "Total deductions", &ts.deductions));
rows.push(spacer());
rows.push(row(
RowKind::Total,
"Income less deductions",
vec![
Some(ts.book_net()),
Some(ts.tax_net()),
diff(ts.book_net(), ts.tax_net()),
],
));
if !ts.uncategorized.is_empty() {
rows.push(spacer());
rows.push(section("Not yet categorized"));
rows.push(subheading(
"Assign each a tax category (books.set_tax_category); not included above:",
));
rows.extend(ts.uncategorized.iter().map(|u| {
row(
RowKind::Line,
u.account.clone(),
vec![Some(u.book), None, None],
)
}));
}
let title = match ts.form {
Some(f) => format!("Tax Summary ({} categories)", f.label()),
None => "Tax Summary".to_string(),
};
statement(
&title,
period_label(ts.start, ts.end),
&["Book", "Tax", "Difference"],
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(),
distribution_code: None,
},
Member {
name: "Bob".into(),
capital_code: "3001".into(),
distribution_code: None,
},
];
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"
);
}
}
+432
View File
@@ -0,0 +1,432 @@
//! Starter charts of accounts by entity type.
//!
//! A new business shouldn't start from a blank chart, and the right chart
//! depends on what the business *is*: a sole proprietor has an owner's
//! capital and draws; a partnership (or multi-member LLC) has a capital and
//! distributions account per partner and pays partners guaranteed payments;
//! an S corporation pays officer wages and makes shareholder distributions;
//! a C corporation pays dividends and income tax. Each template is a
//! proposal for the agent to review with the human before importing (via
//! `books.import_chart_csv`), with tax categories already assigned, plus
//! the `wanderbooks.toml` settings that go with it (tax form, owners, and
//! where period closes go).
use crate::chart_csv::ChartRow;
use rust_decimal::Decimal;
/// The kinds of business a template exists for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityType {
/// Sole proprietorship or single-member LLC (Schedule C).
SoleProprietorship,
/// Partnership or multi-member LLC (Form 1065).
Partnership,
/// S corporation, including an LLC electing S status (Form 1120-S).
SCorporation,
/// C corporation (Form 1120).
CCorporation,
}
impl EntityType {
/// Parse "sole_proprietorship", "partnership", "s_corporation",
/// "c_corporation" and common spellings ("llc" alone is ambiguous).
pub fn parse(s: &str) -> Option<EntityType> {
let k: String = s
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
match k.as_str() {
"soleproprietorship" | "soleprop" | "singlememberllc" | "smllc" => {
Some(EntityType::SoleProprietorship)
}
"partnership" | "multimemberllc" | "mmllc" => Some(EntityType::Partnership),
"scorporation" | "scorp" | "s" => Some(EntityType::SCorporation),
"ccorporation" | "ccorp" | "c" | "corporation" => Some(EntityType::CCorporation),
_ => None,
}
}
}
/// A starter chart plus the settings that go with it.
#[derive(Clone, Debug)]
pub struct ChartTemplate {
/// Accounts, parents first.
pub rows: Vec<ChartRow>,
/// The `wanderbooks.toml` additions: tax form, owners, close settings.
pub settings_toml: String,
/// What the human should decide or know, in plain words.
pub notes: Vec<String>,
}
impl ChartTemplate {
/// The accounts as chart CSV with the tax_category column.
pub fn csv(&self) -> String {
let mut out = String::from("code,name,type,parent_code,tax_category\n");
for r in &self.rows {
let field = |s: &str| {
if s.contains([',', '"']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
};
out.push_str(&format!(
"{},{},{},{},{}\n",
r.code,
field(&r.name),
r.account_type,
r.parent_code.as_deref().unwrap_or(""),
r.tax_category.as_deref().unwrap_or("")
));
}
out
}
}
fn acct(code: &str, name: &str, t: &str, tax: Option<&str>) -> ChartRow {
ChartRow {
code: code.to_string(),
name: name.to_string(),
account_type: t.to_string(),
parent_code: None,
tax_category: tax.map(str::to_string),
}
}
/// Equal shares for `n` owners as percents to two places, the last
/// absorbing the remainder so they sum to exactly 100.
fn equal_shares(n: usize) -> Vec<Decimal> {
let each = (Decimal::from(100) / Decimal::from(n as u64)).round_dp(2);
let mut v = vec![each; n];
if let Some((last, rest)) = v.split_last_mut() {
*last = Decimal::from(100) - rest.iter().sum::<Decimal>();
}
v
}
/// Build the template for `entity` with these owners (names; at least two
/// for a partnership, which gets a capital and distributions account each).
pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemplate, String> {
use EntityType::*;
if entity == Partnership && owners.len() < 2 {
return Err("a partnership template needs at least two owners' names".to_string());
}
if owners.len() > 90 {
return Err("at most 90 owners".to_string());
}
let owed_to = match entity {
SoleProprietorship => "Due to Owner",
Partnership => "Due to Members",
SCorporation | CCorporation => "Due to Shareholders",
};
let mut rows = vec![
acct("1000", "Business Checking", "asset", None),
acct("1100", "Savings", "asset", None),
acct("1200", "Accounts Receivable", "asset", None),
acct("1300", "Prepaid Expenses", "asset", None),
acct("1500", "Equipment", "asset", None),
acct("1590", "Accumulated Depreciation", "asset", None),
acct("2000", "Accounts Payable", "liability", None),
acct("2100", "Credit Card", "liability", None),
acct("2200", "Sales Tax Payable", "liability", None),
acct("2300", "Payroll Liabilities", "liability", None),
acct("2400", "Deferred Revenue", "liability", None),
acct("2500", owed_to, "liability", None),
acct("2700", "Loans Payable", "liability", None),
];
let mut settings = String::new();
let mut notes = vec![
"Rename, add, or drop accounts before importing; codes are a convention, not a rule."
.to_string(),
format!(
"{owed_to}: business expenses an owner paid personally, until the business reimburses them."
),
];
match entity {
SoleProprietorship => {
rows.push(acct("3000", "Owner's Capital", "equity", None));
rows.push(acct("3100", "Owner's Draws", "equity", None));
settings.push_str("[company]\ntax_form = \"schedule_c\"\n\n[books]\n# Net income and draws close into the owner's capital.\nclose_into = \"3000\"\nclose_sweep = [\"3100\"]\n");
notes
.push("Money you take out is a draw (3100), not a wage or an expense.".to_string());
}
Partnership => {
let shares = equal_shares(owners.len());
settings.push_str("[company]\ntax_form = \"1065\"\n\n# Each partner's share drives allocations and closes. Equal shares are a\n# placeholder: use the operating agreement's numbers.\n");
for (i, name) in owners.iter().enumerate() {
let cap = format!("{}", 3000 + i);
let dist = format!("{}", 3100 + i);
rows.push(acct(&cap, &format!("Capital — {name}"), "equity", None));
rows.push(acct(
&dist,
&format!("Distributions — {name}"),
"equity",
None,
));
settings.push_str(&format!(
"\n[[company.members]]\nname = \"{name}\"\nshare = \"{}%\"\ncapital_account = \"{cap}\"\ndistribution_account = \"{dist}\"\n",
shares[i]
));
}
notes.push("A partner paid for work gets guaranteed payments (7200), not wages; profit taken out is a distribution.".to_string());
notes.push(
"Shares default to equal; set them from the operating agreement.".to_string(),
);
}
SCorporation | CCorporation => {
rows.push(acct("3000", "Common Stock", "equity", None));
rows.push(acct("3100", "Additional Paid-in Capital", "equity", None));
if entity == SCorporation {
rows.push(acct("3200", "Shareholder Distributions", "equity", None));
settings.push_str("[company]\ntax_form = \"1120s\"\n\n[books]\n# Net income and distributions close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
notes.push("Owner-employees must take reasonable W-2 wages (officer compensation, 7200) before distributions.".to_string());
} else {
rows.push(acct("3200", "Dividends Paid", "equity", None));
settings.push_str("[company]\ntax_form = \"1120\"\n\n[books]\n# Net income and dividends close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
notes.push("The corporation pays its own income tax (7900, not deductible); dividends are not expenses.".to_string());
}
rows.push(acct("3999", "Retained Earnings", "equity", None));
}
}
rows.extend([
acct("4000", "Sales", "revenue", Some("gross_receipts")),
acct(
"4100",
"Refunds and Allowances",
"revenue",
Some("returns_allowances"),
),
acct(
"4900",
"Interest Income",
"revenue",
Some("interest_income"),
),
acct("4950", "Other Income", "revenue", Some("other_income")),
acct(
"5000",
"Cost of Goods Sold",
"expense",
Some("cost_of_goods_sold"),
),
acct("6000", "Advertising", "expense", Some("advertising")),
acct("6050", "Bank Fees", "expense", Some("other_deductions")),
acct(
"6100",
"Platform and Payment Processing Fees",
"expense",
Some("commissions_fees"),
),
acct("6150", "Contract Labor", "expense", Some("contract_labor")),
acct("6200", "Depreciation", "expense", Some("depreciation")),
acct(
"6250",
"Dues and Memberships",
"expense",
Some("other_deductions"),
),
acct("6300", "Insurance", "expense", Some("insurance")),
acct(
"6350",
"Interest Expense",
"expense",
Some("interest_expense"),
),
acct(
"6400",
"Legal and Professional Fees",
"expense",
Some("legal_professional"),
),
acct("6450", "Meals", "expense", Some("meals")),
acct("6500", "Office Expense", "expense", Some("office_expense")),
acct(
"6550",
"Organizational and Start-up Costs",
"expense",
Some("organization_startup"),
),
acct("6600", "Rent", "expense", Some("rent_property")),
acct(
"6650",
"Repairs and Maintenance",
"expense",
Some("repairs"),
),
acct(
"6700",
"Software and Subscriptions",
"expense",
Some("other_deductions"),
),
acct("6750", "Supplies", "expense", Some("supplies")),
acct(
"6800",
"Taxes and Licenses",
"expense",
Some("taxes_licenses"),
),
acct("6850", "Travel", "expense", Some("travel")),
acct("6900", "Utilities", "expense", Some("utilities")),
acct(
"6950",
"Penalties and Nondeductible Expenses",
"expense",
Some("nondeductible"),
),
acct(
"7000",
"Salaries and Wages",
"expense",
Some("salaries_wages"),
),
acct("7050", "Payroll Taxes", "expense", Some("taxes_licenses")),
acct(
"7100",
"Employee Benefits",
"expense",
Some("employee_benefits"),
),
]);
match entity {
Partnership => rows.push(acct(
"7200",
"Guaranteed Payments to Partners",
"expense",
Some("guaranteed_payments"),
)),
SCorporation | CCorporation => rows.push(acct(
"7200",
"Officer Compensation",
"expense",
Some("officer_compensation"),
)),
SoleProprietorship => {}
}
if entity == CCorporation {
rows.push(acct(
"7900",
"Income Tax Expense",
"expense",
Some("nondeductible"),
));
}
Ok(ChartTemplate {
rows,
settings_toml: settings,
notes,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chart_csv::parse_chart_csv;
#[test]
fn templates_round_trip_through_the_chart_parser() {
for e in [
EntityType::SoleProprietorship,
EntityType::Partnership,
EntityType::SCorporation,
EntityType::CCorporation,
] {
let t = chart_template(e, &["Ann Lee".into(), "Bo, Jr.".into(), "Cy".into()]).unwrap();
let parsed = parse_chart_csv(&t.csv()).unwrap();
assert_eq!(parsed, t.rows, "{e:?}");
// Codes are unique.
let mut codes: Vec<&str> = t.rows.iter().map(|r| r.code.as_str()).collect();
codes.sort_unstable();
codes.dedup();
assert_eq!(codes.len(), t.rows.len(), "{e:?}");
}
}
#[test]
fn equity_fits_the_entity() {
let names = |t: &ChartTemplate| -> Vec<String> {
t.rows
.iter()
.filter(|r| r.account_type == "equity")
.map(|r| r.name.clone())
.collect()
};
let sole = chart_template(EntityType::SoleProprietorship, &[]).unwrap();
assert_eq!(names(&sole), ["Owner's Capital", "Owner's Draws"]);
assert!(sole.settings_toml.contains("close_into = \"3000\""));
let p = chart_template(
EntityType::Partnership,
&["A".into(), "B".into(), "C".into()],
)
.unwrap();
assert_eq!(
names(&p),
[
"Capital — A",
"Distributions — A",
"Capital — B",
"Distributions — B",
"Capital — C",
"Distributions — C"
]
);
// Three equal shares that sum to exactly 100.
assert!(p.settings_toml.contains("share = \"33.33%\""));
assert!(p.settings_toml.contains("share = \"33.34%\""));
assert!(
p.rows
.iter()
.any(|r| r.tax_category.as_deref() == Some("guaranteed_payments"))
);
assert!(chart_template(EntityType::Partnership, &["solo".into()]).is_err());
let s = chart_template(EntityType::SCorporation, &[]).unwrap();
assert!(names(&s).contains(&"Shareholder Distributions".to_string()));
assert!(
s.rows
.iter()
.any(|r| r.tax_category.as_deref() == Some("officer_compensation"))
);
let c = chart_template(EntityType::CCorporation, &[]).unwrap();
assert!(names(&c).contains(&"Dividends Paid".to_string()));
assert!(c.rows.iter().any(|r| r.name == "Income Tax Expense"));
}
#[test]
fn every_category_is_valid_for_the_entitys_return() {
use ledger_core::{AccountType, TaxForm, tax::check_category};
for (e, form) in [
(EntityType::SoleProprietorship, TaxForm::ScheduleC),
(EntityType::Partnership, TaxForm::Form1065),
(EntityType::SCorporation, TaxForm::Form1120S),
(EntityType::CCorporation, TaxForm::Form1120),
] {
for r in chart_template(e, &["A".into(), "B".into()]).unwrap().rows {
let Some(cat) = &r.tax_category else { continue };
let t = if r.account_type == "revenue" {
AccountType::Revenue
} else {
AccountType::Expense
};
check_category(cat, t, Some(form))
.unwrap_or_else(|err| panic!("{e:?} {}: {err}", r.code));
}
}
}
#[test]
fn entity_types_parse() {
assert_eq!(
EntityType::parse("multi-member LLC"),
Some(EntityType::Partnership)
);
assert_eq!(EntityType::parse("S corp"), Some(EntityType::SCorporation));
assert_eq!(
EntityType::parse("sole_proprietorship"),
Some(EntityType::SoleProprietorship)
);
assert_eq!(EntityType::parse("LLC"), None, "ambiguous");
}
}
+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
+382
View File
@@ -0,0 +1,382 @@
//! 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,
/// The income tax return the business files, if known: from
/// `tax_form`, else from `tax_classification`.
pub tax_form: Option<ledger_core::TaxForm>,
/// `[books] close_into`: the equity account a period close puts net
/// income in (a sole proprietor's capital, say). Unset: Retained
/// Earnings, or members' capital accounts when there are members.
pub close_into: Option<String>,
/// `[books] close_sweep`: equity accounts (draws, dividends,
/// distributions) a close zeroes into `close_into`.
pub close_sweep: Vec<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,
/// Code of their distributions account, if distributions are recorded
/// separately from capital (zeroed into capital at close).
pub distribution_account: Option<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(),
tax_form: None,
close_into: None,
close_sweep: Vec::new(),
}
}
}
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>,
tax_form: Option<String>,
tax_classification: Option<String>,
#[serde(default)]
members: Vec<MemberToml>,
}
#[derive(Deserialize)]
struct MemberToml {
name: String,
share: String,
capital_account: Option<String>,
distribution_account: Option<String>,
}
#[derive(Default, Deserialize)]
struct BooksSection {
receipt_threshold: Option<String>,
accounting_basis: Option<String>,
close_into: Option<String>,
#[serde(default)]
close_sweep: Vec<String>,
}
/// The return a federal tax classification files: "partnership" → 1065,
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship"
/// or "disregarded entity" → Schedule C. Unrecognized: unknown.
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
let c = c.to_lowercase();
if c.contains("partnership") {
Some(ledger_core::TaxForm::Form1065)
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") {
Some(ledger_core::TaxForm::Form1120S)
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") {
Some(ledger_core::TaxForm::Form1120)
} else if c.contains("sole") || c.contains("disregarded") {
Some(ledger_core::TaxForm::ScheduleC)
} else {
None
}
}
/// 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(),
distribution_account: m
.distribution_account
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty()),
});
}
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%"));
}
}
company.tax_form =
match (&raw.company.tax_form, &raw.company.tax_classification) {
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
})?),
(None, Some(c)) => form_for_classification(c),
(None, None) => None,
};
if !company.members.is_empty() && raw.books.close_into.is_some() {
return Err(
"close_into is for single-owner businesses; with [[company.members]] a close goes to each member's capital account"
.to_string(),
);
}
company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
company.close_sweep = raw.books.close_sweep;
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(),
distribution_account: None,
},
Member {
name: "Katherine Pendergast".into(),
share: half,
capital_account: "3001".into(),
distribution_account: None,
},
]
);
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 tax_form_from_setting_or_classification() {
use ledger_core::TaxForm;
let form = |toml: &str| parse(toml).unwrap().company.tax_form;
assert_eq!(
form("[company]\ntax_form = \"1120s\"\n"),
Some(TaxForm::Form1120S)
);
assert_eq!(
form("[company]\ntax_classification = \"partnership\"\n"),
Some(TaxForm::Form1065)
);
assert_eq!(
form("[company]\ntax_classification = \"S corporation\"\n"),
Some(TaxForm::Form1120S)
);
assert_eq!(
form("[company]\ntax_classification = \"disregarded entity\"\n"),
Some(TaxForm::ScheduleC)
);
assert_eq!(
form("[company]\ntax_classification = \"partnership\"\ntax_form = \"1120\"\n"),
Some(TaxForm::Form1120)
);
assert_eq!(form(""), None);
assert!(parse("[company]\ntax_form = \"990\"\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,
};
+199
View File
@@ -0,0 +1,199 @@
//! 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 = 7;
/// 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;
"#,
r#"
-- v6: tax categories on accounts (where each account lands on the return,
-- and how much of it is deductible). Percent is TEXT, never REAL.
ALTER TABLE accounts ADD COLUMN tax_category TEXT;
ALTER TABLE accounts ADD COLUMN deductible_percent TEXT;
"#,
r#"
-- v7: 1099 tracking: which return each vendor gets, whether its W-9 is on
-- file (never the TIN itself), and the vendor each entry paid.
ALTER TABLE vendors ADD COLUMN form_1099 TEXT;
ALTER TABLE vendors ADD COLUMN tin_on_file INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS entry_vendors(
entry_id INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL
);
"#,
];
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);
}
+685
View File
@@ -0,0 +1,685 @@
//! 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![
ledger_core::MemberClose {
capital: alice,
share: half,
distributions: None,
},
ledger_core::MemberClose {
capital: bob,
share: half,
distributions: None,
},
]),
)
.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();
}
#[test]
fn schedules_and_recurring_entries_replay_exactly() {
let dir = test_dir("schedules");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let prepaid = store
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
let ins = store
.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
let rent = store
.add_account("6600", "Rent", AccountType::Expense, None, false)
.unwrap();
let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
store
.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd_s("1000"),
start: date(1, 1),
months: 3,
balance_account: prepaid,
pl_account: ins,
})
.unwrap();
store
.register_recurring(ledger_core::NewRecurring {
description: "Rent".into(),
lines: vec![
(rent, Side::Debit, usd_s("500")),
(cash, Side::Credit, usd_s("500")),
],
frequency: ledger_core::Frequency::Monthly,
start: date(1, 1),
end: None,
})
.unwrap();
// 333.33, 333.33 through February; rent drafts for Jan 1 and Feb 1.
let posted = store.run_schedules(date(2, 28)).unwrap();
assert_eq!(posted.len(), 2);
let drafts = store.run_recurring(date(2, 28)).unwrap();
assert_eq!(
drafts.iter().map(|d| d.1).collect::<Vec<_>>(),
[date(1, 1), date(2, 1)]
);
store.post_entry(drafts[0].2).unwrap();
let state = |s: &Store| {
(
s.ledger().account_balance(ins).unwrap(),
s.ledger().account_balance(rent).unwrap(),
s.ledger().get_entry(drafts[1].2).unwrap().status(),
)
};
let want = (
Decimal::new(66666, 2),
Decimal::new(500, 0),
ledger_core::EntryStatus::Draft,
);
assert_eq!(state(&store), want);
drop(store);
let mut store = Store::open(&dir).unwrap();
assert_eq!(state(&store), want);
store.rebuild().unwrap();
assert_eq!(state(&store), want);
store.verify().unwrap();
// Idempotent after replay: nothing new is due through February.
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
assert!(store.run_recurring(date(2, 28)).unwrap().is_empty());
}
#[test]
fn vendor_1099_tags_replay_and_project() {
use ledger_core::arap::DocumentLine;
use ledger_core::{Form1099, vendor_1099_report};
let dir = test_dir("form1099");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let labor = store
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let dana = store.add_vendor("Dana").unwrap();
store
.set_vendor_1099(dana, Some(Form1099::Nec), true)
.unwrap();
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let bill = store
.create_bill(
dana,
d(3, 1),
d(3, 31),
vec![DocumentLine {
description: "Design".into(),
amount: Decimal::from(1500),
}],
)
.unwrap();
store
.pay_bill(bill, d(3, 20), Decimal::from(1500), cash)
.unwrap();
let e = store.create_entry(d(6, 1), "Dana, June").unwrap();
store.add_line(e, labor, Side::Debit, usd(700)).unwrap();
store.add_line(e, cash, Side::Credit, usd(700)).unwrap();
store.post_entry(e).unwrap();
store.set_entry_vendor(e, Some(dana)).unwrap();
let dup = store.create_entry(d(6, 2), "Dana, duplicate").unwrap();
store.add_line(dup, labor, Side::Debit, usd(700)).unwrap();
store.add_line(dup, cash, Side::Credit, usd(700)).unwrap();
store.post_entry(dup).unwrap();
store.set_entry_vendor(dup, Some(dana)).unwrap();
store.reverse_entry(dup, d(6, 3), "duplicate").unwrap();
let before = vendor_1099_report(store.ledger(), 2026, None);
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
assert!(before.vendors[0].reportable);
drop(store);
// Reopen (replay), then rebuild the projection from scratch.
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
}
let store = Store::open(&dir).unwrap();
assert_eq!(vendor_1099_report(store.ledger(), 2026, None), before);
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let tagged: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry_vendors WHERE vendor_id = ?",
[dana as i64],
|r| r.get(0),
)
.unwrap();
// Bill, its payment, June, the duplicate, and its reversal.
assert_eq!(tagged, 5);
let form: String = conn
.query_row("SELECT form_1099 FROM vendors", [], |r| r.get(0))
.unwrap();
assert_eq!(form, "nec");
}
}
+3 -2
View File
@@ -54,8 +54,9 @@ The core is a pure domain library and stays that way.
| Crate | Purpose | Depends on |
|---|---|---|
| `ledger-store` | SQLite persistence of the entry log | `ledger-core` |
| `ledger-import` | GnuCash XML read-only import | `ledger-core` |
| `ledger-store` | Journal (hash-chained JSONL) + SQLite projection, blob store | `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` |
| `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.
+353 -14
View File
@@ -21,6 +21,22 @@ 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
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`. Members may name a
`distribution_account`; single-owner businesses may set `[books] close_into`
(the equity account a close puts net income in; default Retained Earnings)
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
## Transports
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
@@ -29,6 +45,17 @@ layer.
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.
## 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
- **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent
@@ -56,52 +83,272 @@ layer.
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
after every line: the agent watches the entry balance in real time.
- `books.post_entry { entry_id, confirmed }` → without confirmation:
`{ preview: { lines, resulting_balances } }`; with confirmation: validates,
posts, returns `{ entry, balances_changed: [{ code, before, after }] }`.
`{ preview: { lines, resulting_balances, receipts: { attached: [...],
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
preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance {}` → canonical JSON (same shape as
`reports::snapshot` trial-balance section).
- `books.balance_sheet {}` → canonical JSON.
- `books.profit_and_loss {}` → canonical JSON.
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
(the trial balance as of the date, default today).
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
`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" }`
→ `{ entries_posted: [...], totals_by_account: [...] }`. The human review
surface: "what did the AI do with my books lately." The digest is the
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 — schedules and recurring entries
Amortization spreads a prepaid expense (asset → expense) or deferred
revenue (liability → revenue) evenly over the months it covers, posting on
each month end, to the cent (the last month absorbs rounding). Like
depreciation, the schedule is the evidence: its postings are exempt from
the receipt policy (the original payment carries the receipt). Recurring
entries never post: they generate drafts, so every real receipt still goes
through the policy.
- `books.create_schedule { kind: "prepaid_expense" | "deferred_revenue",
description, total, start, months, balance_account, pl_account }` →
`{ schedule_id, ..., total, remaining, months: [{ date, amount, posted }] }`.
Prepaid needs an asset and an expense account; deferred revenue a
liability and a revenue account.
- `books.list_schedules {}` → `{ schedules: [...] }`, same shape.
- `books.run_schedules { through?, confirmed? }` → `{ status: "preview" |
"posted", postings: [{ schedule_id, date, description, amount,
entry_id? }] }`: every unposted month ending on or before `through`
(default today). Idempotent; closed periods are refused.
- `books.create_recurring { description, frequency: "weekly" | "monthly" |
"quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }`
→ `{ recurring_id, ..., generated: [] }`. Lines must balance. Monthly and
longer occurrences are computed from the start date each time, so a
month-end start stays on month ends (Jan 31, Feb 28, Mar 31).
- `books.list_recurring {}` → `{ recurring: [...] }`.
- `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date,
entry_id }] }`: one draft per occurrence on or before `through`, once.
## Tools — payouts
- `books.record_payout { date, description, deposit_account, deposit,
revenue: [{ account, amount, class? }], fees?: [{ account, amount,
class? }], adjustments?: [{ account, side, amount, class? }] }` →
`{ entry_id, status: "draft", gross, fees, adjustments, deposit, lines,
next }`. Builds the grossed-up entry — Dr deposit (net; Cr when negative),
Dr fees, Dr/Cr adjustments (a debit reduces the deposit: refunds,
chargebacks, reserves held, tax withheld), Cr revenue (gross; revenue or
liability accounts, e.g. deferred revenue or sales tax collected) — and
refuses unless gross − fees ± adjustments equals the deposit to the cent,
naming the difference. A draft: attach the payout statement, preview,
post. Platform-agnostic; format-specific statement parsers can feed it.
## Tools — 1099 tracking
Payments are counted as cash: credits minus debits to asset accounts on
entries tagged with the vendor. Card payments move no asset (the card
processor's 1099-K covers them), so they drop out; reversals of tagged
entries carry the tag and net out. Thresholds: $600 for payments through
2025, $2,000 from 2026 (indexed for inflation after 2026); always
overridable, always for the CPA to confirm. TINs are never stored, only
whether a W-9 is on file.
- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?,
tin_on_file }] }`.
- `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none",
tin_on_file? }` → the vendor. `vendor` is an id or a name.
- `books.set_entry_vendor { entry_id, vendor? }` → the entry (as
`books.get_entry`, which now shows `vendor`). Omitting `vendor` clears
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
takes `vendor` too; bills and their payments are tagged automatically.
- `books.vendor_1099 { year, threshold? }` → `{ year, threshold, vendors:
[{ vendor_id, name, form_1099?, tin_on_file, paid, reportable }],
missing_w9, notes }`.
## Tools — receipts & documents
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
optional: a receipt can be filed before its entry exists.
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
receipt to its entry.
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
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.file_document { category: "formation" | "legal" | "tax" |
"accounting" | "other", file_name, mime_type, content_base64,
description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder
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
- `books.export { format: "csv" | "json", scope: "journal" | "trial-balance" |
"profit-and-loss" | "balance-sheet" | "chart" }` → `{ content_base64,
file_name }`. CPA-ready output. GnuCash XML arrives with R-FUTURE-4.
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
financing_accounts? }` → `{ content, content_encoding? }`.
- 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 — chart templates
- `books.chart_template { entity_type, owners? }` → `{ csv, settings_toml,
notes }`: a starter chart for a sole proprietorship (incl. single-member
LLC), partnership (incl. multi-member LLC), S corporation, or C
corporation — the equity and owner-pay accounts that entity needs (owner's
capital and draws; per-partner capital and distributions plus guaranteed
payments; officer compensation and shareholder distributions; dividends
and income tax expense), common income and expense accounts with tax
categories, and the `wanderbooks.toml` settings (tax form, members, close
target). A proposal: review with the human, then `books.import_chart_csv`.
## Tools — tax categories
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
default_deductible_percent, note }] }`: where an account can land on the
income tax return, filtered to the business's return when known
(`[company] tax_form`, or derived from `tax_classification`). Stable
semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...),
never form line numbers, which change by year.
- `books.set_tax_category { account, category?, deductible_percent? }`:
map a revenue or expense account to a category (null clears),
optionally overriding its deductible percent. Journaled as
`account.tax_set`.
- `books.export { report: "tax_summary" }`: book vs. tax amounts by
category, the difference where they disagree (meals 50%, penalties 0%),
and every P&L account still uncategorized. In the CPA package for the
fiscal year to date.
- `books.import_chart_csv` accepts an optional fifth `tax_category` column,
validated before anything is created.
## 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)
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
`list_resources` advertises, `read_resource` renders.
- `books://chart`
- `books://reports/trial-balance`, `books://reports/balance-sheet`,
`books://reports/profit-and-loss`
- `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,
auditors) never need tool credentials.
auditors) never need tool credentials. Tested end-to-end over stdio.
## The agent operating contract
@@ -113,7 +360,8 @@ in the server's tool descriptions, so the model reads it at use time):
in prose.
3. **Attach receipts at record time.** A material entry without its source
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
wrong posting is a reversal. Never guess-post.
5. **The digest is the receipt for your work.** After a batch of bookkeeping,
@@ -124,3 +372,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
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.
## 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.
**Layer 2 — Property tests (live now, growing).**
`random_balanced_journals_preserve_invariants` posts hundreds of random
balanced entries and asserts the accounting laws still hold: trial balance
balances, the accounting equation holds, and the sum of all account movements
is exactly zero. New invariants get new properties. (Later: graduate to
`proptest` with shrinking.)
`crates/ledger-core/tests/properties.rs` uses `proptest` (with shrinking)
to assert the accounting laws over hundreds of random inputs: trial balance
always balances, the accounting equation holds, reversals restore zero,
posted entries are immutable and JSON-round-trip clean, and depreciation
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
will: import fixture books into GnuCash's XML format (or drive GnuCash
headless), run both engines, and diff `snapshot()` output automatically —
`cargo xtask oracle`. This is the direct analog of storytold's
`cargo xtask corpus` / `oracle_diff`. Until it exists, Layers 1 and 2 are
the oracle, and they are not optional.
**Layer 3 — Mutation testing (live now).** `cargo mutants -p ledger-core`
(228 mutants as of 2026-10-10: 159 caught, 28 unviable, 2 timeouts from
induced infinite loops, 39 missed). Every *missed* mutant is triaged:
real gaps get new tests (this pass added parent-account validation,
wrong-typed asset links, zero-proceeds disposal, two-asset ids, 1-month
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)
+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.
+33 -9
View File
@@ -8,7 +8,7 @@ Statuses: `done` = implemented + invariant-tested + golden-covered.
`partial` = implemented but missing tests or goldens. `todo` = not started.
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 |
|---|---|---|---|
@@ -19,17 +19,41 @@ No other statuses.
| 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 |
| 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 |
| Tax line mapping + tax summary | Accounts → Tax line mapping; Tax reports | partial | hand-verified fixture (50% meals, nondeductible, contra income); no golden file yet |
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
| Cash flow statement (indirect) | Reports → Cash Flow | todo | R-FUTURE-3 |
| Period close / retained earnings | Close books | todo | R-FUTURE-2 |
| Multi-currency + FX translation | Multi-currency accts | todo | R-FUTURE-1 |
| GnuCash XML import (read-only) | File format | todo | feeds oracle harness; R-FUTURE-4 |
| A/R invoices + payments + aging | Business → Invoices | todo | R-FUTURE-5 |
| A/P bills + payments + aging | Business → Bills | todo | R-FUTURE-5 |
| Budgets | Budgets | todo | R-FUTURE-6 |
| Scheduled transactions | Scheduled txns | todo | R-FUTURE-6 |
| 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 |
| Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 |
| Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
| QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 |
| 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 (recurring entries → drafts) | Scheduled txns / Recurring transactions | partial | hand-verified fixture incl. month-end dates; drafts only, by design |
| Payout gross-up (deposit → revenue, fees, adjustments) | Bank deposit with fee lines; Xero/QBO Stripe & Shopify integrations | partial | hand-verified fixture incl. classes and a negative payout; no statement parsers yet |
| 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet |
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
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.
+119 -13
View File
@@ -23,7 +23,8 @@ requirement conflict, the requirement wins.
organize the chart and reject postings.
- **R-5 — Single currency per ledger (day one).** Every account and every
journal line uses the ledger's base currency. Anything else is rejected,
not converted. Multi-currency with translation is R-FUTURE-1.
not converted, unless the line records its foreign currency, amount, and
rate (R-FUTURE-1, done: see below).
- **R-6 — Line hygiene.** Zero-amount lines are rejected, not ignored.
Unknown accounts are rejected. Single-line entries are rejected.
@@ -34,10 +35,45 @@ requirement conflict, the requirement wins.
total credits (the `balanced()` check).
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
type. The accounting equation holds exactly: assets = liabilities + equity.
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net
income to date) 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,
with net income. Period filtering arrives with R-FUTURE-2.
Current earnings (revenue minus expenses not yet closed to retained
earnings) are folded into equity so the equation holds on a live book.
`balance_sheet_as_of` gives the statement as of any date, a line per
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-9d — Tax categories.** Revenue and expense accounts map to stable
tax categories (`ledger_core::tax::CATEGORIES`: key, label, income /
cost of goods / deduction, the returns it appears on — Schedule C, 1065,
1120-S, 1120 — default deductible percent, guidance), with an optional
per-account deductible percent. `tax_summary(start, end, form)` groups
the P&L by category with book and deductible amounts and lists
uncategorized accounts. Book basis only: no tax depreciation, basis, or
filing; not tax advice.
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
core reports as deterministic JSON (decimal strings, sorted lines, fixed
keys). Two engines fed the same books must produce identical output. This
@@ -50,16 +86,86 @@ requirement conflict, the requirement wins.
- **R-12 — Command surface.** All mutations go through `Ledger` methods.
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
- **R-FUTURE-1 — Multi-currency.** Per-account currencies, entry-level FX
rates, translation gains/losses, reporting in a presentation currency.
- **R-FUTURE-2 — Periods and close.** Fiscal years/periods, closing entries
that move P&L balances to retained earnings, locked periods.
- **R-FUTURE-3 — Cash flow statement** (indirect method).
- **R-FUTURE-4 — GnuCash XML import** (read-only; feeds the oracle harness).
- **R-FUTURE-5 — A/R and A/P workflows** (invoices, bills, payments, aging).
- **R-FUTURE-6 — Budgets and scheduled transactions.**
- **R-FUTURE-6 — Budgets.** Explicitly declined as bloat. (Scheduled
transactions, the other half of R-FUTURE-6, are done: see below.)
## Promoted from future (done)
- **R-? — Cash flow statement** (indirect method). Was R-FUTURE-3.
`ledger-core::reports::cash_flow` + `books.cash_flow`.
- **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), or `CloseTo::Account` (any equity account, e.g.
a sole proprietor's capital). Every target can sweep owner-pay equity
accounts — draws, distributions, dividends — into it: a member's
distributions account into their own capital. The members' equity
statement counts a member's distributions account as part of their
capital. 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-? — Prepaid expenses, deferred revenue, and recurring entries.** Was
part of R-FUTURE-6 ("scheduled transactions"). Amortization schedules
post monthly, equal to the cent, idempotently, honoring closed periods;
the journal records each posting with its full entry and replay checks
it. Recurring entries generate drafts only, never posts, so the receipt
policy always applies.
- **R-? — Payout gross-up.** Platform deposits are recorded gross:
revenue, fees, and adjustments, reconciled to the deposit to the cent,
as a draft that carries the payout statement as its receipt.
- **R-? — 1099 tracking.** Vendors carry the information return they get
and whether a W-9 is on file (never the TIN). Entries carry the vendor
they paid; bills, bill payments, and reversals carry it automatically.
The year's worksheet counts cash paid (asset-account credits minus
debits), so card payments are excluded, against a year-dependent,
overridable threshold. Schema v7.
- **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)
+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"
);
});
+219 -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
Phase 1 (semantics core) is underway. The repo exists, is private, and the
first engine slice is built, tested, and green.
All ten natural next steps are done, tested, and pushed. The repo is now
public. Parity is 12 of 12 against the spreadsheet workflow.
**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
types with normal sides, chart of accounts (codes, hierarchy,
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,
reversal semantics, the accounting laws on a fixture company, a 300-entry
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
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.
## Next arcs (in order)
1. **Period close** (R-FUTURE-2): fiscal periods, closing entries moving P&L
to retained earnings, locked periods. Parity: balance sheet then reports
retained earnings instead of folded current earnings.
2. **Cash flow, indirect method** (R-FUTURE-3): from P&L + balance-sheet
deltas. Golden-covered.
3. **Property tests graduate**: `proptest` with shrinking for the randomized
invariant test; new properties for close and cash flow.
4. **GnuCash XML import** (R-FUTURE-4, read-only): parse GnuCash files into
fixture books. This unblocks the oracle harness.
5. **Oracle harness** (`ledger-oracle`, `cargo xtask oracle`): run fixture
books through our engine and GnuCash headless, diff snapshots
automatically. Parity catalog becomes machine-checked; CI enforces the
floor.
6. **egui app shell** (`books-app`): command-driven UI, native + WASM,
screenshot-in-the-loop per the storytold protocol. Workflow parity catalog
(QuickBooks/Xero reference) starts here.
The project went AI-first on 2026-10-10 (Stephen's call): the primary
interface is MCP, not a cloned GUI. The first storage + MCP slice is done
(arcs 1–3 below). What remains:
1. ~~**`ledger-store`**~~ done — journal + blob store + SQLite projection.
2. ~~**Blob store**~~ done — folded into `ledger-store`.
3. ~~**`ledger-mcp`**~~ done — 18 tools, receipt policy, digest, export.
Remaining from the spec: MCP *resources* (rmcp 3.x has no resource
macros; hand-roll `list_resources`/`read_resource` next), Streamable HTTP
transport, XLSX/IIF export formats.
4. **Capability parity** (`docs/capability-parity.md`): 11 of 12 rows
covered; full CPA export (xlsx/iif) remains. Then the month-long parallel
run against the GSheet; cutover per the cutover rule.
5. Then: oracle harness (GnuCash diff), read-only GUI on the event log,
workflow parity.
## Decisions log
@@ -51,3 +89,163 @@ first engine slice is built, tested, and green.
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.
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.
- 2026-10-11: Tax categories (Stephen: wanderbooks is a general MCP-first
accounting application, so features ship for every small business, not
when Wanderland needs them). Accounts map to stable categories rather than
form line numbers; each category knows its returns and default
deductible percent; the tax summary shows book vs. tax by category and
every uncategorized account. tax_form comes from config or the tax
classification. Schema v6.
- 2026-10-11: Owner equity for every entity type. Chart templates for sole
proprietorships, partnerships/multi-member LLCs, S corps, and C corps
(equity and owner-pay accounts, tax categories, matching config). Closes
sweep draws, distributions, and dividends into their target; partners'
distribution accounts count as capital on the members' equity statement
and close into it. CloseTo became RetainedEarnings { sweep } / Account
{ into, sweep } / Members([MemberClose]); journals without a target, or
with the old plain "retained_earnings", still replay.
- 2026-10-11: Schedules. Prepaid expenses and deferred revenue amortize
monthly on month ends, to the cent; runs are previewed, idempotent, and
exempt from the receipt policy (the schedule is the evidence). Recurring
entries generate drafts, never posts. Journal events schedule.registered,
schedule.posted (with the full entry, checked on replay),
recurring.registered, recurring.generated. No schema change. 59 tools.
- 2026-10-11: Payouts. books.record_payout grosses a platform deposit up
into revenue (by class), fees, and adjustments, refusing unless it
reconciles to the cent; it creates a draft so the payout statement is
attached as the receipt. Pure core logic (payouts.rs), no new journal
events (it journals as a draft and lines). 60 tools. Statement parsers
(Apple, Google, Stripe) wait for sample files.
- 2026-10-11: 1099 tracking. Vendors get form_1099 (NEC/MISC) and
tin_on_file (never the TIN); entries get an optional vendor tag (bills,
bill payments, and reversals inherit it). books.vendor_1099 counts cash
paid per vendor per calendar year against $600 (≤2025) / $2,000 (2026+,
OBBBA) or an override, and lists reportable vendors missing a W-9.
Journal events vendor.1099_set and entry.vendor_set. Schema v7. 64 tools.
Also: requirements and parity no longer list finished R-FUTURE work as
future (only budgets remain, declined).
+68 -30
View File
@@ -1,49 +1,87 @@
# Roadmap
## Phase 1 — Semantics core (now)
## Phase 1 — Semantics core (done)
The engine: money, accounts, journal, ledger commands, trial balance, P&L,
balance sheet, golden files, property tests, and the docs an agent needs to
work here (AGENTS.md, requirements, architecture, oracle, parity, glossary).
Done when the parity floor is 6+ and every `done` item is tested +
golden-covered.
balance sheet, cash flow, fixed assets with depreciation, golden files,
property tests, mutation testing, fuzzing, and the docs an agent needs to
work here. Parity floor: 12 done.
## Phase 2 — Close the accounting loop
## Phase 2 — AI-first interface (done)
- Period close (fiscal periods, closing entries, locked periods)
- Cash flow statement, indirect method
- `proptest` with shrinking; properties for close and cash flow
- Parity floor moves; goldens grow
The primary interface is MCP, not a cloned GUI (Stephen's call, 2026-10-10).
## 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,
automatic `snapshot()` diffing via `cargo xtask oracle`
automatic `snapshot()` diffing.
- Parity catalog becomes machine-readable; CI enforces the non-regression
floor
floor.
- 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
stack: egui/eframe, no Electron, no React)
- Command-driven UI: every UI action goes through the `Ledger` command
surface; the UI holds no accounting logic
- storytold's screenshot-in-the-loop returns: agents verify UI changes
visually, against reference screenshots of GnuCash/QuickBooks kept
maintainer-local (never committed)
- Workflow parity catalog starts (QuickBooks/Xero reference): invoices,
bills, bank rec, chart management
- Consumes the `ledger-store` event log: live updates — the answer to
"watch its files to update live."
- egui or web; a dumb client holding no accounting logic.
- This is the trust layer for humans (Stephen, Kat, the CPA), not the
primary interface. Read-only at first; editing stays with the agent.
- Design references (Stephen, 2026-10-11; screenshots shared in session,
described here):
- **Reports with a date range and a column interval** (QuickBooks
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
- A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions
- Multi-currency with FX translation
- The goal: a self-hosted bookkeeping app with a tested claim to
semantic parity with GnuCash and workflow parity with QuickBooks —
no SaaS subscription required
- A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions.
- Multi-currency with FX translation.
- Period close.
- The goal: a self-hosted bookkeeping system with a tested claim to
semantic parity with GnuCash and capability parity with the old
GSheet+GDrive workflow — no SaaS subscription required.
## Explicitly not on the roadmap