Compare commits

...
78 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 9927e56431 Add the government_agency party role
check / check (push) Waiting to run
Regulators and registries that don't tax the company (a division of
corporations, a licensing board), distinct from tax_agency. Core Role,
MCP descriptions, viewer label/noun/icon, docs, and tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 20:21:11 -06:00
swaitsandClaude Opus 5.5 2275948a64 Audit fixes: party money, reconciliations, require policy, K-1 property
check / check (push) Waiting to run
An independent recompute of the real books found no numeric
disagreements; review found 15 defects, all fixed with spec-first
regression tests:

- k1: a prepaid credited straight to capital is property; a member
  paying a vendor through 2100 is cash. Docs and B-13 match.
- parties: a reversal of a reversal counts on its root's side;
  liability-to-liability payments (member pays the card) count as paid.
- viewer: party money uses the server's cash accounts and full calendar
  year; per-party "What for" scales multi-party entries.
- reconciliations: refuse a withdrawn statement before journaling; new
  books.void_statement; reconciled lines can't be un-cleared; one shared
  reconcilable-accounts rule for digest and viewer.
- CPA package includes receipts linked to entries; a file can't be both
  filed on and linked to one entry.
- parties = "require" covers payroll, tax remittance (agency) and
  disposal (buyer); no-party marks follow reversal chains and clear when
  a vendor is tagged.
- derived member shares are frozen to exact amounts when posted, so a
  settings change never re-splits history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 20:16:03 -06:00
swaitsandClaude Opus 5.5 4c74169d18 Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
check / check (push) Waiting to run
Entries may name several parties, each with an exact amount, a percent,
or a share derived from the members' capital lines or [[company.members]]
shares (entry.parties_set); shares always sum exactly. The 1099
worksheet counts a vendor's share. A member's party page is their
partner capital statement, from the same ledger_core::k1 source as the
CPA package.

books.set_tin stores any party's TIN (members' SSNs for K-1s) in the
vault (party.tin_set, last four in the books). The CPA package carries a
K-1 member worksheet (k1-members.csv, with a members' total that ties to
Schedule L) and the 1099 worksheet with full TINs, saved outside the
books only. files_jointly_with notes spouses' two K-1s land on one 1040.

Adversarial review fixes: reversed contributions no longer inflate K-1
contributions and distributions; a K-1 row never takes another party's
TIN; TIN detection covers dot/slash/dash separators; refusals redact
TINs; vendor tagging can't silently replace shared parties; reversals
follow re-apportioning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 18:20:18 -06:00
swaitsandClaude Opus 5.5 df3fc3f4e4 Party money counts cash paying down a liability (reimbursements, card payments)
check / check (push) Waiting to run
ledger_core::parties::entry_flow: cash lines count; liability lines count
except the part paired against cash in the same entry, which counts once
as paid to the liability's creditor (or received from a lender). A
reimbursement to the members no longer nets to zero. What-for breakdown
comes from the same function and sums to the net exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 16:07:23 -06:00
swaitsandClaude Opus 5.5 ae927c3f73 Attachments, statements, reconciliations, TIN vault, counterparty policy
check / check (push) Waiting to run
MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 15:42:37 -06:00
swaitsandClaude Opus 5.5 e265ed4339 Parties, attachment links, statements, reconciliations (store), TIN vault crate
check / check (push) Waiting to run
Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 14:43:40 -06:00
swaitsandClaude Opus 5.5 0a62353767 new: ignore the database copies kept when the projection is rebuilt (books-data/wanderbooks.mismatch.*)
check / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:53:44 -06:00
swaitsandClaude Opus 5.5 4826a39a88 fix: books with an asset linked to a since-reversed entry failed to open (the projection read-back re-checked the link against today's books); links restore as recorded, Ledger::acquisition_reversed reports ones to relink; regression test
check / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:51:17 -06:00
swaitsandClaude Opus 5.5 27e78a0ed5 Assets can be corrected before depreciation: books.correct_asset (cost, salvage, life, in-service date, with a reason), journaled as asset.corrected with the old figures; refused once depreciation is posted
check / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:41:15 -06:00
swaitsandClaude Opus 5.5 6e6f9d65b2 Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
check / check (push) Waiting to run
- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md)
- store: asset.tax_basis_set journaled, replayed, projected (schema v10)
- mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset
- import: the register's XLSX and PDF gain the federal tax basis table with citations
- viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one
- just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp)
- docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md)
- tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:30:09 -06:00
swaitsandClaude Opus 5.5 49c49aef33 Asset descriptions can change: books.label_asset description, journaled as asset.described (with the old one), replayed, projected, shown in the journal
check / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:43:28 -06:00
swaitsandClaude Opus 5.5 2e14c702c0 viewer: journal text for asset.acquisition_linked; a test that every event type the store writes has plain-language journal text
check / check (push) Waiting to run
The acquisition link was added after the viewer's asset work and showed its raw event name in recent activity. The new test reads the event types from the store's replay arms, so the next new event without text fails it (checked: it fails without this fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:35:39 -06:00
swaitsandClaude Opus 5.5 ee7e205f5c Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify
check / check (push) Waiting to run
- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:18:00 -06:00
swaitsandClaude Opus 5.5 767fd469cd testing: mutation scores for control roles and settings reload
check / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 10:43:00 -06:00
swaitsandClaude Opus 5.5 1771d87e0e Control roles in the journal (books.assign_role, books.roles); the MCP reloads wanderbooks.toml on change and reports settings problems (books.settings, digest); new writes a full commented wanderbooks.toml
check / check (push) Waiting to run
- core: ControlAccount keys, Ledger::assign_role/check_assign_role/role_account (rules.md B-23): one account per role, of the role's type, balance sheet roles move only when empty; invoices, bills, payroll, remittance, and closes post to the assigned account; an assigned role frees its default code for renumbering
- store: role.assigned journaled and replayed (strict account_id)
- mcp: settings.rs reloads by content at the start of each request, keeps the last good settings on a broken edit; books.settings with chart problems; digest settings_problems; instructions say when to edit the toml
- viewer: journal text for role.assigned
- cli: full example wanderbooks.toml, tested to load with every example uncommented
- tests: spec_roles.rs (25, spec-blind), store roles.rs (6), MCP reload/problems/roles, settings unit tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 10:30:16 -06:00
swaitsandClaude Opus 5.5 efa370dfa3 mcp: tests for the settings beside the books, year earnings (open periods, last year's partial close), year 1, a failed flush; drop by-class P&L's dead placeholder guard; record config, MCP, GUI mutation scores
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 20:45:49 -06:00
swaitsandClaude Opus 5.5 ca3a279219 viewer: drop parties' bill arms (the ledger tags bill entries with their vendor, which wins); burn tests for one covered month and breaking even
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 19:00:47 -06:00
swaitsandClaude Opus 5.5 7a6cc154ae gitignore every mutants output folder
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 18:16:01 -06:00
swaitsandClaude Opus 5.5 612d807f98 viewer: fmt::plain refuses underscore and exponent forms (test)
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 18:15:50 -06:00
swaitsandClaude Opus 5.5 c1f92ed3e8 store journals payroll.taxes_remitted; viewer tests for the numbers mutation found unchecked
check / check (push) Canceled after 0s
A payroll tax remittance was journaled as a plain entry, so the viewer
labeled it "Journal entry" and, at a 0.00 threshold, flagged it as
missing a receipt (the server posts it without one, like a payroll
run). The store now names it with payroll.taxes_remitted, as it names
runs with payroll.run; replay and the projection pass over it, and the
viewer's journal page describes it.

tests/view_model.rs: the cash history is never empty when cash moved
and ends on the last day it did; the prior month runs to the same day;
burn averages over only the months the books cover, half away from
zero; cash accounts found by name when the settings name none; card
accounts; each entry's accounts debits first, once each; only what
arrived since the last refresh is fresh; integrity counts files again
without rereading them; the invoice's customer, the bill payment's
vendor, payroll and remittance origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 18:03:10 -06:00
swaitsandClaude Opus 5.5 f0fc725ca2 mcp: P&L by class no longer counts closing entries; oracle tests for every tool's output
check / check (push) Canceled after 0s
profit_and_loss_by_class re-added every posted entry in the range,
closing entries included: after closing January, its by-class P&L for
January showed an unclassified row of the whole period's net income,
negated. Closing entries are left out, as profit_and_loss_for_period
leaves them out.

tests/oracle_mcp.rs (24 tests): every export (CSV, JSON, XLSX, PDF,
IIF) against ledger-core's reports on the same books; the CPA package's
contents; P&L by class; the 1099 worksheet; disposals; depreciation;
payroll remittance; the digest against the journal; books.show's viewer
commands; resources; receipts and documents round-tripping; the 25 MiB
upload limit; bank import sides; reference resolution; partial FX lines;
preview balances. Written against the mutation run's 126 survivors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 17:14:27 -06:00
swaitsandClaude Opus 5.5 d1cbd845c0 just mutants-gui: also the view-model oracle and copy tests
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 16:43:15 -06:00
swaitsandClaude Opus 5.5 2fa84a37ff mcp: balance sheet's year earnings no longer charge this year with closing last year's; tests for what mutation found untested
check / check (push) Canceled after 0s
year_earnings counted every closing entry dated this year against this
year's income, but a close takes all unclosed income through its end,
earlier years included. A partnership closing January after an unclosed
December printed "Net income (loss)" -969 for a year earning +730, and
the closed December as "Undistributed earnings, prior years". The
unclosed part is now the income after the last close, from no earlier
than the fiscal year's start (closes are cumulative).

Unit tests: the 25 MiB upload limit, YYYY-MM periods, config_mentions
for every setting naming a code, report members, year earnings before
and after a close, and which entries may lose their last receipt.
tests/startup.rs: --help, the default data dir under HOME, settings
read from beside the books, and a held lock refused after the wait
instead of waited on forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 15:58:48 -06:00
swaitsandClaude Opus 5.5 47d301bcfe viewer: the overview's numbers checked against ledger-core
check / check (push) Canceled after 0s
Kpis gets a spec (cash, liabilities, receivables, month-to-date against
the same days of last month, fiscal year to date, burn). tests/view_model.rs
computes each from ledger-core on random books (month ends, leap days,
any fiscal start) along with the period ranges, cash history, account
labels, journal count, receipt rule, origins and parties, the blob
root, and integrity counts. Burn rounds half away from zero, like the
rest of wanderbooks (it was banker's rounding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 15:53:57 -06:00
swaitsandClaude Opus 5.5 eba85f9c77 viewer: click to copy values, amounts, and identifiers
check / check (push) Canceled after 0s
Company details (EIN, state number, address, every profile value), an
entry's number and amount, an account's code and balance, a document's
file name and SHA-256 copy on click: a pointer and copy icon on hover,
a check for a moment after. Amounts copy as their exact plain decimal
(fmt::plain: no "$", commas, or parentheses; "-" for negatives); where a
click already drills down (report cells, register and transaction rows,
general ledger lines), a right-click menu copies the amounts, the entry
number, or the description. The sidebar's chain check copies the
journal head and data folder.

tests/copy.rs: every display style copies back to the exact value
(property), and clicking the EIN, an entry's amount, and its number puts
exactly that on the clipboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 15:38:05 -06:00
swaitsandClaude Opus 5.5 8ac4e5438d mutants-gui timeout 300 s (tests compete with other mutants' builds); read_only test restores permissions on failure and sweeps leftovers from killed runs
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:46:01 -06:00
swaitsandClaude Opus 5.5 122fed0a7a cargo: tests keep their books in /tmp when TMPDIR points elsewhere
check / check (push) Canceled after 0s
just mutants* points TMPDIR at ~/.cache for cargo-mutants' tree copies;
the tests inherited it and synced every journal append to disk, several
times slower per mutant. Cargo's [env] reaches the tests it runs, not
cargo-mutants, so the copies stay in the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:19:27 -06:00
swaitsandClaude Opus 5.5 8de1965707 just mutants-gui: run the accuracy tests per mutant (unit + spec_gui), 60 s timeout; the read-only test renders every page and timed out under parallel load
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:12:40 -06:00
swaitsandClaude Opus 5.5 9a1f009f98 spec: ASC paragraphs read directly (360, 340, 210, 230, 606); all confirm the rules
check / check (push) Canceled after 0s
Read in the ASC basic view and vendored as short quotes with paragraph
numbers (docs/spec/sources/fasb-asc.md). Depreciation: any systematic and
rational allocation of cost less salvage (360-10-35-4); prepaids are
current assets with no prescribed expensing method (340-10-05-4,
210-10-45-1/2); cash flows gross and classified three ways, owners'
advances financing (230-10-45-7/10/14(b)/15(b)); a principal's revenue
is gross (606-10-55-37B). Citation fixed: 230-10-45-14(b), not (2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:11:59 -06:00
swaitsandClaude Opus 5.5 fe9625502f viewer: proven read-only; its database check no longer creates SQLite files
check / check (push) Canceled after 0s
tests/read_only.rs: with every file and folder of the books read-only on
disk, the viewer loads, follows live, and renders every page (receipts
included), and every file keeps its bytes, size, and mtime; on writable
books nothing appears beside the database either.

Found: with no writer running, the snapshot's read-only open of the WAL
database had to create -wal/-shm (a write into the books) and failed on
read-only books, showing a false "database unreadable". With no -wal
file no writer has it open, so check_disk now reads it immutable.

just mutants-gui sweeps only what the viewer must get right: the view
model's numbers (books.rs) and their formatting (fmt.rs); 179 mutants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 13:54:40 -06:00
swaitsandClaude Opus 5.5 13a52c5849 just mutants*: no proptest shrinking (a mutant only needs a failing test); GUI timeout 60 s (its suite takes ~10 s)
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 13:47:48 -06:00
swaitsandClaude Opus 5.5 50f22af695 just mutants*: scratch copies go to the user's cache, never /tmp
check / check (push) Canceled after 0s
cargo-mutants copies and builds the tree per job in TMPDIR; on Arch /tmp
is tmpfs (RAM, up to half of it), and a GUI run put 20 GB there. Every
recipe now uses $XDG_CACHE_HOME/wanderbooks/mutants (~/.cache/...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 13:19:20 -06:00
swaitsandClaude Opus 5.5 9837c62117 mcp: HTTP concurrency test; state-machine suite tolerates a contended disk
check / check (push) Canceled after 0s
tests/http_concurrency.rs: six HTTP clients at once against one store
(main.rs serve_http shares it): every bare entry refused at threshold
0.00, every receipted one posted, each contested draft posted exactly
once, books verify and balance, a restart replays the same head.

spec_mcp.rs: the response timeout is 180 s (a mutation run's jobs
contend for a synced disk; the baseline timed out at 30 s), and
MCP_CASES sets the state machine's cases (just mutants-mcp uses 16).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 12:06:24 -06:00
swaitsandClaude Opus 5.5 e6c69a9d34 just mutants-config/-mcp/-gui, weekly in CI
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 11:41:20 -06:00
swaitsandClaude Opus 5.5 9110cd888d gui: spec-blind suite; search finds the best match, axis ticks never overflow
check / check (push) Canceled after 0s
tests/spec_gui.rs (31 spec-blind tests): exact money text round-trips,
file names never escape the folder or hit a Windows device name, history
against a browser model, fuzzy search against a subsequence model, axis
ticks, the view model against ledger-core, and the attention list.

Found and fixed:
- fuzzy matched greedily, so "ac c" scored 1 for "c" (the "c" inside
  "ac") instead of 5 for the word "c"; it now scores the best alignment.
- ticks accumulated float error (a last tick at -5.55e-17 for a top of
  0), gave more than n + 2 ticks at large magnitudes with tiny spans,
  and overflowed for spans near f64's limit. Ticks are now whole
  multiples of the step, checked, with [lo, hi] when f64 has no nice step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 11:39:41 -06:00
swaitsandClaude Opus 5.5 1291547fc4 lean: apportion's parts sum to the whole for any number of parts
check / check (push) Canceled after 0s
The ranks of n parts are a permutation of 0..n-1 (being ahead is a strict
total order; pigeonhole by hand, core Lean has no subperm), so exactly
u parts get a unit and one gets the fraction. apportion_sum and
apportion_sum_of_shortfall; the audit shows only the standard axioms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 10:17:37 -06:00
swaitsandClaude Opus 5.5 3f20df9700 config, mcp, gui: specs from sources, spec-blind suites, and the bugs they found
check / check (push) Canceled after 0s
ledger-config: parse gets a sourced spec. A fiscal year starts on the
first of a month (IRS Pub. 538); tax_classification is read by whole
words (Treas. Reg. 301.7701-3, Schedule C instructions; "business corp"
no longer reads as an S corp, "corporation or S corporation" is refused);
duplicate or blank members and shared capital accounts are refused; a
negative or non-grammatical receipt threshold is refused; codes trimmed.
tests/spec_config.rs (65 spec-blind tests, 14 properties), fuzz target
fuzz_config_parse (weekly). Mutation: 55 of 55 viable caught (from 25/38).

The receipt policy moves to ledger-config, with one loader
(effective_receipt_policy) and one decimal grammar, so the server and
the viewer enforce and show the same rule (the viewer had its own: only
expense-like debits, a silent $25 default, and no config.json).

ledger-mcp: a module spec (MCP 2025-06-18 tools, JSON-RPC 2.0, the
operating contract). Amounts in are exactly -?digits[.digits]; a number
that is one account's code and another's id (vendors, classes alike) is
refused, not guessed; the threshold is validated at startup. Fixed:
add_line with a bad class journaled the line before refusing;
profit_and_loss_by_class ignored `class`; aging buckets said "0". A wire
in front of rmcp answers malformed JSON with -32700 and invalid requests
with -32600 and their id (rmcp dropped the first, omitted the id).
tests/spec_mcp.rs (26 spec-blind tests: every tool's result against its
own outputSchema, a random-call state machine checking the journal is
untouched by every refusal, amount grammar, previews, the receipt policy).

ledger-gui: money, accounting, and dollars never round (they rounded to
cents, banker's style); export file names avoid Windows device names,
trailing dots and spaces, empty names, and 255-byte overflow; specs on
attention, history, fuzzy search, and axis ticks.

ledger-core: the lifetime profit_and_loss is renamed unclosed_earnings
(docs/testing.md #3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 10:13:05 -06:00
swaitsandClaude Opus 5.5 5b8910a1cd books.renumber_account: give an account a new code, history kept
check / check (push) Canceled after 0s
Ledger::renumber_account changes only the code (entries name accounts by
id): refuses an unknown account, a blank or used code, and a control code
for an account of the wrong type or a placeholder. Journaled as
account.renumbered (with the previous code), replayed, projected, shown
in the viewer's journal. The MCP tool lists wanderbooks.toml settings
that still name the old code. The server instructions now warn that the
engine can't tell a same-type account with another purpose at a control
code (a hosting expense at payroll's 6100).

Tests: core property (only the code changes; trial balance equal), core
refusals, the store's command property, MCP end to end with restart.
Mutation on the new code: 8 of 8 viable caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 08:00:58 -06:00
swaitsandClaude Opus 5.5 ccec4e9439 import: XLSX layout spec and tests; mutation 53% -> one equivalent left
check / check (push) Canceled after 0s
report_to_xlsx gains INVARIANTS 4-6 (header block, right-aligned amount
headings, spacer and account spacing, footer one empty row down, rules
exactly where rule_above says, exact text grouped in threes), tested by
reading the workbook's styles, merges and column widths. The general
ledger's footer now sits one empty row down like the other sheets.

Also killed: IIF transliteration arms, tax summary's optional sections,
and day-equals-month bank dates. `just mutants-import`, weekly in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 23:01:07 -06:00
swaitsandClaude Opus 5.5 abba6241e7 import: tests for the XLSX ledger and journal sheets, header and footer, tax summary differences, cash flow adjustments, owner limit
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:36:02 -06:00
swaitsandClaude Opus 5.5 11e33edc60 import: spec-blind suite (40 tests); POSIX two-digit years; exact sub-cent text
check / check (push) Canceled after 0s
tests/spec_import.rs, written from the import crate's specs and sources
alone: every statement foots as printed on random books (P&L, balance
sheet with and without members, cash flow, trial balance, members'
equity), format_amount round-trips, bank CSV round-trips in both layouts
with date-order scenarios, IIF transactions sum to zero with every
account declared and ASCII fields, XLSX cells read back through a small
zip/DEFLATE reader, PDF labels with Typst markup render as text and the
bytes are deterministic, chart CSV and templates.

Its two findings, both fixed:
- "1/15/69" parsed as 2069: chrono's %y cuts over at 70, POSIX strptime
  at 69 (69-99 are 1900s). Two-digit years are computed by POSIX now.
- An amount finer than a cent went into an XLSX as rounded text ("1.01"
  for 1.005); it is written as its exact text now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:17:02 -06:00
swaitsandClaude Opus 5.5 9f81fc0817 ci: weekly fuzz_import_csv; testing.md: import in progress
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:03:26 -06:00
swaitsandClaude Opus 5.5 38a24c7609 import: specs for the PDF, XLSX, and chart parsers; chart header on the first non-blank row
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:03:03 -06:00
swaitsandClaude Opus 5.5 e52b88433c import: templates match the engine's control accounts; report specs
check / check (push) Canceled after 0s
- Starter charts put "Platform and Payment Processing Fees" at 6100, the
  code payroll posts wages to, so a template company that ran payroll
  would book its wages as processing fees; their own payroll accounts
  (7000, 7050, 2300) were never used. Now 6100 Salary & Wage Expense and
  6110 Payroll Tax Expense (the engine's control accounts, with tax
  categories), fees at 6060, and the unused accounts gone (the
  withholding payables 2110-2150 are created when payroll first runs).
  New tests: every template imports with categories valid for its
  return; no template puts another account at a control code; a
  partnership names every partner and its shares sum to 100%.
- format_amount printed a negative zero ("(0.00)" for -0.004); it rounds
  half away from zero first now, like amount_parts.
- Specs (GOAL/SOURCE/INVARIANTS) for statement finishing, P&L, balance
  sheet, cash flow, trial balance, members' equity (each must foot as
  printed), format_amount, and the templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:01:51 -06:00
swaitsandClaude Opus 5.5 ea180d1d8e tax summary: Schedule K items stated separately; XLSX never shows a wrong amount
check / check (push) Canceled after 0s
From the IRS form instructions (now quoted in docs/spec/sources/
irs-tax-lines.md):
- On Forms 1065 and 1120-S, Schedule K takes "only taxable portfolio
  interest", and charitable contributions are among the items the entity
  "must state separately". The tax summary counted both in ordinary
  income and deductions; they now go in TaxSummary::separately_stated,
  outside book_net and tax_net, and the report, MCP export, and viewer
  list them under "Separately stated to owners (Schedule K)".
- Schedule C: "Do not include charitable contributions". The category no
  longer applies to Schedule C (a sole proprietor's gift is personal, an
  owner draw).
- interest_income's note: interest earned in the business itself is
  ordinary income (other_income).

XLSX: a cell number is a binary double and Excel honors 15 significant
digits (Microsoft, "Excel specifications and limits"), so an amount over
$9,999,999,999,999.99 or finer than a cent is written as its exact text,
right-aligned, instead of a number Excel would show wrong. The old
conversion also fell back to 0.0 silently; gone. Property test: every
whole-cent amount up to 15 digits round-trips exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 20:57:00 -06:00
swaitsandClaude Opus 5.5 c72a762ef1 import: grounded in Intuit's specs; atomic chart and bank imports
check / check (push) Canceled after 0s
New vendored sources (docs/spec/sources/): Intuit's bank CSV upload and
IIF formats, ECMA-376 / Excel number storage, and the IRS form lines the
tax categories map to.

IIF export (rewritten to Intuit's IIF Import Kit):
- declares every account first (!ACCNT NAME, ACCNTTYPE, ACCNUM): an
  undeclared account is created in QuickBooks "as a new bank account";
  the MCP export types them (cash accounts BANK, the control accounts AR
  and AP, registered fixed assets FIXASSET, the rest by type);
- subaccounts by colon path, parents first; colons in a name replaced;
- ASCII only (IIF is ASCII text), dashes and quotes transliterated;
- the kit's 2007+ journal layout and M/D/YYYY dates; an entry using an
  undeclared account is refused.

Bank CSV:
- the 4-column layout is money out then money in by position, which
  matches Intuit's "Credit, Debit" and banks' "Debit, Credit" both (the
  doc's "Expense, Income" labels were wrong, the parsing was not);
- a slash date's order comes from the file (a part over 12 settles it)
  or the caller's new date_order, never a guess: Intuit recommends
  dd/mm/yyyy, US banks write mm/dd/yyyy;
- fractions of a cent and double negatives ("(-5)") are refused; the
  header is recognized on the first non-blank row.

Chart CSV: a blank code or name is refused with its row (found by the
new fuzz_import_csv target), and the core now refuses a blank account
code or name too (replay keeps older books).

MCP:
- books.import_chart_csv and books.import_bank_csv rehearse the whole
  file on a copy of the ledger first: a bad row anywhere refuses the
  file (before, a duplicate within the file, a parent after its child,
  or a date in a closed period left earlier rows behind);
- bank rules: the longest matching pattern wins (they arrive in a map,
  so "first match" changed from run to run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 20:47:41 -06:00
swaitsandClaude Opus 5.5 f1bdac4bca store: last mutation survivors killed; 98% with four equivalent left
check / check (push) Canceled after 0s
Final store mutation run: 357 caught + 1 timeout of 365 viable (from 82%
this morning). Three of its seven survivors now have tests:
- replay refuses a re-chained journal whose schedule posting differs
  from what it re-derives (check_replayed_entry guards that event);
- a disposal refused for an account's type says the type it needed;
- a database whose last pages are damaged while its settings still read
  fails the integrity check and is rebuilt (verified to fail with the
  check disabled: "database disk image is malformed").
The four left are equivalent: sort_value's two arms, OpenFlags | vs ^,
and writing the long form of a sweepless retained-earnings close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:59:37 -06:00
swaitsandClaude Opus 5.5 7559b4c808 store: second mutation pass (345/373); trim what rehearsal covers; tests
check / check (push) Canceled after 0s
Store mutation rerun: 345 caught of 373 viable (92.5%, from 82%). The
survivors, triaged:

- Removed code the survivors showed could never matter: set_line_class's
  hand-written checks (its rehearsal refuses first), ensure_on (the
  engine creates missing control accounts itself), and verify's
  live-vs-replay halves of the asset, class, and payroll comparisons
  (the whole-ledger comparison above them already checks that; the
  SQLite read-back halves stay).
- New tests: disposal account types; depreciation periods must be real
  months; search_entries (filters, newest first, paging, no drafts);
  the database's invoice amount paid and status; replay refusing a
  re-chained journal whose closing entry differs from what it
  re-derives; a projection missing a table.
- Equivalent: sort_value's arms (serde_json's maps are already sorted
  without preserve_order, which nothing enables; the function stays as
  the guard if anything ever does), OpenFlags | vs ^ (disjoint bits), and
  writing the long form of a sweepless retained-earnings close (replay
  reads both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:46:00 -06:00
swaitsandClaude Opus 5.5 d67e897f1b store: a database with the wrong currency is rebuilt, not blamed on the journal
check / check (push) Canceled after 0s
Store::open refused books whose database recorded a base currency other
than the journal's, with JournalCorrupt, though the journal was fine and
the database is derived (a mutation-testing survivor: nothing tested
the case). Now the currency comes from the journal, and a database that
fails its integrity check or disagrees on currency goes through the same
path: kept aside for forensics and rebuilt (open SCENARIO 6).

Also tests: the viewer tells a database Behind the journal from one
Ahead of what it has read; the table check counts missing and
unexpected rows; a new journal creates its directory; a stored blob
exists and an unknown one doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:37:20 -06:00
swaitsandClaude Opus 5.5 0cf8fb842f store: tests for mutation survivors; drop pre-checks the rehearsal covers
check / check (push) Canceled after 0s
From the first store mutation run (324 caught of 396 viable):
- receive_payment and pay_bill repeated the engine's refusals by hand
  after rehearsing the engine itself, so those checks could never fire
  (their mutants survived). Removed: the rehearsal is the check.
- New hand-computed tests: a disposal's final catch-up after some
  depreciation was posted (200 of 300, gain 100); a disposal on the
  purchase date (allowed) or the day before (refused); a retained
  earnings close with a sweep replays it; a float inside an array is
  refused; the viewer's journal and head hash.
- The concurrency test's reader stops when the writer thread ends, not
  only when it says it's done, so a mutant that kills the writer fails
  the test at once instead of spinning to the five-minute timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:32:32 -06:00
swaitsandClaude Opus 5.5 db00b74e82 ci: weekly store mutation run and replay fuzzing
check / check (push) Canceled after 0s
`just mutants-store` (64 random cases per mutant, temp books on a RAM
disk in CI) runs beside the core's weekly; fuzz_store_replay joins the
weekly fuzz matrix. docs/testing.md lists the spec-blind store suite,
the refusal property's converse law, and the replay fuzz oracles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:26:27 -06:00
swaitsandClaude Opus 5.5 4d8d5daf37 store: refusals leave the live books untouched; per-filing names; spec suite
check / check (push) Canceled after 0s
The refusal property gained the converse law (an accepted command
journals something; only batch runs may find nothing to do) and checks
the store's class and payroll listings. It found a schedule registered
with an out-of-range total whose first month was refused after its entry
was begun: the live ledger kept a draft the journal never had.

- Core: compound commands (post_schedule_month, reverse_entry,
  close_period_to, create_invoice, create_bill, receive_payment,
  pay_bill, run_payroll, remit_payroll_taxes) run inside
  Ledger::atomically, so a refusal partway leaves the ledger exactly as
  it was.
- Core: a schedule total no line could carry, or finer than a cent, and
  an asset cost beyond the line range, are refused at registration.
  Replay keeps older schedules (replay_register_schedule).
- Store: add_line, add_line_fx, mark_cleared, set_fx_rate,
  post_schedule_month and close_period_to changed the live ledger before
  journaling; they now run on a copy, journal what it produced, then
  swap it in, so a failed write leaves the live books as journaled.

New spec-blind suite tests/spec_store.rs (57 tests written from the
store's specs alone). Its two findings, adjudicated:
- A file filed twice under different names listed the first name and
  type for both (the projection kept them per blob). Schema v8 gives
  each attachment its own; older databases backfill and heal by verify.
- Withdrawn attachments stay in the store's listing, marked (the MCP
  listing hides them): the spec and doc said "omit" and are corrected.

Also removes journal::truncate_to_last_good_seq: unused, and it dropped
everything after the first bad complete line, which the books now treat
as corruption for a person, never repaired automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:24:34 -06:00
swaitsandClaude Opus 5.5 8a49f0ff0e store: specs for the write path, open, verify, heal, receipts, viewer
check / check (push) Canceled after 0s
GOAL/SOURCE/INVARIANTS/SCENARIOS blocks, citing R-11, R-12, R-13a,
docs/receipt-store.md, IRS Pub. 583, and Rev. Proc. 98-25: the module
spec every command method follows (refuse whole, journal first, gapless
chained lines, replay equals live, multi-step commands rehearsed whole,
nothing deleted), Store::create, open, verify, verify_and_heal,
attach_receipt, link_receipt, unlink_receipt, withdraw_attachment, and
Snapshot::refresh. Also moves ensure_control_account's doc comment back
onto it (it had been stacked on rehearse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:08:33 -06:00
swaitsandClaude Opus 5.5 2cbb6a263f store: benchmarks at 10k entries on disk
check / check (push) Canceled after 0s
benches/store.rs (criterion, in `just bench`): open 249 ms, viewer load
616 ms, full verify 809 ms, post one entry (four synced appends) 62 ms.
The books are built on a temp dir and copied under target/ so every
sync hits a real disk. Baselines in docs/testing.md; no budget, by
decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:06:11 -06:00
swaitsandClaude Opus 5.5 fb3b4ab476 closes: a run of members closes splits cumulatively
check / check (push) Canceled after 0s
Each close into members' capital rounded on its own, ties to the
earlier member, so twelve monthly 50/50 closes of odd-cent income could
leave one member several cents ahead of half the year (docs/testing.md
#12, found with the members' equity fix).

- CloseTo::Members now splits cumulatively over a run (closes back to
  back with the same members and shares): each close gives a member
  allocate(run total so far) - allocate(run total before). Periods
  record their close order and a cumulative close's members and net
  (Period::close_seq, member_close) so the next close continues the run
  and replay recomputes it exactly. A close to anything else, or other
  shares, ends the run.
- Journaled "rounding": "cumulative". The per-close rule is kept as
  CloseTo::MembersPerClose for closes journaled "largest_remainder";
  older closes still replay with the last member absorbing it.
- Lean: runPart_telescopes (a member's total from a run is their
  allocation of the run's total) and closePart_total (each close hands
  out exactly its net), audited to the standard axioms.
- Tests: hand-computed three one-cent months (per close A 0.03 / B 0;
  cumulative A 0.02 / B 0.01), a run ending, a property that each member
  stays within a cent of their share (fails on the per-close rule), and
  a store contract that each rule journals and replays as itself.

Wanderland's books have no closes yet, so all of theirs use the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:58:21 -06:00
swaitsandClaude Opus 5.5 f4381f170c viewer: no false "database differs" while the books are being written
check / check (push) Canceled after 0s
New concurrency test: one thread writes entries, drafts and receipts as
fast as it can while a Snapshot (the viewer) refreshes in a loop. At
every refresh the snapshot's books must balance, never go backward, and
never see the writer's database as differing from the journal; at the
end it must equal the writer in full. It failed 30 runs in 50:
check_disk read the applied position and the tables in separate
statements, so a write landing between them made a consistent database
look like it "differs", which the viewer shows as a problem in Needs
attention.

- check_disk reads in one read transaction (one consistent view).
- A database that has applied lines the viewer hasn't read yet is now
  DiskCheck::Ahead ("viewer catching up"), not Behind.
- Without the transaction the test fails 14 runs in 20; with it, 50/50.

Also: members' equity spec 3b states the exact bound (a member's period
income is within two cents of their share, one cent of rounding at each
end; the running balance chains exactly), and the spec-blind property
checks that bound. docs/testing.md gains the concurrency layer and item
12: each close into members' capital still rounds on its own (pending).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:51:15 -06:00
swaitsandClaude Opus 5.5 37824e55d3 members' equity: split undistributed earnings cumulatively
check / check (push) Canceled after 0s
A capital account is one running balance (Form 1065 Schedule K-1
instructions, Item L: beginning and ending capital "based on the
partnership's books and records"), but the statement split prior
earnings and the period's income separately, so a member's ending on one
statement could differ by a cent from their beginning on the next.

Now each member's share of undistributed earnings is allocated as of a
date (largest remainder, as before): beginning as of the day before the
period, ending as of its last day. A member's period income is the
roll-forward's remainder (their share within a rounding cent); incomes
still sum to net income. New property
each_members_capital_chains_across_statements (random books, odd-cent
shares, closes to members or not, any split date) fails on the old code
and passes now. The hand-computed test is recomputed: the old split gave
two cents off an even 50/50 to date.

Also: rules.md B-13 quotes Item L; docs/testing.md #7 and #8 marked
changed; the CPA-question list is replaced by the rule that wanderbooks
settles questions from the sources and makes real choices per-company
settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:41:37 -06:00
swaitsandClaude Opus 5.5 819cd09e0d cash flow: financing and investing gross; account roles are a setting
check / check (push) Canceled after 0s
From ASC 230 (via Deloitte DART and PwC Viewpoint), replacing two
formerly assumed behaviors:

- Financing and investing are gross (230-10-45-7): per account, cash in
  and cash out are separate lines (contributions apart from
  distributions, borrowing apart from repayment), netted only within
  one entry. Lines come in code order, in before out.
- Investing includes making and collecting loans and buying investments
  (230-10-20, 45-13), not only registered fixed assets:
  CashFlowParams gains investing_accounts.
- The roles are per-company settings: [books] financing_accounts and
  investing_accounts in wanderbooks.toml (beside cash_accounts). The MCP
  tools (cash_flow, export, CPA package) default every list to them and
  share one path; the viewer reads them too, falling back to its name
  guess for debt. A due-to-owners liability listed as financing gives
  PwC 6.9.3.1's constructive treatment of expenses an owner paid.

rules.md B-14 quotes the sources; new tests cite them; docs/testing.md
#8 is resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:36:21 -06:00
swaitsandClaude Opus 5.5 08d1a9c230 docs: the assumed list, checked against the standards; mutation score
check / check (push) Canceled after 0s
Each formerly ASSUMED behavior is now CONFIRMED, CONVENTION, DESIGN, or
CHANGE (pending), with its source (Reg. S-X, ASC 230 via Deloitte DART,
IRS K-1 instructions, OpenStax). Two need engine work: members' equity
should allocate cumulatively so each member's beginning equals the prior
ending (#7), and the cash flow should show financing gross and classify
investing by account role (#8). Two questions for the CPA are listed.
The test doc comments that relied on them now cite the verdicts.

Core mutation run: 1,011 caught + 13 timeouts of 1,024 viable, 0 missed.
No benchmark budget, by decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:18:30 -06:00
swaitsandClaude Opus 5.5 0dcf865ff8 core: no entry posts a fraction of a cent; verify compares whole ledgers
check / check (push) Canceled after 0s
CI's run of the store refusal property found a payroll run that posted
$65.27794: the employer tax was passed in unrounded and nothing refused
it, though rules.md B-3 already says a ledger in one money holds no
fractions of its smallest unit. Sums of it then differed in scale
between journal replay and the SQLite read-back.

- Ledger::post_entry refuses any line finer than its currency's ISO 4217
  minor unit (FinerThanMinorUnit), whatever made the line: payroll,
  closes, disposals. add_line refuses it up front. Older journals replay
  as booked through replay_post_entry. Spec tests for the kernel's
  ten-place limit now run on the replay path.
- The property's body is now run_ops, so failing sequences become named
  regression tests (regression_payroll_with_a_fraction_of_a_cent). The
  generator rounds payroll tax to the cent, as callers must.
- Ledger derives PartialEq and gains differs_from; Store::verify compares
  the live ledger with the journal replay in full (drafts, documents,
  schedules, periods, settings), not just balances, and names the parts
  that differ. The property checks the ledger its commands built against
  that replay and against the reopened books.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:17:13 -06:00
swaitsandClaude Opus 5.5 4af2f6c0bb store: set aside a torn last journal line on open; server waits for lock
check / check (push) Canceled after 0s
A crash or power loss mid-append can leave a partial last line. The
writer acknowledges an append only after its whole line is synced, so
that line was never acknowledged: opening now saves those bytes to
journal.jsonl.torn.<time>, truncates to the last newline, and journals
journal.tail_repaired (replay and projection ignore it; the GUI shows
it; books.digest reports it in journal_repairs). Damage before the last
newline is still corruption and still refuses to open. Tested at every
byte of the last append.

The MCP server now waits up to ten seconds for the writer lock: a
restarted server (a client reconnecting) raced the exiting one and died
with Locked, which also made mcp_schedules_and_recurring_over_stdio
flaky. The test harness now waits for each server to exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:58:29 -06:00
swaitsandClaude Opus 5.5 22d1a3d449 store: refusal property reaches every success path; two engine fixes
check / check (push) Canceled after 0s
The refusal property now starts from real books and adds compound
commands with valid arguments (book an entry, register an asset or
schedule, raise an invoice or bill, close a month to members), so every
command's success path is exercised. Compound commands checkpoint after
each step, so the law is checked against the one command refused.
STORE_CASES sets the case count (256 by default).

It found two bugs:

- run_recurring created drafts dated inside a closed period (B-12).
  generate_recurring now refuses them, atomically; the store leaves
  them ungenerated and books.run_recurring reports them in
  skipped_closed_period for a human to decide. Replay uses
  replay_generate_recurring so older journals still open. The store
  also journals before mutating the ledger here, as everywhere else.
- Disposing an asset for no proceeds (scrapping it) journaled a
  catch-up depreciation entry and a half-built draft with a 0.00 line,
  then refused. Zero lines are now left out (Store::disposal_lines,
  shared with the MCP preview), and the whole disposal is rehearsed on
  a clone before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:48:20 -06:00
swaitsandClaude Opus 5.5 1212367138 fix(store): a full disk mid-command leaves the books whole
check / check (push) Canceled after 0s
Max-testing pass on ledger-store, fault injection (rank 1). A full disk
is simulated with no code seam: the test re-runs its own binary as a
child under bash with the file size capped (ulimit -f, SIGXFSZ
ignored), so writes past the cap fail as on a full disk. Two bugs, both
of which left the books unopenable:

1. SQLite side: applying one journal line runs several statements (rows,
   events mirror, last_applied_seq) in autocommit. When the disk filled
   partway, the rows committed but last_applied_seq didn't, and the next
   open's catch-up re-applied the line into "UNIQUE constraint failed:
   entries.id". Each line now applies inside a savepoint: all or
   nothing. (docs/receipt-store.md promised exactly this heal; it
   wasn't atomic.)
2. Journal side: an append that failed partway left a partial line, and
   any later line would land after it, mid-journal. append now cuts the
   file back to its length before the write on any error.

Tests (tests/faults.rs, its own binary because the child re-runs it):
a_full_disk_mid_command_leaves_the_books_whole (the database fills
first) and a_full_disk_mid_append_leaves_only_whole_journal_lines (the
journal writer alone). Both shown to fail without their fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:16:43 -06:00
swaitsandClaude Opus 5.5 725b9beaac fix(store): a blob id never reaches outside the blob store
check / check (push) Canceled after 0s
Max-testing pass on ledger-store (security property, rank 2). Blob ids
come from the journal, which is untrusted input on disk. BlobStore built
paths from any string: an id under four bytes (or with multi-byte
characters) panicked on slicing, and exists() would report on any path
an id like "../../x" reached. get() never served such bytes (it serves
only content whose SHA-256 equals the id), but it read them.

Now only a 64-hex-digit id names a blob; anything else is an error from
get() and false from exists(), touching nothing. Test
a_blob_id_never_reaches_outside_the_blob_store (empty, short, unicode,
traversal, absolute, non-hex, 63/65 digits; uppercase hex still works).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:10:01 -06:00
swaitsandClaude Opus 5.5 d0490bf106 test(core): every surviving mutant killed or argued equivalent
check / check (push) Canceled after 0s
Max-testing pass, step 8. The final mutation run left 112 survivors
(89% caught). Three agents triaged them by file, by the playbook's
table: spec the boundary then test it, a law rather than one more
pinned value, assert the real value, never weaken a test. Every expected
value is hand-computed.

- reports.rs (35): 28 killed, chiefly cash flow (inclusive start date,
  each line's sign and label, a loss on disposal moving to investing,
  financing designation), general ledger and members' equity date
  boundaries, and the balanced() checks (a real report nudged by a cent
  must stop balancing). 7 equivalent: four >/>= after an early return on
  zero; two in dead code (removed in this commit); one sign of a zero
  cleaned on output.
- ledger.rs, kernel.rs, journal.rs, money.rs (52, plus one new): 49
  killed, including every replay_* mutant at the core level (ids,
  accumulation, status, vendor tags, out-of-order refusal), FX rates,
  0%/100% deductible boundaries, a dividend sweep with no income, the
  legacy member rounding at close, a bill paid on its own date, a
  liability-account reconciliation, mixed-currency entries (via serde),
  ISO 4217 minor units, Display/Debug. 3 equivalent: zero refused before
  a </<= check; a period whose end is in a closed period refused earlier;
  columns(0).
- schedules, tax, analysis, payouts, assets, form1099 (25): 23 killed
  (schedule length 1-600, recurring end on its start, frequency parsing
  and stepping, cost of goods in tax net, a zero payout, disposal on the
  purchase date, quarter starts, labels); 2 equivalent (zero adds zero;
  days_held is provably >= 1).

Two small production cleanups the triage surfaced: kernel::columns(0)
returned a negative-zero credit that would print "-0" (regression
a_zero_movement_has_no_negative_zero_column, shown to fail before); and
cash_flow computed a book value it never used.

Spec lines added where a boundary wasn't specified (cash flow's
inclusive start and line signs, members' equity start date, payout with
no deposit, disposal on the purchase date, bill paid on its own date).
`just mutants` now runs core and store tests (the store's replay tests),
not the whole workspace, which rebuilt the viewer for every mutant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:00:45 -06:00
swaitsandClaude Opus 5.5 8c8ba19471 fix(store): one writer at a time; a second is refused, not allowed to corrupt the books
check / check (push) Canceled after 0s
Max-testing pass on ledger-store, found during the survey: nothing
stopped two processes from opening the same books for writing. Two
agent sessions (two MCP servers) on one books dir would each extend the
hash chain from the same last line; one write from each broke the
journal ("seq gap: expected 3, found 2") and the books would not open.

The store now holds an exclusive lock on books-data/wanderbooks.lock
(std's File::try_lock, no new dependency) from before it reads the
journal until it is dropped; the OS releases it if the process dies. A
second writer gets StoreError::Locked saying another process has the
books open. The viewer reads through Snapshot and never takes the lock.
The lock file is git-ignored in the books scaffold.

Regression: a_second_writer_is_refused_while_the_first_has_the_books
(refused while open, allowed after close, books whole).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 16:05:59 -06:00
swaitsandClaude Opus 5.5 c539004d1a test: fuzzing, Miri, benchmarks, CI, and a strategy note; the journal refuses floats
check / check (push) Canceled after 0s
Max-testing pass, ranks 4-6 and step 9 for ledger-core.

Fuzzing (rank 4): fuzz_op_sequence now drives balanced pairs, foreign
lines, periods, closes, and amounts from raw decimal bits, and asserts
the books stay valid (TB balances, posted entries validate, no draft in
a closed period); new fuzz_core_serde (every persisted core type
round-trips) and fuzz_store_open (a corrupt journal is an error, never a
panic); hangs count (-timeout=10). 54M executions; one crash:
- fuzz_journal_line: a number too big for an integer parsed as a float,
  and its canonical form (which the hash chain covers) re-canonicalized
  differently. The journal never holds floats (R-2), so the reader now
  refuses them (JournalLine::parse, used by every journal reader);
  regression a_journal_line_with_a_float_is_refused. Checked first: the
  real books' journal has none. Rerun: 13.2M executions, clean.
Seeds in fuzz/seeds/ (synthetic, minimal); the grown corpora stay out of
git. `just fuzz <target> <seconds>`.

Miri (rank 4): the core's unit, bridge, and invariant tests under Miri
(our code has no unsafe; rust_decimal and chrono do): no undefined
behavior. `just miri`. A pinned nightly (nightly-2026-10-01, with miri)
is now in mise.toml.

Benchmarks (rank 6): criterion (new dev-dependency: the playbook's Rust
benchmark tool; none existed) in benches/books.rs; baselines in
docs/testing.md. The store's per-command rehearsal clone costs ~1 ms at
10k entries. No budget yet, so nothing gates on them. `just bench`.

CI: .github/workflows/check.yml runs `just check` on every push;
scheduled.yml runs proofs, the GnuCash oracle, fuzzing, and mutation
weekly. Tools come from mise.toml.

docs/testing.md: every layer, its oracle rank, command, runtime, and
gate; benchmark baselines; the ASSUMED invariants for Stephen to reject;
the gap list with triggers. docs/oracle.md's fuzzing paragraph had
claimed persistent corpora that didn't exist; corrected.

Also: ledger-import now inherits the workspace lints (nightly cargo
warned).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 15:09:22 -06:00
swaitsandClaude Opus 5.5 8a9a2485cf test(core): model-based stateful test against the Lean state machine
Max-testing pass, rank 3. tests/model.rs transcribes the Lean ledger
state machine (verify/lean/Wanderbooks/Ledger.lean) into the plainest
Rust with the same observable behavior: lists of accounts, drafts,
posted entries, and periods. Random command sequences (add account,
draft, add line, a balanced pair of lines, post, reverse, create period,
close), weighted toward commands that interact, run against both; after
every command the engine and the model must make the same accept/refuse
decision and agree on the posted log line for line, the chart, drafts,
periods, ids, and every account's balance. 2,048 sequences of up to 60
commands; about 3s.

Honest about its first version: it agreed vacuously. Counting accepted
commands (MODEL_STATS=1) showed zero posts and zero reversals: random
lines never balance. A balanced-pair command fixed it (now ~150 posts,
~100 reversals, ~200 closes per 512 sequences). Shown to have teeth:
letting the model reverse an entry twice fails in 0.03s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:49:46 -06:00
swaitsandClaude Opus 5.5 c9810ea0a3 fix(core): payments never predate their documents; aging as of a date; refusals leave no draft
Three spec decisions from Stephen (2026-10-13), raised by the spec-blind
test writers, each test-first:

- A payment dated before its invoice or bill is refused
  (PaymentBeforeDocument); a real prepayment is a deposit. Test:
  a_payment_never_predates_its_document. Replay is unaffected (it goes
  through replay_invoice_payment / replay_bill_payment).
- Aging as of a date shows only documents issued by then, less only the
  payments made by then. The ledger now records each document's payment
  entries (live and on replay), and aging counts those dated by as_of.
  Test: aging_as_of_a_date_sees_only_what_existed_then; the A/R and A/P
  conservation properties' aging oracles updated to the confirmed rule
  (they had assumed aging ignored dates).
- A schedule through a mid-month date prorates that month by days; the
  code was right and its doc wrong. The spec-blind test that disagreed is
  rewritten to the confirmed spec and no longer ignored.

Also found while wiring payments: a payment to an unknown cash account
created the payment's draft and then failed, leaving it behind; the cash
account is now checked before anything is created.

The unreviewed golden van_schedule.json is gone: every row was first
checked against an independent hand computation (all 61 matched), then
replaced by van_schedule_is_the_hand_computed_one, which asserts that
computation directly (548.39, 59 x 1,000.00, 451.61; total 60,000.00).

Tests: 452. No spec-disagreements left ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:47:10 -06:00
swaitsandClaude Opus 5.5 cece4c4554 test: restore properties.proptest-regressions (deleted by mistake)
proptest replays the failing cases saved there on every run; it is
checked in on purpose (the max-testing playbook: keep the framework's
failure database).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:18 -06:00
swaitsandClaude Opus 5.5 beb1fcf233 test(core): spec-blind suites and a live GnuCash oracle; four fixes they found
Max-testing pass, steps 2-4.

Spec-blind tests (rank 1-2): four agents, each given only the specs and
the public interface (doc comments and signatures, every body removed,
never src/ or the existing tests), wrote tests/spec_reports.rs (62),
spec_ledger.rs (72), spec_arap.rs (42), spec_assets_payroll.rs (50):
hand-computed examples per scenario and a proptest law per invariant
(conservation, partition, invariance, scaling, reversal cancels,
"a refusal leaves the books unchanged" over random invalid commands).
They found:

1. A refused payroll run changed the books: an all-zero run passed
   validation, then failed at post after creating seven payroll
   accounts and using up an entry id. validate_payroll_lines now
   refuses a run with no gross pay.
2. A refused add_account used up an account id (empty code, duplicate,
   unknown parent). The id is now taken only when the account is added.
3. Stale prose: close_period_to and members_equity still described the
   old last-member rounding.
4. Open (left #[ignore]d pending Stephen): depreciation_schedule's doc
   says "through the month containing" the date; the code prorates that
   month by days.

GnuCash oracle (rank 3, an oracle never the spec): `just oracle` builds
GnuCash 5.10 in a container pinned by base digest and package versions
(oracle/gnucash/) and runs tests/oracle_gnucash.rs: 200 seeded random
books built through wanderbooks (entries, foreign lines, reversals,
monthly closes), every account's balance at month ends and random dates
and each month's P&L, compared exactly: 15,800 comparisons. First run:
4,724 differences, all from foreign lines; without them, zero.
Adjudicated from principles (rules.md B-3): wanderbooks booked
foreign * rate unrounded ($108.34567); a ledger in one money holds no
fractions of its smallest unit, and its cash must match a bank. Stephen
decided: book in the base currency's minor unit, half away from zero.
add_line_fx now rounds; minor units per ISO 4217 list one (2026-09-17,
SIX), generated into Currency::minor_units and recorded in
docs/spec/sources/iso-4217.md. Replay now takes the journaled base
amount (replay_add_line_fx), so lines booked before replay exactly as
booked (contract: books_written_under_older_rules_still_open). Now:
zero differences, foreign lines included.

Tests: 449, from 223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:06 -06:00
swaitsandClaude Opus 5.5 0d4a5f8f53 docs(core): written specs, with sources, for the commands and reports
Max-testing pass, step 2: every domain decision in ledger.rs and
reports.rs gets a GOAL / SOURCE / INVARIANTS / SCENARIOS block in its
doc comment: post_entry, try_create_entry, close_period_to,
mark_cleared, reconciliation_report, account_balance, create_invoice,
create_bill, receive_payment, pay_bill, register_asset, dispose_asset,
run_payroll, remit_payroll_taxes, set_line_class, set_entry_vendor;
trial_balance(_as_of), profit_and_loss_for_period, balance_sheet_as_of,
general_ledger, members_equity, cash_flow, profit_and_loss_by_class,
snapshot. Sources trace to docs/spec/rules.md (and through it to
Pacioli, the IRS, FASB); what no document settles is marked CONVENTION,
DECISION, or ASSUMED for Stephen to confirm. The specs are the input to
spec-blind test writing (the next step): tests derived from the spec
alone, never from the code.

Also: rustdoc now runs in `just check` with -D warnings; fixed the seven
existing broken intra-doc links and one bare URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:21:44 -06:00
swaitsandClaude Opus 5.5 925c3ba523 fix(core,store): child accounts share their parent's type; periods nest; lines checked when added
Three spec decisions from Stephen (2026-10-13), each test-first:

- A child account has its parent's type (ParentTypeMismatch): a child
  subdivides its parent. Test: a_child_account_has_its_parents_type.
- Periods nest (a month in a quarter in a year) but never partially
  overlap (PeriodsOverlap; a refusal uses no id). Test:
  periods_nest_but_never_partially_overlap.
- A line's account must exist and take postings when the line is added,
  not only at post (UnknownAccount / PlaceholderAccount from add_line
  and add_line_fx); posting still re-checks. Two older tests that pinned
  the post-time refusal now check both (the post-time check is reached
  through the unchecked replay path, as old journals can).

Compatibility: replay reads history through new Ledger::replay_*
commands (add_account, add_line, add_line_fx, create_period) that skip
checks added later, so books written before these rules still open.
The contract test books_written_under_older_rules_still_open writes such
a journal directly and found, on its first run, that the SQLite
cross-check rebuild used the checked commands and would have failed
verification ("sqlite projection can't be read back"); it now replays
too. Documented in docs/spec/README.md; decisions recorded in rules.md
(B-1, B-4, B-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:16:51 -06:00
swaitsandClaude Opus 5.5 df38dcccf9 feat(core,mcp,gui): never apply a legal rule past what wanderbooks knows
Stephen (2026-10-13): when what wanderbooks knows is stale, check the
date, research it or ask the owner or CPA, and update the rule for
everyone with a pull request (best) or an issue (worst).

- ledger_core::rules: a registry of rules set by law that change over
  time. Each says the years it's known for, its rules.md entry, its
  vendored official source, and a review date. stale_rules(today) is
  pure; the engine has no clock.
- The 1099 threshold is the first: $600 through 2025, $2,000 in 2026,
  then indexed for inflation (26 U.S.C. §6041(h)). default_threshold
  and vendor_1099_report now return Result; for 2027+ they refuse with
  LedgerError::RuleNotKnown, whose message says what to do: get the
  figure from the official source or the owner/CPA, pass it explicitly,
  then open a PR (or issue) at github.com/swaits/wanderbooks. Before,
  2027+ silently used $2,000.
- MCP: books.vendor_1099 relays that refusal; books.digest gains
  stale_rules; the server instructions tell the agent the procedure (and
  how to handle control-account conflicts: explain, never renumber).
  The CPA package writes a NOT-INCLUDED note instead of a guessed 1099
  worksheet.
- Viewer: "Needs attention" shows each stale rule with a ready prompt
  for the agent; the 1099 page explains instead of guessing.

Review date for the 1099 rule: 2026-11-01, when the IRS usually
publishes next year's inflation adjustments (CONVENTION, marked as such
in the registry).

Tests: an_unknown_year_threshold_is_never_guessed,
dated_rules_go_stale_on_their_review_date (dates passed in, so no clock
in the test), MCP stdio: 2027 refused with guidance, explicit threshold
accepted, digest carries stale_rules (asserted date-independently).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:07:22 -06:00
swaitsandClaude Opus 5.5 e2bebce051 fix(core,store): refuse control accounts of the wrong type, with a reason
Invoices, bills, payments, payroll, and closes post to accounts the
engine finds by code (1200, 2000, 4000, 6999, 3999, 6100-2150). If an
account with that code existed it was used whatever its type: a 1200
"Customer Deposits" liability would have taken receivables. Now
(Stephen, 2026-10-13: never force an imported chart into our numbering)
the engine refuses with ControlAccountMismatch, naming the code, the
account, its type, the role, and the type needed, so the agent can work
it out with the human. A placeholder is refused too.

Every command checks all its control accounts before changing anything
(Ledger::check_controls, spec + SOURCE in its doc comment), so a refusal
never leaves a half-made account behind. One table of control accounts
(ledger_core::RECEIVABLES ... PAYROLL) replaces the codes duplicated
across the engine and the store.

Tests (spec-derived, all assert the books are untouched after the
refusal): an_invoice_refuses_a_1200_that_is_not_receivables,
an_invoice_refuses_a_placeholder_1200,
an_invoice_creates_nothing_when_its_second_account_is_wrong,
a_bill_refuses_a_2000_that_is_not_payables,
a_close_refuses_a_3999_that_is_not_equity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 12:29:24 -06:00
swaitsandClaude Opus 5.5 ad572ae89d docs(spec): every rule traced to an authority; public-domain sources vendored
Stephen asked that specs come from recognized documents, not from
anyone's memory. There is no single US bookkeeping bible; authority is
layered, and this records it:

- docs/spec/sources/: verbatim excerpts (whitespace normalized), each
  with URL, retrieval date, and the SHA-256 of the fetched file, of
  public-domain texts: Pacioli's 1494 treatise in Geijsbeek's 1914
  translation (double entry, the trial balance, one money, write only
  forward, correct by opposite entry, close into capital); IRS Pub. 583,
  538, 946, 15; the 1099-MISC/NEC instructions (2025 and 2026); 26 U.S.C.
  §6041, §6041A, §704; Treas. Reg. §1.263(a)-1(f) and -4(f); Notice
  2015-82; Rev. Proc. 98-25; Form 1065 instructions; 17 U.S.C. §105. The
  excerpts were extracted by matching each passage against the fetched
  file, so none can contain text the source doesn't.
- docs/spec/rules.md: the rule catalog, B-1 to B-22. Each rule says
  AUTHORITY, CONVENTION, or DECISION; cites its sources (FASB's
  Codification and Concepts Statement 8 and OpenStax quoted briefly,
  never copied: copyright and CC BY-NC-SA); and names the code that
  enforces it and the tests and proofs that check it.
- Spec blocks in the code now carry SOURCE: lines into the catalog;
  AGENTS.md makes sourcing rule 0 of the formal-methods bar.

Corrections the sources turned up: the de minimis limit is $500 in the
regulation and $2,500 by Notice 2015-82 (both now cited); 1099
thresholds after 2026 are inflation-indexed, so the engine's flat
$2,000 for 2027+ is an open question (B-19). Unverified ASC paragraphs
(360-10-35-4, 340-10, 230-10-45-10, 606-10-55-37B, 210) are listed in
docs/spec/README.md until someone with a FASB basic-view login reads
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 12:18:57 -06:00
swaitsandClaude Opus 5.5 0340f316ee fix(core): due-today is Current; odd cents split by largest remainder
Max-testing pass, step 2: two spec decisions Stephen confirmed
(2026-10-13), each test-first.

Aging (arap::aging_bucket): a document due today was "1-30 days past
due"; it is Current, since past due starts the day after (QuickBooks'
convention). Spec in the doc comment. Boundary examples at every edge
(due today, 1, 30, 31, 60, 61, 90, 91 days) fail on the old code. A
property: the balance lands in exactly one bucket, and time only ages
it.

Member splits (periods::allocate): the last member absorbed every odd
cent, so the same partner always got or lost it. Now largest remainder:
each share rounded toward zero to the cent, leftover cents to the
largest remainders, ties to the earlier member, a loss the negation of
the gain. The core is a new kernel function, kernel::apportion,
allocation-free so Kani proves it (n <= 3): apportion_is_exact (it
succeeds exactly when the shortfall is under a unit per part; then the
parts sum to the whole, each gains 0 or 1 unit, one may take the
sub-unit fraction) and apportion_is_fair (never a unit to a part while
a larger remainder goes without). Kani found an off-by-one in the first
version: a shortfall of n units plus a fraction was accepted. Native
regression apportion_refuses_a_shortfall_of_a_unit_per_part_or_more.
Properties at Decimal (2,000 cases, up to 5 members): conservation,
within a cent of each exact share, fairness, sign symmetry; they fail on
the old rule. Bridge: apportion agrees on i64 and Decimal.

Contract: closes journaled before this change replay exactly. New
member closes journal "rounding": "largest_remainder"; journal lines
without it replay as CloseTo::MembersLastAbsorbs, the old rule
(periods::allocate_last_absorbs, pinned by
closes_journaled_under_the_old_rule_replay_with_it). The members'
equity statement isn't journaled and uses the new rule.

Open gap: Lean proof of apportion's sum law for any n (needs a
permutation lemma on the ranking); Kani covers n <= 3, properties n <= 5.
The Lean closing theorems already hold for any split summing to the
net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 12:10:55 -06:00
swaitsandClaude Opus 5.5 a09c1fb218 fix(store,core): a refused command never reaches the journal; reversal rules
Max-testing pass, step 2 (rank 1 regressions + a rank 2 law). Two ways
to make the books unopenable, both found by test, both fixed:

1. Reversing into a closed period: the store journaled `entry.reversed`,
   then the engine refused it at post. Replay could never apply that
   line, so Store::open failed ("period 1 is closed"). Regression:
   reversal_into_a_closed_period_is_refused_before_journaling.
2. An oversized invoice (or any command whose hand-written store
   pre-checks lagged the engine, here the new line range) journaled,
   then failed in the engine. Found by the new property
   refused_commands_never_reach_the_journal within 4s.

The fix is structural, not another hand-written check: every
journal-first store command now rehearses on a scratch clone of the
ledger (`Store::rehearse`), so whatever the engine refuses is refused
before the journal, now and after any future engine change. The
create_entry rehearsal uses the checked constructor: no draft is ever
dated in a closed period (the Lean invariant Inv.draftsOpen, which the
store had bypassed).

The property (1,000 random command sequences, heavy on refusals)
asserts, after every command: a refusal leaves the journal
byte-identical; every posted entry validates; the trial balance
balances; no draft sits in a closed period; and the books reopen and
verify at the end. Shown to have teeth: removing the create_entry
rehearsal makes it fail and shrink to open-period, close, draft.

Engine (spec confirmed by Stephen, 2026-10-13), Ledger::check_reversal,
a pure pre-check that reverse_entry and the store share:
- an entry is reversed at most once (EntryAlreadyReversed); reversing
  the reversal re-instates it;
- a closing entry is never reversed (ClosingEntryIrreversible);
- a refused reversal leaves no trace (it used to leave a draft and
  burn an id).

Specs go in doc comments as a ```text GOAL/INVARIANTS/SCENARIOS block.
New dev dependency: proptest for ledger-store (already the workspace's
property library).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 11:40:50 -06:00
218 changed files with 82058 additions and 2370 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Test books (a few KB each, every journal append synced) go in the system
# temp dir, /tmp, even when TMPDIR points elsewhere: `just mutants*` sets
# TMPDIR to ~/.cache for cargo-mutants' multi-GB tree copies, and cargo
# passes [env] to the tests it runs, not to cargo-mutants itself. On disk
# the synced appends made each mutant's tests several times slower.
[env]
TMPDIR = { value = "/tmp", force = true }
+15
View File
@@ -0,0 +1,15 @@
# Every change: format, clippy and rustdoc with zero warnings, every test.
# Tools come from mise.toml, the same as on a laptop.
name: check
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- name: GUI build dependencies
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev
- run: just check
+82
View File
@@ -0,0 +1,82 @@
# Weekly, and on demand: the slow layers (docs/testing.md).
name: scheduled
on:
schedule:
- cron: "17 6 * * 1"
workflow_dispatch:
jobs:
prove:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just prove
oracle:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just oracle
fuzz:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_import_csv, fuzz_config_parse]
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just fuzz ${{ matrix.target }} 600
mutants:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just mutants
- run: just mutants-store
- if: always()
uses: actions/upload-artifact@v4
with:
name: mutants
path: |
mutants.out
mutants-store/mutants.out
mutants-import:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just mutants-import
- run: just mutants-config
- if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-import
path: |
mutants-import/mutants.out
mutants-config/mutants.out
mutants-mcp:
runs-on: ubuntu-24.04
timeout-minutes: 350
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just mutants-mcp
- if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-mcp
path: mutants-mcp/mutants.out
mutants-gui:
runs-on: ubuntu-24.04
timeout-minutes: 350
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev
- run: just mutants-gui
- if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-gui
path: mutants-gui/mutants.out
+3
View File
@@ -3,3 +3,6 @@
.DS_Store
site/dist/
.netlify/
# cargo-mutants output (just mutants*)
/mutants.out*/
/mutants-*/
+11
View File
@@ -93,6 +93,16 @@ property tests, mutation testing, and 11.5M fuzz executions:
So the bar, for every agent, every change:
0. **Specs come from authorities, not memory.** Every rule the engine
enforces is in `docs/spec/rules.md` with its source: Pacioli for the
mechanics of double entry, the IRS (vendored verbatim, public domain)
for records, periods, 1099s, capitalization, partnerships, and payroll,
FASB's Codification (cited, never copied) for GAAP. A rule no authority
states is marked CONVENTION or DECISION, with who decided and when.
Spec blocks in the code (```text GOAL/SOURCE/INVARIANTS/SCENARIOS)
carry a `SOURCE:` line. Never write a spec from what the code happens
to do, or from what you remember; find the document, or ask.
1. **Money arithmetic goes in `kernel.rs`, generic over `Amount`.** If
you write a sum, a sign flip, a split, or a rounding plug anywhere
else, move it into the kernel. The engine calls the kernel. It never
@@ -166,6 +176,7 @@ clone, with status. Rules:
| `docs/requirements.md` | What the engine must do (R-1…), what is future work |
| `docs/architecture.md` | Crate layout, command pattern, dependency rules |
| `docs/oracle.md` | How we check correctness: golden files, property tests, fuzzing, cross-checks against other implementations (and who is right) |
| `docs/spec/` | The authorities every rule comes from (`rules.md`), with public-domain sources vendored verbatim |
| `docs/verification.md` | The formal proofs: every accounting law, its Kani harness and Lean theorem, what they found |
| `docs/parity.md` | Feature catalog and the non-regression floor |
| `docs/capability-parity.md` | Parity vs the GSheet+GDrive system + the cutover rule |
Generated
+642 -5
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", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"]
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault"]
[workspace.package]
edition = "2024"
+2
View File
@@ -11,6 +11,8 @@ name = "wanderbooks"
path = "src/main.rs"
[dev-dependencies]
ledger-config = { path = "../ledger-config" }
ledger-core = { path = "../ledger-core" }
serde_json = "1"
toml = "1"
tempfile = "3"
+106 -2
View File
@@ -143,7 +143,12 @@ anything. Keep it conversational; ask a few things at a time.
tax form, close target) in `wanderbooks.toml`.
3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax
classification, formation state and date, EIN, address. Don't store
SSNs or full account numbers anywhere in this directory.
SSNs or full account numbers anywhere in this directory. The server
reloads the file on every change: check `books.settings` after each
edit (no `reload_error`, no `problems`).
If the imported chart numbers receivables, payables, revenue, payroll,
or retained earnings differently from the defaults (`books.roles`),
assign those roles to its accounts with `books.assign_role`.
4. Record opening balances as one balanced opening entry (or the owners'
first contributions). Verify the trial balance before moving on.
5. File the formation documents they have (articles, EIN letter,
@@ -231,16 +236,108 @@ const CLAUDE_MD: &str = "@AGENTS.md\n";
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.
#
# Every setting is optional. A commented-out line is an example to fill in
# (uncomment it); a live line is the default. The MCP server and the
# viewer reload this file when it changes: no restart needed.
[company]
name = ""
# Legal name, for report headers.
# name = "Acme Widgets LLC"
# Shown in the profile. The journal's currency is fixed when the books are
# created (books-data/config.json base_currency, default USD).
base_currency = "USD"
# "MM-01": a fiscal year starts on the first of a month and ends the day
# before the next start (it ends on the last day of a month: IRS Pub. 538).
fiscal_year_start = "01-01"
# The return the business files: "schedule_c", "1065", "1120s", or "1120".
# tax_form = "1065"
# Or the federal classification, and the return follows from it:
# "partnership", "S corporation", "C corporation", or "disregarded entity"
# (Treas. Reg. §301.7701-3). tax_form wins when both are set.
# tax_classification = "partnership"
# Identity facts the CPA needs, each from a primary document (EIN letter,
# certificate of organization). Any simple key = value line here shows in
# the viewer's profile, labelled from its key.
# entity_type = "LLC, multi-member, member-managed"
# formation_state = "UT"
# formed = "2026-01-15"
# ein = "00-0000000"
# address = "123 Main St, Salt Lake City, UT 84101"
# Never store owners' SSNs (or any TIN) in this directory: its git history
# keeps everything. The CPA collects them through their own channel.
# Members of a multi-member LLC, one block each: shares come from the
# operating agreement (IRC §704(a)), "50%" or "0.5", summing to 100%. Each
# names a capital account in the chart, and optionally a distributions
# account that a close zeroes into capital. Spouses who file one joint
# return each name the other in files_jointly_with (each still gets their
# own K-1; the CPA package notes both go on one Form 1040).
# [[company.members]]
# name = "Alex Rivera"
# share = "50%"
# capital_account = "3000"
# distribution_account = "3100"
# files_jointly_with = "Sam Chen"
#
# [[company.members]]
# name = "Sam Chen"
# share = "50%"
# capital_account = "3001"
# distribution_account = "3101"
# files_jointly_with = "Alex Rivera"
[books]
# The books' data, relative to this file. The tools take it from --data-dir
# (the justfile, .mcp.json), not from here: move them together.
data_dir = "books-data"
# "accrual" or "cash", for report footers. Use the same method from year
# to year (IRS Pub. 538).
accounting_basis = "accrual"
# An entry whose debits are strictly over this needs a receipt; "0.00"
# means every entry (IRS Pub. 583: keep the documents that support your
# books). A receipt_policy in books-data/config.json overrides it.
receipt_threshold = "25.00"
# Account codes that hold cash (bank, savings, cash on hand), for the cash
# flow statement and dashboards. Unset: cash accounts are found by name.
# cash_accounts = ["1000", "1010"]
# Liabilities the cash flow statement treats as financing: loans, and what
# owners advanced by paying company expenses (ASC 230-10-45-14(b)).
# financing_accounts = ["2100"]
# Assets it treats as investing: loans the company makes, investments it
# buys (ASC 230-10-20). Registered fixed assets need not be listed.
# investing_accounts = ["1400"]
# Single owner only (with [[company.members]] a close goes to each
# member's capital): the equity account a period close puts net income in.
# Unset: Retained Earnings (3999).
# close_into = "3000"
# Equity accounts a close zeroes into close_into (or Retained Earnings):
# draws, distributions, dividends.
# close_sweep = ["3100"]
# The prefix of generated asset tags (the labels on equipment: WL-0001,
# WL-0002, ...). Unset: the company name's initials, without LLC/Inc
# ("Wanderland Labs LLC" -> WL); 1 to 8 letters or digits.
# asset_tag_prefix = "WL"
# Whether entries name their counterparty (the party paid or paid by):
# "suggest" (the default) has the agent propose one when an entry has none;
# "require" refuses to post until it names one or is marked as having none
# (a transfer between your own accounts); "off" never mentions it.
# parties = "suggest"
# Which accounts invoices, bills, payroll, and closes post to (receivables,
# payables, revenue, payroll, retained earnings) is not set here: those
# roles are part of the books, journaled with books.assign_role and listed
# by books.roles. Until assigned, each uses its default code (1200, 2000,
# 4000, 6999, 3999, 6100, 6110, 2110-2150).
[vault]
# The TIN vault (full taxpayer identification numbers and W-9s, encrypted,
# in vault/ beside the books, which git ignores) is opened with this key
# file, kept OUTSIDE the books. Keep a copy in your password manager:
# without it the vault can't be read. Unset: ~/.config/wanderbooks/vault.key
# key_file = "~/.config/wanderbooks/vault.key"
[viewer]
# When a session starts and the viewer isn't open (`just gui-status`), the
@@ -252,6 +349,13 @@ open_at_start = "ask"
const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl.
books-data/wanderbooks.db
books-data/wanderbooks.db-*
# The TIN vault: encrypted, but never in git (it must be purgeable).
vault/
# Copies of a database that disagreed with the journal, kept when it is
# rebuilt; disposable like the database itself.
books-data/wanderbooks.mismatch.*
# Held while an agent's MCP server has the books open: one writer at a time.
books-data/wanderbooks.lock
# Generated exports can always be regenerated from the journal.
exports/
# The background viewer's log (`just gui`).
+163
View File
@@ -230,3 +230,166 @@ fn codex_gets_its_own_named_server() {
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
assert!(out.contains("just codex"), "{out}");
}
/// The `wanderbooks.toml` a fresh `new` writes.
fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) {
let books = tmp.join("b");
assert!(new(tmp, &books, &[]).0);
let path = books.join("wanderbooks.toml");
let text = fs::read_to_string(&path).unwrap();
(path, text)
}
/// The example a commented line holds, if it is one: `# key = value`, or
/// `# [section]` / `# [[section]]`. Prose comments are not examples.
fn example(line: &str) -> Option<&str> {
let rest = line.strip_prefix("# ")?;
let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
let header = rest
.trim_start_matches('[')
.trim_end_matches(']')
.split('.')
.all(is_key);
let assignment = rest
.split_once(" = ")
.is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty());
((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest)
}
/// The template with the examples `pick` chooses uncommented.
fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String {
text.lines()
.map(|l| match example(l) {
Some(ex) if pick(ex) => ex,
_ => l,
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_scaffolded_toml_loads_with_the_defaults() {
let tmp = tempfile::tempdir().unwrap();
let (path, text) = scaffolded_toml(tmp.path());
let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads");
let c = &s.company;
assert_eq!(c.name, "Unnamed company", "name is left for onboarding");
assert_eq!(c.fiscal_year_start, (1, 1));
assert_eq!(c.basis, "Accrual");
assert!(c.members.is_empty());
assert_eq!(c.tax_form, None);
assert_eq!(c.close_into, None);
assert!(c.close_sweep.is_empty());
assert_eq!(
c.profile,
[
("base_currency".to_string(), "USD".to_string()),
("fiscal_year_start".to_string(), "01-01".to_string()),
]
);
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
assert!(s.cash_accounts.is_empty());
assert!(s.financing_accounts.is_empty());
assert!(s.investing_accounts.is_empty());
let toml: toml::Value = toml::from_str(&text).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
// The justfile's session-start reads this line with sed, from column 0.
assert!(
text.lines().any(|l| l == "open_at_start = \"ask\""),
"{text}"
);
assert!(text.contains("SSN"), "the template warns off storing SSNs");
}
#[test]
fn every_example_in_the_toml_is_valid() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let examples = text.lines().filter_map(example).count();
assert!(examples >= 20, "only {examples} examples found");
// close_into is for a single owner and refused with members, so it
// gets a pass of its own; together the two passes cover every example.
let single_owner = |ex: &str| ex.starts_with("close_into ");
let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex)))
.unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}"));
let c = &all.company;
assert_eq!(c.name, "Acme Widgets LLC");
assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065));
assert_eq!(c.members.len(), 2);
assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101"));
assert_eq!(c.close_sweep, ["3100"]);
assert!(c.profile.iter().any(|(k, _)| k == "ein"));
assert_eq!(all.cash_accounts, ["1000", "1010"]);
assert_eq!(all.financing_accounts, ["2100"]);
assert_eq!(all.investing_accounts, ["1400"]);
let single = ledger_config::parse(&uncomment(&text, single_owner))
.unwrap_or_else(|e| panic!("close_into example parses: {e}"));
assert_eq!(single.company.close_into.as_deref(), Some("3000"));
assert!(
ledger_config::parse(&uncomment(&text, |_| true)).is_err(),
"close_into with members is refused, as the template says"
);
}
/// The serde field names of `struct name` in ledger-config's source.
fn fields(src: &str, name: &str) -> Vec<String> {
let start = src
.find(&format!("struct {name} {{"))
.unwrap_or_else(|| panic!("ledger-config has struct {name}"));
let body = &src[start..];
let body = &body[..body.find("\n}").expect("struct ends")];
let found: Vec<String> = body
.lines()
.skip(1)
.map(str::trim)
.filter(|l| !l.starts_with("//") && !l.starts_with("#["))
.filter_map(|l| l.split_once(':'))
.map(|(k, _)| k.trim_start_matches("pub ").to_string())
.collect();
assert!(!found.is_empty(), "struct {name} has fields");
found
}
#[test]
fn the_toml_shows_every_setting_the_config_reads() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
// (section, key) for every assignment, examples included.
let mut section = String::new();
let mut present = Vec::new();
for line in uncomment(&text, |_| true).lines() {
if line.starts_with('[') {
section = line.trim_matches(['[', ']']).to_string();
} else if let Some((k, _)) = line.split_once(" = ") {
present.push((section.clone(), k.to_string()));
}
}
let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k);
// Read from the config crate's serde structs, so a new setting there
// fails here until the template shows it.
let src = include_str!("../../ledger-config/src/lib.rs");
assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]);
for (strukt, sect) in [
("CompanyToml", "company"),
("MemberToml", "company.members"),
("BooksSection", "books"),
("VaultSection", "vault"),
] {
for key in fields(src, strukt) {
if strukt == "CompanyToml" && key == "members" {
assert!(text.contains("# [[company.members]]"));
continue;
}
assert!(has(sect, &key), "[{sect}] {key} is in the template");
}
}
// Read elsewhere: the justfile's session-start (main.rs), and the two
// the scaffold has always written live.
for (sect, key) in [
("viewer", "open_at_start"),
("company", "base_currency"),
("books", "data_dir"),
] {
assert!(has(sect, key), "[{sect}] {key} is in the template");
}
}
+5
View File
@@ -11,7 +11,12 @@ ledger-core = { path = "../ledger-core" }
chrono = "0.4"
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = { version = "1", features = ["preserve_order"] }
[dev-dependencies]
proptest = "1"
tempfile = "3"
[lints]
workspace = true
+465 -27
View File
@@ -9,6 +9,9 @@
//! 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.
pub mod receipt_policy;
pub use receipt_policy::ReceiptPolicy;
use chrono::{Datelike, NaiveDate};
use rust_decimal::Decimal;
use serde::Deserialize;
@@ -52,6 +55,12 @@ pub struct Member {
/// Code of their distributions account, if distributions are recorded
/// separately from capital (zeroed into capital at close).
pub distribution_account: Option<String>,
/// `files_jointly_with`: the other member this member files a joint
/// individual return with (spouses). Each still gets their own K-1
/// (a partnership reports each partner separately, IRS Instructions
/// for Form 1065, Schedule K-1); both K-1s go on the one Form 1040
/// (Schedule E, Part II). Informational: the CPA package says so.
pub files_jointly_with: Option<String>,
}
impl Default for Company {
@@ -70,6 +79,42 @@ impl Default for Company {
}
impl Company {
/// The members as party shares see them ([`ledger_core::PartyMember`]):
/// each member whose name (ignoring case and surrounding space) is a
/// party with the member role, and whose capital account is in the
/// chart. The party not archived of that name when there is one, else
/// the most recent archived one: archiving a member ("no longer dealt
/// with; history stays") never takes their history away. Members
/// missing either are left out, so their shares can't be derived (and
/// the viewer shows no capital statement for them).
pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> {
let is_member = |p: &&ledger_core::Party| p.roles.contains(&ledger_core::Role::Member);
self.members
.iter()
.filter_map(|m| {
let key = ledger_core::parties::name_key(&m.name);
let party = match ledger.party_by_name(&m.name) {
Some(p) => Some(p).filter(is_member)?,
None => ledger
.parties()
.filter(|p| p.archived && ledger_core::parties::name_key(&p.name) == key)
.filter(is_member)
.max_by_key(|p| p.id)?,
};
Some(ledger_core::PartyMember {
party: party.id,
share: m.share,
capital: ledger.chart().find_by_code(&m.capital_account)?.id,
distributions: m
.distribution_account
.as_deref()
.and_then(|c| ledger.chart().find_by_code(c))
.map(|a| a.id),
})
})
.collect()
}
/// 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;
@@ -97,6 +142,72 @@ pub struct BooksSettings {
/// savings, cash on hand), for cash reports and dashboards. Empty:
/// callers decide (e.g. by account name).
pub cash_accounts: Vec<String>,
/// `[books] financing_accounts`: liability account codes the cash flow
/// statement treats as financing (loans, and what owners advanced:
/// ASC 230-10-45-14(b)). Empty: callers decide.
pub financing_accounts: Vec<String>,
/// `[books] investing_accounts`: asset account codes the cash flow
/// statement treats as investing (loans the company makes, investments
/// it buys: ASC 230-10-20). Registered fixed assets need not be listed.
pub investing_accounts: Vec<String>,
/// `[books] asset_tag_prefix`: the prefix of generated asset tags
/// ("WL" for "WL-0001"). Unset: [`BooksSettings::asset_tag_prefix`]
/// derives it from the company name.
pub asset_tag_prefix: Option<String>,
/// `[books] parties`: how hard the agent asks for each entry's
/// counterparty. Unset: [`PartyPolicy::Suggest`].
pub parties: PartyPolicy,
/// `[vault] key_file`: the TIN vault's key file. Unset: the vault's
/// default (`~/.config/wanderbooks/vault.key`).
pub vault_key_file: Option<std::path::PathBuf>,
}
impl BooksSettings {
/// The prefix generated asset tags use: `[books] asset_tag_prefix`, or
/// else the initials of the company name's words, legal-form words
/// (LLC, Inc, Corp, ...) left out, at most 8 ("Wanderland Labs LLC" →
/// "WL"); "FA" (fixed asset) when the name gives none.
pub fn asset_tag_prefix(&self) -> String {
if let Some(p) = &self.asset_tag_prefix {
return p.clone();
}
const FORMS: &[&str] = &[
"llc",
"l",
"c",
"inc",
"incorporated",
"corp",
"corporation",
"co",
"company",
"ltd",
"limited",
"llp",
"lp",
"pllc",
"pc",
"pa",
"the",
"and",
"of",
];
let initials: String = self
.company
.name
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty() && !FORMS.contains(&w.to_ascii_lowercase().as_str()))
.filter_map(|w| w.chars().next())
.filter(char::is_ascii_alphabetic)
.map(|c| c.to_ascii_uppercase())
.take(8)
.collect();
if initials.is_empty() || self.company.name == Company::default().name {
"FA".to_string()
} else {
initials
}
}
}
#[derive(Deserialize)]
@@ -105,6 +216,13 @@ struct BooksToml {
company: CompanyToml,
#[serde(default)]
books: BooksSection,
#[serde(default)]
vault: VaultSection,
}
#[derive(Default, Deserialize)]
struct VaultSection {
key_file: Option<String>,
}
#[derive(Default, Deserialize)]
@@ -123,6 +241,7 @@ struct MemberToml {
share: String,
capital_account: Option<String>,
distribution_account: Option<String>,
files_jointly_with: Option<String>,
}
#[derive(Default, Deserialize)]
@@ -134,23 +253,52 @@ struct BooksSection {
close_sweep: Vec<String>,
#[serde(default)]
cash_accounts: Vec<String>,
#[serde(default)]
financing_accounts: Vec<String>,
#[serde(default)]
investing_accounts: Vec<String>,
asset_tag_prefix: Option<String>,
parties: Option<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.
/// The return a federal tax classification files (docs/spec/sources/
/// irs-entity-classification.md), read by whole words, ignoring case:
/// "partnership" → 1065; "S corporation" (S corp, S-corp, scorp) →
/// 1120-S; "C corporation", or "corporation", "corp", or "association"
/// alone → 1120; "disregarded", "sole proprietor(ship)", or
/// "single-member" → Schedule C. Anything else, or words naming two
/// returns ("corporation or S corporation"), names none.
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
use ledger_core::TaxForm;
let lower = c.to_lowercase();
let words: Vec<&str> = lower
.split(|ch: char| !ch.is_alphanumeric())
.filter(|w| !w.is_empty())
.collect();
let has = |w: &str| words.contains(&w);
let mut found = Vec::new();
if has("partnership") {
found.push(TaxForm::Form1065);
}
for (i, w) in words.iter().enumerate() {
let prev = i.checked_sub(1).map(|p| words[p]);
let form = match *w {
"scorp" => Some(TaxForm::Form1120S),
"ccorp" | "association" => Some(TaxForm::Form1120),
"corp" | "corporation" if prev == Some("s") => Some(TaxForm::Form1120S),
"corp" | "corporation" => Some(TaxForm::Form1120),
_ => None,
};
found.extend(form);
}
if has("disregarded") || has("sole") || words.windows(2).any(|p| p == ["single", "member"]) {
found.push(TaxForm::ScheduleC);
}
found.sort();
found.dedup();
match found.as_slice() {
[one] => Some(*one),
_ => None,
}
}
@@ -168,19 +316,121 @@ fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
Ok(share)
}
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
/// day every year has.
/// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the
/// last day of a month: IRS Pub. 538, docs/spec/sources/
/// irs-periods-and-methods.md).
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"))?;
if !(1..=12).contains(&m) {
return Err(bad());
}
if d != 1 {
return Err(format!(
"fiscal_year_start {s:?}: a fiscal year starts on the first of a month (it ends on the last day of one: IRS Pub. 538)"
));
}
Ok((m, d))
}
/// Account codes as written, trimmed; a blank one is refused.
fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
list.into_iter()
.map(|c| {
let c = c.trim().to_string();
if c.is_empty() {
Err(format!("{key}: an account code is blank"))
} else {
Ok(c)
}
})
.collect()
}
/// A decimal string as the books take one: exactly `-?digits[.digits]`.
/// The decimal parser alone also takes "1_000", "+5", ".5", and
/// exponents ("1e3"); a setting or tool argument never means those.
pub fn decimal(s: &str) -> Option<Decimal> {
let digits = s.strip_prefix('-').unwrap_or(s);
let (whole, frac) = match digits.split_once('.') {
Some((w, f)) => (w, Some(f)),
None => (digits, None),
};
let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) {
return None;
}
s.parse().ok()
}
/// The receipt policy in force: `config_json`'s `receipt_policy` when the
/// file sets one, else `[books] receipt_threshold`, else the default
/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and
/// the viewer both call this, so they enforce and show one rule.
pub fn effective_receipt_policy(
config_json: &Path,
settings: &BooksSettings,
) -> Result<ReceiptPolicy, String> {
#[derive(Deserialize)]
struct ConfigJson {
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
}
let from_json = if config_json.exists() {
let raw = std::fs::read_to_string(config_json)
.map_err(|e| format!("{}: {e}", config_json.display()))?;
serde_json::from_str::<ConfigJson>(&raw)
.map_err(|e| format!("bad config {}: {e}", config_json.display()))?
.receipt_policy
} else {
None
};
let policy = match (from_json, &settings.receipt_threshold) {
(Some(p), _) => p,
(None, Some(t)) => ReceiptPolicy {
require_receipts_above: t.clone(),
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
policy.validate()?;
Ok(policy)
}
/// Parse `wanderbooks.toml` text.
///
/// ```text
/// GOAL: read the company's settings, refusing any the books can't honor
/// rather than guessing.
/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md:
/// a fiscal year ends on the last day of a month); Treas. Reg.
/// §301.7701-3 and the Schedule C instructions
/// (docs/spec/sources/irs-entity-classification.md); IRC §704(a)
/// (shares from the agreement: docs/spec/sources/irs-partnerships.md).
/// INVARIANTS:
/// 1. Every setting is optional; an empty file is a calendar-year,
/// accrual-basis "Unnamed company" with no members.
/// 2. fiscal_year_start MUST be the first of a month ("MM-01").
/// 3. Members: each MUST have a name and a capital account, names and
/// capital accounts MUST be distinct (and no member's distributions
/// account another's capital), and shares (each in (0, 1], "50%" or
/// "0.5") MUST sum to exactly 1.
/// 4. tax_form wins over tax_classification (which is then not read);
/// a classification is read by whole words, ignoring case, and MUST
/// name exactly one return (source table). tax_form, basis, and
/// classification values ignore case.
/// 5. receipt_threshold MUST be a decimal at least zero.
/// 6. Account codes (cash, financing, investing, close_into,
/// close_sweep, members' capital and distributions) are trimmed; a
/// blank one is refused (a blank distributions account means none). close_into is
/// refused with members (a close goes to their capital accounts).
/// 7. profile holds every simple [company] value in file order:
/// strings as written, numbers, booleans, and dates in TOML's
/// spelling; tables and arrays (members) are left out.
/// 8. NEVER accept a file that would be read differently than written:
/// an unknown basis, tax form, or classification is refused.
/// ```
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();
@@ -204,8 +454,62 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
.distribution_account
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty()),
files_jointly_with: m
.files_jointly_with
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty()),
});
}
for (i, m) in company.members.iter().enumerate() {
if m.name.is_empty() {
return Err(format!("member {}: name is blank", i + 1));
}
for other in &company.members[..i] {
if other.name == m.name {
return Err(format!("member {} is listed twice", m.name));
}
if other.capital_account == m.capital_account {
return Err(format!(
"members {} and {} share capital account {}",
other.name, m.name, m.capital_account
));
}
}
for other in &company.members {
if other.distribution_account.as_deref() == Some(m.capital_account.as_str()) {
return Err(format!(
"{}'s distributions account {} is {}'s capital account",
other.name, m.capital_account, m.name
));
}
}
}
// files_jointly_with names another member, and both say so.
for m in &company.members {
if let Some(j) = &m.files_jointly_with {
if *j == m.name {
return Err(format!(
"member {}: files_jointly_with names themself",
m.name
));
}
match company.members.iter().find(|o| o.name == *j) {
None => {
return Err(format!(
"member {}: files_jointly_with {j:?} isn't a member",
m.name
));
}
Some(o) if o.files_jointly_with.as_deref() != Some(m.name.as_str()) => {
return Err(format!(
"member {} files jointly with {j}, but {j}'s files_jointly_with doesn't name {}: set it on both",
m.name, m.name
));
}
Some(_) => {}
}
}
}
if !company.members.is_empty() {
let total: Decimal = company.members.iter().map(|m| m.share).sum();
if total != Decimal::ONE {
@@ -217,7 +521,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
(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, Some(c)) => Some(form_for_classification(c).ok_or_else(|| {
format!(
"tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)"
)
})?),
(None, None) => None,
};
if !company.members.is_empty() && raw.books.close_into.is_some() {
@@ -226,8 +534,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
.to_string(),
);
}
company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
company.close_sweep = raw.books.close_sweep;
company.close_into = match raw.books.close_into {
Some(c) => Some(codes("close_into", vec![c])?.remove(0)),
None => None,
};
company.close_sweep = codes("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(),
@@ -241,9 +552,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
}
let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => {
t.trim()
.parse::<Decimal>()
.map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?;
let v = decimal(t.trim())
.ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?;
if v.is_sign_negative() && !v.is_zero() {
return Err(format!("receipt_threshold {t:?} is below zero"));
}
Some(t.trim().to_string())
}
None => None,
@@ -268,10 +581,74 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
Ok(BooksSettings {
company,
receipt_threshold,
cash_accounts: raw.books.cash_accounts,
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
vault_key_file: match raw.vault.key_file {
Some(p) if p.trim().is_empty() => {
return Err("[vault] key_file is empty".into());
}
Some(p) => Some(std::path::PathBuf::from(match p.strip_prefix("~/") {
Some(rest) => format!("{}/{rest}", std::env::var("HOME").unwrap_or_default()),
None => p,
})),
None => None,
},
asset_tag_prefix: match raw.books.asset_tag_prefix {
Some(p)
if !p.is_empty()
&& p.len() <= 8
&& p.chars().all(|c| c.is_ascii_alphanumeric()) =>
{
Some(p)
}
Some(p) => {
return Err(format!(
"[books] asset_tag_prefix {p:?}: 1 to 8 letters or digits"
));
}
None => None,
},
parties: match raw.books.parties.as_deref() {
None | Some("suggest") => PartyPolicy::Suggest,
Some("off") => PartyPolicy::Off,
Some("require") | Some("required") => PartyPolicy::Require,
Some(other) => {
return Err(format!(
"[books] parties {other:?}: \"off\", \"suggest\", or \"require\""
));
}
},
})
}
/// `[books] parties`: whether entries name their counterparty
/// (docs/parties-and-attachments.md "Counterparties"; DECISION, Stephen,
/// 2026-10-15: the default suggests, books may require or turn it off).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PartyPolicy {
/// Never mention counterparties.
Off,
/// A draft or preview without a party names the likely one; nothing
/// is refused.
#[default]
Suggest,
/// Posting is refused until the entry names a party or is marked as
/// having none.
Require,
}
impl PartyPolicy {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
PartyPolicy::Off => "off",
PartyPolicy::Suggest => "suggest",
PartyPolicy::Require => "require",
}
}
}
/// 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()))?;
@@ -282,6 +659,63 @@ pub fn load(path: &Path) -> Result<BooksSettings, String> {
mod tests {
use super::*;
/// files_jointly_with: names another member, on both sides.
#[test]
fn files_jointly_with() {
let m = |a: &str, b: &str| {
format!(
"[company]\nname = \"X\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n{a}\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n{b}\n"
)
};
let ok = parse(&m(
"files_jointly_with = \"B\"",
"files_jointly_with = \"A\"",
))
.unwrap();
assert_eq!(
ok.company.members[0].files_jointly_with.as_deref(),
Some("B")
);
assert!(
parse(&m("files_jointly_with = \"B\"", ""))
.unwrap_err()
.contains("set it on both")
);
assert!(
parse(&m("files_jointly_with = \"C\"", ""))
.unwrap_err()
.contains("isn't a member")
);
assert!(
parse(&m("files_jointly_with = \"A\"", ""))
.unwrap_err()
.contains("themself")
);
assert_eq!(
parse(&m("", "")).unwrap().company.members[1].files_jointly_with,
None
);
}
/// `[books] parties`: suggest by default; off, suggest, require (and
/// "required") parse; anything else is refused, naming the choices.
#[test]
fn parties_policy() {
let base = "[company]\nname = \"Acme LLC\"\n[books]\n";
assert_eq!(parse(base).unwrap().parties, PartyPolicy::Suggest);
for (v, want) in [
("off", PartyPolicy::Off),
("suggest", PartyPolicy::Suggest),
("require", PartyPolicy::Require),
("required", PartyPolicy::Require),
] {
let s = parse(&format!("{base}parties = \"{v}\"\n")).unwrap();
assert_eq!(s.parties, want, "{v}");
}
let e = parse(&format!("{base}parties = \"always\"\n")).unwrap_err();
assert!(e.contains("require"), "{e}");
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
@@ -337,7 +771,7 @@ mod tests {
#[test]
fn keeps_the_whole_profile_in_order() {
let s = parse(
"[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\n",
"[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n",
)
.unwrap();
assert_eq!(
@@ -349,6 +783,8 @@ mod tests {
]
);
assert_eq!(s.cash_accounts, ["1000", "1010"]);
assert_eq!(s.financing_accounts, ["2100"]);
assert_eq!(s.investing_accounts, ["1400"]);
}
#[test]
@@ -366,12 +802,14 @@ mod tests {
share: half,
capital_account: "3000".into(),
distribution_account: None,
files_jointly_with: None,
},
Member {
name: "Katherine Pendergast".into(),
share: half,
capital_account: "3001".into(),
distribution_account: None,
files_jointly_with: None,
},
]
);
+224
View File
@@ -0,0 +1,224 @@
//! 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.
//!
//! ```text
//! GOAL: no entry posts without the document that supports it.
//! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep
//! supporting documents"; they "support the entries in your
//! books"); DECISION (Stephen: the threshold, strictly greater
//! than; Wanderland's is 0.00, every entry, 2026-10-12).
//! INVARIANTS:
//! 1. An entry touching a required account always needs a receipt.
//! 2. Otherwise it needs one exactly when its debits are strictly
//! greater than the threshold and some line is outside the exempt
//! accounts; at threshold 0.00 every entry with debits needs one.
//! 3. Splitting does not dodge it: posted entries the same day with the
//! same description (whitespace and case aside) and the same debit
//! accounts count together (split_group), reversals aside.
//! 4. A threshold that isn't a decimal, or is below zero, is refused at
//! startup (validate).
//! ```
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 {
/// Refuse a policy the server can't honor: a threshold that isn't a
/// decimal, or is below zero. Checked at startup, so a typo can't
/// quietly become some other threshold.
pub fn validate(&self) -> Result<(), String> {
match crate::decimal(self.require_receipts_above.trim()) {
Some(t) if !t.is_sign_negative() || t.is_zero() => Ok(()),
_ => Err(format!(
"receipt threshold {:?} must be a decimal at least zero",
self.require_receipts_above
)),
}
}
/// The threshold: receipts are required strictly above it.
pub fn threshold(&self) -> Decimal {
// validate() runs at startup; an unvalidated policy (tests) that
// fails to parse requires a receipt on everything.
crate::decimal(self.require_receipts_above.trim()).unwrap_or(Decimal::ZERO)
}
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 {
if self.threshold().is_zero() {
"receipts required on every entry".to_string()
} else {
format!(
"receipts required when entry debits are strictly greater than {}",
self.threshold()
)
}
}
/// The receipt rule, phrased for the agent's operating instructions.
pub fn rule(&self) -> String {
if self.threshold().is_zero() {
"every entry needs at least one receipt".to_string()
} else {
format!(
"any entry moving more than ${} in debits needs at least one receipt",
self.threshold().round_dp(2)
)
}
}
/// 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
+5
View File
@@ -17,4 +17,9 @@ serde_json = "1"
workspace = true
[dev-dependencies]
criterion = "0.8.2"
proptest = "1"
[[bench]]
name = "books"
harness = false
+94
View File
@@ -0,0 +1,94 @@
//! Benchmarks for the hot paths (rank 6: a budget, never correctness).
//!
//! `cargo bench -p ledger-core --bench books`. Books of 1k and 10k entries
//! over a 40-account chart: posting, cloning (the store clones the ledger
//! to rehearse every command), trial balance, balance sheet, and a
//! period's P&L. Baselines are recorded in docs/testing.md; there is no
//! budget yet, so nothing gates on them.
use chrono::NaiveDate;
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period, trial_balance};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
use std::hint::black_box;
const TYPES: [AccountType; 5] = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
/// `entries` balanced two-line entries across 2026 over 40 accounts.
fn books(entries: u64) -> Ledger {
let mut l = Ledger::new(Currency::USD);
let ids: Vec<u64> = (0..40)
.map(|i| {
l.add_account(
&format!("{}", 1000 + i),
"a",
TYPES[i as usize % 5],
None,
false,
)
.unwrap()
})
.collect();
let mut x: u64 = 0x9e37_79b9;
for n in 0..entries {
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let date =
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(n * 365 / entries);
let e = l.create_entry(date, "bench");
let m = Money::new(Decimal::new((x % 1_000_000) as i64 + 1, 2), Currency::USD);
l.add_line(e, ids[(x >> 8) as usize % 40], Side::Debit, m)
.unwrap();
l.add_line(e, ids[(x >> 20) as usize % 40], Side::Credit, m)
.unwrap();
l.post_entry(e).unwrap();
}
l
}
fn bench(c: &mut Criterion) {
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
for n in [1_000u64, 10_000] {
let l = books(n);
let mut g = c.benchmark_group(format!("{n} entries"));
g.sample_size(20);
g.bench_function(BenchmarkId::new("clone (store rehearsal)", n), |b| {
b.iter(|| black_box(l.clone()))
});
g.bench_function(BenchmarkId::new("post one more entry", n), |b| {
b.iter_batched(
|| l.clone(),
|mut l| {
let e = l.create_entry(end, "one more");
let m = Money::new(Decimal::new(100, 2), Currency::USD);
l.add_line(e, 1, Side::Debit, m).unwrap();
l.add_line(e, 2, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l
},
criterion::BatchSize::LargeInput,
)
});
g.bench_function(BenchmarkId::new("trial balance", n), |b| {
b.iter(|| black_box(trial_balance(&l)))
});
g.bench_function(BenchmarkId::new("balance sheet", n), |b| {
b.iter(|| black_box(balance_sheet_as_of(&l, end).unwrap()))
});
g.bench_function(BenchmarkId::new("one month's P&L", n), |b| {
let start = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
let stop = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
b.iter(|| black_box(profit_and_loss_for_period(&l, start, stop, None).unwrap()))
});
g.finish();
}
}
criterion_group!(benches, bench);
criterion_main!(benches);
+20 -2
View File
@@ -57,7 +57,7 @@ impl AccountType {
}
/// A single account in the chart of accounts.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Account {
/// Opaque handle.
pub id: AccountId,
@@ -84,7 +84,7 @@ pub struct Account {
}
/// The registry of accounts. Owns id allocation for accounts it holds.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ChartOfAccounts {
accounts: HashMap<AccountId, Account>,
by_code: HashMap<String, AccountId>,
@@ -125,6 +125,24 @@ impl ChartOfAccounts {
self.accounts.values()
}
/// Give an account a new code. The code must be non-empty and free.
pub fn set_code(&mut self, id: AccountId, code: &str) -> Result<(), LedgerError> {
if code.trim().is_empty() {
return Err(LedgerError::EmptyAccountCode);
}
if self.by_code.contains_key(code) {
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
}
let a = self
.accounts
.get_mut(&id)
.ok_or(LedgerError::UnknownAccount(id))?;
self.by_code.remove(&a.code);
a.code = code.to_string();
self.by_code.insert(a.code.clone(), id);
Ok(())
}
/// Set (or clear) an account's tax category and deductible percent.
pub fn set_tax(
&mut self,
+81 -7
View File
@@ -27,7 +27,7 @@ pub type InvoiceId = u64;
pub type BillId = u64;
/// A customer: someone we invoice.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Customer {
/// Opaque handle.
pub id: CustomerId,
@@ -36,7 +36,7 @@ pub struct Customer {
}
/// A vendor: someone who bills us.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Vendor {
/// Opaque handle.
pub id: VendorId,
@@ -64,7 +64,7 @@ pub enum DocumentStatus {
}
/// One line on an invoice or bill.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DocumentLine {
/// Description of the goods/services.
pub description: String,
@@ -73,7 +73,7 @@ pub struct DocumentLine {
}
/// A sales invoice: we billed a customer.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Invoice {
/// Opaque handle.
pub id: InvoiceId,
@@ -106,7 +106,7 @@ impl Invoice {
}
/// A vendor bill: we owe a vendor.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Bill {
/// Opaque handle.
pub id: BillId,
@@ -194,7 +194,29 @@ impl AgingRow {
}
}
/// Bucket a past-due balance by days overdue.
/// Put an open balance in its aging bucket.
///
/// ```text
/// GOAL: say how overdue a balance is, as of a date.
/// SOURCE: docs/spec/rules.md B-18 (bucket edges: CONVENTION, ASC
/// 326-20-55-38 illustrative; due today is Current: DECISION,
/// Stephen 2026-10-13).
/// INVARIANTS:
/// 1. MUST put the whole balance in exactly one bucket; the others are
/// zero.
/// 2. NEVER moves a balance to a younger bucket as `as_of` advances.
/// SCENARIOS:
/// 1. IF `as_of` is on or before the due date:
/// - SHOULD be Current
/// - NEVER past due
/// BECAUSE a document due today is not yet overdue (QuickBooks'
/// convention; Stephen, 2026-10-13).
/// 2. IF it is 1 to 30 days past due: SHOULD be 1-30.
/// 3. IF 31 to 60: SHOULD be 31-60.
/// 4. IF 61 to 90: SHOULD be 61-90.
/// 5. IF more than 90: SHOULD be 90+.
/// BECAUSE these are the standard 30-day aging buckets.
/// ```
pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow {
let mut row = AgingRow {
name: String::new(),
@@ -204,7 +226,7 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) ->
days_90: Decimal::ZERO,
days_90_plus: Decimal::ZERO,
};
if due_date > as_of {
if due_date >= as_of {
row.current = balance;
} else {
let days = (as_of - due_date).num_days();
@@ -348,6 +370,58 @@ mod tests {
assert!(matches!(err, Err(LedgerError::Overpayment(_, _))));
}
#[test]
fn aging_boundaries() {
// Spec scenarios 1-5, at every edge.
let due = NaiveDate::from_ymd_opt(2026, 3, 31).unwrap();
let b = Decimal::from(7);
let bucket = |days: i64| {
let r = aging_bucket(due, due + chrono::Duration::days(days), b);
[r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus]
.iter()
.position(|x| !x.is_zero())
.unwrap()
};
for (days, want) in [
(-10, 0),
(0, 0),
(1, 1),
(30, 1),
(31, 2),
(60, 2),
(61, 3),
(90, 3),
(91, 4),
(4000, 4),
] {
assert_eq!(bucket(days), want, "{days} days past due");
}
}
proptest::proptest! {
/// Invariants 1 and 2: one bucket holds the whole balance, and
/// time only ever ages it.
#[test]
fn aging_is_exclusive_and_only_ages(
due_off in 0i64..2000, a in 0i64..2000, step in 0i64..400, cents in 1i64..10_000_000,
) {
let base = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let due = base + chrono::Duration::days(due_off);
let b = Decimal::new(cents, 2);
let idx = |as_of: NaiveDate| {
let r = aging_bucket(due, as_of, b);
let cells = [r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus];
let held: Vec<usize> = (0..5).filter(|&i| !cells[i].is_zero()).collect();
proptest::prop_assert_eq!(held.len(), 1);
proptest::prop_assert_eq!(cells[held[0]], b);
Ok(held[0])
};
let early = base + chrono::Duration::days(a);
let later = early + chrono::Duration::days(step);
proptest::prop_assert!(idx(early)? <= idx(later)?);
}
}
#[test]
fn ar_aging_buckets_correctly() {
let mut l = arap_ledger();
+225 -3
View File
@@ -5,7 +5,7 @@
//! 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
//! The register is master data owned by [`Ledger`](crate::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
@@ -68,6 +68,208 @@ pub struct Asset {
pub expense_account: AccountId,
/// Lifecycle state.
pub status: AssetStatus,
/// The label on the thing itself, e.g. "WL-0001" (see
/// [`check_asset_tag`]); `None` until one is set.
#[serde(default)]
pub tag: Option<String>,
/// The manufacturer's serial number, if recorded.
#[serde(default)]
pub serial: Option<String>,
/// The posted entry that put it on the books (its purchase, or a
/// member's contribution of it), if linked
/// ([`crate::Ledger::set_asset_acquisition`]).
#[serde(default)]
pub acquired_by: Option<crate::EntryId>,
/// Its federal tax basis, if recorded ([`crate::Ledger::set_asset_tax_basis`]).
/// Book cost is [`Asset::cost`]; tax basis can differ (a member's
/// contribution carries the member's basis, not its market value).
#[serde(default)]
pub tax: Option<TaxBasis>,
}
/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AssetCorrection {
/// Capitalized cost.
pub cost: Money,
/// Residual value.
pub salvage_value: Money,
/// Useful life in months.
pub useful_life_months: u32,
/// In-service date (depreciation starts here).
pub purchase_date: NaiveDate,
}
/// Which rule sets an asset's federal tax basis.
///
/// ```text
/// SOURCE: docs/spec/sources/irs-partnership-basis.md: IRC §1012(a) (cost),
/// IRC §723 (property a partner contributes keeps the partner's
/// adjusted basis), Treas. Reg. §1.168(i)-4(b) (property converted
/// from personal use depreciates from the lesser of its fair market
/// value and its adjusted basis), IRC §704(c)(1)(C) (built-in loss).
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxBasisRule {
/// Bought by the company: its cost (IRC §1012(a)), the book cost.
Cost,
/// Contributed by a member from business use: the member's adjusted
/// basis carries over (IRC §723).
Carryover,
/// Contributed by a member from personal use: depreciable basis is the
/// lesser of fair market value (the book cost) and the member's
/// adjusted basis (Treas. Reg. §1.168(i)-4(b), with IRC §723).
PersonalUseConversion,
}
impl TaxBasisRule {
/// Its wire name: `cost`, `carryover`, `personal_use_conversion`.
pub fn key(self) -> &'static str {
match self {
TaxBasisRule::Cost => "cost",
TaxBasisRule::Carryover => "carryover",
TaxBasisRule::PersonalUseConversion => "personal_use_conversion",
}
}
/// The rule named `key`.
pub fn parse(key: &str) -> Option<Self> {
[Self::Cost, Self::Carryover, Self::PersonalUseConversion]
.into_iter()
.find(|r| r.key() == key)
}
}
/// An asset's federal tax basis: the rule and, for a member's
/// contribution, the member's adjusted basis at the time.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaxBasis {
/// The rule.
pub rule: TaxBasisRule,
/// The contributing member's adjusted basis at contribution (cost less
/// any depreciation they took). `None` for [`TaxBasisRule::Cost`].
pub contributor_basis: Option<Decimal>,
}
impl TaxBasis {
/// Whether the record fits its rule: Cost takes no contributor basis;
/// Carryover and PersonalUseConversion require a non-negative one in
/// cents at most. Returns why not. Needs no asset, so a caller can check
/// before registering one.
pub fn check(&self) -> Result<(), String> {
use TaxBasisRule::*;
match (self.rule, self.contributor_basis) {
(Cost, Some(_)) => {
Err("a purchase's tax basis is its cost; give no contributor basis".into())
}
(Carryover | PersonalUseConversion, None) => {
Err("a contribution needs the contributing member's adjusted basis".into())
}
(_, Some(b)) if b.is_sign_negative() && !b.is_zero() => {
Err("a basis can't be negative".into())
}
(_, Some(b)) if b.normalize().scale() > 2 => Err("a basis is in cents at most".into()),
_ => Ok(()),
}
}
}
impl Asset {
/// Its depreciable federal tax basis, by its rule, or `None` if not
/// recorded.
///
/// ```text
/// INVARIANTS:
/// 1. Cost: the book cost.
/// 2. Carryover: the contributor's adjusted basis.
/// 3. PersonalUseConversion: min(book cost, contributor's basis); the
/// book cost of a contribution is its fair market value.
/// ```
pub fn tax_basis(&self) -> Option<Decimal> {
let t = self.tax?;
Some(match (t.rule, t.contributor_basis) {
(TaxBasisRule::Cost, _) => self.cost.amount,
(TaxBasisRule::Carryover, Some(b)) => b,
(TaxBasisRule::PersonalUseConversion, Some(b)) => b.min(self.cost.amount),
(_, None) => return None,
})
}
/// Built-in gain (positive) or loss (negative) at contribution, for
/// IRC §704(c): fair market value (the book cost) less the contributor's
/// adjusted basis. `None` for a purchase or when not recorded. A
/// built-in loss counts only for the contributing member
/// (§704(c)(1)(C)).
pub fn built_in_gain(&self) -> Option<Decimal> {
let b = self.tax?.contributor_basis?;
Some(self.cost.amount - b)
}
}
/// The longest asset tag.
pub const MAX_ASSET_TAG_LEN: usize = 32;
/// The longest serial number.
pub const MAX_SERIAL_LEN: usize = 64;
/// Check an asset tag's form: 1 to [`MAX_ASSET_TAG_LEN`] characters, each
/// an ASCII letter or digit or one of `-` `_` `.` `/`, at least one of
/// them a letter or digit. Returns why not.
///
/// ```text
/// GOAL: a tag prints on a small label, reads aloud, scans from a QR code,
/// and types back in exactly.
/// SOURCE: DECISION (Stephen, 2026-10-15): generated tags ("WL-0001") or
/// the human's own.
/// INVARIANTS:
/// 1. Accepted tags contain no spaces, no control or non-ASCII
/// characters, and at least one letter or digit.
/// ```
pub fn check_asset_tag(tag: &str) -> Result<(), String> {
if tag.is_empty() {
return Err("a tag can't be empty".into());
}
if tag.len() > MAX_ASSET_TAG_LEN {
return Err(format!("a tag is at most {MAX_ASSET_TAG_LEN} characters"));
}
if let Some(c) = tag
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')))
{
return Err(format!(
"{c:?} can't be in a tag (letters, digits, - _ . / only)"
));
}
if !tag.chars().any(|c| c.is_ascii_alphanumeric()) {
return Err("a tag needs a letter or digit".into());
}
Ok(())
}
/// Check a serial number's form: 1 to [`MAX_SERIAL_LEN`] characters, no
/// control characters, no leading or trailing space. Returns why not.
pub fn check_serial(serial: &str) -> Result<(), String> {
if serial.trim().is_empty() {
return Err("a serial number can't be empty".into());
}
if serial.chars().count() > MAX_SERIAL_LEN {
return Err(format!(
"a serial number is at most {MAX_SERIAL_LEN} characters"
));
}
if serial.trim() != serial {
return Err("a serial number can't start or end with a space".into());
}
if serial.chars().any(char::is_control) {
return Err("a serial number can't contain control characters".into());
}
Ok(())
}
/// The key tags are compared by: tags are unique ignoring case
/// ("wl-0001" is "WL-0001").
pub fn tag_key(tag: &str) -> String {
tag.to_ascii_uppercase()
}
/// Parameters for registering an asset. A struct (not eight arguments)
@@ -128,8 +330,26 @@ fn depreciation_end(asset: &Asset) -> NaiveDate {
}
}
/// Straight-line depreciation schedule from the purchase month through the
/// month containing `through` (clamped to the depreciation end).
/// Straight-line depreciation schedule from the purchase month through
/// `through` (clamped to the depreciation end): one line per month, the
/// last prorated by days when `through` falls mid-month.
///
/// ```text
/// GOAL: how much of an asset's cost has been used up, month by month.
/// SOURCE: docs/spec/rules.md B-15 (straight line; salvage never
/// depreciated; partial months by days); DECISION (Stephen,
/// 2026-10-13): a schedule through a mid-month date prorates that
/// month by days, as partial first and last months do.
/// INVARIANTS:
/// 1. A full schedule MUST sum to exactly cost - salvage, to the cent.
/// 2. Accumulated depreciation NEVER falls; book value NEVER rises and
/// NEVER drops below salvage.
/// SCENARIOS:
/// 1. IF $12,000 over 12 months from Jan 1, through Jun 15:
/// - SHOULD end with June at 500.00 (15 of 30 days), accumulated
/// 5,500.00
/// BECAUSE depreciation accrues by the day within a month.
/// ```
///
/// 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
@@ -234,6 +454,8 @@ pub struct DisposalAmounts {
}
/// Compute disposal amounts. Pure math; posting is the caller's job.
/// A disposal may fall on the purchase date (one day held, accrual being
/// inclusive, B-15 in docs/spec/rules.md); one before it is an error.
pub fn disposal_amounts(
asset: &Asset,
date: NaiveDate,
+2 -2
View File
@@ -180,7 +180,7 @@ mod tests {
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();
let plain = crate::reports::unclosed_earnings(&ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (plain.revenue, plain.expenses));
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
}
@@ -205,7 +205,7 @@ mod tests {
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 total = crate::reports::unclosed_earnings(&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));
+213 -2
View File
@@ -35,6 +35,20 @@ pub enum LedgerError {
#[error("journal line amount is out of range (at most 1000000000000000, ten decimal places)")]
AmountOutOfRange,
/// An amount finer than its currency's smallest unit (ISO 4217 minor
/// units): no one can pay a fraction of a cent (rules.md B-3).
#[error(
"amount {amount} {currency} has more than {places} decimal places; round it to the {currency} minor unit first"
)]
FinerThanMinorUnit {
/// The amount as given.
amount: rust_decimal::Decimal,
/// Its currency code.
currency: String,
/// The decimal places the currency allows.
places: u32,
},
/// The referenced account does not exist in the chart of accounts.
#[error("unknown account id {0}")]
UnknownAccount(u64),
@@ -65,6 +79,197 @@ pub enum LedgerError {
#[error("entry {0} is not posted yet")]
EntryNotPosted(u64),
/// An entry is reversed at most once: a second reversal would undo it
/// twice. (Reversing the reversal re-instates it.)
#[error("entry {0} is already reversed by entry {1}")]
EntryAlreadyReversed(u64, u64),
/// A period's closing entry is never reversed: that would leave the
/// period closed with its earnings unswept.
#[error("entry {0} closes a period and cannot be reversed")]
ClosingEntryIrreversible(u64),
/// The engine posts invoices, bills, payroll, and closes to accounts it
/// finds by code (1200 receivables, 2000 payables, 4000 revenue, ...).
/// An account with that code exists but is the wrong type, or a
/// placeholder, so the engine refuses rather than post there or force
/// the chart into its numbering (Stephen, 2026-10-13). The bookkeeper
/// decides with the human how to handle it.
#[error(
"account {code} \"{name}\" is {}, but {role} needs account {code} to be a postable {expected:?} account; \
wanderbooks won't post {role} there. Decide with the books' owner how to handle it: \
assign {role} to an account of theirs, or renumber that account so {code} is free",
if *placeholder { format!("a placeholder {found:?} account") } else { format!("a {found:?} account") }
)]
ControlAccountMismatch {
/// The code the engine looks for.
code: String,
/// The existing account's name.
name: String,
/// What the engine wanted it for, e.g. "accounts receivable".
role: String,
/// The type that role needs.
expected: crate::AccountType,
/// The existing account's type.
found: crate::AccountType,
/// Whether the existing account is a placeholder.
placeholder: bool,
},
/// An asset tag of the wrong form (see `assets::check_asset_tag`).
#[error("bad asset tag {tag:?}: {why}")]
InvalidAssetTag {
/// The tag given.
tag: String,
/// Why it was refused.
why: String,
},
/// Asset tags are never shared or reused: another asset holds, or
/// held, this one.
#[error("asset tag {tag:?} belongs to asset {asset}; tags are never reused, so choose another")]
AssetTagTaken {
/// The tag given.
tag: String,
/// The asset that holds or held it.
asset: u64,
},
/// An asset's acquisition entry must be a posted, unreversed entry
/// that debits the asset's cost account.
#[error("entry {entry} can't be asset {asset}'s acquisition: {why}")]
AcquisitionEntryMismatch {
/// The asset.
asset: u64,
/// The entry given.
entry: u64,
/// Why not.
why: String,
},
/// An asset's tax basis record doesn't fit its rule (see
/// `Ledger::set_asset_tax_basis`).
#[error("bad tax basis: {0}")]
InvalidTaxBasis(String),
/// No party has this id.
#[error("unknown party {0}")]
UnknownParty(u64),
/// A party command that breaks a rule of the directory
/// (`crate::parties`).
#[error("{0}")]
InvalidParty(String),
/// A serial number of the wrong form (see `assets::check_serial`).
#[error("bad serial number: {0}")]
InvalidSerial(String),
/// No control role has that name (see `ledger_core::control_accounts`).
#[error(
"no role {0:?}; the roles are receivables, revenue, payables, general_expense, \
retained_earnings, salary_expense, payroll_tax_expense, federal_withholding, \
state_withholding, social_security_payable, medicare_payable, other_deductions_payable"
)]
UnknownRole(String),
/// One account per role: an account already filling a role can't take
/// a second (its balance would mix what the two control).
#[error("account {code} \"{name}\" already holds {role}; give each role its own account")]
RoleTaken {
/// The account's code.
code: String,
/// The account's name.
name: String,
/// The role it fills, e.g. "accounts receivable".
role: String,
},
/// A balance sheet role (receivables, payables, withholdings, retained
/// earnings) can't move while its account holds a balance: the control
/// account must keep agreeing with what it controls.
#[error(
"{role} is in account {code} \"{name}\", which holds {balance}; move that balance to the \
new account with a journal entry first, then assign the role"
)]
RoleHoldsBalance {
/// The role, e.g. "accounts receivable".
role: String,
/// The code of the account filling it now.
code: String,
/// That account's name.
name: String,
/// Its balance.
balance: rust_decimal::Decimal,
},
/// A rule set by law (a dollar threshold, a rate) isn't known to
/// wanderbooks for the year asked; the engine never guesses (see
/// [`crate::rules`]).
#[error(
"wanderbooks knows the {rule} only through {known_through}, not for {year}. \
Find the {year} figure from the official source (see {spec}) or ask the books' \
owner or CPA, and pass it explicitly. Then update the rule for everyone: open a \
pull request (or at least an issue) at https://github.com/swaits/wanderbooks with \
the new value and its official source."
)]
RuleNotKnown {
/// The rule's plain name.
rule: String,
/// The year asked for.
year: i32,
/// The last year the rule is known for.
known_through: i32,
/// The vendored official source of the rule.
spec: String,
},
/// A child account subdivides its parent, so it must be the same type
/// (Stephen, 2026-10-13).
#[error(
"account {code} is {found:?}, but its parent {parent_code} is {expected:?}; a child account must be the same type as its parent"
)]
ParentTypeMismatch {
/// The new account's code.
code: String,
/// The parent's code.
parent_code: String,
/// The parent's type, which the child must share.
expected: crate::AccountType,
/// The type the child was given.
found: crate::AccountType,
},
/// Periods nest (a month in a quarter in a year) but never partially
/// overlap (Stephen, 2026-10-13).
#[error(
"period {name} ({start} to {end}) partially overlaps period {other} ({other_start} to {other_end}); periods may nest, but not overlap"
)]
PeriodsOverlap {
/// The new period's name.
name: String,
/// Its first day.
start: chrono::NaiveDate,
/// Its last day.
end: chrono::NaiveDate,
/// The existing period's name.
other: String,
/// The existing period's first day.
other_start: chrono::NaiveDate,
/// The existing period's last day.
other_end: chrono::NaiveDate,
},
/// A payment can't apply to an invoice or bill dated after it (Stephen,
/// 2026-10-13); a real prepayment is a deposit, recorded differently.
#[error("a payment dated {date} can't apply to a document dated {document_date}")]
PaymentBeforeDocument {
/// The payment's date.
date: chrono::NaiveDate,
/// The invoice's or bill's date.
document_date: chrono::NaiveDate,
},
/// The referenced journal entry does not exist.
#[error("entry {0} does not exist")]
UnknownEntry(u64),
@@ -77,6 +282,11 @@ pub enum LedgerError {
#[error("account code must not be empty")]
EmptyAccountCode,
/// An account's name is empty or only spaces: a chart lists every
/// account by number and title.
#[error("account name must not be empty")]
EmptyAccountName,
/// The referenced parent account does not exist.
#[error("unknown parent account id {0}")]
UnknownParentAccount(u64),
@@ -254,8 +464,9 @@ pub enum LedgerError {
#[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")]
/// A payroll run must pay some wages: at least one employee line with
/// gross pay.
#[error("payroll run must have at least one employee with gross pay")]
EmptyPayroll,
/// Payroll amounts must not be negative.
+219 -34
View File
@@ -3,16 +3,24 @@
//! 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.
//! (`pay_bill` tags automatically). The amount paid is what left the
//! company's payment sources on the vendor's tagged entries: credits minus
//! debits to them ([`PaymentAccounts`]). The sources are the cash accounts
//! (bank, cash on hand) and the financing liabilities (what owners or
//! lenders paid on the company's behalf, e.g. Due to Shareholders): the
//! company is the payer either way. A card payment (Dr expense, Cr credit
//! card) leaves neither, so it is excluded, as it should be: card payments
//! are reported by the card processor on 1099-K, not by the payer. A bill
//! (Cr payables) is not a payment until it is paid, and a prepayment
//! (Dr prepaid, Cr cash) counts in full when made: §6041 reports payments
//! made in the year. An entry with several parties (a payment shared
//! between a contractor and a member) counts only the vendor's share of
//! that payment ([`vendor_1099_report_with_members`]).
//!
//! 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 crate::{AccountType, Ledger, LedgerError, VendorId};
use chrono::Datelike;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -47,17 +55,40 @@ impl Form1099 {
}
}
/// 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 {
/// The reporting threshold for payments made in `year`.
///
/// ```text
/// GOAL: the dollar amount at which a vendor's payments become reportable.
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a), (h), §6041A(a)(2),
/// Pub. L. 119-21 §70433; sources/irs-1099-thresholds.md).
/// SCENARIOS:
/// 1. IF `year` <= 2025: SHOULD be $600.
/// 2. IF `year` == 2026: SHOULD be $2,000.
/// 3. IF `year` > rules::THRESHOLD_1099.known_through:
/// - SHOULD refuse with RuleNotKnown
/// - NEVER guess $2,000
/// BECAUSE the amount is indexed for inflation after 2026 and not
/// yet known (Stephen, 2026-10-13: never apply stale rules).
/// ```
///
/// Royalties (1099-MISC, $10) and attorneys' gross proceeds ($600) are not
/// modeled. Pass an explicit threshold to [`vendor_1099_report`] to
/// override.
pub fn default_threshold(year: i32) -> Result<Decimal, LedgerError> {
let rule = crate::rules::THRESHOLD_1099;
if year > rule.known_through {
return Err(LedgerError::RuleNotKnown {
rule: rule.name.to_string(),
year,
known_through: rule.known_through,
spec: rule.source.to_string(),
});
}
Ok(if year <= 2025 {
Decimal::new(600, 0)
} else {
Decimal::new(2000, 0)
}
})
}
/// One vendor's year.
@@ -86,6 +117,10 @@ pub struct Vendor1099Report {
pub threshold: Decimal,
/// Every vendor marked for a 1099 or paid in the year, by name.
pub vendors: Vec<Vendor1099Line>,
/// Entries naming several parties whose shares didn't resolve, in
/// posting order: each counted in full toward its vendor until its
/// shares are fixed ([`vendor_1099_report_with_members`] INVARIANT 4).
pub unresolved: Vec<crate::EntryId>,
}
/// Cash paid per vendor in calendar `year`, from posted entries tagged
@@ -94,9 +129,141 @@ pub fn vendor_1099_report(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
) -> Vendor1099Report {
let threshold = threshold.unwrap_or_else(|| default_threshold(year));
) -> Result<Vendor1099Report, LedgerError> {
vendor_1099_report_with(ledger, year, threshold, &PaymentAccounts::default())
}
/// The accounts payments to vendors come out of, for
/// [`vendor_1099_report_with`]: the books' `[books] cash_accounts` and
/// `[books] financing_accounts`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PaymentAccounts {
/// Cash accounts: bank, savings, cash on hand.
pub cash: Vec<crate::AccountId>,
/// Financing liabilities: owner advances, loans (paid on the company's
/// behalf). Never a payment card.
pub financing: Vec<crate::AccountId>,
}
impl PaymentAccounts {
/// Whether `account` is a payment source. With no cash accounts given,
/// every asset account is one (books that haven't named their cash).
fn includes(&self, ledger: &Ledger, account: crate::AccountId) -> bool {
if self.cash.is_empty() {
ledger
.chart()
.get(account)
.is_some_and(|a| a.account_type == AccountType::Asset)
|| self.financing.contains(&account)
} else {
self.cash.contains(&account) || self.financing.contains(&account)
}
}
}
/// [`vendor_1099_report`], counting payments out of `sources`, with no
/// members: [`vendor_1099_report_with_members`] with none (an entry naming
/// several parties then needs amount or percent shares to resolve, which
/// a vendor beside anyone always does, since a vendor is not a member).
pub fn vendor_1099_report_with(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
sources: &PaymentAccounts,
) -> Result<Vendor1099Report, LedgerError> {
vendor_1099_report_with_members(ledger, year, threshold, sources, &[])
}
/// What an entry paid out of `sources`: credits minus debits to them.
fn payment(ledger: &Ledger, entry: &crate::JournalEntry, sources: &PaymentAccounts) -> Decimal {
entry
.lines()
.iter()
.filter(|l| sources.includes(ledger, l.account))
.map(|l| match l.side {
crate::Side::Credit => l.amount.amount,
crate::Side::Debit => -l.amount.amount,
})
.sum()
}
/// The vendor's part of `payment` when the entry names several parties:
/// its resolved share scaled from the share target to the payment (the
/// SCENARIOS of [`vendor_1099_report_with_members`], INVARIANT 3).
fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Decimal {
if payment == target {
share
} else if target.is_zero() {
Decimal::ZERO
} else {
(payment * share / target)
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
}
}
/// [`vendor_1099_report`], counting payments out of `sources`, each
/// vendor's part of an entry with several parties resolved with `members`
/// (the company's members, from the settings).
///
/// ```text
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
/// the year; card payments go on 1099-K); crate::shares (an entry
/// with several parties, each with their share; DECISION, Stephen,
/// 2026-10-15).
/// DEFINITIONS:
/// payment(e): credits minus debits to `sources` on entry e.
/// money: crate::parties::money_accounts(ledger, sources.cash), what the
/// shares resolve against (the MCP server checks them with the same).
/// target(e): crate::shares::share_target(e, money): the entry_flow net,
/// else the total debits.
/// INVARIANTS:
/// 1. Only posted entries dated in `year` and tagged with a vendor
/// (Ledger::entry_vendor) count, toward that vendor.
/// 2. An entry naming at most one party (Ledger::entry_parties): the
/// vendor's amount is payment(e), exactly as before shares.
/// 3. An entry naming several parties (one of them the vendor's party,
/// crate::shares INVARIANT 5): the vendor's amount is its resolved
/// share s (crate::shares::resolve with money and `members`) scaled
/// to the payment: s when payment(e) = target(e) (no rounding: an
/// expense paid from cash or by a member), else
/// payment(e) × s / target(e) rounded to the nearest cent, half away
/// from zero (0 when the payment is 0: charged to a card; 0 when the
/// target is 0). The other parties' parts are not anyone's 1099.
/// 4. An entry naming several parties whose shares don't resolve (the
/// members changed in the settings, say): counts payment(e) in full
/// toward the vendor, the conservative answer, and is listed in
/// `unresolved` so the human fixes the shares.
/// SCENARIOS:
/// 1. Paid from cash, or by a member through a financing liability:
/// counts. Charged to a card, or billed and unpaid: doesn't.
/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to
/// cash and a reversal subtract.
/// 3. $1,000.00 from the bank (Dr contract labor / Cr 1000) shared 60/40
/// by amount between a contractor and a member: the contractor's
/// 1099 counts 600.00.
/// 4. $100.01 from the bank, 50% / 50% contractor first: the
/// contractor gets 50.01 (the leftover cent goes to the first party,
/// crate::shares INVARIANT 8); named second, 50.00.
/// 5. $1,000.00 split 60/40, 600.00 of it from the bank and 400.00 on
/// the card: payment 600.00, target 1,000.00, the contractor counts
/// 600.00 × 600.00 / 1,000.00 = 360.00.
/// 6. Reversing scenario 3: the reversal carries the shares, resolves
/// to −600.00, and the year nets to 0.00.
/// ```
pub fn vendor_1099_report_with_members(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
sources: &PaymentAccounts,
members: &[crate::PartyMember],
) -> Result<Vendor1099Report, LedgerError> {
let threshold = match threshold {
Some(t) => t,
None => default_threshold(year)?,
};
let money = crate::parties::money_accounts(ledger, &sources.cash);
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
let mut unresolved = Vec::new();
for entry in ledger.posted_entries() {
if entry.date().year() != year {
continue;
@@ -104,21 +271,33 @@ pub fn vendor_1099_report(
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 cash = payment(ledger, entry, sources);
let shares = ledger.entry_parties(entry.id());
let amount = if shares.len() < 2 {
cash
} else {
let party = ledger.party_of_vendor(vendor);
match crate::shares::resolve(entry, shares, &money, members) {
Ok(parts) => {
match parts.into_iter().find(|(p, _)| Some(*p) == party) {
Some((_, share)) => {
vendor_part(cash, share, crate::shares::share_target(entry, &money))
}
// Tagged by hand with a vendor none of the parties
// is: nothing to scale by, so the whole payment.
None => {
unresolved.push(entry.id());
cash
}
}
}
Err(_) => {
unresolved.push(entry.id());
cash
}
}
};
*paid.entry(vendor).or_default() += amount;
}
let mut vendors: Vec<Vendor1099Line> = ledger
.list_vendors()
@@ -130,16 +309,22 @@ pub fn vendor_1099_report(
vendor_id: v.id,
name: v.name.clone(),
form: v.form_1099,
tin_on_file: v.tin_on_file,
// On file by the vendor's flag or its party's W-9 facts.
tin_on_file: v.tin_on_file
|| ledger
.party_of_vendor(v.id)
.and_then(|p| ledger.get_party(p))
.is_some_and(|p| p.w9.is_some()),
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 {
Ok(Vendor1099Report {
year,
threshold,
vendors,
}
unresolved,
})
}
+2 -2
View File
@@ -24,7 +24,7 @@ pub enum EntryStatus {
}
/// One debit or credit line of a journal entry.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct JournalLine {
/// The account this line posts to.
pub account: AccountId,
@@ -51,7 +51,7 @@ pub struct JournalLine {
}
/// A balanced set of journal lines with a date and a description.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct JournalEntry {
id: EntryId,
date: NaiveDate,
+870
View File
@@ -0,0 +1,870 @@
//! The members' K-1 worksheet: each member's capital account for the
//! year, as Schedule K-1 (Form 1065) Item L reports it, with property
//! contributed apart from cash.
//!
//! ```text
//! GOAL: hand the CPA, per member, what the K-1 needs from the books:
//! beginning capital, contributions (cash and property separately),
//! their share of income, distributions, ending capital.
//! SOURCE: Form 1065 Schedule K-1 instructions, Item L ("Partner's capital
//! account analysis", on the partnership's books); B-13;
//! DECISION (Stephen, 2026-10-15): the CPA package carries a K-1
//! worksheet per member.
//! INVARIANTS:
//! 1. Every figure but the contribution split is the statement of
//! changes in members' equity's (reports::members_equity) for the
//! same span, so the two never disagree.
//! 2. contributions_cash + contributions_property = contributions,
//! exactly, per member.
//! 3. Property is what an entry crediting a member's capital account
//! debits to asset accounts that are neither cash (`cash`) nor
//! receivables: equipment, a vehicle, software, or the unexpired part
//! of a service contract (AppleCare+, a prepaid subscription) that a
//! member owned and hands the company, each with its own basis
//! (§723). The rest of a contribution is cash: deposits, and what a
//! member paid for the company (an expense booked straight to
//! capital, or a debt they took on, e.g. Dr 2100 / Cr capital). A
//! member who pays a vendor for the company's prepaid service is
//! booked through Due to Shareholders (Dr prepaid / Cr 2100), then
//! converted (Dr 2100 / Cr capital): cash. A prepaid credited
//! straight to capital is a contract they handed over: property.
//! (DECISION, Stephen, 2026-10-15, after the AppleCare+ contributed
//! with the MacBook read as cash.)
//! 4. Within one entry, the property (at most the members' credits in
//! it) is shared among the members credited in proportion to their
//! credits, to the cent, leftover cents by largest remainder
//! (kernel::apportion, ties to the earlier member): each takes at
//! most its own credit, and the split depends on the order the
//! members are given only for a tied leftover cent (never on line
//! order). Joint property is joint (B-13's
//! shares, the house convention that joint funds are 50/50).
//! 5. k1_detail's lines explain each row: per member, its cash lines
//! sum to contributions_cash, its property lines to
//! contributions_property, and its distribution lines to
//! −distributions, exactly (the same entries members_equity reads:
//! posted, in the span, not a period's closing entry, not cancelled
//! by its reversal in the span).
//! 6. An entry and its reversal both dated in the span cancel
//! (reports::cancelled_in_span; members_equity INVARIANT 6): neither
//! gives a line, so a reversed contribution is neither a contribution
//! nor a distribution, and a reversed distribution neither a
//! distribution nor a contribution.
//! SCENARIOS:
//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001
//! 500) reversed the next day: each member 0.00 contributed, 0.00
//! distributed.
//! 2. Members pay a vendor ahead for the company's annual service from
//! joint funds, through Due to Shareholders (Dr 1300 Prepaid 3,000 /
//! Cr 2100 3,000), later converted to capital (Dr 2100 3,000 / Cr
//! 3000 1,500, Cr 3001 1,500): 1,500.00 cash each, no property.
//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000
//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
//! whichever member is listed first.
//! 4. Members hand the company a service contract they already owned,
//! straight to capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr
//! 3001 1,500): 1,500.00 property each, no cash.
//! ```
use crate::reports::{MemberCapital, members_equity};
use crate::{AccountId, AccountType, EntryId, Ledger, LedgerError, PartyId, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// One member's row on the K-1 worksheet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct K1Row {
/// The member's name, as in wanderbooks.toml.
pub name: String,
/// Share of income, e.g. 0.5.
pub share: Decimal,
/// Capital at the start of the span.
pub beginning: Decimal,
/// Contributed in cash (or paid for the company) in the span.
pub contributions_cash: Decimal,
/// Contributed as property (book value credited) in the span.
pub contributions_property: Decimal,
/// Their share of the span's net income (loss).
pub net_income: Decimal,
/// Distributions in the span (positive, subtracted).
pub distributions: Decimal,
/// Any other change, as on the statement of members' equity.
pub other: Decimal,
/// Capital at the end of the span.
pub ending: Decimal,
}
/// How one entry moved one member's capital ([`K1Line`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum K1Kind {
/// The cash part of a contribution (INVARIANT 3).
CashContribution,
/// The property part of a contribution (INVARIANTS 3-4).
PropertyContribution,
/// Taken out: debits to capital, net debits to distributions.
Distribution,
/// A period close allocating income into capital. Informational:
/// the row's `net_income` is the roll-forward's (INVARIANT 1), which
/// also counts income not yet closed.
IncomeClose,
}
/// One entry's effect on one member's capital in the span, by kind
/// ([`k1_detail`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct K1Line {
/// The member's index in the `members` given.
pub member: usize,
/// The entry.
pub entry: EntryId,
/// What it was.
pub kind: K1Kind,
/// Signed: positive raises their capital, negative lowers it.
pub amount: Decimal,
}
/// The K-1 worksheet with the entries behind it ([`k1_detail`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct K1Detail {
/// One row per member, in order: [`k1_worksheet`]'s.
pub rows: Vec<K1Row>,
/// Every entry dated in the span that moved a member's capital
/// account (or distributions account), in journal order, a kind per
/// line (an entry with more than one kind for a member is more than
/// one line). Per member (INVARIANT 5):
/// Σ CashContribution = contributions_cash,
/// Σ PropertyContribution = contributions_property, and
/// −Σ Distribution = distributions, exactly.
pub lines: Vec<K1Line>,
}
/// The K-1 worksheet for `start..=end`: one row per member, in order
/// (INVARIANTS 1-4). `cash` are the company's cash accounts; it must not
/// be empty (without it a bank deposit would read as property).
pub fn k1_worksheet(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
members: &[MemberCapital],
cash: &[AccountId],
) -> Result<Vec<K1Row>, LedgerError> {
k1_detail(ledger, start, end, members, cash).map(|d| d.rows)
}
/// [`k1_worksheet`] with the per-entry lines behind each row's
/// contributions and distributions (INVARIANT 5): the one source the
/// viewer's member capital statement reads, so it never disagrees with
/// the CPA package.
pub fn k1_detail(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
members: &[MemberCapital],
cash: &[AccountId],
) -> Result<K1Detail, LedgerError> {
if cash.is_empty() {
return Err(LedgerError::InvalidMembers(
"the K-1 worksheet needs the cash accounts".into(),
));
}
let me = members_equity(ledger, start, end, members)?;
let receivables = ledger.role_account(&crate::RECEIVABLES);
let closings = ledger.closing_entry_ids();
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
let property_account = |id: AccountId| {
!cash.contains(&id)
&& Some(id) != receivables
&& ledger
.chart()
.get(id)
.is_some_and(|a| a.account_type == AccountType::Asset)
};
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
e.lines()
.iter()
.filter(|l| l.side == side && l.account == account)
.map(|l| l.amount.amount)
.sum()
};
let mut lines = Vec::new();
for e in ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end && !cancelled.contains(&e.id()))
{
let line = |member, kind, amount| K1Line {
member,
entry: e.id(),
kind,
amount,
};
if closings.contains(&e.id()) {
for (i, m) in members.iter().enumerate() {
let effect: Decimal = std::iter::once(m.capital_account)
.chain(m.distribution_account)
.map(|a| sum(e, Side::Credit, a) - sum(e, Side::Debit, a))
.sum();
if !effect.is_zero() {
lines.push(line(i, K1Kind::IncomeClose, effect));
}
}
continue;
}
let pool: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit && property_account(l.account))
.map(|l| l.amount.amount)
.sum();
// Contributions are credits to capital (members_equity's).
let credits: Vec<Decimal> = members
.iter()
.map(|m| sum(e, Side::Credit, m.capital_account))
.collect();
let takes = share_property(pool, &credits);
for (i, m) in members.iter().enumerate() {
let (credit, take) = (credits[i], takes[i]);
if !(credit - take).is_zero() {
lines.push(line(i, K1Kind::CashContribution, credit - take));
}
if !take.is_zero() {
lines.push(line(i, K1Kind::PropertyContribution, take));
}
// Distributions: debits to capital, net debits to the
// distributions account (members_equity's).
let taken = sum(e, Side::Debit, m.capital_account)
+ m.distribution_account.map_or(Decimal::ZERO, |d| {
sum(e, Side::Debit, d) - sum(e, Side::Credit, d)
});
if !taken.is_zero() {
lines.push(line(i, K1Kind::Distribution, -taken));
}
}
}
let total = |i: usize, k: K1Kind| -> Decimal {
lines
.iter()
.filter(|l| l.member == i && l.kind == k)
.map(|l| l.amount)
.sum()
};
let rows = me
.members
.into_iter()
.zip(members)
.enumerate()
.map(|(i, (col, m))| {
// Each entry's property is at most that member's credit in it,
// and contributions are those credits' sum: property never
// exceeds contributions, and is never negative.
let prop = total(i, K1Kind::PropertyContribution);
K1Row {
name: col.name,
share: m.share,
beginning: col.beginning,
contributions_cash: col.contributions - prop,
contributions_property: prop,
net_income: col.net_income,
distributions: col.distributions,
other: col.other,
ending: col.ending,
}
})
.collect();
Ok(K1Detail { rows, lines })
}
/// One entry's property shared among the members' credits in it
/// (INVARIANT 4): at most their total credit, in proportion to each
/// credit, rounded toward zero to the cent, the leftover cents by largest
/// remainder. Each part is at most its own credit.
fn share_property(pool: Decimal, credits: &[Decimal]) -> Vec<Decimal> {
use rust_decimal::RoundingStrategy::ToZero;
let total: Decimal = credits.iter().sum();
let whole = pool.min(total);
if whole <= Decimal::ZERO || total <= Decimal::ZERO {
return vec![Decimal::ZERO; credits.len()];
}
// whole × credit is exact; ÷ total is exact whenever the share is a
// whole number of cents, so truncating never loses a cent it is owed.
let exact: Vec<Decimal> = credits.iter().map(|c| whole * c / total).collect();
let mut parts: Vec<Decimal> = exact
.iter()
.map(|x| x.round_dp_with_strategy(2, ToZero))
.collect();
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2))
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
parts
}
/// The party a member (by name, as in wanderbooks.toml) is in the
/// directory: the party not archived with that name, ignoring case and
/// surrounding space ([`Ledger::party_by_name`]), and only when it has
/// the member role. The same rule as ledger-config's
/// `Company::party_members`, so the K-1 worksheet and the viewer's
/// capital statement always name the same party. A display name never
/// matches, and a party without the member role is never a member's: a
/// vendor whose display name is a member's name must never put its TIN
/// on that member's K-1 (the caller reports the member's TIN as a gap).
pub fn member_party(ledger: &Ledger, name: &str) -> Option<PartyId> {
ledger
.party_by_name(name)
.filter(|p| p.roles.contains(&crate::Role::Member))
.map(|p| p.id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Currency, Money};
use std::str::FromStr;
fn d(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
/// Hand-worked: A contributes 300 cash and a 700 laptop in one entry
/// (Dr 1000 300, Dr 1500 700 / Cr 3000 1000); B contributes a 200
/// receivable-backed promise (Dr 1200 200 / Cr 3001 200, cash by
/// INVARIANT 3) and pays a 50 expense (Dr 6999 50 / Cr 3001 50).
#[test]
fn contributions_split_into_cash_and_property() {
let mut l = Ledger::new(Currency::USD);
let add =
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add(&mut l, "1000", AccountType::Asset);
let ar = add(&mut l, "1200", AccountType::Asset);
let equip = add(&mut l, "1500", AccountType::Asset);
let a = add(&mut l, "3000", AccountType::Equity);
let b = add(&mut l, "3001", AccountType::Equity);
let exp = add(&mut l, "6999", AccountType::Expense);
assert_eq!(
l.role_account(&crate::RECEIVABLES),
Some(ar),
"1200 by default"
);
let day = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
let mut post = |lines: &[(AccountId, Side, &str)]| {
let e = l.create_entry(day, "x");
for (acct, side, amt) in lines {
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
.unwrap();
}
l.post_entry(e).unwrap();
};
post(&[
(cash, Side::Debit, "300"),
(equip, Side::Debit, "700"),
(a, Side::Credit, "1000"),
]);
post(&[(ar, Side::Debit, "200"), (b, Side::Credit, "200")]);
post(&[(exp, Side::Debit, "50"), (b, Side::Credit, "50")]);
let members = [
MemberCapital {
name: "A".into(),
capital_account: a,
distribution_account: None,
share: d("0.5"),
},
MemberCapital {
name: "B".into(),
capital_account: b,
distribution_account: None,
share: d("0.5"),
},
];
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
let rows = k1_worksheet(&l, start, end, &members, &[cash]).unwrap();
assert_eq!(
(rows[0].contributions_cash, rows[0].contributions_property),
(d("300"), d("700"))
);
assert_eq!(
(rows[1].contributions_cash, rows[1].contributions_property),
(d("250"), d("0"))
);
// Net income -50, split -25 each.
assert_eq!(rows[0].ending, d("975"));
assert_eq!(rows[1].ending, d("225"));
assert!(k1_worksheet(&l, start, end, &members, &[]).is_err());
}
/// Hand-worked: A (capital 3000, distributions 3100) contributes 100
/// cash and 40 of equipment in one entry and takes 30 through 3100; B
/// takes 10 straight from 3001; revenue 60; Jan closed 50/50 (30
/// each, A's 3100 zeroed into 3000). A: 140 in (100 cash, 40
/// property), 30 out, 30 income, ending 140. B: 10 out, 30 income,
/// ending 20. The lines explain the rows (INVARIANT 5).
#[test]
fn detail_lines_explain_each_row() {
let mut l = Ledger::new(Currency::USD);
let add =
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add(&mut l, "1000", AccountType::Asset);
let equip = add(&mut l, "1500", AccountType::Asset);
let a = add(&mut l, "3000", AccountType::Equity);
let b = add(&mut l, "3001", AccountType::Equity);
let da = add(&mut l, "3100", AccountType::Equity);
let rev = add(&mut l, "4000", AccountType::Revenue);
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let mut post = |date, lines: &[(AccountId, Side, &str)]| {
let e = l.create_entry(date, "x");
for (acct, side, amt) in lines {
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
.unwrap();
}
l.post_entry(e).unwrap();
e
};
let put_in = post(
day(1, 2),
&[
(cash, Side::Debit, "100"),
(equip, Side::Debit, "40"),
(a, Side::Credit, "140"),
],
);
let a_out = post(
day(1, 3),
&[(da, Side::Debit, "30"), (cash, Side::Credit, "30")],
);
let b_out = post(
day(1, 4),
&[(b, Side::Debit, "10"), (cash, Side::Credit, "10")],
);
post(
day(1, 5),
&[(cash, Side::Debit, "60"), (rev, Side::Credit, "60")],
);
let jan = l.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
let close = l
.close_period_to(
jan,
&crate::periods::CloseTo::Members(vec![
crate::MemberClose {
capital: a,
share: d("0.5"),
distributions: Some(da),
},
crate::MemberClose {
capital: b,
share: d("0.5"),
distributions: None,
},
]),
)
.unwrap()
.unwrap();
let members = [
MemberCapital {
name: "A".into(),
capital_account: a,
distribution_account: Some(da),
share: d("0.5"),
},
MemberCapital {
name: "B".into(),
capital_account: b,
distribution_account: None,
share: d("0.5"),
},
];
let (start, end) = (day(1, 1), day(12, 31));
let detail = k1_detail(&l, start, end, &members, &[cash]).unwrap();
assert_eq!(
detail.rows,
k1_worksheet(&l, start, end, &members, &[cash]).unwrap()
);
let line = |member, entry, kind, amount: &str| K1Line {
member,
entry,
kind,
amount: d(amount),
};
assert_eq!(
detail.lines,
vec![
line(0, put_in, K1Kind::CashContribution, "100"),
line(0, put_in, K1Kind::PropertyContribution, "40"),
line(0, a_out, K1Kind::Distribution, "-30"),
line(1, b_out, K1Kind::Distribution, "-10"),
line(0, close, K1Kind::IncomeClose, "30"),
line(1, close, K1Kind::IncomeClose, "30"),
]
);
let ra = &detail.rows[0];
assert_eq!(
(
ra.beginning,
ra.contributions_cash,
ra.contributions_property
),
(d("0"), d("100"), d("40"))
);
assert_eq!(
(ra.net_income, ra.distributions, ra.ending),
(d("30"), d("30"), d("140"))
);
let rb = &detail.rows[1];
assert_eq!(
(rb.net_income, rb.distributions, rb.ending),
(d("30"), d("10"), d("20"))
);
for (i, row) in detail.rows.iter().enumerate() {
let sum = |k| -> Decimal {
detail
.lines
.iter()
.filter(|x| x.member == i && x.kind == k)
.map(|x| x.amount)
.sum()
};
assert_eq!(sum(K1Kind::CashContribution), row.contributions_cash);
assert_eq!(
sum(K1Kind::PropertyContribution),
row.contributions_property
);
assert_eq!(-sum(K1Kind::Distribution), row.distributions);
assert_eq!(
row.beginning
+ row.contributions_cash
+ row.contributions_property
+ row.net_income
- row.distributions
+ row.other,
row.ending
);
}
// February alone: nothing moved; the capital carries over.
let feb = k1_detail(&l, day(2, 1), day(2, 28), &members, &[cash]).unwrap();
assert!(feb.lines.is_empty());
assert_eq!(
(feb.rows[0].beginning, feb.rows[0].ending),
(d("140"), d("140"))
);
}
/// Two members A (3000) and B (3001), cash 1000, equipment 1500,
/// prepaid 1300, and a `post` that returns the entry.
struct Books {
l: Ledger,
cash: AccountId,
equip: AccountId,
prepaid: AccountId,
a: AccountId,
b: AccountId,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut add =
|code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = add("1000", "Bluevine", AccountType::Asset);
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
let equip = add("1500", "Equipment", AccountType::Asset);
let a = add("3000", "Capital A", AccountType::Equity);
let b = add("3001", "Capital B", AccountType::Equity);
Books {
l,
cash,
equip,
prepaid,
a,
b,
}
}
fn post(l: &mut Ledger, m: u32, day: u32, lines: &[(AccountId, Side, &str)]) -> EntryId {
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, m, day).unwrap(), "x");
for (acct, side, amt) in lines {
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
.unwrap();
}
l.post_entry(e).unwrap();
e
}
fn members(bk: &Books) -> [MemberCapital; 2] {
[
MemberCapital {
name: "A".into(),
capital_account: bk.a,
distribution_account: None,
share: d("0.5"),
},
MemberCapital {
name: "B".into(),
capital_account: bk.b,
distribution_account: None,
share: d("0.5"),
},
]
}
/// (cash, property, distributions) per member for 2026.
fn split(bk: &Books, members: &[MemberCapital]) -> Vec<(Decimal, Decimal, Decimal)> {
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), members, &[bk.cash]).unwrap();
detail
.rows
.iter()
.map(|r| {
(
r.contributions_cash,
r.contributions_property,
r.distributions,
)
})
.collect()
}
/// INVARIANT 6 and SCENARIO 1, by hand. Case 1: a joint 1,000 cash
/// contribution reversed the next day: 0 in, 0 out each (not 500 of
/// each). Case 2: a 700 laptop contribution reversed: A 0 property, 0
/// out. Case 3: a real 500 + 500 contribution, then a 100 + 100
/// distribution reversed: 500 cash each, 0 out (not 600 in, 100 out).
#[test]
fn a_reversed_contribution_or_distribution_cancels() {
let mut bk = books();
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
let joint = post(
&mut bk.l,
1,
2,
&[
(cash, Side::Debit, "1000"),
(a, Side::Credit, "500"),
(b, Side::Credit, "500"),
],
);
let r =
bk.l.reverse_entry(joint, NaiveDate::from_ymd_opt(2026, 1, 3).unwrap(), "x")
.unwrap();
let ms = members(&bk);
let zero = (d("0"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![zero, zero]);
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), &ms, &[cash]).unwrap();
assert!(
detail
.lines
.iter()
.all(|l| l.entry != joint && l.entry != r),
"{:?}",
detail.lines
);
// The statement of members' equity agrees (INVARIANT 1).
let me = members_equity(&bk.l, year(1, 1), year(12, 31), &ms).unwrap();
assert_eq!(
(me.members[0].contributions, me.members[0].distributions),
(d("0"), d("0"))
);
// Only the reversal in the span: by side, a distribution.
let feb_on = members_equity(&bk.l, year(1, 3), year(12, 31), &ms).unwrap();
assert_eq!(feb_on.members[0].distributions, d("500"));
let laptop = post(
&mut bk.l,
2,
1,
&[(equip, Side::Debit, "700"), (a, Side::Credit, "700")],
);
bk.l.reverse_entry(laptop, year(2, 2), "x").unwrap();
assert_eq!(split(&bk, &ms), vec![zero, zero]);
post(
&mut bk.l,
3,
1,
&[
(cash, Side::Debit, "1000"),
(a, Side::Credit, "500"),
(b, Side::Credit, "500"),
],
);
let out = post(
&mut bk.l,
3,
2,
&[
(a, Side::Debit, "100"),
(b, Side::Debit, "100"),
(cash, Side::Credit, "200"),
],
);
let undo = bk.l.reverse_entry(out, year(3, 3), "x").unwrap();
let five = (d("500"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![five, five]);
// Reversing the reversal reinstates the distribution.
bk.l.reverse_entry(undo, year(3, 4), "x").unwrap();
let back = (d("500"), d("0"), d("100"));
assert_eq!(split(&bk, &ms), vec![back, back]);
}
/// INVARIANT 3, by hand: a contract the members hand over (Dr 1300
/// Prepaid 25 / Cr 3000 12.50, Cr 3001 12.50, the AppleCare+ with the
/// MacBook) is property, 12.50 each. Members paying a 3,000 annual
/// service for the company go through 2100: Dr 1300 / Cr 2100 3,000,
/// then Dr 2100 / Cr 3000 1,500, Cr 3001 1,500: 1,500 cash each.
#[test]
fn a_contract_handed_over_is_property_a_service_paid_for_is_cash() {
let mut bk = books();
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
post(
&mut bk.l,
1,
5,
&[
(prepaid, Side::Debit, "25"),
(a, Side::Credit, "12.50"),
(b, Side::Credit, "12.50"),
],
);
let ms = members(&bk);
let handed = (d("0"), d("12.50"), d("0"));
assert_eq!(split(&bk, &ms), vec![handed, handed]);
let due =
bk.l.add_account(
"2100",
"Due to Members",
AccountType::Liability,
None,
false,
)
.unwrap();
post(
&mut bk.l,
2,
1,
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
);
post(
&mut bk.l,
2,
2,
&[
(due, Side::Debit, "3000"),
(a, Side::Credit, "1500"),
(b, Side::Credit, "1500"),
],
);
let each = (d("1500"), d("12.50"), d("0"));
assert_eq!(split(&bk, &ms), vec![each, each]);
}
/// SCENARIOS 2 and 4 exactly as the module doc states them: 3,000 of
/// prepaid service through 2100 is 1,500.00 cash each; the same 3,000
/// straight to capital is 1,500.00 property each.
#[test]
fn scenarios_2_and_4_prepaid_through_2100_is_cash_straight_to_capital_is_property() {
let mut bk = books();
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
let due =
bk.l.add_account(
"2100",
"Due to Shareholders",
AccountType::Liability,
None,
false,
)
.unwrap();
post(
&mut bk.l,
1,
5,
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
);
post(
&mut bk.l,
1,
6,
&[
(due, Side::Debit, "3000"),
(a, Side::Credit, "1500"),
(b, Side::Credit, "1500"),
],
);
let ms = members(&bk);
let cash = (d("1500"), d("0"), d("0"));
assert_eq!(split(&bk, &ms), vec![cash, cash]);
post(
&mut bk.l,
2,
1,
&[
(prepaid, Side::Debit, "3000"),
(a, Side::Credit, "1500"),
(b, Side::Credit, "1500"),
],
);
let both = (d("1500"), d("1500"), d("0"));
assert_eq!(split(&bk, &ms), vec![both, both]);
}
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 /
/// Cr 3000 500, Cr 3001 500. Property 400 split by credit (500:500):
/// 200 each; cash 300 each, whichever member is listed first. Unequal
/// credits (Cr 3000 750, Cr 3001 250, property 401): 401 × 0.75 =
/// 300.75, 401 × 0.25 = 100.25, exact. Odd cents: property 0.01 on
/// equal credits goes to the earlier member on a tie.
#[test]
fn joint_property_is_shared_by_credit() {
let mut bk = books();
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
post(
&mut bk.l,
1,
5,
&[
(cash, Side::Debit, "600"),
(equip, Side::Debit, "400"),
(a, Side::Credit, "500"),
(b, Side::Credit, "500"),
],
);
let ms = members(&bk);
let each = (d("300"), d("200"), d("0"));
assert_eq!(split(&bk, &ms), vec![each, each]);
let mut flipped = ms.clone();
flipped.reverse();
assert_eq!(split(&bk, &flipped), vec![each, each]);
assert_eq!(
share_property(d("401"), &[d("750"), d("250")]),
vec![d("300.75"), d("100.25")]
);
assert_eq!(
share_property(d("0.01"), &[d("5"), d("5")]),
vec![d("0.01"), d("0.00")]
);
// More property than credit (part financed): at most the credits.
assert_eq!(
share_property(d("900"), &[d("300"), d("100")]),
vec![d("300"), d("100")]
);
assert_eq!(
share_property(d("50"), &[d("0"), d("0")]),
vec![d("0"), d("0")]
);
}
/// member_party doc: the party of that name with the member role only.
/// A contractor whose display name is the member's name, or a party
/// of the name without the member role, is never the member's.
#[test]
fn member_party_needs_the_member_role_and_the_name() {
let mut l = Ledger::new(Currency::USD);
let ke = l
.add_party("KE Consulting LLC", &[crate::Role::Contractor])
.unwrap();
l.update_party(
ke,
crate::PartyField::DisplayName(Some("Kat Example".into())),
)
.unwrap();
assert_eq!(member_party(&l, "Kat Example"), None);
let plain = l.add_party("Kat Example", &[]).unwrap();
assert_eq!(member_party(&l, "kat example "), None, "no member role");
l.set_party_role(plain, crate::Role::Member, true).unwrap();
assert_eq!(member_party(&l, "kat example "), Some(plain));
}
}
+168 -9
View File
@@ -8,21 +8,25 @@
//! laws it implements hold for books of any size. See
//! `docs/verification.md` for which proof covers which law.
//!
//! The laws, in the order they appear:
//! The laws, in the order they appear (each traced to its authority in
//! `docs/spec/rules.md`):
//!
//! * a line's signed amount is `+amount` for a debit, `-amount` for a credit;
//! * a line's signed amount is `+amount` for a debit, `-amount` for a credit
//! (B-2);
//! * an entry is acceptable when it has two or more lines, every amount is
//! positive and in range, and its debits equal its credits exactly;
//! * an acceptable entry nets to zero, account by account summed;
//! positive and in range, and its debits equal its credits exactly (B-1,
//! B-2, B-21);
//! * an acceptable entry nets to zero, account by account summed (B-1);
//! * a reversal flips every side, so it is acceptable exactly when the
//! original is, and the two together net every account to zero;
//! original is, and the two together net every account to zero (B-9);
//! * an account's balance is its movement read on its normal side, so the
//! balances satisfy the accounting equation whenever movements net to
//! zero;
//! zero (B-5, B-7);
//! * a trial balance puts each movement in exactly one column, so its
//! columns differ by the net of all movements;
//! * a closing line zeroes the account it closes;
//! * an allocation's parts sum to the whole, to the last unit.
//! columns differ by the net of all movements (B-6);
//! * a closing line zeroes the account it closes (B-11);
//! * an allocation's parts sum to the whole, to the last unit, by largest
//! remainder (B-13).
use crate::{AccountType, Side};
use rust_decimal::Decimal;
@@ -229,6 +233,9 @@ pub fn normal_balance<T: Amount>(account_type: AccountType, movement: T) -> Opti
pub fn columns<T: Amount>(movement: T) -> Option<(T, T)> {
if movement > T::ZERO {
Some((movement, T::ZERO))
} else if movement == T::ZERO {
// Never a negative zero: money never prints "-0".
Some((T::ZERO, T::ZERO))
} else {
Some((T::ZERO, movement.checked_neg()?))
}
@@ -268,6 +275,83 @@ pub fn plug_last<T: Amount>(whole: T, parts: &mut [T]) -> Option<()> {
Some(())
}
/// Largest-remainder apportionment: make rounded-down `parts` sum to
/// `whole`, handing out the shortfall one `unit` at a time, largest
/// `remainders` first (ties to the earlier part). Any final fraction of a
/// unit (when `whole` isn't a whole number of units) goes to the first
/// part in that order.
///
/// ```text
/// GOAL: split a whole into parts, each a whole number of units where
/// possible, summing to the whole exactly, without favoring anyone.
/// SOURCE: docs/spec/rules.md B-13 (shares: 26 U.S.C. §704(a); cent
/// rounding: DECISION, Stephen 2026-10-13), B-21 (exact totals).
/// INVARIANTS:
/// 1. MUST make the parts sum to `whole` exactly.
/// 2. MUST change each part by zero or one unit (the first in order
/// may also take the sub-unit fraction).
/// 3. NEVER gives a unit to a part while a part with a strictly larger
/// remainder goes without.
/// SCENARIOS:
/// 1. IF the shortfall is negative, or `n` units or more (the parts
/// weren't rounded down from shares of `whole`):
/// - SHOULD return None, leaving `parts` unchanged
/// BECAUSE apportioning only corrects rounding.
/// 2. IF $100.00 splits three equal ways:
/// - SHOULD be 33.34, 33.33, 33.33
/// - NEVER the same partner absorbing every odd cent by position
/// BECAUSE the cent goes to the largest remainder, ties to the first
/// (Stephen, 2026-10-13).
/// ```
///
/// Allocation-free and O(n^2): it ranks each part by counting the parts
/// ahead of it, so the model checker can prove it outright.
pub fn apportion<T: Amount>(whole: T, parts: &mut [T], remainders: &[T], unit: T) -> Option<()> {
if parts.len() != remainders.len() || unit <= T::ZERO {
return None;
}
if parts.is_empty() {
// Nothing to give the whole to.
return (whole == T::ZERO).then_some(());
}
let mut shortfall = whole;
for &p in parts.iter() {
shortfall = shortfall.checked_sub(p)?;
}
if shortfall < T::ZERO {
return None;
}
// Whole units short, by subtraction: no division in `Amount`. A
// shortfall of a unit per part or more isn't rounding; refuse it.
let mut units = 0;
while shortfall >= unit {
shortfall = shortfall.checked_sub(unit)?;
units += 1;
if units == parts.len() {
return None;
}
}
// A part's rank: the parts ahead of it (larger remainder, or equal and
// earlier).
let rank = |i: usize| {
(0..remainders.len())
.filter(|&j| remainders[j] > remainders[i] || (remainders[j] == remainders[i] && j < i))
.count()
};
for (i, part) in parts.iter_mut().enumerate() {
let r = rank(i);
let mut add = T::ZERO;
if r < units {
add = unit;
}
if r == 0 {
add = add.checked_add(shortfall)?;
}
*part = part.checked_add(add)?;
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -283,6 +367,81 @@ mod tests {
assert!("1.000000000000".parse::<Decimal>().unwrap().in_line_range());
}
/// Regression (Kani `apportion_is_exact`, 2026-10-13): a shortfall of
/// two units and a bit, with one part, was accepted and handed almost
/// two units to that part. Spec scenario 1: more than one unit short
/// per part is refused.
#[test]
fn apportion_refuses_a_shortfall_of_a_unit_per_part_or_more() {
let mut parts = [0i64];
assert_eq!(apportion(1805, &mut parts, &[0], 903), None);
assert_eq!(parts, [0]);
let mut parts = [0i64, 0];
assert_eq!(apportion(1806, &mut parts, &[0, 0], 903), None);
// Just under: accepted.
let mut parts = [0i64, 0];
assert_eq!(apportion(1805, &mut parts, &[1, 0], 903), Some(()));
assert_eq!(parts, [1805, 0]);
}
/// The i64 model's line range: up to 2^40 either way, inclusive.
#[test]
fn i64_line_range_is_two_to_the_fortieth() {
assert!((1i64 << 40).in_line_range());
assert!((-(1i64 << 40)).in_line_range());
assert!(!((1i64 << 40) + 1).in_line_range());
assert!(!i64::MIN.in_line_range());
}
/// B-9: a reversal flips every side and keeps accounts and amounts, so
/// with the original it nets every account to zero.
#[test]
fn a_reversal_flips_every_line_and_nets_to_zero() {
let lines = [
(1u8, Side::Debit, 70i64),
(2, Side::Credit, 50),
(3, Side::Credit, 20),
];
let rev = reversal(&lines);
assert_eq!(rev.len(), lines.len());
for (o, r) in lines.iter().zip(&rev) {
assert_eq!((r.0, r.1, r.2), (o.0, flip(o.1), o.2));
}
let both = movements(lines.iter().chain(&rev).copied()).unwrap();
assert_eq!(both, vec![(1, 0), (2, 0), (3, 0)]);
}
/// Spec scenario 1's preconditions: parts and remainders pair up, and
/// the unit is positive; otherwise nothing is apportioned.
#[test]
fn apportion_refuses_mismatched_inputs_or_a_nonpositive_unit() {
let mut parts = [0i64, 0];
assert_eq!(apportion(0, &mut parts, &[0], 1), None);
assert_eq!(parts, [0, 0]);
let mut none: [i64; 0] = [];
assert_eq!(apportion(0, &mut none, &[], 0), None);
assert_eq!(apportion(0, &mut none, &[], -1), None);
}
/// With no parts, only a zero whole is fully apportioned.
#[test]
fn apportion_to_no_parts_accepts_only_zero() {
let mut none: [i64; 0] = [];
assert_eq!(apportion(0, &mut none, &[], 1), Some(()));
assert_eq!(apportion(5, &mut none, &[], 1), None);
}
/// A zero movement's columns are both a plain zero, never "-0" (found
/// by mutation triage, 2026-10-13: it returned a negative-zero credit).
#[test]
fn a_zero_movement_has_no_negative_zero_column() {
let (d, c) = columns(Decimal::ZERO).unwrap();
assert_eq!(
(d.to_string(), c.to_string()),
("0".to_string(), "0".to_string())
);
}
#[test]
fn credit_line_sides() {
assert_eq!(credit_line(5i64), Some((Side::Credit, 5)));
File diff suppressed because it is too large. Load diff
+27 -11
View File
@@ -12,7 +12,7 @@
//! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`,
//! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections
//! are reversing entries.
//! * Reports ([`trial_balance`], [`balance_sheet`], [`profit_and_loss`]) are
//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`unclosed_earnings`](reports::unclosed_earnings)) are
//! pure functions of a ledger, which is what makes the semantic oracle
//! (`docs/oracle.md`) possible.
@@ -24,16 +24,20 @@ pub mod classes;
pub mod error;
pub mod form1099;
pub mod journal;
pub mod k1;
pub mod kernel;
pub mod ledger;
pub mod money;
pub mod parties;
pub mod payouts;
pub mod payroll;
pub mod periods;
#[cfg(kani)]
mod proofs;
pub mod reports;
pub mod rules;
pub mod schedules;
pub mod shares;
pub mod tax;
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
@@ -42,27 +46,39 @@ pub use arap::{
Vendor, VendorId,
};
pub use assets::{
Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine,
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset,
ScheduleLine, TaxBasis, TaxBasisRule, 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 form1099::{
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
vendor_1099_report_with, vendor_1099_report_with_members,
};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use ledger::{
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
};
pub use money::{Currency, Money};
pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts};
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 periods::{
CloseTo, MemberClose, MemberCloseRecord, 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,
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
CashFlowParams, CashFlowStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount,
LedgerPosting, MemberCapital, MemberEquityColumn, MembersEquityStatement, ProfitAndLoss,
ProfitAndLossStatement, StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of,
cash_flow, fixed_asset_register, 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 shares::{PartyMember, PartyShare, Share};
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
+49
View File
@@ -39,6 +39,24 @@ impl Currency {
Ok(Currency(upper))
}
/// The currency's minor units: how many decimal places its smallest
/// unit has (2 for USD's cents, 0 for JPY, 3 for KWD), or `None` for
/// codes ISO 4217 gives none (gold, SDRs). Unlisted codes are 2.
///
/// SOURCE: ISO 4217 list one, published 2026-09-17
/// (docs/spec/sources/iso-4217.md); generated from it, not typed in.
pub fn minor_units(&self) -> Option<u32> {
match self.code() {
"BIF" | "CLP" | "DJF" | "GNF" | "ISK" | "JPY" | "KMF" | "KRW" | "PYG" | "RWF"
| "UGX" | "UYI" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => Some(0),
"BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => Some(3),
"CLF" | "UYW" => Some(4),
"XAG" | "XAU" | "XBA" | "XBB" | "XBC" | "XBD" | "XDR" | "XPD" | "XPT" | "XSU"
| "XTS" | "XUA" | "XXX" => None,
_ => Some(2),
}
}
/// The three-letter code, e.g. `"USD"`.
pub fn code(&self) -> &str {
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
@@ -153,6 +171,37 @@ mod tests {
);
}
/// ISO 4217 list one (docs/spec/sources/iso-4217.md): one code from
/// each exponent class, and the codes with no minor unit.
#[test]
fn minor_units_follow_iso_4217() {
let units = |c: &str| Currency::new(c).unwrap().minor_units();
assert_eq!(units("USD"), Some(2));
assert_eq!(units("JPY"), Some(0));
assert_eq!(units("BHD"), Some(3));
assert_eq!(units("KWD"), Some(3));
assert_eq!(units("CLF"), Some(4));
assert_eq!(units("UYW"), Some(4));
assert_eq!(units("XAU"), None);
assert_eq!(units("XDR"), None);
}
#[test]
fn currency_and_money_print_their_code_and_amount() {
assert_eq!(Currency::new("usd").unwrap().to_string(), "USD");
assert_eq!(format!("{:?}", Currency::EUR), "Currency(EUR)");
assert_eq!(usd(d(1250)).to_string(), "12.50 USD");
assert_eq!(format!("{:?}", usd(d(-5))), "-0.05 USD");
}
#[test]
fn is_zero_means_exactly_zero() {
assert!(Money::zero(Currency::USD).is_zero());
assert!(usd(d(0)).is_zero());
assert!(!usd(d(1)).is_zero());
assert!(!usd(d(-1)).is_zero());
}
#[test]
fn checked_arithmetic_rejects_currency_mismatch() {
let eur = Money::new(d(100), Currency::EUR);
+750
View File
@@ -0,0 +1,750 @@
//! Parties: everyone the company deals with, in one directory.
//!
//! A party is a vendor, a contractor, a customer, an employee, a member, a
//! payout platform, a tax agency, a bank or card issuer, a landlord, or
//! several of these. Vendor and customer *records* (bills, invoices,
//! payments, aging) stay where they are and link to their party; a party
//! adds who they are (names, facts) and links entries to them.
//!
//! ```text
//! GOAL: answer "who is this, and what has the company done with them?"
//! for anyone the books touch, without a CRM's sprawl.
//! SOURCE: docs/parties-and-attachments.md; DECISION (Stephen,
//! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4.
//! INVARIANTS:
//! 1. No party field holds a full taxpayer identification number: any
//! run of nine digits (single spaces or hyphens between them allowed)
//! is refused. A W-9's TIN is kept as its last four digits only.
//! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need
//! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type,
//! last four, and vault record) are allowed for any role: members
//! need theirs for K-1s.
//! 2a. A party's TIN facts and W-9 facts never disagree: both on file
//! means the same last four (DECISION, Stephen, 2026-10-15).
//! 3. Names are unique, ignoring case and surrounding space, among parties
//! not archived.
//! 4. Every vendor and customer record links to exactly one party.
//! ```
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
/// Opaque party handle.
pub type PartyId = u64;
/// What a party is to the company.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
/// Sells to the company (has a vendor record: bills, payments).
Vendor,
/// A vendor paid for services who gets a 1099-NEC.
Contractor,
/// Buys from the company (has a customer record: invoices).
Customer,
/// Works for the company on payroll.
Employee,
/// A member (owner) of the company, as in wanderbooks.toml.
Member,
/// Pays the company out of sales (an app store, a payment processor).
PayoutPlatform,
/// Taxes the company (the IRS, a state).
TaxAgency,
/// Regulates or registers the company without taxing it (a secretary
/// of state, a division of corporations, a licensing board).
GovernmentAgency,
/// Holds the company's money or credit (a bank, a card issuer).
FinancialInstitution,
/// Rents property to the company (gets a 1099-MISC for rent).
Landlord,
}
impl Role {
/// Every role, in display order.
pub const ALL: [Role; 10] = [
Role::Vendor,
Role::Contractor,
Role::Customer,
Role::Employee,
Role::Member,
Role::PayoutPlatform,
Role::TaxAgency,
Role::GovernmentAgency,
Role::FinancialInstitution,
Role::Landlord,
];
/// Its wire name, e.g. `payout_platform`.
pub fn key(self) -> &'static str {
match self {
Role::Vendor => "vendor",
Role::Contractor => "contractor",
Role::Customer => "customer",
Role::Employee => "employee",
Role::Member => "member",
Role::PayoutPlatform => "payout_platform",
Role::TaxAgency => "tax_agency",
Role::GovernmentAgency => "government_agency",
Role::FinancialInstitution => "financial_institution",
Role::Landlord => "landlord",
}
}
/// The role named `key`.
pub fn parse(key: &str) -> Option<Role> {
Role::ALL.into_iter().find(|r| r.key() == key)
}
/// Whether this role files a 1099 (and so may carry W-9 facts).
pub fn gets_1099(self) -> bool {
matches!(self, Role::Contractor | Role::Landlord)
}
}
/// What a W-9 said, without the number itself.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct W9Facts {
/// When it was received.
pub received: NaiveDate,
/// Line 3 federal tax classification, e.g. "individual/sole proprietor".
pub entity_type: String,
/// Line 4 exempt payee code, if any.
#[serde(default)]
pub exempt_payee_code: Option<String>,
/// Line 4 FATCA exemption code, if any.
#[serde(default)]
pub fatca_code: Option<String>,
/// The TIN's last four digits (exactly four digits).
pub tin_last4: String,
/// Where the form itself is kept, e.g. "wanderbooks vault" or "CPA portal".
pub kept_at: String,
/// SHA-256 of the vault record holding the full TIN, when one exists:
/// proves the vault held it without the books holding it.
#[serde(default)]
pub vault_record: Option<String>,
}
/// A taxpayer identification number's type.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TinType {
/// Social Security number (an individual).
Ssn,
/// Employer identification number (a business).
Ein,
/// IRS individual taxpayer identification number.
Itin,
}
impl TinType {
/// Its wire name: ssn, ein, or itin.
pub fn key(self) -> &'static str {
match self {
TinType::Ssn => "ssn",
TinType::Ein => "ein",
TinType::Itin => "itin",
}
}
/// How a form names it: SSN, EIN, or ITIN.
pub fn label(self) -> &'static str {
match self {
TinType::Ssn => "SSN",
TinType::Ein => "EIN",
TinType::Itin => "ITIN",
}
}
}
/// What the books know of a party's taxpayer identification number,
/// without the number: its type, its last four digits, and the hash of
/// the TIN vault record that holds it in full. Any role may have one
/// (a member's SSN for their K-1, a contractor's from their W-9).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TinFacts {
/// SSN, EIN, or ITIN.
pub tin_type: TinType,
/// The last four digits (exactly four digits).
pub last4: String,
/// SHA-256 of the vault record holding the full TIN; `None` once the
/// record is purged (or when the vault never held it).
#[serde(default)]
pub vault_record: Option<String>,
/// When and why the vault's copy was purged, if it was.
#[serde(default)]
pub purged: Option<String>,
}
impl TinFacts {
/// Check the facts' form. Returns why not.
pub fn check(&self) -> Result<(), String> {
if self.last4.len() != 4 || !self.last4.chars().all(|c| c.is_ascii_digit()) {
return Err("a TIN's last four is exactly four digits".into());
}
if let Some(h) = &self.vault_record
&& (h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()))
{
return Err("vault_record is a SHA-256 in hex".into());
}
if let Some(t) = &self.purged {
check_text("purged", t)?;
}
Ok(())
}
}
/// What a W-4 said, without the SSN.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct W4Facts {
/// When it was received.
pub received: NaiveDate,
/// The work state, two letters.
pub state: String,
/// Where the form itself is kept.
pub kept_at: String,
}
/// One party.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Party {
/// Opaque handle.
pub id: PartyId,
/// Legal or trade name, as on documents.
pub name: String,
/// A shorter name to show, if any.
#[serde(default)]
pub display_name: Option<String>,
/// Roles set by hand (vendor and customer also follow their records).
pub roles: BTreeSet<Role>,
/// Website.
#[serde(default)]
pub website: Option<String>,
/// Business address.
#[serde(default)]
pub address: Option<String>,
/// Notes.
#[serde(default)]
pub notes: Option<String>,
/// No longer dealt with; history stays.
#[serde(default)]
pub archived: bool,
/// W-9 facts (contractor, landlord).
#[serde(default)]
pub w9: Option<W9Facts>,
/// W-4 facts (employee).
#[serde(default)]
pub w4: Option<W4Facts>,
/// Taxpayer-ID facts (any role): the TIN's type and last four, and
/// its vault record. The full number is only ever in the vault.
#[serde(default)]
pub tin: Option<TinFacts>,
/// Its vendor record, if it bills the company.
#[serde(default)]
pub vendor: Option<crate::VendorId>,
/// Its customer record, if the company invoices it.
#[serde(default)]
pub customer: Option<crate::CustomerId>,
}
impl Party {
/// The name to show: the display name, else the name.
pub fn label(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
/// Its roles: the ones set by hand, plus vendor and customer when it
/// has those records.
pub fn all_roles(&self) -> BTreeSet<Role> {
let mut r = self.roles.clone();
if self.vendor.is_some() {
r.insert(Role::Vendor);
}
if self.customer.is_some() {
r.insert(Role::Customer);
}
r
}
}
/// The key names are compared by: case and surrounding space ignored.
pub fn name_key(name: &str) -> String {
name.trim().to_lowercase()
}
/// The characters that may sit between a TIN's digits: space, hyphen,
/// dot, slash, underscore, and the Unicode dashes and no-break space
/// (`571-93-2846`, `571.93.2846`, `571/93/2846`, `571 - 93 - 2846`).
fn tin_separator(c: char) -> bool {
matches!(
c,
' ' | '-'
| '.'
| '/'
| '_'
| '\u{a0}'
| '\u{2010}'
| '\u{2011}'
| '\u{2012}'
| '\u{2013}'
| '\u{2014}'
| '\u{2212}'
)
}
/// The most separators that may sit between two of a TIN's digits
/// (`" - "` is three).
const MAX_TIN_GAP: usize = 3;
/// Each run in `chars` that holds nine digits or more, as (first, last)
/// char indices of its digits: digits with at most [`MAX_TIN_GAP`]
/// separators ([`tin_separator`]) between neighbors.
fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
let mut runs = Vec::new();
let mut i = 0;
while i < chars.len() {
if !chars[i].is_numeric() {
i += 1;
continue;
}
let (mut digits, mut last, mut j) = (1, i, i + 1);
while j < chars.len() {
if chars[j].is_numeric() {
digits += 1;
last = j;
j += 1;
continue;
}
let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count();
if (1..=MAX_TIN_GAP).contains(&gap)
&& chars.get(j + gap).is_some_and(|c| c.is_numeric())
{
j += gap;
} else {
break;
}
}
if digits >= 9 {
runs.push((i, last));
}
i = last + 1;
}
runs
}
/// Whether `text` holds a run of nine digits, with at most three
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's
/// written (`571-93-2846`, `571.93.2846`, `571 - 93 - 2846`). Any Unicode
/// numeral counts as a digit (full-width, Arabic-Indic, ...), so a number
/// can't slip through in another script.
pub fn has_full_tin(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
!full_tin_runs(&chars).is_empty()
}
/// `text` with every run [`has_full_tin`] finds masked: each of its
/// digits becomes `•`, separators kept. For refusals that quote what they
/// were given, so no error echoes a full TIN (docs/spec/rules.md B-26).
pub fn redact_full_tins(text: &str) -> std::borrow::Cow<'_, str> {
let mut chars: Vec<char> = text.chars().collect();
let runs = full_tin_runs(&chars);
if runs.is_empty() {
return std::borrow::Cow::Borrowed(text);
}
for (first, last) in runs {
for c in &mut chars[first..=last] {
if c.is_numeric() {
*c = '•';
}
}
}
std::borrow::Cow::Owned(chars.into_iter().collect())
}
/// Check one party text field: not a full TIN, at most 500 characters, no
/// control characters except newline (addresses and notes run several
/// lines). Returns why not.
pub fn check_text(field: &str, text: &str) -> Result<(), String> {
if has_full_tin(text) {
return Err(format!(
"{field} looks like it holds a full taxpayer identification number; \
the books keep only its last four digits (the full number belongs in the vault)"
));
}
if text.chars().count() > 500 {
return Err(format!("{field} is at most 500 characters"));
}
if text.chars().any(|c| c.is_control() && c != '\n') {
return Err(format!("{field} can't contain control characters"));
}
Ok(())
}
impl W9Facts {
/// Check the facts' form. Returns why not.
pub fn check(&self) -> Result<(), String> {
if self.tin_last4.len() != 4 || !self.tin_last4.chars().all(|c| c.is_ascii_digit()) {
return Err("tin_last4 is exactly four digits".into());
}
if self.entity_type.trim().is_empty() {
return Err("a W-9's entity type (line 3) is required".into());
}
if self.kept_at.trim().is_empty() {
return Err("say where the W-9 itself is kept".into());
}
for (f, t) in [
("entity_type", Some(self.entity_type.as_str())),
("exempt_payee_code", self.exempt_payee_code.as_deref()),
("fatca_code", self.fatca_code.as_deref()),
("kept_at", Some(self.kept_at.as_str())),
] {
if let Some(t) = t {
check_text(f, t)?;
}
}
if let Some(h) = &self.vault_record
&& (h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()))
{
return Err("vault_record is a SHA-256 in hex".into());
}
Ok(())
}
}
impl W4Facts {
/// Check the facts' form. Returns why not.
pub fn check(&self) -> Result<(), String> {
if self.state.len() != 2 || !self.state.chars().all(|c| c.is_ascii_uppercase()) {
return Err("state is two capital letters, e.g. UT".into());
}
if self.kept_at.trim().is_empty() {
return Err("say where the W-4 itself is kept".into());
}
check_text("kept_at", &self.kept_at)
}
}
/// One fact of a party to change ([`crate::Ledger::update_party`]).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "field", content = "value")]
pub enum PartyField {
/// The legal or trade name.
Name(String),
/// The display name (`None` clears it).
DisplayName(Option<String>),
/// The website.
Website(Option<String>),
/// The business address.
Address(Option<String>),
/// Notes.
Notes(Option<String>),
/// Archived (no longer dealt with) or not.
Archived(bool),
}
/// What the books did with one party over a span of dates
/// ([`activity`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Activity {
/// The party's entries, drafts included, by id.
pub entries: BTreeSet<crate::EntryId>,
/// Paid to it in the span (net of refunds and reversals of payments).
pub paid: rust_decimal::Decimal,
/// Received from it in the span (net of reversals of receipts).
pub received: rust_decimal::Decimal,
/// Posted entries in the span naming it among several parties whose
/// shares don't resolve against the entry and the members given
/// ([`crate::shares::resolve`]): counted in neither total.
pub unresolved: BTreeSet<crate::EntryId>,
}
/// The accounts [`entry_flow`] reads money from: the cash accounts (every
/// asset account but receivables when `cash` is empty), and the
/// liabilities other than payables and the payroll withholdings (cards,
/// what members paid for the company and are owed back, loans).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MoneyAccounts {
/// Cash: bank, savings, cash on hand.
pub cash: std::collections::HashSet<crate::AccountId>,
/// Liabilities that pay for the company or are paid down.
pub liabilities: std::collections::HashSet<crate::AccountId>,
}
/// The [`MoneyAccounts`] of `ledger`, with `cash` the books' cash accounts.
pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> MoneyAccounts {
use crate::AccountType;
let not_money: std::collections::HashSet<crate::AccountId> = crate::PAYROLL[2..]
.iter()
.chain([&crate::PAYABLES, &crate::RECEIVABLES])
.filter_map(|c| ledger.role_account(c))
.collect();
let mut out = MoneyAccounts::default();
for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) {
match a.account_type {
AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => {
out.cash.insert(a.id);
}
AccountType::Liability => {
out.liabilities.insert(a.id);
}
_ => {}
}
}
out
}
/// What one entry moved with its counterparty ([`entry_flow`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EntryFlow {
/// Paid to the counterparty (positive) or received from it (negative).
pub net: rust_decimal::Decimal,
/// What for, by account (debits − credits): positive is what the money
/// paid for, negative where money received came from. Sums to `net`.
pub what_for: Vec<(crate::AccountId, rust_decimal::Decimal)>,
}
/// The money one entry moved with its counterparty, and what for.
///
/// ```text
/// GOAL: "how much did we pay them" right however it was paid: from the
/// bank, on a card, by a member for the company, or by paying down
/// what the company owes them (a reimbursement, a card payment).
/// SOURCE: DECISION (Stephen, 2026-10-15), after a reimbursement to the
/// members netted to zero under the earlier rule (cash and
/// liabilities alike as money).
/// DEFINITIONS (over the entry's lines, in `money`):
/// cash_cr, cash_dr: credits and debits on cash accounts;
/// liab_cr, liab_dr: credits and debits on money liabilities;
/// down = min(liab_dr, cash_cr): cash paying down a liability (its
/// creditor is paid; the liability is what for);
/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the
/// lender pays the company);
/// left(a), for each money liability a: its debits less what of them
/// `down` takes, less its credits less what of them `up` takes (`down`
/// and `up` taken from its lines in line order), so one account's
/// debits and credits offset first;
/// across = min(Σ left(a) > 0, Σ −left(a) < 0): one liability paying
/// down another (a member paying the card from their own funds: Dr
/// 2050 / Cr 2100; a loan paying off a card): the debited liability's
/// creditor is paid, by the credited one's.
/// INVARIANTS:
/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up
/// + across.
/// 2. what_for: every line not on a money account (debits − credits),
/// plus `down` on the debited liabilities and −`up` on the credited
/// ones (in line order), plus `across` on the liabilities with
/// left(a) > 0 (in order of first appearance), zero sums left out,
/// accounts merged; its sum is net exactly, because the entry
/// balances.
/// SCENARIOS:
/// 1. Expense from the bank, on a card, or paid by a member for the
/// company (Dr expense / Cr 1000, 2050, or 2100): net is the amount.
/// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 /
/// Cr 1000): net is the amount, what for is the liability.
/// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received).
/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net. A charge
/// and a refund on the same card in one entry net on that card: no
/// card paid down.
/// 5. A transfer between the company's own cash accounts: zero.
/// 6. A member contributing property (Dr equipment / Cr capital): zero.
/// 7. A member paying the card bill from their own funds (Dr 2050 / Cr
/// 2100), or a loan paying it off (Dr 2050 / Cr loan): net is the
/// amount, what for is the card, as if paid from the bank.
/// ```
pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow {
use crate::Side;
use rust_decimal::Decimal;
let (mut cash_cr, mut cash_dr, mut liab_cr, mut liab_dr) =
(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
for l in entry.lines() {
let a = l.amount.amount;
match (
money.cash.contains(&l.account),
money.liabilities.contains(&l.account),
l.side,
) {
(true, _, Side::Credit) => cash_cr += a,
(true, _, Side::Debit) => cash_dr += a,
(false, true, Side::Credit) => liab_cr += a,
(false, true, Side::Debit) => liab_dr += a,
_ => {}
}
}
let down = liab_dr.min(cash_cr);
let up = liab_cr.min(cash_dr);
let mut by: std::collections::BTreeMap<crate::AccountId, Decimal> = Default::default();
let (mut down_left, mut up_left) = (down, up);
let mut order: Vec<crate::AccountId> = Vec::new();
// left(a) per money liability, in order of first appearance.
let mut left: Vec<(crate::AccountId, Decimal)> = Vec::new();
let add = |by: &mut std::collections::BTreeMap<crate::AccountId, Decimal>,
order: &mut Vec<crate::AccountId>,
account: crate::AccountId,
part: Decimal| {
if !part.is_zero() {
if !by.contains_key(&account) {
order.push(account);
}
*by.entry(account).or_default() += part;
}
};
for l in entry.lines() {
let a = l.amount.amount;
let part = if money.cash.contains(&l.account) {
Decimal::ZERO
} else if money.liabilities.contains(&l.account) {
let (part, rest) = match l.side {
Side::Debit => {
let take = a.min(down_left);
down_left -= take;
(take, a - take)
}
Side::Credit => {
let take = a.min(up_left);
up_left -= take;
(-take, -(a - take))
}
};
match left.iter_mut().find(|(x, _)| *x == l.account) {
Some((_, v)) => *v += rest,
None => left.push((l.account, rest)),
}
part
} else {
match l.side {
Side::Debit => a,
Side::Credit => -a,
}
};
add(&mut by, &mut order, l.account, part);
}
let owed_down: Decimal = left
.iter()
.map(|(_, v)| *v)
.filter(|v| *v > Decimal::ZERO)
.sum();
let owed_up: Decimal = -left
.iter()
.map(|(_, v)| *v)
.filter(|v| *v < Decimal::ZERO)
.sum::<Decimal>();
let across = owed_down.min(owed_up);
let mut across_left = across;
for (account, v) in left {
if v > Decimal::ZERO && !across_left.is_zero() {
let take = v.min(across_left);
across_left -= take;
add(&mut by, &mut order, account, take);
}
}
let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up + across;
EntryFlow {
net,
what_for: order
.into_iter()
.map(|a| (a, by[&a]))
.filter(|(_, v)| !v.is_zero())
.collect(),
}
}
/// One party's entries, and the money paid to and received from it in
/// `start..=end`, with no members ([`activity_with`] with none: an entry
/// naming several parties then needs explicit shares to count).
pub fn activity(
ledger: &crate::Ledger,
party: PartyId,
span: (NaiveDate, NaiveDate),
cash: &[crate::AccountId],
) -> Activity {
activity_with(ledger, party, span, cash, &[])
}
/// One party's entries, and its share of the money paid to and received
/// from the entries' parties in `start..=end`.
///
/// ```text
/// GOAL: one answer to "what did we pay them this year?" for the MCP
/// server and the viewer alike, right when an entry was with
/// several parties.
/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION
/// (Stephen, 2026-10-15).
/// INVARIANTS:
/// 1. entries: every entry Ledger::entry_parties names the party in
/// (hand tags, vendor tags, bills and their payments, invoices and
/// their payments, and reversals of any of these carry it).
/// 2. Each posted entry dated in the span whose entry_flow net is not
/// zero counts the party's resolved share of it
/// (crate::shares::resolve with `members`; the whole net for a sole
/// party): positive is a payment, negative a receipt. A reversal
/// counts on its root original's side (the first entry of
/// Ledger::reversal_chain), so a reversal subtracts and a reversal
/// of that reversal adds back, on the same side.
/// 3. An entry moving no money (net zero, like property contributed)
/// counts nothing. An entry whose shares don't resolve counts
/// nothing and is listed in `unresolved`.
/// 4. Over all the parties an entry names, the shares counted sum to its
/// net exactly (crate::shares INVARIANT 6).
/// 5. A card charge counts as paid (the company paid them, by card); a
/// bill doesn't until it is paid. Not the 1099 amount: that is
/// form1099::vendor_1099_report_with.
/// ```
pub fn activity_with(
ledger: &crate::Ledger,
party: PartyId,
(start, end): (NaiveDate, NaiveDate),
cash: &[crate::AccountId],
members: &[crate::PartyMember],
) -> Activity {
use rust_decimal::Decimal;
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
.filter(|&id| ledger.entry_names_party(id, party))
.collect();
let money = money_accounts(ledger, cash);
let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).net;
let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO);
let mut unresolved = BTreeSet::new();
for &id in &entries {
let Some(e) = ledger.get_entry(id) else {
continue;
};
if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end {
continue;
}
let net = net_out(e);
if net.is_zero() {
continue;
}
let share = match crate::shares::resolve(e, ledger.entry_parties(id), &money, members) {
Ok(parts) => parts
.into_iter()
.find(|(p, _)| *p == party)
.map(|(_, v)| v)
.unwrap_or_default(),
Err(_) => {
unresolved.insert(id);
continue;
}
};
let side = match e.reversal_of() {
Some(_) => ledger
.reversal_chain(id)
.first()
.and_then(|&root| ledger.get_entry(root))
.map_or(net, net_out),
None => net,
};
if side > Decimal::ZERO {
paid += share;
} else {
received -= share;
}
}
Activity {
entries,
paid,
received,
unresolved,
}
}
+2
View File
@@ -84,6 +84,8 @@ pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
/// revenue or liability accounts, e.g. deferred revenue or sales tax
/// collected; fees debit expense accounts), and the payout reconciles to
/// the cent.
/// A zero deposit (fees and adjustments ate the sales exactly) has no
/// deposit line: every planned line is positive (B-17, docs/spec/rules.md).
pub fn payout_plan(
payout: &Payout,
chart: &ChartOfAccounts,
+9 -1
View File
@@ -24,6 +24,10 @@ pub type PayrollRunId = u64;
pub struct PayrollLine {
/// Employee name, for the run record.
pub employee_name: String,
/// The employee's party, if named (new runs should; runs from before
/// the party directory carry the name only).
#[serde(default)]
pub employee: Option<crate::PartyId>,
/// Gross pay for the period.
pub gross_pay: Decimal,
/// Federal income tax withheld.
@@ -91,7 +95,10 @@ pub struct PayrollAccounts {
/// 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() {
// No wages at all is no payroll: refused before anything changes
// (found by the spec-blind tests, 2026-10-13: an all-zero run passed
// validation, then failed at post after creating accounts).
if lines.is_empty() || lines.iter().all(|l| l.gross_pay.is_zero()) {
return Err(LedgerError::EmptyPayroll);
}
for line in lines {
@@ -191,6 +198,7 @@ mod tests {
fn line(name: &str) -> PayrollLine {
PayrollLine {
employee_name: name.to_string(),
employee: None,
gross_pay: d(500_000), // 5000.00
federal_withholding: d(75_000), // 750.00
state_withholding: d(25_000), // 250.00
+63 -5
View File
@@ -9,7 +9,7 @@
//! 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
//! Once closed, [`Ledger::try_create_entry`](crate::Ledger::try_create_entry) and [`Ledger::post_entry`](crate::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).
@@ -50,8 +50,20 @@ pub enum CloseTo {
},
/// 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.
/// their allocated earnings and absorbs their distributions. Odd cents
/// go by largest remainder ([`allocate`]), cumulatively: a run of
/// member closes with the same members and shares gives each member,
/// in total, their share of the run's total to the cent, so rounding
/// never piles up close after close (rules.md B-13; since 2026-10-13).
Members(Vec<MemberClose>),
/// [`CloseTo::Members`] rounding each close on its own (largest
/// remainder), the rule for closes journaled on 2026-10-13 before the
/// cumulative one. Only replay of those journals produces it.
MembersPerClose(Vec<MemberClose>),
/// [`CloseTo::Members`] under the rounding closes used before
/// 2026-10-13 ([`allocate_last_absorbs`]). Only replay of those journals
/// produces it; new closes use `Members`.
MembersLastAbsorbs(Vec<MemberClose>),
}
/// One member in a partnership close.
@@ -66,11 +78,40 @@ pub struct MemberClose {
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.
/// Split `amount` by `shares` (which sum to 1) to the cent, by largest
/// remainder: every part is its exact share rounded toward zero to the
/// cent, and the leftover cents go one each to the largest remainders,
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
/// always sum to `amount` exactly, and no member is favored by position
/// (Stephen, 2026-10-13). A loss splits as the negation of the same gain.
pub fn allocate(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
) -> Vec<rust_decimal::Decimal> {
use rust_decimal::{Decimal, RoundingStrategy::ToZero};
let magnitude = amount.abs();
let exact: Vec<Decimal> = shares.iter().map(|s| magnitude * s).collect();
let mut parts: Vec<Decimal> = exact
.iter()
.map(|e| e.round_dp_with_strategy(2, ToZero))
.collect();
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(e, p)| e - p).collect();
crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2))
.expect("shares sum to 1, so the shortfall is under one cent per member");
if amount.is_sign_negative() {
for p in &mut parts {
*p = -*p;
}
}
parts
}
/// The rule closes used before 2026-10-13: each share rounded half away
/// from zero to the cent, the last absorbing the rounding. Kept only so
/// closes journaled under it replay exactly ([`CloseTo::MembersLastAbsorbs`]).
pub fn allocate_last_absorbs(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
) -> Vec<rust_decimal::Decimal> {
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
let mut parts: Vec<_> = shares
@@ -86,7 +127,7 @@ pub fn allocate(
pub const RETAINED_EARNINGS_CODE: &str = "3999";
/// A named accounting period: a month, quarter, or year.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Period {
/// Opaque handle.
pub id: PeriodId,
@@ -100,6 +141,23 @@ pub struct Period {
pub status: PeriodStatus,
/// The closing entry's id, once closed.
pub closing_entry: Option<u64>,
/// The order it closed in among all closes (1 for the first), once
/// closed.
#[serde(default)]
pub close_seq: Option<u64>,
/// What a cumulative members close allocated, once closed that way.
#[serde(default)]
pub member_close: Option<MemberCloseRecord>,
}
/// A cumulative members close ([`CloseTo::Members`]): who shared, and the
/// net it closed. The next close with the same members continues the run.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MemberCloseRecord {
/// (capital account, share), in the order given.
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
/// The net income this close allocated (negative for a loss).
pub net: rust_decimal::Decimal,
}
impl Period {
+83
View File
@@ -460,6 +460,89 @@ fn plug_last_sums_exactly() {
}
}
/// Most parts the apportionment harnesses explore (its ranking is
/// quadratic, so the bound is smaller than `N`).
const AP: usize = 3;
/// Inputs for `kernel::apportion`: rounded-down parts, remainders, a unit,
/// and a whole some shortfall above the parts' sum.
fn any_apportionment() -> (usize, [i64; AP], [i64; AP], i64, i64, i64) {
let n: usize = kani::any();
kani::assume(n <= AP);
let before: [i64; AP] = core::array::from_fn(|_| any_amount());
let rems: [i64; AP] = core::array::from_fn(|_| any_amount());
let unit: i64 = kani::any();
kani::assume(unit > 0 && unit <= 1000);
let shortfall: i64 = kani::any();
kani::assume(shortfall.unsigned_abs() <= 4000);
let mut whole = shortfall;
for &p in &before[..n] {
whole += p;
}
(n, before, rems, unit, shortfall, whole)
}
/// `kernel::apportion` succeeds exactly when the parts are short of the
/// whole by less than one unit per part; then the parts sum to the whole,
/// and each gains nothing or one unit, except that one part may also take
/// the sub-unit fraction.
#[kani::proof]
#[kani::unwind(5)]
fn apportion_is_exact() {
let (n, before, rems, unit, shortfall, whole) = any_apportionment();
let mut parts = before;
let ok = kernel::apportion(whole, &mut parts[..n], &rems[..n], unit);
let in_range = if n == 0 {
whole == 0
} else {
shortfall >= 0 && shortfall < n as i64 * unit
};
assert_eq!(ok.is_some(), in_range);
if ok.is_none() {
return;
}
let mut sum = 0i64;
let mut fraction_takers = 0;
for i in 0..n {
sum += parts[i];
let gain = parts[i] - before[i];
assert!(gain >= 0 && gain < 2 * unit);
if gain != 0 && gain != unit {
fraction_takers += 1;
}
}
assert_eq!(sum, whole);
assert!(fraction_takers <= 1);
kani::cover!(
n == AP && shortfall >= 2 * unit,
"three parts, two units short"
);
}
/// `kernel::apportion` never gives a unit to a part while a part with a
/// strictly larger remainder goes without (Stephen, 2026-10-13: no member
/// favored by position).
#[kani::proof]
#[kani::unwind(5)]
fn apportion_is_fair() {
let (n, before, rems, unit, _, whole) = any_apportionment();
let mut parts = before;
if kernel::apportion(whole, &mut parts[..n], &rems[..n], unit).is_none() {
return;
}
for i in 0..n {
for j in 0..n {
if rems[i] > rems[j] {
assert!(parts[i] - before[i] >= unit || parts[j] - before[j] < unit);
}
}
}
kani::cover!(
n == AP && rems[0] < rems[2],
"a later part with a larger remainder"
);
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------
+560 -60
View File
@@ -1,4 +1,4 @@
//! Reports: pure functions of a [`Ledger`](crate::Ledger).
//! Reports: pure functions of a [`Ledger`].
//!
//! Reports never mutate and never guess. They are what the semantic oracle
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
@@ -44,6 +44,14 @@ impl TrialBalance {
}
/// Build the trial balance from all posted entries.
///
/// ```text
/// GOAL: the trial balance over every posted entry (see trial_balance_as_of).
/// SOURCE: docs/spec/rules.md B-6; R-7.
/// INVARIANTS:
/// 1. MUST equal trial_balance_as_of on or after the last entry's date.
/// 2. MUST balance (total debits == total credits).
/// ```
pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
trial_balance_where(ledger, &|_| true)
}
@@ -51,6 +59,29 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
/// The trial balance as of a date: every posted entry dated on or before
/// it (closing entries included, so after a close it is the post-closing
/// trial balance).
///
/// ```text
/// GOAL: every account's balance, in a debit or a credit column, from the
/// posted entries dated on or before `as_of`.
/// SOURCE: docs/spec/rules.md B-6 (Pacioli ch. 36; IRS Pub. 583); R-7, R-9b.
/// INVARIANTS:
/// 1. MUST have total debits equal total credits, for any books built
/// through the Ledger commands.
/// 2. MUST list each non-placeholder account with a nonzero balance
/// exactly once, in code order; NEVER list a placeholder or a zero
/// balance.
/// 3. MUST put each balance in exactly one column, never negative.
/// 4. NEVER count a draft, or an entry dated after `as_of`; closing
/// entries dated on or before it count (after a close this is the
/// post-closing trial balance).
/// SCENARIOS:
/// 1. IF no entries are posted by `as_of`:
/// - SHOULD be empty with zero totals.
/// 2. IF an asset was debited 100 and credited 30:
/// - SHOULD show it with debit 70, credit 0.
/// BECAUSE the trial balance shows each account's net balance on the
/// side it falls.
/// ```
pub fn trial_balance_as_of(ledger: &Ledger, as_of: NaiveDate) -> TrialBalance {
trial_balance_where(ledger, &|e| e.date() <= as_of)
}
@@ -95,8 +126,9 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool)
}
}
/// Profit and loss: revenue and expenses for the life of the ledger. For a
/// date range, with a line per account, use [`profit_and_loss_for_period`].
/// Revenue and expense totals: from [`unclosed_earnings`], what the books
/// hold now (closed periods already moved to equity); for a date range,
/// with a line per account, use [`profit_and_loss_for_period`].
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfitAndLoss {
/// Total revenue (credit-normal balances, reported positive).
@@ -112,15 +144,19 @@ impl ProfitAndLoss {
}
}
/// Build the profit and loss statement from all posted entries.
pub fn profit_and_loss(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
/// Earnings not yet closed: revenue and expenses over every posted entry,
/// closing entries included, so a closed period's income (already moved
/// to equity) nets out. This is the balance sheet's current-earnings line,
/// not a profit and loss for any period (docs/testing.md #3; OpenStax,
/// Principles of Financial Accounting 5.1).
pub fn unclosed_earnings(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
profit_and_loss_filtered(ledger, None)
}
/// Build the profit and loss statement, optionally filtered to one class.
///
/// With `None` every line counts and the result is identical to
/// [`profit_and_loss`]. With `Some(class)` only lines tagged with that
/// [`unclosed_earnings`]. With `Some(class)` only lines tagged with that
/// class count. Unlike the account-at-a-time accumulation this works line
/// by line, because classes live on lines: each line contributes its signed
/// amount (positive on the account's normal side) to its account type's
@@ -150,7 +186,17 @@ pub struct ClassProfitAndLoss {
/// sorted by class name; the unclassified row comes last.
///
/// The rows partition the ledger: summing every row's revenue and expenses
/// reproduces [`profit_and_loss`].
/// reproduces [`unclosed_earnings`].
///
/// ```text
/// GOAL: a profit and loss per class (department, location, job), plus the
/// unclassified lines.
/// SOURCE: docs/spec/rules.md B-14; R-? Tracking classes.
/// INVARIANTS:
/// 1. The per-class P&Ls (with unclassified) MUST sum to the whole P&L,
/// account by account (conservation).
/// 2. NEVER count a line in two classes.
/// ```
pub fn profit_and_loss_by_class(ledger: &Ledger) -> Result<Vec<ClassProfitAndLoss>, LedgerError> {
// Which classes actually have P&L lines, and whether any P&L line is
// unclassified. Balance-sheet-only classes get no row.
@@ -272,7 +318,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
AccountType::Revenue | AccountType::Expense => {}
}
}
let current_earnings = profit_and_loss(ledger)?.net_income();
let current_earnings = unclosed_earnings(ledger)?.net_income();
equity += current_earnings;
Ok(BalanceSheet {
assets,
@@ -399,6 +445,26 @@ fn statement_lines(
/// counts every line). Closing entries are excluded: they move a period's
/// earnings into retained earnings, so counting them would report a closed
/// period as breaking even.
///
/// ```text
/// GOAL: revenue, expenses, and net income for the entries dated from
/// `start` through `end` (both inclusive), optionally one class's lines.
/// SOURCE: docs/spec/rules.md B-14, B-11; R-9.
/// INVARIANTS:
/// 1. MUST count only revenue and expense accounts, a line per account
/// with its normal-side amount, and net income = revenue - expenses.
/// 2. NEVER count a closing entry (a close moves earnings; it doesn't
/// earn or spend), a draft, or an entry outside the range.
/// 3. MUST partition: for start <= m < end, the period's net income equals
/// [start, m] plus [m+1, end].
/// 4. MUST equal the change in current earnings (balance sheet) across
/// the range when no close falls inside it.
/// 5. With a class: MUST count only lines tagged with that class.
/// SCENARIOS:
/// 1. IF a month was closed: SHOULD still report that month's earnings.
/// BECAUSE closing doesn't make a period break even (R-9).
/// 2. IF `start` is after `end`: SHOULD refuse. (DESIGN: no standard governs a reversed range; docs/testing.md #1)
/// ```
pub fn profit_and_loss_for_period(
ledger: &Ledger,
start: NaiveDate,
@@ -443,6 +509,23 @@ pub fn profit_and_loss_for_period(
/// Revenue and expense not yet closed to retained earnings appear as
/// current earnings inside equity, so the accounting equation holds on
/// every date, before and after closes.
///
/// ```text
/// GOAL: assets, liabilities, and equity as of a date, a line per account.
/// SOURCE: docs/spec/rules.md B-7 (CON 8 ¶E62), B-11; R-8.
/// INVARIANTS:
/// 1. MUST balance: assets = liabilities + equity + current earnings,
/// exactly.
/// 2. MUST count every posted entry dated on or before `as_of`, closing
/// entries included (after a close, earnings sit in retained earnings
/// or members' capital, and current earnings is zero for closed time).
/// 3. Current earnings MUST be revenue minus expenses not yet closed.
/// 4. NEVER list revenue or expense accounts as lines.
/// 5. Lines use each account's normal-side balance (a contra balance is
/// negative).
/// SCENARIOS:
/// 1. IF nothing is posted by `as_of`: SHOULD be empty and balanced.
/// ```
pub fn balance_sheet_as_of(
ledger: &Ledger,
as_of: NaiveDate,
@@ -538,6 +621,22 @@ pub struct GeneralLedger {
/// posting to its account, with the entry's other side as the split, and a
/// running balance from the account's balance before `start`. Closing
/// entries are postings like any other.
///
/// ```text
/// GOAL: every posting from `start` through `end`, by account in code
/// order, with running balances.
/// SOURCE: docs/spec/rules.md B-6; R-9a (the QuickBooks general ledger).
/// INVARIANTS:
/// 1. Each account MUST show its beginning balance (entries before
/// `start`), every posting in the range in date order, and an ending
/// balance equal to beginning plus the postings, on the account's
/// normal side.
/// 2. Each account's ending balance MUST equal its trial-balance balance
/// as of `end` (normal side).
/// 3. Each posting MUST name its split: the one account on the entry's
/// other side, or "-Split-" when the other side has several.
/// 4. NEVER include drafts.
/// ```
pub fn general_ledger(
ledger: &Ledger,
start: NaiveDate,
@@ -710,14 +809,78 @@ pub struct MembersEquityStatement {
pub total: MemberEquityColumn,
}
/// The posted entries in `[start, end]` that cancel: each reversal dated
/// in the span with the original it reverses, when that is dated in the
/// span too. Paired in journal order, an entry at most once, so in a chain
/// (an entry, its reversal, the reversal's reversal) the first two cancel
/// and the third, which reinstates the original, counts.
pub fn cancelled_in_span(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
) -> BTreeSet<crate::EntryId> {
let in_span = |d: NaiveDate| d >= start && d <= end;
let mut out = BTreeSet::new();
for e in ledger.posted_entries().filter(|e| in_span(e.date())) {
if let Some(o) = e.reversal_of()
&& ledger
.get_entry(o)
.is_some_and(|x| x.status() == crate::EntryStatus::Posted && in_span(x.date()))
&& !out.contains(&o)
&& !out.contains(&e.id())
{
out.insert(o);
out.insert(e.id());
}
}
out
}
/// Build the statement of changes in members' equity for `[start, end]`.
///
/// Each member's capital account supplies contributions (credits) and
/// distributions (debits). Earnings are partnership earnings: net income
/// for the period, and earnings from before it not yet distributed (retained
/// earnings plus unclosed income), are allocated by share. Allocation is to
/// the cent with the last member absorbing rounding, so every row's total
/// is exact and the ending total equals balance-sheet equity.
/// the cent, odd cents by largest remainder, so every row's total is exact
/// and the ending total equals balance-sheet equity.
///
/// ```text
/// GOAL: each member's capital roll-forward for a partnership over a
/// period: beginning, contributions, allocated net income, distributions,
/// ending; with a total.
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule
/// M-2), B-11; R-9c.
/// INVARIANTS:
/// 1. Each row MUST roll forward exactly: beginning + contributions +
/// income - distributions + other = ending.
/// 2. The total ending MUST equal balance-sheet equity at `end`.
/// 3. Undistributed earnings MUST be split by share, to the cent, by
/// largest remainder (DECISION, Stephen 2026-10-13), cumulatively as
/// of each date: a member's beginning is their capital and share as
/// of the day before `start`, their ending as of `end`.
/// 3a. A member's ending on one statement MUST equal their beginning on
/// the next, exactly (a capital account is one running balance: Form
/// 1065 Schedule K-1 instructions, Item L, "beginning capital account
/// and ending capital account ... based on the partnership's books").
/// 3b. A member's income is the roll-forward's remainder: their share of
/// the period's income, off by less than two cents (under one cent of
/// rounding at each end of the cumulative split); the members'
/// incomes MUST sum to the period's net income. (Earnings a close has
/// already moved into capital were rounded by that close, B-13.)
/// 4. NEVER hide an unexplained change: it goes in "other".
/// 5. An entry dated `start` is period activity, not beginning capital.
/// 6. An entry and its reversal both dated in the span cancel
/// ([`cancelled_in_span`]): neither is a contribution or a
/// distribution, since the correction undoes the original (B-9: "it
/// is just as if you had written nothing"). A reversal whose original
/// is outside the span counts by side, like any entry.
/// SCENARIOS:
/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse.
/// 2. A joint 1,000.00 contribution (500.00 each) reversed the next day:
/// contributions 0.00 and distributions 0.00 for each member, not
/// 500.00 of each.
/// ```
pub fn members_equity(
ledger: &Ledger,
start: NaiveDate,
@@ -773,12 +936,15 @@ pub fn members_equity(
// Contributions and distributions move a capital account; a closing
// entry allocating earnings into it is income, already counted below.
let closings = ledger.closing_entry_ids();
let cancelled = cancelled_in_span(ledger, start, end);
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
for e in ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
{
for e in ledger.posted_entries().filter(|e| {
e.date() >= start
&& e.date() <= end
&& !closings.contains(&e.id())
&& !cancelled.contains(&e.id())
}) {
for l in e.lines().iter().filter(|l| l.account == id) {
match l.side {
crate::Side::Credit => credits += l.amount.amount,
@@ -812,10 +978,39 @@ pub fn members_equity(
.collect();
// Everything in equity that isn't a capital account is partnership
// earnings not yet distributed: allocate it by share.
let undistributed = begin_bs.total_equity - capital.iter().sum::<Decimal>();
let begin_alloc = allocate(undistributed, &shares);
let income_alloc = allocate(pnl.net_income(), &shares);
// earnings not yet distributed. Each member's share of it is allocated
// cumulatively, as of a date, so a member's capital on a date never
// depends on which statement shows it: one statement's ending is the
// next one's beginning, to the cent (a capital account is one running
// balance: Form 1065 Schedule K-1 instructions, Item L).
let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal {
let mut m = Decimal::ZERO;
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
for l in e.lines().iter().filter(|l| l.account == id) {
match l.side {
crate::Side::Credit => m += l.amount.amount,
crate::Side::Debit => m -= l.amount.amount,
}
}
}
m
};
let capital_end: Vec<Decimal> = members
.iter()
.map(|m| {
capital_through(m.capital_account, end)
+ m.distribution_account
.map_or(Decimal::ZERO, |d| capital_through(d, end))
})
.collect();
let begin_alloc = allocate(
begin_bs.total_equity - capital.iter().sum::<Decimal>(),
&shares,
);
let end_alloc = allocate(
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
&shares,
);
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
let other_alloc = allocate(
end_bs.total_equity - begin_bs.total_equity - explained,
@@ -825,13 +1020,17 @@ pub fn members_equity(
let mut cols = Vec::with_capacity(members.len());
for (i, m) in members.iter().enumerate() {
let beginning = capital[i] + begin_alloc[i];
let ending = capital_end[i] + end_alloc[i];
let (contributions, distributions) = flow[i];
let ending = beginning + contributions - distributions + income_alloc[i] + other_alloc[i];
// The member's income is what the roll-forward leaves: their share
// of the period's income, give or take the rounding cent that keeps
// the running balance exact. The members' incomes sum to net income.
let income = ending - beginning - contributions + distributions - other_alloc[i];
cols.push(MemberEquityColumn {
name: m.name.clone(),
beginning: zero_clean(beginning),
contributions,
net_income: zero_clean(income_alloc[i]),
net_income: zero_clean(income),
distributions,
other: zero_clean(other_alloc[i]),
ending: zero_clean(ending),
@@ -862,31 +1061,47 @@ pub fn members_equity(
/// are sorted by account code, and object keys are fixed. Two engines fed the
/// same fixture books must produce byte-identical output — that is the whole
/// point (see `docs/oracle.md`).
///
/// ```text
/// GOAL: the trial balance, P&L, and balance sheet as deterministic JSON,
/// the interchange format for cross-checking against other
/// implementations.
/// SOURCE: R-10; docs/oracle.md.
/// INVARIANTS:
/// 1. MUST be identical for the same books, whatever order accounts were
/// added in (decimal strings, sorted lines, fixed keys).
/// 2. NEVER contain floats.
/// ```
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
let tb = trial_balance(ledger);
let pl = profit_and_loss(ledger)?;
let pl = unclosed_earnings(ledger)?;
let bs = balance_sheet(ledger)?;
// By value, not by written scale: a sum taken in hash-map order can
// write the same zero as "0" or "0.00", and verify compared the
// strings, so two equal ledgers could differ at random (found
// 2026-10-15 by a flaky store test).
let v = |d: Decimal| d.normalize().to_string();
Ok(serde_json::json!({
"trial_balance": {
"lines": tb.lines.iter().map(|l| serde_json::json!({
"code": l.code,
"name": l.name,
"debit": l.debit.to_string(),
"credit": l.credit.to_string(),
"debit": v(l.debit),
"credit": v(l.credit),
})).collect::<Vec<_>>(),
"total_debits": tb.total_debits.to_string(),
"total_credits": tb.total_credits.to_string(),
"total_debits": v(tb.total_debits),
"total_credits": v(tb.total_credits),
},
"profit_and_loss": {
"revenue": pl.revenue.to_string(),
"expenses": pl.expenses.to_string(),
"net_income": pl.net_income().to_string(),
"revenue": v(pl.revenue),
"expenses": v(pl.expenses),
"net_income": v(pl.net_income()),
},
"balance_sheet": {
"assets": bs.assets.to_string(),
"liabilities": bs.liabilities.to_string(),
"equity": bs.equity.to_string(),
"current_earnings": bs.current_earnings.to_string(),
"assets": v(bs.assets),
"liabilities": v(bs.liabilities),
"equity": v(bs.equity),
"current_earnings": v(bs.current_earnings),
"balanced": bs.balanced(),
},
}))
@@ -918,9 +1133,16 @@ pub struct CashFlowParams {
pub end: chrono::NaiveDate,
/// Accounts treated as cash. Their net change is the reconciling total.
pub cash_accounts: Vec<crate::AccountId>,
/// Liability accounts treated as financing (debt). Default empty: all
/// liabilities are operating working capital, all equity is financing.
/// Liability accounts treated as financing (debt, including what the
/// owners advanced: ASC 230-10-45-14(b) "other short- or long-term
/// borrowing"). Default empty: all liabilities are operating working
/// capital. All equity is financing.
pub financing_accounts: Vec<crate::AccountId>,
/// Asset accounts treated as investing: loans the entity makes and
/// investments it buys (ASC 230-10-20; 230-10-45-13). Registered fixed
/// assets are investing without being listed. Default empty: other
/// assets are operating working capital.
pub investing_accounts: Vec<crate::AccountId>,
}
/// Cash flow statement, indirect method.
@@ -969,8 +1191,43 @@ pub struct CashFlowStatement {
///
/// Pure function of the ledger: net income plus non-cash depreciation plus
/// working-capital changes gives operating cash flow; fixed-asset purchases
/// and disposal proceeds give investing; equity and designated debt give
/// financing. The three sections sum to the net change in `cash_accounts`.
/// and disposal proceeds, and the accounts named as investing, give
/// investing; equity and the debt named as financing give financing. The
/// three sections sum to the net change in `cash_accounts`.
///
/// ```text
/// GOAL: the statement of cash flows by the indirect method: net income
/// adjusted to operating cash, plus investing and financing, for a
/// period.
/// SOURCE: docs/spec/rules.md B-14 (ASC 230-10-45-28 indirect method;
/// 230-10-45-16 operating inflows; 230-10-45-7/8 gross over net;
/// 230-10-45-13/14/15 investing and financing flows; 230-10-20
/// investing activities).
/// INVARIANTS:
/// 1. Operating + investing + financing MUST equal the net change in the
/// cash accounts over the period, exactly.
/// 2. Operating MUST start from the period's net income (closing entries
/// excluded) and adjust for changes in non-cash working-capital
/// accounts.
/// 3. Investing MUST hold fixed-asset purchases and disposal proceeds
/// (a disposal's gain or loss moves out of operating into investing)
/// and the changes in the accounts named as investing.
/// 4. Financing MUST hold changes in equity (other than earnings) and in
/// the liabilities named as financing.
/// 4a. Investing and financing MUST be gross (ASC 230-10-45-7: gross
/// receipts and payments are "more relevant" than net): per account,
/// cash in and cash out are separate lines (contributions apart from
/// distributions, borrowing apart from repayment), netted only within
/// one entry.
/// 5. NEVER count closing entries.
/// 6. The period is `[start, end]`, inclusive: an entry dated `start` is
/// period activity, not part of the beginning cash balance.
/// 7. Every line's amount is its cash effect: positive is cash in. Its
/// label names the direction ("Increase in" / "Decrease in" for
/// working capital; "Proceeds from" / "Repayment of" for financing
/// debt; "Owner contributions" / "Owner distributions" for equity;
/// "Payments for" / "Collections on" for investing accounts).
/// ```
pub fn cash_flow(
ledger: &Ledger,
params: &CashFlowParams,
@@ -1000,6 +1257,8 @@ pub fn cash_flow(
let cash_set: HashSet<crate::AccountId> = params.cash_accounts.iter().copied().collect();
let financing_set: HashSet<crate::AccountId> =
params.financing_accounts.iter().copied().collect();
let investing_set: HashSet<crate::AccountId> =
params.investing_accounts.iter().copied().collect();
// Movement helpers: debits minus credits over a date predicate. Closing
// entries are skipped: a close moves earnings into retained earnings,
@@ -1081,6 +1340,12 @@ pub fn cash_flow(
if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) {
continue; // cash reconciles; fixed assets are investing (gross, below)
}
if account.account_type == AccountType::Equity
|| financing_set.contains(&id)
|| investing_set.contains(&id)
{
continue; // financing and investing roles are gross, below
}
let begin = normal(id, begin_movement(id));
let end = normal(id, period_movement(id) + begin_movement(id));
let delta = end - begin;
@@ -1100,14 +1365,6 @@ pub fn cash_flow(
};
(format!("{dir} {}", display(id)), cash_effect)
}
AccountType::Liability if financing_set.contains(&id) => {
let dir = if delta > Decimal::ZERO {
"Proceeds from"
} else {
"Repayment of"
};
(format!("{dir} {}", display(id)), cash_effect)
}
AccountType::Liability => {
let dir = if delta > Decimal::ZERO {
"Increase in"
@@ -1116,28 +1373,88 @@ pub fn cash_flow(
};
(format!("{dir} {}", display(id)), cash_effect)
}
AccountType::Equity => {
let dir = if delta > Decimal::ZERO {
"Owner contributions"
} else {
"Owner distributions"
};
(format!("{dir} — {}", display(id)), cash_effect)
}
_ => continue, // revenue/expense handled via net income
};
let target = match account.account_type {
AccountType::Equity => &mut financing,
AccountType::Liability if financing_set.contains(&id) => &mut financing,
_ => &mut working_capital,
};
target.push(CashFlowLine {
working_capital.push(CashFlowLine {
label,
amount: line,
account: Some(account.code.clone()),
});
}
// --- Financing and investing accounts, gross (ASC 230-10-45-7) ---
// Per account, cash in and cash out as separate lines; an entry's own
// debits and credits to the account net first (one transaction, one
// flow).
let mut roles: Vec<&crate::Account> = ledger
.chart()
.all()
.filter(|a| {
!a.placeholder
&& !cash_set.contains(&a.id)
&& !cost_accounts.contains(&a.id)
&& !accum_accounts.contains(&a.id)
&& (a.account_type == AccountType::Equity
|| financing_set.contains(&a.id)
|| investing_set.contains(&a.id))
})
.collect();
roles.sort_by(|a, b| a.code.cmp(&b.code));
for account in roles {
let (mut cash_in, mut cash_out) = (Decimal::ZERO, Decimal::ZERO);
for entry in ledger.posted_entries() {
let d = entry.date();
if d < params.start || d > params.end || closings.contains(&entry.id()) {
continue;
}
// Cash effect of this entry's change to the account: minus its
// movement (debits - credits), for assets and claims alike.
let effect: Decimal = entry
.lines()
.iter()
.filter(|l| l.account == account.id)
.map(|l| match l.side {
crate::Side::Debit => -l.amount.amount,
crate::Side::Credit => l.amount.amount,
})
.sum();
if effect > Decimal::ZERO {
cash_in += effect;
} else {
cash_out += effect;
}
}
let name = display(account.id);
let (in_label, out_label, target) = if investing_set.contains(&account.id) {
(
format!("Collections on {name}"),
format!("Payments for {name}"),
&mut investing,
)
} else if account.account_type == AccountType::Equity {
(
format!("Owner contributions — {name}"),
format!("Owner distributions — {name}"),
&mut financing,
)
} else {
(
format!("Proceeds from {name}"),
format!("Repayment of {name}"),
&mut financing,
)
};
for (label, amount) in [(in_label, cash_in), (out_label, cash_out)] {
if !amount.is_zero() {
target.push(CashFlowLine {
label,
amount,
account: Some(account.code.clone()),
});
}
}
}
// --- Investing, gross: purchases and disposal proceeds ---
// A disposal entry credits a cost account and debits its linked accum.
let mut disposal_gain_loss = Decimal::ZERO; // +gain / -loss, to remove from operating
@@ -1189,10 +1506,8 @@ pub fn cash_flow(
_ => {}
}
}
let book_value = cost_amount - accum_debit;
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
let _ = book_value;
disposal_gain_loss += gain - loss;
investing.push(CashFlowLine {
label: format!("Sale of fixed assets — {proceeds_label}"),
@@ -1252,3 +1567,188 @@ pub fn cash_flow(
cash_ending,
})
}
/// One asset on the fixed asset register.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetRegisterRow {
/// Asset id.
pub asset_id: crate::AssetId,
/// Its tag, if it has one.
pub tag: Option<String>,
/// What it is.
pub description: String,
/// The manufacturer's serial number, if recorded.
pub serial: Option<String>,
/// Placed in service (depreciation starts).
pub in_service: NaiveDate,
/// Capitalized cost.
pub cost: Decimal,
/// Salvage value.
pub salvage: Decimal,
/// Useful life in months.
pub useful_life_months: u32,
/// Accumulated depreciation by its schedule through the date (zero once
/// disposed: it left the books with the asset).
pub accumulated: Decimal,
/// Cost less accumulated (zero once disposed).
pub book_value: Decimal,
/// Disposal date and proceeds, if disposed by the date.
pub disposed: Option<(NaiveDate, Decimal)>,
/// The federal tax basis rule, if recorded.
pub tax_rule: Option<crate::TaxBasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_basis: Option<Decimal>,
/// The depreciable federal tax basis (`Asset::tax_basis`), if recorded.
pub tax_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, IRC §704(c)
/// (`Asset::built_in_gain`).
pub built_in_gain: Option<Decimal>,
}
/// The fixed asset register as of a date, reconciled to the general ledger.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetRegister {
/// The date.
pub as_of: NaiveDate,
/// Every asset registered by the date, in registration order; disposed
/// ones carry their disposal and no cost on the books.
pub rows: Vec<AssetRegisterRow>,
/// Total cost of the assets still held.
pub total_cost: Decimal,
/// Their accumulated depreciation by schedule.
pub total_accumulated: Decimal,
/// Their book value.
pub total_book_value: Decimal,
/// The total depreciable tax basis of the assets still held that have
/// one recorded.
pub total_tax_basis: Decimal,
/// How many assets still held have no tax basis recorded.
pub missing_tax_basis: usize,
/// The general ledger's balance (debit positive) of the assets' cost
/// accounts on the date.
pub ledger_cost: Decimal,
/// The general ledger's accumulated depreciation (credit positive) in
/// the assets' accumulated depreciation accounts on the date.
pub ledger_accumulated: Decimal,
}
impl AssetRegister {
/// Depreciation the schedules say is due by the date but the books
/// don't carry yet (run `run_depreciation`); negative if the books
/// carry more.
pub fn unposted_depreciation(&self) -> Decimal {
self.total_accumulated - self.ledger_accumulated
}
/// Cost in the ledger's cost accounts that no held asset accounts for
/// (equipment bought but not registered), or negative if registered
/// cost isn't in the books.
pub fn unregistered_cost(&self) -> Decimal {
self.ledger_cost - self.total_cost
}
}
/// The fixed asset register as of a date.
///
/// ```text
/// GOAL: one list of everything the company owns as a fixed asset: what,
/// which label, cost, depreciation, book value, and whether the
/// books agree.
/// SOURCE: docs/spec/rules.md B-15 (depreciation); DECISION (Stephen,
/// 2026-10-15): a fully featured asset register.
/// INVARIANTS:
/// 1. Rows: the assets registered on or before as_of, in registration
/// order; an asset disposed on or before as_of shows its disposal,
/// zero accumulated and book value, and counts in no total.
/// 2. accumulated is the asset's schedule through as_of
/// (assets::accumulated_depreciation); book_value = cost −
/// accumulated.
/// 3. Totals are the sums over the held assets, exactly; tax basis
/// totals over those with one recorded, and missing_tax_basis counts
/// the rest.
/// 5. Each row's tax fields are the asset's (Asset::tax_basis,
/// Asset::built_in_gain), never re-derived here.
/// 4. ledger_cost and ledger_accumulated are the general ledger's
/// balances on as_of of the distinct cost and accumulated
/// depreciation accounts of the registered assets, so
/// unposted_depreciation and unregistered_cost are zero exactly when
/// register and books agree.
/// ```
pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister {
use crate::AssetStatus;
let mut rows = Vec::new();
let (mut cost, mut accumulated) = (Decimal::ZERO, Decimal::ZERO);
let (mut tax_total, mut tax_missing) = (Decimal::ZERO, 0usize);
let mut cost_accounts = std::collections::BTreeSet::new();
let mut accum_accounts = std::collections::BTreeSet::new();
for a in ledger
.registered_assets()
.filter(|a| a.purchase_date <= as_of)
{
cost_accounts.insert(a.cost_account);
accum_accounts.insert(a.accum_depr_account);
let disposed = match &a.status {
AssetStatus::Disposed { date, proceeds } if *date <= as_of => {
Some((*date, proceeds.amount))
}
_ => None,
};
let acc = if disposed.is_some() {
Decimal::ZERO
} else {
crate::assets::accumulated_depreciation(a, as_of)
};
if disposed.is_none() {
cost += a.cost.amount;
accumulated += acc;
match a.tax_basis() {
Some(t) => tax_total += t,
None => tax_missing += 1,
}
}
rows.push(AssetRegisterRow {
asset_id: a.id,
tag: a.tag.clone(),
description: a.description.clone(),
serial: a.serial.clone(),
in_service: a.purchase_date,
cost: a.cost.amount,
salvage: a.salvage_value.amount,
useful_life_months: a.useful_life_months,
accumulated: acc,
book_value: if disposed.is_some() {
Decimal::ZERO
} else {
a.cost.amount - acc
},
disposed,
tax_rule: a.tax.map(|t| t.rule),
contributor_basis: a.tax.and_then(|t| t.contributor_basis),
tax_basis: a.tax_basis(),
built_in_gain: a.built_in_gain(),
});
}
let tb = trial_balance_as_of(ledger, as_of);
let net = |id: crate::AccountId| -> Decimal {
let code = match ledger.chart().get(id) {
Some(a) => &a.code,
None => return Decimal::ZERO,
};
tb.lines
.iter()
.find(|l| &l.code == code)
.map(|l| l.debit - l.credit)
.unwrap_or(Decimal::ZERO)
};
AssetRegister {
as_of,
rows,
total_cost: cost,
total_accumulated: accumulated,
total_book_value: cost - accumulated,
total_tax_basis: tax_total,
missing_tax_basis: tax_missing,
ledger_cost: cost_accounts.iter().map(|&id| net(id)).sum(),
ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Rules set by law that change over time, and how far wanderbooks knows
//! them.
//!
//! The engine has no clock, so it never decides on its own that a rule is
//! out of date. Instead each dated rule says which years it is known for
//! and when it is due for review; callers that know today's date (the MCP
//! server, the viewer) ask [`stale_rules`] and surface what is due.
//!
//! ```text
//! GOAL: never apply a legal amount for a year it isn't known for, and say
//! so before anyone needs it.
//! SOURCE: DECISION (Stephen, 2026-10-13): when what wanderbooks knows is
//! stale, research it or ask the owner or CPA, and update the rule
//! for everyone (a pull request, or at least an issue, on
//! github.com/swaits/wanderbooks).
//! INVARIANTS:
//! 1. NEVER returns a rule's value for a year past `known_through`.
//! 2. MUST report a rule as stale from its review date on.
//! ```
//!
//! To update a rule: vendor the new official text beside the old in its
//! `source` file (docs/spec/README.md, "Vendoring rules"), extend the
//! rule's table and `known_through`, move `review_after` to when the next
//! change is expected, and say so in the commit.
use chrono::NaiveDate;
/// A rule set by law whose value changes over time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DatedRule {
/// Plain name, e.g. "1099 reporting threshold".
pub name: &'static str,
/// Its entry in docs/spec/rules.md, e.g. "B-19".
pub catalog: &'static str,
/// The vendored official text it comes from.
pub source: &'static str,
/// The last year wanderbooks knows the rule for.
pub known_through: i32,
/// When to look again, as (year, month, day): the day the next value
/// is expected to be published or to take effect.
pub review: (i32, u32, u32),
/// Why that date, and where the next value will come from.
pub review_why: &'static str,
}
impl DatedRule {
/// The review date.
pub fn review_after(&self) -> NaiveDate {
let (y, m, d) = self.review;
NaiveDate::from_ymd_opt(y, m, d).expect("rule review dates are valid")
}
/// Whether the rule is due for review on `today`.
pub fn is_stale(&self, today: NaiveDate) -> bool {
today >= self.review_after()
}
}
/// The 1099-NEC / 1099-MISC reporting threshold (rules.md B-19): $600 for
/// payments through 2025, $2,000 in 2026, then indexed for inflation.
pub const THRESHOLD_1099: DatedRule = DatedRule {
name: "1099 reporting threshold",
catalog: "B-19",
source: "docs/spec/sources/irs-1099-thresholds.md",
known_through: 2026,
review: (2026, 11, 1),
review_why: "26 U.S.C. §6041(h) indexes the threshold for calendar years after 2026, \
rounded to the nearest $100; the IRS publishes each year's inflation \
adjustments late in the year before (CONVENTION: the usual timing, not a \
cited date), so look for the 2027 figure from November 2026.",
};
/// Every dated rule the engine applies.
pub const DATED_RULES: &[DatedRule] = &[THRESHOLD_1099];
/// The dated rules due for review on `today`.
pub fn stale_rules(today: NaiveDate) -> Vec<&'static DatedRule> {
DATED_RULES.iter().filter(|r| r.is_stale(today)).collect()
}
+16
View File
@@ -112,6 +112,22 @@ pub fn amortization_plan(
/// Validate a new schedule against the chart.
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
validate_schedule_shape(new, chart)?;
// Every month's posting derives from the total: one no line could
// carry, or finer than a cent, is refused here, not at a month end
// (found by the store's refusal property, 2026-10-13).
if new.total.amount > crate::kernel::MAX_LINE_AMOUNT {
return Err(LedgerError::AmountOutOfRange);
}
crate::ledger::check_minor_units(new.total)
}
/// [`validate_schedule`] without the total's range and minor-unit checks
/// added on 2026-10-13, for replaying journals written before them.
pub fn validate_schedule_shape(
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());
+314
View File
@@ -0,0 +1,314 @@
//! Party shares: an entry with more than one party, each with their part of
//! what it moved.
//!
//! An entry names its counterparties as a list of [`PartyShare`]s. Most
//! name one party, who had all of it. A joint transaction names several:
//! the two members of a partnership reimbursed together, a payment split
//! between two contractors, a contribution from both members' joint funds.
//! Each share is an exact amount, a percentage, or derived from the entry
//! itself ([`Share::Derived`]).
//!
//! ```text
//! GOAL: "what did we pay each of them?" right when one entry was with
//! several parties, with apportioning the human controls exactly.
//! SOURCE: DECISION (Stephen, 2026-10-15): no "joint" party; a joint
//! transaction is with both members, each with their share, and
//! shares can be apportioned explicitly ("do you allow for
//! controlled apportioning?" Yes). docs/parties-and-attachments.md
//! "Shares".
//! DEFINITIONS:
//! flow: crate::parties::entry_flow(entry, money).
//! target: flow.net when it is not zero, else the entry's total debits
//! (a member contributing property moves no money but is still all
//! theirs). magnitude = |target|; sign = the sign of target (positive
//! when net is zero).
//! A member: a PartyMember (party, income share, capital account,
//! distributions account), passed in by the caller from the company's
//! settings ([[company.members]]); core doesn't read the settings.
//! INVARIANTS (of an entry's shares, checked by check_shares, journaled):
//! 1. No party appears twice; every party is in the directory.
//! 2. All shares are one kind: all Amount, all Percent, or all Derived.
//! 3. An Amount is positive with at most two decimal places (cents).
//! 4. A Percent is positive, at most 100, and the percents sum to exactly
//! 100.
//! 5. At most one party has a vendor record: a 1099 counts a payment
//! toward a single vendor.
//! INVARIANTS (of resolve, against the entry's lines and the members):
//! 6. Resolved amounts sum to target exactly, and each has target's sign.
//! 7. Amount: each party gets sign × its amount; refused unless the
//! amounts sum to magnitude.
//! 8. Percent: each party gets magnitude × percent / 100 rounded toward
//! zero to the cent; the cents left over (fewer than one per party)
//! go to the first party in order. Then the sign.
//! 9. Derived, one party: it gets target.
//! 10. Derived, several parties: every one must be a member (else
//! refused: give amounts or percents). If the entry has a line on any
//! named member's capital or distributions account, each member gets
//! |credits − debits| on their own two accounts, and those must sum
//! to magnitude (else refused). Otherwise each gets their income share
//! of magnitude, the shares rescaled to the members named, rounded as
//! in 8 (remainder to the first).
//! SCENARIOS:
//! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named,
//! derived, 50/50: $50.00 each.
//! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100):
//! derived from the capital lines, Stephen −200.00, Katherine −100.00
//! (received).
//! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001
//! 500): net is zero; target is the total, 500.00 each.
//! 4. $100.00 split 1/3 each three ways by percent 33.33, 33.33, 33.34:
//! 33.33, 33.33, 33.34. By 33.333/33.333/33.334: 33.34, 33.33, 33.33
//! (the leftover cent to the first).
//! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused.
//! ```
use crate::{AccountId, JournalEntry, PartyId, Side};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// What part of an entry one party had.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Share {
/// Exactly this much of what the entry moved (a positive amount, in
/// cents; the sign follows the entry).
Amount(Decimal),
/// This percent of what the entry moved (0 < p ≤ 100).
Percent(Decimal),
/// From the entry itself: all of it for a sole party; for members,
/// their capital lines or their income share (see the module's
/// INVARIANT 10).
Derived,
}
/// One party named on an entry, with their share.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartyShare {
/// The party.
#[serde(rename = "party_id")]
pub party: PartyId,
/// Their share.
pub share: Share,
}
impl PartyShare {
/// The sole party, with all of it.
pub fn whole(party: PartyId) -> Self {
PartyShare {
party,
share: Share::Derived,
}
}
}
/// A member of the company as shares see them: their party, their share of
/// income (a fraction, e.g. 0.50), and their capital and distributions
/// accounts. Built by the caller from the settings' members.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PartyMember {
/// The member's party in the directory.
pub party: PartyId,
/// Their share of income, as a fraction (the members' sum to 1).
pub share: Decimal,
/// Their capital account.
pub capital: AccountId,
/// Their distributions account, if they have one.
pub distributions: Option<AccountId>,
}
/// The structural checks on an entry's shares (the module's INVARIANTS 1–4;
/// 5 needs the directory and is [`crate::Ledger::check_set_entry_parties`]'s).
/// `known` says whether a party is in the directory. Returns why not.
pub fn check_shares(shares: &[PartyShare], known: impl Fn(PartyId) -> bool) -> Result<(), String> {
let mut seen = std::collections::BTreeSet::new();
for s in shares {
if !known(s.party) {
return Err(format!("unknown party {}", s.party));
}
if !seen.insert(s.party) {
return Err(format!("party {} is named twice", s.party));
}
}
let kind = |s: &Share| match s {
Share::Amount(_) => 0,
Share::Percent(_) => 1,
Share::Derived => 2,
};
if let Some(first) = shares.first()
&& shares.iter().any(|s| kind(&s.share) != kind(&first.share))
{
return Err(
"give every party the same kind of share: all amounts, all percents, or none (derived)"
.into(),
);
}
let mut percent = Decimal::ZERO;
let mut any_percent = false;
for s in shares {
match s.share {
Share::Amount(a) => {
if a <= Decimal::ZERO {
return Err(format!("party {}'s amount must be positive", s.party));
}
if a.normalize().scale() > 2 {
return Err(format!(
"party {}'s amount {a} is finer than a cent",
s.party
));
}
}
Share::Percent(p) => {
if p <= Decimal::ZERO || p > Decimal::ONE_HUNDRED {
return Err(format!(
"party {}'s percent must be more than 0 and at most 100",
s.party
));
}
any_percent = true;
percent += p;
}
Share::Derived => {}
}
}
if any_percent && percent != Decimal::ONE_HUNDRED {
return Err(format!("the percents sum to {percent}, not 100"));
}
Ok(())
}
/// What the shares are split over: the entry's flow net, else its total
/// debits (the module's DEFINITIONS).
pub fn share_target(entry: &JournalEntry, money: &crate::parties::MoneyAccounts) -> Decimal {
let net = crate::parties::entry_flow(entry, money).net;
if net.is_zero() {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum()
} else {
net
}
}
/// Split `magnitude` (non-negative) by `weights` (positive, any sum): each
/// part rounded toward zero to the cent, the cents left over to the first.
fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Vec<Decimal> {
use rust_decimal::RoundingStrategy::ToZero;
let total: Decimal = weights.iter().copied().sum();
if weights.is_empty() || total.is_zero() {
return vec![Decimal::ZERO; weights.len()];
}
let mut parts: Vec<Decimal> = weights
.iter()
.map(|w| (magnitude * w / total).round_dp_with_strategy(2, ToZero))
.collect();
let left = magnitude - parts.iter().copied().sum::<Decimal>();
parts[0] += left;
parts
}
/// Each party's exact part of what `entry` moved (the module's INVARIANTS
/// 6–10), in the shares' order. Refused, with why, when the shares can't
/// resolve against these lines and members.
pub fn resolve(
entry: &JournalEntry,
shares: &[PartyShare],
money: &crate::parties::MoneyAccounts,
members: &[PartyMember],
) -> Result<Vec<(PartyId, Decimal)>, String> {
let target = share_target(entry, money);
let magnitude = target.abs();
let signed = |v: Decimal| if target.is_sign_negative() { -v } else { v };
let Some(first) = shares.first() else {
return Ok(Vec::new());
};
let parts: Vec<Decimal> = match first.share {
Share::Amount(_) => {
let amounts: Vec<Decimal> = shares
.iter()
.map(|s| match s.share {
Share::Amount(a) => a,
_ => Decimal::ZERO,
})
.collect();
let sum: Decimal = amounts.iter().copied().sum();
if sum != magnitude {
return Err(format!(
"the parties' amounts sum to {sum}, but entry {} moved {magnitude}",
entry.id()
));
}
amounts
}
Share::Percent(_) => {
let weights: Vec<Decimal> = shares
.iter()
.map(|s| match s.share {
Share::Percent(p) => p,
_ => Decimal::ZERO,
})
.collect();
split_first(magnitude, &weights)
}
Share::Derived if shares.len() == 1 => vec![magnitude],
Share::Derived => {
let named: Vec<&PartyMember> = shares
.iter()
.map(|s| {
members.iter().find(|m| m.party == s.party).ok_or_else(|| {
format!(
"party {} isn't a member, so its share can't be derived: give each party an amount or a percent",
s.party
)
})
})
.collect::<Result<_, _>>()?;
let on = |m: &PartyMember, a: AccountId| a == m.capital || Some(a) == m.distributions;
let touches = entry
.lines()
.iter()
.any(|l| named.iter().any(|m| on(m, l.account)));
if touches {
let parts: Vec<Decimal> = named
.iter()
.map(|m| {
entry
.lines()
.iter()
.filter(|l| on(m, l.account))
.map(|l| match l.side {
Side::Credit => l.amount.amount,
Side::Debit => -l.amount.amount,
})
.sum::<Decimal>()
.abs()
})
.collect();
let sum: Decimal = parts.iter().copied().sum();
if sum != magnitude {
return Err(format!(
"the members' capital lines on entry {} come to {sum}, but it moved {magnitude}: give each member an amount",
entry.id()
));
}
parts
} else {
let weights: Vec<Decimal> = named.iter().map(|m| m.share).collect();
if weights.iter().any(|w| *w <= Decimal::ZERO) {
return Err(
"a member named has no income share: give each party an amount or a percent"
.into(),
);
}
split_first(magnitude, &weights)
}
}
};
Ok(shares
.iter()
.zip(parts)
.map(|(s, v)| (s.party, signed(v)))
.collect())
}
+26 -3
View File
@@ -99,6 +99,8 @@ pub struct TaxCategory {
const ALL: &[TaxForm] = &TaxForm::ALL;
const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120];
/// Every return but Schedule C.
const ENTITIES: &[TaxForm] = &[TaxForm::Form1065, TaxForm::Form1120S, TaxForm::Form1120];
const fn cat(
key: &'static str,
@@ -144,7 +146,7 @@ pub const CATEGORIES: &[TaxCategory] = &[
Income,
ALL,
100,
"Pass-throughs report it separately to owners (portfolio income).",
"Portfolio interest: partnerships and S corps state it separately (Schedule K). Interest earned in the business itself (on customer accounts, say) is ordinary income: use other_income.",
),
cat("other_income", "Other income", Income, ALL, 100, ""),
cat(
@@ -176,9 +178,9 @@ pub const CATEGORIES: &[TaxCategory] = &[
"charitable_contributions",
"Charitable contributions",
Deduction,
ALL,
ENTITIES,
100,
"Separately stated to owners for partnerships and S corps; limited for C corps; not a Schedule C deduction.",
"Separately stated to owners for partnerships and S corps (Schedule K); limited for C corps. Not on Schedule C (\"Do not include charitable contributions\"): a sole proprietor's gift is personal, an owner draw.",
),
cat(
"commissions_fees",
@@ -419,6 +421,12 @@ pub struct TaxSummary {
pub cost_of_goods: Vec<TaxSummaryLine>,
/// Deductions.
pub deductions: Vec<TaxSummaryLine>,
/// Items a partnership or S corporation states separately to its owners
/// (Schedule K) instead of netting them in ordinary income: portfolio
/// interest and charitable contributions (docs/spec/sources/
/// irs-tax-lines.md). Not in [`TaxSummary::book_net`] or `tax_net`.
#[serde(default)]
pub separately_stated: Vec<TaxSummaryLine>,
/// P&L accounts with activity and no category.
pub uncategorized: Vec<Uncategorized>,
}
@@ -497,11 +505,16 @@ pub fn tax_summary(
income: vec![],
cost_of_goods: vec![],
deductions: vec![],
separately_stated: vec![],
uncategorized,
};
// Catalog order, so the summary reads like the return.
for c in CATEGORIES {
if let Some(line) = by_cat.remove(c.key) {
if separately_stated(c.key, form) {
out.separately_stated.push(line);
continue;
}
match c.kind {
TaxKind::Income => out.income.push(line),
TaxKind::CostOfGoods => out.cost_of_goods.push(line),
@@ -511,3 +524,13 @@ pub fn tax_summary(
}
Ok(out)
}
/// Whether a partnership or S corporation states this category separately
/// to its owners (Schedule K) rather than in ordinary income. Form 1065
/// and 1120-S instructions: Schedule K takes "only taxable portfolio
/// interest", and "charitable contributions" are among the "items ... must
/// state separately".
pub fn separately_stated(key: &str, form: Option<TaxForm>) -> bool {
matches!(form, Some(TaxForm::Form1065 | TaxForm::Form1120S))
&& matches!(key, "interest_income" | "charitable_contributions")
}
+35 -1
View File
@@ -6,7 +6,8 @@
use chrono::NaiveDate;
use ledger_core::analysis::{
Interval, balance_history, balance_series, flows_by_interval, pnl_by_interval,
Interval, accounts_of_type, balance_history, balance_series, flows_by_interval, interval_start,
pnl_by_interval,
};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
@@ -79,3 +80,36 @@ fn balances_pnl_and_flows_over_time() {
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
}
#[test]
fn intervals_start_on_calendar_boundaries() {
for (day, want) in [
(d(2, 14), d(1, 1)),
(d(5, 31), d(4, 1)),
(d(11, 30), d(10, 1)),
] {
assert_eq!(interval_start(day, Interval::Quarter), want, "{day}");
assert_eq!(interval_start(day, Interval::Year), d(1, 1), "{day}");
}
assert_eq!(interval_start(d(11, 30), Interval::Month), d(11, 1));
}
#[test]
fn accounts_of_type_picks_from_the_chart_in_order() {
let mut l = Ledger::new(Currency::USD);
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
let cash = a("1000", AccountType::Asset);
let card = a("2100", AccountType::Liability);
let rent = a("6600", AccountType::Expense);
let bank = a("1010", AccountType::Asset);
let assets = accounts_of_type(&l, &[AccountType::Asset]);
assert_eq!(assets.len(), 2);
assert!(assets.contains(&cash) && assets.contains(&bank));
let mut cash_like = accounts_of_type(&l, &[AccountType::Asset, AccountType::Liability]);
cash_like.sort();
let mut want = vec![cash, card, bank];
want.sort();
assert_eq!(cash_like, want);
assert!(!cash_like.contains(&rent));
assert!(accounts_of_type(&l, &[AccountType::Revenue]).is_empty());
}
+110 -8
View File
@@ -261,18 +261,39 @@ fn schedule_through_purchase_date_covers_one_day() {
}
#[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.
fn van_schedule_is_the_hand_computed_one() {
// Rank 1, computed by hand (rules.md B-15), replacing an unreviewed
// golden file (2026-10-13; every row of the old file was checked
// against this computation first, and matched).
// $60,000 over 60 months is $1,000 a month. Bought 2026-01-15: January
// holds 17 of 31 days = 548.39; February 2026 through December 2030 are
// 59 full months; life ends 2031-01-15, so January 2031 holds 14 of 31
// days = 451.61. 548.39 + 59,000 + 451.61 = 60,000.00 exactly.
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);
assert_eq!(sched.len(), 61);
let mut accumulated = Decimal::ZERO;
for (k, line) in sched.iter().enumerate() {
let (year, month) = (2026 + (k as i32) / 12, (k as u32) % 12 + 1);
let amount = match k {
0 => Decimal::new(54839, 2),
60 => Decimal::new(45161, 2),
_ => Decimal::new(100000, 2),
};
accumulated += amount;
assert_eq!(line.period, format!("{year}-{month:02}"));
assert_eq!(line.amount, amount, "{}", line.period);
assert_eq!(line.accumulated, accumulated, "{}", line.period);
assert_eq!(
line.book_value,
Decimal::from(60_000) - accumulated,
"{}",
line.period
);
}
assert_eq!(accumulated, Decimal::from(60_000));
}
#[test]
@@ -379,3 +400,84 @@ fn one_month_useful_life_is_valid() {
let total: Decimal = sched.iter().map(|l| l.amount).sum();
assert_eq!(total, Decimal::new(30000, 2));
}
#[test]
fn disposal_on_the_purchase_date_is_allowed() {
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();
// Held one day: 1,000/month × 1/31 = 32.26 of depreciation.
let d = disposal_amounts(&asset, date(2026, 1, 15), usd("60000")).unwrap();
assert_eq!(d.accumulated, Decimal::new(3226, 2));
assert_eq!(d.book_value, Decimal::new(5_996_774, 2));
assert_eq!(d.gain_loss, Decimal::new(3226, 2));
// The day before purchase is not a disposal.
assert!(matches!(
disposal_amounts(&asset, date(2026, 1, 14), usd("60000")),
Err(ledger_core::LedgerError::DisposalBeforePurchase(_))
));
}
/// An asset links to the posted entry that acquired it, if that entry
/// debits its cost account and isn't a reversal or reversed; a correction
/// (reverse, repost) links the replacement. Refusals change nothing.
#[test]
fn acquisition_links_only_to_a_live_entry_debiting_the_cost_account() {
use ledger_core::{LedgerError, Side};
let (mut ledger, cost, accum, expense) = test_ledger();
let capital = ledger
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let id = van_asset(&mut ledger, 1650, cost, accum, expense);
let post = |l: &mut Ledger, dr: u64, cr: u64| {
let e = l.create_entry(date(2026, 1, 15), "contribution");
l.add_line(e, dr, Side::Debit, usd("1650")).unwrap();
l.add_line(e, cr, Side::Credit, usd("1650")).unwrap();
l.post_entry(e).unwrap();
e
};
let original = post(&mut ledger, cost, capital);
let wrong = post(&mut ledger, expense, capital);
let draft = ledger.create_entry(date(2026, 1, 15), "draft");
let refused = |l: &mut Ledger, entry: u64| {
let before = l.clone();
let err = l.set_asset_acquisition(id, entry).unwrap_err();
assert_eq!(*l, before);
err
};
assert!(matches!(
refused(&mut ledger, wrong),
LedgerError::AcquisitionEntryMismatch { .. }
));
assert!(matches!(
refused(&mut ledger, draft),
LedgerError::AcquisitionEntryMismatch { .. }
));
assert!(matches!(
refused(&mut ledger, 999),
LedgerError::UnknownEntry(999)
));
assert!(matches!(
ledger.set_asset_acquisition(99, original),
Err(LedgerError::UnknownAsset(99))
));
ledger.set_asset_acquisition(id, original).unwrap();
assert_eq!(ledger.get_asset(id).unwrap().acquired_by, Some(original));
// Corrected: reversed, reposted. Neither the original nor its
// reversal can be the link; the replacement can.
let reversal = ledger
.reverse_entry(original, date(2026, 1, 16), "joint, not one member's")
.unwrap();
let replacement = post(&mut ledger, cost, capital);
assert!(matches!(
refused(&mut ledger, original),
LedgerError::AcquisitionEntryMismatch { .. }
));
assert!(matches!(
refused(&mut ledger, reversal),
LedgerError::AcquisitionEntryMismatch { .. }
));
ledger.set_asset_acquisition(id, replacement).unwrap();
assert_eq!(ledger.get_asset(id).unwrap().acquired_by, Some(replacement));
}
+516 -5
View File
@@ -86,7 +86,7 @@ fn books() -> Ledger {
#[test]
fn cash_paid_per_vendor_and_who_is_reportable() {
let l = books();
let r = vendor_1099_report(&l, 2026, None);
let r = vendor_1099_report(&l, 2026, None).unwrap();
assert_eq!(r.threshold, dec("2000"));
let rows: Vec<(&str, Decimal, bool)> = r
.vendors
@@ -110,14 +110,15 @@ fn cash_paid_per_vendor_and_who_is_reportable() {
#[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);
// SOURCE: rules.md B-19 (sources/irs-1099-thresholds.md).
assert_eq!(default_threshold(2025), Ok(dec("600")));
assert_eq!(default_threshold(2026), Ok(dec("2000")));
let y2025 = vendor_1099_report(&l, 2025, None).unwrap();
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")));
let r = vendor_1099_report(&l, 2026, Some(dec("1000"))).unwrap();
assert!(
r.vendors
.iter()
@@ -145,3 +146,513 @@ fn tags_need_real_entries_and_vendors() {
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
assert_eq!(Form1099::parse("K"), None);
}
/// Spec (Stephen, 2026-10-13): a rule set by law that wanderbooks doesn't
/// know for a year is never guessed. 26 U.S.C. §6041(h) indexes the $2,000
/// threshold for calendar years after 2026; until the indexed amount is in
/// the rule table, asking for it is an error that says what to do.
#[test]
fn an_unknown_year_threshold_is_never_guessed() {
let err = default_threshold(2027).unwrap_err();
let LedgerError::RuleNotKnown {
rule,
year,
known_through,
spec,
} = &err
else {
panic!("expected RuleNotKnown, got {err:?}");
};
assert_eq!(
(rule.as_str(), *year, *known_through),
("1099 reporting threshold", 2027, 2026)
);
assert!(spec.ends_with("irs-1099-thresholds.md"), "{spec}");
let why = err.to_string();
for needle in [
"2027",
"official source",
"CPA",
"github.com/swaits/wanderbooks",
] {
assert!(why.contains(needle), "missing {needle:?} in: {why}");
}
let l = books();
assert!(matches!(
vendor_1099_report(&l, 2027, None),
Err(LedgerError::RuleNotKnown { .. })
));
// An explicit threshold (from the IRS, or the owner or CPA) works.
let r = vendor_1099_report(&l, 2027, Some(dec("2100"))).unwrap();
assert_eq!(r.threshold, dec("2100"));
}
/// Spec: a dated rule is due for review from its review date, and stays
/// due; before it, nothing is stale. The engine has no clock: callers pass
/// today.
#[test]
fn dated_rules_go_stale_on_their_review_date() {
use ledger_core::rules::{THRESHOLD_1099, stale_rules};
assert!(stale_rules(d(2026, 10, 13)).is_empty());
let due = THRESHOLD_1099.review_after();
assert!(stale_rules(due.pred_opt().unwrap()).is_empty());
assert_eq!(stale_rules(due).len(), 1);
assert_eq!(
stale_rules(d(2027, 1, 1))[0].name,
"1099 reporting threshold"
);
assert_eq!(stale_rules(d(2030, 6, 1)).len(), 1);
}
#[test]
fn forms_have_their_irs_names() {
assert_eq!(Form1099::Nec.label(), "1099-NEC");
assert_eq!(Form1099::Misc.label(), "1099-MISC");
// A label parses back to its form.
for f in [Form1099::Nec, Form1099::Misc] {
assert_eq!(Form1099::parse(f.label()), Some(f));
}
}
/// vendor_1099_report_with SCENARIOS 1–2, worked by hand. Payment sources:
/// cash 1000 and the members' financing liability 2200. Kim (1099-MISC,
/// landlord) in 2026:
/// - 1,000 rent from the bank (Dr rent, Cr 1000): +1,000
/// - 2,400 prepaid rent from the bank (Dr 1300, Cr 1000): +2,400 (paid
/// when made; the prepaid asset is not a source, so it doesn't offset)
/// - 800 rent paid by a member (Dr rent, Cr 2200): +800
/// - 500 rent on the card (Dr rent, Cr 2100): 0 (1099-K)
/// - 300 refunded to the bank (Dr 1000, Cr rent): −300
///
/// Total 3,900. The plain report (every asset a source, no financing)
/// sees 1,000 + 0 + 0 + 0 − 300 = 700: the understatement this fixes.
#[test]
fn payments_come_from_cash_and_financing_only() {
use ledger_core::{PaymentAccounts, vendor_1099_report_with};
let mut l = Ledger::new(Currency::USD);
let acct = |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let bank = acct(&mut l, "1000", AccountType::Asset);
let prepaid = acct(&mut l, "1300", AccountType::Asset);
let card = acct(&mut l, "2100", AccountType::Liability);
let members = acct(&mut l, "2200", AccountType::Liability);
let rent = acct(&mut l, "6600", AccountType::Expense);
let kim = l.add_vendor("Kim").unwrap();
l.set_vendor_1099(kim, Some(Form1099::Misc), true).unwrap();
for (dr, cr, amt) in [
(rent, bank, "1000"),
(prepaid, bank, "2400"),
(rent, members, "800"),
(rent, card, "500"),
(bank, rent, "300"),
] {
let e = l.create_entry(d(2026, 5, 1), "rent");
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(kim)).unwrap();
}
let sources = PaymentAccounts {
cash: vec![bank],
financing: vec![members],
};
let r = vendor_1099_report_with(&l, 2026, None, &sources).unwrap();
assert_eq!(r.vendors[0].paid, dec("3900"));
assert!(r.vendors[0].reportable, "3,900 is over 2,000");
let plain = vendor_1099_report(&l, 2026, None).unwrap();
assert_eq!(plain.vendors[0].paid, dec("700"));
}
/// Books for the shared-payment tests: bank 1000, card 2050, contract
/// labor 6000; Dana (1099-NEC) and member Stephen as parties.
struct Shared {
l: Ledger,
bank: ledger_core::AccountId,
card: ledger_core::AccountId,
labor: ledger_core::AccountId,
dana: ledger_core::VendorId,
dana_p: ledger_core::PartyId,
stephen: ledger_core::PartyId,
}
fn shared_books() -> Shared {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let labor = l
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let dana = l.add_vendor("Dana").unwrap();
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
let dana_p = l.party_of_vendor(dana).unwrap();
let stephen = l
.add_party("Stephen", &[ledger_core::Role::Member])
.unwrap();
Shared {
l,
bank,
card,
labor,
dana,
dana_p,
stephen,
}
}
/// Post `lines` on `date` and name `shares` on it.
fn post_shared(
s: &mut Shared,
date: NaiveDate,
lines: &[(ledger_core::AccountId, Side, &str)],
shares: &[(ledger_core::PartyId, ledger_core::Share)],
) -> ledger_core::EntryId {
let e = s.l.create_entry(date, "shared payment");
for (a, side, amt) in lines {
s.l.add_line(e, *a, *side, Money::new(dec(amt), Currency::USD))
.unwrap();
}
s.l.post_entry(e).unwrap();
let shares: Vec<ledger_core::PartyShare> = shares
.iter()
.map(|&(party, share)| ledger_core::PartyShare { party, share })
.collect();
s.l.set_entry_parties(e, &shares).unwrap();
e
}
fn dana_paid(s: &Shared) -> (Decimal, Vec<ledger_core::EntryId>) {
let r = vendor_1099_report(&s.l, 2026, None).unwrap();
let line = r.vendors.iter().find(|v| v.vendor_id == s.dana).unwrap();
(line.paid, r.unresolved)
}
/// vendor_1099_report_with_members SCENARIO 3, by hand: $1,000.00 from
/// the bank (Dr 6000 / Cr 1000), Dana $600.00 and Stephen $400.00 by
/// amount. Payment = target = 1,000.00, so Dana's 1099 counts her share
/// unchanged: 600.00 (not the whole 1,000.00).
#[test]
fn a_shared_payment_counts_the_vendors_share() {
use ledger_core::Share::Amount;
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let e = post_shared(
&mut s,
d(2026, 3, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("400.00"))),
],
);
assert_eq!(
s.l.entry_vendor(e),
Some(s.dana),
"the vendor tag follows Dana"
);
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
// With sources and members named, the same.
let r = ledger_core::vendor_1099_report_with_members(
&s.l,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![bank],
financing: vec![],
},
&[],
)
.unwrap();
assert_eq!(r.vendors[0].paid, dec("600.00"));
assert!(!r.vendors[0].reportable, "600.00 is under 2,000");
}
/// SCENARIO 4, by hand: $100.01 from the bank split 50% / 50%. Each part
/// is 100.01 × 50 / 100 = 50.005, rounded toward zero to 50.00; the cent
/// left over goes to the party named first (shares INVARIANT 8). Dana
/// first: 50.01. A second payment with Dana named second: 50.00. Total
/// 100.01.
#[test]
fn a_percent_split_gives_the_leftover_cent_to_the_first() {
use ledger_core::Share::Percent;
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let lines = [
(labor, Side::Debit, "100.01"),
(bank, Side::Credit, "100.01"),
];
post_shared(
&mut s,
d(2026, 4, 1),
&lines,
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
);
assert_eq!(dana_paid(&s).0, dec("50.01"));
post_shared(
&mut s,
d(2026, 4, 2),
&lines,
&[(stephen, Percent(dec("50"))), (dana_p, Percent(dec("50")))],
);
assert_eq!(dana_paid(&s).0, dec("100.01"), "50.01 + 50.00");
}
/// SCENARIO 6, by hand: SCENARIO 3's payment reversed the same year. The
/// reversal carries the shares; its target is −1,000.00, Dana's amount
/// resolves to −600.00, and the payment is −1,000.00 = target, so she
/// nets 600.00 − 600.00 = 0.00 (still listed: she is marked for a 1099).
#[test]
fn a_reversed_shared_payment_nets_to_zero() {
use ledger_core::Share::Amount;
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let e = post_shared(
&mut s,
d(2026, 3, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("400.00"))),
],
);
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
assert_eq!(s.l.entry_parties(rev), s.l.entry_parties(e));
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
}
/// SCENARIO 5, by hand: $1,000.00 of contract labor, 600.00 from the
/// bank and 400.00 on the card, Dana 60% / Stephen 40%. Target (the flow
/// net, the card counted as money) 1,000.00, Dana's share 600.00, the
/// 1099 payment 600.00 (the card is not a payment source): 600.00 ×
/// 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the bank and
/// 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 =
/// 16.665, rounded half away from zero to 16.67. Total 376.67.
#[test]
fn a_shared_payment_partly_on_a_card_scales_to_the_payment() {
use ledger_core::Share::Percent;
let mut s = shared_books();
let (bank, card, labor, dana_p, stephen) = (s.bank, s.card, s.labor, s.dana_p, s.stephen);
post_shared(
&mut s,
d(2026, 5, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "600.00"),
(card, Side::Credit, "400.00"),
],
&[(dana_p, Percent(dec("60"))), (stephen, Percent(dec("40")))],
);
assert_eq!(dana_paid(&s).0, dec("360.00"));
post_shared(
&mut s,
d(2026, 5, 2),
&[
(labor, Side::Debit, "100.00"),
(bank, Side::Credit, "33.33"),
(card, Side::Credit, "66.67"),
],
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
);
assert_eq!(dana_paid(&s).0, dec("376.67"));
}
/// INVARIANT 4, by hand: amounts of 600.00 and 300.00 on a 1,000.00
/// payment don't resolve (shares SCENARIO 5), so the whole 1,000.00
/// counts toward Dana and the entry is listed. A single-party entry
/// (300.00, Dana alone) counts in full, as before shares: 1,300.00.
#[test]
fn unresolved_shares_count_in_full_and_are_listed() {
use ledger_core::Share::{Amount, Derived};
let mut s = shared_books();
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
let e = post_shared(
&mut s,
d(2026, 6, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("300.00"))),
],
);
post_shared(
&mut s,
d(2026, 6, 2),
&[
(labor, Side::Debit, "300.00"),
(bank, Side::Credit, "300.00"),
],
&[(dana_p, Derived)],
);
assert_eq!(dana_paid(&s), (dec("1300.00"), vec![e]));
}
/// The SCENARIO 3 payment: $1,000.00 from the bank, Dana 600.00 and
/// Stephen 400.00 by amount.
fn dana_and_stephen(s: &mut Shared) -> ledger_core::EntryId {
use ledger_core::Share::Amount;
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
post_shared(
s,
d(2026, 3, 1),
&[
(labor, Side::Debit, "1000.00"),
(bank, Side::Credit, "1000.00"),
],
&[
(dana_p, Amount(dec("600.00"))),
(stephen, Amount(dec("400.00"))),
],
)
}
/// set_entry_vendor SCENARIO 4, by hand: re-tagging Dana's vendor on the
/// shared payment keeps both shares, so her 1099 stays 600.00 (not the
/// whole 1,000.00) and Stephen keeps his part. Tagging a vendor whose
/// party isn't named is refused, changing nothing.
#[test]
fn tagging_the_vendor_keeps_a_shared_entrys_parties() {
let mut s = shared_books();
let e = dana_and_stephen(&mut s);
let before = s.l.entry_parties(e).to_vec();
s.l.set_entry_vendor(e, Some(s.dana)).unwrap();
assert_eq!(s.l.entry_parties(e), before.as_slice());
assert!(s.l.entry_names_party(e, s.stephen));
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
let other = s.l.add_vendor("Other Co").unwrap();
let err = s.l.set_entry_vendor(e, Some(other)).unwrap_err();
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
assert_eq!(s.l.entry_parties(e), before.as_slice());
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
// One party or none: the vendor's party becomes the entry's one party.
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
let lone = post_shared(
&mut s,
d(2026, 3, 5),
&[(labor, Side::Debit, "50.00"), (bank, Side::Credit, "50.00")],
&[(stephen, ledger_core::Share::Derived)],
);
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
assert_eq!(s.l.entry_party(lone), Some(s.dana_p));
}
/// set_entry_vendor SCENARIO 5: clearing the tag on the shared payment,
/// whose vendor's party (Dana) is named, is refused and changes nothing,
/// so the 1099 (600.00) and Dana's party activity never disagree.
#[test]
fn clearing_the_vendor_on_a_shared_entry_is_refused() {
let mut s = shared_books();
let e = dana_and_stephen(&mut s);
let err = s.l.set_entry_vendor(e, None).unwrap_err();
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
assert!(s.l.entry_names_party(e, s.dana_p));
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
// Dropping Dana from the parties clears the tag with her.
s.l.set_entry_parties(e, &[ledger_core::PartyShare::whole(s.stephen)])
.unwrap();
assert_eq!(s.l.entry_vendor(e), None);
}
/// set_entry_parties SCENARIO 3, by hand: the shared payment reversed,
/// then re-apportioned 700.00 / 300.00. The reversal is re-apportioned
/// too, so Dana nets 700.00 − 700.00 = 0.00 (not 700.00 − 600.00 =
/// 100.00). Re-apportioning the reversal itself does the same for the
/// original.
#[test]
fn re_apportioning_a_reversed_entry_re_apportions_its_reversal() {
use ledger_core::Share::Amount;
let mut s = shared_books();
let e = dana_and_stephen(&mut s);
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
assert_eq!(s.l.reversal_chain(e), vec![e, rev]);
assert_eq!(s.l.reversal_chain(rev), vec![e, rev]);
let shares = |a: &str, b: &str| {
vec![
ledger_core::PartyShare {
party: s.dana_p,
share: Amount(dec(a)),
},
ledger_core::PartyShare {
party: s.stephen,
share: Amount(dec(b)),
},
]
};
let new = shares("700.00", "300.00");
s.l.set_entry_parties(e, &new).unwrap();
assert_eq!(s.l.entry_parties(rev), new.as_slice());
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
let back = shares("550.00", "450.00");
s.l.set_entry_parties(rev, &back).unwrap();
assert_eq!(s.l.entry_parties(e), back.as_slice());
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
// Re-tagging the vendor on a reversed one-party entry tags both.
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
let lone = post_shared(
&mut s,
d(2026, 4, 1),
&[(labor, Side::Debit, "80.00"), (bank, Side::Credit, "80.00")],
&[(stephen, ledger_core::Share::Derived)],
);
let lone_rev = s.l.reverse_entry(lone, d(2026, 4, 2), "oops").unwrap();
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
assert_eq!(s.l.entry_vendor(lone_rev), Some(s.dana));
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
}
/// add_vendor INVARIANTS 2-3: a party named on a shared entry beside
/// Dana (who has a vendor record) can't gain a vendor record, which
/// would leave the entry naming two vendors (shares INVARIANT 5); a party
/// named with no vendor party gains the tag with its new record.
#[test]
fn a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry() {
use ledger_core::Share::Percent;
let mut s = shared_books();
let beta = s.l.add_party("Beta", &[]).unwrap();
let (bank, labor, dana_p) = (s.bank, s.labor, s.dana_p);
let e = post_shared(
&mut s,
d(2026, 7, 1),
&[
(labor, Side::Debit, "100.00"),
(bank, Side::Credit, "100.00"),
],
&[(dana_p, Percent(dec("60"))), (beta, Percent(dec("40")))],
);
let vendors_before = s.l.list_vendors().len();
let err = s.l.add_vendor("Beta").unwrap_err();
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
assert_eq!(s.l.list_vendors().len(), vendors_before, "nothing changed");
assert_eq!(s.l.get_party(beta).unwrap().vendor, None);
// Re-issuing the same shares is still accepted.
let same = s.l.entry_parties(e).to_vec();
s.l.set_entry_parties(e, &same).unwrap();
// Gamma, named only beside Stephen (a member, no vendor record).
let gamma = s.l.add_party("Gamma", &[]).unwrap();
let stephen = s.stephen;
let g = post_shared(
&mut s,
d(2026, 7, 2),
&[
(labor, Side::Debit, "100.00"),
(bank, Side::Credit, "100.00"),
],
&[(stephen, Percent(dec("50"))), (gamma, Percent(dec("50")))],
);
assert_eq!(s.l.entry_vendor(g), None);
let gv = s.l.add_vendor("Gamma").unwrap();
assert_eq!(s.l.get_party(gamma).unwrap().vendor, Some(gv));
assert_eq!(s.l.entry_vendor(g), Some(gv), "the tag follows the party");
}
@@ -1,368 +0,0 @@
[
{
"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"
}
]
+772 -9
View File
@@ -10,9 +10,10 @@
//! semantic oracle (see `docs/oracle.md`).
use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
use ledger_core::reports::{balance_sheet, snapshot, trial_balance, unclosed_earnings};
use ledger_core::{
AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side,
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
LedgerError, Money, Side,
};
use rust_decimal::Decimal;
@@ -209,17 +210,68 @@ fn out_of_range_amount_is_rejected() {
ledger.post_entry(id).unwrap_err(),
LedgerError::AmountOutOfRange
);
// Too precise.
// Too precise: refused as soon as it is added (finer than a cent)...
let id = ledger.create_entry(date(21), "Too precise");
let tiny = Money::new("0.00000000001".parse().unwrap(), Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, tiny).unwrap();
ledger.add_line(id, c.equity, Side::Credit, tiny).unwrap();
assert!(matches!(
ledger.add_line(id, c.cash, Side::Debit, tiny),
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
));
// ...and out of range even for books replayed from before that rule.
ledger
.replay_add_line(id, c.cash, Side::Debit, tiny)
.unwrap();
ledger
.replay_add_line(id, c.equity, Side::Credit, tiny)
.unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
ledger.replay_post_entry(id).unwrap_err(),
LedgerError::AmountOutOfRange
);
}
/// B-3 (DECISION, Stephen 2026-10-13: a ledger in one money holds no
/// fractions of its smallest unit). Regression: a payroll run posted
/// $65.27794 (found by the store's refusal property in CI, 2026-10-13).
#[test]
fn no_entry_posts_a_fraction_of_the_minor_unit() {
let c = fixture_company();
let mut ledger = c.ledger;
let usd = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
// Whole cents, with or without trailing zeros, are fine.
for ok in ["65.27", "65.270", "65"] {
let id = ledger.create_entry(date(21), "cents");
ledger.add_line(id, c.cash, Side::Debit, usd(ok)).unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(ok))
.unwrap();
ledger.post_entry(id).unwrap();
}
// A fraction of a cent is refused when added...
let id = ledger.create_entry(date(21), "sub-cent");
assert!(matches!(
ledger.add_line(id, c.cash, Side::Debit, usd("65.27794")),
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
));
// ...and when posted, whatever path put the line on the draft.
ledger
.replay_add_line(id, c.cash, Side::Debit, usd("65.275"))
.unwrap();
ledger
.replay_add_line(id, c.equity, Side::Credit, usd("65.275"))
.unwrap();
assert!(matches!(
ledger.post_entry(id),
Err(LedgerError::FinerThanMinorUnit { .. })
));
assert_eq!(
ledger.get_entry(id).unwrap().status(),
ledger_core::EntryStatus::Draft
);
// Books from before the rule still replay as booked.
ledger.replay_post_entry(id).unwrap();
}
#[test]
fn fx_overflow_is_an_error() {
let c = fixture_company();
@@ -242,12 +294,18 @@ fn fx_overflow_is_an_error() {
#[test]
fn unknown_account_is_rejected() {
// Refused when the line is added (since 2026-10-13); a line that got
// in anyway (replayed from an older journal) is still refused at post.
let c = fixture_company();
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();
assert_eq!(
ledger.add_line(id, 999_999, Side::Credit, usd(100)),
Err(LedgerError::UnknownAccount(999_999))
);
ledger
.add_line(id, 999_999, Side::Credit, usd(100))
.replay_add_line(id, 999_999, Side::Credit, usd(100))
.unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
@@ -263,7 +321,14 @@ fn placeholder_account_is_rejected() {
.add_account("100", "Assets header", AccountType::Asset, None, true)
.unwrap();
let id = ledger.create_entry(date(21), "Post to header");
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
assert_eq!(
ledger.add_line(id, header, Side::Debit, usd(100)),
Err(LedgerError::PlaceholderAccount("100".to_string()))
);
// Got in anyway (an older journal): still refused at post.
ledger
.replay_add_line(id, header, Side::Debit, usd(100))
.unwrap();
ledger
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
@@ -289,6 +354,39 @@ fn foreign_currency_line_is_rejected() {
);
}
/// R-1 / B-3: one currency throughout an entry. Even when every line
/// matches its own account's currency (a chart the ledger never builds
/// today, R-5), an entry mixing currencies is refused.
#[test]
fn an_entry_mixing_currencies_is_refused_even_on_matching_accounts() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "two moneys");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger
.add_line(id, c.revenue, Side::Credit, usd(100))
.unwrap();
let mut json = serde_json::to_value(ledger.get_entry(id).unwrap()).unwrap();
json["lines"][1]["amount"]["currency"] = serde_json::to_value(Currency::EUR).unwrap();
let mixed: JournalEntry = serde_json::from_value(json).unwrap();
let mut chart = ChartOfAccounts::default();
chart
.add(ledger.chart().get(c.cash).unwrap().clone())
.unwrap();
let mut revenue = ledger.chart().get(c.revenue).unwrap().clone();
revenue.currency = Currency::EUR;
chart.add(revenue).unwrap();
assert_eq!(
mixed.validate(&chart),
Err(LedgerError::CurrencyMismatch {
expected: "USD".to_string(),
found: "EUR".to_string(),
})
);
}
#[test]
fn duplicate_account_code_is_rejected() {
let c = fixture_company();
@@ -354,6 +452,88 @@ fn reversal_nets_to_zero_and_links_back() {
assert!(balance_sheet(&ledger).unwrap().balanced());
}
/// A posted mistake, for the reversal rules below.
fn posted_mistake(ledger: &mut Ledger, c: &Company) -> ledger_core::EntryId {
let id = ledger.create_entry(date(21), "Mistake");
ledger.add_line(id, c.rent, Side::Debit, usd(500)).unwrap();
ledger.add_line(id, c.cash, Side::Credit, usd(500)).unwrap();
ledger.post_entry(id).unwrap();
id
}
/// Spec (Stephen, 2026-10-13): an entry is reversed at most once. A second
/// reversal would undo the original twice.
#[test]
fn an_entry_is_reversed_at_most_once() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
let before = ledger.next_entry_id();
assert_eq!(
ledger.reverse_entry(id, date(23), "Again").unwrap_err(),
LedgerError::EntryAlreadyReversed(id, rev)
);
// Refused means untouched: no entry allocated, no draft left behind.
assert_eq!(ledger.next_entry_id(), before);
assert_eq!(ledger.account_movement(c.rent), Decimal::from(800));
}
/// Spec: reversing the reversal re-instates the original, and is allowed
/// (once, like any other entry).
#[test]
fn a_reversal_can_itself_be_reversed_once() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
let back = ledger.reverse_entry(rev, date(23), "Re-instate").unwrap();
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
assert_eq!(
ledger.reverse_entry(rev, date(24), "Again").unwrap_err(),
LedgerError::EntryAlreadyReversed(rev, back)
);
}
/// Spec (Stephen, 2026-10-13): a closing entry is never reversed. Undoing
/// it would leave the period marked closed with its earnings unswept.
#[test]
fn a_closing_entry_is_never_reversed() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
let closing = ledger.close_period(jan).unwrap().unwrap();
let feb = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
let before = ledger.next_entry_id();
assert_eq!(
ledger
.reverse_entry(closing, feb, "Undo close")
.unwrap_err(),
LedgerError::ClosingEntryIrreversible(closing)
);
assert_eq!(ledger.next_entry_id(), before);
}
/// Regression (max-testing pass, 2026-10-13): a reversal dated in a closed
/// period was refused only at post time, after allocating an id and
/// leaving a draft behind. Refused means untouched.
#[test]
fn a_reversal_into_a_closed_period_leaves_no_trace() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
// Nothing is open in January now except our posted mistake; close it.
ledger.close_period(jan).unwrap();
let before = ledger.next_entry_id();
assert!(matches!(
ledger.reverse_entry(id, date(25), "Into the closed month"),
Err(LedgerError::ClosedPeriod(..))
));
assert_eq!(ledger.next_entry_id(), before);
assert!(ledger.check_reversal(id, date(25)).is_err());
}
// ---------------------------------------------------------------------------
// Reports: the accounting laws hold.
// ---------------------------------------------------------------------------
@@ -385,7 +565,7 @@ fn accounting_equation_holds() {
#[test]
fn profit_and_loss_is_correct() {
let c = fixture_company();
let pl = profit_and_loss(&c.ledger).unwrap();
let pl = unclosed_earnings(&c.ledger).unwrap();
assert_eq!(pl.revenue, Decimal::from(4_000));
assert_eq!(pl.expenses, Decimal::from(800));
assert_eq!(pl.net_income(), Decimal::from(3_200));
@@ -544,6 +724,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
end: date(31),
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
}
}
@@ -692,3 +873,585 @@ fn cash_flow_with_registered_asset_and_disposal() {
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
}
// ---------------------------------------------------------------------------
// Control accounts (Stephen, 2026-10-13): the engine posts invoices, bills,
// payroll, and closes to accounts it finds by code. When an account with
// that code exists but is the wrong kind, it refuses, says why, and changes
// nothing, rather than posting to it or forcing the chart into its
// numbering. SOURCE: rules.md B-4 (the elements), DECISION.
// ---------------------------------------------------------------------------
fn books_with(code: &str, name: &str, t: AccountType, placeholder: bool) -> Ledger {
let mut l = Ledger::new(Currency::USD);
l.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
l.add_account(code, name, t, None, placeholder).unwrap();
l
}
fn doc(amount: u64) -> Vec<ledger_core::DocumentLine> {
vec![ledger_core::DocumentLine {
description: "work".into(),
amount: Decimal::from(amount),
}]
}
/// Refused means untouched: same chart, same ids, nothing posted.
fn assert_untouched(before: &Ledger, after: &Ledger) {
assert_eq!(after.chart().len(), before.chart().len());
assert_eq!(after.next_entry_id(), before.next_entry_id());
assert_eq!(after.next_invoice_id(), before.next_invoice_id());
assert_eq!(after.next_bill_id(), before.next_bill_id());
assert_eq!(
after.posted_entries().count(),
before.posted_entries().count()
);
}
#[test]
fn an_invoice_refuses_a_1200_that_is_not_receivables() {
let mut l = books_with("1200", "Customer Deposits", AccountType::Liability, false);
let c = l.add_customer("Acme").unwrap();
let before = l.clone();
let err = l
.create_invoice(c, date(5), date(30), doc(100))
.unwrap_err();
assert_eq!(
err,
LedgerError::ControlAccountMismatch {
code: "1200".into(),
name: "Customer Deposits".into(),
role: "accounts receivable".into(),
expected: AccountType::Asset,
found: AccountType::Liability,
placeholder: false,
}
);
// The reason names the account, what it is, and what was needed.
let why = err.to_string();
assert!(
why.contains("1200") && why.contains("Customer Deposits"),
"{why}"
);
assert!(why.contains("Liability") && why.contains("Asset"), "{why}");
assert_untouched(&before, &l);
}
#[test]
fn an_invoice_refuses_a_placeholder_1200() {
let mut l = books_with("1200", "Receivables", AccountType::Asset, true);
let c = l.add_customer("Acme").unwrap();
let before = l.clone();
assert!(matches!(
l.create_invoice(c, date(5), date(30), doc(100)),
Err(LedgerError::ControlAccountMismatch {
placeholder: true,
..
})
));
assert_untouched(&before, &l);
}
#[test]
fn an_invoice_creates_nothing_when_its_second_account_is_wrong() {
// 1200 is missing (would be created) but 4000 is the wrong type: the
// refusal must come before 1200 is added.
let mut l = books_with("4000", "Owner Loans", AccountType::Liability, false);
let c = l.add_customer("Acme").unwrap();
let before = l.clone();
assert!(matches!(
l.create_invoice(c, date(5), date(30), doc(100)),
Err(LedgerError::ControlAccountMismatch { .. })
));
assert!(l.chart().find_by_code("1200").is_none());
assert_untouched(&before, &l);
}
#[test]
fn a_bill_refuses_a_2000_that_is_not_payables() {
let mut l = books_with("2000", "Savings", AccountType::Asset, false);
let v = l.add_vendor("Vend").unwrap();
let before = l.clone();
assert!(matches!(
l.create_bill(v, date(5), date(30), doc(100)),
Err(LedgerError::ControlAccountMismatch {
expected: AccountType::Liability,
..
})
));
assert_untouched(&before, &l);
}
#[test]
fn a_close_refuses_a_3999_that_is_not_equity() {
let mut l = books_with("3999", "Suspense", AccountType::Asset, false);
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let e = l.create_entry(date(5), "sale");
l.add_line(e, 1, Side::Debit, usd(100)).unwrap();
l.add_line(e, sales, Side::Credit, usd(100)).unwrap();
l.post_entry(e).unwrap();
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
let before = l.clone();
assert!(matches!(
l.close_period(jan),
Err(LedgerError::ControlAccountMismatch {
expected: AccountType::Equity,
..
})
));
assert_untouched(&before, &l);
assert!(l.closed_period_containing(date(5)).is_none());
}
// ---------------------------------------------------------------------------
// Chart, periods, and lines: rules confirmed by Stephen, 2026-10-13.
// ---------------------------------------------------------------------------
/// A child account subdivides its parent, so it is the same kind of
/// account. SOURCE: rules.md B-4; DECISION (Stephen, 2026-10-13).
#[test]
fn a_child_account_has_its_parents_type() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, true)
.unwrap();
l.add_account("1010", "Checking", AccountType::Asset, Some(cash), false)
.unwrap();
let before = l.chart().len();
assert_eq!(
l.add_account("1020", "Rent", AccountType::Expense, Some(cash), false),
Err(LedgerError::ParentTypeMismatch {
code: "1020".into(),
parent_code: "1000".into(),
expected: AccountType::Asset,
found: AccountType::Expense,
})
);
assert_eq!(l.chart().len(), before);
assert!(l.chart().find_by_code("1020").is_none());
}
/// Periods nest (a month in a quarter in a year) but never partially
/// overlap. DECISION (Stephen, 2026-10-13).
#[test]
fn periods_nest_but_never_partially_overlap() {
let d = |m: u32, day: u32| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let mut l = Ledger::new(Currency::USD);
l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
// A quarter around both months, and a year around everything.
l.create_period("Q1 2026", d(1, 1), d(3, 31)).unwrap();
l.create_period("2026", d(1, 1), d(12, 31)).unwrap();
// A week inside January nests too.
l.create_period("week", d(1, 5), d(1, 11)).unwrap();
let next = l.next_period_id();
// Mid-January to mid-February straddles two months: refused.
assert!(matches!(
l.create_period("straddle", d(1, 15), d(2, 15)),
Err(LedgerError::PeriodsOverlap { .. })
));
// So is a quarter that starts inside Q1.
assert!(matches!(
l.create_period("odd quarter", d(2, 1), d(4, 30)),
Err(LedgerError::PeriodsOverlap { .. })
));
assert_eq!(l.next_period_id(), next, "refused means no id burned");
// Disjoint is fine.
l.create_period(
"2027-01",
NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2027, 1, 31).unwrap(),
)
.unwrap();
}
/// A line names a real, postable account when it is added, not only when
/// the entry posts. DECISION (Stephen, 2026-10-13); rules.md B-1.
#[test]
fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() {
let c = fixture_company();
let mut l = c.ledger.clone();
let header = l
.add_account("1900", "Other assets", AccountType::Asset, None, true)
.unwrap();
let e = l.create_entry(date(21), "Lines");
assert_eq!(
l.add_line(e, 999_999, Side::Debit, usd(10)),
Err(LedgerError::UnknownAccount(999_999))
);
assert_eq!(
l.add_line(e, header, Side::Debit, usd(10)),
Err(LedgerError::PlaceholderAccount("1900".into()))
);
assert!(matches!(
l.add_line_fx(
e,
header,
Side::Debit,
Currency::EUR,
Decimal::from(10),
Decimal::from(2)
),
Err(LedgerError::PlaceholderAccount(_))
));
assert!(
l.get_entry(e).unwrap().lines().is_empty(),
"refused lines aren't added"
);
}
/// Spec (rules.md B-3; DECISION, Stephen 2026-10-13, after the GnuCash
/// cross-check): a foreign line is booked in the base currency's smallest
/// unit, half away from zero; the foreign original is kept.
#[test]
fn a_foreign_line_is_booked_in_whole_base_units() {
let book = |base: Currency, foreign: Currency, amount: &str, rate: &str| {
let mut l = Ledger::new(base);
let a = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let e = l.create_entry(date(5), "fx");
let r = l.add_line_fx(
e,
a,
Side::Debit,
foreign,
amount.parse().unwrap(),
rate.parse().unwrap(),
);
r.map(|()| {
let line = l.get_entry(e).unwrap().lines()[0].clone();
(line.amount.amount, line.foreign_amount)
})
};
let dec = |s: &str| s.parse::<Decimal>().unwrap();
// 100.00 EUR x 1.0834567 = 108.34567 USD -> 108.35.
assert_eq!(
book(Currency::USD, Currency::EUR, "100.00", "1.0834567").unwrap(),
(dec("108.35"), Some(dec("100.00")))
);
// 10.00 USD x 151.237 = 1512.37 JPY -> 1512 (yen have no minor unit).
assert_eq!(
book(Currency::JPY, Currency::USD, "10.00", "151.237")
.unwrap()
.0,
dec("1512")
);
// Exactly half a cent rounds away from zero.
assert_eq!(
book(Currency::USD, Currency::EUR, "1", "0.125").unwrap().0,
dec("0.13")
);
// Rounds to nothing: refused.
assert!(book(Currency::USD, Currency::EUR, "0.001", "1").is_err());
}
// ---------------------------------------------------------------------------
// Ledger bookkeeping surfaces: id allocators, FX table, tax percent,
// reconciliation, and closing variants (mutation-testing gaps, 2026-10-13).
// ---------------------------------------------------------------------------
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
/// `next_*_id` predicts the id the next record receives, and each record
/// gets its own.
#[test]
fn id_allocators_predict_the_next_id_and_never_repeat() {
use ledger_core::{Frequency, NewRecurring, NewSchedule, ScheduleKind};
let c = fixture_company();
let mut l = c.ledger;
let schedule = |n: &str| NewSchedule {
description: n.into(),
kind: ScheduleKind::PrepaidExpense,
total: usd(1_200),
start: date(1),
months: 12,
balance_account: c.equipment,
pl_account: c.rent,
};
let mut seen = Vec::new();
for n in ["a", "b"] {
let predicted = l.next_schedule_id();
let id = l.register_schedule(schedule(n)).unwrap();
assert_eq!(id, predicted);
assert!(!seen.contains(&id));
seen.push(id);
}
let mut listed: Vec<_> = l.schedules().map(|s| s.id).collect();
listed.sort();
assert_eq!(listed, seen);
let recurring = || NewRecurring {
description: "rent".into(),
lines: vec![
(c.rent, Side::Debit, usd(800)),
(c.cash, Side::Credit, usd(800)),
],
frequency: Frequency::Monthly,
start: date(1),
end: None,
};
let mut seen = Vec::new();
for _ in 0..2 {
let predicted = l.next_recurring_id();
let id = l.register_recurring(recurring()).unwrap();
assert_eq!(id, predicted);
assert!(!seen.contains(&id));
seen.push(id);
}
let v = l.add_vendor("Vend").unwrap();
for _ in 0..2 {
let predicted = l.next_bill_id();
let id = l.create_bill(v, date(5), date(30), doc(100)).unwrap();
assert_eq!(id, predicted);
assert_ne!(l.next_bill_id(), id);
}
}
/// The FX table records positive rates for foreign currencies only, and
/// gives back exactly what was recorded.
#[test]
fn fx_rates_are_positive_foreign_and_read_back_exactly() {
let mut l = Ledger::new(Currency::USD);
let rate: Decimal = "1.0834".parse().unwrap();
assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), None);
assert_eq!(
l.set_fx_rate(Currency::EUR, date(5), Decimal::ZERO),
Err(LedgerError::InvalidFxRate(Decimal::ZERO))
);
assert!(l.set_fx_rate(Currency::EUR, date(5), -rate).is_err());
assert!(l.set_fx_rate(Currency::USD, date(5), rate).is_err());
assert_eq!(l.get_fx_rate(Currency::USD, date(5)), None);
l.set_fx_rate(Currency::EUR, date(5), rate).unwrap();
assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), Some(rate));
assert_eq!(l.get_fx_rate(Currency::EUR, date(6)), None);
}
/// A deductible percent is 0 to 100 inclusive (set_account_tax's spec);
/// 0 is real (penalties).
#[test]
fn deductible_percent_bounds_are_inclusive() {
let c = fixture_company();
let mut l = c.ledger;
let rent = c.rent;
l.set_account_tax(rent, Some("meals"), Some(Decimal::ZERO))
.unwrap();
l.set_account_tax(rent, Some("meals"), Some(Decimal::from(100)))
.unwrap();
assert!(matches!(
l.set_account_tax(rent, Some("meals"), Some(cents(-1))),
Err(LedgerError::InvalidTaxCategory(_))
));
}
/// Spec (pay_bill scenario 2): a payment may not predate its bill; the
/// bill's own date is fine.
#[test]
fn a_bill_can_be_paid_on_its_own_date() {
let c = fixture_company();
let mut l = c.ledger;
let v = l.add_vendor("Vend").unwrap();
let bill = l.create_bill(v, date(21), date(31), doc(100)).unwrap();
l.pay_bill(bill, date(21), cents(1_000), c.cash).unwrap();
}
/// Reconciliation invariants 1 and 4: on a credit-normal account the book
/// balance is the account's balance, and a line dated on the statement
/// date is outstanding until cleared.
#[test]
fn reconciling_a_card_reads_its_normal_side_and_includes_the_statement_date() {
let c = fixture_company();
let mut l = c.ledger;
let card = l
.add_account("2100", "Card", AccountType::Liability, None, false)
.unwrap();
post(
&mut l,
date(21),
"charge",
&[(c.rent, Side::Debit, 500), (card, Side::Credit, 500)],
);
post(
&mut l,
date(25),
"pay card",
&[(card, Side::Debit, 200), (c.cash, Side::Credit, 200)],
);
let r = l
.reconciliation_report(card, date(25), Decimal::from(300))
.unwrap();
assert_eq!(r.book_balance, Decimal::from(300));
assert_eq!(r.book_balance, l.account_balance(card).unwrap());
let outstanding: Vec<_> = r.uncleared_lines.iter().map(|u| u.date).collect();
assert_eq!(outstanding, vec![date(21), date(25)]);
}
/// B-11: closing to retained earnings with nothing to close but a swept
/// account's balance still sweeps it.
#[test]
fn a_close_sweeps_dividends_even_without_income() {
let mut l = books_with("3100", "Dividends", AccountType::Equity, false);
let dividends = 2;
post(
&mut l,
date(10),
"dividend",
&[(dividends, Side::Debit, 100), (1, Side::Credit, 100)],
);
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
let to = ledger_core::CloseTo::RetainedEarnings {
sweep: vec![dividends],
};
let predicted = l.preview_close_period_to(jan, &to).unwrap();
assert_eq!(predicted, Some(l.next_entry_id()));
assert_eq!(l.close_period_to(jan, &to).unwrap(), predicted);
assert_eq!(l.account_balance(dividends).unwrap(), Decimal::ZERO);
let re = l.chart().find_by_code("3999").unwrap().id;
assert_eq!(l.account_balance(re).unwrap(), Decimal::from(-100));
}
/// `preview_close_period` names the entry a close with income will post.
#[test]
fn preview_close_names_the_closing_entry() {
let mut c = fixture_company();
let jan = c
.ledger
.create_period("2026-01", date(1), date(31))
.unwrap();
let next = c.ledger.next_entry_id();
assert_eq!(c.ledger.preview_close_period(jan).unwrap(), Some(next));
assert_eq!(c.ledger.close_period(jan).unwrap(), Some(next));
}
/// Closes journaled under the pre-2026-10-13 rule replay under it: each
/// share rounded half away from zero, the last member absorbing the rest.
/// Net 1.05 at 30/30/40 is 0.32, 0.32, 0.41 (largest remainder would give
/// 0.32, 0.31, 0.42).
#[test]
fn a_last_absorbs_close_gives_the_rounding_to_the_last_member() {
let mut l = books_with("4000", "Sales", AccountType::Revenue, false);
let capital: Vec<AccountId> = ["3001", "3002", "3003"]
.iter()
.map(|code| {
l.add_account(code, "Capital", AccountType::Equity, None, false)
.unwrap()
})
.collect();
let e = l.create_entry(date(5), "sale");
let m = Money::new(cents(105), Currency::USD);
l.add_line(e, 1, Side::Debit, m).unwrap();
l.add_line(e, 2, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
let members = capital
.iter()
.zip(["0.3", "0.3", "0.4"])
.map(|(&capital, share)| ledger_core::MemberClose {
capital,
share: share.parse().unwrap(),
distributions: None,
})
.collect();
l.close_period_to(jan, &ledger_core::CloseTo::MembersLastAbsorbs(members))
.unwrap();
let got: Vec<Decimal> = capital
.iter()
.map(|&a| l.account_balance(a).unwrap())
.collect();
assert_eq!(got, vec![cents(32), cents(32), cents(41)]);
}
/// B-12: a period whose closing date already lies in a closed period
/// can't close; the refusal names that period.
#[test]
fn a_period_ending_inside_a_closed_period_cannot_close() {
let mut c = fixture_company();
let q1 = c
.ledger
.create_period(
"2026-Q1",
date(1),
NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
)
.unwrap();
let jan = c
.ledger
.create_period("2026-01", date(1), date(31))
.unwrap();
c.ledger.close_period(q1).unwrap();
assert_eq!(
c.ledger.close_period(jan),
Err(LedgerError::ClosedPeriod(q1, date(31).to_string()))
);
}
/// Replay (for journaling stores) rebuilds invoice and bill records
/// exactly: ids follow the allocator, payments accumulate, vendors tag
/// the entries. A record replayed out of order is refused.
#[test]
fn replayed_documents_and_payments_rebuild_the_records() {
use ledger_core::DocumentStatus;
let mut l = books_with("2000", "Accounts Payable", AccountType::Liability, false);
let c = l.add_customer("Acme").unwrap();
let v = l.add_vendor("Vend").unwrap();
let inv = l.next_invoice_id();
assert_eq!(
l.replay_invoice(inv + 1, c, date(5), date(30), doc(100), 10),
Err(LedgerError::ReplayIdMismatch(inv + 1, inv))
);
l.replay_invoice(inv, c, date(5), date(30), doc(100), 10)
.unwrap();
// Each later record replays at the id the allocator now predicts.
let mut ids = vec![inv];
for entry in [13, 14] {
let next = l.next_invoice_id();
l.replay_invoice(next, c, date(5), date(30), doc(1), entry)
.unwrap();
ids.push(next);
}
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 3);
assert!(ids.iter().all(|&i| l.get_invoice(i).is_some()));
l.replay_invoice_payment(inv, Decimal::from(30), 11)
.unwrap();
l.replay_invoice_payment(inv, Decimal::from(20), 12)
.unwrap();
let i = l.get_invoice(inv).unwrap();
assert_eq!(i.amount_paid, Decimal::from(50));
assert_eq!(i.balance(), Decimal::from(50));
assert_eq!(i.status, DocumentStatus::Partial);
let bill = l.next_bill_id();
assert_eq!(
l.replay_bill(bill + 1, v, date(5), date(30), doc(100), 20),
Err(LedgerError::ReplayIdMismatch(bill + 1, bill))
);
assert_eq!(l.entry_vendor(20), None);
l.replay_bill(bill, v, date(5), date(30), doc(100), 20)
.unwrap();
assert_eq!(l.entry_vendor(20), Some(v));
let mut ids = vec![bill];
for entry in [23, 24] {
let next = l.next_bill_id();
l.replay_bill(next, v, date(5), date(30), doc(1), entry)
.unwrap();
ids.push(next);
}
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 3);
l.replay_bill_payment(bill, Decimal::from(30), 21).unwrap();
l.replay_bill_payment(bill, Decimal::from(70), 22).unwrap();
let b = l.get_bill(bill).unwrap();
assert_eq!(b.amount_paid, Decimal::from(100));
assert_eq!(b.status, DocumentStatus::Paid);
assert_eq!(l.entry_vendor(22), Some(v));
}
+20
View File
@@ -147,6 +147,26 @@ proptest! {
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
}
#[test]
fn apportion_agrees(
parts in prop::collection::vec(arb_cents(), 0..5),
rems in prop::collection::vec(0i64..100, 5),
short in 0i64..500,
) {
let n = parts.len();
let whole: i64 = parts.iter().sum::<i64>() + short;
let mut pi = parts.clone();
let mut pd: Vec<Decimal> = parts.iter().copied().map(dec).collect();
let ri = &rems[..n];
let rd: Vec<Decimal> = ri.iter().copied().map(dec).collect();
let oi = kernel::apportion(whole, &mut pi, ri, 100);
let od = kernel::apportion(dec(whole), &mut pd, &rd, dec(100));
prop_assert_eq!(oi.is_some(), od.is_some());
if oi.is_some() {
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
}
}
#[test]
fn line_range_agrees(cents in arb_cents()) {
prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range());
+523
View File
@@ -0,0 +1,523 @@
//! Model-based stateful test (rank 3: a peer).
//!
//! The peer is the Lean state machine (verify/lean/Wanderbooks/Ledger.lean)
//! transcribed into the simplest Rust that has the same observable
//! behavior: a list of accounts, a list of drafts, an append-only list of
//! posted entries, a list of periods, and loops. It is written from the
//! spec (rules.md, the Ledger doc comments), not from ledger.rs.
//!
//! Random command sequences, weighted toward commands that interact (post
//! a draft, reverse a posted entry, close a period with drafts in it,
//! reverse a closing entry, reverse twice), run against both. After every
//! command: the same accept/refuse decision, and the same posted log,
//! chart, drafts, periods, and every account's balance.
//!
//! `MODEL_STATS=1 cargo test --test model -- --nocapture` prints every
//! accepted command: check that posts, reversals, and closes really
//! happen. (The first version never posted anything, so it agreed with the
//! model vacuously; the balanced `Pair` command fixed that.) Shown to have
//! teeth: letting the model reverse an entry twice fails in 0.03s.
use chrono::NaiveDate;
use ledger_core::reports::trial_balance;
use ledger_core::{AccountType, Currency, EntryStatus, Ledger, Money, Side};
use proptest::prelude::*;
use rust_decimal::Decimal;
// ---------------------------------------------------------------------------
// The model
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
struct MLine {
account: u64,
side: Side,
amount: Decimal,
}
#[derive(Clone, Debug)]
struct MEntry {
id: u64,
date: NaiveDate,
lines: Vec<MLine>,
reversal_of: Option<u64>,
closes: bool,
}
#[derive(Clone, Debug)]
struct MPeriod {
start: NaiveDate,
end: NaiveDate,
closed: bool,
}
#[derive(Clone, Debug, Default)]
struct Model {
/// (id, code, type, placeholder)
accounts: Vec<(u64, String, AccountType, bool)>,
drafts: Vec<MEntry>,
posted: Vec<MEntry>,
periods: Vec<MPeriod>,
next_account: u64,
next_entry: u64,
}
fn signed(l: &MLine) -> Decimal {
if l.side == Side::Debit {
l.amount
} else {
-l.amount
}
}
impl Model {
fn new() -> Self {
Model {
next_account: 1,
next_entry: 1,
..Default::default()
}
}
fn account(&self, id: u64) -> Option<&(u64, String, AccountType, bool)> {
self.accounts.iter().find(|a| a.0 == id)
}
fn closed_on(&self, d: NaiveDate) -> bool {
self.periods
.iter()
.any(|p| p.closed && p.start <= d && d <= p.end)
}
/// Debits minus credits on an account, over posted entries (through
/// `end` if given).
fn movement(&self, account: u64, end: Option<NaiveDate>) -> Decimal {
self.posted
.iter()
.filter(|e| end.is_none_or(|end| e.date <= end))
.flat_map(|e| &e.lines)
.filter(|l| l.account == account)
.map(signed)
.sum()
}
fn add_account(&mut self, code: &str, t: AccountType, placeholder: bool) -> bool {
if code.is_empty() || self.accounts.iter().any(|a| a.1 == code) {
return false;
}
self.accounts
.push((self.next_account, code.into(), t, placeholder));
self.next_account += 1;
true
}
fn draft(&mut self, date: NaiveDate) -> Option<u64> {
if self.closed_on(date) {
return None;
}
let id = self.next_entry;
self.next_entry += 1;
self.drafts.push(MEntry {
id,
date,
lines: vec![],
reversal_of: None,
closes: false,
});
Some(id)
}
fn add_line(&mut self, entry: u64, l: MLine) -> bool {
let postable = self.account(l.account).is_some_and(|a| !a.3);
let Some(d) = self.drafts.iter_mut().find(|d| d.id == entry) else {
return false;
};
if !postable {
return false;
}
d.lines.push(l);
true
}
fn acceptable(&self, lines: &[MLine]) -> bool {
let debits: Decimal = lines
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount)
.sum();
let credits: Decimal = lines
.iter()
.filter(|l| l.side == Side::Credit)
.map(|l| l.amount)
.sum();
lines.len() >= 2
&& lines.iter().all(|l| l.amount > Decimal::ZERO)
&& lines
.iter()
.all(|l| self.account(l.account).is_some_and(|a| !a.3))
&& debits == credits
}
fn post(&mut self, entry: u64) -> bool {
let Some(i) = self.drafts.iter().position(|d| d.id == entry) else {
return false;
};
let d = &self.drafts[i];
if self.closed_on(d.date) || !self.acceptable(&d.lines) {
return false;
}
let d = self.drafts.remove(i);
self.posted.push(d);
true
}
fn reverse(&mut self, entry: u64, date: NaiveDate) -> bool {
let Some(orig) = self.posted.iter().find(|e| e.id == entry).cloned() else {
return false;
};
let reversed_already = self.posted.iter().any(|e| e.reversal_of == Some(entry));
if reversed_already || orig.closes || self.closed_on(date) {
return false;
}
let lines = orig
.lines
.iter()
.map(|l| MLine {
side: if l.side == Side::Debit {
Side::Credit
} else {
Side::Debit
},
..l.clone()
})
.collect();
self.posted.push(MEntry {
id: self.next_entry,
date,
lines,
reversal_of: Some(entry),
closes: false,
});
self.next_entry += 1;
true
}
fn create_period(&mut self, start: NaiveDate, end: NaiveDate) -> bool {
if end < start {
return false;
}
let nests =
|p: &MPeriod| (start >= p.start && end <= p.end) || (p.start >= start && p.end <= end);
if self
.periods
.iter()
.any(|p| start <= p.end && p.start <= end && !nests(p))
{
return false;
}
self.periods.push(MPeriod {
start,
end,
closed: false,
});
true
}
/// Close into retained earnings (3999).
fn close(&mut self, index: usize) -> bool {
let Some(p) = self.periods.get(index).cloned() else {
return false;
};
if p.closed
|| self
.drafts
.iter()
.any(|d| p.start <= d.date && d.date <= p.end)
|| self.periods.iter().any(|q| q.closed && q.end > p.end)
|| self
.periods
.iter()
.any(|q| q.closed && q.start <= p.end && p.end <= q.end)
{
return false;
}
// Retained earnings must be a postable equity account if it exists.
if let Some(re) = self.accounts.iter().find(|a| a.1 == "3999")
&& (re.2 != AccountType::Equity || re.3)
{
return false;
}
let mut lines = Vec::new();
let nominal: Vec<u64> = {
let mut v: Vec<_> = self
.accounts
.iter()
.filter(|a| matches!(a.2, AccountType::Revenue | AccountType::Expense))
.collect();
v.sort_by(|a, b| a.1.cmp(&b.1));
v.iter().map(|a| a.0).collect()
};
for a in nominal {
let m = self.movement(a, Some(p.end));
if m > Decimal::ZERO {
lines.push(MLine {
account: a,
side: Side::Credit,
amount: m,
});
} else if m < Decimal::ZERO {
lines.push(MLine {
account: a,
side: Side::Debit,
amount: -m,
});
}
}
if !lines.is_empty() {
let net: Decimal = lines.iter().map(signed).sum();
let re = match self.accounts.iter().find(|a| a.1 == "3999") {
Some(a) => a.0,
None => {
self.add_account("3999", AccountType::Equity, false);
self.next_account - 1
}
};
if net > Decimal::ZERO {
lines.push(MLine {
account: re,
side: Side::Credit,
amount: net,
});
} else if net < Decimal::ZERO {
lines.push(MLine {
account: re,
side: Side::Debit,
amount: -net,
});
}
self.posted.push(MEntry {
id: self.next_entry,
date: p.end,
lines,
reversal_of: None,
closes: true,
});
self.next_entry += 1;
}
self.periods[index].closed = true;
true
}
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
const CODES: [&str; 7] = ["1000", "2000", "3000", "3999", "4000", "6000", "1900"];
const TYPES: [AccountType; 5] = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
#[derive(Clone, Debug)]
enum Cmd {
AddAccount(usize, usize, bool),
Draft(u32),
AddLine(usize, usize, bool, i64),
/// A balanced pair: debit one account, credit another, same amount.
Pair(usize, usize, usize, i64),
Post(usize),
Reverse(usize, u32),
CreatePeriod(u32, u32),
Close(usize),
}
fn day(n: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n % 180))
}
fn cmd() -> impl Strategy<Value = Cmd> {
prop_oneof![
2 => (0..CODES.len(), 0..TYPES.len(), prop::bool::weighted(0.1))
.prop_map(|(c, t, h)| Cmd::AddAccount(c, t, h)),
3 => (0u32..180).prop_map(Cmd::Draft),
6 => (0usize..8, 0usize..8, any::<bool>(),
prop_oneof![8 => 1i64..100_000, 1 => Just(0i64), 1 => -500i64..0])
.prop_map(|(e, a, s, c)| Cmd::AddLine(e, a, s, c)),
6 => (0usize..8, 0usize..8, 0usize..8, 1i64..100_000)
.prop_map(|(e, a, b, c)| Cmd::Pair(e, a, b, c)),
5 => (0usize..8).prop_map(Cmd::Post),
2 => (0usize..8, 0u32..180).prop_map(|(e, d)| Cmd::Reverse(e, d)),
2 => (0u32..180, 0u32..60).prop_map(|(s, len)| Cmd::CreatePeriod(s, s + len)),
2 => (0usize..4).prop_map(Cmd::Close),
]
}
/// Apply one command to both and return (engine accepted, model accepted).
/// Indices pick among existing drafts / posted entries / accounts / periods,
/// so most commands hit something real.
fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) {
let pick = |v: &[u64], i: usize| v.get(i % v.len().max(1)).copied().unwrap_or(999);
let accounts: Vec<u64> = m.accounts.iter().map(|a| a.0).collect();
let drafts: Vec<u64> = m.drafts.iter().map(|d| d.id).collect();
let posted: Vec<u64> = m.posted.iter().map(|e| e.id).collect();
match *c {
Cmd::AddAccount(code, t, h) => (
l.add_account(CODES[code], CODES[code], TYPES[t], None, h)
.is_ok(),
m.add_account(CODES[code], TYPES[t], h),
),
Cmd::Draft(d) => {
let got = l.try_create_entry(day(d), "x").ok();
let want = m.draft(day(d));
assert_eq!(got, want, "draft ids");
(got.is_some(), want.is_some())
}
Cmd::AddLine(e, a, debit, cents) => {
let (e, a) = (pick(&drafts, e), pick(&accounts, a));
let side = if debit { Side::Debit } else { Side::Credit };
let amount = Decimal::new(cents, 2);
(
l.add_line(e, a, side, Money::new(amount, Currency::USD))
.is_ok(),
m.add_line(
e,
MLine {
account: a,
side,
amount,
},
),
)
}
Cmd::Pair(e, a, b, cents) => {
let (e, a, b) = (pick(&drafts, e), pick(&accounts, a), pick(&accounts, b));
let amount = Decimal::new(cents, 2);
let money = Money::new(amount, Currency::USD);
let got = l.add_line(e, a, Side::Debit, money).is_ok()
&& l.add_line(e, b, Side::Credit, money).is_ok();
let want = m.add_line(
e,
MLine {
account: a,
side: Side::Debit,
amount,
},
) && m.add_line(
e,
MLine {
account: b,
side: Side::Credit,
amount,
},
);
(got, want)
}
Cmd::Post(e) => {
let e = pick(&drafts, e);
(l.post_entry(e).is_ok(), m.post(e))
}
Cmd::Reverse(e, d) => {
let e = pick(&posted, e);
(
l.reverse_entry(e, day(d), "undo").is_ok(),
m.reverse(e, day(d)),
)
}
Cmd::CreatePeriod(s, e) => (
l.create_period("p", day(s), day(e)).is_ok(),
m.create_period(day(s), day(e)),
),
Cmd::Close(i) => {
let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect();
let i = if ids.is_empty() { 0 } else { i % ids.len() };
let id = ids.get(i).copied().unwrap_or(999);
(l.close_period(id).is_ok(), m.close(i))
}
}
}
/// Every observable agrees.
fn agree(l: &Ledger, m: &Model) -> Result<(), TestCaseError> {
// Chart.
prop_assert_eq!(l.chart().len(), m.accounts.len());
for (id, code, t, h) in &m.accounts {
let a = l.chart().get(*id).expect("account");
prop_assert_eq!((&a.code, a.account_type, a.placeholder), (code, *t, *h));
}
// The posted log, in order, line for line.
let log: Vec<_> = l.posted_entries().collect();
prop_assert_eq!(log.len(), m.posted.len());
for (e, me) in log.iter().zip(&m.posted) {
prop_assert_eq!(
(e.id(), e.date(), e.reversal_of()),
(me.id, me.date, me.reversal_of)
);
let lines: Vec<MLine> = e
.lines()
.iter()
.map(|l| MLine {
account: l.account,
side: l.side,
amount: l.amount.amount,
})
.collect();
prop_assert_eq!(&lines, &me.lines, "entry {}", e.id());
}
// Drafts, with their lines.
for d in &m.drafts {
let e = l.get_entry(d.id).expect("draft exists");
prop_assert_eq!(e.status(), EntryStatus::Draft);
prop_assert_eq!(e.lines().len(), d.lines.len());
}
prop_assert_eq!(l.next_entry_id(), m.next_entry);
prop_assert_eq!(l.next_account_id(), m.next_account);
// Periods.
let ps = l.list_periods();
prop_assert_eq!(ps.len(), m.periods.len());
for (p, mp) in ps.iter().zip(&m.periods) {
prop_assert_eq!(
(
p.start,
p.end,
p.status == ledger_core::PeriodStatus::Closed
),
(mp.start, mp.end, mp.closed)
);
}
// Every account's balance, and the trial balance balances.
for (id, ..) in &m.accounts {
prop_assert_eq!(
l.account_movement(*id),
m.movement(*id, None),
"account {}",
id
);
}
let tb = trial_balance(l);
prop_assert_eq!(tb.total_debits, tb.total_credits);
Ok(())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2048))]
/// Oracle: the model (the Lean state machine, transcribed). Same
/// decision on every command, same books after every command.
#[test]
fn the_ledger_matches_its_model(cmds in prop::collection::vec(cmd(), 1..60)) {
let mut l = Ledger::new(Currency::USD);
let mut m = Model::new();
for c in &cmds {
let (got, want) = apply(&mut l, &mut m, c);
if std::env::var("MODEL_STATS").is_ok() && got {
eprintln!("ACCEPTED {}", format!("{c:?}").split('(').next().unwrap());
}
prop_assert_eq!(got, want, "{:?}: engine accepted {}, model {}", c, got, want);
agree(&l, &m)?;
}
}
}
+289
View File
@@ -0,0 +1,289 @@
//! Cross-check against GnuCash (rank 3: a differential oracle).
//!
//! GnuCash is *an* independent implementation of double entry, not the
//! spec (docs/oracle.md, "Who is right"). This test builds random books
//! through wanderbooks' engine (entries, foreign-currency lines, reversals,
//! period closes), hands their posted entries to GnuCash 5.10 running in a
//! pinned container (oracle/gnucash/), and compares every account's
//! balance at month ends and random dates, exactly. A difference is a
//! finding to adjudicate from principles (rules.md), never a reason to
//! change wanderbooks to match.
//!
//! Needs Docker: `just oracle` builds the image and runs this.
use chrono::NaiveDate;
use ledger_core::reports::{profit_and_loss_for_period, trial_balance_as_of};
use ledger_core::{AccountId, AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::io::Write;
use std::process::{Command, Stdio};
/// Books per run, and the seed of the first.
const BOOKS: u64 = 200;
const SEED: u64 = 0x005e_ed0f_b00c;
/// A small deterministic generator (xorshift64*): the run is reproducible
/// from its seed, with no new dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
}
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn month_end(m: u32) -> NaiveDate {
if m == 12 {
d(12, 31)
} else {
d(m + 1, 1).pred_opt().unwrap()
}
}
/// Random books through the engine's public commands. Closing months
/// (when `closes`) happens in order, after the month's entries.
fn generate(seed: u64, closes: bool, fx: bool) -> (Ledger, Vec<AccountId>) {
let mut r = Rng(seed | 1);
let mut l = Ledger::new(Currency::USD);
let chart = [
("1000", "Cash", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("2000", "Payables", AccountType::Liability),
("3000", "Capital", AccountType::Equity),
("4000", "Sales", AccountType::Revenue),
("4100", "Interest", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
("6100", "Software", AccountType::Expense),
];
let ids: Vec<AccountId> = chart
.iter()
.map(|(c, n, t)| l.add_account(c, n, *t, None, false).unwrap())
.collect();
let mut posted = Vec::new();
for month in 1..=6u32 {
let entries = 1 + r.below(6);
for _ in 0..entries {
let day = 1 + r.below(28) as u32;
let e = l.create_entry(d(month, day), "generated");
let lines = 1 + r.below(3);
let mut net = Decimal::ZERO;
for _ in 0..lines {
let account = ids[r.below(ids.len() as u64) as usize];
let side = if r.below(2) == 0 {
Side::Debit
} else {
Side::Credit
};
if fx && r.below(8) == 0 {
// A foreign line: odd decimal places in the base amount.
let foreign = Decimal::new(1 + r.below(500_000) as i64, 2);
let rate = Decimal::new(5_000 + r.below(20_000) as i64, 4);
l.add_line_fx(e, account, side, Currency::EUR, foreign, rate)
.unwrap();
// Balance against what was booked (rounded to the cent).
let base = l
.get_entry(e)
.unwrap()
.lines()
.last()
.unwrap()
.amount
.amount;
net += if side == Side::Debit { base } else { -base };
} else {
let amount = Decimal::new(1 + r.below(1_000_000) as i64, 2);
l.add_line(e, account, side, Money::new(amount, Currency::USD))
.unwrap();
net += if side == Side::Debit { amount } else { -amount };
}
}
// A plug line on cash balances the entry.
if !net.is_zero() {
let side = if net > Decimal::ZERO {
Side::Credit
} else {
Side::Debit
};
l.add_line(e, ids[0], side, Money::new(net.abs(), Currency::USD))
.unwrap();
} else {
let amount = Money::new(Decimal::ONE, Currency::USD);
l.add_line(e, ids[0], Side::Debit, amount).unwrap();
l.add_line(e, ids[3], Side::Credit, amount).unwrap();
}
if l.post_entry(e).is_ok() {
posted.push(e);
}
}
// Now and then, reverse an earlier entry of this month.
if r.below(3) == 0
&& let Some(&e) = posted.last()
{
let date = l.get_entry(e).unwrap().date();
let _ = l.reverse_entry(e, date, "reversal");
}
if closes {
let p = l
.create_period(&format!("2026-{month:02}"), d(month, 1), month_end(month))
.unwrap();
l.close_period(p).unwrap();
}
}
(l, ids)
}
fn book_json(l: &Ledger, as_of: &[NaiveDate]) -> Value {
let accounts: Vec<Value> = l
.chart()
.all()
.map(|a| {
json!({"id": a.id, "code": a.code, "name": a.name,
"type": format!("{:?}", a.account_type)})
})
.collect();
let entries: Vec<Value> = l
.posted_entries()
.map(|e| {
json!({
"date": e.date().to_string(),
"description": e.description(),
"lines": e.lines().iter().map(|ln| json!({
"account": ln.account,
"side": if ln.side == Side::Debit { "debit" } else { "credit" },
"amount": ln.amount.amount.to_string(),
})).collect::<Vec<_>>(),
})
})
.collect();
json!({
"accounts": accounts,
"entries": entries,
"as_of": as_of.iter().map(|d| d.to_string()).collect::<Vec<_>>(),
})
}
/// wanderbooks' trial balance as of a date, as GnuCash reports balances:
/// debits positive, by account id, zero for accounts not listed.
fn ours(l: &Ledger, as_of: NaiveDate) -> BTreeMap<AccountId, Decimal> {
let tb = trial_balance_as_of(l, as_of);
let mut out: BTreeMap<AccountId, Decimal> =
l.chart().all().map(|a| (a.id, Decimal::ZERO)).collect();
for line in tb.lines {
let id = l.chart().find_by_code(&line.code).unwrap().id;
out.insert(id, line.debit - line.credit);
}
out
}
fn gnucash(books: &[Value]) -> Vec<Value> {
let mut child = Command::new("docker")
.args(["run", "--rm", "-i", "wanderbooks-gnucash-oracle"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("docker is needed: run `just oracle`");
child
.stdin
.take()
.unwrap()
.write_all(serde_json::to_string(books).unwrap().as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"the GnuCash oracle failed; build it with `just oracle`"
);
serde_json::from_slice(&out.stdout).expect("oracle output is JSON")
}
fn dec(v: &Value) -> Decimal {
v.as_str().unwrap().parse().unwrap()
}
#[test]
#[ignore = "needs Docker and the GnuCash image: `just oracle`"]
fn balances_match_gnucash_on_generated_books() {
let mut cases = Vec::new();
for i in 0..BOOKS {
let seed = SEED.wrapping_add(i.wrapping_mul(0x9e37_79b9_7f4a_7c15));
let closes = i % 2 == 1;
let (l, _) = generate(seed, closes, true);
let mut r = Rng(seed ^ 0xa5a5);
let mut as_of: Vec<NaiveDate> = (1..=6).map(month_end).collect();
for _ in 0..3 {
as_of.push(d(1 + r.below(6) as u32, 1 + r.below(28) as u32));
}
cases.push((seed, closes, l, as_of));
}
let books: Vec<Value> = cases
.iter()
.map(|(_, _, l, as_of)| book_json(l, as_of))
.collect();
let results = gnucash(&books);
assert_eq!(results.len(), cases.len());
let mut diffs = Vec::new();
let mut compared = 0usize;
for ((seed, closes, l, as_of), result) in cases.iter().zip(&results) {
for date in as_of {
let theirs = &result["balances"][date.to_string()];
for (id, mine) in ours(l, *date) {
let g = dec(&theirs[id.to_string()]);
compared += 1;
if g != mine {
let code = &l.chart().get(id).unwrap().code;
diffs.push(format!(
"seed {seed:#x} (closes: {closes}) account {code} as of {date}: wanderbooks {mine}, GnuCash {g}"
));
}
}
}
// A month's P&L is the change in its income and expense balances,
// on books with no closing entries (GnuCash books a close as an
// ordinary transaction; wanderbooks' period P&L leaves it out).
if !closes {
for m in 2..=6u32 {
let pnl = profit_and_loss_for_period(l, d(m, 1), month_end(m), None).unwrap();
let before = &result["balances"][month_end(m - 1).to_string()];
let after = &result["balances"][month_end(m).to_string()];
let mut gnucash_net = Decimal::ZERO;
for a in l.chart().all() {
if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) {
let k = a.id.to_string();
// Income is credit-normal: net income = -(change).
gnucash_net -= dec(&after[&k]) - dec(&before[&k]);
}
}
compared += 1;
if gnucash_net != pnl.net_income() {
diffs.push(format!(
"seed {seed:#x} P&L 2026-{m:02}: wanderbooks {}, GnuCash {gnucash_net}",
pnl.net_income()
));
}
}
}
}
eprintln!(
"compared {compared} balances and P&Ls across {} books",
cases.len()
);
assert!(
diffs.is_empty(),
"{} difference(s) with GnuCash; adjudicate each from rules.md:\n{}",
diffs.len(),
diffs.join("\n")
);
}
+30
View File
@@ -153,3 +153,33 @@ fn accounts_must_fit_their_roles() {
p.revenue.clear();
assert!(invalid(p), "needs revenue");
}
#[test]
fn a_zero_payout_has_no_bank_line() {
let b = books();
// 10.00 of sales, 3.00 of fees, a 7.00 refund: nothing deposited.
let p = Payout {
deposit_account: b.bank,
deposit: dec("0.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("7.00"),
class: None,
}],
};
let (plan, totals) = payout_plan(&p, b.l.chart()).unwrap();
assert_eq!(totals.deposit, Decimal::ZERO);
assert_eq!(
plan.iter()
.map(|(a, s, m, _)| (*a, *s, *m))
.collect::<Vec<_>>(),
[
(b.fees, Side::Debit, dec("3.00")),
(b.refunds, Side::Debit, dec("7.00")),
(b.sales, Side::Credit, dec("10.00")),
]
);
}
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200]
cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842]
+69 -3
View File
@@ -10,8 +10,8 @@ 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,
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
unclosed_earnings,
},
};
use proptest::prelude::*;
@@ -157,7 +157,7 @@ proptest! {
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();
let all = unclosed_earnings(&ledger).unwrap();
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
}
@@ -319,3 +319,69 @@ proptest! {
}
}
}
// ---------------------------------------------------------------------------
// Member splits (periods::allocate): conservation, a cent of each exact
// share, fairness, and sign symmetry. Rank 2: laws, not recomputation.
// ---------------------------------------------------------------------------
/// 1 to 5 positive shares summing to exactly 1 (the last takes the rest).
fn arb_shares() -> impl Strategy<Value = Vec<Decimal>> {
prop::collection::vec(1u32..1000, 1..=5).prop_map(|weights| {
let total: u32 = weights.iter().sum();
let mut shares: Vec<Decimal> = weights
.iter()
.map(|w| Decimal::from(*w) / Decimal::from(total))
.collect();
let rest: Decimal = shares[..shares.len() - 1].iter().sum();
*shares.last_mut().unwrap() = Decimal::ONE - rest;
shares
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
#[test]
fn prop_split_parts_sum_to_the_whole_within_a_cent_of_each_share(
cents in -10_000_000_000i64..10_000_000_000, shares in arb_shares(),
) {
let amount = Decimal::new(cents, 2);
let parts = ledger_core::periods::allocate(amount, &shares);
prop_assert_eq!(parts.len(), shares.len());
prop_assert_eq!(parts.iter().copied().sum::<Decimal>(), amount);
for (p, s) in parts.iter().zip(&shares) {
prop_assert!((*p - amount * s).abs() < Decimal::new(1, 2), "{p} vs share {s}");
prop_assert!(p.scale() <= 2 || p.normalize().scale() <= 2, "{p} is not whole cents");
}
}
#[test]
fn prop_split_never_rounds_up_a_smaller_remainder_over_a_larger(
cents in 0i64..10_000_000_000, shares in arb_shares(),
) {
let amount = Decimal::new(cents, 2);
let parts = ledger_core::periods::allocate(amount, &shares);
let exact: Vec<Decimal> = shares.iter().map(|s| amount * s).collect();
let floor = |e: &Decimal| e.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::ToZero);
for i in 0..parts.len() {
for j in 0..parts.len() {
let (ri, rj) = (exact[i] - floor(&exact[i]), exact[j] - floor(&exact[j]));
let (up_i, up_j) = (parts[i] > floor(&exact[i]), parts[j] > floor(&exact[j]));
if ri > rj {
prop_assert!(up_i || !up_j, "member {j} rounded up over member {i}");
}
}
}
}
#[test]
fn prop_split_of_a_loss_is_the_negated_split_of_the_gain(
cents in 0i64..10_000_000_000, shares in arb_shares(),
) {
let amount = Decimal::new(cents, 2);
let gain = ledger_core::periods::allocate(amount, &shares);
let loss = ledger_core::periods::allocate(-amount, &shares);
prop_assert_eq!(loss, gain.iter().map(|p| -*p).collect::<Vec<_>>());
}
}
+183
View File
@@ -224,3 +224,186 @@ fn recurring_entries_generate_drafts_never_posts() {
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
let _ = b.revenue;
}
/// Regression (B-12, found by the store's refusal property on
/// 2026-10-13): a recurring occurrence dated in a closed period is
/// refused, leaves nothing behind, and is reported as needing a human.
#[test]
fn a_recurring_occurrence_in_a_closed_period_is_refused() {
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: None,
})
.unwrap();
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
b.l.close_period(jan).unwrap();
let next = b.l.next_entry_id();
assert!(matches!(
b.l.generate_recurring(id, d(1, 31)),
Err(LedgerError::ClosedPeriod(p, _)) if p == jan
));
assert_eq!(b.l.next_entry_id(), next, "no id spent");
assert!(b.l.get_entry(next).is_none(), "no draft left behind");
assert_eq!(b.l.recurring_in_closed_periods(d(2, 28)), [(id, d(1, 31))]);
// Later occurrences still generate.
assert_eq!(b.l.generate_recurring(id, d(2, 28)).unwrap(), next);
}
#[test]
fn schedule_length_is_one_to_six_hundred_months() {
let b = books();
let sched = |months| NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("600"),
start: d(1, 1),
months,
balance_account: b.prepaid,
pl_account: b.insurance,
};
let chart = b.l.chart();
for ok in [1, 600] {
assert!(
ledger_core::schedules::validate_schedule(&sched(ok), chart).is_ok(),
"{ok} months"
);
}
for bad in [0, 601] {
assert!(
matches!(
ledger_core::schedules::validate_schedule(&sched(bad), chart),
Err(LedgerError::InvalidSchedule(_))
),
"{bad} months"
);
}
}
#[test]
fn recurring_templates_take_any_number_of_lines_and_may_end_on_their_start() {
let mut b = books();
// Three balanced lines are a valid template.
b.l.register_recurring(NewRecurring {
description: "Rent and insurance".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.insurance, Side::Debit, usd("100")),
(b.cash, Side::Credit, usd("2100")),
],
frequency: Frequency::Monthly,
start: d(1, 1),
end: None,
})
.unwrap();
// An end on the start date is one occurrence, not an error.
let once =
b.l.register_recurring(NewRecurring {
description: "One-off".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("2000")),
],
frequency: Frequency::Monthly,
start: d(5, 1),
end: Some(d(5, 1)),
})
.unwrap();
let r = b.l.recurring_entries().find(|r| r.id == once).unwrap();
assert_eq!(r.due(d(12, 31)), [d(5, 1)]);
// An end before the start is.
let bad = b.l.register_recurring(NewRecurring {
description: "Backwards".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("2000")),
],
frequency: Frequency::Monthly,
start: d(5, 2),
end: Some(d(5, 1)),
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
}
#[test]
fn frequencies_parse_and_step_from_the_start() {
assert_eq!(Frequency::parse("weekly"), Some(Frequency::Weekly));
assert_eq!(Frequency::parse(" Monthly "), Some(Frequency::Monthly));
assert_eq!(Frequency::parse("QUARTERLY"), Some(Frequency::Quarterly));
assert_eq!(Frequency::parse("annually"), Some(Frequency::Yearly));
assert_eq!(Frequency::parse("fortnightly"), None);
// Weekly: the kth occurrence is 7k days on.
let start = d(1, 1);
for (k, want) in [(0, d(1, 1)), (1, d(1, 8)), (2, d(1, 15)), (3, d(1, 22))] {
assert_eq!(Frequency::Weekly.nth(start, k), Some(want), "week {k}");
}
assert_eq!(Frequency::Quarterly.nth(start, 2), Some(d(7, 1)));
for (k, year) in [(1, 2027), (2, 2028), (3, 2029)] {
assert_eq!(
Frequency::Yearly.nth(start, k),
NaiveDate::from_ymd_opt(year, 1, 1),
"year {k}"
);
}
}
/// Regression (found by the store's refusal property, 2026-10-13): a
/// schedule whose total no line could carry, or finer than a cent, is
/// refused when registered, not at a month end.
#[test]
fn a_schedule_total_no_month_could_post_is_refused() {
let mut b = books();
let new = |total: &str| NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd(total),
start: d(1, 1),
months: 6,
balance_account: b.prepaid,
pl_account: b.insurance,
};
let huge = new("46116860184273879.03");
let sub_cent = new("120.005");
assert!(matches!(
b.l.register_schedule(huge),
Err(LedgerError::AmountOutOfRange)
));
assert!(matches!(
b.l.register_schedule(sub_cent),
Err(LedgerError::FinerThanMinorUnit { .. })
));
}
/// Regression: a schedule month refused after its entry was begun leaves
/// the ledger exactly as it was: no half-built draft, no id spent
/// (Ledger::atomically). The doomed schedule can only come from an older
/// journal now, so it is registered through the replay path.
#[test]
fn a_refused_schedule_month_leaves_nothing_behind() {
let mut b = books();
let id =
b.l.replay_register_schedule(NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("46116860184273879.03"),
start: d(1, 1),
months: 6,
balance_account: b.prepaid,
pl_account: b.insurance,
})
.unwrap();
let before = b.l.clone();
assert!(b.l.post_schedule_month(id, d(1, 31)).is_err());
assert!(
b.l.differs_from(&before).is_empty(),
"{:?}",
b.l.differs_from(&before)
);
}
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
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
File diff suppressed because it is too large. Load diff
+693
View File
@@ -0,0 +1,693 @@
//! Spec-derived tests for an asset's federal tax basis (rules.md B-25):
//! `TaxBasisRule`, `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`,
//! `Asset::tax_basis`, `Asset::built_in_gain`, and the tax fields of
//! `reports::fixed_asset_register`.
//!
//! Every expected value comes from the written specification: the doc
//! comments and INVARIANTS/SCENARIOS blocks on those items, and the
//! vendored sources (`docs/spec/sources/irs-partnership-basis.md`): cost
//! basis (IRC §1012(a)), carryover of the contributor's adjusted basis
//! (IRC §723), the lesser of fair market value and adjusted basis on a
//! conversion from personal use (Treas. Reg. §1.168(i)-4(b)(1)), and
//! built-in gain or loss as fair market value less the contributor's basis
//! (IRC §704(c)). Amounts are worked by hand.
use std::str::FromStr;
use chrono::NaiveDate;
use ledger_core::{
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
TaxBasis, TaxBasisRule, fixed_asset_register,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
// ---------------------------------------------------------------- helpers
fn d(s: &str) -> Decimal {
Decimal::from_str(s).expect("decimal literal")
}
fn usd(s: &str) -> Money {
Money::new(d(s), Currency::USD)
}
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).expect("valid date")
}
struct Accts {
equipment: AccountId,
accum: AccountId,
depr_expense: AccountId,
}
/// A USD ledger with a fixed-asset chart, funded with $50,000 cash.
fn fixture() -> (Ledger, Accts) {
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, name: &str, ty: AccountType| {
l.add_account(code, name, ty, None, false).unwrap()
};
let cash = acct("1000", "Cash", AccountType::Asset);
let equipment = acct("1500", "Equipment", AccountType::Asset);
let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset);
let capital = acct("3000", "Owner Capital", AccountType::Equity);
let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense);
let e = l.try_create_entry(date(2026, 1, 1), "funding").unwrap();
l.add_line(e, cash, Side::Debit, usd("50000.00")).unwrap();
l.add_line(e, capital, Side::Credit, usd("50000.00"))
.unwrap();
l.post_entry(e).unwrap();
(
l,
Accts {
equipment,
accum,
depr_expense,
},
)
}
fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId {
l.register_asset(NewAsset {
description: description.to_string(),
purchase_date: date(2026, 1, 1),
cost: usd(cost),
salvage_value: usd("0.00"),
useful_life_months: 36,
cost_account: a.equipment,
accum_depr_account: a.accum,
expense_account: a.depr_expense,
})
.unwrap()
}
fn tb(rule: TaxBasisRule, basis: Option<&str>) -> TaxBasis {
TaxBasis {
rule,
contributor_basis: basis.map(d),
}
}
/// A ledger with one asset of book cost (fair market value) `cost`.
fn one(cost: &str) -> (Ledger, AssetId) {
let (mut l, a) = fixture();
let id = register(&mut l, &a, "Laptop", cost);
(l, id)
}
fn set(l: &mut Ledger, id: AssetId, rule: TaxBasisRule, basis: Option<&str>) {
l.set_asset_tax_basis(id, tb(rule, basis)).unwrap();
}
const RULES: [TaxBasisRule; 3] = [
TaxBasisRule::Cost,
TaxBasisRule::Carryover,
TaxBasisRule::PersonalUseConversion,
];
// ---------------------------------------------------------------- TaxBasisRule
/// TaxBasisRule::key: the wire names `cost`, `carryover`,
/// `personal_use_conversion`.
#[test]
fn rule_keys_are_the_documented_wire_names() {
assert_eq!(TaxBasisRule::Cost.key(), "cost");
assert_eq!(TaxBasisRule::Carryover.key(), "carryover");
assert_eq!(
TaxBasisRule::PersonalUseConversion.key(),
"personal_use_conversion"
);
}
/// TaxBasisRule::parse: the rule named `key`; round-trips with key().
#[test]
fn rule_parse_round_trips_with_key() {
for r in RULES {
assert_eq!(TaxBasisRule::parse(r.key()), Some(r));
}
}
/// Anything other than a documented wire name is no rule.
#[test]
fn rule_parse_refuses_other_names() {
for bad in [
"",
"Cost",
"COST",
" cost",
"cost ",
"carry_over",
"personal-use-conversion",
"PersonalUseConversion",
"fmv",
] {
assert_eq!(TaxBasisRule::parse(bad), None, "{bad:?}");
}
}
/// The serde names are snake_case, the same as the wire names.
#[test]
fn rule_serde_names_are_the_wire_names() {
for r in RULES {
let j = serde_json::to_value(r).unwrap();
assert_eq!(j, serde_json::Value::String(r.key().to_string()));
let back: TaxBasisRule = serde_json::from_value(j).unwrap();
assert_eq!(back, r);
}
}
// ---------------------------------------------------------------- set / check
/// SCENARIO 1: an unknown asset is refused with UnknownAsset.
#[test]
fn an_unknown_asset_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
let unknown = id + 99;
for t in [
tb(TaxBasisRule::Cost, None),
tb(TaxBasisRule::Carryover, Some("500.00")),
] {
assert!(matches!(
l.check_asset_tax_basis(unknown, t),
Err(LedgerError::UnknownAsset(x)) if x == unknown
));
assert!(matches!(
l.set_asset_tax_basis(unknown, t),
Err(LedgerError::UnknownAsset(x)) if x == unknown
));
}
assert_eq!(l, before, "INVARIANT 3");
}
/// INVARIANT 1: Cost takes no contributor basis.
#[test]
fn cost_with_a_contributor_basis_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for b in ["0.00", "500.00", "1000.00"] {
let t = tb(TaxBasisRule::Cost, Some(b));
assert!(matches!(
l.check_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
assert!(matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
}
assert_eq!(l, before, "INVARIANT 3");
assert_eq!(l.get_asset(id).unwrap().tax, None);
}
/// INVARIANT 1: Carryover and PersonalUseConversion require one.
#[test]
fn a_contribution_without_a_contributor_basis_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
let t = tb(r, None);
assert!(matches!(
l.check_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
assert!(matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
}
assert_eq!(l, before, "INVARIANT 3");
}
/// INVARIANT 2: a contributor basis is non-negative.
#[test]
fn a_negative_contributor_basis_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
for b in ["-0.01", "-1", "-500.00"] {
let t = tb(r, Some(b));
assert!(
matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
),
"{r:?} {b}"
);
assert!(l.check_asset_tax_basis(id, t).is_err());
}
}
assert_eq!(l, before, "INVARIANT 3");
}
/// INVARIANT 2: in cents at most.
#[test]
fn more_than_two_decimal_places_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
for b in ["500.001", "0.005", "1.2345"] {
let t = tb(r, Some(b));
assert!(
matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
),
"{r:?} {b}"
);
assert!(l.check_asset_tax_basis(id, t).is_err());
}
}
assert_eq!(l, before, "INVARIANT 3");
}
/// Valid records: zero is non-negative; whole dollars and one decimal
/// place are "in cents at most". check agrees with set, and a valid set
/// changes nothing but the asset's tax record (book figures don't change).
#[test]
fn valid_records_are_accepted_and_book_figures_stay() {
for (r, b) in [
(TaxBasisRule::Cost, None),
(TaxBasisRule::Carryover, Some("0")),
(TaxBasisRule::Carryover, Some("0.00")),
(TaxBasisRule::Carryover, Some("750")),
(TaxBasisRule::PersonalUseConversion, Some("750.5")),
(TaxBasisRule::PersonalUseConversion, Some("750.50")),
(TaxBasisRule::PersonalUseConversion, Some("2500.00")),
] {
let (mut l, id) = one("1000.00");
let t = tb(r, b);
l.check_asset_tax_basis(id, t).unwrap();
let before = l.get_asset(id).unwrap().clone();
l.set_asset_tax_basis(id, t).unwrap();
let after = l.get_asset(id).unwrap();
assert_eq!(after.tax, Some(t), "{r:?} {b:?}");
// Book figures don't change.
let mut same = after.clone();
same.tax = before.tax;
assert_eq!(same, before, "{r:?} {b:?}");
}
}
/// SCENARIO 2: set again, the new record replaces the old.
#[test]
fn setting_again_replaces() {
let (mut l, id) = one("1000.00");
set(&mut l, id, TaxBasisRule::Cost, None);
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("1000.00")));
set(&mut l, id, TaxBasisRule::Carryover, Some("400.00"));
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax, Some(tb(TaxBasisRule::Carryover, Some("400.00"))));
assert_eq!(a.tax_basis(), Some(d("400.00")));
set(
&mut l,
id,
TaxBasisRule::PersonalUseConversion,
Some("1200.00"),
);
let a = l.get_asset(id).unwrap();
assert_eq!(
a.tax,
Some(tb(TaxBasisRule::PersonalUseConversion, Some("1200.00")))
);
assert_eq!(a.tax_basis(), Some(d("1000.00")));
set(&mut l, id, TaxBasisRule::Cost, None);
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax, Some(tb(TaxBasisRule::Cost, None)));
assert_eq!(a.built_in_gain(), None);
}
/// INVARIANT 3: a refusal after a valid record leaves that record.
#[test]
fn a_refusal_keeps_the_earlier_record() {
let (mut l, id) = one("1000.00");
set(&mut l, id, TaxBasisRule::Carryover, Some("300.00"));
let before = l.clone();
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Cost, Some("1.00")))
.is_err()
);
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, None))
.is_err()
);
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("-1.00")))
.is_err()
);
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("1.001")))
.is_err()
);
assert_eq!(l, before);
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("300.00")));
}
/// Setting one asset's record leaves the others alone.
#[test]
fn a_record_belongs_to_one_asset() {
let (mut l, a) = fixture();
let x = register(&mut l, &a, "Laptop", "1000.00");
let y = register(&mut l, &a, "Desk", "400.00");
set(&mut l, x, TaxBasisRule::Carryover, Some("800.00"));
assert_eq!(l.get_asset(y).unwrap().tax, None);
assert_eq!(l.get_asset(y).unwrap().tax_basis(), None);
}
// ---------------------------------------------------------------- tax_basis
/// Not recorded: None (and no built-in gain).
#[test]
fn no_record_means_no_tax_basis() {
let (l, id) = one("1000.00");
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax, None);
assert_eq!(a.tax_basis(), None);
assert_eq!(a.built_in_gain(), None);
}
/// INVARIANT 1: Cost: the book cost (IRC §1012(a)).
#[test]
fn cost_basis_is_the_book_cost() {
let (mut l, id) = one("1234.56");
set(&mut l, id, TaxBasisRule::Cost, None);
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis(), Some(d("1234.56")));
// A purchase has no built-in gain.
assert_eq!(a.built_in_gain(), None);
}
/// INVARIANT 2: Carryover: the contributor's adjusted basis (IRC §723),
/// whether above or below fair market value.
#[test]
fn carryover_basis_is_the_contributors_basis() {
for (fmv, basis, gain) in [
("1000.00", "600.00", "400.00"), // built-in gain
("1000.00", "1500.00", "-500.00"), // built-in loss
("1000.00", "1000.00", "0.00"), // boundary
("1000.00", "0.00", "1000.00"), // fully depreciated
] {
let (mut l, id) = one(fmv);
set(&mut l, id, TaxBasisRule::Carryover, Some(basis));
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis(), Some(d(basis)), "{fmv} {basis}");
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
}
}
/// INVARIANT 3: PersonalUseConversion: min(book cost, contributor's basis)
/// (Treas. Reg. §1.168(i)-4(b)(1): "the lesser of its fair market value or
/// its adjusted depreciable basis").
#[test]
fn personal_use_conversion_is_the_lesser_of_value_and_basis() {
for (fmv, basis, tax, gain) in [
// Paid 2,000, worth 1,200 now: basis 1,200; built-in loss −800.
("1200.00", "2000.00", "1200.00", "-800.00"),
// Paid 300, worth 450: basis 300; built-in gain 150.
("450.00", "300.00", "300.00", "150.00"),
// Boundary: value equals basis.
("999.99", "999.99", "999.99", "0.00"),
// A cent either side of the boundary.
("1000.00", "1000.01", "1000.00", "-0.01"),
("1000.00", "999.99", "999.99", "0.01"),
] {
let (mut l, id) = one(fmv);
set(&mut l, id, TaxBasisRule::PersonalUseConversion, Some(basis));
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis(), Some(d(tax)), "{fmv} {basis}");
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
}
}
/// built_in_gain: FMV (book cost) less the contributor's basis; positive
/// for a gain, negative for a loss, zero at FMV == basis, regardless of
/// which contribution rule (the definition does not depend on the rule).
#[test]
fn built_in_gain_sign_and_boundary() {
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
let (mut l, id) = one("800.00");
set(&mut l, id, r, Some("500.00"));
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
assert_eq!(g, d("300.00"));
assert!(g > Decimal::ZERO, "{r:?}: FMV > basis is a gain");
set(&mut l, id, r, Some("1100.00"));
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
assert_eq!(g, d("-300.00"));
assert!(g < Decimal::ZERO, "{r:?}: basis > FMV is a loss");
set(&mut l, id, r, Some("800.00"));
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
assert!(g.is_zero(), "{r:?}: FMV == basis is neither");
}
}
// ---------------------------------------------------------------- register
struct RegisterBooks {
l: Ledger,
bought: AssetId,
carried: AssetId,
converted: AssetId,
unrecorded: AssetId,
sold: AssetId,
}
/// Five assets: a purchase at cost, a carryover contribution, a
/// personal-use conversion, one with no tax basis, and one disposed (with
/// a tax basis, which the totals must leave out).
fn register_books() -> RegisterBooks {
let (mut l, a) = fixture();
let bought = register(&mut l, &a, "Server", "3000.00");
let carried = register(&mut l, &a, "Printer", "500.00");
let converted = register(&mut l, &a, "Laptop", "1200.00");
let unrecorded = register(&mut l, &a, "Desk", "700.00");
let sold = register(&mut l, &a, "Old monitor", "360.00");
set(&mut l, bought, TaxBasisRule::Cost, None);
set(&mut l, carried, TaxBasisRule::Carryover, Some("650.25"));
set(
&mut l,
converted,
TaxBasisRule::PersonalUseConversion,
Some("2000.00"),
);
set(&mut l, sold, TaxBasisRule::Cost, None);
l.dispose_asset(sold, date(2026, 3, 31), usd("100.00"))
.unwrap();
RegisterBooks {
l,
bought,
carried,
converted,
unrecorded,
sold,
}
}
/// INVARIANT 5: each row's tax fields are the asset's.
#[test]
fn register_rows_carry_the_assets_tax_fields() {
let b = register_books();
let r = fixed_asset_register(&b.l, date(2026, 6, 30));
assert_eq!(r.rows.len(), 5);
for row in &r.rows {
let a = b.l.get_asset(row.asset_id).unwrap();
assert_eq!(row.tax_rule, a.tax.map(|t| t.rule));
assert_eq!(
row.contributor_basis,
a.tax.and_then(|t| t.contributor_basis)
);
assert_eq!(row.tax_basis, a.tax_basis());
assert_eq!(row.built_in_gain, a.built_in_gain());
}
let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap();
// By hand.
let x = row(b.bought);
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
assert_eq!(x.contributor_basis, None);
assert_eq!(x.tax_basis, Some(d("3000.00")));
assert_eq!(x.built_in_gain, None);
let x = row(b.carried);
assert_eq!(x.tax_rule, Some(TaxBasisRule::Carryover));
assert_eq!(x.contributor_basis, Some(d("650.25")));
assert_eq!(x.tax_basis, Some(d("650.25")));
assert_eq!(x.built_in_gain, Some(d("-150.25")));
let x = row(b.converted);
assert_eq!(x.tax_rule, Some(TaxBasisRule::PersonalUseConversion));
assert_eq!(x.contributor_basis, Some(d("2000.00")));
assert_eq!(x.tax_basis, Some(d("1200.00")));
assert_eq!(x.built_in_gain, Some(d("-800.00")));
let x = row(b.unrecorded);
assert_eq!(x.tax_rule, None);
assert_eq!(x.contributor_basis, None);
assert_eq!(x.tax_basis, None);
assert_eq!(x.built_in_gain, None);
// A disposed asset's row still shows its record.
let x = row(b.sold);
assert!(x.disposed.is_some());
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
assert_eq!(x.tax_basis, Some(d("360.00")));
}
/// INVARIANT 3: total_tax_basis sums held assets with a basis, exactly;
/// missing_tax_basis counts held ones without; disposed excluded.
#[test]
fn register_tax_totals_cover_held_assets_only() {
let b = register_books();
let r = fixed_asset_register(&b.l, date(2026, 6, 30));
// 3000.00 + 650.25 + 1200.00 (the disposed 360.00 left out).
assert_eq!(r.total_tax_basis, d("4850.25"));
assert_eq!(r.missing_tax_basis, 1);
// Before the disposal date the old monitor is still held.
let r = fixed_asset_register(&b.l, date(2026, 2, 28));
assert_eq!(r.total_tax_basis, d("5210.25"));
assert_eq!(r.missing_tax_basis, 1);
}
/// A disposed asset without a tax basis isn't counted as missing.
#[test]
fn a_disposed_asset_without_a_basis_is_not_missing() {
let (mut l, a) = fixture();
let x = register(&mut l, &a, "Laptop", "1000.00");
let y = register(&mut l, &a, "Desk", "400.00");
l.dispose_asset(y, date(2026, 3, 31), usd("0.00")).unwrap();
let r = fixed_asset_register(&l, date(2026, 6, 30));
assert_eq!(r.missing_tax_basis, 1);
assert_eq!(r.total_tax_basis, Decimal::ZERO);
set(&mut l, x, TaxBasisRule::Carryover, Some("900.00"));
let r = fixed_asset_register(&l, date(2026, 6, 30));
assert_eq!(r.missing_tax_basis, 0);
assert_eq!(r.total_tax_basis, d("900.00"));
}
/// No assets: zero total, none missing.
#[test]
fn an_empty_register_has_no_tax_basis() {
let (l, _) = fixture();
let r = fixed_asset_register(&l, date(2026, 6, 30));
assert!(r.rows.is_empty());
assert_eq!(r.total_tax_basis, Decimal::ZERO);
assert_eq!(r.missing_tax_basis, 0);
}
/// Recording tax basis leaves the book totals alone.
#[test]
fn tax_basis_does_not_move_book_totals() {
let (mut l, a) = fixture();
let x = register(&mut l, &a, "Laptop", "1200.00");
let before = fixed_asset_register(&l, date(2026, 6, 30));
set(
&mut l,
x,
TaxBasisRule::PersonalUseConversion,
Some("2000.00"),
);
let after = fixed_asset_register(&l, date(2026, 6, 30));
assert_eq!(after.total_cost, before.total_cost);
assert_eq!(after.total_accumulated, before.total_accumulated);
assert_eq!(after.total_book_value, before.total_book_value);
assert_eq!(after.ledger_cost, before.ledger_cost);
assert_eq!(after.ledger_accumulated, before.ledger_accumulated);
}
// ---------------------------------------------------------------- proptest
fn cents() -> impl Strategy<Value = Decimal> {
(0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
}
fn positive_cents() -> impl Strategy<Value = Decimal> {
(1i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Asset::tax_basis INVARIANTS 1–3 and built_in_gain over random
/// costs, bases, and rules; register totals agree.
#[test]
fn prop_tax_basis_follows_its_rule(
assets in proptest::collection::vec((positive_cents(), cents(), 0usize..4), 1..8)
) {
let (mut l, a) = fixture();
let mut expected_total = Decimal::ZERO;
let mut expected_missing = 0usize;
for (i, (cost, basis, rule)) in assets.iter().enumerate() {
let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string());
let want = match rule {
0 => {
set(&mut l, id, TaxBasisRule::Cost, None);
Some((*cost, None))
}
1 => {
l.set_asset_tax_basis(id, TaxBasis {
rule: TaxBasisRule::Carryover,
contributor_basis: Some(*basis),
}).unwrap();
Some((*basis, Some(*cost - *basis)))
}
2 => {
l.set_asset_tax_basis(id, TaxBasis {
rule: TaxBasisRule::PersonalUseConversion,
contributor_basis: Some(*basis),
}).unwrap();
Some((if cost < basis { *cost } else { *basis }, Some(*cost - *basis)))
}
_ => None,
};
let asset = l.get_asset(id).unwrap();
match want {
Some((tax, gain)) => {
prop_assert_eq!(asset.tax_basis(), Some(tax));
prop_assert_eq!(asset.built_in_gain(), gain);
expected_total += tax;
}
None => {
prop_assert_eq!(asset.tax_basis(), None);
expected_missing += 1;
}
}
if let Some(t) = asset.tax_basis() {
// Personal-use conversion never exceeds fair market value.
if *rule == 2 {
prop_assert!(t <= *cost);
prop_assert!(t <= *basis);
}
}
}
let r = fixed_asset_register(&l, date(2026, 6, 30));
prop_assert_eq!(r.total_tax_basis, expected_total);
prop_assert_eq!(r.missing_tax_basis, expected_missing);
}
/// check agrees with set, and a refused record changes nothing.
#[test]
fn prop_check_agrees_with_set(
cents in -1_000_000i64..1_000_000,
scale in 0u32..5,
rule in 0usize..3,
some in any::<bool>(),
) {
let (mut l, id) = one("1000.00");
let basis = some.then(|| Decimal::new(cents, scale));
let t = TaxBasis { rule: RULES[rule], contributor_basis: basis };
let before = l.clone();
let checked = l.check_asset_tax_basis(id, t);
let done = l.set_asset_tax_basis(id, t);
prop_assert_eq!(checked.is_ok(), done.is_ok());
// The spec, independently: INVARIANTS 1 and 2.
let valid = match (RULES[rule], basis) {
(TaxBasisRule::Cost, None) => true,
(TaxBasisRule::Cost, Some(_)) => false,
(_, None) => false,
(_, Some(b)) => b >= Decimal::ZERO && b.normalize().scale() <= 2,
};
prop_assert_eq!(done.is_ok(), valid, "{:?} {:?}", RULES[rule], basis);
if done.is_err() {
prop_assert_eq!(&l, &before);
} else {
prop_assert_eq!(l.get_asset(id).unwrap().tax, Some(t));
}
}
}
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc b177aed2b1d69b6a0abb37b170ec5b9efc73d7fae49bace065c15c819ecca711 # shrinks to months = [(0, 0, 0, 507, 37392), (0, 0, 0, 9054, 3952), (0, 0, 0, 34144, 7979)], closes = [false, true, false, false, false, false, false, false, false, false, false, false], split = 14
+816 -8
View File
@@ -15,7 +15,7 @@
use chrono::NaiveDate;
use ledger_core::reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, unclosed_earnings,
};
use ledger_core::{
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
@@ -127,7 +127,7 @@ fn profit_and_loss_is_scoped_to_the_period() {
(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();
let all = unclosed_earnings(&b.ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
// One class: only the tagged software line.
@@ -214,6 +214,7 @@ fn a_closed_period_still_reports_its_earnings() {
end: d(1, 31),
cash_accounts: vec![b.cash],
financing_accounts: vec![],
investing_accounts: vec![],
},
)
.unwrap();
@@ -249,6 +250,7 @@ fn cash_flow_lines_are_in_code_order() {
end: d(12, 31),
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
},
)
.unwrap();
@@ -469,16 +471,20 @@ fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
);
// February begins where January ended, whether or not January was
// closed to retained earnings. 100.01 splits 50.01 / 50.00.
// closed to retained earnings (still undistributed either way).
// Cumulative split (docs/testing.md #7): everything to date, 666.67
// + 100.01 = 766.68, is 383.34 each, so Alice's February share is
// 383.34 - 333.34 = 50.00 and Bob's 383.34 - 333.33 = 50.01.
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
let (alice, bob) = (("50.00", "983.34"), ("50.01", "683.34"));
assert_eq!(
row(&feb.members[0]),
(
cents("933.34"),
cents("0"),
cents("50.01"),
cents(alice.0),
cents("0"),
cents("983.35")
cents(alice.1)
)
);
assert_eq!(
@@ -486,9 +492,9 @@ fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
(
cents("733.33"),
cents("0"),
cents("50.00"),
cents(bob.0),
cents("100"),
cents("683.33")
cents(bob.1)
)
);
assert_eq!(feb.total.ending, cents("1666.68"));
@@ -529,11 +535,17 @@ fn cash_flow_lines_name_their_account() {
end: d(12, 31),
cash_accounts: vec![1],
financing_accounts: vec![],
investing_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")]);
// Gross (ASC 230-10-45-7): 3001 contributed and took a distribution,
// so it has a line for each.
assert_eq!(accounts, [Some("3000"), Some("3001"), Some("3001")]);
assert!(cf.financing[1].label.starts_with("Owner contributions"));
assert!(cf.financing[2].label.starts_with("Owner distributions"));
assert!(cf.financing[1].amount > Decimal::ZERO && cf.financing[2].amount < Decimal::ZERO);
}
#[test]
@@ -888,3 +900,799 @@ fn partner_distribution_accounts_roll_into_capital() {
Err(LedgerError::InvalidMembers(_))
));
}
// ---------------------------------------------------------------------------
// Splitting odd cents among members: largest remainder (Stephen,
// 2026-10-13). Spec: periods::allocate.
// ---------------------------------------------------------------------------
fn split(amount: &str, shares: &[&str]) -> Vec<Decimal> {
let shares: Vec<Decimal> = shares.iter().map(|s| cents(s)).collect();
ledger_core::periods::allocate(cents(amount), &shares)
}
#[test]
fn an_odd_cent_goes_to_the_largest_remainder_not_the_last_member() {
// Three equal partners: the cent goes to the first (ties to the
// earlier), where the old rule always handed it to the last.
assert_eq!(
split("100.00", &["0.3333333333333333333333333333"; 3]),
[cents("33.34"), cents("33.33"), cents("33.33")]
);
// Unequal shares: 6.666... has the larger remainder.
assert_eq!(
split(
"10.00",
&[
"0.6666666666666666666666666667",
"0.3333333333333333333333333333"
]
),
[cents("6.67"), cents("3.33")]
);
// Equal remainders (0.035 each): ties go to the earlier member.
assert_eq!(
split("0.07", &["0.5", "0.5"]),
[cents("0.04"), cents("0.03")]
);
// 0.015 and 0.035: half a cent each, a tie again.
assert_eq!(
split("0.05", &["0.3", "0.7"]),
[cents("0.02"), cents("0.03")]
);
// 0.003 and 0.007: the later member's remainder is larger, so the
// cent is theirs.
assert_eq!(
split("0.01", &["0.3", "0.7"]),
[cents("0.00"), cents("0.01")]
);
// One cent, two ways: someone gets it, nobody gets a fraction.
assert_eq!(
split("0.01", &["0.5", "0.5"]),
[cents("0.01"), cents("0.00")]
);
}
#[test]
fn a_loss_splits_as_the_negation_of_the_same_gain() {
assert_eq!(
split("-100.00", &["0.3333333333333333333333333333"; 3]),
[cents("-33.34"), cents("-33.33"), cents("-33.33")]
);
}
#[test]
fn closes_journaled_under_the_old_rule_replay_with_it() {
// Contract: a close made before 2026-10-13 (last member absorbs) must
// replay to the same lines forever.
let shares = [cents("0.3333333333333333333333333333"); 3];
assert_eq!(
ledger_core::periods::allocate_last_absorbs(cents("100.00"), &shares),
[cents("33.33"), cents("33.33"), cents("33.34")]
);
}
// ---------------------------------------------------------------------------
// Boundaries, signs, and labels found untested by mutation testing
// (cargo-mutants on src/reports.rs). Expected values are hand-computed.
// ---------------------------------------------------------------------------
#[test]
fn the_balance_checks_catch_an_imbalance() {
let b = books(false);
let mut tb = trial_balance_as_of(&b.ledger, d(2, 28));
assert!(tb.balanced());
tb.total_credits += cents("0.01");
assert!(!tb.balanced(), "a one-cent difference is out of balance");
let mut bs = balance_sheet(&b.ledger).unwrap();
assert!(bs.balanced());
bs.liabilities += cents("0.01");
assert!(!bs.balanced());
let mut bss = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert!(bss.balanced());
bss.total_assets -= cents("0.01");
assert!(!bss.balanced());
}
#[test]
fn a_one_day_general_ledger_is_valid_and_a_backwards_one_is_not() {
let b = books(false);
// Jan 15 only: cash begins at the Jan 2 contribution, then the 2,500
// client payment lands.
let gl = general_ledger(&b.ledger, d(1, 15), d(1, 15)).unwrap();
let cash = gl.accounts.iter().find(|a| a.code == "1000").unwrap();
assert_eq!(cash.beginning_balance, dec(10_000));
assert_eq!(cash.postings.len(), 1);
assert_eq!(cash.ending_balance, dec(12_500));
assert!(matches!(
general_ledger(&b.ledger, d(1, 16), d(1, 15)),
Err(LedgerError::InvalidDateRange { .. })
));
}
fn cf_params(start: NaiveDate, end: NaiveDate, cash: AccountId) -> CashFlowParams {
CashFlowParams {
start,
end,
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
}
}
fn reconciles(cf: &ledger_core::CashFlowStatement) {
// B-14 / cash_flow INVARIANT 1: the three sections sum to the change in
// cash, exactly.
assert_eq!(
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
cf.net_change_in_cash
);
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
}
#[test]
fn cash_flow_starts_counting_on_the_start_date() {
let b = books(false);
// A one-day period is a valid range. The Jan 15 payment is in it, not
// in the beginning balance.
let cf = cash_flow(&b.ledger, &cf_params(d(1, 15), d(1, 15), b.cash)).unwrap();
assert_eq!(cf.cash_beginning, dec(10_000));
assert_eq!(cf.cash_ending, dec(12_500));
assert_eq!(cf.net_income, dec(2_500));
reconciles(&cf);
// Backwards is refused.
assert!(matches!(
cash_flow(&b.ledger, &cf_params(d(1, 16), d(1, 15), b.cash)),
Err(LedgerError::InvalidDateRange { .. })
));
}
#[test]
fn cash_flow_labels_say_which_way_each_balance_moved() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let ar = l
.add_account("1100", "Receivable", AccountType::Asset, None, false)
.unwrap();
let ap = l
.add_account("2000", "Payable", AccountType::Liability, None, false)
.unwrap();
let loan = l
.add_account("2500", "Loan", AccountType::Liability, None, false)
.unwrap();
let capital = l
.add_account("3000", "Capital", 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();
// January: every account starts February with a balance.
post(&mut l, d(1, 2), "Contribution", cash, capital, 10_000);
post(&mut l, d(1, 3), "Invoice", ar, rev, 1_000);
post(&mut l, d(1, 4), "Bill", exp, ap, 500);
post(&mut l, d(1, 5), "Borrow", cash, loan, 2_000);
// February: every balance goes up.
post(&mut l, d(2, 3), "Invoice", ar, rev, 100);
post(&mut l, d(2, 4), "Bill", exp, ap, 50);
post(&mut l, d(2, 5), "Borrow", cash, loan, 300);
post(&mut l, d(2, 6), "Contribution", cash, capital, 400);
// March: every balance comes back down.
post(&mut l, d(3, 3), "Collect", cash, ar, 100);
post(&mut l, d(3, 4), "Pay bill", ap, cash, 50);
post(&mut l, d(3, 5), "Repay", loan, cash, 300);
post(&mut l, d(3, 6), "Distribution", capital, cash, 400);
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
v.iter().map(|l| (l.label.clone(), l.amount)).collect()
};
let s = |x: &str| x.to_string();
let params = |start, end| CashFlowParams {
start,
end,
cash_accounts: vec![cash],
financing_accounts: vec![loan],
investing_accounts: vec![],
};
// February: net income 100 - 50 = 50; receivable up 100 (cash out),
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400.
let feb = cash_flow(&l, &params(d(2, 1), d(2, 28))).unwrap();
assert_eq!(feb.net_income, dec(50));
assert_eq!(
lines(&feb.working_capital),
[
(s("Increase in 1100 Receivable"), dec(-100)),
(s("Increase in 2000 Payable"), dec(50)),
]
);
assert_eq!(
lines(&feb.financing),
[
(s("Proceeds from 2500 Loan"), dec(300)),
(s("Owner contributions — 3000 Capital"), dec(400)),
]
);
assert_eq!(feb.cash_from_operations, dec(0));
assert_eq!(feb.net_change_in_cash, dec(700));
reconciles(&feb);
// March: the mirror image. Collected 100, paid 50, repaid 300,
// distributed 400.
let mar = cash_flow(&l, &params(d(3, 1), d(3, 31))).unwrap();
assert_eq!(mar.net_income, dec(0));
assert_eq!(
lines(&mar.working_capital),
[
(s("Decrease in 1100 Receivable"), dec(100)),
(s("Decrease in 2000 Payable"), dec(-50)),
]
);
assert_eq!(
lines(&mar.financing),
[
(s("Repayment of 2500 Loan"), dec(-300)),
(s("Owner distributions — 3000 Capital"), dec(-400)),
]
);
assert_eq!(mar.net_change_in_cash, dec(-650));
reconciles(&mar);
}
#[test]
fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equipment = l
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let accum = l
.add_account("1501", "Accum Depr", AccountType::Asset, None, false)
.unwrap();
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let depr = l
.add_account("6500", "Depreciation", AccountType::Expense, None, false)
.unwrap();
let loss = l
.add_account(
"6900",
"Loss on Disposal",
AccountType::Expense,
None,
false,
)
.unwrap();
l.register_asset(ledger_core::NewAsset {
description: "Laptop".to_string(),
purchase_date: d(1, 10),
cost: usd(1_000),
salvage_value: usd(0),
useful_life_months: 10,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
post(&mut l, d(1, 5), "Contribution", cash, capital, 5_000);
post(&mut l, d(1, 10), "Buy laptop", equipment, cash, 1_000);
post(&mut l, d(1, 31), "Depreciation", depr, accum, 100);
// Sold for 600 at a book value of 900: a 300 loss.
let e = l.create_entry(d(2, 15), "Sell laptop");
l.add_line(e, cash, Side::Debit, usd(600)).unwrap();
l.add_line(e, accum, Side::Debit, usd(100)).unwrap();
l.add_line(e, loss, Side::Debit, usd(300)).unwrap();
l.add_line(e, equipment, Side::Credit, usd(1_000)).unwrap();
l.post_entry(e).unwrap();
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
v.iter().map(|l| (l.label.clone(), l.amount)).collect()
};
// February: net income -300; the loss is added back (positive, it used
// no cash), so operations is 0; the 600 proceeds are investing.
let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap();
assert_eq!(feb.net_income, dec(-300));
assert_eq!(
lines(&feb.working_capital),
[(
"Less: gain on sale of fixed assets (in investing)".to_string(),
dec(300)
)]
);
assert_eq!(feb.cash_from_operations, dec(0));
assert_eq!(
lines(&feb.investing),
[("Sale of fixed assets — 1000 Cash".to_string(), dec(600))]
);
assert_eq!(feb.net_change_in_cash, dec(600));
reconciles(&feb);
}
#[test]
fn a_contribution_on_the_start_date_is_a_contribution_not_beginning_capital() {
let (l, members) = partnership(false);
// The partnership fixture's contributions are dated Jan 2. Starting the
// statement that day must give the same rows as all of January.
let me = members_equity(&l, d(1, 2), d(1, 31), &members).unwrap();
let row = |c: &ledger_core::MemberEquityColumn| (c.beginning, c.contributions, c.ending);
assert_eq!(
row(&me.members[0]),
(cents("0"), cents("600"), cents("933.34"))
);
assert_eq!(
row(&me.members[1]),
(cents("0"), cents("400"), cents("733.33"))
);
}
#[test]
fn distribution_accounts_reduce_capital_and_returns_reduce_distributions() {
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", "Dist — Alice", AccountType::Equity, None, false)
.unwrap();
let dist_b = l
.add_account("3101", "Dist — Bob", AccountType::Equity, 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, 20), "Bob's distribution", dist_b, cash, 20);
// February: Bob takes 30 and hands 10 back: 20 distributed, net.
post(&mut l, d(2, 5), "Bob's distribution", dist_b, cash, 30);
post(&mut l, d(2, 6), "Bob returns some", cash, dist_b, 10);
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,
},
];
// No close: Bob's January distribution is still in his distributions
// account, and it comes off his capital, not the partnership's.
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
let row = |c: &ledger_core::MemberEquityColumn| {
(c.beginning, c.contributions, c.distributions, c.ending)
};
assert_eq!(row(&feb.members[0]), (dec(100), dec(0), dec(0), dec(100)));
assert_eq!(row(&feb.members[1]), (dec(80), dec(0), dec(20), dec(60)));
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()));
}
/// The cash flow fixture for financing and investing roles. Hand-computed:
///
/// | date | entry | Dr | Cr |
/// |--------|----------------------------------------|----------------|----------------|
/// | Mar 1 | bank loan 1,000 | 1000 Cash | 2500 Loan |
/// | Mar 5 | owner pays software 100 personally | 6000 Software | 2100 Due to owners |
/// | Mar 10 | lend a member 500 | 1400 Member loan | 1000 Cash |
/// | Mar 20 | repay 400 of the bank loan | 2500 Loan | 1000 Cash |
/// | Mar 25 | the member repays 200 | 1000 Cash | 1400 Member loan |
/// | Mar 28 | reimburse the owner 100 | 2100 Due to owners | 1000 Cash |
///
/// Cash: +1,000 - 500 - 400 + 200 - 100 = +200.
fn roles() -> (Ledger, CashFlowParams) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let member_loan = add("1400", AccountType::Asset);
let due_owners = add("2100", AccountType::Liability);
let loan = add("2500", AccountType::Liability);
let software = add("6000", AccountType::Expense);
post(&mut l, d(3, 1), "loan", cash, loan, 1000);
post(&mut l, d(3, 5), "software", software, due_owners, 100);
post(&mut l, d(3, 10), "lend", member_loan, cash, 500);
post(&mut l, d(3, 20), "repay", loan, cash, 400);
post(&mut l, d(3, 25), "collect", cash, member_loan, 200);
post(&mut l, d(3, 28), "reimburse", due_owners, cash, 100);
let params = CashFlowParams {
start: d(3, 1),
end: d(3, 31),
cash_accounts: vec![cash],
financing_accounts: vec![loan, due_owners],
investing_accounts: vec![member_loan],
};
(l, params)
}
fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> {
ls.iter()
.map(|l| (l.account.clone().unwrap_or_default(), l.amount))
.collect()
}
/// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash
/// receipts and cash payments during a period is more relevant than
/// information about the net amounts"), 230-10-45-14(b) (proceeds from
/// "other short- or long-term borrowing") and 45-15(2) ("Repayments of
/// amounts borrowed"): borrowing and repayment are separate lines.
#[test]
fn financing_is_gross_borrowing_apart_from_repayment() {
let (l, params) = roles();
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(
lines(&cf.financing),
[
("2100".into(), dec(100)),
("2100".into(), dec(-100)),
("2500".into(), dec(1000)),
("2500".into(), dec(-400)),
]
);
assert_eq!(cf.cash_from_financing, dec(600));
}
/// Oracle: ASC 230-10-20 ("Investing activities include making and
/// collecting loans") and 230-10-45-13 ("Disbursements for loans made by
/// the entity"): a loan to a member is investing, gross, not working
/// capital.
#[test]
fn a_loan_the_entity_makes_is_investing_gross() {
let (l, params) = roles();
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(
lines(&cf.investing),
[("1400".into(), dec(200)), ("1400".into(), dec(-500))]
);
assert!(cf.investing[0].label.starts_with("Collections on"));
assert!(cf.investing[1].label.starts_with("Payments for"));
assert!(
cf.working_capital
.iter()
.all(|w| w.account.as_deref() != Some("1400"))
);
assert_eq!(cf.cash_from_investing, dec(-300));
}
/// Oracle: PwC Viewpoint, Financial statement presentation 6.9.3.1
/// (constructive receipts and disbursements: cash a third party pays on
/// the entity's behalf is presented "as though the transaction took place
/// through the bank accounts of the reporting entity"). The owner paying
/// software personally is a borrowing (financing in) and an operating
/// payment (in net income); reimbursing the owner is a repayment.
/// Hand-computed: operating -100, investing -300, financing +600 = +200,
/// the change in cash.
#[test]
fn an_expense_the_owner_paid_is_borrowing_then_operating() {
let (l, params) = roles();
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(cf.net_income, dec(-100));
assert_eq!(cf.cash_from_operations, dec(-100));
assert_eq!(cf.net_change_in_cash, dec(200));
assert_eq!(
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
cf.net_change_in_cash
);
}
/// Gross lines net only within one entry: an entry that both debits and
/// credits a financing account by the same amount moves no cash and adds
/// no line.
#[test]
fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() {
let (mut l, params) = roles();
let loan = l.chart().find_by_code("2500").unwrap().id;
let e = l.create_entry(d(3, 30), "reclass within the loan");
l.add_line(e, loan, Side::Debit, usd(50)).unwrap();
l.add_line(e, loan, Side::Credit, usd(50)).unwrap();
l.post_entry(e).unwrap();
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(cf.cash_from_financing, dec(600));
assert_eq!(cf.financing.len(), 4);
}
mod chaining {
use super::*;
use ledger_core::{CloseTo, MemberClose};
use proptest::prelude::*;
/// One month of activity: (member, contribution cents, distribution
/// cents, revenue cents, expense cents).
type Month = (usize, i64, i64, i64, i64);
fn month() -> impl Strategy<Value = Month> {
(
0usize..3,
0i64..50_000,
0i64..20_000,
0i64..100_000,
0i64..80_000,
)
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
/// Oracle: members' equity INVARIANT 3a (docs/testing.md #7; Form
/// 1065 Schedule K-1 instructions, Item L: one running capital
/// balance per member). For random books, shares with odd cents,
/// months closed to the members or not, and any split date: each
/// member's ending on the first statement is their beginning on
/// the second, and the two statements together roll to the
/// one-statement figures; every member's incomes sum to net income.
#[test]
fn each_members_capital_chains_across_statements(
months in prop::collection::vec(month(), 1..12),
closes in prop::collection::vec(any::<bool>(), 12),
split in 1u32..330,
) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
add("3002", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
let exp = add("6000", AccountType::Expense);
let shares = [
Decimal::from_str("0.3333").unwrap(),
Decimal::from_str("0.3333").unwrap(),
Decimal::from_str("0.3334").unwrap(),
];
let cents = |n: i64| Money::new(Decimal::new(n, 2), Currency::USD);
let post2 = |l: &mut Ledger, date, dr, cr, n: i64| {
if n > 0 {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, cents(n)).unwrap();
l.add_line(e, cr, Side::Credit, cents(n)).unwrap();
l.post_entry(e).unwrap();
}
};
for (i, (who, contrib, dist, sales, costs)) in months.iter().enumerate() {
let m = i as u32 + 1;
post2(&mut l, d(m, 3), cash, caps[*who], *contrib);
post2(&mut l, d(m, 10), cash, rev, *sales);
post2(&mut l, d(m, 15), exp, cash, *costs);
post2(&mut l, d(m, 20), caps[*who], cash, *dist);
if closes[i] {
let end = if m == 12 { d(12, 31) } else { d(m + 1, 1).pred_opt().unwrap() };
let p = l.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap();
let to = CloseTo::Members(
caps.iter()
.zip(shares)
.map(|(&capital, share)| MemberClose { capital, share, distributions: None })
.collect(),
);
// Closes must go in order; a refused close just leaves
// the month open.
let _ = l.close_period_to(p, &to);
}
}
let members: Vec<MemberCapital> = caps
.iter()
.zip(shares)
.enumerate()
.map(|(i, (&capital_account, share))| MemberCapital {
name: format!("M{i}"),
capital_account,
distribution_account: None,
share,
})
.collect();
let mid = d(1, 1) + chrono::Days::new(u64::from(split));
let first = members_equity(&l, d(1, 1), mid, &members).unwrap();
let second = members_equity(&l, mid.succ_opt().unwrap(), d(12, 31), &members).unwrap();
let whole = members_equity(&l, d(1, 1), d(12, 31), &members).unwrap();
for i in 0..3 {
prop_assert_eq!(first.members[i].ending, second.members[i].beginning);
prop_assert_eq!(second.members[i].ending, whole.members[i].ending);
prop_assert_eq!(first.members[i].beginning, whole.members[i].beginning);
}
for st in [&first, &second, &whole] {
let incomes: Decimal = st.members.iter().map(|m| m.net_income).sum();
prop_assert_eq!(incomes, st.total.net_income);
for m in &st.members {
prop_assert_eq!(
m.beginning + m.contributions + m.net_income - m.distributions + m.other,
m.ending
);
}
}
}
}
}
mod cumulative_closes {
use super::*;
use ledger_core::{CloseTo, MemberClose};
/// Three months of one-cent income, 50/50, each month closed into the
/// members' capital.
fn three_cent_months(to: impl Fn(&[AccountId; 2]) -> CloseTo) -> (Ledger, [AccountId; 2]) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
for m in 1..=3 {
let e = l.create_entry(d(m, 10), "a cent");
l.add_line(
e,
cash,
Side::Debit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
Side::Credit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
let end = d(m + 1, 1).pred_opt().unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
.unwrap();
l.close_period_to(p, &to(&caps)).unwrap();
}
(l, caps)
}
fn half(caps: &[AccountId; 2]) -> Vec<MemberClose> {
caps.iter()
.map(|&capital| MemberClose {
capital,
share: cents("0.5"),
distributions: None,
})
.collect()
}
/// Oracle: rules.md B-13 (cumulative closes; docs/testing.md #12),
/// hand-computed. Closing each cent on its own gives the earlier member
/// every tie: A 0.03, B 0.00 after three months. Cumulatively: after
/// month 1 the run's 0.01 splits A 0.01 / B 0; after month 2, 0.02
/// splits 0.01 / 0.01, so month 2 gives B the cent; after month 3, 0.03
/// splits 0.02 / 0.01, so month 3 gives A the cent. A 0.02, B 0.01.
#[test]
fn a_run_of_closes_never_piles_up_rounding() {
let (l, caps) = three_cent_months(|c| CloseTo::MembersPerClose(half(c)));
let bal = |l: &Ledger, a| l.account_balance(a).unwrap();
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.03"), cents("0.00")),
"per close: the earlier member takes every tie"
);
let (l, caps) = three_cent_months(|c| CloseTo::Members(half(c)));
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.02"), cents("0.01")),
"cumulative: within a cent of half of 0.03 each"
);
}
proptest::proptest! {
/// Oracle: rules.md B-13, cumulative closes. For any run of monthly
/// profits and losses closed into three members at 33.33% /
/// 33.33% / 33.34%, each member's total from the run is within one
/// cent of their exact share of everything closed.
#[test]
fn each_member_stays_within_a_cent_of_their_share(
nets in proptest::collection::vec(-500_000i64..500_000, 1..12),
) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
add("3002", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
let exp = add("6000", AccountType::Expense);
let shares = [cents("0.3333"), cents("0.3333"), cents("0.3334")];
let to = CloseTo::Members(
caps.iter()
.zip(shares)
.map(|(&capital, share)| MemberClose { capital, share, distributions: None })
.collect(),
);
for (i, n) in nets.iter().enumerate() {
let m = i as u32 + 1;
if *n != 0 {
let amount = Money::new(Decimal::new(n.abs(), 2), Currency::USD);
let (dr, cr) = if *n > 0 { (cash, rev) } else { (exp, cash) };
let e = l.create_entry(d(m, 10), "month");
l.add_line(e, dr, Side::Debit, amount).unwrap();
l.add_line(e, cr, Side::Credit, amount).unwrap();
l.post_entry(e).unwrap();
}
let end = if m == 12 { d(12, 31) } else { d(m + 1, 1).pred_opt().unwrap() };
let p = l.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap();
l.close_period_to(p, &to).unwrap();
}
let total = Decimal::new(nets.iter().sum::<i64>(), 2);
for (cap, share) in caps.iter().zip(shares) {
let got = l.account_balance(*cap).unwrap();
proptest::prop_assert!((got - total * share).abs() < cents("0.01"), "{got} vs {}", total * share);
}
}
}
/// A run is closes with the same members and shares, back to back: a
/// close to anything else (or with other shares) ends it, and the next
/// members close starts afresh.
#[test]
fn a_different_close_ends_the_run() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
let cent = |l: &mut Ledger, m| {
let e = l.create_entry(d(m, 10), "a cent");
l.add_line(
e,
cash,
Side::Debit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
Side::Credit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
};
let close = |l: &mut Ledger, m: u32, to: &CloseTo| {
let end = d(m + 1, 1).pred_opt().unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
.unwrap();
l.close_period_to(p, to).unwrap();
};
// Month 1 to members (A gets the cent), month 2 to retained
// earnings (ends the run), month 3 to members again: a new run, so
// A gets that cent too.
cent(&mut l, 1);
close(&mut l, 1, &CloseTo::Members(half(&caps)));
cent(&mut l, 2);
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
cent(&mut l, 3);
close(&mut l, 3, &CloseTo::Members(half(&caps)));
assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02"));
assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0"));
}
}
+114
View File
@@ -228,3 +228,117 @@ fn forms_parse_from_common_spellings() {
}
assert_eq!(TaxForm::parse("990"), None);
}
#[test]
fn cost_of_goods_reduces_net_like_a_deduction() {
let mut l = books();
let cash = l.chart().find_by_code("1000").unwrap().id;
let cogs = l
.add_account("5000", "Cost of sales", AccountType::Expense, None, false)
.unwrap();
l.set_account_tax(cogs, Some("cost_of_goods_sold"), None)
.unwrap();
let e = l.create_entry(d(2, 1), "x");
let m = Money::new(dec("1000"), Currency::USD);
l.add_line(e, cogs, Side::Debit, m).unwrap();
l.add_line(e, cash, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
assert_eq!(ts.cost_of_goods.len(), 1);
assert_eq!(ts.cost_of_goods[0].book, dec("1000"));
assert_eq!(ts.cost_of_goods[0].tax, dec("1000"));
// The fixture's nets, less 1,000 of cost of goods sold.
assert_eq!(ts.book_net(), dec("4276.67"));
assert_eq!(ts.tax_net(), dec("4483.33"));
}
#[test]
fn forms_have_their_irs_names() {
let labels: Vec<&str> = TaxForm::ALL.iter().map(|f| f.label()).collect();
assert_eq!(
labels,
["Schedule C", "Form 1065", "Form 1120-S", "Form 1120"]
);
// A label parses back to its form.
for f in TaxForm::ALL {
assert_eq!(TaxForm::parse(f.label()), Some(f));
}
}
/// A small book with portfolio interest (125) and a donation (300) besides
/// sales (1,000), for where each lands by return.
fn interest_and_gift() -> 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, t, cat: &str| {
let id = l.add_account(code, code, t, None, false).unwrap();
l.set_account_tax(id, Some(cat), None).unwrap();
id
};
let sales = acct("4000", AccountType::Revenue, "gross_receipts");
let interest = acct("4200", AccountType::Revenue, "interest_income");
let gift = acct("6900", AccountType::Expense, "charitable_contributions");
let usd = |s: &str| Money::new(dec(s), Currency::USD);
for (dr, cr, amt) in [
(cash, sales, "1000"),
(cash, interest, "125"),
(gift, cash, "300"),
] {
let e = l.create_entry(d(2, 1), "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();
}
l
}
/// Oracle: docs/spec/sources/irs-tax-lines.md. On Forms 1065 and 1120-S,
/// Schedule K takes "only taxable portfolio interest", and charitable
/// contributions are among the "items ... must state separately": both
/// leave the ordinary-income net (1,000 here, not 825) and are listed
/// apart. On Form 1120 they stay in it (lines 5 and 19).
#[test]
fn pass_throughs_state_interest_and_gifts_separately() {
let l = interest_and_gift();
for form in [TaxForm::Form1065, TaxForm::Form1120S] {
let ts = tax_summary(&l, d(1, 1), d(12, 31), Some(form)).unwrap();
let keys: Vec<&str> = ts
.separately_stated
.iter()
.map(|x| x.category.as_str())
.collect();
assert_eq!(
keys,
["interest_income", "charitable_contributions"],
"{form:?}"
);
assert_eq!(ts.book_net(), dec("1000"), "{form:?}");
}
let ts = tax_summary(&l, d(1, 1), d(12, 31), Some(TaxForm::Form1120)).unwrap();
assert!(ts.separately_stated.is_empty());
assert_eq!(ts.book_net(), dec("825"));
}
/// Oracle: Schedule C instructions, Part V ("Do not include charitable
/// contributions"): the category is refused for a Schedule C business.
#[test]
fn a_sole_proprietor_cannot_deduct_a_gift_on_schedule_c() {
assert!(matches!(
check_category(
"charitable_contributions",
AccountType::Expense,
Some(TaxForm::ScheduleC)
),
Err(LedgerError::InvalidTaxCategory(_))
));
assert!(
check_category(
"charitable_contributions",
AccountType::Expense,
Some(TaxForm::Form1120)
)
.is_ok()
);
}
+1
View File
@@ -31,6 +31,7 @@ serde_json = "1"
[dev-dependencies]
chrono = "0.4"
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
proptest = "1"
[lints]
workspace = true
+42 -6
View File
@@ -21,6 +21,8 @@ pub struct PageState {
pub transactions: pages::transactions::State,
pub reports: pages::reports::State,
pub accounts: pages::accounts::State,
pub assets: pages::assets::State,
pub parties: pages::parties::State,
pub documents: pages::documents::State,
pub journal: pages::journal::State,
}
@@ -121,6 +123,8 @@ impl App {
"transactions" => Route::Transactions,
"reports" => Route::Reports(ReportKind::ProfitAndLoss),
"accounts" => Route::Accounts,
"assets" => Route::Assets,
"parties" => Route::Parties,
"documents" => Route::Documents,
"company" => Route::Company,
"journal" => Route::Journal,
@@ -170,6 +174,7 @@ impl App {
"vendor_1099" => ReportKind::Vendor1099,
"ar_aging" => ReportKind::ArAging,
"ap_aging" => ReportKind::ApAging,
"fixed_asset_register" => ReportKind::FixedAssetRegister,
_ => return,
};
if let (s, e) = (date(&start), date(&end))
@@ -182,6 +187,25 @@ impl App {
Route::Reports(kind)
}
T::Document { attachment_id } => Route::Document(attachment_id),
T::Asset { asset_id } => {
if b.ledger.get_asset(asset_id).is_none() {
return;
}
Route::Asset(asset_id)
}
T::Party { party_id } => {
if b.ledger.get_party(party_id).is_none() {
return;
}
Route::Party(party_id)
}
T::Reconciliation { id } => {
// Its own page: statement, balance, the lines it cleared.
if b.reconciliation(id).is_none() {
return;
}
Route::Reconciliation(id)
}
T::Search { query } => {
let st = &mut self.state.transactions;
st.query = query;
@@ -675,12 +699,23 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
} else {
(icon::WARNING_CIRCLE, p.negative)
};
ui.label(
RichText::new(format!("{glyph} {text}"))
.font(theme::regular(11.5))
.color(color),
)
.on_hover_text(tip);
let r = ui
.label(
RichText::new(format!("{glyph} {text}"))
.font(theme::regular(11.5))
.color(color),
)
.on_hover_text(tip);
if text == "Journal chain verified" {
r.context_menu(|ui| {
crate::widgets::copy_item(ui, "Copy journal head", &b.head);
crate::widgets::copy_item(
ui,
"Copy data folder",
&b.data_dir.display().to_string(),
);
});
}
}
});
}
@@ -700,6 +735,7 @@ fn checks(b: &Books) -> Vec<(bool, String, String)> {
out.push(match &b.integrity.disk {
DiskCheck::Matches => (true, "Database matches journal".to_string(), "The server's SQLite database matches a fresh projection of the journal, every table, row for row.".to_string()),
DiskCheck::Behind { applied, journal } => (true, "Database catching up".to_string(), format!("The database has applied {applied} of {journal} journal events; the server applies the rest right after writing them.")),
DiskCheck::Ahead { .. } => (true, "Viewer catching up".to_string(), "The server has written journal events this view hasn't read yet; it reads them on its next refresh.".to_string()),
DiskCheck::Missing => (true, "Read-only copy".to_string(), "No server database here: a copy of the books (a CPA's, say), or the server hasn't run yet. The journal and every file are verified either way.".to_string()),
DiskCheck::Differs(why) => (false, "Database differs".to_string(), format!("{why}. The server rebuilds it from the journal (books.verify, or on its next start).")),
DiskCheck::Unreadable(why) => (false, "Database unreadable".to_string(), why.clone()),
+274 -5
View File
@@ -1,7 +1,11 @@
//! What the books need: drafts left unposted, missing receipts, schedules
//! and recurring entries due, open past periods, uncategorized tax lines,
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
//! owners. Each item says what's wrong, where to look, and the prompt that
//! owners, fixed asset depreciation not posted, assets without a tag or a
//! tax basis, contractors and landlords paid without W-9 facts, bank and
//! card accounts not reconciled through last month, and legal
//! rules (like the 1099 threshold) due for an update. Each item says
//! what's wrong, where to look, and the prompt that
//! asks the agent to fix it (the GUI never changes the books itself).
use crate::books::{Attention, Books, Severity};
@@ -12,6 +16,55 @@ use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
/// Everything the books need, as items to show.
///
/// ```text
/// GOAL: tell the humans what's unfinished or wrong, and hand them the
/// prompt that gets the agent to fix it; never change the books.
/// SOURCE: DECISION (Stephen: the viewer is read-only; its "Needs
/// attention" list offers prompts, AGENTS.md); the conditions
/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s).
/// INVARIANTS:
/// 1. Pure: reads `b`, writes nothing.
/// 2. One item per: draft entry (Todo); posted entry missing a receipt
/// (Problem; four or more are one combined item); recurring entry or
/// schedule due through today (Todo); period ended and still open
/// (Todo); fiscal-year account with activity and no tax category
/// (Info); legal rule known only through a past year (Todo); 1099
/// vendor without a W-9 (Problem); overdue bills, and overdue
/// invoices (Problem, one each, with the total); money owed to
/// owners (Info, per account with a balance); the database differing
/// from the journal or unreadable, and damaged or missing stored
/// files (Problem); depreciation due through the last finished month
/// and not posted (Books::depreciation_due amount > 0: Todo, one
/// item); the asset register and the books' asset cost accounts
/// disagreeing today (unregistered_cost != 0: Problem); an active
/// asset in service with no tag (Todo, one per asset); an active
/// asset in service with no tax basis (Asset::tax_basis is None:
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis);
/// a party not archived whose W-9 is Missing (pages::parties::
/// w9_status: it files a 1099, has no W-9 facts, and its vendor
/// record doesn't say one is on file) and that was paid this year
/// (PartyInfo::paid_ytd > 0),
/// unless its vendor is already a 1099 vendor without a W-9 above
/// (Todo, one per party, its prompt from
/// pages::parties::collect_w9_prompt, naming books.set_w9); a cash
/// or card account (Books::reconcilable_accounts) whose
/// Books::reconciliation_due is Some(date) (Todo, one per account,
/// titled "Reconcile <account> through <date>", its prompt from
/// pages::reconciliation::reconcile_prompt, naming
/// books.record_statement and books.reconcile); when `[books]
/// parties` isn't off, the posted entries dated this calendar year
/// (Jan 1 through today) that name no party and aren't marked as
/// having none (Ledger::entry_no_party), leaving out the ones the
/// engine makes from a schedule (period closes, depreciation,
/// amortization: no counterparty by nature) (one item: Info under
/// suggest, Todo under require; its prompt names
/// books.set_entry_party).
/// 3. Nothing else: books with none of those yield no items.
/// 4. Every item's prompt starts "In wanderbooks," or names
/// wanderbooks, and its amounts are the books' exact amounts.
/// ```
pub fn find(b: &Books) -> Vec<Attention> {
let mut out = Vec::new();
let today = b.today;
@@ -49,9 +102,9 @@ pub fn find(b: &Books) -> Vec<Attention> {
.collect();
out.push(Attention {
severity: Severity::Problem,
title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")),
title: format!("{} without receipts", fmt::count(missing.len(), "entry", "entries")),
detail: format!(
"{} of spending has no receipt attached ({}).",
"{} in entries with no receipt attached ({}).",
fmt::dollars(total),
b.receipt_rule()
),
@@ -176,13 +229,41 @@ pub fn find(b: &Books) -> Vec<Attention> {
}
}
// Rules set by law that wanderbooks may no longer know (rules.md;
// Stephen, 2026-10-13): research or ask, then update them for everyone.
for rule in ledger_core::rules::stale_rules(today) {
out.push(Attention {
severity: Severity::Todo,
title: format!("Check the {}", rule.name),
detail: format!(
"wanderbooks knows it through {}. {}",
rule.known_through, rule.review_why
),
target: None,
prompt: format!(
"wanderbooks' {} is known only through {}. Find the current figure from the official source (see {} in the wanderbooks repo), or ask me or our CPA, and tell me what you found. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official source (or at least an issue).",
rule.name, rule.known_through, rule.source
),
});
}
let mut years = vec![today.year()];
if today.month() == 1 {
years.insert(0, today.year() - 1);
}
// Vendors the "needs a W-9" item already names this year.
let mut asked_w9 = std::collections::HashSet::new();
for year in years {
let r = vendor_1099_report(&b.ledger, year, None);
// A year whose threshold isn't known is flagged by the stale-rule
// item above; never guess it here.
let Ok(r) = vendor_1099_report(&b.ledger, year, None) else {
continue;
};
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
if year == today.year() {
asked_w9.insert(v.vendor_id);
}
let party = b.ledger.party_of_vendor(v.vendor_id);
out.push(Attention {
severity: Severity::Problem,
title: format!("{} needs a W-9", v.name),
@@ -191,7 +272,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
fmt::dollars(v.paid),
fmt::dollars(r.threshold)
),
target: Some(Route::Company),
target: Some(party.map_or(Route::Company, Route::Party)),
prompt: format!(
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
v.name
@@ -200,6 +281,34 @@ pub fn find(b: &Books) -> Vec<Attention> {
}
}
for p in b.ledger.parties().filter(|p| {
!p.archived
&& crate::pages::parties::w9_status(b, p) == crate::pages::parties::W9Status::Missing
&& !p.vendor.is_some_and(|v| asked_w9.contains(&v))
}) {
let Some(info) = b.party(p.id).filter(|i| i.paid_ytd > Decimal::ZERO) else {
continue;
};
out.push(Attention {
severity: Severity::Todo,
title: format!("Collect a W-9 from {}", p.label()),
detail: format!(
"Paid {} in {} as {}, with no W-9 facts recorded: collect a W-9 before the next payment.",
fmt::dollars(info.paid_ytd),
today.year(),
if p.roles.contains(&ledger_core::Role::Landlord)
&& !p.roles.contains(&ledger_core::Role::Contractor)
{
"a landlord"
} else {
"a contractor"
}
),
target: Some(Route::Party(p.id)),
prompt: crate::pages::parties::collect_w9_prompt(b, p),
});
}
let overdue = |rows: Vec<ledger_core::AgingRow>| -> (usize, Decimal) {
let late: Vec<Decimal> = rows
.iter()
@@ -260,6 +369,166 @@ pub fn find(b: &Books) -> Vec<Attention> {
}
}
for a in b.reconcilable_accounts() {
let Some(through) = b.reconciliation_due(a) else {
continue;
};
let name = b.account_name(a);
out.push(Attention {
severity: Severity::Todo,
title: format!("Reconcile {name} through {}", fmt::date(through)),
detail: match b.reconciled_through(a) {
Some(d) => format!(
"Reconciled through {}. The monthly close reconciles it to each statement since.",
fmt::date(d)
),
None => "Never reconciled. The monthly close reconciles it to its statement.".into(),
},
target: Some(Route::Account(a)),
prompt: crate::pages::reconciliation::reconcile_prompt(b, a, through),
});
}
let policy = b.settings.parties;
if policy != ledger_config::PartyPolicy::Off {
let year_start = chrono::NaiveDate::from_ymd_opt(today.year(), 1, 1).expect("valid");
let unnamed: Vec<_> = b
.entries
.iter()
.filter(|e| {
e.status == EntryStatus::Posted
&& e.date >= year_start
&& e.date <= today
&& e.parties.is_empty()
&& b.ledger.entry_no_party(e.id).is_none()
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
})
.collect();
if !unnamed.is_empty() {
let list: Vec<String> = unnamed
.iter()
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
.collect();
out.push(Attention {
severity: if policy == ledger_config::PartyPolicy::Require {
Severity::Todo
} else {
Severity::Info
},
title: format!(
"{} no counterparty",
fmt::count(unnamed.len(), "entry names", "entries name")
),
detail: format!(
"Posted in {} with no party and not marked as having none. Tagging each entry with who it was with keeps the parties' totals and 1099s complete.",
today.year()
),
target: Some(Route::Transactions),
prompt: format!(
"In wanderbooks, these posted entries from {} name no counterparty: {}. Propose a party for each, grouped by party (adding any that aren't in the directory yet), and once I agree, tag them with books.set_entry_party. For any with truly no counterparty (like a transfer between the company's own accounts), mark it with books.set_entry_party with party null and a no_party_reason.",
today.year(),
list.join("; ")
),
});
}
}
let due = b.depreciation_due(today);
if due.amount > Decimal::ZERO {
let months = if due.periods.is_empty() {
"every month not yet posted".to_string()
} else {
due.periods.join(", ")
};
out.push(Attention {
severity: Severity::Todo,
title: format!("Depreciation not posted through {}", fmt::date(due.through)),
detail: format!(
"{} scheduled and not yet in the books ({}).",
fmt::dollars(due.amount),
months
),
target: Some(Route::Assets),
prompt: format!(
"In wanderbooks, run books.run_depreciation for {months} (depreciation due through {} and not posted: {}), then show me the fixed asset register.",
due.through,
fmt::dollars(due.amount)
),
});
}
let register = b.asset_register(today);
let unregistered = register.unregistered_cost();
if !unregistered.is_zero() {
let (title, why) = if unregistered > Decimal::ZERO {
(
format!(
"{} of equipment not in the asset register",
fmt::dollars(unregistered)
),
"The asset cost accounts hold more than the registered assets cost: something bought was never registered.",
)
} else {
(
format!(
"{} of registered assets not in the books",
fmt::dollars(-unregistered)
),
"Registered assets cost more than their cost accounts hold: a purchase entry may be missing.",
)
};
out.push(Attention {
severity: Severity::Problem,
title,
detail: why.into(),
target: Some(Route::Assets),
prompt: format!(
"In wanderbooks, the fixed asset register and the asset cost accounts differ by {} today. Find what's behind it (books.list_assets and the cost accounts' registers) and tell me how to fix it.",
fmt::dollars(unregistered)
),
});
}
for a in b.ledger.registered_assets().filter(|a| {
a.tag.is_none() && a.status == ledger_core::AssetStatus::Active && a.purchase_date <= today
}) {
out.push(Attention {
severity: Severity::Todo,
title: format!("No tag: {}", a.description),
detail: format!(
"Asset {}, in service {}. A tag is the label on the thing itself.",
a.id,
fmt::date(a.purchase_date)
),
target: Some(Route::Asset(a.id)),
prompt: format!(
"In wanderbooks, label asset {} (\"{}\") with books.label_asset: give it the next generated tag unless I have my own, and its serial number if I can find it. Then print its label with books.asset_labels.",
a.id, a.description
),
});
}
for a in b.ledger.registered_assets().filter(|a| {
a.tax_basis().is_none()
&& a.status == ledger_core::AssetStatus::Active
&& a.purchase_date <= today
}) {
let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id));
out.push(Attention {
severity: Severity::Todo,
title: format!("No tax basis: {}", b.asset_label(a.id)),
detail: format!(
"Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.",
fmt::dollars(a.cost.amount),
fmt::date(a.purchase_date)
),
target: Some(Route::Asset(a.id)),
prompt: format!(
"In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (cost), contributed by a member from business use (carryover, IRC §723: the member's adjusted basis), or converted by a member from personal use (personal-use conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis; the tax basis is the lesser of that and its fair market value). Then show me the asset.",
a.description,
fmt::dollars(a.cost.amount)
),
});
}
use ledger_store::DiskCheck;
let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string();
match &b.integrity.disk {
File diff suppressed because it is too large. Load diff
+45 -17
View File
@@ -15,25 +15,53 @@ fn f(d: Decimal) -> f64 {
d.try_into().unwrap_or(0.0)
}
/// ```text
/// INVARIANTS (finite lo <= hi, n >= 1):
/// 1. Evenly spaced by a step of 1, 2, 2.5, or 5 times a power of ten
/// (each tick a whole multiple of the step), all finite.
/// 2. The first tick is at or below lo and the last at or above hi, so
/// no data falls outside the gridlines.
/// 3. At most n + 2 ticks.
/// When no such step exists in f64 (nice ticks past f64's range, within
/// a factor of ten of it, or a span too small for the magnitude), the
/// ticks are just [lo, hi] (or [lo]).
/// ```
/// "Nice" axis ticks covering [lo, hi].
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
let span = (hi - lo).max(1e-9);
let raw = span / n as f64;
let mag = 10f64.powf(raw.log10().floor());
let step = [1.0, 2.0, 2.5, 5.0, 10.0]
.iter()
.map(|m| m * mag)
.find(|s| span / s <= n as f64)
.unwrap_or(10.0 * mag);
let start = (lo / step).floor() * step;
let mut v = vec![start];
// Up to the first tick at or above the top, so the data never pokes
// past the last gridline.
while *v.last().expect("non-empty") < hi - step * 1e-9 {
let next = v.last().expect("non-empty") + step;
v.push(next);
pub fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
let n = n.max(1);
let fallback = || if lo == hi { vec![lo] } else { vec![lo, hi] };
let span = (hi - lo).max(f64::MIN_POSITIVE);
if !span.is_finite() {
return fallback();
}
v
let raw = span / n as f64;
let mut mag = 10f64.powf(raw.log10().floor());
// Try the nice steps from the smallest that fits n, coarser as needed
// (rounding at large magnitudes can cost a tick).
for _ in 0..4 {
for m in [1.0, 2.0, 2.5, 5.0] {
let step = m * mag;
if !step.is_finite() || step <= 0.0 || span / step > n as f64 {
continue;
}
let (k0, k1) = ((lo / step).floor(), (hi / step).ceil());
if !(k0.is_finite() && k1.is_finite()) || k1 - k0 + 1.0 > (n + 2) as f64 {
continue;
}
let v: Vec<f64> = (0..=(k1 - k0) as i64)
.map(|k| (k0 + k as f64) * step)
.collect();
let ok = v.iter().all(|x| x.is_finite())
&& v.windows(2).all(|w| w[1] > w[0])
&& v.first().is_some_and(|f| *f <= lo)
&& v.last().is_some_and(|l| *l >= hi);
if ok {
return v;
}
}
mag *= 10.0;
}
fallback()
}
fn money_label(v: f64) -> String {
+269 -7
View File
@@ -2,15 +2,17 @@
//! small design studio (single-member LLC) with client invoices, template
//! sales paid out by a platform, contractors (one without a W-9), rent on a
//! recurring schedule, a credit card, a laptop on depreciation, prepaid
//! insurance, owner draws, a closed year, PDF receipts, and a few loose
//! ends for the "needs attention" list. Deterministic for a given `today`.
//! insurance, owner draws, a closed year, PDF receipts, bank statements
//! with reconciliations (one voided and redone), documents linked to the
//! company, an asset, and a party, and a few loose ends for the "needs
//! attention" list. Deterministic for a given `today`.
use chrono::{Datelike, Days, Months, NaiveDate};
use ledger_core::{
AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring,
NewSchedule, ScheduleKind, Side, arap::DocumentLine,
};
use ledger_store::{Store, StoreError};
use ledger_store::{Statement, Store, StoreError, Subject};
use rust_decimal::Decimal;
use std::path::Path;
@@ -250,8 +252,96 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
.map(|n| s.add_customer(n))
.collect::<Result<_, _>>()?;
// The party directory: who they are, beyond the vendor and customer
// records (roles, W-9 facts with only the TIN's last four, sites).
{
use ledger_core::{PartyField, Role, W9Facts};
let party_of = |s: &Store, v: u64| s.ledger().party_of_vendor(v).expect("linked");
let sam_p = party_of(&s, sam);
s.set_party_role(sam_p, Role::Contractor, true)?;
s.set_party_w9(
sam_p,
Some(W9Facts {
received: start + Days::new(20),
entity_type: "Individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: "4821".into(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
}),
)?;
s.update_party(
sam_p,
PartyField::Notes(Some(
"Illustrator for client projects; invoices after each delivery.".into(),
)),
)?;
let riley_p = party_of(&s, riley);
s.set_party_role(riley_p, Role::Contractor, true)?;
s.update_party(riley_p, PartyField::Website(Some("rileychen.dev".into())))?;
let lofts = party_of(&s, landlord);
s.set_party_role(lofts, Role::Landlord, true)?;
s.set_party_w9(
lofts,
Some(W9Facts {
received: start + Days::new(3),
entity_type: "LLC taxed as a partnership".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: "3307".into(),
kept_at: "CPA portal".into(),
vault_record: None,
}),
)?;
s.update_party(
lofts,
PartyField::Address(Some("1200 NW Everett St, Portland, OR 97209".into())),
)?;
s.update_party(lofts, PartyField::DisplayName(Some("Everett Lofts".into())))?;
if let Some(fern) = s.ledger().party_of_customer(customers[0]) {
s.update_party(
fern,
PartyField::Website(Some("fernandfield.coffee".into())),
)?;
s.update_party(
fern,
PartyField::Address(Some("88 SE Division St, Portland, OR 97202".into())),
)?;
}
let irs = s.add_party("Internal Revenue Service", &[Role::TaxAgency])?;
s.update_party(irs, PartyField::DisplayName(Some("IRS".into())))?;
s.update_party(irs, PartyField::Website(Some("irs.gov".into())))?;
let odr = s.add_party("Oregon Department of Revenue", &[Role::TaxAgency])?;
s.update_party(odr, PartyField::Website(Some("oregon.gov/dor".into())))?;
let old = s.add_party("Etsy, Inc.", &[Role::PayoutPlatform])?;
s.update_party(
old,
PartyField::Notes(Some("Sold templates here before moving to Gumroad.".into())),
)?;
s.update_party(old, PartyField::Archived(true))?;
}
let gumroad = s.add_party("Gumroad, Inc.", &[ledger_core::Role::PayoutPlatform])?;
s.update_party(
gumroad,
ledger_core::PartyField::DisplayName(Some("Gumroad".into())),
)?;
s.update_party(
gumroad,
ledger_core::PartyField::Website(Some("gumroad.com".into())),
)?;
let chase = s.add_party(
"JPMorgan Chase Bank, N.A.",
&[ledger_core::Role::FinancialInstitution],
)?;
s.update_party(
chase,
ledger_core::PartyField::DisplayName(Some("Chase".into())),
)?;
let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?;
// Formation documents.
s.file_document(
let (articles, _, _) = s.file_document(
"formation",
"Articles of Organization.pdf",
"application/pdf",
@@ -265,7 +355,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
),
Some("Filed with the state"),
)?;
s.file_document(
let (ein_letter, _, _) = s.file_document(
"tax",
"IRS EIN confirmation.pdf",
"application/pdf",
@@ -376,6 +466,17 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
accum_depr_account: accum,
expense_account: depreciation,
})?;
// The label on the laptop, and its serial number.
s.set_asset_tag(asset, "JS-0001")?;
s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?;
// Bought by the company, so its federal tax basis is its cost.
s.set_asset_tax_basis(
asset,
ledger_core::TaxBasis {
rule: ledger_core::TaxBasisRule::Cost,
contributor_basis: None,
},
)?;
// Prepaid insurance, spread over a year.
let ins_date = day(start + Months::new(2), 15);
@@ -551,7 +652,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
// Pay the card in full on the 25th.
let date = day(m, 25);
if in_range(date) && card_balance > Decimal::ZERO {
post(
let paid = post(
&mut s,
date,
"Credit card payment",
@@ -571,6 +672,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
None,
None,
)?;
s.set_entry_party(paid, Some(chase))?;
card_balance = Decimal::ZERO;
}
// Client invoices: two or three a month, net 30.
@@ -646,6 +748,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
None,
)?;
s.post_entry(e)?;
s.set_entry_party(e, Some(gumroad))?;
}
}
// Contractors.
@@ -704,7 +807,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
if month_index >= 3 {
let date = day(m, 28);
if in_range(date) {
post(
let draw = post(
&mut s,
date,
"Owner draw",
@@ -715,6 +818,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
None,
None,
)?;
s.set_entry_party(draw, Some(alex))?;
}
}
if month_index % 3 == 2 {
@@ -792,5 +896,163 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
month_index += 1;
}
let _ = (bank_fees, client_class);
// The company's papers, linked to it; the laptop's warranty and
// photo, linked to the asset; Sam's contract, linked to Sam and the
// company.
s.link_attachment(articles, Subject::Company)?;
s.link_attachment(ein_letter, Subject::Company)?;
let (warranty, _, _) = s.file_document(
"warranty",
"MacBook Pro AppleCare.pdf",
"application/pdf",
&receipt_pdf(
"AppleCare+ for Mac",
&[
"Agreement for MacBook Pro 14-inch".into(),
format!("Coverage from {}", laptop_date.format("%B %-d, %Y")),
"Three years of hardware coverage".into(),
],
),
Some("Warranty, three years"),
)?;
s.link_attachment(warranty, Subject::Asset(asset))?;
let sam_party = s.ledger().party_of_vendor(sam).expect("linked");
let (contract_doc, _, _) = s.file_document(
"contract",
"Sam Okafor services agreement.pdf",
"application/pdf",
&receipt_pdf(
"Independent Contractor Agreement",
&[
"Juniper Studio LLC and Sam Okafor".into(),
"Illustration services, billed per project".into(),
format!("Signed {}", (start + Months::new(1)).format("%B %-d, %Y")),
],
),
Some("Signed agreement"),
)?;
s.link_attachment(contract_doc, Subject::Party(sam_party))?;
s.link_attachment(contract_doc, Subject::Company)?;
// Statements and reconciliations: savings reconciled through last
// month; checking through the month before (its first reconciliation
// voided and redone against the bank's reissued statement, one check
// outstanding at the first statement); the card never.
let month_end = |m: NaiveDate| m + Months::new(1) - Days::new(1);
let lines_on = |s: &Store, account: u64, through: NaiveDate| -> Vec<(u64, usize, NaiveDate)> {
let mut out = Vec::new();
for e in s.ledger().posted_entries() {
if e.date() <= through {
for (i, l) in e.lines().iter().enumerate() {
if l.account == account && !s.ledger().is_cleared(e.id(), i) {
out.push((e.id(), i, e.date()));
}
}
}
}
out.sort_by_key(|x| (x.2, x.0, x.1));
out
};
let statement = |s: &mut Store,
account: u64,
name: &str,
month: NaiveDate,
note: &str,
opening: Option<Decimal>|
-> Result<u64, StoreError> {
let end = month_end(month);
// The bank's opening is its last closing: what had cleared by then.
let opening = match opening {
Some(o) => o,
None => {
s.ledger()
.reconciliation_report(account, month - Days::new(1), Decimal::ZERO)?
.cleared_balance
}
};
let closing = s
.ledger()
.reconciliation_report(account, end, Decimal::ZERO)?
.cleared_balance;
let (att, _, _) = s.file_document(
"statement",
&format!(
"{name} statement {}{}.pdf",
month.format("%Y-%m"),
if note.is_empty() {
String::new()
} else {
format!(" ({})", note.to_lowercase())
}
),
"application/pdf",
&receipt_pdf(
&format!("{name} statement"),
&[
format!(
"{} through {}",
month.format("%B %-d, %Y"),
end.format("%B %-d, %Y")
),
format!("Opening balance: ${opening}"),
format!("Closing balance: ${closing}"),
note.to_string(),
],
),
Some(&format!("{name}, {}", month.format("%B %Y"))),
)?;
s.record_statement(Statement {
attachment_id: att,
account_id: account,
period_start: month,
period_end: end,
opening_balance: opening,
closing_balance: closing,
})?;
Ok(att)
};
let last_month = this_month - Months::new(1);
// Savings: every line through last month cleared.
for (e, i, _) in lines_on(&s, savings, month_end(last_month)) {
s.mark_cleared(e, i, true)?;
}
let st = statement(&mut s, savings, "Savings", last_month, "", None)?;
s.complete_reconciliation(st)?;
// Checking, three months back: the month's last check still out.
let m1 = this_month - Months::new(3);
let pending = lines_on(&s, checking, month_end(m1));
let outstanding = pending.last().map(|x| (x.0, x.1));
for (e, i, _) in pending {
if Some((e, i)) != outstanding {
s.mark_cleared(e, i, true)?;
}
}
let first = statement(&mut s, checking, "Operating Checking", m1, "", None)?;
let voided = s.complete_reconciliation(first)?;
s.void_reconciliation(
voided.id,
"The bank reissued this statement; reconciled again against the corrected copy",
)?;
let reissued = statement(&mut s, checking, "Operating Checking", m1, "Reissued", None)?;
let m1_closing = s
.ledger()
.reconciliation_report(checking, month_end(m1), Decimal::ZERO)?
.cleared_balance;
s.complete_reconciliation(reissued)?;
// Two months back: everything through it cleared.
let m2 = this_month - Months::new(2);
for (e, i, _) in lines_on(&s, checking, month_end(m2)) {
s.mark_cleared(e, i, true)?;
}
let second = statement(
&mut s,
checking,
"Operating Checking",
m2,
"",
Some(m1_closing),
)?;
s.complete_reconciliation(second)?;
Ok(())
}
+65 -1
View File
@@ -20,6 +20,23 @@ pub fn downloads_dir() -> PathBuf {
}
/// `name` made safe as a file name on every platform.
///
/// ```text
/// GOAL: a report title (company data) can never name a file outside the
/// downloads folder, or one a platform refuses.
/// SOURCE: docs/spec/sources/file-names.md (Microsoft's naming rules;
/// POSIX NAME_MAX).
/// INVARIANTS:
/// 1. The result is `<base>.<ext>`, `base` holding no reserved
/// character (< > : " / \ | ? *) and no control character: each is
/// replaced ('-' or a space).
/// 2. `base` is never empty, never "." or "..", never ends with a space
/// or a period, and is never a reserved device name (CON, NUL, COM1,
/// LPT¹, ... in any case, before its first period): such a base gets
/// a leading '_'.
/// 3. The whole name is at most 255 bytes of UTF-8, cut at a character
/// boundary.
/// ```
pub fn file_name(name: &str, ext: &str) -> String {
let clean: String = name
.chars()
@@ -29,7 +46,54 @@ pub fn file_name(name: &str, ext: &str) -> String {
c => c,
})
.collect();
format!("{}.{ext}", clean.trim())
let mut base = clean.trim().trim_end_matches(['.', ' ']).to_string();
if base.is_empty() {
base = "report".to_string();
}
let stem = base
.split('.')
.next()
.unwrap_or("")
.trim_end()
.to_uppercase();
let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ["COM", "LPT"].iter().any(|p| {
stem.strip_prefix(p).is_some_and(|n| {
matches!(
n,
"1" | "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "\u{b9}"
| "\u{b2}"
| "\u{b3}"
)
})
});
if reserved {
base.insert(0, '_');
}
let limit = 255usize.saturating_sub(ext.len() + 1);
if base.len() > limit {
let mut cut = limit;
while !base.is_char_boundary(cut) {
cut -= 1;
}
base.truncate(cut);
// A cut can leave a trailing space or period: trim again.
while base.ends_with(['.', ' ']) {
base.pop();
}
if base.is_empty() {
base = "report".to_string();
}
}
format!("{base}.{ext}")
}
/// Write `bytes` to the downloads folder as `name.ext` (replacing a file
+84 -16
View File
@@ -1,36 +1,56 @@
//! Formatting money, dates, and counts. Amounts are exact decimals in and
//! exact strings out: nothing here rounds except `compact`, which says so.
//!
//! ```text
//! GOAL: show every amount exactly as the books hold it.
//! SOURCE: DECISION (the MCP contract: "money is exact or it is wrong");
//! CONVENTION (US accounting display: thousands commas,
//! negatives in parentheses or with a minus sign).
//! INVARIANTS:
//! 1. money, accounting, and dollars NEVER round: every decimal place
//! the amount has (at least two) is shown, thousands grouped by
//! commas in threes, so the text reads back to the same value.
//! 2. Zero (and negative zero) shows without a sign; a negative shows
//! U+2212 (money, dollars) or parentheses (accounting), never both
//! and never "-0".
//! 3. compact rounds (chart axes, tight tiles): $950, $1.2k, $25k,
//! $3.4M; it never shows a sign on what rounds to zero.
//! ```
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Thousands separators, two decimals, a true minus sign: `−1,234.56`.
/// Thousands separators, every decimal (at least two), a true minus sign:
/// `−1,234.56`.
pub fn money(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero();
let s = group(d.abs().round_dp(2));
if neg { format!("\u{2212}{s}") } else { s }
let s = group(d.abs());
if is_negative(d) {
format!("\u{2212}{s}")
} else {
s
}
}
/// Accounting style: negatives in parentheses, `(1,234.56)`.
pub fn accounting(d: Decimal) -> String {
if d.is_sign_negative() && !d.is_zero() {
format!("({})", group(d.abs().round_dp(2)))
} else {
group(d.round_dp(2))
}
let s = group(d.abs());
if is_negative(d) { format!("({s})") } else { s }
}
/// With the currency sign: `$1,234.56`, `−$12.00`.
pub fn dollars(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero();
let s = format!("${}", group(d.abs().round_dp(2)));
if neg { format!("\u{2212}{s}") } else { s }
let s = format!("${}", group(d.abs()));
if is_negative(d) {
format!("\u{2212}{s}")
} else {
s
}
}
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
pub fn compact(d: Decimal) -> String {
let f: f64 = d.try_into().unwrap_or(0.0);
let (neg, a) = (f < 0.0, f.abs());
let a = f.abs();
let s = if a >= 1_000_000.0 {
format!("${:.1}M", a / 1_000_000.0)
} else if a >= 10_000.0 {
@@ -40,11 +60,22 @@ pub fn compact(d: Decimal) -> String {
} else {
format!("${a:.0}")
};
if neg { format!("\u{2212}{s}") } else { s }
// No sign on what rounds to zero ("$0", not "−$0").
if f < 0.0 && s != "$0" {
format!("\u{2212}{s}")
} else {
s
}
}
fn is_negative(d: Decimal) -> bool {
d.is_sign_negative() && !d.is_zero()
}
/// A non-negative amount, every decimal it has (at least two), grouped.
fn group(d: Decimal) -> String {
let s = format!("{:.2}", d);
let places = d.normalize().scale().max(2) as usize;
let s = format!("{:.*}", places, d);
let (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
let mut out = String::new();
for (i, c) in int.chars().enumerate() {
@@ -56,6 +87,31 @@ fn group(d: Decimal) -> String {
format!("{out}.{frac}")
}
/// The plain decimal behind a displayed amount, for the clipboard:
/// `$1,234.56` → `1234.56`, `(207.68)` and `−12.00` → `-207.68`, `-12.00`.
/// Exact (the display never rounds). None when the text isn't an amount.
pub fn plain(display: &str) -> Option<String> {
let t = display.trim();
let (neg, t) = match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
Some(inner) => (true, inner),
None => (false, t),
};
let (neg, t) = match t.strip_prefix('\u{2212}').or_else(|| t.strip_prefix('-')) {
Some(rest) => (!neg, rest),
None => (neg, t.strip_prefix('+').unwrap_or(t)),
};
let digits: String = t.chars().filter(|c| !matches!(c, '$' | ',')).collect();
let d: Decimal = digits.parse().ok()?;
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit() || c == '.') {
return None;
}
Some(if neg && !d.is_zero() {
format!("-{digits}")
} else {
digits
})
}
/// A percent change from `prev` to `now`, if `prev` isn't zero.
pub fn pct_change(prev: Decimal, now: Decimal) -> Option<f64> {
if prev.is_zero() {
@@ -172,7 +228,9 @@ mod tests {
assert_eq!(money(dec("0")), "0.00");
assert_eq!(money(dec("-0.00")), "0.00");
assert_eq!(accounting(dec("-207.68")), "(207.68)");
assert_eq!(dollars(dec("999.999")), "$1,000.00");
assert_eq!(dollars(dec("999.999")), "$999.999");
assert_eq!(money(dec("0.125")), "0.125");
assert_eq!(compact(dec("-0.4")), "$0");
assert_eq!(compact(dec("1234")), "$1.2k");
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
assert_eq!(compact(dec("950.4")), "$950");
@@ -180,5 +238,15 @@ mod tests {
assert_eq!(label("ein"), "EIN");
assert_eq!(label("irs_name_control"), "IRS name control");
assert_eq!(words("PrepaidExpense"), "Prepaid expense");
assert_eq!(plain("$1,234.56").as_deref(), Some("1234.56"));
assert_eq!(plain("(207.68)").as_deref(), Some("-207.68"));
assert_eq!(plain("\u{2212}$12.00").as_deref(), Some("-12.00"));
assert_eq!(plain("+1,000.005").as_deref(), Some("1000.005"));
assert_eq!(plain("0.00").as_deref(), Some("0.00"));
assert_eq!(plain("Debit"), None);
// Only digits and a point: the decimal parser alone takes these.
assert_eq!(plain("1_000.00"), None);
assert_eq!(plain("1e3"), None);
assert_eq!(plain(""), None);
}
}
+5 -5
View File
@@ -4,16 +4,16 @@
#![allow(missing_docs)]
pub mod app;
mod attention;
pub mod attention;
pub mod books;
mod charts;
pub mod charts;
pub mod data;
pub mod demo;
mod export;
mod fmt;
pub mod export;
pub mod fmt;
pub mod nav;
pub mod pages;
mod palette;
pub mod palette;
mod receipts;
mod theme;
mod widgets;
+41 -4
View File
@@ -1,6 +1,6 @@
//! Where you are and how you got there: a browser-style history.
use ledger_core::{AccountId, EntryId};
use ledger_core::{AccountId, AssetId, EntryId, PartyId};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ReportKind {
@@ -14,16 +14,18 @@ pub enum ReportKind {
ArAging,
ApAging,
Vendor1099,
FixedAssetRegister,
}
impl ReportKind {
pub const ALL: [ReportKind; 10] = [
pub const ALL: [ReportKind; 11] = [
ReportKind::ProfitAndLoss,
ReportKind::BalanceSheet,
ReportKind::CashFlow,
ReportKind::MembersEquity,
ReportKind::TrialBalance,
ReportKind::GeneralLedger,
ReportKind::FixedAssetRegister,
ReportKind::TaxSummary,
ReportKind::Vendor1099,
ReportKind::ArAging,
@@ -42,6 +44,7 @@ impl ReportKind {
ReportKind::ArAging => "Receivables aging",
ReportKind::ApAging => "Payables aging",
ReportKind::Vendor1099 => "1099 worksheet",
ReportKind::FixedAssetRegister => "Fixed asset register",
}
}
}
@@ -56,6 +59,17 @@ pub enum Route {
Reports(ReportKind),
Accounts,
Account(AccountId),
/// One reconciliation: its statement, balance, and the lines it
/// cleared.
Reconciliation(u64),
/// The fixed asset register.
Assets,
/// One fixed asset.
Asset(AssetId),
/// The party directory.
Parties,
/// One party.
Party(PartyId),
Documents,
Document(u64),
Company,
@@ -72,7 +86,9 @@ impl Route {
Route::Profit => Section::Profit,
Route::Transactions | Route::Entry(_) => Section::Transactions,
Route::Reports(_) => Section::Reports,
Route::Accounts | Route::Account(_) => Section::Accounts,
Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts,
Route::Assets | Route::Asset(_) => Section::Assets,
Route::Parties | Route::Party(_) => Section::Parties,
Route::Documents | Route::Document(_) => Section::Documents,
Route::Company => Section::Company,
Route::Journal | Route::JournalEvent(_) => Section::Journal,
@@ -88,19 +104,23 @@ pub enum Section {
Transactions,
Reports,
Accounts,
Assets,
Parties,
Documents,
Company,
Journal,
}
impl Section {
pub const ALL: [Section; 9] = [
pub const ALL: [Section; 11] = [
Section::Overview,
Section::Cash,
Section::Profit,
Section::Transactions,
Section::Reports,
Section::Accounts,
Section::Assets,
Section::Parties,
Section::Documents,
Section::Company,
Section::Journal,
@@ -114,6 +134,8 @@ impl Section {
Section::Transactions => "Transactions",
Section::Reports => "Reports",
Section::Accounts => "Accounts",
Section::Assets => "Assets",
Section::Parties => "Parties",
Section::Documents => "Documents",
Section::Company => "Company",
Section::Journal => "Journal",
@@ -129,6 +151,8 @@ impl Section {
Section::Transactions => i::LIST_BULLETS,
Section::Reports => i::FILE_TEXT,
Section::Accounts => i::TREE_STRUCTURE,
Section::Assets => i::PACKAGE,
Section::Parties => i::ADDRESS_BOOK,
Section::Documents => i::FILES,
Section::Company => i::BUILDINGS,
Section::Journal => i::SCROLL,
@@ -143,6 +167,8 @@ impl Section {
Section::Transactions => Route::Transactions,
Section::Reports => Route::Reports(ReportKind::ProfitAndLoss),
Section::Accounts => Route::Accounts,
Section::Assets => Route::Assets,
Section::Parties => Route::Parties,
Section::Documents => Route::Documents,
Section::Company => Route::Company,
Section::Journal => Route::Journal,
@@ -150,6 +176,17 @@ impl Section {
}
}
/// ```text
/// GOAL: back and forward behave like a web browser's.
/// SOURCE: CONVENTION (browser session history: going somewhere new
/// drops the forward list).
/// INVARIANTS:
/// 1. go(r) to a different route makes it current, pushes the old one
/// on back, and clears forward; go to the current route does nothing.
/// 2. back() then forward() returns to the same route (and vice versa);
/// either does nothing when its list is empty.
/// 3. can_back/can_forward say whether back/forward would move.
/// ```
/// Back/forward history.
pub struct Nav {
back: Vec<Route>,
+223 -4
View File
@@ -1,6 +1,8 @@
//! The chart of accounts with balances, and one account's register: every
//! posting with a running balance and the balance over time.
//! posting with a running balance and the balance over time, its bank or
//! card statements, its reconciliation history, and its documents.
use super::assets::row;
use super::page;
use crate::app::Shell;
use crate::books::Books;
@@ -11,9 +13,11 @@ use crate::widgets;
use chrono::NaiveDate;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::AccountId;
use ledger_core::AccountType;
use ledger_core::analysis::balance_history;
use ledger_core::reports::general_ledger;
use ledger_store::Subject;
use rust_decimal::Decimal;
#[derive(Default)]
@@ -168,20 +172,27 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
.range
.unwrap_or((first.min(b.today), b.today));
page(ui, "account", |ui| {
ui.label(
widgets::copyable(
ui,
RichText::new(format!("{:?} account · {}", a.account_type, a.code))
.font(theme::regular(13.0))
.color(p.muted),
&a.code,
);
ui.label(RichText::new(&a.name).font(theme::bold(26.0)));
ui.add_space(4.0);
let bal = b.balance_on(id, b.today);
ui.label(
widgets::copyable(
ui,
RichText::new(format!("Balance today {}", fmt::dollars(bal)))
.font(theme::regular(15.0))
.color(p.muted),
&fmt::plain(&fmt::dollars(bal)).unwrap_or_default(),
);
ui.add_space(14.0);
ui.add_space(6.0);
let mut go_to: Option<Route> = None;
reconciled_badge(ui, b, id);
ui.add_space(8.0);
let hist = balance_history(&b.ledger, &[id]);
if hist.len() > 1 && a.account_type.is_balance_sheet() {
widgets::titled_card(
@@ -192,6 +203,19 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
);
ui.add_space(12.0);
}
statements_and_reconciliations(ui, b, id, &mut go_to);
super::documents::documents_card(
ui,
b,
"Documents",
&[Subject::Account(id)],
"No documents linked to this account (statements, the agreement that opened it).",
&mut go_to,
);
ui.add_space(14.0);
if let Some(r) = go_to.take() {
shell.go(r);
}
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
@@ -274,6 +298,25 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
Sense::click(),
);
resp.context_menu(|ui| {
for (label, v) in [("Copy debit", d), ("Copy credit", c)] {
if !v.is_zero() {
widgets::copy_item(
ui,
label,
&fmt::plain(&fmt::money(v)).unwrap_or_default(),
);
}
}
widgets::copy_item(
ui,
"Copy balance",
&fmt::plain(&fmt::money(bal)).unwrap_or_default(),
);
if let Some(id) = sub.strip_prefix('#').and_then(|t| t.split(' ').next()) {
widgets::copy_item(ui, "Copy entry number", &format!("#{id}"));
}
});
if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
@@ -378,3 +421,179 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
}
});
}
/// Where the account stands on reconciliation: "Reconciled through
/// <date>" (in green when that covers last month, else with a notice and
/// the agent prompt), or "Never reconciled" for an account the books
/// expect to reconcile or one with statements. Nothing for other
/// accounts.
fn reconciled_badge(ui: &mut Ui, b: &Books, id: AccountId) {
let p = pal(ui.ctx());
let expected = b.reconcilable_accounts().contains(&id);
let through = b.reconciled_through(id);
if !expected && through.is_none() && b.statements_for(id).is_empty() {
return;
}
let due = b.reconciliation_due(id);
ui.horizontal_wrapped(|ui| {
match through {
Some(d) => {
let (fg, bg) = if due.is_some() {
(p.warning, p.warning_soft)
} else {
(p.positive, p.positive_soft)
};
widgets::pill(
ui,
&format!("{} Reconciled through {}", icon::CHECK_SQUARE, fmt::date(d)),
fg,
bg,
);
}
None => {
widgets::pill(ui, "Never reconciled", p.warning, p.warning_soft);
}
}
if let Some(d) = due {
ui.label(
RichText::new(format!(
"Due through {}: the monthly close reconciles every bank and card account to its statement.",
fmt::date(d)
))
.font(theme::regular(12.5))
.color(p.muted),
);
widgets::copy_prompt(ui, &super::reconciliation::reconcile_prompt(b, id, d));
}
});
}
/// The account's statements (period, opening, closing, the file) and its
/// reconciliations (date, statement balance, cleared lines, standing),
/// side by side when there's room. Nothing when it has neither.
fn statements_and_reconciliations(ui: &mut Ui, b: &Books, id: AccountId, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let statements = b.statements_for(id);
let recs = b.reconciliations_for(id);
if statements.is_empty() && recs.is_empty() {
return;
}
super::columns(ui, 1100.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
widgets::titled_card(
ui,
"Statements",
|_| {},
|ui| {
if statements.is_empty() {
ui.label(RichText::new("No statements recorded.").color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 8.0;
let w = ui.available_width().max(180.0 + amt * 2.0 + 120.0);
widgets::min_width(ui, "statements", w, |ui| {
let head =
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Period", 0.0, false),
head("Opening", amt, true),
head("Closing", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for st in statements.iter().rev() {
let file = b
.attachment(st.attachment_id)
.map_or_else(String::new, |a| a.file_name.clone());
let resp = row(
ui,
w,
&[
(
format!(
"{} – {}",
fmt::date_short(st.period_start),
fmt::date(st.period_end)
),
0.0,
false,
p.text,
),
(fmt::money(st.opening_balance), amt, true, p.muted),
(fmt::money(st.closing_balance), amt, true, p.text),
],
Some((&format!("{} {file}", icon::FILE_PDF), p.faint)),
false,
true,
);
if resp.on_hover_text("Open the statement").clicked() {
*go = Some(Route::Document(st.attachment_id));
}
}
});
},
);
} else {
widgets::titled_card(
ui,
"Reconciliations",
|_| {},
|ui| {
if recs.is_empty() {
ui.label(RichText::new("Never reconciled.").color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 8.0;
let w = ui.available_width().max(130.0 + amt + 70.0 + 110.0);
widgets::min_width(ui, "reconciliations", w, |ui| {
let head =
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Through", 0.0, false),
head("Balance", amt, true),
head("Lines", 70.0, true),
],
None,
true,
false,
);
widgets::rule(ui);
for r in recs.iter().rev() {
use super::reconciliation::Standing;
let (sub, color) = match super::reconciliation::standing(b, r) {
Standing::Current => ("Current".to_string(), p.positive),
Standing::Superseded => ("Superseded".to_string(), p.faint),
Standing::Voided(why) => (format!("Voided: {why}"), p.warning),
};
let text = if r.voided.is_some() { p.muted } else { p.text };
let resp = row(
ui,
w,
&[
(fmt::date(r.statement_date), 0.0, false, text),
(fmt::money(r.statement_balance), amt, true, text),
(r.cleared.len().to_string(), 70.0, true, p.muted),
],
Some((&sub, color)),
false,
true,
);
if resp.on_hover_text("Open this reconciliation").clicked() {
*go = Some(Route::Reconciliation(r.id));
}
}
});
},
);
}
});
}
File diff suppressed because it is too large. Load diff
+293
View File
@@ -0,0 +1,293 @@
//! A member's capital account statement, on their party page: the partner
//! capital statement (opening balance, contributions of cash and of
//! property, share of net income, distributions, closing balance), their
//! share, and their part of what the company owes the members jointly.
//! Every number is [`crate::books::CapitalStatement`]'s (which reads
//! ledger_core::k1, the CPA package's K-1 worksheet); this module only
//! lays it out.
use crate::books::{Books, CapitalKind, CapitalStatement};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{Align, Layout, RichText, Sense, Ui, vec2};
use rust_decimal::Decimal;
/// What a capital line is, in words.
pub fn kind_label(k: CapitalKind) -> &'static str {
match k {
CapitalKind::CashContribution => "Contribution — cash",
CapitalKind::PropertyContribution => "Contribution — property",
CapitalKind::IncomeShare => "Share of net income",
CapitalKind::Distribution => "Distribution",
}
}
/// A share as a percent: 0.5 → "50%", 0.3333 → "33.33%".
pub fn percent(share: Decimal) -> String {
format!("{}%", (share * Decimal::ONE_HUNDRED).normalize())
}
/// One statement row: an indented label, an amount column, and an
/// optional total column; `strong` for the balances.
fn line(
ui: &mut Ui,
label: &str,
indent: f32,
amount: Option<String>,
total: Option<String>,
strong: bool,
) {
let p = pal(ui.ctx());
let col = 130.0;
ui.horizontal(|ui| {
ui.add_space(indent);
let font = if strong {
theme::semibold(14.0)
} else {
theme::regular(14.0)
};
ui.label(RichText::new(label).font(font));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let t = total.unwrap_or_default();
widgets::amount_cell(ui, &t, col, p.text, strong, false);
let a = amount.unwrap_or_default();
widgets::amount_cell(ui, &a, col, p.text, false, false);
});
});
}
/// A short rule over the amount column (`double`: two, under a total).
fn underline(ui: &mut Ui, total_col: bool, double: bool) {
let p = pal(ui.ctx());
let col = 130.0;
let w = ui.available_width();
let (rect, _) = ui.allocate_exact_size(vec2(w, if double { 5.0 } else { 3.0 }), Sense::hover());
let right = if total_col {
rect.right()
} else {
rect.right() - col - ui.spacing().item_spacing.x
};
let left = right - col + 30.0;
let stroke = egui::Stroke::new(1.0, p.text);
let y = rect.top() + 1.0;
ui.painter()
.line_segment([egui::pos2(left, y), egui::pos2(right, y)], stroke);
if double {
ui.painter().line_segment(
[egui::pos2(left, y + 3.0), egui::pos2(right, y + 3.0)],
stroke,
);
}
}
/// The member's capital account card.
pub fn capital_card(
ui: &mut Ui,
b: &Books,
st: &CapitalStatement,
name: &str,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
let account = b.account_name(st.capital);
let (open_account, ()) = widgets::titled_card(
ui,
"Capital account",
|ui| {
widgets::link(
ui,
RichText::new(&account)
.font(theme::regular(12.0))
.color(p.accent),
)
.on_hover_text("Open the capital account's register")
.clicked()
},
|ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new(name).font(theme::semibold(15.0)));
ui.label(
RichText::new(format!(
"Statement of partner's capital · {} – {}",
fmt::date(st.span.0),
fmt::date(st.span.1)
))
.font(theme::regular(12.5))
.color(p.muted),
);
if let Some(j) = &st.files_jointly_with {
ui.label(
RichText::new(format!(
"Files a joint return with {j}: a K-1 each, both on one Form 1040"
))
.font(theme::regular(12.0))
.color(p.muted),
);
}
});
ui.add_space(10.0);
let amt = |d: Decimal| Some(fmt::accounting(d));
line(
ui,
&format!("Capital, {}", fmt::date(st.span.0)),
0.0,
None,
Some(format!("${}", fmt::accounting(st.opening))),
true,
);
ui.add_space(4.0);
ui.label(
RichText::new("Add")
.font(theme::regular(12.0))
.color(p.muted),
);
line(
ui,
"Contributions of cash",
16.0,
amt(st.contributions_cash),
None,
false,
);
line(
ui,
"Contributions of property (at fair market value)",
16.0,
amt(st.contributions_property),
None,
false,
);
line(
ui,
&format!("Share of net income ({})", percent(st.share)),
16.0,
amt(st.income_share),
None,
false,
);
ui.label(
RichText::new("Less")
.font(theme::regular(12.0))
.color(p.muted),
);
line(
ui,
"Distributions",
16.0,
Some(fmt::accounting(-st.distributions_taken)),
None,
false,
);
if !st.other.is_zero() {
line(ui, "Other changes", 16.0, amt(st.other), None, false);
}
underline(ui, false, false);
let change = st.contributions_cash + st.contributions_property + st.income_share
- st.distributions_taken
+ st.other;
line(
ui,
"Net change in capital",
0.0,
None,
Some(fmt::accounting(change)),
false,
);
underline(ui, true, false);
line(
ui,
&format!("Capital, {}", fmt::date(st.span.1)),
0.0,
None,
Some(format!("${}", fmt::accounting(st.closing))),
true,
);
underline(ui, true, true);
if !st.income_share.is_zero()
&& !st.lines.iter().any(|l| l.kind == CapitalKind::IncomeShare)
{
ui.label(
RichText::new(
"No period closed in this span: the share of net income is earned but not yet closed into the capital account.",
)
.font(theme::regular(11.5))
.color(p.faint),
);
}
ui.add_space(10.0);
widgets::rule(ui);
ui.add_space(6.0);
line(
ui,
"Share of profit, loss, and capital",
0.0,
None,
Some(percent(st.share)),
false,
);
let owed = format!(
"Owed to them on Due to Shareholders ({} of {})",
percent(st.share),
fmt::dollars(st.due_to_members)
);
line(
ui,
&owed,
0.0,
None,
Some(fmt::dollars(st.owed_to_member)),
false,
);
if !st.lines.is_empty() {
ui.add_space(10.0);
ui.collapsing(
RichText::new(format!(
"{} in this span",
fmt::count(st.lines.len(), "capital entry", "capital entries")
))
.font(theme::regular(12.5)),
|ui| {
for l in &st.lines {
ui.horizontal(|ui| {
ui.label(
RichText::new(fmt::date(l.date))
.font(theme::regular(12.5))
.color(p.muted),
);
if widgets::link(
ui,
RichText::new(format!("#{} {}", l.entry, l.description))
.font(theme::regular(12.5)),
)
.clicked()
{
*go = Some(Route::Entry(l.entry));
}
ui.label(
RichText::new(kind_label(l.kind))
.font(theme::regular(12.0))
.color(p.faint),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
widgets::amount_cell(
ui,
&fmt::accounting(l.amount),
130.0,
p.text,
false,
false,
);
});
});
}
},
);
}
},
);
if open_account {
*go = Some(Route::Account(st.capital));
}
}
+63 -13
View File
@@ -20,7 +20,7 @@ fn kv(ui: &mut Ui, k: &str, v: &str) {
// Narrow, the label goes above its value.
if ui.available_width() < 360.0 {
ui.label(RichText::new(k).font(theme::regular(12.0)).color(p.muted));
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
ui.add_space(4.0);
return;
}
@@ -34,7 +34,7 @@ fn kv(ui: &mut Ui, k: &str, v: &str) {
theme::regular(13.0),
p.muted,
);
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
});
}
@@ -180,10 +180,36 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
);
}
});
let mut subjects = vec![ledger_store::Subject::Company];
subjects.extend(
b.ledger
.list_periods()
.iter()
.map(|x| ledger_store::Subject::Period(x.id)),
);
subjects.extend(
b.ledger
.schedules()
.map(|x| ledger_store::Subject::Schedule(x.id)),
);
let mut go = None;
super::documents::documents_card_with(
ui,
b,
"Company documents",
&subjects,
&[ledger_store::Subject::Company],
"No documents linked to the company, its periods, or its schedules yet (formation papers, the operating agreement, tax filings).",
&mut go,
);
ui.add_space(16.0);
if let Some(r) = go {
shell.go(r);
}
assets(ui, b, shell);
schedules(ui, b);
recurring(ui, b);
parties(ui, b);
parties(ui, b, shell);
});
}
@@ -194,10 +220,18 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
return;
}
let mut go = None;
widgets::titled_card(
let (register, _) = widgets::titled_card(
ui,
"Fixed assets",
|_| {},
|ui| {
widgets::link(
ui,
RichText::new(format!("Asset register {}", icon::ARROW_RIGHT))
.font(theme::regular(12.5))
.color(p.accent),
)
.clicked()
},
|ui| {
for a in &assets {
// What has actually been posted, so this ties to the balance
@@ -228,11 +262,11 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
ui.horizontal_wrapped(|ui| {
if widgets::link(
ui,
RichText::new(&a.description).font(theme::semibold(13.5)),
RichText::new(b.asset_label(a.id)).font(theme::semibold(13.5)),
)
.clicked()
{
go = Some(a.cost_account);
go = Some(a.id);
}
ui.label(
RichText::new(format!(
@@ -271,7 +305,10 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
},
);
if let Some(id) = go {
shell.go(Route::Account(id));
shell.go(Route::Asset(id));
}
if register {
shell.go(Route::Assets);
}
ui.add_space(16.0);
}
@@ -410,10 +447,20 @@ fn recurring(ui: &mut Ui, b: &Books) {
ui.add_space(16.0);
}
fn parties(ui: &mut Ui, b: &Books) {
fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let mut go: Option<crate::nav::Route> = None;
// A record's name opens its party.
let mut name = |ui: &mut Ui, text: &str, party: Option<u64>| {
let r = widgets::link(ui, RichText::new(text).font(theme::semibold(13.0)));
if let Some(id) = party
&& r.on_hover_text("Open this party").clicked()
{
go = Some(crate::nav::Route::Party(id));
}
};
let year = chrono::Datelike::year(&b.today);
let r1099 = vendor_1099_report(&b.ledger, year, None);
let r1099 = vendor_1099_report(&b.ledger, year, None).ok();
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
@@ -427,13 +474,13 @@ fn parties(ui: &mut Ui, b: &Books) {
}
for v in vendors {
let paid = r1099
.vendors
.iter()
.flat_map(|r| &r.vendors)
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
name(ui, &v.name, b.ledger.party_of_vendor(v.id));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
if v.tin_on_file {
@@ -479,7 +526,7 @@ fn parties(ui: &mut Ui, b: &Books) {
.map(|i| i.balance())
.sum();
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
name(ui, &c.name, b.ledger.party_of_customer(c.id));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
@@ -498,4 +545,7 @@ fn parties(ui: &mut Ui, b: &Books) {
);
}
});
if let Some(r) = go {
shell.go(r);
}
}
+280 -19
View File
@@ -1,5 +1,7 @@
//! Every receipt and document the books hold, rendered: what it is, what
//! entry it proves, and whether it was withdrawn (and why).
//! Every receipt and document the books hold, rendered: what it is,
//! everything it is linked to, and whether it was withdrawn (and why).
//! Also the compact "Documents" card every subject's page shows
//! ([`documents_card`]).
use super::page;
use crate::app::Shell;
@@ -11,7 +13,7 @@ use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_store::Attachment;
use ledger_store::{Attachment, Subject};
#[derive(Default)]
pub struct State {
@@ -246,7 +248,11 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
}
});
});
ui.label(RichText::new(&a.file_name).font(theme::bold(20.0)));
widgets::copyable(
ui,
RichText::new(&a.file_name).font(theme::bold(20.0)),
&a.file_name,
);
ui.add_space(8.0);
egui::Grid::new(("docfacts", a.id))
.num_columns(2)
@@ -265,27 +271,42 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
ui.label(fmt::timestamp(&a.created_at));
ui.end_row();
k(ui, "SHA-256");
ui.add(
egui::Label::new(RichText::new(&a.blob_sha).monospace().size(11.5)).wrap(),
widgets::copyable(
ui,
RichText::new(&a.blob_sha).monospace().size(11.5),
&a.blob_sha,
);
ui.end_row();
if let Some(e) = a.entry_id {
k(ui, "Proves");
let label = b
.entry(e)
.map(|x| {
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
})
.unwrap_or_else(|| format!("#{e}"));
if widgets::link_truncated(ui, RichText::new(label).color(p.accent))
.clicked()
{
shell.go(Route::Entry(e));
}
if let Some(st) = b.statement(a.id) {
k(ui, "Statement");
ui.add(
egui::Label::new(format!(
"{}, {} – {}",
b.account_label(st.account_id),
fmt::date(st.period_start),
fmt::date(st.period_end)
))
.wrap(),
);
ui.end_row();
k(ui, "Opening");
ui.label(fmt::dollars(st.opening_balance));
ui.end_row();
k(ui, "Closing");
ui.label(
RichText::new(fmt::dollars(st.closing_balance))
.font(theme::semibold(13.5)),
);
ui.end_row();
}
});
ui.add_space(10.0);
let mut go = None;
links_section(ui, b, a, &mut go);
if let Some(r) = go {
shell.go(r);
}
ui.add_space(10.0);
super::entry::render(ui, b, a, shell, ui.available_width());
if let Some(d) = &a.description {
ui.add_space(6.0);
@@ -302,3 +323,243 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
}
});
}
/// The icon for a subject's kind.
pub fn subject_icon(s: Subject) -> &'static str {
match s {
Subject::Entry(_) => icon::RECEIPT,
Subject::Account(_) => icon::TREE_STRUCTURE,
Subject::Asset(_) => icon::PACKAGE,
Subject::Party(_) => icon::ADDRESS_BOOK,
Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT,
Subject::Period(_) => icon::CALENDAR_BLANK,
Subject::PayrollRun(_) => icon::USERS,
Subject::Schedule(_) => icon::CALENDAR_CHECK,
Subject::Reconciliation(_) => icon::CHECK_SQUARE,
Subject::Company => icon::BUILDINGS,
}
}
/// A small chip naming a subject, which opens it (see
/// [`Books::subject_route`]) when it has somewhere to go.
pub fn subject_chip(ui: &mut Ui, b: &Books, s: Subject, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let route = b.subject_route(s);
// A reconciliation's chip names its date before anything is cut.
let mut label = match s {
Subject::Reconciliation(r) => b.reconciliation(r).map_or_else(
|| b.subject_label(s),
|r| {
format!(
"Reconciliation, {} through {}",
b.account_name(r.account_id),
fmt::date(r.statement_date)
)
},
),
_ => b.subject_label(s),
};
if label.chars().count() > 60 {
label = label.chars().take(59).collect::<String>() + "…";
}
let r = ui.add(
egui::Button::new(
RichText::new(format!("{} {label}", subject_icon(s)))
.font(theme::regular(11.5))
.color(if route.is_some() { p.accent } else { p.muted }),
)
.fill(if route.is_some() {
p.accent_soft
} else {
p.hover
})
.stroke(Stroke::NONE)
.corner_radius(CornerRadius::same(255))
.min_size(vec2(0.0, 22.0)),
);
if let Some(to) = route
&& r.on_hover_text(format!("Open {}", b.subject_label(s)))
.clicked()
{
*go = Some(to);
}
}
/// The compact list of the documents linked to `subjects`: each file's
/// name (opening its document page), category and description, and the
/// other places it is linked as chips that open them.
///
/// ```text
/// GOAL: every subject's page shows the files that are evidence of it,
/// and where else each one is evidence.
/// SOURCE: docs/parties-and-attachments.md (Attachments, Viewer).
/// INVARIANTS:
/// 1. Lists exactly Books::documents_for(subjects), in that order.
/// 2. A file's chips are links_of(file) less `here` (the page's own
/// subject; [`documents_card`] passes `subjects`).
/// 3. Empty: `empty` in muted text (nothing else).
/// ```
pub fn documents_card_with(
ui: &mut Ui,
b: &Books,
title: &str,
subjects: &[Subject],
here: &[Subject],
empty: &str,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
let docs = b.documents_for(subjects);
widgets::titled_card(
ui,
title,
|ui| {
if !docs.is_empty() {
widgets::pill(ui, &docs.len().to_string(), p.muted, p.hover);
}
},
|ui| {
if docs.is_empty() {
ui.add(egui::Label::new(RichText::new(empty).color(p.muted)).wrap());
return;
}
for (i, d) in docs.iter().enumerate() {
if i > 0 {
ui.add_space(4.0);
widgets::rule(ui);
ui.add_space(4.0);
}
document_row(ui, b, d, here, go);
}
},
);
}
/// [`documents_card_with`], the chips leaving out `subjects` themselves.
pub fn documents_card(
ui: &mut Ui,
b: &Books,
title: &str,
subjects: &[Subject],
empty: &str,
go: &mut Option<Route>,
) {
documents_card_with(ui, b, title, subjects, subjects, empty, go);
}
/// One file in a [`documents_card_with`].
fn document_row(ui: &mut Ui, b: &Books, d: &Attachment, here: &[Subject], go: &mut Option<Route>) {
let p = pal(ui.ctx());
ui.horizontal_top(|ui| {
ui.label(RichText::new(file_icon(d)).size(18.0).color(p.muted));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if widgets::link_truncated(ui, RichText::new(&d.file_name).font(theme::semibold(13.0)))
.on_hover_text("Open this document")
.clicked()
{
*go = Some(Route::Document(d.id));
}
let mut sub = vec![];
match b.statement(d.id) {
None => sub.push(fmt::label(&d.category)),
Some(st) => sub.push(format!(
"Statement {} – {}, closing {}",
fmt::date_short(st.period_start),
fmt::date(st.period_end),
fmt::dollars(st.closing_balance)
)),
}
if let Some(desc) = &d.description {
sub.push(desc.clone());
}
ui.add(
egui::Label::new(
RichText::new(sub.join(" · "))
.font(theme::regular(11.5))
.color(p.muted),
)
.wrap(),
);
let elsewhere: Vec<Subject> = b
.links_of(d.id)
.iter()
.copied()
.filter(|s| !here.contains(s))
.collect();
if !elsewhere.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
ui.label(
RichText::new("Also on")
.font(theme::regular(11.5))
.color(p.faint),
);
for s in elsewhere {
subject_chip(ui, b, s, go);
}
});
}
});
});
}
/// A PDF or an image.
pub fn file_icon(a: &Attachment) -> &'static str {
if a.mime_type == "application/pdf" {
icon::FILE_PDF
} else if a.mime_type.starts_with("image/") {
icon::FILE_IMAGE
} else {
icon::FILE
}
}
/// Everything a document is linked to: a chip per subject (an entry's
/// with its date and amount in the label), opening it; and, for a
/// statement, the reconciliations that used it.
fn links_section(ui: &mut Ui, b: &Books, a: &Attachment, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let links = b.links_of(a.id);
ui.label(
RichText::new("LINKED TO")
.font(theme::semibold(11.5))
.color(p.faint),
);
ui.add_space(4.0);
if links.is_empty() {
ui.label(
RichText::new("Nothing yet: filed on its own.")
.font(theme::regular(12.5))
.color(p.muted),
);
}
for s in links {
ui.horizontal(|ui| {
subject_chip(ui, b, *s, go);
if let Subject::Entry(e) = s
&& let Some(x) = b.entry(*e)
{
ui.label(
RichText::new(format!(
"{} · {}",
fmt::date(x.date),
fmt::dollars(x.amount)
))
.font(theme::regular(11.5))
.color(p.muted),
);
}
if let Subject::Reconciliation(r) = s
&& let Some(r) = b.reconciliation(*r)
&& let Some(why) = &r.voided
{
ui.label(
RichText::new(format!("voided: {why}"))
.font(theme::regular(11.5))
.color(p.warning),
);
}
});
}
}
+124 -6
View File
@@ -24,10 +24,12 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
.auto_shrink(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
widgets::copyable(
ui,
RichText::new(format!("Entry #{}", e.id))
.font(theme::regular(12.5))
.color(p.muted),
&format!("#{}", e.id),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
@@ -49,7 +51,9 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
}
};
widgets::pill(ui, e.origin, p.muted, p.hover);
if let Some(v) = &e.vendor {
if let Some(v) = &e.vendor
&& e.parties.is_empty()
{
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
}
if e.missing_receipt {
@@ -83,6 +87,67 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
shell.go(Route::Entry(r));
}
}
let several = e.parties.len() > 1;
for share in &e.parties {
let Some(party) = b.ledger.get_party(share.id) else {
continue;
};
ui.add_space(4.0);
let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
if several {
text.push_str(&format!(" · {}", share_text(share)));
}
if widgets::link(ui, RichText::new(text).color(p.accent))
.on_hover_text(format!(
"{} this entry is with ({}): open them",
if several {
"One of the parties"
} else {
"The party"
},
super::parties::roles_text(party).to_lowercase()
))
.clicked()
{
shell.go(Route::Party(party.id));
}
}
if several && e.parties.iter().any(|x| x.resolved.is_none()) {
ui.add_space(4.0);
ui.label(
RichText::new(format!(
"{} The parties' shares don't add up to what this entry moved",
icon::WARNING
))
.color(p.negative),
);
}
if e.parties.is_empty()
&& let Some(why) = b.ledger.entry_no_party(e.id)
{
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(format!("{} No counterparty: {why}", icon::USER_MINUS))
.color(p.muted),
)
.wrap(),
)
.on_hover_text("Marked as having no counterparty (entry.no_party)");
}
if let Some(&asset) = b.entry_assets.get(&e.id) {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!("{} {}", icon::PACKAGE, b.asset_label(asset)))
.color(p.accent),
)
.on_hover_text("Open this fixed asset")
.clicked()
{
shell.go(Route::Asset(asset));
}
}
ui.add_space(16.0);
// Facts.
@@ -106,7 +171,11 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
ui.label(fmt::date(e.date));
ui.end_row();
k(ui, "Amount");
ui.label(RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0)));
widgets::copyable(
ui,
RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0)),
&fmt::plain(&fmt::dollars(e.amount)).unwrap_or_default(),
);
ui.end_row();
if let Some(c) = &created {
k(ui, "Recorded");
@@ -227,18 +296,25 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
});
ui.add_space(18.0);
// Receipts.
// Receipts and documents: filed on it, linked to it, or linked
// to the invoice, bill, or payroll run it records; a withdrawn
// receipt filed on it stays, marked.
let subjects = b.entry_subjects(e.id);
let all_for_entry: Vec<&Attachment> = b
.attachments
.iter()
.filter(|a| a.entry_id == Some(e.id))
.filter(|a| {
a.entry_id == Some(e.id)
|| (a.withdrawn_at.is_none()
&& b.links_of(a.id).iter().any(|s| subjects.contains(s)))
})
.collect();
section(
ui,
if all_for_entry.len() == 1 {
"Receipt"
} else {
"Receipts"
"Receipts and documents"
},
);
if all_for_entry.is_empty() {
@@ -254,6 +330,29 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
}));
}
for a in all_for_entry {
let elsewhere: Vec<ledger_store::Subject> = b
.links_of(a.id)
.iter()
.copied()
.filter(|s| *s != ledger_store::Subject::Entry(e.id))
.collect();
if !elsewhere.is_empty() {
let mut go = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
ui.label(
RichText::new("Linked to")
.font(theme::regular(11.5))
.color(p.faint),
);
for s in elsewhere {
super::documents::subject_chip(ui, b, s, &mut go);
}
});
if let Some(r) = go {
shell.go(r);
}
}
attachment_view(ui, b, a, shell, true);
ui.add_space(12.0);
}
@@ -406,3 +505,22 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid
}
}
}
/// One party's share of an entry, in words: "$50.00 (derived)", "$70.00",
/// "$33.34 (33.333%)", or the share alone when it doesn't resolve.
pub fn share_text(s: &crate::books::EntryParty) -> String {
let how = match s.share {
ledger_core::Share::Amount(_) => None,
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
ledger_core::Share::Derived => Some("derived".to_string()),
};
match (s.resolved, how) {
(Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())),
(Some(v), None) => fmt::dollars(v.abs()),
(None, Some(h)) => h,
(None, None) => match s.share {
ledger_core::Share::Amount(a) => fmt::dollars(a),
_ => String::new(),
},
}
}
+615 -26
View File
@@ -23,6 +23,8 @@ pub struct Event {
pub icon: &'static str,
pub text: String,
pub route: Option<Route>,
/// A second place it's about (the asset of a depreciation entry).
pub also: Option<Route>,
}
fn s<'a>(p: &'a Value, k: &str) -> &'a str {
@@ -51,6 +53,114 @@ fn attachment_name(b: &Books, id: u64) -> String {
.unwrap_or_else(|| format!("attachment {id}"))
}
/// "Tax basis of WL-0001: personal-use conversion, contributor's basis
/// $1,917.21 → tax basis $1,650.00". The tax basis is ledger-core's
/// (`Asset::tax_basis`) for the event's rule and basis on the asset's
/// book cost, which never changes once registered.
fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
let asset = asset_id.and_then(|id| b.ledger.get_asset(id));
let name = match (asset, asset_id) {
(Some(a), _) => a.tag.clone().unwrap_or_else(|| a.description.clone()),
(None, Some(id)) => format!("asset {id}"),
(None, None) => "an asset".to_string(),
};
let rule = ledger_core::TaxBasisRule::parse(s(p, "rule"));
let contributor: Option<rust_decimal::Decimal> = p
.get("contributor_basis")
.and_then(|v| v.as_str())
.and_then(|v| v.parse().ok());
let Some(rule) = rule else {
return format!("Tax basis of {name} recorded");
};
let tax = asset.and_then(|a| {
let mut a = a.clone();
a.tax = Some(ledger_core::TaxBasis {
rule,
contributor_basis: contributor,
});
a.tax_basis()
});
let mut text = format!("Tax basis of {name}: {}", super::assets::rule_words(rule));
if let Some(c) = contributor {
text.push_str(&format!(", contributor's basis {}", fmt::dollars(c)));
}
match (tax, contributor.is_some()) {
(Some(t), true) => text.push_str(&format!(" → tax basis {}", fmt::dollars(t))),
(Some(t), false) => text.push_str(&format!(", tax basis {}", fmt::dollars(t))),
(None, _) => {}
}
text
}
/// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in
/// words (Books::subject_label), and where it opens
/// (Books::subject_route).
fn subject_text(b: &Books, subject: Option<&Value>) -> (String, Option<Route>) {
let parsed =
subject.and_then(|v| serde_json::from_value::<ledger_store::Subject>(v.clone()).ok());
match parsed {
Some(ledger_store::Subject::Company) => ("the company".into(), Some(Route::Company)),
Some(sub) => (b.subject_label(sub), b.subject_route(sub)),
None => ("a subject".into(), None),
}
}
/// A date from the payload, in words ("Sep 30, 2026"), or as written.
fn date_text(p: &Value, k: &str) -> String {
let t = s(p, k);
t.parse::<chrono::NaiveDate>()
.map(fmt::date)
.unwrap_or_else(|_| t.to_string())
}
/// A party's label, or "a party" when the event names none.
fn party_name(b: &Books, id: Option<u64>) -> String {
match id {
Some(id) => b.party_label(id),
None => "a party".to_string(),
}
}
/// "Display name of Gumroad, Inc. set to Gumroad", "Archived Etsy": one
/// `party.updated` event in words. Names are today's (the display name's
/// change names the legal name, so it reads the same afterwards).
fn party_update_text(b: &Books, id: Option<u64>, change: Option<&Value>) -> String {
let name = party_name(b, id);
let legal = id
.and_then(|i| b.ledger.get_party(i))
.map_or_else(|| name.clone(), |p| p.name.clone());
let field = change
.and_then(|c| c.get("field"))
.and_then(|v| v.as_str())
.unwrap_or("");
let value = change.and_then(|c| c.get("value"));
let text = value.and_then(|v| v.as_str());
let words = match field {
"name" => "Name",
"display_name" => "Display name",
"website" => "Website",
"address" => "Address",
"notes" => "Notes",
_ => "",
};
match (field, text) {
("archived", _) => {
if value.and_then(|v| v.as_bool()).unwrap_or(false) {
format!("Archived {name}")
} else {
format!("Unarchived {name}")
}
}
("name", Some(t)) => format!("Renamed a party to {t}"),
("display_name", Some(t)) => format!("Display name of {legal} set to {t}"),
("display_name", None) => format!("Display name of {legal} cleared"),
("notes", Some(_)) => format!("Notes of {name} updated"),
("", _) => format!("Updated {name}"),
(_, Some(t)) => format!("{words} of {name} set to {t}"),
(_, None) => format!("{words} of {name} cleared"),
}
}
/// A sentence for a meaningful event; `None` for the intermediate steps of
/// building an entry (created, line added), which the posted event covers.
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
@@ -58,7 +168,32 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
let entry_id = p.get("entry_id").and_then(|v| v.as_u64());
let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0);
let ev = |icon, text: String, route: Option<Route>| Some(Event { icon, text, route });
let ev = |icon, text: String, route: Option<Route>| {
Some(Event {
icon,
text,
route,
also: None,
})
};
let asset_id = p.get("asset_id").and_then(|v| v.as_u64());
let asset = |id: Option<u64>| {
id.map(|a| {
b.ledger
.get_asset(a)
.map(|x| x.description.clone())
.unwrap_or_else(|| format!("asset {a}"))
})
.unwrap_or_default()
};
let with_asset = |icon, text: String| {
Some(Event {
icon,
text,
route: entry_id.map(Route::Entry),
also: asset_id.map(Route::Asset),
})
};
match line.line_type.as_str() {
"entry.created" | "entry.line_added" | "entry.line_class_set" => None,
"store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None),
@@ -111,6 +246,100 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
),
entry_id.map(Route::Entry),
),
"attachment.linked" | "attachment.unlinked" => {
let (what, route) = subject_text(b, p.get("subject"));
let name = attachment_name(b, att);
Some(Event {
icon: if line.line_type == "attachment.linked" {
icon::LINK
} else {
icon::LINK_BREAK
},
text: if line.line_type == "attachment.linked" {
format!("Linked {name} to {what}")
} else {
format!("Unlinked {name} from {what}: {}", s(p, "reason"))
},
route: Some(Route::Document(att)),
also: route,
})
}
"statement.recorded" => {
let account = p.get("account_id").and_then(|v| v.as_u64());
let closing = s(p, "closing_balance")
.parse()
.map(fmt::dollars)
.unwrap_or_default();
Some(Event {
icon: icon::FILE_TEXT,
text: format!(
"Recorded {}'s statement, {} – {}: closing balance {closing}",
account.map_or("an account".into(), |a| b.account_label(a)),
date_text(p, "period_start"),
date_text(p, "period_end")
),
route: Some(Route::Document(att)),
also: account.map(Route::Account),
})
}
"statement.voided" => Some(Event {
icon: icon::PROHIBIT,
text: format!(
"Voided the statement recorded on {}: {}",
attachment_name(b, att),
s(p, "reason")
),
route: Some(Route::Document(att)),
also: None,
}),
"reconciliation.completed" => {
let account = p.get("account_id").and_then(|v| v.as_u64());
let balance = s(p, "statement_balance")
.parse()
.map(fmt::dollars)
.unwrap_or_default();
let lines = p
.get("cleared")
.and_then(|v| v.as_array())
.map_or(0, |v| v.len());
Some(Event {
icon: icon::CHECK_SQUARE,
text: format!(
"Reconciled {} through {} at {balance} ({} cleared)",
account.map_or("an account".into(), |a| b.account_label(a)),
date_text(p, "statement_date"),
fmt::count(lines, "line", "lines")
),
route: p
.get("id")
.and_then(|v| v.as_u64())
.map(Route::Reconciliation),
also: account.map(Route::Account),
})
}
"reconciliation.voided" => {
let r = p
.get("id")
.and_then(|v| v.as_u64())
.and_then(|i| b.reconciliation(i));
Some(Event {
icon: icon::PROHIBIT,
text: match r {
Some(r) => format!(
"Voided the reconciliation of {} through {}: {}",
b.account_label(r.account_id),
fmt::date(r.statement_date),
s(p, "reason")
),
None => format!("Voided reconciliation {id}: {}", s(p, "reason")),
},
route: p
.get("id")
.and_then(|v| v.as_u64())
.map(Route::Reconciliation),
also: r.map(|r| Route::Account(r.account_id)),
})
}
"attachment.withdrawn" => ev(
icon::ARCHIVE,
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
@@ -121,6 +350,39 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Added account {} {}", s(p, "code"), s(p, "name")),
Some(Route::Account(id)),
),
"account.renumbered" => ev(
icon::TAG,
format!(
"Renumbered {} from {}",
b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)),
s(p, "previous_code")
),
p.get("account_id")
.and_then(|v| v.as_u64())
.map(Route::Account),
),
"role.assigned" => {
let role = s(p, "role");
let control = ledger_core::control_account(role);
let purpose = control.map_or(role, |c| c.role);
let mut chars = purpose.chars();
let purpose: String = chars
.next()
.map(|f| f.to_uppercase().chain(chars).collect())
.unwrap_or_default();
let account = p.get("account_id").and_then(|v| v.as_u64());
ev(
icon::TAG,
match account {
Some(id) => format!("{purpose} now posts to {}", b.account_label(id)),
None => format!(
"{purpose} back to its default code {}",
control.map_or("", |c| c.code)
),
},
account.map(Route::Account),
)
}
"account.tax_set" => ev(
icon::TAG,
format!(
@@ -137,24 +399,246 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
"vendor.added" => ev(
icon::STOREFRONT,
format!("Added vendor {}", s(p, "name")),
Some(Route::Company),
Some(
p.get("id")
.and_then(|v| v.as_u64())
.and_then(|v| b.ledger.party_of_vendor(v))
.map_or(Route::Company, Route::Party),
),
),
"customer.added" => ev(
icon::USER_PLUS,
format!("Added customer {}", s(p, "name")),
Some(Route::Company),
Some(
p.get("id")
.and_then(|v| v.as_u64())
.and_then(|c| b.ledger.party_of_customer(c))
.map_or(Route::Company, Route::Party),
),
),
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
"vendor.1099_set" => ev(
icon::IDENTIFICATION_CARD,
"Updated a vendor's 1099 details".into(),
Some(Route::Company),
),
"entry.vendor_set" => ev(
icon::STOREFRONT,
format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
"party.added" => {
let roles: Vec<String> = p
.get("roles")
.and_then(|v| v.as_array())
.map(|rs| {
rs.iter()
.filter_map(|r| r.as_str().and_then(ledger_core::Role::parse))
.map(|r| super::parties::role_label(r).to_lowercase())
.collect()
})
.unwrap_or_default();
let name = match s(p, "name") {
"" => party_name(b, p.get("id").and_then(|v| v.as_u64())),
n => n.to_string(),
};
ev(
icon::ADDRESS_BOOK,
if roles.is_empty() {
format!("Added party {name}")
} else {
format!("Added party {name} ({})", roles.join(", "))
},
p.get("id").and_then(|v| v.as_u64()).map(Route::Party),
)
}
"party.updated" => {
let pid = p.get("id").and_then(|v| v.as_u64());
ev(
icon::PENCIL_SIMPLE,
party_update_text(b, pid, p.get("change")),
pid.map(Route::Party),
)
}
"party.role_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let role = ledger_core::Role::parse(s(p, "role"));
let on = p.get("on").and_then(|v| v.as_bool()).unwrap_or(true);
let text = match (role, on) {
(Some(r), true) => format!("{name} is now {}", super::parties::role_noun(r)),
(Some(r), false) => format!("{name} is no longer {}", super::parties::role_noun(r)),
(None, _) => format!("Changed the roles of {name}"),
};
ev(icon::USER_GEAR, text, pid.map(Route::Party))
}
"party.w9_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let text = match p.get("facts").filter(|f| !f.is_null()) {
Some(f) => {
let mut t = format!("W-9 for {name} recorded");
if let Some(d) = f.get("received").and_then(|v| v.as_str()) {
t.push_str(&format!(", received {d}"));
}
if let Some(e) = f.get("entity_type").and_then(|v| v.as_str()) {
t.push_str(&format!(": {e}"));
}
if let Some(l4) = f.get("tin_last4").and_then(|v| v.as_str()) {
t.push_str(&format!(", TIN ending {l4}"));
}
t
}
None => format!("W-9 facts for {name} cleared"),
};
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
}
"party.tin_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let text = match p.get("facts").filter(|f| !f.is_null()) {
Some(f) => {
let kind = match f.get("tin_type").and_then(|v| v.as_str()) {
Some("ssn") => "SSN",
Some("ein") => "EIN",
Some("itin") => "ITIN",
_ => "TIN",
};
let l4 = f.get("last4").and_then(|v| v.as_str()).unwrap_or("????");
if f.get("purged").is_some_and(|v| !v.is_null()) {
format!("Purged {name}'s {kind} (••••{l4}) from the vault")
} else {
format!(
"Taxpayer ID for {name} recorded: {kind} ending {l4}, kept in the vault"
)
}
}
None => format!("Taxpayer ID facts for {name} cleared"),
};
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
}
"party.w4_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let text = match p.get("facts").filter(|f| !f.is_null()) {
Some(f) => {
let mut t = format!("W-4 for {name} recorded");
if let Some(d) = f.get("received").and_then(|v| v.as_str()) {
t.push_str(&format!(", received {d}"));
}
if let Some(st) = f.get("state").and_then(|v| v.as_str()) {
t.push_str(&format!(" (works in {st})"));
}
t
}
None => format!("W-4 facts for {name} cleared"),
};
ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party))
}
"entry.parties_set" => {
let e = entry_id.unwrap_or(0);
let named: Vec<(u64, String)> = p
.get("parties")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|x| {
let id = x.get("party_id").and_then(|v| v.as_u64())?;
let share = match x.get("share") {
Some(Value::Object(o)) => o
.get("amount")
.and_then(|v| v.as_str())
.map(|a| {
rust_decimal::Decimal::from_str_exact(a)
.map(fmt::dollars)
.unwrap_or_else(|_| a.to_string())
})
.or_else(|| {
o.get("percent")
.and_then(|v| v.as_str())
.map(|pc| format!("{pc}%"))
}),
_ => None,
};
let name = party_name(b, Some(id));
Some((
id,
match share {
Some(sh) => format!("{name} ({sh})"),
None => name,
},
))
})
.collect();
Some(Event {
icon: icon::ADDRESS_BOOK,
text: if named.is_empty() {
format!("Cleared the parties of #{e}")
} else {
format!(
"Tagged #{e} with {}",
named
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(" and ")
)
},
route: entry_id.map(Route::Entry),
also: named.first().map(|(id, _)| Route::Party(*id)),
})
}
"entry.party_set" => {
let pid = p.get("party_id").and_then(|v| v.as_u64());
let e = entry_id.unwrap_or(0);
Some(Event {
icon: icon::ADDRESS_BOOK,
text: match pid {
Some(_) => format!("Tagged #{e} with {}", party_name(b, pid)),
None => format!("Cleared the party of #{e}"),
},
route: entry_id.map(Route::Entry),
also: pid.map(Route::Party),
})
}
"vendor.1099_set" => {
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
let name = vendor
.and_then(|v| b.ledger.get_vendor(v))
.map(|v| v.name.clone());
let form = p
.get("form_1099")
.and_then(|v| v.as_str())
.and_then(ledger_core::Form1099::parse)
.map(|f| f.label());
let w9 = p.get("tin_on_file").and_then(|v| v.as_bool());
let text = match (name, form, w9) {
(Some(n), Some(f), Some(w)) => format!(
"1099 details of {n}: {f}, W-9 {}",
if w { "on file" } else { "not on file" }
),
(Some(n), None, Some(_)) => format!("{n} no longer gets a 1099"),
(Some(n), _, None) => format!("Updated the 1099 details of {n}"),
(None, ..) => "Updated a vendor's 1099 details".into(),
};
ev(
icon::IDENTIFICATION_CARD,
text,
Some(
vendor
.and_then(|v| b.ledger.party_of_vendor(v))
.map_or(Route::Company, Route::Party),
),
)
}
"entry.vendor_set" => {
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
let e = entry_id.unwrap_or(0);
Some(Event {
icon: icon::STOREFRONT,
text: match vendor.and_then(|v| b.ledger.get_vendor(v)) {
Some(v) => format!("Tagged #{e} with vendor {}", v.name),
None if vendor.is_none() && p.get("vendor_id").is_some() => {
format!("Cleared the vendor of #{e}")
}
None => format!("Tagged #{e} with a vendor"),
},
route: entry_id.map(Route::Entry),
also: vendor
.and_then(|v| b.ledger.party_of_vendor(v))
.map(Route::Party),
})
}
"invoice.created" => ev(
icon::INVOICE,
format!("Invoice {id} created"),
@@ -190,17 +674,93 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
"asset.registered" => ev(
icon::PACKAGE,
format!("Registered asset {}", s(p, "description")),
Some(Route::Company),
Some(Route::Asset(id)),
),
"asset.depreciation_posted" => ev(
"asset.tagged" => {
let tag = s(p, "tag");
let text = match p.get("previous_tag").and_then(|v| v.as_str()) {
Some(old) => format!("Retagged {} from {old} to {tag}", asset(asset_id)),
None => format!("Tagged {} {tag}", asset(asset_id)),
};
ev(icon::TAG, text, asset_id.map(Route::Asset))
}
"asset.serial_set" => {
let text = match p.get("serial").and_then(|v| v.as_str()) {
Some(serial) => format!("Serial number of {} set to {serial}", asset(asset_id)),
None => format!("Serial number of {} cleared", asset(asset_id)),
};
ev(icon::BARCODE, text, asset_id.map(Route::Asset))
}
"asset.tax_basis_set" => ev(
icon::SCALES,
tax_basis_text(b, asset_id, p),
asset_id.map(Route::Asset),
),
"asset.corrected" => ev(
icon::PENCIL_SIMPLE,
format!(
"Corrected {}: cost {} (was {}), {} months from {}. {}",
asset(asset_id),
p.get("cost")
.and_then(|c| c.get("amount"))
.and_then(|v| v.as_str())
.unwrap_or("?"),
p.get("previous")
.and_then(|c| c.get("cost"))
.and_then(|c| c.get("amount"))
.and_then(|v| v.as_str())
.unwrap_or("?"),
p.get("useful_life_months")
.and_then(|v| v.as_u64())
.unwrap_or(0),
s(p, "purchase_date"),
s(p, "reason")
),
asset_id.map(Route::Asset),
),
"asset.described" => ev(
icon::PENCIL_SIMPLE,
format!(
"Renamed asset {} to {}",
s(p, "previous_description"),
s(p, "description")
),
asset_id.map(Route::Asset),
),
"asset.acquisition_linked" => {
let entry = p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0);
Some(Event {
icon: icon::LINK,
text: format!(
"Linked {} to the entry that acquired it, #{entry}",
asset(asset_id)
),
route: asset_id.map(Route::Asset),
also: Some(Route::Entry(entry)),
})
}
"asset.depreciation_posted" => with_asset(
icon::TREND_DOWN,
"Depreciation posted".into(),
entry_id.map(Route::Entry),
format!(
"Depreciation posted: {} {} ({})",
asset(asset_id),
s(p, "period"),
amount(p)
),
),
"asset.disposed" => ev(
"asset.disposed" => with_asset(
icon::TRASH,
"Asset disposed".into(),
entry_id.map(Route::Entry),
format!(
"Disposed {} on {} for {}",
asset(asset_id),
s(p, "date"),
p.get("proceeds")
.and_then(|v| v.get("amount"))
.and_then(|v| v.as_str())
.and_then(|a| a.parse().ok())
.map(fmt::dollars)
.unwrap_or_default()
),
),
"schedule.registered" => ev(
icon::CALENDAR,
@@ -220,6 +780,11 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Recurring: {}", s(p, "description")),
Some(Route::Company),
),
"journal.tail_repaired" => ev(
icon::WARNING,
format!("Cut-off journal line set aside ({} bytes)", p["bytes"]),
None,
),
"recurring.generated" => ev(
icon::REPEAT,
"Recurring draft created".into(),
@@ -235,6 +800,23 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
"Payroll run".into(),
entry_id.map(Route::Entry),
),
"payroll.taxes_remitted" => ev(
icon::BANK,
format!(
"Remitted payroll taxes ({})",
fmt::dollars(s(p, "total").parse().unwrap_or_default())
),
entry_id.map(Route::Entry),
),
"entry.no_party" => ev(
icon::USER_MINUS,
format!(
"Entry #{} marked as having no counterparty: {}",
entry_id.unwrap_or(0),
s(p, "reason")
),
entry_id.map(Route::Entry),
),
"line.cleared" => ev(
icon::CHECK_SQUARE,
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
@@ -264,6 +846,7 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
icon: icon::PENCIL_SIMPLE,
text: format!("Drafted #{id} {}", s(p, "description")),
route: Some(Route::Entry(id)),
also: None,
},
"entry.line_added" => {
let amount = p
@@ -286,6 +869,7 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
),
),
route: Some(Route::Entry(id)),
also: None,
}
}
"entry.line_class_set" => {
@@ -304,12 +888,14 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
.map_or(0, |i| i + 1)
),
route: Some(Route::Entry(id)),
also: None,
}
}
t => Event {
icon: icon::DOT,
text: t.to_string(),
route: None,
also: None,
},
}
}
@@ -464,14 +1050,17 @@ fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
fact(ui, "Previous", &line.prev);
let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes());
fact(ui, "This hash", &own);
if let Some(r) = &ev.route {
for r in ev.route.iter().chain(&ev.also) {
ui.add_space(6.0);
if ui
.button(format!(
"{} Open {}",
icon::ARROW_SQUARE_OUT,
crate::palette::describe(b, r)
))
.button(format!("{} Open {}", icon::ARROW_SQUARE_OUT, {
let d = crate::palette::describe(b, r);
if d.is_empty() {
r.section().title().to_string()
} else {
d
}
}))
.clicked()
{
shell.go(r.clone());
+9
View File
@@ -1,13 +1,17 @@
//! One module per screen.
pub mod accounts;
pub mod assets;
pub mod capital;
pub mod cash;
pub mod company;
pub mod documents;
pub mod entry;
pub mod journal;
pub mod overview;
pub mod parties;
pub mod profit;
pub mod reconciliation;
pub mod reports;
pub mod transactions;
@@ -52,6 +56,11 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
Route::Reports(kind) => reports::show(ui, b, *kind, shell),
Route::Accounts => accounts::show_chart(ui, b, shell),
Route::Account(id) => accounts::show_account(ui, b, *id, shell),
Route::Reconciliation(id) => reconciliation::show(ui, b, *id, shell),
Route::Assets => assets::show_register(ui, b, shell),
Route::Asset(id) => assets::show_asset(ui, b, *id, shell),
Route::Parties => parties::show_list(ui, b, shell),
Route::Party(id) => parties::show_party(ui, b, *id, shell),
Route::Documents => documents::show(ui, b, None, shell),
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
Route::Company => company::show(ui, b, shell),
+102 -58
View File
@@ -21,28 +21,28 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
widgets::page_header(ui, b.company_name(), &headline(b));
kpis(ui, b, shell);
let _ = k;
columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i {
0 => {
let (more, _) = widgets::titled_card(
// What needs doing gets the page's full width, under the numbers:
// long items wrap in their own room instead of squeezing the chart.
attention(ui, b, shell);
ui.add_space(16.0);
let (more, _) = widgets::titled_card(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
}
_ => attention(ui, b, shell),
});
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
ui.add_space(16.0);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => income_vs_spending(ui, b, shell),
_ => where_it_went(ui, b, shell),
@@ -253,33 +253,52 @@ fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
}
}
/// "Needs attention", full width: a summary of how many problems, to-dos,
/// and notes there are, then each item as a row (its severity, the title
/// that opens where to look, what's wrong, and the prompt for the agent at
/// the right; stacked under the text when narrow). The first few show;
/// the rest wait behind a button that says how many.
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let count = |s: Severity| b.attention.iter().filter(|a| a.severity == s).count();
widgets::titled_card(
ui,
"Needs attention",
|ui| {
if !b.attention.is_empty() {
widgets::pill(
ui,
&b.attention.len().to_string(),
p.warning,
p.warning_soft,
);
// Laid out right to left: problems end up first, reading on.
for (sev, one, many, fg, bg) in [
(Severity::Info, "note", "notes", p.muted, p.hover),
(Severity::Todo, "to do", "to do", p.warning, p.warning_soft),
(
Severity::Problem,
"problem",
"problems",
p.negative,
p.negative_soft,
),
] {
let n = count(sev);
if n > 0 {
widgets::pill(ui, &fmt::count(n, one, many), fg, bg);
}
}
},
|ui| {
if b.attention.is_empty() {
widgets::empty(
ui,
icon::CHECK_CIRCLE,
"All clear",
"Nothing is missing or overdue.",
);
ui.horizontal(|ui| {
ui.label(
RichText::new(icon::CHECK_CIRCLE)
.size(18.0)
.color(p.positive),
);
ui.label(
RichText::new("All clear: nothing is missing or overdue.")
.font(theme::regular(13.5))
.color(p.muted),
);
});
return;
}
// No inner scrolling: the card grows to fit. Past a handful of
// items, the rest sit behind a button that says how many.
const FIRST: usize = 6;
let id = ui.id().with("attention-expanded");
let mut expanded: bool = ui.ctx().data(|d| d.get_temp(id)).unwrap_or(false);
@@ -288,34 +307,59 @@ fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
} else {
b.attention.len().min(FIRST)
};
let wide = ui.available_width() >= 720.0;
for (i, a) in b.attention.iter().take(shown).enumerate() {
if i > 0 {
ui.add_space(4.0);
ui.add_space(6.0);
widgets::rule(ui);
ui.add_space(4.0);
ui.add_space(6.0);
}
let (glyph, color) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning),
Severity::Info => (icon::INFO, p.muted),
let (glyph, color, word) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative, "Problem"),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning, "To do"),
Severity::Info => (icon::INFO, p.muted, "Note"),
};
let full = ui.available_width();
let button_w = if wide { 150.0 } else { 0.0 };
ui.horizontal_top(|ui| {
ui.label(RichText::new(glyph).size(16.0).color(color));
ui.vertical(|ui| {
let t = RichText::new(&a.title).font(theme::semibold(13.0));
let r = widgets::link(ui, t);
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.label(
RichText::new(&a.detail)
.font(theme::regular(12.0))
.color(p.muted),
);
widgets::copy_prompt(ui, &a.prompt);
});
ui.label(RichText::new(glyph).size(18.0).color(color))
.on_hover_text(word);
let text_w = (full - 30.0 - button_w).max(120.0);
ui.allocate_ui_with_layout(
egui::vec2(text_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_width(text_w);
ui.spacing_mut().item_spacing.y = 2.0;
let t = RichText::new(&a.title).font(theme::semibold(13.5));
let r = ui.add(egui::Label::new(t).wrap().sense(egui::Sense::click()));
if a.target.is_some() && r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.add(
egui::Label::new(
RichText::new(&a.detail)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
if !wide {
ui.add_space(4.0);
widgets::copy_prompt(ui, &a.prompt);
}
},
);
if wide {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
widgets::copy_prompt(ui, &a.prompt);
});
}
});
}
let hidden = b.attention.len() - shown;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,417 @@
//! One reconciliation: the account and statement it agrees with, the
//! balance, every line it found cleared (each opening its entry), and the
//! lines on the account through that date it did not clear. Shared with
//! the account page and "Needs attention": the prompt that asks the agent
//! to reconcile an account, and where a reconciliation stands.
use super::assets::{Notice, facts, notice_box, row};
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::NaiveDate;
use egui::{RichText, Ui};
use egui_phosphor::regular as icon;
use ledger_core::{AccountId, EntryId, Side};
use ledger_store::{Reconciliation, Subject};
use rust_decimal::Decimal;
/// The agent prompt that reconciles an account through a date.
///
/// ```text
/// GOAL: one paste gets the agent to do the monthly reconciliation the
/// way the books require it (docs/parties-and-attachments.md,
/// Reconciliations: a statement recorded and attached, the
/// difference exactly zero).
/// INVARIANTS:
/// 1. Starts "In wanderbooks,"; names the account's code and name, the
/// date (YYYY-MM-DD), books.record_statement, books.mark_cleared,
/// and books.reconcile.
/// 2. Says where it last stood: reconciled through a date, or never.
/// ```
pub fn reconcile_prompt(b: &Books, account: AccountId, through: NaiveDate) -> String {
let since = match b.reconciled_through(account) {
Some(d) => format!("It is reconciled through {d}, so start with the statement after that"),
None => "It has never been reconciled, so start with its first statement".to_string(),
};
format!(
"In wanderbooks, reconcile {} through {through}. {since}. For each monthly statement: file the PDF, record it with books.record_statement (its period and opening and closing balances), mark the lines that cleared with books.mark_cleared, then run books.reconcile against it (preview first; the difference must be exactly zero). Show me anything that doesn't match, and the account when it's done.",
b.account_label(account)
)
}
/// Where a reconciliation stands among its account's.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Standing {
/// The account's newest reconciliation not voided.
Current,
/// A later reconciliation not voided supersedes it.
Superseded,
/// Voided, with the reason.
Voided(String),
}
/// A reconciliation's [`Standing`].
pub fn standing(b: &Books, r: &Reconciliation) -> Standing {
match &r.voided {
Some(why) => Standing::Voided(why.clone()),
None if b.reconciled_through(r.account_id) == Some(r.statement_date) => Standing::Current,
None => Standing::Superseded,
}
}
/// A line's amount in the account's normal direction (a debit raises an
/// asset, a credit raises a liability).
fn signed(b: &Books, account: AccountId, side: Side, amount: Decimal) -> Decimal {
let normal = b
.ledger
.chart()
.get(account)
.map_or(Side::Debit, |a| a.account_type.normal_side());
if side == normal { amount } else { -amount }
}
/// Every posted line on the reconciliation's account dated on or before
/// its statement date, as (entry, line index, date, description, signed
/// amount, cleared by it), by date then entry.
pub fn lines(
b: &Books,
r: &Reconciliation,
) -> Vec<(EntryId, usize, NaiveDate, String, Decimal, bool)> {
let mut out = Vec::new();
for e in b.ledger.posted_entries() {
if e.date() > r.statement_date {
continue;
}
for (i, l) in e.lines().iter().enumerate() {
if l.account == r.account_id {
out.push((
e.id(),
i,
e.date(),
e.description().to_string(),
signed(b, r.account_id, l.side, l.amount.amount),
r.cleared.contains(&(e.id(), i)),
));
}
}
}
out.sort_by(|a, b| a.2.cmp(&b.2).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1)));
out
}
pub fn show(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(r) = b.reconciliation(id) else {
page(ui, "reconciliation", |ui| {
widgets::empty(
ui,
icon::CHECK_SQUARE,
"No such reconciliation",
"It isn't in these books.",
);
});
return;
};
let mut go: Option<Route> = None;
page(ui, "reconciliation", |ui| {
ui.horizontal(|ui| {
if widgets::link(
ui,
RichText::new(format!(
"{} {}",
icon::ARROW_LEFT,
b.account_label(r.account_id)
))
.font(theme::regular(12.5))
.color(p.muted),
)
.clicked()
{
go = Some(Route::Account(r.account_id));
}
widgets::copyable(
ui,
RichText::new(format!("· Reconciliation {id}"))
.font(theme::regular(12.5))
.color(p.muted),
&id.to_string(),
);
});
ui.add_space(4.0);
ui.label(
RichText::new(format!(
"{} reconciled through {}",
b.account_name(r.account_id),
fmt::date(r.statement_date)
))
.font(theme::bold(26.0)),
);
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| match standing(b, r) {
Standing::Current => {
widgets::pill(
ui,
&format!("{} Current", icon::CHECK),
p.positive,
p.positive_soft,
);
}
Standing::Superseded => {
widgets::pill(ui, "Superseded by a later reconciliation", p.muted, p.hover);
}
Standing::Voided(why) => {
widgets::pill(ui, &format!("Voided: {why}"), p.warning, p.warning_soft);
}
});
ui.add_space(14.0);
let all = lines(b, r);
let cleared: Vec<_> = all.iter().filter(|l| l.5).collect();
let outstanding: Vec<_> = all.iter().filter(|l| !l.5).collect();
let cleared_total: Decimal = cleared.iter().map(|l| l.4).sum();
let st = b.statement(r.attachment_id);
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
widgets::titled_card(
ui,
"Reconciliation",
|_| {},
|ui| {
facts(ui, |ui, k| {
k(ui, "Account");
if widgets::link_truncated(
ui,
RichText::new(b.account_label(r.account_id)).color(p.accent),
)
.clicked()
{
go = Some(Route::Account(r.account_id));
}
ui.end_row();
k(ui, "Through");
ui.label(fmt::date(r.statement_date));
ui.end_row();
k(ui, "Statement balance");
widgets::copyable(
ui,
RichText::new(fmt::dollars(r.statement_balance))
.font(theme::semibold(14.0)),
&r.statement_balance.to_string(),
);
ui.end_row();
k(ui, "Cleared lines");
ui.label(format!(
"{} totaling {}",
fmt::count(r.cleared.len(), "line", "lines"),
fmt::dollars(cleared_total)
));
ui.end_row();
k(ui, "Books' balance then");
ui.label(fmt::dollars(b.balance_on(r.account_id, r.statement_date)));
ui.end_row();
});
ui.add_space(6.0);
ui.add(
egui::Label::new(
RichText::new("Recorded only when the cleared lines agree with the statement to the cent. A reconciliation is never edited: a mistake is voided and redone.")
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
},
);
} else {
widgets::titled_card(
ui,
"Statement",
|_| {},
|ui| {
let file = b.attachment(r.attachment_id);
facts(ui, |ui, k| {
if let Some(st) = st {
k(ui, "Period");
ui.label(format!(
"{} – {}",
fmt::date(st.period_start),
fmt::date(st.period_end)
));
ui.end_row();
k(ui, "Opening");
ui.label(fmt::dollars(st.opening_balance));
ui.end_row();
k(ui, "Closing");
ui.label(fmt::dollars(st.closing_balance));
ui.end_row();
}
k(ui, "File");
let name = file.map_or_else(
|| format!("attachment {}", r.attachment_id),
|a| a.file_name.clone(),
);
if widgets::link_truncated(
ui,
RichText::new(format!("{} {name}", icon::FILE_PDF)).color(p.accent),
)
.clicked()
{
go = Some(Route::Document(r.attachment_id));
}
ui.end_row();
});
},
);
}
});
lines_card(
ui,
b,
"Cleared lines",
&cleared,
"It cleared no lines (the account had no activity through this date).",
&mut go,
);
ui.add_space(14.0);
if !outstanding.is_empty() {
notice_box(
ui,
&Notice {
problem: false,
text: format!(
"{} on the account through {} had not cleared the bank by this statement (outstanding then).",
fmt::count(outstanding.len(), "line", "lines"),
fmt::date(r.statement_date)
),
},
);
lines_card(ui, b, "Outstanding then", &outstanding, "", &mut go);
ui.add_space(14.0);
}
super::documents::documents_card(
ui,
b,
"Documents",
&[Subject::Reconciliation(id)],
"No documents linked to it.",
&mut go,
);
ui.add_space(14.0);
widgets::titled_card(
ui,
"History",
|_| {},
|ui| {
for line in b.journal.iter().filter(|l| {
matches!(
l.line_type.as_str(),
"reconciliation.completed" | "reconciliation.voided"
) && l.payload.get("id").and_then(|v| v.as_u64()) == Some(id)
}) {
let ev = super::journal::describe_any(b, line);
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).color(p.muted));
if widgets::link_truncated(
ui,
RichText::new(&ev.text).font(theme::regular(12.5)),
)
.clicked()
{
go = Some(Route::JournalEvent(line.seq));
}
ui.label(
RichText::new(format!(
"event {} · {}",
line.seq,
fmt::timestamp(&line.ts)
))
.font(theme::regular(11.0))
.color(p.faint),
);
});
}
},
);
});
if let Some(to) = go {
shell.go(to);
}
}
type Line = (EntryId, usize, NaiveDate, String, Decimal, bool);
/// A table of account lines, each opening its entry.
fn lines_card(
ui: &mut Ui,
b: &Books,
title: &str,
rows: &[&Line],
empty: &str,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
title,
|ui| {
if !rows.is_empty() {
widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover);
}
},
|ui| {
if rows.is_empty() {
ui.label(RichText::new(empty).color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 16.0;
let w = ui.available_width().max(104.0 + 70.0 + 200.0 + amt);
widgets::min_width(ui, title, w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Date", 104.0, false),
head("Entry", 70.0, false),
head("Description", 0.0, false),
head("Amount", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for (e, _, date, desc, amount, _) in rows.iter().copied() {
let resp = row(
ui,
w,
&[
(fmt::date(*date), 104.0, false, p.muted),
(format!("#{e}"), 70.0, false, p.muted),
(desc.clone(), 0.0, false, p.text),
(
fmt::money(*amount),
amt,
true,
if *amount < Decimal::ZERO {
p.negative
} else {
p.text
},
),
],
None,
false,
true,
);
if resp.on_hover_text("Open its entry").clicked() {
*go = Some(Route::Entry(*e));
}
}
});
let _ = b;
},
);
}
+281 -4
View File
@@ -554,7 +554,12 @@ fn balance_sheet(b: &Books, dates: &[(NaiveDate, String)]) -> Table {
t
}
/// Financing accounts: from `[books] financing_accounts`, else liabilities
/// whose names say they are debt.
fn financing_accounts(b: &Books) -> Vec<AccountId> {
if !b.financing_accounts.is_empty() {
return b.financing_accounts.clone();
}
b.ledger
.chart()
.all()
@@ -585,6 +590,7 @@ fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table {
end: *e,
cash_accounts: b.cash_accounts.clone(),
financing_accounts: fin.clone(),
investing_accounts: b.investing_accounts.clone(),
},
)
.ok()
@@ -857,6 +863,13 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table {
net,
vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())],
);
if !ts.separately_stated.is_empty() {
sec(
&mut t,
"Separately stated to owners (Schedule K)",
&ts.separately_stated,
);
}
if !ts.uncategorized.is_empty() {
t.section("Not categorized");
for u in &ts.uncategorized {
@@ -930,6 +943,154 @@ fn aging(rows: Vec<ledger_core::AgingRow>, as_of: NaiveDate) -> Table {
t
}
/// The fixed asset register on `as_of`: ledger-core's
/// `fixed_asset_register`, laid out, with its reconciliation to the
/// general ledger and what that says (the Assets page's notices). Each
/// asset's federal tax basis sits beside its book figures: contributor's
/// basis, tax basis, and built-in gain (loss), the register row's own,
/// with its rule (and citation) in the detail; the tax basis total is the
/// register's.
fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
let r = b.asset_register(as_of);
let mut t = Table::new(vec![
"Cost".into(),
"Accumulated".into(),
"Book value".into(),
"Contributor's basis".into(),
"Tax basis".into(),
"Built-in gain (loss)".into(),
]);
let none3 = || vec![Cell::none(), Cell::none(), Cell::none()];
if r.rows.is_empty() {
t.note(format!(
"No fixed asset was in service by {}.",
fmt::date(as_of)
));
return t;
}
t.section("Assets");
for x in &r.rows {
let label = match &x.tag {
Some(tag) => format!("{tag} · {}", x.description),
None => format!("{} (no tag)", x.description),
};
let mut detail = Vec::new();
if let Some(s) = &x.serial {
detail.push(format!("serial {s}"));
}
detail.push(format!("in service {}", fmt::date(x.in_service)));
detail.push(fmt::count(x.useful_life_months as usize, "month", "months"));
if !x.salvage.is_zero() {
detail.push(format!("salvage {}", fmt::dollars(x.salvage)));
}
let tax = super::assets::TaxFacts::of_row(x);
detail.push(match tax.rule {
Some(rule) => format!(
"tax basis: {}",
ledger_import::asset_register::rule_cited(rule)
),
None => "tax basis not recorded".into(),
});
let opt = |v: Option<Decimal>| Cell {
value: v,
drill: None,
};
let cells = match x.disposed {
Some(_) => {
detail.push(format!(
"{}; cost {}",
super::assets::status_text(x.disposed),
fmt::dollars(x.cost)
));
[none3(), none3()].concat()
}
None => vec![
Cell::of(x.cost),
Cell::of(x.accumulated),
Cell::of(x.book_value),
opt(tax.contributor_basis),
opt(tax.tax_basis),
opt(tax.built_in_gain),
],
};
t.rows.push(Row {
kind: Kind::Line,
label,
detail: detail.join(" · "),
cells,
});
}
t.row(
Kind::Grand,
"Total held",
vec![
Cell::of(r.total_cost),
Cell::of(r.total_accumulated),
Cell::of(r.total_book_value),
Cell::none(),
Cell::of(r.total_tax_basis),
Cell::none(),
],
);
t.section("Reconciliation to the books");
let start = b.kpis.first_entry.unwrap_or(as_of).min(as_of);
let held: Vec<_> = r
.rows
.iter()
.filter_map(|x| b.ledger.get_asset(x.asset_id))
.collect();
// One account behind a figure drills down to it.
let only = |ids: Vec<AccountId>| -> Option<Drill> {
let mut ids = ids;
ids.sort_unstable();
ids.dedup();
(ids.len() == 1).then(|| Drill {
account: ids[0],
start,
end: as_of,
})
};
let cost_drill = only(held.iter().map(|a| a.cost_account).collect());
let accum_drill = only(held.iter().map(|a| a.accum_depr_account).collect());
t.row(
Kind::Line,
"In the books",
vec![
Cell {
value: Some(r.ledger_cost),
drill: cost_drill,
},
Cell {
value: Some(r.ledger_accumulated),
drill: accum_drill,
},
Cell::of(r.ledger_cost - r.ledger_accumulated),
Cell::none(),
Cell::none(),
Cell::none(),
],
);
t.row(
Kind::Subtotal,
"Register less books",
vec![
Cell::of(-r.unregistered_cost()),
Cell::of(r.unposted_depreciation()),
Cell::of(-r.unregistered_cost() - r.unposted_depreciation()),
Cell::none(),
Cell::none(),
Cell::none(),
],
);
for n in super::assets::notices(b, &r) {
t.note(n.text);
}
t.note(
"Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule and can differ. Built-in gain (loss) is that value less the contributor's basis (IRC §704(c)). Tax depreciation starts from the tax basis — the CPA's.",
);
t
}
// ----------------------------------------------------------------------
// The page.
@@ -946,7 +1107,11 @@ const GROUPS: [(&str, &[ReportKind]); 3] = [
),
(
"Ledgers",
&[ReportKind::TrialBalance, ReportKind::GeneralLedger],
&[
ReportKind::TrialBalance,
ReportKind::GeneralLedger,
ReportKind::FixedAssetRegister,
],
),
(
"Tax and aging",
@@ -1029,7 +1194,10 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
let p = pal(ui.ctx());
let point_in_time = matches!(
kind,
ReportKind::TrialBalance | ReportKind::ArAging | ReportKind::ApAging
ReportKind::TrialBalance
| ReportKind::ArAging
| ReportKind::ApAging
| ReportKind::FixedAssetRegister
) || (kind == ReportKind::BalanceSheet
&& shell.state.reports.columns == Columns::Total);
let splittable = matches!(
@@ -1218,6 +1386,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke
Some(t)
}
ReportKind::TrialBalance => Some(trial_balance(b, e)),
ReportKind::FixedAssetRegister => Some(asset_register(b, e)),
ReportKind::MembersEquity => Some(owners_equity(b, s, e)),
ReportKind::TaxSummary => {
let mut t = tax(b, s, e);
@@ -1527,15 +1696,37 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
egui::pos2(x1 - col_w + 8.0, rr.top()),
egui::pos2(x1, rr.bottom()),
);
let copy = fmt::plain(&text);
let cresp = ui.interact(
cell,
ui.id().with(("cell", ri, i)),
if c.drill.is_some() {
if c.drill.is_some() || copy.is_some() {
Sense::click()
} else {
Sense::hover()
},
);
// An amount copies as its plain decimal: a click
// where the cell doesn't drill down, the right-click
// menu where it does.
if let Some(v) = &copy {
if c.drill.is_some() {
cresp.context_menu(|ui| widgets::copy_item(ui, "Copy amount", v));
} else {
if cresp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if cresp.clicked() {
widgets::copy_now(ui, cresp.id, v);
}
let note = if widgets::recently_copied(ui, cresp.id) {
"Copied"
} else {
"Click to copy"
};
cresp.clone().on_hover_text(note);
}
}
let hovered = c.drill.is_some() && cresp.hovered();
let vcolor = if hovered { p.accent } else { color };
let vfont = if matches!(r.kind, Kind::Line) {
@@ -1664,6 +1855,15 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
color: egui::Color32|
-> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
resp.context_menu(|ui| {
for v in &vals {
if let Some(id) = v.strip_prefix('#') {
widgets::copy_item(ui, "Copy entry number", &format!("#{id}"));
} else if let Some(a) = fmt::plain(v) {
widgets::copy_item(ui, &format!("Copy {v}"), &a);
}
}
});
if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
@@ -1777,7 +1977,15 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let r = vendor_1099_report(&b.ledger, year, None);
let r = match vendor_1099_report(&b.ledger, year, None) {
Ok(r) => r,
Err(e) => {
// Never guess a threshold (rules.md B-19): say why there's no
// worksheet, in the engine's words.
ui.label(RichText::new(e.to_string()).color(p.muted));
return;
}
};
ui.label(
RichText::new(format!(
"Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts cash paid on entries tagged with the vendor; card payments are reported by the card processor (1099-K) and drop out.",
@@ -1825,6 +2033,74 @@ fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
mod tests {
use super::*;
/// The fixed asset register report's tax columns are the register's:
/// each held asset's contributor's basis, tax basis, and built-in gain
/// (ledger-core's Asset::tax_basis and built_in_gain), the total its
/// total_tax_basis, and its rule (cited) in the detail.
#[test]
fn asset_register_report_carries_tax_basis() {
let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
let dir = std::env::temp_dir().join(format!("ledger-gui-tax-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
crate::demo::build(&dir, today).unwrap();
let b = crate::data::load_once(&crate::data::Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
})
.unwrap();
let r = ledger_core::fixed_asset_register(&b.ledger, today);
assert!(!r.rows.is_empty(), "the demo has an asset");
assert_eq!(r.missing_tax_basis, 0, "the demo's asset has a tax basis");
let t = asset_register(&b, today);
assert_eq!(
t.columns,
[
"Cost",
"Accumulated",
"Book value",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)"
]
);
for x in &r.rows {
let a = b.ledger.get_asset(x.asset_id).unwrap();
let row = t
.rows
.iter()
.find(|row| row.kind == Kind::Line && row.label.contains(&x.description))
.expect("a line per asset");
let v: Vec<Option<Decimal>> = row.cells.iter().map(|c| c.value).collect();
assert_eq!(
v,
[
Some(x.cost),
Some(x.accumulated),
Some(x.book_value),
a.tax.and_then(|t| t.contributor_basis),
a.tax_basis(),
a.built_in_gain(),
]
);
assert!(row.detail.contains("tax basis: Cost"), "{}", row.detail);
}
let total = t.rows.iter().find(|row| row.label == "Total held").unwrap();
assert_eq!(total.cells[4].value, Some(r.total_tax_basis));
assert_eq!(total.cells[4].value, r.rows[0].tax_basis);
assert!(
t.rows
.iter()
.all(|row| row.cells.is_empty() || row.cells.len() == 6)
);
assert!(
t.rows
.iter()
.any(|row| row.kind == Kind::Note && row.label.contains("fair market value"))
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Every table-backed report typesets as a PDF, in its plain, by-month,
/// and compared layouts. Set `PDF_DIR` to keep the files for eyes.
#[test]
@@ -1870,6 +2146,7 @@ mod tests {
tables.push(("equity", owners_equity(&b, s, e)));
tables.push(("tax", tax(&b, s, e)));
tables.push(("ar", aging(b.ledger.ar_aging(e), e)));
tables.push(("assets", asset_register(&b, e)));
tables.push(("gl", gl_table(&general_ledger(&b.ledger, s, e).unwrap())));
let keep = std::env::var_os("PDF_DIR").map(std::path::PathBuf::from);
for (name, t) in tables {
+22 -5
View File
@@ -78,7 +78,7 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!(
"{} Search description, account, vendor, amount",
"{} Search description, account, party, amount",
icon::MAGNIFYING_GLASS
))
.desired_width(if narrow { ui.available_width() } else { 340.0 }),
@@ -134,6 +134,22 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response {
let p = pal(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click());
resp.context_menu(|ui| {
widgets::copy_item(
ui,
"Copy amount",
&fmt::plain(&fmt::money(e.amount)).unwrap_or_default(),
);
if !e.cash_effect.is_zero() {
widgets::copy_item(
ui,
"Copy cash effect",
&fmt::plain(&fmt::money(e.cash_effect)).unwrap_or_default(),
);
}
widgets::copy_item(ui, "Copy entry number", &format!("#{}", e.id));
widgets::copy_item(ui, "Copy description", &e.description);
});
let fresh = b.fresh.contains(&e.id) && since_update < 8.0;
if on {
ui.painter()
@@ -188,10 +204,11 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
max_w,
);
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
if let Some(v) = &e.party {
sub.push_str(&format!(" · {v}"));
}
// The party first: it's what's cut last when the row is narrow.
let sub = match &e.party {
Some(v) => format!("#{} · {} · {v} · {}", e.id, e.origin, accounts.join(" → ")),
None => format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → ")),
};
widgets::clipped_text(
&painter,
egui::pos2(text_left, rect.center().y + 10.0),
+218 -43
View File
@@ -1,5 +1,8 @@
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
//! amount, or #id), vendors, and documents, ranked by a simple fuzzy match.
//! amount, or #id), fixed assets (by tag, serial, or description), parties
//! (vendors and customers among them, by name or display name),
//! documents, bank and card statements, and reconciliations (by account
//! and date), ranked by a simple fuzzy match.
use crate::books::Books;
use crate::fmt;
@@ -16,12 +19,13 @@ pub struct Palette {
focus: bool,
}
struct Item {
icon: &'static str,
title: String,
detail: String,
route: Route,
score: i32,
/// One search result.
pub struct Item {
pub icon: &'static str,
pub title: String,
pub detail: String,
pub route: Route,
pub score: i32,
}
impl Palette {
@@ -75,7 +79,7 @@ impl Palette {
let screen = ctx.content_rect().size();
ui.set_width(620.0_f32.min(screen.x - 48.0).max(200.0));
let edit = egui::TextEdit::singleline(&mut self.query)
.hint_text("Search entries, accounts, vendors, documents, reports…")
.hint_text("Search entries, accounts, assets, vendors, documents, reports…")
.font(theme::regular(16.0))
.frame(egui::Frame::NONE)
.desired_width(f32::INFINITY);
@@ -148,42 +152,100 @@ impl Palette {
/// Subsequence match: every query character in order, rewarding
/// contiguous runs and word starts. None if no match.
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
if query.is_empty() {
/// Fuzzy match score, higher is better.
///
/// ```text
/// INVARIANTS:
/// 1. Matches (Some) exactly when the query's non-space characters,
/// lower-cased, appear in order in the lower-cased hay; the empty
/// query matches everything with score 0.
/// 2. A match's score is at least the number of non-space query
/// characters; consecutive matches and matches at a word start score
/// more. The score is the best over every way to match (not the
/// first match found), so a hay holding the query whole at a word
/// start scores at least as high as any hay's match of it.
/// ```
pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
let hay: Vec<char> = hay.to_lowercase().chars().collect();
// Query characters, each marked whether a space came just before it
// (a space ends a run: the next match earns no run bonus).
let mut q: Vec<(char, bool)> = Vec::new();
let mut after_space = false;
for c in query.to_lowercase().chars() {
if c == ' ' {
after_space = true;
} else {
q.push((c, after_space));
after_space = false;
}
}
if q.is_empty() {
return Some(0);
}
let hay: Vec<char> = hay.to_lowercase().chars().collect();
let mut score = 0;
let mut hi = 0;
let mut prev_match = false;
for qc in query.to_lowercase().chars() {
if qc == ' ' {
prev_match = false;
continue;
}
let mut found = false;
while hi < hay.len() {
let c = hay[hi];
hi += 1;
if c == qc {
score += if prev_match { 6 } else { 1 };
if hi == 1 || !hay[hi - 2].is_alphanumeric() {
score += 4;
}
prev_match = true;
found = true;
break;
// The best score over every way to match (a greedy left-most match
// can miss a word start further on): best[j] is the top score for the
// query so far with its last character at hay[j].
let word_start = |j: usize| j == 0 || !hay[j - 1].is_alphanumeric();
let mut best: Vec<Option<i32>> = hay
.iter()
.enumerate()
.map(|(j, &c)| (c == q[0].0).then(|| 1 + if word_start(j) { 4 } else { 0 }))
.collect();
for &(qc, gap) in &q[1..] {
let mut next = vec![None; hay.len()];
// The best score ending strictly before j.
let mut before: Option<i32> = None;
for j in 0..hay.len() {
if hay[j] == qc {
let bonus = if word_start(j) { 4 } else { 0 };
let run = if gap {
None
} else {
j.checked_sub(1)
.and_then(|p| best[p])
.map(|s| s + 6 + bonus)
};
let jump = before.map(|s| s + 1 + bonus);
next[j] = run.max(jump);
}
prev_match = false;
}
if !found {
return None;
before = before.max(best[j]);
}
best = next;
}
Some(score)
best.into_iter().flatten().max()
}
fn search(b: &Books, query: &str) -> Vec<Item> {
/// What the palette lists for `query`, best first (at most 40).
///
/// ```text
/// INVARIANTS (fixed assets; everything else as it was):
/// 1. An asset whose tag equals the trimmed query, ignoring ASCII case,
/// scores 2000: above every other result (an entry's exact #id is
/// 1000).
/// 2. Otherwise an asset is listed when the query fuzzily matches its
/// tag, serial, and description; a query found whole in its tag or
/// serial (ignoring case) adds 500, in its description 50.
/// 3. Every asset result opens Route::Asset(its id).
/// INVARIANTS (parties):
/// 4. A party whose name or display name equals the trimmed query,
/// ignoring case, scores 1500: above an entry's exact #id, below an
/// exact asset tag.
/// 5. Otherwise a party is listed when the query fuzzily matches its
/// name and display name; a query found whole in either (ignoring
/// case) adds 500.
/// 6. Every party result opens Route::Party(its id); archived parties
/// are found too, and say so.
/// INVARIANTS (statements and reconciliations):
/// 7. A statement is listed when the query fuzzily matches "statement",
/// its account's code and name, its period's dates (as shown and as
/// YYYY-MM-DD), and its file name; it opens Route::Document(its
/// attachment).
/// 8. A reconciliation is listed when the query fuzzily matches
/// "reconciliation reconciled", its account's code and name, and its
/// statement date (as shown and as YYYY-MM-DD); it opens
/// Route::Reconciliation(its id), voided ones too, saying so.
/// ```
pub fn search(b: &Books, query: &str) -> Vec<Item> {
let q = query.trim();
let mut out: Vec<Item> = Vec::new();
let mut add = |icon: &'static str, title: String, detail: String, route: Route, hay: &str| {
@@ -253,13 +315,123 @@ fn search(b: &Books, query: &str) -> Vec<Item> {
});
}
}
for v in b.ledger.list_vendors() {
if let Some(score) = fuzzy(&v.name, q) {
let ql = q.to_lowercase();
for a in b.ledger.registered_assets() {
let tag = a.tag.as_deref().unwrap_or("");
let serial = a.serial.as_deref().unwrap_or("");
let hay = format!("{tag} {serial} {}", a.description);
let score = if !tag.is_empty() && tag.eq_ignore_ascii_case(q) {
Some(2000)
} else {
fuzzy(&hay, q).map(|s| {
let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql);
s + if whole(tag) || whole(serial) {
500
} else if whole(&a.description) {
50
} else {
0
}
})
};
if let Some(score) = score {
let mut detail = vec!["Fixed asset".to_string()];
if !serial.is_empty() {
detail.push(format!("serial {serial}"));
}
detail.push(format!("in service {}", fmt::date(a.purchase_date)));
out.push(Item {
icon: icon::STOREFRONT,
title: v.name.clone(),
detail: "Vendor".into(),
route: Route::Company,
icon: icon::PACKAGE,
title: b.asset_label(a.id),
detail: detail.join(" · "),
route: Route::Asset(a.id),
score,
});
}
}
for p in b.ledger.parties() {
let display = p.display_name.as_deref().unwrap_or("");
let exact = |t: &str| !t.is_empty() && t.trim().to_lowercase() == ql;
let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql);
let score = if exact(&p.name) || exact(display) {
Some(1500)
} else {
fuzzy(&format!("{} {display}", p.name), q).map(|s| {
s + if whole(&p.name) || whole(display) {
500
} else {
0
}
})
};
if let Some(score) = score {
let mut detail = vec![crate::pages::parties::roles_text(p)];
if p.display_name.is_some() {
detail.push(p.name.clone());
}
if p.archived {
detail.push("archived".into());
}
out.push(Item {
icon: crate::pages::parties::party_icon(p),
title: p.label().to_string(),
detail: detail.join(" · "),
route: Route::Party(p.id),
score,
});
}
}
for st in &b.statements {
let file = b
.attachment(st.attachment_id)
.map_or("", |a| a.file_name.as_str());
let hay = format!(
"statement {} {} {} {} {} {file}",
b.account_label(st.account_id),
fmt::date(st.period_start),
fmt::date(st.period_end),
st.period_start,
st.period_end
);
if let Some(score) = fuzzy(&hay, q) {
out.push(Item {
icon: icon::FILE_TEXT,
title: format!(
"Statement: {}, {} – {}",
b.account_name(st.account_id),
fmt::date_short(st.period_start),
fmt::date(st.period_end)
),
detail: format!("Closing {} · {file}", fmt::dollars(st.closing_balance)),
route: Route::Document(st.attachment_id),
score,
});
}
}
for r in &b.reconciliations {
let hay = format!(
"reconciliation reconciled {} {} {}",
b.account_label(r.account_id),
fmt::date(r.statement_date),
r.statement_date
);
if let Some(score) = fuzzy(&hay, q) {
let mut detail = vec![
fmt::dollars(r.statement_balance),
fmt::count(r.cleared.len(), "cleared line", "cleared lines"),
];
if r.voided.is_some() {
detail.push("voided".into());
}
out.push(Item {
icon: icon::CHECK_SQUARE,
title: format!(
"Reconciliation: {} through {}",
b.account_name(r.account_id),
fmt::date(r.statement_date)
),
detail: detail.join(" · "),
route: Route::Reconciliation(r.id),
score,
});
}
@@ -301,6 +473,9 @@ pub fn describe(b: &Books, r: &Route) -> String {
.find(|a| a.id == *id)
.map(|a| a.file_name.clone())
.unwrap_or_default(),
Route::Asset(id) => b.asset_label(*id),
Route::Party(id) => b.party_label(*id),
Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)),
Route::JournalEvent(seq) => format!("Event {seq}"),
_ => String::new(),
}
+97 -1
View File
@@ -114,15 +114,35 @@ pub fn amount_cell(
} else {
theme::regular(14.0)
};
// An amount copies as its plain decimal: a click when the cell doesn't
// drill down, the right-click menu when it does.
let copy = crate::fmt::plain(text);
let (rect, resp) = ui.allocate_exact_size(
vec2(width, 22.0),
if drill {
if drill || copy.is_some() {
Sense::click()
} else {
Sense::hover()
},
);
let p = pal(ui.ctx());
let copy_id = resp.id.with("copy");
if let Some(v) = &copy {
if drill {
resp.context_menu(|ui| copy_item(ui, "Copy amount", v));
} else if resp.clicked() {
copy_now(ui, copy_id, v);
}
}
let copied = copy.is_some() && !drill && recently_copied(ui, copy_id);
if copy.is_some() && !drill && resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let resp = if copy.is_some() && !drill {
resp.on_hover_text(if copied { "Copied" } else { "Click to copy" })
} else {
resp
};
let hovered = drill && resp.hovered();
let galley = ui.painter().layout_no_wrap(
text.to_string(),
@@ -143,6 +163,15 @@ pub fn amount_cell(
Stroke::new(1.0, p.accent),
);
}
if copied {
ui.painter().text(
egui::pos2(pos.x - 4.0, rect.center().y),
egui::Align2::RIGHT_CENTER,
egui_phosphor::regular::CHECK,
egui::FontId::proportional(12.0),
p.positive,
);
}
ui.painter().galley(pos, galley, color);
resp
}
@@ -223,6 +252,73 @@ pub fn empty(ui: &mut Ui, icon: &str, title: &str, text: &str) {
});
}
/// Put `value` on the clipboard, remembering when under `id` (for the
/// brief "Copied" check).
pub fn copy_now(ui: &Ui, id: egui::Id, value: &str) {
ui.ctx().copy_text(value.to_string());
let now = ui.input(|i| i.time);
ui.ctx().data_mut(|d| d.insert_temp(id, now));
}
/// Whether `id` was copied in the last moment; keeps repainting until the
/// check fades.
pub fn recently_copied(ui: &Ui, id: egui::Id) -> bool {
let at: Option<f64> = ui.ctx().data(|d| d.get_temp(id));
let now = ui.input(|i| i.time);
let recent = at.is_some_and(|t| now - t < 1.2);
if recent {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(200));
}
recent
}
/// A value that copies itself when clicked: a pointer and a copy icon on
/// hover, a check for a moment after.
pub fn copyable(ui: &mut Ui, text: RichText, value: &str) -> Response {
let id = ui.id().with(("copy", value));
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let r = ui.add(egui::Label::new(text).sense(Sense::click()));
let recent = recently_copied(ui, id);
let (slot, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
if r.hovered() || recent {
ui.painter().text(
slot.center(),
egui::Align2::CENTER_CENTER,
if recent {
egui_phosphor::regular::CHECK
} else {
egui_phosphor::regular::COPY
},
egui::FontId::proportional(12.0),
if recent { p.positive } else { p.muted },
);
}
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let r = r.on_hover_text(if recent { "Copied" } else { "Click to copy" });
if r.clicked() {
copy_now(ui, id, value);
}
r
})
.inner
}
/// A "Copy …" item for a right-click menu.
pub fn copy_item(ui: &mut Ui, label: &str, value: &str) {
if ui
.button(format!("{} {label}", egui_phosphor::regular::COPY))
.clicked()
{
ui.ctx().copy_text(value.to_string());
ui.close();
}
}
/// "Copy prompt": puts an agent prompt on the clipboard.
pub fn copy_prompt(ui: &mut Ui, prompt: &str) {
let p = pal(ui.ctx());
+837
View File
@@ -0,0 +1,837 @@
//! Fixed assets in the viewer: the register and each asset's schedule are
//! ledger-core's numbers exactly; what's posted comes from the journal;
//! search, books.show, the journal, and "Needs attention" know assets.
use chrono::{Datelike, Days, Months, NaiveDate};
use egui_kittest::Harness;
use ledger_core::{
AccountType, AssetId, Currency, EntryId, Money, NewAsset, Side, TaxBasis, TaxBasisRule,
depreciation_schedule, fixed_asset_register,
};
use ledger_gui::books::Books;
use ledger_gui::nav::{ReportKind, Route};
use ledger_gui::pages::assets::{MonthStatus, TaxFacts, missing_tax_notice, notices, schedule};
use ledger_gui::pages::journal::describe;
use ledger_gui::{App, Source, attention, data, fmt, palette};
use ledger_store::Store;
use ledger_store::viewer::ViewerTarget;
use proptest::prelude::*;
use rust_decimal::Decimal;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
static DIRS: AtomicUsize = AtomicUsize::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
let n = DIRS.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!(
"ledger-gui-assets-{tag}-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TempDir(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(cents: i64) -> Money {
Money::new(Decimal::new(cents, 2), Currency::USD)
}
fn load(dir: &Path, today: NaiveDate) -> Books {
data::load_once(&Source {
data_dir: dir.join("books-data"),
books_toml: None,
today: Some(today),
})
.unwrap()
}
struct Fixture {
_dir: TempDir,
books: Books,
/// The store's own ledger (the oracle's input), reopened from disk.
oracle: ledger_core::Ledger,
laptop: AssetId,
monitor: AssetId,
old: AssetId,
laptop_purchase: EntryId,
}
const TODAY: (i32, u32, u32) = (2026, 10, 15);
fn today() -> NaiveDate {
d(TODAY.0, TODAY.1, TODAY.2)
}
/// A laptop (tagged, serial, depreciation through July; tax basis first
/// recorded as a carryover, then corrected to cost), a monitor (no tag, no
/// tax basis, through August), and an old laptop retagged, its serial
/// cleared, converted from personal use worth less than its basis (a
/// built-in loss), and sold in June.
fn fixture(tag: &str) -> Fixture {
let dir = TempDir::new(tag);
let data = dir.0.join("books-data");
let mut s = Store::create(&data, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let equip = s
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let accum = s
.add_account(
"1510",
"Accumulated Depreciation",
AccountType::Asset,
None,
false,
)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let dep = s
.add_account("6500", "Depreciation", AccountType::Expense, None, false)
.unwrap();
let gain = s
.add_account("7000", "Gain on sale", AccountType::Revenue, None, false)
.unwrap();
let buy = |s: &mut Store, date, what: &str, cents| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, equip, Side::Debit, usd(cents)).unwrap();
s.add_line(e, cash, Side::Credit, usd(cents)).unwrap();
s.post_entry(e).unwrap();
e
};
let e = s.create_entry(d(2026, 1, 1), "Owner contribution").unwrap();
s.add_line(e, cash, Side::Debit, usd(1_000_000)).unwrap();
s.add_line(e, capital, Side::Credit, usd(1_000_000))
.unwrap();
s.post_entry(e).unwrap();
let register = |s: &mut Store, what: &str, date, cents, salvage, months| {
s.register_asset(NewAsset {
description: what.into(),
purchase_date: date,
cost: usd(cents),
salvage_value: usd(salvage),
useful_life_months: months,
cost_account: equip,
accum_depr_account: accum,
expense_account: dep,
})
.unwrap()
};
let laptop_purchase = buy(&mut s, d(2026, 1, 10), "Laptop", 300_000);
let laptop = register(&mut s, "Laptop", d(2026, 1, 10), 300_000, 0, 36);
buy(&mut s, d(2026, 2, 1), "Monitor", 120_000);
let monitor = register(&mut s, "Monitor", d(2026, 2, 1), 120_000, 0, 12);
buy(&mut s, d(2026, 1, 1), "Old laptop", 60_000);
let old = register(&mut s, "Old laptop", d(2026, 1, 1), 60_000, 10_000, 24);
s.set_asset_tag(laptop, "WL-0001").unwrap();
s.set_asset_serial(laptop, Some("SN-123")).unwrap();
s.set_asset_tag(old, "OLD-1").unwrap();
s.set_asset_tag(old, "WL-0009").unwrap();
s.set_asset_serial(old, Some("X1")).unwrap();
s.set_asset_serial(old, None).unwrap();
let tax = |rule, basis: Option<i64>| TaxBasis {
rule,
contributor_basis: basis.map(|c| Decimal::new(c, 2)),
};
s.set_asset_tax_basis(laptop, tax(TaxBasisRule::Carryover, Some(200_000)))
.unwrap();
s.set_asset_tax_basis(laptop, tax(TaxBasisRule::Cost, None))
.unwrap();
s.set_asset_tax_basis(old, tax(TaxBasisRule::PersonalUseConversion, Some(70_000)))
.unwrap();
for m in 1..=7 {
s.run_depreciation(laptop, &format!("2026-{m:02}")).unwrap();
}
for m in 2..=8 {
s.run_depreciation(monitor, &format!("2026-{m:02}"))
.unwrap();
}
for m in 1..=3 {
s.run_depreciation(old, &format!("2026-{m:02}")).unwrap();
}
s.dispose_asset(old, d(2026, 6, 30), usd(30_000), cash, gain)
.unwrap();
drop(s);
let oracle = Store::open(&data).unwrap().ledger().clone();
Fixture {
books: load(&dir.0, today()),
_dir: dir,
oracle,
laptop,
monitor,
old,
laptop_purchase,
}
}
#[test]
fn the_register_is_ledger_cores_register() {
let f = fixture("register");
let b = &f.books;
for as_of in [
d(2025, 12, 31),
d(2026, 1, 10),
d(2026, 3, 31),
d(2026, 6, 29),
d(2026, 6, 30),
d(2026, 7, 31),
d(2026, 9, 30),
today(),
d(2030, 1, 1),
] {
let mine = b.asset_register(as_of);
let oracle = fixed_asset_register(&f.oracle, as_of);
assert_eq!(mine, oracle, "register on {as_of}");
}
let r = b.asset_register(today());
assert_eq!(r.rows.len(), 3);
let old = r.rows.iter().find(|x| x.asset_id == f.old).unwrap();
assert_eq!(
old.disposed,
Some((d(2026, 6, 30), Decimal::new(30_000, 2)))
);
assert_eq!(
ledger_gui::pages::assets::status_text(old.disposed),
"Disposed Jun 30, 2026 for $300.00"
);
}
#[test]
fn depreciation_due_is_the_register_through_the_last_finished_month() {
let f = fixture("due");
let b = &f.books;
let due = b.depreciation_due(today());
assert_eq!(due.through, d(2026, 9, 30));
assert_eq!(
due.amount,
fixed_asset_register(&f.oracle, d(2026, 9, 30)).unposted_depreciation()
);
assert_eq!(due.periods, vec!["2026-08", "2026-09"]);
// Independently: the laptop's August and September, the monitor's
// September, straight from the schedules.
let sched = |id| depreciation_schedule(f.oracle.get_asset(id).unwrap(), d(2026, 9, 30));
let month = |id, p: &str| {
sched(id)
.into_iter()
.find(|l| l.period == p)
.unwrap()
.amount
};
assert_eq!(
due.amount,
month(f.laptop, "2026-08") + month(f.laptop, "2026-09") + month(f.monitor, "2026-09")
);
// A month's last day counts that month as finished.
assert_eq!(b.depreciation_due(d(2026, 7, 31)).through, d(2026, 7, 31));
assert_eq!(
b.depreciation_due(d(2026, 7, 31)).periods,
Vec::<String>::new()
);
// The notices name the due amount exactly, and the month under way.
let r = b.asset_register(today());
let n = notices(b, &r);
assert!(n[0].problem);
assert!(
n[0].text.contains(&format!(
"Depreciation not yet posted through Sep 30, 2026: {}",
fmt::dollars(due.amount)
)),
"{:?}",
n[0].text
);
assert!(n[0].text.contains("ask the bookkeeper to run depreciation"));
let accruing = r.unposted_depreciation() - due.amount;
assert!(accruing > Decimal::ZERO);
assert!(n[1].text.contains(&fmt::dollars(accruing)));
assert!(!n[1].problem);
}
#[test]
fn an_assets_schedule_and_trail() {
let f = fixture("schedule");
let b = &f.books;
let lines = schedule(b, f.laptop);
let asset = f.oracle.get_asset(f.laptop).unwrap();
let oracle = depreciation_schedule(asset, NaiveDate::MAX);
assert_eq!(
oracle.len(),
37,
"36 months from Jan 10: 37 calendar months"
);
assert_eq!(
lines.iter().map(|(l, _)| l.clone()).collect::<Vec<_>>(),
oracle
);
let trail = b.asset_trail(f.laptop);
for (l, st) in &lines {
let want = match l.period.as_str() {
p if p <= "2026-07" => MonthStatus::Posted(trail.depreciation[p].0),
"2026-08" | "2026-09" => MonthStatus::Due,
"2026-10" => MonthStatus::ThisMonth,
_ => MonthStatus::Scheduled,
};
assert_eq!(*st, want, "{}", l.period);
}
// Posted amounts are the schedule's, and the purchase is found.
for (period, (_, amount)) in &trail.depreciation {
let l = oracle.iter().find(|l| &l.period == period).unwrap();
assert_eq!(*amount, l.amount);
}
assert_eq!(trail.purchase_entry, Some(f.laptop_purchase));
assert_eq!(b.entry_assets.get(&f.laptop_purchase), Some(&f.laptop));
assert_eq!(trail.entries().len(), 1 + 7);
// The sold laptop: its disposal entry, and a final depreciation.
let old = b.asset_trail(f.old);
let disposal = old.disposal_entry.expect("disposal entry");
assert_eq!(b.entry(disposal).unwrap().origin, "Asset disposal");
assert!(old.depreciation.contains_key("2026-06"));
assert_eq!(b.entry_assets.get(&disposal), Some(&f.old));
assert_eq!(b.asset_label(f.laptop), "WL-0001 Laptop");
assert_eq!(b.asset_label(f.monitor), "Monitor");
}
#[test]
fn search_finds_assets_by_tag_serial_and_description() {
let f = fixture("search");
let b = &f.books;
for q in ["WL-0001", "wl-0001", " Wl-0001 "] {
let items = palette::search(b, q);
assert_eq!(items[0].route, Route::Asset(f.laptop), "{q}");
assert_eq!(items[0].score, 2000);
}
let first_asset = |q: &str| {
palette::search(b, q)
.into_iter()
.find(|i| matches!(i.route, Route::Asset(_)))
.map(|i| i.route)
};
assert_eq!(first_asset("sn-123"), Some(Route::Asset(f.laptop)));
assert_eq!(
palette::search(b, "SN-123")[0].route,
Route::Asset(f.laptop)
);
assert_eq!(first_asset("monitor"), Some(Route::Asset(f.monitor)));
assert_eq!(first_asset("wl-0009"), Some(Route::Asset(f.old)));
// Everything else still found as before.
assert!(
palette::search(b, "checking")
.iter()
.any(|i| matches!(i.route, Route::Account(_)))
);
assert!(
palette::search(b, "")
.iter()
.any(|i| i.route == Route::Assets)
);
assert!(
palette::search(b, "fixed asset register")
.iter()
.any(|i| i.route == Route::Reports(ReportKind::FixedAssetRegister))
);
assert_eq!(
palette::describe(b, &Route::Asset(f.laptop)),
"WL-0001 Laptop"
);
}
#[test]
fn journal_text_for_asset_events() {
let f = fixture("journal");
let b = &f.books;
let texts: Vec<(String, Option<Route>, Option<Route>)> = b
.journal
.iter()
.filter(|l| l.line_type.starts_with("asset."))
.filter_map(|l| describe(b, l))
.map(|e| (e.text, e.route, e.also))
.collect();
let has = |t: &str, route: Option<Route>| {
assert!(
texts.iter().any(|(x, r, _)| x == t && *r == route),
"no {t:?} ({route:?}) in {texts:#?}"
);
};
has("Registered asset Laptop", Some(Route::Asset(f.laptop)));
has("Tagged Laptop WL-0001", Some(Route::Asset(f.laptop)));
has(
"Retagged Old laptop from OLD-1 to WL-0009",
Some(Route::Asset(f.old)),
);
has(
"Serial number of Laptop set to SN-123",
Some(Route::Asset(f.laptop)),
);
has(
"Serial number of Old laptop cleared",
Some(Route::Asset(f.old)),
);
let disposal = b.asset_trail(f.old).disposal_entry;
has(
"Disposed Old laptop on 2026-06-30 for $300.00",
disposal.map(Route::Entry),
);
let (jan, _) = b.asset_trail(f.laptop).depreciation["2026-01"];
let dep = texts
.iter()
.find(|(x, _, _)| x.starts_with("Depreciation posted: Laptop 2026-01"))
.expect("depreciation text");
assert_eq!(dep.1, Some(Route::Entry(jan)));
assert_eq!(dep.2, Some(Route::Asset(f.laptop)));
}
#[test]
fn attention_asks_for_depreciation_and_tags() {
let f = fixture("attention");
let b = &f.books;
let items = attention::find(b);
let due = b.depreciation_due(today());
let dep = items
.iter()
.find(|a| a.title == "Depreciation not posted through Sep 30, 2026")
.expect("depreciation item");
assert_eq!(dep.target, Some(Route::Assets));
assert!(dep.prompt.starts_with("In wanderbooks,"));
assert!(
dep.prompt
.contains("books.run_depreciation for 2026-08, 2026-09")
);
assert!(dep.prompt.contains(&fmt::dollars(due.amount)));
let tag = items
.iter()
.find(|a| a.title == "No tag: Monitor")
.expect("tag item");
assert_eq!(tag.target, Some(Route::Asset(f.monitor)));
assert!(tag.prompt.contains("books.label_asset"));
// Tagged, disposed, and agreeing assets ask for nothing.
assert!(
!items
.iter()
.any(|a| a.title.contains("Laptop") && a.title.starts_with("No tag"))
);
assert!(!items.iter().any(|a| a.title.contains("not in the")));
for a in &items {
assert!(a.prompt.contains("wanderbooks"), "{:?}", a.prompt);
}
// Caught up and labeled: nothing about assets.
let data = f._dir.0.join("books-data");
let mut s = Store::open(&data).unwrap();
s.run_depreciation(f.laptop, "2026-08").unwrap();
s.run_depreciation(f.laptop, "2026-09").unwrap();
s.run_depreciation(f.monitor, "2026-09").unwrap();
s.set_asset_tag(f.monitor, "WL-0002").unwrap();
drop(s);
let b = load(&f._dir.0, today());
let titles: Vec<String> = attention::find(&b).into_iter().map(|a| a.title).collect();
assert!(
!titles
.iter()
.any(|t| t.contains("Depreciation") || t.starts_with("No tag")),
"{titles:?}"
);
assert_eq!(b.depreciation_due(today()).amount, Decimal::ZERO);
}
/// The viewer's tax figures are ledger-core's: each asset's
/// (Asset::tax_basis, Asset::built_in_gain), each register row's, and the
/// register's total and missing count, on every date.
#[test]
fn tax_basis_is_ledger_cores() {
let f = fixture("tax");
let b = &f.books;
for id in [f.laptop, f.monitor, f.old] {
let core = f.oracle.get_asset(id).unwrap();
let mine = TaxFacts::of(b.ledger.get_asset(id).unwrap());
assert_eq!(mine.rule, core.tax.map(|t| t.rule), "asset {id}");
assert_eq!(
mine.contributor_basis,
core.tax.and_then(|t| t.contributor_basis)
);
assert_eq!(mine.tax_basis, core.tax_basis(), "asset {id}");
assert_eq!(mine.built_in_gain, core.built_in_gain(), "asset {id}");
}
// Independently: the laptop's basis is its cost; the old laptop's is
// its value ($600.00, under the member's $700.00), a $100.00 loss.
let laptop = TaxFacts::of(b.ledger.get_asset(f.laptop).unwrap());
assert_eq!(laptop.rule, Some(TaxBasisRule::Cost));
assert_eq!(laptop.tax_basis, Some(Decimal::new(300_000, 2)));
assert_eq!(laptop.built_in_gain, None);
let old = TaxFacts::of(b.ledger.get_asset(f.old).unwrap());
assert_eq!(old.tax_basis, Some(Decimal::new(60_000, 2)));
assert_eq!(old.built_in_gain, Some(Decimal::new(-10_000, 2)));
assert_eq!(
old.summary(),
"Personal-use conversion · contributor's basis $700.00"
);
assert_eq!(
old.detail(),
"Tax basis $600.00: Personal-use conversion (Treas. Reg. §1.168(i)-4(b)); contributor's basis $700.00; built-in loss $100.00."
);
assert_eq!(
TaxFacts::of(b.ledger.get_asset(f.monitor).unwrap()).detail(),
"Tax basis not recorded."
);
assert_eq!(
TaxFacts::of(b.ledger.get_asset(f.monitor).unwrap()),
TaxFacts {
rule: None,
contributor_basis: None,
tax_basis: None,
built_in_gain: None
}
);
for as_of in [d(2026, 1, 31), d(2026, 6, 30), today()] {
let r = b.asset_register(as_of);
let oracle = fixed_asset_register(&f.oracle, as_of);
assert_eq!(r.total_tax_basis, oracle.total_tax_basis, "{as_of}");
assert_eq!(r.missing_tax_basis, oracle.missing_tax_basis, "{as_of}");
for x in &r.rows {
let a = f.oracle.get_asset(x.asset_id).unwrap();
let facts = TaxFacts::of_row(x);
assert_eq!(facts.tax_basis, a.tax_basis());
assert_eq!(facts.built_in_gain, a.built_in_gain());
assert_eq!(facts, TaxFacts::of(a));
}
}
// Today: the laptop's $3,000.00 (the monitor has none; the old laptop
// is gone), and one missing, said in the notices.
let r = b.asset_register(today());
assert_eq!(r.total_tax_basis, Decimal::new(300_000, 2));
assert_eq!(r.missing_tax_basis, 1);
let want = missing_tax_notice(1).unwrap();
assert!(want.starts_with("Tax basis not recorded for 1 asset"));
assert!(want.contains("books.set_asset_tax_basis"));
assert!(notices(b, &r).iter().any(|n| n.problem && n.text == want));
assert_eq!(missing_tax_notice(0), None);
assert!(
missing_tax_notice(2)
.unwrap()
.starts_with("Tax basis not recorded for 2 assets")
);
}
#[test]
fn journal_text_for_tax_basis() {
let f = fixture("tax-journal");
let b = &f.books;
let texts: Vec<(String, Option<Route>)> = b
.journal
.iter()
.filter(|l| l.line_type == "asset.tax_basis_set")
.filter_map(|l| describe(b, l))
.map(|e| (e.text, e.route))
.collect();
assert_eq!(
texts,
vec![
(
"Tax basis of WL-0001: carryover, contributor's basis $2,000.00 → tax basis $2,000.00"
.to_string(),
Some(Route::Asset(f.laptop))
),
(
"Tax basis of WL-0001: cost, tax basis $3,000.00".to_string(),
Some(Route::Asset(f.laptop))
),
(
"Tax basis of WL-0009: personal-use conversion, contributor's basis $700.00 → tax basis $600.00"
.to_string(),
Some(Route::Asset(f.old))
),
]
);
}
/// An asset held with no tax basis asks for one, with a prompt naming
/// the tool and the asset; recording it clears the item.
#[test]
fn attention_asks_for_tax_basis() {
let f = fixture("tax-attention");
let items = attention::find(&f.books);
let tax: Vec<_> = items
.iter()
.filter(|a| a.title.starts_with("No tax basis"))
.collect();
assert_eq!(tax.len(), 1, "only the monitor: {tax:#?}");
let item = tax[0];
assert_eq!(item.title, "No tax basis: Monitor");
assert_eq!(item.target, Some(Route::Asset(f.monitor)));
assert!(item.prompt.starts_with(&format!(
"In wanderbooks, record the tax basis of asset {} (\"Monitor\", book cost $1,200.00) with books.set_asset_tax_basis.",
f.monitor
)));
assert!(item.detail.contains("$1,200.00"));
let data = f._dir.0.join("books-data");
let mut s = Store::open(&data).unwrap();
s.set_asset_tag(f.monitor, "WL-0002").unwrap();
drop(s);
let b = load(&f._dir.0, today());
let item = attention::find(&b)
.into_iter()
.find(|a| a.title.starts_with("No tax basis"))
.expect("still missing");
assert_eq!(item.title, "No tax basis: WL-0002 Monitor");
assert!(
item.prompt
.starts_with("In wanderbooks, record the tax basis of WL-0002 (")
);
let mut s = Store::open(&data).unwrap();
s.set_asset_tax_basis(
f.monitor,
TaxBasis {
rule: TaxBasisRule::Carryover,
contributor_basis: Some(Decimal::new(90_000, 2)),
},
)
.unwrap();
drop(s);
let b = load(&f._dir.0, today());
assert!(
!attention::find(&b)
.iter()
.any(|a| a.title.starts_with("No tax basis"))
);
let r = b.asset_register(today());
assert_eq!(r.missing_tax_basis, 0);
assert!(
!notices(&b, &r)
.iter()
.any(|n| n.text.contains("Tax basis not recorded"))
);
// $3,000.00 laptop + the monitor's $900.00 carryover.
assert_eq!(r.total_tax_basis, Decimal::new(390_000, 2));
}
#[test]
fn unregistered_equipment_is_flagged() {
let f = fixture("unregistered");
let data = f._dir.0.join("books-data");
let mut s = Store::open(&data).unwrap();
let equip = s.ledger().chart().find_by_code("1500").unwrap().id;
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
let e = s.create_entry(d(2026, 9, 2), "Printer").unwrap();
s.add_line(e, equip, Side::Debit, usd(45_000)).unwrap();
s.add_line(e, cash, Side::Credit, usd(45_000)).unwrap();
s.post_entry(e).unwrap();
drop(s);
let b = load(&f._dir.0, today());
let r = b.asset_register(today());
assert_eq!(r.unregistered_cost(), Decimal::new(45_000, 2));
assert!(
notices(&b, &r)
.iter()
.any(|n| n.problem && n.text.starts_with("$450.00 in the asset cost accounts"))
);
assert!(
attention::find(&b)
.iter()
.any(|a| a.title == "$450.00 of equipment not in the asset register")
);
}
#[test]
fn books_show_opens_assets() {
let f = fixture("show");
let laptop = f.laptop;
let books = f.books;
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 920.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
h.state_mut().show(ViewerTarget::Page {
page: "assets".into(),
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Assets);
h.state_mut().show(ViewerTarget::Asset { asset_id: laptop });
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Asset(laptop));
h.state_mut().show(ViewerTarget::Report {
report: "fixed_asset_register".into(),
start: None,
end: Some("2026-06-30".into()),
columns: None,
compare: None,
});
h.run_steps(2);
assert_eq!(
h.state_mut().route(),
&Route::Reports(ReportKind::FixedAssetRegister)
);
assert!(matches!(
h.state_mut().state_mut().reports.range,
ledger_gui::pages::reports::Range::Custom(_, e) if e == d(2026, 6, 30)
));
// An unknown asset is ignored, not a crash.
h.state_mut().show(ViewerTarget::Asset { asset_id: 999 });
h.run_steps(1);
assert_eq!(
h.state_mut().route(),
&Route::Reports(ReportKind::FixedAssetRegister)
);
}
/// The asset pages render at every width without panicking, and with no
/// assets at all.
#[test]
fn asset_pages_render() {
let f = fixture("render");
let routes = [
Route::Assets,
Route::Asset(f.laptop),
Route::Asset(f.monitor),
Route::Asset(f.old),
Route::Asset(999),
Route::Reports(ReportKind::FixedAssetRegister),
];
for width in [420.0, 900.0, 1440.0] {
for route in &routes {
let books = load(&f._dir.0, today());
let mut h = Harness::builder()
.with_size(egui::vec2(width, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, true, route.clone()));
h.run_steps(3);
}
}
let empty = TempDir::new("empty");
drop(Store::create(&empty.0.join("books-data"), Currency::USD).unwrap());
for route in [
Route::Assets,
Route::Reports(ReportKind::FixedAssetRegister),
] {
let books = load(&empty.0, today());
let mut h = Harness::builder()
.with_size(egui::vec2(1280.0, 800.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
h.run_steps(3);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(12))]
/// On random assets with depreciation run for random months, the
/// register on any date is ledger-core's, and what's due is the
/// register through the last finished month.
#[test]
fn register_and_due_match_the_oracle(
assets in proptest::collection::vec(
(0u64..400, 1i64..2_000_000, 0i64..50, 1u32..60, 0u32..30),
1..5,
),
today_off in 0u64..900,
probe in 0u64..1200,
) {
let dir = TempDir::new("prop");
let data = dir.0.join("books-data");
let mut s = Store::create(&data, Currency::USD).unwrap();
let cash = s.add_account("1000", "Checking", AccountType::Asset, None, false).unwrap();
let equip = s.add_account("1500", "Equipment", AccountType::Asset, None, false).unwrap();
let accum = s.add_account("1510", "Accumulated", AccountType::Asset, None, false).unwrap();
let dep = s.add_account("6500", "Depreciation", AccountType::Expense, None, false).unwrap();
let base = d(2025, 1, 1);
for (i, (off, cents, salvage_pct, months, run)) in assets.iter().enumerate() {
let date = base + Days::new(*off);
let e = s.create_entry(date, &format!("Buy {i}")).unwrap();
s.add_line(e, equip, Side::Debit, usd(*cents)).unwrap();
s.add_line(e, cash, Side::Credit, usd(*cents)).unwrap();
s.post_entry(e).unwrap();
let id = s.register_asset(NewAsset {
description: format!("Asset {i}"),
purchase_date: date,
cost: usd(*cents),
salvage_value: usd(cents * salvage_pct / 100),
useful_life_months: *months,
cost_account: equip,
accum_depr_account: accum,
expense_account: dep,
}).unwrap();
// Depreciation for the first `run` months of its life.
for k in 0..*run {
let m = date.with_day0(0).unwrap() + Months::new(k);
let _ = s.run_depreciation(id, &m.format("%Y-%m").to_string());
}
}
drop(s);
let oracle = Store::open(&data).unwrap().ledger().clone();
let today = base + Days::new(today_off);
let b = load(&dir.0, today);
let at = base + Days::new(probe);
prop_assert_eq!(b.asset_register(at), fixed_asset_register(&oracle, at));
let due = b.depreciation_due(today);
prop_assert!(due.through <= today);
prop_assert_eq!(due.through.succ_opt().unwrap().day0(), 0);
prop_assert!(today - due.through < chrono::TimeDelta::days(32));
prop_assert_eq!(
due.amount,
fixed_asset_register(&oracle, due.through).unposted_depreciation()
);
// Every period listed is unposted and in some schedule.
for p in &due.periods {
let found = b.ledger.registered_assets().any(|a| {
depreciation_schedule(a, due.through).iter().any(|l| &l.period == p)
&& !b.asset_trail(a.id).depreciation.contains_key(p)
});
prop_assert!(found, "{} listed but not due", p);
}
}
}
/// Every event the store can journal reads as a sentence in the journal
/// and recent activity, never as its raw type: the event types come from
/// the store's own source (its replay arms), so a new event without
/// journal text fails here. (asset.acquisition_linked showed raw,
/// 2026-10-15.)
#[test]
fn every_journal_event_has_plain_language_text() {
let src = include_str!("../../ledger-store/src/store.rs");
let mut kinds: Vec<&str> = src
.lines()
.flat_map(|l| l.split('"').skip(1).step_by(2))
.filter(|t| {
let mut parts = t.split('.');
matches!(
(parts.next(), parts.next(), parts.next()),
(Some(a), Some(b), None)
if !a.is_empty() && !b.is_empty()
&& a.chars().chain(b.chars()).all(|c| c.is_ascii_lowercase() || c == '_')
)
})
.filter(|t| src.contains(&format!("\"{t}\" =>")) || src.contains(&format!("\"{t}\" |")))
.collect();
kinds.sort_unstable();
kinds.dedup();
assert!(kinds.len() > 30, "found only {kinds:?}");
let f = fixture("every-event");
for kind in kinds {
let line = ledger_store::JournalLine {
seq: 1,
ts: "2026-10-15T00:00:00Z".into(),
line_type: kind.to_string(),
payload: serde_json::json!({}),
prev: String::new(),
};
let e = ledger_gui::pages::journal::describe_any(&f.books, &line);
assert_ne!(e.text, kind, "{kind} has no journal text");
}
}
+651
View File
@@ -0,0 +1,651 @@
//! Attachment links, statements, and reconciliations in the viewer: each
//! subject shows exactly the files linked to it, each account its
//! statements and reconciliations, "Needs attention" asks for a
//! reconciliation until one covers last month, the journal says what
//! happened and opens it, and search finds statements and
//! reconciliations.
use chrono::NaiveDate;
use egui_kittest::Harness;
use ledger_core::{AccountType, Currency, EntryId, Money, NewAsset, PartyId, Role, Side};
use ledger_gui::books::Books;
use ledger_gui::nav::Route;
use ledger_gui::pages::journal::describe;
use ledger_gui::{App, Source, attention, data, palette};
use ledger_store::{Statement, Store, Subject};
use rust_decimal::Decimal;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
static DIRS: AtomicUsize = AtomicUsize::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
let n = DIRS.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!(
"ledger-gui-attachments-{tag}-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TempDir(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
fn today() -> NaiveDate {
d(2026, 10, 15)
}
fn load(dir: &Path) -> Books {
data::load_once(&Source {
data_dir: dir.join("books-data"),
books_toml: None,
today: Some(today()),
})
.unwrap()
}
struct Fixture {
dir: TempDir,
store: Store,
checking: u64,
card: u64,
party: PartyId,
asset: u64,
contribution: EntryId,
software: EntryId,
linked_only: EntryId,
receipt: u64,
contract: u64,
photo: u64,
}
/// Checking funded in July; software paid in August (its receipt filed on
/// it, and also linked to a second August entry that has no receipt of
/// its own); a contract linked to a party, the company, and the software
/// entry; a photo linked to an asset; a card account with no activity.
fn fixture(tag: &str) -> Fixture {
let dir = TempDir::new(tag);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let acct = |s: &mut Store, code: &str, name: &str, t| {
s.add_account(code, name, t, None, false).unwrap()
};
let checking = acct(&mut s, "1000", "Checking", AccountType::Asset);
let equipment = acct(&mut s, "1500", "Equipment", AccountType::Asset);
let accum = acct(
&mut s,
"1510",
"Accumulated Depreciation",
AccountType::Asset,
);
let card = acct(&mut s, "2050", "Business Card", AccountType::Liability);
let capital = acct(&mut s, "3000", "Capital", AccountType::Equity);
let software_acct = acct(&mut s, "6300", "Software", AccountType::Expense);
let depreciation = acct(&mut s, "6800", "Depreciation", AccountType::Expense);
let post = |s: &mut Store, date, what: &str, dr, cr, c, receipt: bool| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
if receipt {
s.attach_receipt(
Some(e),
&format!("{what}.pdf"),
"application/pdf",
what.as_bytes(),
None,
)
.unwrap();
}
s.post_entry(e).unwrap();
e
};
let contribution = post(
&mut s,
d(2026, 7, 5),
"Contribution",
checking,
capital,
500_000,
true,
);
let software = post(
&mut s,
d(2026, 8, 10),
"Software",
software_acct,
checking,
12_000,
true,
);
// Its receipt also covers this one (one invoice, two entries).
let e = s
.create_entry(d(2026, 8, 12), "Software, second seat")
.unwrap();
s.add_line(e, software_acct, Side::Debit, usd(3_000))
.unwrap();
s.add_line(e, checking, Side::Credit, usd(3_000)).unwrap();
let receipt = s.list_attachments(Some(software)).unwrap()[0].id;
s.link_attachment(receipt, Subject::Entry(e)).unwrap();
s.post_entry(e).unwrap();
let linked_only = e;
let party = s.add_party("Acme Software", &[Role::Contractor]).unwrap();
let (contract, _, _) = s
.file_document(
"contract",
"acme-agreement.pdf",
"application/pdf",
b"%PDF agreement",
Some("Signed agreement"),
)
.unwrap();
s.link_attachment(contract, Subject::Party(party)).unwrap();
s.link_attachment(contract, Subject::Company).unwrap();
s.link_attachment(contract, Subject::Entry(software))
.unwrap();
let asset = s
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 7, 6),
cost: usd(200_000),
salvage_value: usd(0),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
})
.unwrap();
let (photo, _, _) = s
.file_document("photo", "laptop.png", "image/png", b"png bytes", None)
.unwrap();
s.link_attachment(photo, Subject::Asset(asset)).unwrap();
Fixture {
dir,
store: s,
checking,
card,
party,
asset,
contribution,
software,
linked_only,
receipt,
contract,
photo,
}
}
/// Clear every line on `account` through `through`, file and record its
/// statement for `month_start..=through`, and reconcile it. Returns the
/// statement's attachment id and the reconciliation id.
fn reconcile(
s: &mut Store,
account: u64,
month_start: NaiveDate,
through: NaiveDate,
) -> (u64, u64) {
let mut lines = Vec::new();
for e in s.ledger().posted_entries() {
if e.date() <= through {
for (i, l) in e.lines().iter().enumerate() {
if l.account == account && !s.ledger().is_cleared(e.id(), i) {
lines.push((e.id(), i));
}
}
}
}
for (e, i) in lines {
s.mark_cleared(e, i, true).unwrap();
}
let report = |s: &Store, date| {
s.ledger()
.reconciliation_report(account, date, Decimal::ZERO)
.unwrap()
.cleared_balance
};
let opening = report(s, month_start.pred_opt().unwrap());
let closing = report(s, through);
let (att, _, _) = s
.file_document(
"statement",
&format!("statement-{through}.pdf"),
"application/pdf",
format!("statement {through}").as_bytes(),
None,
)
.unwrap();
s.record_statement(Statement {
attachment_id: att,
account_id: account,
period_start: month_start,
period_end: through,
opening_balance: opening,
closing_balance: closing,
})
.unwrap();
let r = s.complete_reconciliation(att).unwrap();
(att, r.id)
}
fn ids(docs: Vec<&ledger_store::Attachment>) -> Vec<u64> {
docs.into_iter().map(|a| a.id).collect()
}
#[test]
fn each_subject_shows_exactly_its_links() {
let f = fixture("links");
let b = load(&f.dir.0);
// An entry: filed on it, and linked to it.
assert_eq!(
ids(b.documents_for(&[Subject::Entry(f.software)])),
vec![f.receipt, f.contract]
);
assert_eq!(
ids(b.documents_for(&[Subject::Entry(f.linked_only)])),
vec![f.receipt]
);
assert_eq!(
ids(b.documents_for(&[Subject::Party(f.party)])),
vec![f.contract]
);
assert_eq!(ids(b.documents_for(&[Subject::Company])), vec![f.contract]);
assert_eq!(
ids(b.documents_for(&[Subject::Asset(f.asset)])),
vec![f.photo]
);
assert!(b.documents_for(&[Subject::Account(f.checking)]).is_empty());
// Every link of the contract, in the store's order (by kind, id).
assert_eq!(
b.links_of(f.contract),
f.store.attachment_links(f.contract).unwrap().as_slice()
);
assert_eq!(b.links_of(f.receipt)[0], Subject::Entry(f.software));
assert!(
b.links_of(f.receipt)
.contains(&Subject::Entry(f.linked_only))
);
// The receipt policy counts a linked receipt (the server's rule).
let only = b.entry(f.linked_only).unwrap();
assert!(!only.missing_receipt);
assert_eq!(only.receipts.len(), 1);
assert_eq!(b.attachments[only.receipts[0]].id, f.receipt);
// Every subject the books hold matches the store's own list.
for sub in [
Subject::Entry(f.contribution),
Subject::Entry(f.software),
Subject::Entry(f.linked_only),
Subject::Party(f.party),
Subject::Company,
Subject::Asset(f.asset),
Subject::Account(f.checking),
] {
assert_eq!(
ids(b.documents_for(&[sub])),
f.store
.attachments_for(sub)
.unwrap()
.iter()
.map(|a| a.id)
.collect::<Vec<_>>(),
"{sub:?}"
);
}
// Each chip opens its subject.
assert_eq!(
b.subject_route(Subject::Party(f.party)),
Some(Route::Party(f.party))
);
assert_eq!(b.subject_route(Subject::Company), Some(Route::Company));
assert_eq!(
b.subject_route(Subject::Asset(f.asset)),
Some(Route::Asset(f.asset))
);
}
#[test]
fn unlinking_and_withdrawing_take_a_file_off_its_subjects() {
let mut f = fixture("unlink");
f.store
.unlink_attachment(f.contract, Subject::Company, "Not company-wide")
.unwrap();
// A file comes off its links before it is withdrawn.
f.store
.unlink_attachment(f.photo, Subject::Asset(f.asset), "Blurry")
.unwrap();
f.store
.withdraw_attachment(f.photo, "Blurry; retaking it")
.unwrap();
let b = load(&f.dir.0);
assert!(b.documents_for(&[Subject::Company]).is_empty());
assert!(b.documents_for(&[Subject::Asset(f.asset)]).is_empty());
assert_eq!(
ids(b.documents_for(&[Subject::Party(f.party)])),
vec![f.contract]
);
assert!(!b.links_of(f.contract).contains(&Subject::Company));
assert!(b.links_of(f.photo).is_empty());
}
#[test]
fn statements_and_reconciliations_per_account() {
let mut f = fixture("recs");
let (jul_st, jul) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
f.store.void_reconciliation(jul, "Wrong statement").unwrap();
let (_, jul2) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
let (aug_st, aug) = reconcile(&mut f.store, f.checking, d(2026, 8, 1), d(2026, 8, 31));
let b = load(&f.dir.0);
let st = b.statements_for(f.checking);
assert_eq!(st.len(), 3);
assert_eq!(st[0].attachment_id, jul_st);
assert_eq!(st.last().unwrap().attachment_id, aug_st);
assert_eq!(
st.iter().map(|s| (*s).clone()).collect::<Vec<_>>(),
f.store.statements(Some(f.checking)).unwrap()
);
let recs = b.reconciliations_for(f.checking);
assert_eq!(
recs.iter().map(|r| r.id).collect::<Vec<_>>(),
vec![jul, jul2, aug]
);
assert_eq!(recs[0].voided.as_deref(), Some("Wrong statement"));
// Cleared lines: the contribution's, then August's two payments.
let a = b.reconciliation(aug).unwrap();
assert_eq!(a.cleared.len(), 3);
assert_eq!(a.statement_balance, cents(500_000 - 12_000 - 3_000));
assert!(b.reconciliations_for(f.card).is_empty());
assert_eq!(b.reconciled_through(f.checking), Some(d(2026, 8, 31)));
assert_eq!(b.reconciled_through(f.card), None);
use ledger_gui::pages::reconciliation::{Standing, lines, standing};
assert_eq!(standing(&b, a), Standing::Current);
assert_eq!(
standing(&b, b.reconciliation(jul2).unwrap()),
Standing::Superseded
);
assert_eq!(
standing(&b, b.reconciliation(jul).unwrap()),
Standing::Voided("Wrong statement".into())
);
// Its lines are the account's through the date, each marked.
let ls = lines(&b, a);
assert_eq!(ls.len(), 3);
assert!(ls.iter().all(|l| l.5));
let total: Decimal = ls.iter().map(|l| l.4).sum();
assert_eq!(total, a.statement_balance);
// The statement is linked to its account and its reconciliation.
assert!(b.links_of(aug_st).contains(&Subject::Account(f.checking)));
assert!(b.links_of(aug_st).contains(&Subject::Reconciliation(aug)));
assert_eq!(
b.subject_route(Subject::Reconciliation(aug)),
Some(Route::Reconciliation(aug))
);
}
fn reconcile_items(b: &Books) -> Vec<String> {
attention::find(b)
.into_iter()
.filter(|a| a.title.starts_with("Reconcile "))
.map(|a| a.title)
.collect()
}
#[test]
fn attention_asks_for_a_reconciliation_until_last_month_is_covered() {
let mut f = fixture("attention");
let b = load(&f.dir.0);
// Checking has activity; the card has none (no item).
assert_eq!(b.last_month_end(), d(2026, 9, 30));
assert_eq!(
reconcile_items(&b),
vec!["Reconcile Checking through Sep 30, 2026".to_string()]
);
let item = attention::find(&b)
.into_iter()
.find(|a| a.title.starts_with("Reconcile "))
.unwrap();
assert!(item.prompt.starts_with("In wanderbooks,"));
assert!(item.prompt.contains("books.record_statement"));
assert!(item.prompt.contains("books.reconcile"));
assert!(item.prompt.contains("1000 Checking"));
assert!(item.prompt.contains("2026-09-30"));
assert!(item.prompt.contains("never been reconciled"));
assert_eq!(item.target, Some(Route::Account(f.checking)));
// Through August only: still due.
reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 8, 31));
let b = load(&f.dir.0);
assert_eq!(reconcile_items(&b).len(), 1);
assert!(
attention::find(&b)
.iter()
.any(|a| a.prompt.contains("reconciled through 2026-08-31"))
);
// Through September (last month): clear.
let (_, sep) = reconcile(&mut f.store, f.checking, d(2026, 9, 1), d(2026, 9, 30));
let b = load(&f.dir.0);
assert!(reconcile_items(&b).is_empty(), "{:?}", reconcile_items(&b));
assert_eq!(b.reconciliation_due(f.checking), None);
// Voided, it's due again.
f.store.void_reconciliation(sep, "Redo").unwrap();
let b = load(&f.dir.0);
assert_eq!(reconcile_items(&b).len(), 1);
// Card activity before this month makes the card due too.
let e = f.store.create_entry(d(2026, 9, 3), "Card charge").unwrap();
let software = f.store.ledger().chart().find_by_code("6300").unwrap().id;
f.store
.add_line(e, software, Side::Debit, usd(1_000))
.unwrap();
f.store
.add_line(e, f.card, Side::Credit, usd(1_000))
.unwrap();
f.store
.attach_receipt(Some(e), "c.pdf", "application/pdf", b"card", None)
.unwrap();
f.store.post_entry(e).unwrap();
let b = load(&f.dir.0);
assert!(
reconcile_items(&b).contains(&"Reconcile Business Card through Sep 30, 2026".to_string())
);
}
#[test]
fn journal_text_names_and_opens_the_subjects() {
let mut f = fixture("journal");
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
f.store.void_reconciliation(r, "Wrong statement").unwrap();
f.store
.unlink_attachment(f.contract, Subject::Company, "Not company-wide")
.unwrap();
let b = load(&f.dir.0);
let find = |t: &str| {
b.journal
.iter()
.filter(|l| l.line_type == t)
.map(|l| describe(&b, l).unwrap())
.collect::<Vec<_>>()
};
let linked = find("attachment.linked");
let party_link = linked
.iter()
.find(|e| e.also == Some(Route::Party(f.party)))
.unwrap();
assert_eq!(
party_link.text,
"Linked acme-agreement.pdf to Acme Software"
);
assert_eq!(party_link.route, Some(Route::Document(f.contract)));
assert!(
linked
.iter()
.any(|e| e.text == "Linked laptop.png to Laptop"
&& e.also == Some(Route::Asset(f.asset)))
);
assert!(
linked
.iter()
.any(|e| e.text == format!("Linked acme-agreement.pdf to #{} Software", f.software))
);
assert!(
linked
.iter()
.any(|e| e.also == Some(Route::Reconciliation(r)))
);
let unlinked = &find("attachment.unlinked")[0];
assert_eq!(
unlinked.text,
"Unlinked acme-agreement.pdf from the company: Not company-wide"
);
assert_eq!(unlinked.also, Some(Route::Company));
let recorded = &find("statement.recorded")[0];
assert!(
recorded.text.contains("1000 Checking") && recorded.text.contains("Jul 31, 2026"),
"{}",
recorded.text
);
assert_eq!(recorded.route, Some(Route::Document(st)));
assert_eq!(recorded.also, Some(Route::Account(f.checking)));
let done = &find("reconciliation.completed")[0];
assert!(
done.text
.starts_with("Reconciled 1000 Checking through Jul 31, 2026"),
"{}",
done.text
);
assert_eq!(done.route, Some(Route::Reconciliation(r)));
let voided = &find("reconciliation.voided")[0];
assert_eq!(
voided.text,
"Voided the reconciliation of 1000 Checking through Jul 31, 2026: Wrong statement"
);
assert_eq!(voided.route, Some(Route::Reconciliation(r)));
}
#[test]
fn search_finds_statements_and_reconciliations() {
let mut f = fixture("search");
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
let b = load(&f.dir.0);
let routes = |q: &str| -> Vec<Route> {
palette::search(&b, q)
.into_iter()
.map(|i| i.route)
.collect()
};
assert!(routes("reconciliation checking").contains(&Route::Reconciliation(r)));
assert!(routes("checking 2026-07-31").contains(&Route::Reconciliation(r)));
assert!(routes("statement checking jul").contains(&Route::Document(st)));
assert!(!routes("statement savings").contains(&Route::Document(st)));
}
#[test]
fn every_new_page_renders() {
let mut f = fixture("render");
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
for route in [
Route::Overview,
Route::Account(f.checking),
Route::Account(f.card),
Route::Reconciliation(r),
Route::Reconciliation(999),
Route::Document(st),
Route::Document(f.contract),
Route::Entry(f.software),
Route::Entry(f.linked_only),
Route::Party(f.party),
Route::Asset(f.asset),
Route::Company,
Route::Parties,
] {
let books = load(&f.dir.0);
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
h.run_steps(3);
assert_eq!(h.state_mut().route(), &route);
}
}
#[test]
fn books_show_opens_a_reconciliation() {
use ledger_store::viewer::ViewerTarget;
let mut f = fixture("show");
let (_, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
let books = load(&f.dir.0);
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
h.state_mut().show(ViewerTarget::Reconciliation { id: r });
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
// An unknown one goes nowhere.
h.state_mut().show(ViewerTarget::Reconciliation { id: 999 });
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
}
/// ledger_store::reconcilable INVARIANT 1, the digest's rule: a liability
/// not named for a card (an Amex) joins the accounts to reconcile once it
/// has a recorded statement, so Needs attention asks for it as the digest
/// does (it used to be listed by the digest only).
#[test]
fn a_liability_with_a_statement_needs_reconciling_whatever_its_name() {
let mut f = fixture("amex");
let amex = f
.store
.add_account("2060", "Amex", AccountType::Liability, None, false)
.unwrap();
let software = f.store.ledger().chart().find_by_code("6300").unwrap().id;
let e = f.store.create_entry(d(2026, 9, 3), "Amex charge").unwrap();
f.store
.add_line(e, software, Side::Debit, usd(1_000))
.unwrap();
f.store.add_line(e, amex, Side::Credit, usd(1_000)).unwrap();
f.store.post_entry(e).unwrap();
let b = load(&f.dir.0);
assert!(!b.reconcilable_accounts().contains(&amex));
let (att, _, _) = f
.store
.file_document(
"statement",
"amex-aug.pdf",
"application/pdf",
b"amex aug",
None,
)
.unwrap();
f.store
.record_statement(Statement {
attachment_id: att,
account_id: amex,
period_start: d(2026, 8, 1),
period_end: d(2026, 8, 31),
opening_balance: Decimal::ZERO,
closing_balance: Decimal::ZERO,
})
.unwrap();
let b = load(&f.dir.0);
assert!(b.reconcilable_accounts().contains(&amex));
assert_eq!(b.reconciliation_due(amex), Some(d(2026, 9, 30)));
assert!(reconcile_items(&b).contains(&"Reconcile Amex through Sep 30, 2026".to_string()));
}
+106
View File
@@ -0,0 +1,106 @@
//! Click to copy: values leave the viewer exactly.
//!
//! ```text
//! GOAL: what people retype into forms (the EIN, an amount, an entry
//! number) can be copied instead, and copies exactly.
//! SOURCE: DECISION (Stephen, 2026-10-14: click-to-copy on the company
//! page, amounts, and identifiers).
//! INVARIANTS:
//! 1. An amount copies as its plain decimal: fmt::plain of money,
//! accounting, or dollars text is the exact value, with "-" for a
//! negative and no "$", commas, or parentheses.
//! 2. Clicking a company value or an entry's amount puts exactly that
//! value on the clipboard.
//! ```
use egui::OutputCommand;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_gui::nav::Route;
use ledger_gui::{App, Source, data, fmt};
use proptest::prelude::*;
use rust_decimal::Decimal;
proptest! {
/// INVARIANT 1: every display style copies back to the same value.
#[test]
fn an_amount_copies_as_its_exact_value(
mantissa in -10i128.pow(16)..10i128.pow(16),
scale in 0u32..8,
) {
let d = Decimal::from_i128_with_scale(mantissa, scale);
for shown in [fmt::money(d), fmt::accounting(d), fmt::dollars(d)] {
let plain = fmt::plain(&shown).expect("an amount");
prop_assert!(!plain.contains(['$', ',', '(', ')', '\u{2212}']), "{}", plain);
prop_assert_eq!(plain.parse::<Decimal>().unwrap(), d, "{} copied as {}", shown, plain);
}
}
}
fn copied(h: &Harness<'_, App>) -> Vec<String> {
h.output()
.platform_output
.commands
.iter()
.filter_map(|c| match c {
OutputCommand::CopyText(t) => Some(t.clone()),
_ => None,
})
.collect()
}
/// INVARIANT 2, on the demo books.
#[test]
fn clicking_a_value_copies_it() {
let dir = std::env::temp_dir().join(format!("ledger-gui-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
ledger_gui::demo::build(&dir, today).unwrap();
let source = Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
};
let open = |route: Route| {
let books = data::load_once(&source).unwrap();
let mut h = Harness::builder()
.with_size(egui::vec2(1280.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route));
h.run_steps(3);
h
};
// The company's EIN, as written in wanderbooks.toml.
let ein = data::load_once(&source)
.unwrap()
.settings
.company
.profile
.iter()
.find(|(k, _)| k == "ein")
.map(|(_, v)| v.clone())
.expect("the demo has an EIN");
let mut h = open(Route::Company);
h.get_by_label(&ein).click();
h.step();
assert_eq!(copied(&h), [ein]);
// An entry's amount, as a plain decimal.
let books = data::load_once(&source).unwrap();
let e = books.entries.iter().find(|e| !e.amount.is_zero()).unwrap();
let (id, amount) = (e.id, e.amount);
drop(books);
let mut h = open(Route::Entry(id));
let shown = fmt::dollars(amount);
h.get_by_label(&shown).click();
h.step();
assert_eq!(copied(&h), [fmt::plain(&shown).unwrap()]);
assert_eq!(copied(&h)[0].parse::<Decimal>().unwrap(), amount);
// The entry number.
let mut h = open(Route::Entry(id));
h.get_by_label(&format!("Entry #{id}")).click();
h.step();
assert_eq!(copied(&h), [format!("#{id}")]);
let _ = std::fs::remove_dir_all(&dir);
}
File diff suppressed because it is too large. Load diff
+223
View File
@@ -0,0 +1,223 @@
//! The viewer is read-only: it never writes into the books.
//!
//! ```text
//! GOAL: Stephen, Kat, and the CPA can open the books in the viewer
//! without any chance of changing them.
//! SOURCE: DECISION (AGENTS.md: "read-only and live ... It never writes;
//! changes go through you").
//! INVARIANTS:
//! 1. With every file and folder of the books made read-only on disk,
//! the viewer still loads, follows live, and renders every page
//! (receipts included), and reports the books whole.
//! 2. Afterwards every file holds the same bytes, size, and
//! modification time, and no file has appeared or gone; on writable
//! books too (no SQLite -wal or -shm created beside the database).
//! ```
use egui_kittest::Harness;
use ledger_gui::data::{self, Loader, Update};
use ledger_gui::nav::{ReportKind, Route};
use ledger_gui::{App, Source};
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
type Tree = BTreeMap<PathBuf, (Vec<u8>, u64, SystemTime)>;
fn tree(root: &Path) -> Tree {
fn walk(root: &Path, d: &Path, out: &mut Tree) {
for e in std::fs::read_dir(d).unwrap() {
let p = e.unwrap().path();
let m = std::fs::symlink_metadata(&p).unwrap();
if m.is_dir() {
walk(root, &p, out);
} else {
out.insert(
p.strip_prefix(root).unwrap().to_path_buf(),
(std::fs::read(&p).unwrap(), m.len(), m.modified().unwrap()),
);
}
}
}
let mut out = BTreeMap::new();
walk(root, root, &mut out);
out
}
/// Every file 0444 and folder 0555 (`read_only`), or back to 0644/0755.
fn set_read_only(root: &Path, read_only: bool) {
fn walk(p: &Path, ro: bool) {
let m = std::fs::symlink_metadata(p).unwrap();
if m.is_dir() {
if !ro {
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
for e in std::fs::read_dir(p).unwrap() {
walk(&e.unwrap().path(), ro);
}
if ro {
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o555)).unwrap();
}
} else {
let mode = if ro { 0o444 } else { 0o644 };
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
}
}
walk(root, read_only);
}
/// Makes the books writable again when dropped, so a failing or
/// interrupted run never leaves a folder no one can delete.
struct Writable(PathBuf);
impl Drop for Writable {
fn drop(&mut self) {
if self.0.exists() {
set_read_only(&self.0, false);
}
}
}
/// Folders left read-only by a killed run (no destructor ran): made
/// writable and removed once their process is gone.
fn sweep_leftovers() {
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
return;
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let Some(pid) = name.strip_prefix("ledger-gui-ro-") else {
continue;
};
if Path::new("/proc").join(pid).exists() {
continue; // still running (or the pid was reused): leave it
}
set_read_only(&e.path(), false);
let _ = std::fs::remove_dir_all(e.path());
}
}
#[test]
fn the_viewer_never_writes_into_the_books() {
sweep_leftovers();
let dir = std::env::temp_dir().join(format!("ledger-gui-ro-{}", std::process::id()));
if dir.exists() {
set_read_only(&dir, false);
std::fs::remove_dir_all(&dir).unwrap();
}
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
ledger_gui::demo::build(&dir, today).unwrap();
let source = Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
};
let _restore = Writable(dir.clone());
set_read_only(&dir, true);
let before = tree(&dir);
// Load once; the books are whole (the database matches the journal).
let books = data::load_once(&source).expect("loads from read-only books");
assert!(
matches!(books.integrity.disk, ledger_store::DiskCheck::Matches),
"read-only books reported as {:?}",
books.integrity.disk
);
assert!(books.integrity.file_failures.is_empty());
let entry = books.entries.last().map(|e| e.id).unwrap();
let cash = books.cash_accounts[0];
let doc = books.attachments.last().map(|a| a.id).unwrap();
let event = books.seq();
let asset = books
.ledger
.registered_assets()
.next()
.map(|a| a.id)
.unwrap();
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
drop(books);
// Follow live for a moment.
let loader = Loader::spawn(source.clone(), egui::Context::default());
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut live = false;
while std::time::Instant::now() < deadline {
match loader.wait(Duration::from_millis(200)) {
Some(Update::Books(_)) => {
live = true;
break;
}
Some(Update::Error(e)) => panic!("live loader on read-only books: {e}"),
_ => {}
}
}
assert!(live, "the live loader never delivered the books");
// Render every page.
let routes = [
Route::Overview,
Route::Transactions,
Route::Entry(entry),
Route::Cash,
Route::Profit,
Route::Reports(ReportKind::ProfitAndLoss),
Route::Reports(ReportKind::BalanceSheet),
Route::Reports(ReportKind::CashFlow),
Route::Reports(ReportKind::TrialBalance),
Route::Reports(ReportKind::GeneralLedger),
Route::Reports(ReportKind::TaxSummary),
Route::Reports(ReportKind::Vendor1099),
Route::Reports(ReportKind::ArAging),
Route::Reports(ReportKind::ApAging),
Route::Reports(ReportKind::MembersEquity),
Route::Reports(ReportKind::FixedAssetRegister),
Route::Accounts,
Route::Account(cash),
Route::Assets,
Route::Asset(asset),
Route::Parties,
Route::Party(party),
Route::Document(doc),
Route::JournalEvent(event),
Route::Documents,
Route::Company,
Route::Journal,
];
for route in routes {
let books = data::load_once(&source).unwrap();
let mut harness = Harness::builder()
.with_size(egui::vec2(1280.0, 800.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
harness.run_steps(3);
let ctx = harness.ctx.clone();
harness.state_mut().settle(&ctx);
harness.run_steps(2);
}
let after = tree(&dir);
set_read_only(&dir, false);
let changed: Vec<_> = before
.keys()
.chain(after.keys())
.filter(|k| before.get(*k) != after.get(*k))
.collect();
assert!(changed.is_empty(), "the viewer changed {changed:?}");
// On writable books too: loading and checking creates nothing (no
// SQLite -wal or -shm beside the database).
let before = tree(&dir);
let books = data::load_once(&source).unwrap();
assert!(matches!(
books.integrity.disk,
ledger_store::DiskCheck::Matches
));
drop(books);
let after = tree(&dir);
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"the viewer created or removed files in writable books"
);
assert!(before == after, "the viewer changed writable books");
let _ = std::fs::remove_dir_all(&dir);
}
+43
View File
@@ -45,6 +45,38 @@ fn screenshots() {
let first_cash = loaded.cash_accounts.first().copied().unwrap_or(1);
let last_doc = loaded.attachments.last().map(|a| a.id).unwrap_or(1);
let last_event = loaded.seq();
let first_asset = loaded
.ledger
.registered_assets()
.next()
.map(|a| a.id)
.unwrap_or(1);
// A party with the most to show: W-9 facts first, else any.
let party = std::env::var("SHOT_PARTY")
.ok()
.and_then(|p| p.parse().ok())
.or_else(|| {
loaded
.ledger
.parties()
.find(|p| p.w9.is_some() && p.vendor.is_some())
.or_else(|| loaded.ledger.parties().next())
.map(|p| p.id)
})
.unwrap_or(1);
let reconciliation = loaded.reconciliations.last().map(|r| r.id).unwrap_or(1);
let voided = loaded
.reconciliations
.iter()
.find(|r| r.voided.is_some())
.map_or(reconciliation, |r| r.id);
// A document linked to more than one subject, else the last.
let linked_doc = loaded
.attachments
.iter()
.rev()
.find(|a| loaded.links_of(a.id).len() > 1)
.map_or(last_doc, |a| a.id);
drop(loaded);
let routes: Vec<(&str, Route)> = vec![
("overview", Route::Overview),
@@ -68,9 +100,20 @@ fn screenshots() {
("tax", Route::Reports(ReportKind::TaxSummary)),
("v1099", Route::Reports(ReportKind::Vendor1099)),
("ar", Route::Reports(ReportKind::ArAging)),
(
"asset-register",
Route::Reports(ReportKind::FixedAssetRegister),
),
("accounts", Route::Accounts),
("account", Route::Account(first_cash)),
("assets", Route::Assets),
("asset", Route::Asset(first_asset)),
("parties", Route::Parties),
("party", Route::Party(party)),
("document", Route::Document(last_doc)),
("document-linked", Route::Document(linked_doc)),
("reconciliation", Route::Reconciliation(reconciliation)),
("reconciliation-voided", Route::Reconciliation(voided)),
("event", Route::JournalEvent(last_event)),
("documents", Route::Documents),
("company", Route::Company),
+39
View File
@@ -18,6 +18,7 @@ fn show_requests_navigate_the_viewer() {
};
let books = data::load_once(&source).unwrap();
let rent = books.ledger.chart().find_by_code("6200").unwrap().id;
let laptop = books.ledger.asset_by_tag("js-0001").unwrap().id;
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 920.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, true, Route::Overview));
@@ -62,6 +63,44 @@ fn show_requests_navigate_the_viewer() {
assert_eq!(h.state_mut().route(), &Route::Transactions);
assert_eq!(h.state_mut().state_mut().transactions.query, "adobe");
// "Show me the asset register", "the laptop", "the register at June 30".
h.state_mut().show(ViewerTarget::Page {
page: "assets".into(),
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Assets);
h.state_mut().show(ViewerTarget::Asset { asset_id: laptop });
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Asset(laptop));
h.state_mut().show(ViewerTarget::Report {
report: "fixed_asset_register".into(),
start: None,
end: Some("2026-06-30".into()),
columns: None,
compare: None,
});
h.run_steps(2);
assert_eq!(
h.state_mut().route(),
&Route::Reports(ReportKind::FixedAssetRegister)
);
assert!(matches!(
h.state_mut().state_mut().reports.range,
ledger_gui::pages::reports::Range::Custom(_, e)
if e == chrono::NaiveDate::from_ymd_opt(2026, 6, 30).unwrap()
));
// An unknown asset is ignored.
h.state_mut().show(ViewerTarget::Asset { asset_id: 999 });
h.run_steps(1);
assert_eq!(
h.state_mut().route(),
&Route::Reports(ReportKind::FixedAssetRegister)
);
h.state_mut().show(ViewerTarget::Search {
query: "adobe".into(),
});
h.run_steps(2);
// Unknown accounts are ignored, not crashes.
h.state_mut().show(ViewerTarget::Account {
code: "9999".into(),
@@ -0,0 +1,12 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc e1abb99e086123434884a3986a83c6a04e095d9f4a0d6b0b8f9490c3ef7a3fb4 # shrinks to center = 721350534.2385265, span_exp = -2, n = 1
cc 67ed971061efe8d67c42fac1e63e6d1943358315b8d447482fd8e723d2850577 # shrinks to query = "c", head = "ac", tail = "", other = "c"
cc 2f8bab974c3fe9cadfdc5f457bec479125025199050e9ad7d2a868750951e1d8 # shrinks to a = 0.0, b = -0.4626437454001001, n = 3, same = false
cc a358fb90f0d14b99605d509ebbe00e178285158223ab165a54676d70a1a01287 # shrinks to plan = BooksPlan { threshold: "0.00", entries: [EntryPlan { day: 0, debits: [(0, 1)], credit_accounts: [0], split_pct: 1, scale: 3, post: false, receipt: false }], reverse: None }
cc 3b788cd1184019b4cf33c3150a5368e446d20bdc52afe98b6d3094516ac7e5ac # shrinks to a = 1.2726536570066655e-11, b = -0.44869856490793697, n = 1, same = false
cc adafa1178ead699437f63287c9be531c043a0a9de3ee34f908e4ca3ef81bc6c3 # shrinks to center = 79655376.47358137, span_exp = -6, n = 5
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 3eecfe5edc181e6caa0c4157dda266dacbca83c62d0b95b8792177443100d78f # shrinks to p = Plan { today: 2026-03-31, fy_month: 1, entries: [(0, 0, 1, 1), (1, 0, 2, 1)] }
+651
View File
@@ -0,0 +1,651 @@
//! The viewer's numbers are the books' numbers: the view model
//! (books::Books, books::Kpis) against ledger-core, computed here
//! independently, on random books.
use chrono::{Datelike, Days, Months, NaiveDate};
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period, trial_balance_as_of};
use ledger_core::{AccountType, Currency, DocumentLine, Money, Side};
use ledger_gui::{Source, data, fmt};
use ledger_store::Store;
use proptest::prelude::*;
use rust_decimal::{Decimal, RoundingStrategy};
use std::path::PathBuf;
const CHART: [(&str, &str, AccountType); 9] = [
("1000", "Checking", AccountType::Asset),
("1010", "Savings", AccountType::Asset),
("1200", "Accounts Receivable", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("2000", "Accounts Payable", AccountType::Liability),
("2100", "Due to owners", AccountType::Liability),
("3000", "Capital", AccountType::Equity),
("4000", "Revenue", AccountType::Revenue),
("6000", "Supplies", AccountType::Expense),
];
const CASH: [&str; 2] = ["1000", "1010"];
#[derive(Clone, Debug)]
struct Plan {
today: NaiveDate,
fy_month: u32,
/// (days before today, debit index, credit index, cents)
entries: Vec<(u64, usize, usize, i64)>,
}
fn plan() -> impl Strategy<Value = Plan> {
(
// Month ends and leap days included.
prop_oneof![
Just(NaiveDate::from_ymd_opt(2026, 3, 31).unwrap()),
Just(NaiveDate::from_ymd_opt(2028, 2, 29).unwrap()),
Just(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
(0u64..900).prop_map(|d| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() + Days::new(d)),
],
1u32..=12,
proptest::collection::vec((0u64..200, 0usize..9, 0usize..9, 1i64..5_000_000), 0..25),
)
.prop_map(|(today, fy_month, entries)| Plan {
today,
fy_month,
entries: entries.into_iter().filter(|e| e.1 != e.2).collect(),
})
}
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ledger-gui-vm-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn build(plan: &Plan, tag: &str) -> (Source, ledger_core::Ledger) {
let d = dir(tag);
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let ids: Vec<_> = CHART
.iter()
.map(|(c, n, t)| s.add_account(c, n, *t, None, false).unwrap())
.collect();
for &(back, dr, cr, cents) in &plan.entries {
let e = s.create_entry(plan.today - Days::new(back), "x").unwrap();
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, ids[dr], Side::Debit, m).unwrap();
s.add_line(e, ids[cr], Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
}
std::fs::write(
d.join("wanderbooks.toml"),
format!(
"[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"{:02}-01\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nreceipt_threshold = \"25.00\"\n",
plan.fy_month
),
)
.unwrap();
let ledger = s.ledger().clone();
(
Source {
data_dir: d.join("books-data"),
books_toml: Some(d.join("wanderbooks.toml")),
today: Some(plan.today),
},
ledger,
)
}
/// An account's normal-side balance at the end of `date`, from the trial
/// balance.
fn balance(l: &ledger_core::Ledger, code: &str, date: NaiveDate) -> Decimal {
let t = l.chart().find_by_code(code).unwrap().account_type;
let tb = trial_balance_as_of(l, date);
let net = tb
.lines
.iter()
.find(|x| x.code == code)
.map(|x| x.debit - x.credit)
.unwrap_or_default();
match t {
AccountType::Asset | AccountType::Expense => net,
_ => -net,
}
}
fn cash(l: &ledger_core::Ledger, date: NaiveDate) -> Decimal {
CASH.iter().map(|c| balance(l, c, date)).sum()
}
fn first_of(d: NaiveDate) -> NaiveDate {
NaiveDate::from_ymd_opt(d.year(), d.month(), 1).unwrap()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
/// Kpis INVARIANTS 1-7 and the period helpers, against ledger-core.
#[test]
fn the_overview_numbers_are_the_books_numbers(p in plan()) {
let (source, l) = build(&p, "kpi");
let b = data::load_once(&source).unwrap();
let k = &b.kpis;
let t = p.today;
let m0 = first_of(t);
prop_assert_eq!(b.month_start(), m0);
prop_assert_eq!(k.cash, cash(&l, t));
prop_assert_eq!(k.cash_30d_ago, cash(&l, t - Days::new(30)));
prop_assert_eq!(k.liabilities, balance_sheet_as_of(&l, t).unwrap().total_liabilities);
prop_assert_eq!(k.receivables, balance(&l, "1200", t));
let pl = |s, e| profit_and_loss_for_period(&l, s, e, None).unwrap();
let mtd = pl(m0, t);
prop_assert_eq!(k.revenue_mtd, mtd.total_revenue);
prop_assert_eq!(k.expenses_mtd, mtd.total_expenses);
let prev_start = m0 - Months::new(1);
let prev_last = m0 - Days::new(1);
let same = NaiveDate::from_ymd_opt(prev_start.year(), prev_start.month(), t.day())
.unwrap_or(prev_last);
let prev = pl(prev_start, same);
prop_assert_eq!(k.revenue_prev_month, prev.total_revenue);
prop_assert_eq!(k.expenses_prev_month, prev.total_expenses);
// Fiscal year: its first day is the last fiscal-start first-of-month
// on or before today.
let mut fy = NaiveDate::from_ymd_opt(t.year(), p.fy_month, 1).unwrap();
if fy > t {
fy = fy - Months::new(12);
}
prop_assert_eq!(b.fiscal_ytd(), (fy, t));
let ytd = pl(fy, t);
prop_assert_eq!(k.net_ytd, ytd.total_revenue - ytd.total_expenses);
let first = p.entries.iter().map(|e| t - Days::new(e.0)).min();
prop_assert_eq!(k.first_entry, first);
// Burn: the three full months before m0, averaged over those the
// books cover.
let three = pl(m0 - Months::new(3), prev_last);
let loss = three.total_expenses - three.total_revenue;
let covered = match first {
None => 0,
Some(f) => (0..3)
.filter(|i| first_of(m0 - Months::new(i + 1)) >= first_of(f))
.count() as i64,
};
let burn = (loss > Decimal::ZERO && covered > 0).then(|| {
(loss / Decimal::from(covered))
.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
});
prop_assert_eq!(k.monthly_burn, burn);
// Trailing months: n months ending with this one.
for n in 1..=12u32 {
prop_assert_eq!(b.trailing_months(n), (m0 - Months::new(n - 1), t));
}
// Cash history: each point is the cash balance that day, dates
// strictly rising, through the last day cash moved.
let h = b.cash_history();
for w in h.windows(2) {
prop_assert!(w[0].0 < w[1].0);
}
for (d, v) in &h {
prop_assert_eq!(*v, cash(&l, *d), "cash on {}", d);
}
// ... and it isn't empty when a cash account moved: its last point
// is the last day one did (a transfer between them included).
let cash_idx = [0usize, 1];
let last_move = p
.entries
.iter()
.filter(|e| cash_idx.contains(&e.1) || cash_idx.contains(&e.2))
.map(|e| t - Days::new(e.0))
.max();
prop_assert_eq!(h.last().map(|x| x.0), last_move);
// Names and labels.
for (code, name, _) in CHART {
let id = l.chart().find_by_code(code).unwrap().id;
prop_assert_eq!(b.account_label(id), format!("{code} {name}"));
prop_assert_eq!(b.account_name(id), name);
}
prop_assert_eq!(b.company_name(), "Oracle Co");
let events = std::fs::read_to_string(source.data_dir.join("journal.jsonl"))
.unwrap()
.lines()
.count() as u64;
prop_assert_eq!(b.seq(), events);
prop_assert_eq!(b.receipt_rule(), "over the $25.00 receipt threshold");
let _ = std::fs::remove_dir_all(source.data_dir.parent().unwrap());
}
}
/// Where each entry came from, and who it was with, from the journal.
#[test]
fn origins_and_parties() {
let d = dir("origin");
let today = NaiveDate::from_ymd_opt(2026, 5, 20).unwrap();
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let sup = s
.add_account("6000", "Supplies", AccountType::Expense, None, false)
.unwrap();
let cust = s.add_customer("Acme Buyer").unwrap();
let vend = s.add_vendor("Paper Co").unwrap();
let line = |a: i64| {
vec![DocumentLine {
description: "x".into(),
amount: Decimal::new(a, 2),
}]
};
let inv = s
.create_invoice(cust, today, today + Days::new(30), line(10_000))
.unwrap();
s.create_bill(vend, today, today + Days::new(30), line(5_000))
.unwrap();
let paid = s
.receive_payment(inv, today, Decimal::new(10_000, 2), cash)
.unwrap();
let e = s.create_entry(today, "Paper").unwrap();
let m = Money::new(Decimal::new(1_000, 2), Currency::USD);
s.add_line(e, sup, Side::Debit, m).unwrap();
s.add_line(e, cash, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
let (blob, _, _) = s
.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF receipt", None)
.unwrap();
let rev = s.reverse_entry(e, today, "undo").unwrap();
drop(s);
std::fs::write(
d.join("wanderbooks.toml"),
"[books]\nreceipt_threshold = \"0.00\"\n",
)
.unwrap();
let source = Source {
data_dir: d.join("books-data"),
books_toml: Some(d.join("wanderbooks.toml")),
today: Some(today),
};
let b = data::load_once(&source).unwrap();
let by_origin = |o: &str| b.entries.iter().filter(|x| x.origin == o).count();
assert_eq!(by_origin("Invoice"), 1);
assert_eq!(by_origin("Bill"), 1);
assert_eq!(by_origin("Customer payment"), 1);
assert_eq!(by_origin("Reversal"), 1);
assert_eq!(b.entry(paid).unwrap().party.as_deref(), Some("Acme Buyer"));
let bill_entry = b.entries.iter().find(|x| x.origin == "Bill").unwrap();
assert_eq!(bill_entry.party.as_deref(), Some("Paper Co"));
assert_eq!(b.entry(e).unwrap().reversed_by, Some(rev));
assert_eq!(b.entry(rev).unwrap().reversal_of, Some(e));
// Every entry's journal events name it.
for x in &b.entries {
assert!(!x.events.is_empty(), "entry {} has no events", x.id);
}
assert_eq!(b.receipt_rule(), "every entry needs a receipt");
// The receipt's bytes are where the blob root says.
let a = b.attachments.iter().find(|a| a.id == blob).unwrap();
let path = b
.blob_root()
.join(&a.blob_sha[..2])
.join(&a.blob_sha[2..4])
.join(&a.blob_sha);
assert_eq!(std::fs::read(path).unwrap(), b"%PDF receipt");
assert_eq!(b.integrity.files_ok, 1);
assert!(b.integrity.file_failures.is_empty());
let _ = std::fs::remove_dir_all(&d);
}
/// fmt helpers the viewer shows: percent change and tax forms.
#[test]
fn percent_change_and_tax_forms() {
let d = |s: &str| s.parse::<Decimal>().unwrap();
assert_eq!(fmt::pct_change(d("0"), d("5")), None);
assert_eq!(fmt::pct_change(d("100"), d("150")), Some(50.0));
assert_eq!(fmt::pct_change(d("200"), d("150")), Some(-25.0));
assert_eq!(fmt::pct_change(d("-100"), d("-50")), Some(50.0));
use ledger_core::TaxForm::*;
for (f, number) in [
(ScheduleC, "Schedule C"),
(Form1065, "1065"),
(Form1120S, "1120-S"),
(Form1120, "1120"),
] {
assert!(fmt::tax_form(f).contains(number), "{f:?}");
}
assert_eq!(fmt::compact(d("-1234")), "\u{2212}$1.2k");
}
fn store_at(tag: &str) -> (PathBuf, Store) {
let d = dir(tag);
let s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
(d, s)
}
fn source(d: &std::path::Path, toml: &str, today: NaiveDate) -> Source {
std::fs::write(d.join("wanderbooks.toml"), toml).unwrap();
Source {
data_dir: d.join("books-data"),
books_toml: Some(d.join("wanderbooks.toml")),
today: Some(today),
}
}
fn post(s: &mut Store, date: NaiveDate, dr: u64, cr: u64, cents: i64) -> u64 {
let e = s.create_entry(date, "x").unwrap();
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, dr, Side::Debit, m).unwrap();
s.add_line(e, cr, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
e
}
/// Kpis INVARIANTS 4 and 6 on fixed dates: the prior month runs to the
/// same day; burn averages over only the months the books cover.
#[test]
fn comparisons_and_burn_on_fixed_dates() {
let (d, mut s) = store_at("burn");
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rev = s
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let exp = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let today = NaiveDate::from_ymd_opt(2026, 5, 10).unwrap();
// The books start in March: burn covers March and April, not February.
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 3, 3).unwrap(),
exp,
cash,
30_000,
);
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 4, 10).unwrap(),
exp,
cash,
10_000,
);
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 4, 11).unwrap(),
cash,
rev,
7_000,
); // after the same day
post(
&mut s,
NaiveDate::from_ymd_opt(2026, 5, 2).unwrap(),
cash,
rev,
1_000,
);
drop(s);
let b = data::load_once(&source(&d, "[books]\ncash_accounts = [\"1000\"]\n", today)).unwrap();
let k = &b.kpis;
assert_eq!(k.revenue_mtd, Decimal::new(1_000, 2));
assert_eq!(k.expenses_prev_month, Decimal::new(10_000, 2), "April 1-10");
assert_eq!(
k.revenue_prev_month,
Decimal::ZERO,
"April 11 is after the same day"
);
// Feb-Apr loss: 300 + 100 - 70 = 330 over the 2 months covered.
assert_eq!(k.monthly_burn, Some(Decimal::new(16_500, 2)));
// A profitable stretch has no burn; books starting this month have none.
let (d2, mut s2) = store_at("burn-new");
let c2 = s2
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let e2 = s2
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
post(
&mut s2,
NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(),
e2,
c2,
5_000,
);
drop(s2);
let b2 = data::load_once(&source(&d2, "", today)).unwrap();
assert_eq!(b2.kpis.monthly_burn, None);
// Three full months of loss: divided by 3, half away from zero.
let (d3, mut s3) = store_at("burn-three");
let c3 = s3
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let e3 = s3
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
post(
&mut s3,
NaiveDate::from_ymd_opt(2025, 12, 1).unwrap(),
e3,
c3,
1,
);
post(
&mut s3,
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
e3,
c3,
1_000,
);
drop(s3);
let b3 = data::load_once(&source(&d3, "", today)).unwrap();
// 10.00 / 3 = 3.333... -> 3.33
assert_eq!(b3.kpis.monthly_burn, Some(Decimal::new(333, 2)));
// Books covering only last month: one month, not two.
let (d4, mut s4) = store_at("burn-one");
let c4 = s4
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let e4 = s4
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
post(
&mut s4,
NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
e4,
c4,
9_000,
);
drop(s4);
let b4 = data::load_once(&source(&d4, "", today)).unwrap();
assert_eq!(b4.kpis.monthly_burn, Some(Decimal::new(9_000, 2)));
// Breaking even burns nothing.
let (d5, mut s5) = store_at("burn-even");
let c5 = s5
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let r5 = s5
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let e5 = s5
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
post(
&mut s5,
NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(),
e5,
c5,
4_000,
);
post(
&mut s5,
NaiveDate::from_ymd_opt(2026, 3, 6).unwrap(),
c5,
r5,
4_000,
);
drop(s5);
let b5 = data::load_once(&source(&d5, "", today)).unwrap();
assert_eq!(b5.kpis.monthly_burn, None);
for x in [d, d2, d3, d4, d5] {
let _ = std::fs::remove_dir_all(x);
}
}
/// The view model's indexes: cash accounts by name when the settings name
/// none, card accounts, each entry's accounts (debits first, once each),
/// and what's new since the last refresh.
#[test]
fn indexes_cash_cards_accounts_and_fresh() {
let (d, mut s) = store_at("index");
let checking = s
.add_account("1000", "Business Checking", AccountType::Asset, None, false)
.unwrap();
let savings = s
.add_account("1010", "Savings", AccountType::Asset, None, false)
.unwrap();
let group = s
.add_account("1090", "Cash accounts", AccountType::Asset, None, true)
.unwrap();
let equip = s
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account(
"2050",
"Chase Ink Card",
AccountType::Liability,
None,
false,
)
.unwrap();
let cardish = s
.add_account("1600", "Card reader", AccountType::Asset, None, false)
.unwrap();
let rent = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let today = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
// Two debits to one account, then two credits.
let e = s.create_entry(today, "split").unwrap();
let m = |c| Money::new(Decimal::new(c, 2), Currency::USD);
s.add_line(e, rent, Side::Debit, m(100)).unwrap();
s.add_line(e, rent, Side::Debit, m(200)).unwrap();
s.add_line(e, card, Side::Credit, m(250)).unwrap();
s.add_line(e, checking, Side::Credit, m(50)).unwrap();
s.post_entry(e).unwrap();
drop(s);
let src = source(&d, "", today);
let b = data::load_once(&src).unwrap();
let mut cash = b.cash_accounts.clone();
cash.sort();
assert_eq!(
cash,
vec![checking, savings],
"by name, postable assets only"
);
let _ = (group, equip, cardish);
assert_eq!(b.card_accounts, vec![card], "liabilities named card");
assert_eq!(b.entry(e).unwrap().accounts, vec![rent, card, checking]);
assert!(b.fresh.is_empty(), "a first load marks nothing new");
// A new entry after a refresh is the only fresh one.
let seq = b.seq();
let mut s = Store::open(&d.join("books-data")).unwrap();
let e2 = post(&mut s, today, rent, checking, 999);
drop(s);
let snap = ledger_store::Snapshot::load(&src.data_dir).unwrap();
let settings = ledger_config::load(src.books_toml.as_ref().unwrap()).unwrap();
let mut verified = std::collections::HashSet::new();
let integrity = ledger_gui::books::Integrity::check(&snap, &mut verified);
let b2 = ledger_gui::books::Books::build(&snap, settings, today, Some(seq), integrity).unwrap();
assert_eq!(b2.fresh, [e2].into_iter().collect());
let _ = std::fs::remove_dir_all(&d);
}
/// Integrity counts every stored file, read once: a second check with the
/// same memory counts them again without rereading; a damaged one fails.
#[test]
fn integrity_counts_files_once_verified() {
let (d, mut s) = store_at("integrity");
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rent = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let e = post(
&mut s,
NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(),
rent,
cash,
100,
);
s.attach_receipt(Some(e), "a.pdf", "application/pdf", b"one", None)
.unwrap();
s.attach_receipt(Some(e), "b.pdf", "application/pdf", b"two", None)
.unwrap();
drop(s);
let snap = ledger_store::Snapshot::load(&d.join("books-data")).unwrap();
let mut verified = std::collections::HashSet::new();
let first = ledger_gui::books::Integrity::check(&snap, &mut verified);
assert_eq!((first.files_ok, first.file_failures.len()), (2, 0));
let again = ledger_gui::books::Integrity::check(&snap, &mut verified);
assert_eq!((again.files_ok, again.file_failures.len()), (2, 0));
let _ = std::fs::remove_dir_all(&d);
}
/// Origins and parties for the rest of the journal's kinds: the invoice
/// itself shows its customer, a bill payment its vendor, and a payroll
/// tax remittance is labeled as one (so the receipt rule for hand-made
/// entries doesn't flag it).
#[test]
fn more_origins_and_parties() {
let (d, mut s) = store_at("origin2");
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let today = NaiveDate::from_ymd_opt(2026, 7, 15).unwrap();
let cust = s.add_customer("Acme Buyer").unwrap();
let vend = s.add_vendor("Paper Co").unwrap();
let line = |a: i64| {
vec![DocumentLine {
description: "x".into(),
amount: Decimal::new(a, 2),
}]
};
s.create_invoice(cust, today, today + Days::new(30), line(10_000))
.unwrap();
let bill = s
.create_bill(vend, today, today + Days::new(30), line(5_000))
.unwrap();
let paid = s
.pay_bill(bill, today, Decimal::new(5_000, 2), cash)
.unwrap();
s.run_payroll(
today,
vec![ledger_core::PayrollLine {
employee_name: "Pat".into(),
employee: None,
gross_pay: Decimal::new(100_000, 2),
federal_withholding: Decimal::new(10_000, 2),
state_withholding: Decimal::new(3_000, 2),
ss_employee: Decimal::new(6_200, 2),
medicare_employee: Decimal::new(1_450, 2),
ss_employer: Decimal::new(6_200, 2),
medicare_employer: Decimal::new(1_450, 2),
other_deductions: Decimal::ZERO,
}],
cash,
)
.unwrap();
let remit = s.remit_payroll_taxes(today, cash).unwrap();
drop(s);
let b = data::load_once(&source(
&d,
"[books]\nreceipt_threshold = \"0.00\"\n",
today,
))
.unwrap();
let inv = b.entries.iter().find(|x| x.origin == "Invoice").unwrap();
assert_eq!(inv.party.as_deref(), Some("Acme Buyer"));
let pay = b.entry(paid).unwrap();
assert_eq!(pay.origin, "Bill payment");
assert_eq!(pay.party.as_deref(), Some("Paper Co"));
assert_eq!(
b.entries.iter().filter(|x| x.origin == "Payroll").count(),
1
);
let r = b.entry(remit).unwrap();
assert_eq!(r.origin, "Payroll taxes");
assert!(!r.missing_receipt, "a remittance isn't a hand-made entry");
let _ = std::fs::remove_dir_all(&d);
}
+8
View File
@@ -17,3 +17,11 @@ typst-pdf = "0.15.1"
typst = "0.15.1"
serde_json = "1"
typst-layout = "0.15.1"
qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
[lints]
workspace = true
[dev-dependencies]
proptest = "1"
rqrr = { version = "0.11.0", default-features = false }
+599
View File
@@ -0,0 +1,599 @@
//! The fixed asset register as XLSX and PDF, for the CPA.
//!
//! [`ledger_core::fixed_asset_register`] computes the register; [`layout`]
//! lays it out once — columns, one line per asset, the totals, and the
//! reconciliation to the general ledger — and both renderers draw from
//! that layout, so they can't drift apart. Amounts stay exact `Decimal`s
//! until a renderer writes them, through the same paths as the statements
//! ([`crate::report::amount_parts`] for PDF, the accounting number formats
//! of [`crate::xlsx`] for XLSX).
//!
//! ```text
//! GOAL: one list of what the company owns, its cost, depreciation, and
//! book value, that a CPA can file, and that shows at a glance
//! whether the register and the books agree.
//! SOURCE: ledger_core::fixed_asset_register (INVARIANTS 1-4); CONVENTION
//! (layout, the statements' header and footer).
//! INVARIANTS:
//! 1. One line per register row, in the register's order, disposed ones
//! included and marked "Disposed DATE, proceeds AMOUNT"; held ones
//! "Active".
//! 2. The totals are the register's own totals (held assets only),
//! never re-summed here.
//! 3. Reconciliation: cost per register, per books, and their difference
//! (unregistered_cost); accumulated depreciation per schedule, per
//! books, and their difference (unposted_depreciation), exactly.
//! 4. An empty register says "No fixed assets" and still shows totals
//! and the reconciliation (the books may carry unregistered cost).
//! 5. Header and footer as on every statement: company, "Fixed Asset
//! Register", "As of DATE"; basis, prepared on, journal seq.
//! 6. PDF: deterministic bytes; every text is data, never markup.
//! 7. Tax basis beside book value: every line carries its tax basis
//! rule (in words: [`rule_label`], [`rule_cited`]), the contributor's
//! basis, the tax basis, and the built-in gain (loss), each the
//! register row's own (ledger_core::Asset::tax_basis,
//! Asset::built_in_gain), never re-derived here; a line with none
//! recorded says "Not recorded" and leaves the amounts blank.
//! 8. The tax basis total is the register's total_tax_basis; when
//! missing_tax_basis > 0 a line says "Tax basis not recorded for N
//! asset(s)" (see [`missing_line`]).
//! 9. The notes ([`notes`]) say that book value is fair market value at
//! contribution and tax basis follows its rule, and cite each rule
//! (IRC §1012(a), §723, Treas. Reg. §1.168(i)-4(b), IRC §704(c)).
//! The PDF marks each rule in the tax table with its note's number.
//! ```
//!
//! SOURCE (7-9): docs/spec/rules.md B-25;
//! docs/spec/sources/irs-partnership-basis.md.
use crate::pdf::{INTER, PdfError};
use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
use crate::xlsx::{Formats, XlsxError, footer, header, number};
use ledger_core::TaxBasisRule;
use ledger_core::reports::{AssetRegister, AssetRegisterRow};
use rust_decimal::Decimal;
use rust_xlsxwriter::{Format, FormatAlign, Workbook};
use std::sync::OnceLock;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
use typst_layout::PagedDocument;
/// The report's title.
pub const TITLE: &str = "Fixed Asset Register";
/// Column headings, left to right.
pub const COLUMNS: [&str; 10] = [
"Tag",
"Description",
"Serial",
"In service",
"Cost",
"Salvage",
"Life (months)",
"Accumulated depreciation",
"Book value",
"Status",
];
/// The PDF's tax basis table's headings, left to right.
pub const TAX_COLUMNS: [&str; 7] = [
"Tag",
"Description",
"Tax basis rule",
"Book cost",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)",
];
/// The XLSX headings, left to right: the book columns, the tax columns,
/// then the status.
pub const XLSX_COLUMNS: [&str; 14] = [
"Tag",
"Description",
"Serial",
"In service",
"Cost",
"Salvage",
"Life (months)",
"Accumulated depreciation",
"Book value",
"Tax basis rule",
"Contributor's basis",
"Tax basis",
"Built-in gain (loss)",
"Status",
];
/// The line shown when there are no assets.
pub const EMPTY: &str = "No fixed assets";
/// The tax table's title.
pub const TAX_TITLE: &str = "Federal tax basis";
/// The rule cell of an asset with no tax basis recorded.
pub const NOT_RECORDED: &str = "Not recorded";
/// A rule in short words, for the table: "Cost", "Carryover",
/// "Personal-use conversion".
pub fn rule_label(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "Cost",
TaxBasisRule::Carryover => "Carryover",
TaxBasisRule::PersonalUseConversion => "Personal-use conversion",
}
}
/// A rule in words with its citation: "Cost", "Carryover (IRC §723)",
/// "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))".
pub fn rule_cited(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "Cost",
TaxBasisRule::Carryover => "Carryover (IRC §723)",
TaxBasisRule::PersonalUseConversion => {
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
}
}
}
/// The number of a rule's note in [`notes`].
pub fn rule_mark(r: TaxBasisRule) -> &'static str {
match r {
TaxBasisRule::Cost => "1",
TaxBasisRule::Carryover => "2",
TaxBasisRule::PersonalUseConversion => "3",
}
}
/// One note under the register.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Note {
/// Its number, as the tax table marks it, or empty for a general note.
pub mark: String,
/// What it says.
pub text: String,
}
/// The notes (INVARIANT 9): book value against tax basis, then each
/// rule's citation and the built-in gain (loss).
pub fn notes() -> Vec<Note> {
let n = |mark: &str, text: &str| Note {
mark: mark.to_string(),
text: text.to_string(),
};
vec![
n(
"",
"Book value is the fair market value at contribution for property a member contributed, \
and the price paid for property the company bought; tax basis follows its rule and can \
differ. Depreciation here is book depreciation; tax depreciation starts from the tax \
basis and is the CPA's.",
),
n(
rule_mark(TaxBasisRule::Cost),
"Cost: property the company bought has a basis of its cost (IRC §1012(a)).",
),
n(
rule_mark(TaxBasisRule::Carryover),
"Carryover (IRC §723): property a member contributes keeps the member's adjusted \
basis at contribution (their cost less any depreciation they took).",
),
n(
rule_mark(TaxBasisRule::PersonalUseConversion),
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted from \
personal use depreciates from the lesser of its fair market value and the member's \
adjusted basis at conversion.",
),
n(
"4",
"Built-in gain (loss): fair market value at contribution less the contributor's basis \
(IRC §704(c)(1)(A)); a built-in loss is taken into account only for the contributing \
member (IRC §704(c)(1)(C)).",
),
]
}
/// "Tax basis not recorded for 1 asset" / "... for 2 assets", or `None`
/// when every asset held has one.
pub fn missing_line(missing: usize) -> Option<String> {
(missing > 0).then(|| {
format!(
"Tax basis not recorded for {missing} {}",
if missing == 1 { "asset" } else { "assets" }
)
})
}
/// One asset's line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetLine {
/// Tag, or empty.
pub tag: String,
/// What it is.
pub description: String,
/// Serial number, or empty.
pub serial: String,
/// In-service date, ISO 8601.
pub in_service: String,
/// Capitalized cost.
pub cost: Decimal,
/// Salvage value.
pub salvage: Decimal,
/// Useful life in months.
pub life_months: u32,
/// Accumulated depreciation by schedule (zero once disposed).
pub accumulated: Decimal,
/// Book value (zero once disposed).
pub book_value: Decimal,
/// "Active", or "Disposed 2026-09-30, proceeds 450.00".
pub status: String,
/// Disposed by the date (shown, not in the totals).
pub disposed: bool,
/// The tax basis rule, if recorded.
pub tax_rule: Option<TaxBasisRule>,
/// The contributing member's adjusted basis (a contribution only).
pub contributor_basis: Option<Decimal>,
/// The depreciable federal tax basis, if recorded.
pub tax_basis: Option<Decimal>,
/// Built-in gain (+) or loss (−) at contribution, if any.
pub built_in_gain: Option<Decimal>,
}
/// Which register column a reconciliation amount sits under.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReconColumn {
/// Under "Cost".
Cost,
/// Under "Accumulated depreciation".
Accumulated,
}
/// One reconciliation line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReconLine {
/// "Cost per register", ...
pub label: String,
/// The amount.
pub amount: Decimal,
/// The column it reconciles.
pub column: ReconColumn,
/// A difference line (ruled above, bold).
pub difference: bool,
}
/// The register laid out for rendering.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RegisterLayout {
/// [`TITLE`].
pub title: String,
/// "As of October 15, 2026".
pub period: String,
/// One per asset, in register order.
pub lines: Vec<AssetLine>,
/// Total cost, accumulated depreciation, and book value of the assets
/// held.
pub totals: [Decimal; 3],
/// Total tax basis of the assets held that have one.
pub tax_total: Decimal,
/// [`missing_line`] of the register's missing_tax_basis.
pub missing: Option<String>,
/// [`notes`].
pub notes: Vec<Note>,
/// Cost then accumulated depreciation: per register, per books,
/// difference.
pub reconciliation: Vec<ReconLine>,
}
fn status(a: &AssetRegisterRow) -> String {
match a.disposed {
Some((date, proceeds)) => {
format!("Disposed {date}, proceeds {}", format_amount(proceeds))
}
None => "Active".to_string(),
}
}
/// Lay the register out (see the module spec).
pub fn layout(r: &AssetRegister) -> RegisterLayout {
let recon = |label: &str, amount, column, difference| ReconLine {
label: label.to_string(),
amount,
column,
difference,
};
RegisterLayout {
title: TITLE.to_string(),
period: format!("As of {}", long_date(r.as_of)),
lines: r
.rows
.iter()
.map(|a| AssetLine {
tag: a.tag.clone().unwrap_or_default(),
description: a.description.clone(),
serial: a.serial.clone().unwrap_or_default(),
in_service: a.in_service.to_string(),
cost: a.cost,
salvage: a.salvage,
life_months: a.useful_life_months,
accumulated: a.accumulated,
book_value: a.book_value,
status: status(a),
disposed: a.disposed.is_some(),
tax_rule: a.tax_rule,
contributor_basis: a.contributor_basis,
tax_basis: a.tax_basis,
built_in_gain: a.built_in_gain,
})
.collect(),
totals: [r.total_cost, r.total_accumulated, r.total_book_value],
tax_total: r.total_tax_basis,
missing: missing_line(r.missing_tax_basis),
notes: notes(),
reconciliation: vec![
recon("Cost per register", r.total_cost, ReconColumn::Cost, false),
recon("Cost per books", r.ledger_cost, ReconColumn::Cost, false),
recon(
"Difference (unregistered cost)",
r.unregistered_cost(),
ReconColumn::Cost,
true,
),
recon(
"Accumulated depreciation per schedule",
r.total_accumulated,
ReconColumn::Accumulated,
false,
),
recon(
"Accumulated depreciation per books",
r.ledger_accumulated,
ReconColumn::Accumulated,
false,
),
recon(
"Difference (not yet posted)",
r.unposted_depreciation(),
ReconColumn::Accumulated,
true,
),
],
}
}
/// The fixed asset register as XLSX bytes.
///
/// Layout as every statement (crate::xlsx INVARIANT 4): company, title,
/// and date on rows 1-3, headings ([`XLSX_COLUMNS`]) on row 5, one row
/// per asset from row 6, amounts as numbers in the accounting format, the
/// tax rule in words with its citation ([`rule_cited`]); then the totals
/// row (double rule) with the tax basis total under Tax basis, the
/// missing tax basis line when there is one, an empty row, "Reconciliation
/// to the books" and its lines with amounts under Cost and Accumulated
/// depreciation, an empty row, and "Notes" with [`notes`].
pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
const AMOUNTS: [u16; 7] = [4, 5, 7, 8, 10, 11, 12];
const TOTALS: [u16; 4] = [4, 7, 8, 11];
let l = layout(r);
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
let f = Formats::new();
sheet.set_name(TITLE)?;
let width = XLSX_COLUMNS.len() as u16;
let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?;
for (i, h) in XLSX_COLUMNS.iter().enumerate() {
let fmt = if AMOUNTS.contains(&(i as u16)) || i == 6 {
&f.header_right
} else {
&f.header
};
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
}
row += 1;
if l.lines.is_empty() {
sheet.write_string_with_format(row, 0, EMPTY, &f.sub_label)?;
row += 1;
}
let quiet = Format::new().set_italic().set_font_color("#666666");
let quiet_plain = f.plain.clone().set_italic().set_font_color("#666666");
let missing = Format::new().set_italic().set_font_color("#9A5B00");
for a in &l.lines {
let (text, amount) = if a.disposed {
(&quiet, &quiet_plain)
} else {
(&Format::new(), &f.plain)
};
sheet.write_string_with_format(row, 0, &a.tag, text)?;
sheet.write_string_with_format(row, 1, &a.description, text)?;
sheet.write_string_with_format(row, 2, &a.serial, text)?;
sheet.write_string_with_format(row, 3, &a.in_service, text)?;
number(sheet, row, 4, a.cost, amount)?;
number(sheet, row, 5, a.salvage, amount)?;
sheet.write_number_with_format(
row,
6,
f64::from(a.life_months),
&text.clone().set_align(FormatAlign::Right),
)?;
number(sheet, row, 7, a.accumulated, amount)?;
number(sheet, row, 8, a.book_value, amount)?;
match a.tax_rule {
Some(rule) => sheet.write_string_with_format(row, 9, rule_cited(rule), text)?,
None => sheet.write_string_with_format(
row,
9,
NOT_RECORDED,
if a.disposed { text } else { &missing },
)?,
};
for (c, v) in [
(10, a.contributor_basis),
(11, a.tax_basis),
(12, a.built_in_gain),
] {
if let Some(v) = v {
number(sheet, row, c, v, amount)?;
}
}
sheet.write_string_with_format(row, 13, &a.status, text)?;
row += 1;
}
sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?;
for c in (1..width).filter(|c| !TOTALS.contains(c)) {
sheet.write_blank(row, c, &f.dollars_total)?;
}
for (c, d) in TOTALS
.into_iter()
.zip(l.totals.into_iter().chain([l.tax_total]))
{
number(sheet, row, c, d, &f.dollars_total)?;
}
row += 1;
if let Some(m) = &l.missing {
sheet.write_string_with_format(row, 0, m, &f.sub_label)?;
row += 1;
}
row += 1;
sheet.write_string_with_format(row, 0, "Reconciliation to the books", &f.bold)?;
row += 1;
for line in &l.reconciliation {
let col = match line.column {
ReconColumn::Cost => 4,
ReconColumn::Accumulated => 7,
};
let (label_fmt, amount_fmt) = if line.difference {
(&f.bold, &f.dollars_sum)
} else {
(&f.line_label, &f.plain)
};
sheet.write_string_with_format(row, 0, &line.label, label_fmt)?;
number(sheet, row, col, line.amount, amount_fmt)?;
row += 1;
if line.difference {
row += 1;
}
}
sheet.write_string_with_format(row, 0, "Notes", &f.bold)?;
row += 1;
for n in &l.notes {
let text = if n.mark.is_empty() {
n.text.clone()
} else {
format!("{}. {}", n.mark, n.text)
};
sheet.write_string_with_format(row, 0, text, &f.line_label)?;
row += 1;
}
for (c, w) in [
(0, 12.0),
(1, 40.0),
(2, 20.0),
(3, 12.0),
(4, 14.0),
(5, 12.0),
(6, 9.0),
(7, 16.0),
(8, 14.0),
(9, 30.0),
(10, 14.0),
(11, 14.0),
(12, 14.0),
(13, 34.0),
] {
sheet.set_column_width(c, w)?;
}
footer(sheet, &f, meta, row, width)?;
Ok(workbook.save_to_buffer()?)
}
static TEMPLATE: &str = include_str!("../templates/asset_register.typ");
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(INTER)
.build()
})
}
fn amount(d: Decimal) -> serde_json::Value {
let p = amount_parts(d);
serde_json::json!({ "digits": p.digits, "negative": p.negative })
}
fn maybe(d: Option<Decimal>) -> serde_json::Value {
d.map_or(serde_json::Value::Null, amount)
}
/// The PDF template's input: the layout, every amount split for
/// alignment.
pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value {
let l = layout(r);
serde_json::json!({
"company": meta.company,
"title": l.title,
"period": l.period,
"footer": meta.footer(),
"columns": COLUMNS,
"tax_title": TAX_TITLE,
"tax_columns": TAX_COLUMNS,
"empty": EMPTY,
"lines": l.lines.iter().map(|a| serde_json::json!({
"tag": a.tag,
"description": a.description,
"serial": a.serial,
"in_service": a.in_service,
"cost": amount(a.cost),
"salvage": amount(a.salvage),
"life": a.life_months.to_string(),
"accumulated": amount(a.accumulated),
"book_value": amount(a.book_value),
"status": a.status,
"disposed": a.disposed,
"rule": a.tax_rule.map_or(NOT_RECORDED, rule_label),
"rule_mark": a.tax_rule.map_or("", rule_mark),
"recorded": a.tax_rule.is_some(),
"contributor_basis": maybe(a.contributor_basis),
"tax_basis": maybe(a.tax_basis),
"built_in_gain": maybe(a.built_in_gain),
})).collect::<Vec<_>>(),
"totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
"tax_total": amount(l.tax_total),
"missing": l.missing,
"notes": l.notes.iter().map(|n| serde_json::json!({
"mark": n.mark,
"text": n.text,
})).collect::<Vec<_>>(),
"reconciliation": l.reconciliation.iter().map(|x| serde_json::json!({
"label": x.label,
"amount": amount(x.amount),
"difference": x.difference,
})).collect::<Vec<_>>(),
})
}
/// Typeset the register into a document (pages, before PDF encoding), so
/// the layout can be inspected.
pub fn typeset_asset_register(
r: &AssetRegister,
meta: &ReportMeta,
) -> Result<PagedDocument, PdfError> {
let mut inputs = Dict::new();
inputs.insert(
"data".into(),
template_data(r, meta).to_string().into_value(),
);
engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))
}
/// The fixed asset register as PDF bytes (US Letter, landscape). See the
/// module spec.
pub fn asset_register_pdf(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let doc = typeset_asset_register(r, meta)?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
+154 -49
View File
@@ -1,12 +1,37 @@
//! Parse QuickBooks Online bank/credit-card upload CSVs.
//!
//! QBO accepts two layouts:
//! ```text
//! GOAL: read a bank or card export into dated, signed transactions, never
//! guessing at what a cell means.
//! SOURCE: docs/spec/sources/intuit-bank-csv.md (Intuit, "Format CSV files
//! in Excel to get bank transactions into QuickBooks"; "Manually
//! upload transactions into QuickBooks Online").
//! INVARIANTS:
//! 1. Two layouts, by column count (Intuit): 3 columns Date, Description,
//! Amount (a payment negative, a deposit positive); 4 columns Date,
//! Description, then money out, then money in, each positive with the
//! other cell blank. Intuit heads those two "Credit, Debit" (its example
//! puts the payment under Credit and the deposit under Debit); bank
//! exports often head them "Debit, Credit" in the bank's sense; both
//! put money out third, so the columns are read by position.
//! 2. Every transaction MUST carry a nonzero amount in whole cents; a zero
//! row is skipped (Intuit: leave zero cells blank).
//! 3. A slash date's order (month or day first) MUST come from the file or
//! the caller, never a guess: Intuit recommends dd/mm/yyyy while US
//! banks write mm/dd/yyyy, and a date like 3/4/2026 reads both ways.
//! A part over 12 settles the order for the whole file; a file that
//! settles both ways is refused; a file that settles neither needs the
//! caller's order. ISO dates (2026-03-04) need none.
//! 4. A two-digit year follows the POSIX rule (69-99 are 1900s, 00-68
//! 2000s).
//! 5. NEVER accept an amount with a fraction of a cent, a double negative
//! ("(-5)", "--5"), or both of a 4-column row's amounts.
//! ```
//!
//! - 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`.
//! Amounts may carry `$`, thousands commas, and parentheses for negatives
//! (common in bank exports; Intuit's pages are silent on these: CONVENTION).
//! A header row is optional: the first non-blank row is a header when its
//! first cell says "date".
use chrono::NaiveDate;
use rust_decimal::Decimal;
@@ -24,6 +49,15 @@ pub struct BankTransaction {
pub amount: Decimal,
}
/// Which part of a slash date (3/4/2026) is the month.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateOrder {
/// mm/dd/yyyy (US banks).
MonthFirst,
/// dd/mm/yyyy (Intuit's recommendation; most of the world).
DayFirst,
}
/// Parse errors.
#[derive(Debug, Error, PartialEq)]
pub enum BankCsvError {
@@ -33,94 +67,165 @@ pub enum BankCsvError {
/// A date did not parse.
#[error("bad date {0:?}")]
BadDate(String),
/// An amount did not parse.
#[error("bad amount {0:?}")]
/// An amount did not parse, or isn't whole cents.
#[error("bad amount {0:?}: expected a number of dollars and cents")]
BadAmount(String),
/// The file's slash dates read month-first in one place and day-first
/// in another.
#[error("the dates disagree: {0:?} must be day first, {1:?} month first")]
DatesDisagree(String, String),
/// The file's slash dates read either way, and no order was given.
#[error(
"the dates read either way (month or day first, e.g. {0:?}); say which order the bank uses"
)]
DateOrderUnknown(String),
/// The CSV itself is malformed.
#[error("csv error: {0}")]
Csv(String),
}
/// Parse a QBO bank CSV. Returns transactions in file order.
/// Parse a QBO bank CSV, reading slash dates in the order the file itself
/// settles. Returns transactions in file order.
pub fn parse_bank_csv(text: &str) -> Result<Vec<BankTransaction>, BankCsvError> {
parse_bank_csv_with(text, None)
}
/// [`parse_bank_csv`], with the caller's date order for a file whose dates
/// read either way. A file that settles its own order must agree with it.
pub fn parse_bank_csv_with(
text: &str,
order: Option<DateOrder>,
) -> 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();
let mut rows: Vec<(String, String, Decimal)> = Vec::new();
let mut seen_row = false;
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 first = !seen_row;
seen_row = true;
if first && fields[0].eq_ignore_ascii_case("date") {
continue; // a header row
}
let (date, description, amount) = match fields.len() {
3 => {
let amount = parse_amount(fields[2])?;
(fields[0], fields[1], amount)
}
let amount = match fields.len() {
3 => parse_amount(fields[2])?,
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)?,
// Money out in the third column, money in in the fourth.
match (!fields[2].is_empty(), !fields[3].is_empty()) {
(true, false) => -parse_amount(fields[2])?,
(false, true) => parse_amount(fields[3])?,
(false, false) => Decimal::ZERO,
(true, true) => {
return Err(BankCsvError::Csv(format!(
"row {} has both expense and income amounts",
"row {} has both money out and money in",
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,
});
rows.push((fields[0].to_string(), fields[1].to_string(), amount));
}
Ok(out)
let order = date_order(rows.iter().map(|r| r.0.as_str()), order)?;
rows.into_iter()
.map(|(date, description, amount)| {
Ok(BankTransaction {
date: parse_date(&date, order)?,
description,
amount,
})
})
.collect()
}
fn parse_date(s: &str) -> Result<NaiveDate, BankCsvError> {
/// The (month-or-day, day-or-month) parts of a slash date, if it is one.
fn slash_parts(s: &str) -> Option<(u32, u32)> {
let mut it = s.trim().split('/');
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
it.next()?; // a year
Some((a, b))
}
/// The order the file's slash dates settle, or the caller's (INVARIANT 3).
fn date_order<'a>(
dates: impl Iterator<Item = &'a str>,
given: Option<DateOrder>,
) -> Result<DateOrder, BankCsvError> {
let (mut day_first, mut month_first, mut ambiguous) = (None, None, None);
for d in dates {
match slash_parts(d) {
Some((a, _)) if a > 12 => day_first = day_first.or(Some(d.to_string())),
Some((_, b)) if b > 12 => month_first = month_first.or(Some(d.to_string())),
Some((a, b)) if a != b => ambiguous = ambiguous.or(Some(d.to_string())),
_ => {}
}
}
let settled = match (day_first, month_first) {
(Some(d), Some(m)) => return Err(BankCsvError::DatesDisagree(d, m)),
(Some(_), None) => Some(DateOrder::DayFirst),
(None, Some(_)) => Some(DateOrder::MonthFirst),
(None, None) => None,
};
match (settled, given, ambiguous) {
(Some(s), Some(g), _) if s != g => Err(BankCsvError::DatesDisagree(
format!("the file reads {s:?}"),
format!("the caller said {g:?}"),
)),
(Some(s), _, _) => Ok(s),
(None, Some(g), _) => Ok(g),
// Nothing to disambiguate (ISO dates, or day equals month).
(None, None, None) => Ok(DateOrder::MonthFirst),
(None, None, Some(example)) => Err(BankCsvError::DateOrderUnknown(example)),
}
}
fn parse_date(s: &str, order: DateOrder) -> 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);
let four = match order {
DateOrder::MonthFirst => "%m/%d/%Y",
DateOrder::DayFirst => "%d/%m/%Y",
};
let bad = || BankCsvError::BadDate(s.to_string());
let (rest, year) = s.rsplit_once('/').ok_or_else(bad)?;
if year.len() != 2 {
return NaiveDate::parse_from_str(s, four).map_err(|_| bad());
}
// 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()))
// A two-digit year follows POSIX strptime %y: 69-99 are 1900s, 00-68
// 2000s. (chrono's %y puts the cutoff at 70; found by the spec-blind
// suite, 2026-10-13.)
let yy: i32 = year.parse().map_err(|_| bad())?;
let full = if yy >= 69 { 1900 + yy } else { 2000 + yy };
NaiveDate::parse_from_str(&format!("{rest}/{full}"), four).map_err(|_| bad())
}
fn parse_amount(s: &str) -> Result<Decimal, BankCsvError> {
let bad = || BankCsvError::BadAmount(s.to_string());
let cleaned: String = s.trim().replace(['$', ','], "");
// Parentheses mean negative: (123.45).
// Parentheses mean negative: (123.45). Inside them, no sign.
let (neg, inner) = match cleaned.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
Some(inner) if inner.starts_with(['-', '+']) => return Err(bad()),
Some(inner) => (true, inner),
None => (false, cleaned.as_str()),
};
let mut amount =
Decimal::from_str(inner).map_err(|_| BankCsvError::BadAmount(s.to_string()))?;
let mut amount = Decimal::from_str(inner).map_err(|_| bad())?;
// Whole cents only: a bank moves no fraction of one.
if amount.normalize().scale() > 2 {
return Err(bad());
}
if neg {
amount = -amount;
}
@@ -143,9 +248,9 @@ mod tests {
}
#[test]
fn four_column_expense_income() {
fn four_column_money_out_then_in() {
let csv =
"Date,Description,Expense,Income\n2026-01-15,Coffee,4.50,\n2026-01-16,Refund,,12.00\n";
"Date,Description,Credit,Debit\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));
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more