Compare commits

..
132 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 a82791789e P2 slice 2: the data root (plan 3.4, 5.5)
check / check (push) Waiting to run
ledger_store::root: one private folder for every book, found without the
environment (--root, else the root setup recorded in a file only this
account can write, else the platform default under the home from the
password database). Every folder made 0700, files 0600; a root that is a
link, another user's, open to others, unmarked, or under a folder another
user could swap is refused; no error names it. Setup keeps a recorded
root, never makes a second root or moves books while one holds any, and
leaves a folder holding other things untouched. Books are created and
adopted one at a time; adoption reads the source under its writer lock
(changing nothing in it), copies into staging, re-hashes every stored
file against its name, checks every named file is there, opens and
verifies the copy with the source's head, imports settings for books
that keep none, syncs, and only then renames it into place.
wanderbooks setup makes the root and never prints where it is. Three
review rounds; mutation on the diff (docs/testing.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 14:42:32 -06:00
swaitsandClaude Opus 5.5 6bd921c4c8 plan: defect 23, a vendor marked no-1099 reads as undecided
check / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 12:40:27 -06:00
swaitsandClaude Opus 5.5 920b15ba82 P2 slice 1: one executable (D-22)
check / check (push) Waiting to run
The server and the viewer are libraries with run(prog, args), returning
Exit(code) instead of ending the process; ledger-mcp and ledger-gui are
one-line shims (the live books keep using them until the cutover).
wanderbooks dispatches dev-stdio and gui to them in-process; a mode must
come first and owns its flags. The gui feature (default) leaves the
viewer out of the server-only build, which just server-only builds,
clippies, tests, and checks for window libraries. The viewer's mutation
recipe is retired (owner, 2026-10-18); its two musts are tested
directly. Two review rounds; mutation on the diff (docs/testing.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 12:27:48 -06:00
swaitsandClaude Opus 5.5 3b1cf472e2 plan: cargo-packager for installers and updates (D-25)
check / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 10:56:41 -06:00
swaitsandClaude Opus 5.5 c6fea172ec plan: one app, open while held, installers and updaters (D-22, D-23, D-24)
check / check (push) Waiting to run
The owner, 2026-10-18: one app to simplify installing and updating.
One wanderbooks executable whose modes run as separate processes; the
books open while the app, an agent session (its stdio bridge), a serve
terminal or a CLI command holds them, and close after the last (still
no background service); a signed, notarized dmg and a per-user Windows
installer that update themselves, AUR, apt, dnf and a self-updating
AppImage on Linux. New section 3.9 (leases, the helper's clean host
start, local sockets instead of TCP, restarts, one version at a time,
the server's format-upgrade gate); P2, P11 and the decisions follow.
Five review rounds, the last clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 10:45:44 -06:00
swaitsandClaude Opus 5.5 2aaba56b38 plan: the local server runs in the foreground, started by hand (D-21)
check / check (push) Canceled after 0s
The owner, 2026-10-18: no background service for now. wanderbooks serve
runs in a terminal (server plus archive process) until stopped; nothing
is installed to run in the background. P2's scope, cutover, tests,
limitations, user flows and P11 follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 00:06:56 -06:00
swaitsandClaude Opus 5.5 b0b92235cc plan: P1 live on the first company's books; Q-2 answered (D-20)
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 23:06:38 -06:00
swaitsandClaude Opus 5.5 94338b76cb K-1 shares print by value; filed documents may print the company's own EIN
check / check (push) Canceled after 0s
The first company's P1 cutover found the K-1 worksheet printing a
member's share as typed: "50%" read from a settings file printed
0.50, the same share kept by the books as 0.5 printed 0.5. Shares now
print at least two places whatever their spelling.

Every company document now goes into the books (D-20, owner,
2026-10-17), and formation papers and plans print the company's EIN,
which the filing scan refused as a printed TIN. It now passes the
company's own EIN, as its settings hold it, printed 2-7, and nothing
else; a vault TIN is still refused (rules.md B-26). Its refusal no
longer suggests converting a file to get past it.

plan/served.md: D-20; correcting a record's source without changing
its words (P5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 21:16:34 -06:00
swaitsandClaude Opus 5.5 a833229d87 tests: every test dir is removed when its test ends, pass or panic
Many test helpers removed their temp dir only before the test, or only
on pass, so runs left them behind: 117,000 filled /tmp (RAM here) and
stopped a mutation run. Each now holds a guard that removes the dir on
drop, unwind included; servers started on a test dir hold its guard
until they stop. A full cargo test --workspace leaves 0 dirs (859
before). just sweep-test-tmp stays for runs killed mid-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 20:47:44 -06:00
swaitsandClaude Opus 5.5 b292a505b1 P1: the books keep how they are kept
check / check (push) Canceled after 0s
Settings, conventions (posting templates, approval rules, policies,
close steps, calendar, mappings, notes), decisions, open items and
entry approvals are journal events (journal format 2). Books that keep
their settings never read wanderbooks.toml or config.json; the server
and the viewer take the vault key from --vault-key-file instead.
books.import_settings moves a settings file in once, exactly as the
server reads it. Approval rules, when enforced, gate every posting
path. Replay refuses what it can't read as newer, never as corrupt.

Lean: a record changes no figure. Mutation on the whole diff: every
survivor killed by a test but two equivalent (docs/testing.md). The
Company page says a late open item is overdue in words, not colour
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 19:27:48 -06:00
swaitsandClaude Opus 5.5 ee1a372179 local_files: a file is the checked one by size and times too, not inode alone
check / check (push) Canceled after 0s
CI failed on a_file_swapped_after_the_check_is_never_read: its filesystem
handed the deleted checked file's inode number to the file made in its
place, so device and inode matched for another file. The opened file
must now also match the checked file's size, modification time and change
time. The test now models a real swap (the replacement exists before the
original goes), the reused-inode case, an in-place rewrite, and that the
unchanged file is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 10:58:44 -06:00
swaitsandClaude Opus 5.5 71c345d39c plan: one app for Windows, Mac and Linux (P11); backups restore (P2)
check / check (push) Canceled after 0s
Owner, 2026-10-17. D-19 settled: one installer and updater per
system; server, viewer and CLI ship together but stay separate
programs; no launcher; the viewer gains back up, export, restore and
new books (the last two through the server). P2 gains verified,
non-destructive restore onto new or existing installs, and restore
drills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 10:41:46 -06:00
swaitsandClaude Opus 5.5 349d8a79cd plan: P0 landed and live; P1 next
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 10:31:15 -06:00
swaitsandClaude Opus 5.5 391634a0a5 plan: P0 notes
check / check (push) Canceled after 0s
plan/served.md: defect 21 (found in P0: the CPA package's
general-journal.iif ignores as_of, so an as-of package includes entries
dated after it; its xlsx and csv files respect it) joins the defect
count, P3 beside defect 7, and the P10 re-run; 5.6 records that the
first company's goldens are kept privately in its own books dir
(export_cpa_package zips plus a canonical JSON form made by its
goldens/canon.py, which normalizes per-export text, with the comparison
protocol in its README), not in this public repo, by decision.
plan/STATUS.md: P0 is in final review.

Review round 3: STATUS says what is true (P0 in final review, the
mutation runs done).

Adds defect 22 (depreciation through an asset's final month plugs to
the full amount mid-month) to P3 and to the P10 re-audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 09:25:00 -06:00
swaitsandClaude Opus 5.5 c9772b70c1 docs: P0 review: server instructions, handbook, local files, fail closed
docs/mcp-interface.md: the connect text and its budgets, the handbook
(tool and resources), annotations, failing closed, import_local_file's
roots and refusals, the set_w9 path rule and where TIN-bearing files may
go. docs/testing.md: the P0 laws and the tests that check them.
docs/architecture.md: the journal format gate and the server's
fail-closed store lock.

Round-2 review (2026-10-16): mcp-interface.md lists all 26 destructive
tools (unlink_receipt, unlink_attachment and correct_asset were missing)
and openWorldHint false on every tool; the connect text states the
parties rule and the exact receipt rule, and how it fits its budget; the
lint covers every refusal; the import roots refuse /, the home folder and
folders holding the books or a key, the open is a component walk, every
missing or outside path gets one refusal, and the TIN-file registry and
text scan (and what they can't catch) are described. architecture.md:
the frozen envelope, the lenient next-line prev, no repair of newer
books. testing.md: the new laws and tests. verification.md and
parity.md: P0 notes (no new law to prove, no status moved).
The TIN text check, narrowed (2026-10-16; rules.md B-26,
mcp-interface.md): text is refused for a TIN the vault holds, in its
common renderings, or a stand-alone printed SSN or EIN shape, so bank
exports go in; the oracle a vault comparison gives is recorded as a
known limit for P7. testing.md: the P0 mutation run of the final diff,
per crate, and how each survivor was resolved (killed by a named test,
removed by simpler code, or argued equivalent).
The probe bound (rules.md B-26, mcp-interface.md): rule (a) answers
for at most two candidates a filing, so finding a TIN from its last four
takes on the order of 50,000 refused filings; the open limit now names
that path, closed by P2's access log and P7's human-only TIN channel.
testing.md: the law and its tests; tin_files.rs mutated again, 74 of
74 caught.

Review round 3: rules.md B-28 (four-digit years, amounts a line can
carry, checked products, bounded recurring runs) and B-26/B-27 updates
(BOM-less UTF-16, when set_w9 records); mcp-interface.md matches the
code (25 destructive tools, the five idempotent ones, no reverse_entry
receipt checklist, strictly above the threshold, no tool text names
wanderbooks.toml, dot names and roots inside the books refused, the new
settings lists); testing.md lists the new laws and this round's
mutation run.

Review round 4: rules.md B-28 says the bounds are for new commands
only, replay accepts every line an earlier version accepted, and derived
figures refuse rather than panic; mcp-interface.md documents plan_error
and the nullable totals; testing.md lists the old-books contracts and this round's
mutation run (core, store, MCP, config, GUI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 09:25:00 -06:00
swaitsandClaude Opus 5.5 b22a034746 MCP: short connect text, a handbook, tool annotations, safe local imports
- The 13K-character instructions (clients cut them short) become a
  connect text of at most 1,400 characters whose first 512 stand alone,
  plus books.handbook and books://handbook/{section}: the old guidance by
  section, with no helper commands, home paths, pull requests or
  file-reading suggestions (tool_text_lint).
- Every tool has a title and read-only/destructive hints; no
  requiresUserInteraction anywhere (owner decision 2026-10-16).
- books.import_local_file reads only regular files inside the import
  roots (the downloads folder by default, or --import-root), never the
  data dir, books dir, vault or the key's folder, no symlink escapes or
  hard links, and the file opened must be the file checked.
- The store lock is a tokio mutex taken off the runtime threads.
- get_info refreshes settings first; a bare books://digest reads.

Review fixes (P0 review, 2026-10-16):
- Fail closed: a tokio mutex never poisons and spawn_blocking caught the
  panic, so after a panic mid-mutation the server kept serving a Store
  behind its journal. A panic (the task, or while holding the store)
  now stops the shared store; every later request on every session is
  refused with a plain message; a restart re-verifies the journal. The
  join error goes through redact_refusal and never carries the panic's
  text.
- books.set_w9 w9_file_path read any path (a link named w9.pdf to the
  vault key was stored and get_w9 wrote it out): it now goes through the
  import roots' lockdown, its type judged on the canonical path.
- Sealed even inside a root: the key file in force, where a linked one
  leads, both folders, and the default key folder whatever the settings
  say. Refusals check the roots first, so outside them every path gets
  one message (no probing what exists); inside, other books-dir files say
  to copy them out first.
- The open is O_NOFOLLOW | O_NONBLOCK (a pipe swapped in after the check
  can't hang a thread) and must match the checked file's device and
  inode. New dependency: libc (unix only), for those two open flags,
  which std doesn't name.
- Default import roots are the downloads folder only (the plan); files
  with full TINs (get_w9, vendor_1099 include_tins, the CPA package with
  TINs) are never written into an import root, and the server refuses to
  import any file it wrote with TINs (by SHA-256).
- set_tin and set_w9 overwrite vault records: destructive_hint = true;
  annotations_complete pins the key tools' hints. The read-only hint is
  checked in behaviour on every spec_mcp call (journal and data-dir files
  unchanged).
- Text: the connect text names the settings file as the one local file
  to edit, says every entry balances on real accounts and names its
  party, says invoice/bill/payment/payroll tools post unchecked, rewords
  the exemptions, and shows the company name as one plain line; the
  handbook takes section "all", lists its ids in the schema and says to
  read contract first; books.settings drops the config.json path and no
  error names the data dir or the key file. tool_text_lint covers the
  whole tool JSON and resources and bans git/commit/WL-0001; tool
  descriptions over 700 characters are pinned to today's eight (P5).
- digest: an empty since= is bare; the default date is pinned on every
  day of a month.

Round-2 review fixes (2026-10-16):
- TIN files (B-26): the in-memory, per-session set of TIN-file hashes
  became one registry beside the vault (vault/tin-files.sha256, hashes
  only), shared by every session and every later server. It holds the
  W-9 set_w9 reads (recorded as read), get_w9 copies, 1099 worksheets
  with TINs, CPA packages with TINs and each file in one that holds a
  TIN; a file is recorded before it is written, and a failure refuses.
  import_local_file, attach_receipt and file_document (base64 was never
  checked) refuse a recorded file, and a text file (by type, name, or
  UTF-8/UTF-16 decoding) holding a full TIN. Not caught: an altered
  binary copy; the docs say so (P2/P7 remove the path tools).
- The text check is narrow (owner/engineer decision, 2026-10-16: bank
  exports are filed for reconciliation, and has_full_tin's "nine digits
  however separated" refused their routing numbers, long references and
  dates run into amounts). Text is refused when it holds (a) a TIN the
  vault holds for a party, anywhere, as nine digits, 3-2-4 joined by
  hyphens, spaces or dots, or 2-7 joined by a hyphen or space, compared
  on the server against the decrypted vault and never returned or
  logged (a vault that can't be opened leaves (b)); or (b) a stand-alone
  printed SSN (123-45-6789) or EIN (12-3456789) shape: no digit, and no
  hyphen or dot followed by a digit, beside it. Full-width digits and
  Unicode hyphens and spaces read as ASCII. has_full_tin still guards
  refusals and results (redact_refusal). Known limit, for P7: a refusal
  under (a) tells the agent a text it chose holds a vault TIN.
  New dependency: zeroize in ledger-mcp (already in the workspace
  through ledger-vault, same major version), so the vault's TINs decoded
  for the scan are wiped from memory on drop as the vault's own are.
- Mutation testing of the final P0 diff (docs/testing.md): tests for
  each law a survivor broke (handbook titles, a dropped default root, a
  .jpg W-9, the waker, the walk's folder flags against a link and a
  pipe, the 40-link limit, save paths refused as such, the package
  marking only its TIN files, a registry that can't be read, text types,
  the short receipt rule's counts and a 12-character code). Simpler
  code where only a race could tell: inspect places an existing path by
  canonicalize itself (no second comparison), read_checked's read limit
  is its one size check, openat fails on -1 as specified. The 25 MiB
  upload test removes its 50 MB of books however it ends.
- The GUI's stale-rule prompt no longer says to open a pull request: it
  asks the agent to find the figure from the rule's official authority,
  confirm it with the human, pass it explicitly, and tell the human so it
  can be reported upstream (as the handbook says); a lib test pins it and
  bans files, git, GitHub and pull requests from every attention prompt.
- Import roots (local_files.rs INVARIANT 0): XDG_DOWNLOAD_DIR="$HOME/"
  made $HOME (or /) the root. No root may be /, the home folder or hold
  it, or hold the books, data dir, vault or a key: a named one stops the
  server at startup, a default one is dropped; the default falls back to
  ~/Downloads only if that is a real folder other than $HOME; with no
  root, an import says so.
- The read is a component walk from / with openat(O_NOFOLLOW |
  O_DIRECTORY) per folder and O_NOFOLLOW | O_NONBLOCK for the file, so a
  folder swapped for a link after the check fails; no step may land on a
  protected inode (one renamed into a root); dev/ino and nlink stay. No
  /proc. Portable to macOS. (libc's openat, two small unsafe blocks.)
- One refusal for missing, dangling and outside paths: resolved()
  follows every link, dangling ones too, so a link's target existing or
  not no longer changes the answer.
- Sealing: the key files (in force, default) and their targets always;
  their folders only when not $HOME, not a root, not holding a root; and
  ~/.config/wanderbooks whatever XDG_CONFIG_HOME says.
- Agent text (I-12): RuleNotKnown names the authority (IRS, with its
  address) and says to tell the human, never a repo path, PR or GitHub;
  the digest's stale_rules drop the repo catalog and path for it; vault,
  outside-books, STOPPED and local-file refusals name no git, path or
  flag. tool_text_lint bans docs/, .md, /home, data dir, --data-dir,
  --import-root, vault.key, .jsonl, sqlite, .gitignore, github, pull
  request, and also lints every error template (scanned from source),
  built error texts, and the server's refusal constants.
- The receipt rule told (ReceiptPolicy::rule) is the one enforced: the
  exact threshold in the base currency (no "$", no rounding), required
  and exempt accounts, reversals exempt; the connect text fits 1,400
  characters by dropping reasons, then codes, never the rule.
- The connect text follows the parties setting; "offer books.digest"
  everywhere. shown_name drops Unicode format characters (bidi, zero
  width).
- read_only_tools_change_nothing compares file contents, the database
  by every row, the vault and the settings file (raw database bytes did
  not change either; rows are the stable comparison).
- import_local_file takes the name and type from the canonical path;
  the digest's journal repair says when the tail was set aside, not the
  journal's file name; "version-history messages" back in the TIN rule;
  openWorldHint false on every tool, pinned in annotations_complete.
- Outside the MCP crate, for the same texts: ledger-core's DatedRule
  gains `authority` (the statute or publication with its address) and
  RuleNotKnown carries it; ledger-store's "sqlite error" reads "database
  error"; ledger-config's ReceiptPolicy::rule takes the base currency.
  New dev-dependency: rusqlite in ledger-mcp (already in the workspace,
  same version and features as ledger-store), to read the database's
  rows in read_only_tools_change_nothing.
- The filing scan is no TIN oracle (tin_files.rs INVARIANT 4): text
  holding more than two TIN-shaped numbers (any rendering rule (a)
  reads) that end in a known TIN's last four (the books' TIN and W-9
  facts, via known_tin_last4, before the vault is opened; then the
  vault's own) is refused with TIN_PROBE, the same words whether or not
  one is the vault's TIN; only one or two candidates are compared
  against the vault. Tests: unit
  text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it,
  one_or_two_candidates_are_compared_against_the_vault; over HTTP
  text_probing_for_a_tin_is_refused_alike_by_every_filing_tool.

Plan: plan/served.md P0, section 6 "Immediate hole (P0)", 4.1, 4.3, 4.9.

Review round 3. S1: a panic stops the server for every session, so no
input may reach one. Dates are four-digit years (new ledger_core::
calendar, 0001-01-01..9999-12-31): the MCP's parse_date is strict
YYYY-MM-DD, periods are bounded, last_day_of_month returns a refusal
(server and store); schedules, assets, recurring entries, periods,
documents, disposals and entries refuse dates outside the calendar, and
replay refuses such journal dates. Every amount a command takes is at
most a line's (draft lines by size, document lines, payouts, payroll,
statements, shares, tax basis, proceeds, recurring templates), on
replay too, so sums are exact; the 1099 vendor part and the K-1
property share use checked products (TooLargeToCompute); a recurring
run makes at most 1,000 drafts. tests/hostile_args.rs drives every tool
with schema-derived hostile arguments and every resource with generated
URIs, asserting the server never stops; the audit's panics are pinned.
S2: a `.` or `..` path name is refused as NO_SUCH_FILE up front. S3:
NUL-interleaved UTF-16 without a byte-order mark is scanned both ways.
S4: resource-read refusals are TIN-redacted. S5: set_w9 records its W-9
only once the call passes every check, just before the vault write.
S8: idempotentHint only on verify, run_depreciation, run_schedules,
run_recurring, set_party_roles (a repeat leaves journal and vault
alone; spec_vault checks every candidate twice); every tool's hints
pinned in one table. C1: books.settings lists receipt_required_accounts
and receipt_exempt_accounts uncut. C2: the default company name reads
"a new company". C3: the parties rule names no-party reasons. C4/C5:
the handbook names the settings file only as books.settings' books_toml,
and restores "check books.digest and offer it". C6: an import root
inside the books, data dir or vault is refused at startup. The lint
catches `just` however it ends and where the books are;
digest_resource_bare_reads reads the server's month.

Review round 4 (2026-10-17): round 3's bounds ran in replay too, so
journals main wrote and reopened (an entry dated 0000-06-01 or
+10000-01-01, a draft line of 2e15, an asset ending past 9999, a basis
or share past the line limit, sub-cent document and payroll lines) no
longer opened. Posted is forever: the bounds are the commands' only.
- Replay applies main's rule exactly: parse_date takes any date chrono
  reads; replay_add_line(_fx) has no size bound (add_line/add_line_fx
  do); replay_register_asset/correct_asset (chrono overflow only),
  replay_set_asset_tax_basis (check_journaled), replay_set_entry_parties
  (check_shares_journaled), replay_register_recurring
  (validate_recurring_journaled), replay_register_schedule (months 1 to
  600), replay_create_period (order only), replay_dispose_asset, and no
  line, date, payment or payroll range checks in replay_invoice/bill,
  their payments and replay_payroll_run.
- Derived figures over such books refuse (TooLargeToCompute,
  DateOutOfRange), never panic: amortization plans, depreciation into
  chrono's last month (and the true-up for lives past 9999), intervals,
  document totals and aging, net pay and payroll plans, money flows and
  share resolution, the asset register, receipt-policy debits; the MCP
  tools and the viewer show what can't be computed.
- tests/fixtures/old-books: the review's 13 journals, its control and 15
  more edges, written by main's Store API with main's figures
  (expected.json); every one opens in the writer and the viewer to those
  figures, every derived figure computes or refuses, every MCP tool and
  GUI page answers; hostile_args restarts the server after each
  property; each bound has a command/replay test pair.

Final review: a schedule's plan refuses a total of 10^26 or more
(TooLargeToCompute) instead of letting Decimal round its cents silently,
and a null `remaining` in the schedule output is always explained by
plan_error. Replay is unaffected: no journaled schedule month can belong
to a total that large (the line limit bounds every posted month).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 09:25:00 -06:00
swaitsandClaude Opus 5.5 aec55b8d12 Store: say "newer wanderbooks", not "corrupt", for books from a newer version
An unknown event type on a whole, chained line, or a journal.format_set
declaring a newer journal format, now raises StoreError::NewerJournal
(update wanderbooks) instead of JournalCorrupt. Corruption (bad hash,
seq, or JSON) is still corruption. The gate runs in the store, the
Snapshot and the GUI, after each line's parse, seq and hash checks, and
never skips a line it can't read. Adds the journal.format_set event
(format 1 today; nothing writes it yet) and a registry of every event
type this version reads, tested against the replay and projection arms.

Review fixes (P0 review, 2026-10-16):
- A line's own hash is checked only by the next line's prev, and the
  gate used to refuse a line before reading the next: a flipped bit in a
  middle line's type (or a format_set edited from 1 to 2) was reported
  as "newer, the books are not damaged". Now the reader settles every
  refusal against the next line (format::settle): a next line that
  doesn't chain makes it corrupt; one that chains makes it newer and
  verified; with no next line the message says "newer or damaged; update,
  then run verify", never "not damaged" (NewerJournal.verified).
- A newer format number is judged newer before its features are parsed:
  a newer format may shape them differently.
- Snapshot treats a blank complete line as corrupt, as the writer does.
- docs/spec/rules.md B-27, which the code cites, now exists.

Round-2 review fixes (2026-10-16):
- The envelope {seq, ts, type, payload, prev} and its hash (SHA-256 of
  the canonical JSON) are frozen across formats (rules.md B-27); new
  fields go in payload. settle reads only the next line's prev, leniently,
  so a newer format's next line (a float, a string seq, keys of its own)
  is never called corrupt: no readable prev means unverified newer.
- A key beside the envelope's was silently dropped (and the chain then
  broke as "corrupt"): it is now NewerJournal, hashed over the raw
  canonical JSON so the next line still checks it; JournalLine::parse
  refuses such a line.
- "the books are not damaged" overclaimed: a verified refusal says line N
  is intact and this version can't check the lines after it.
- The writer read the journal with BufRead::lines (invalid UTF-8 was an
  I/O error); it now splits bytes on newlines as the Snapshot does, so a
  non-UTF-8 line is JournalCorrupt in both.
- Store::open truncated a torn tail before the gate ran, altering newer
  books (perhaps mid-append) before refusing them. The gate now runs over
  the complete lines first; the tail is set aside (and journaled) only
  when they pass.
- Mutation testing (docs/testing.md, P0): the viewer and the writer say
  the same words about the same damaged line (the newline is no part of
  it); read_journal_and_format returns every line and the format in
  force; the journal page tells a format declaration.
Plan: plan/served.md P0 and 5.1.

Review round 3 (S6, S7). Store::open replays every complete line in
memory (the first line's currency, then the ledger) before a cut-off
last line is set aside, so a complete line the books can't apply
refuses the open with the journal byte for byte as it was and nothing
set aside (B-27 SCENARIO 3; test
a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was);
catch_up now only projects. The viewer
applies the writer's first-line rule (created_currency, shared) instead
of guessing USD: any other first line, or a store.created without a
sound base_currency, is corrupt to both (test
the_first_line_is_store_created_for_the_viewer_and_the_writer). fuzz_store_replay varies the
first line (14,696 runs clean).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 00:32:42 -06:00
swaitsandClaude Opus 5.5 754d616a9d P0: pin dev tools, honest transports doc, tools-only template
mise.toml: pin cargo-mutants 27.1.0 and cargo-fuzz 0.13.2 (the latest
published, and what is installed) instead of "latest", so mutation and
fuzz runs repeat (plan/served.md I-13, P0).

docs/mcp-interface.md: the spec had two Transports sections, and the
first claimed bearer-token auth on Streamable HTTP. There is none: the
server uses rmcp's default config, whose only guard is a Host check for
localhost, 127.0.0.1 and ::1, with no token and no Origin check. One
section now says so plainly and points to plan/served.md P2 for auth.

ledger-cli AGENTS.md template: "the journal is the evidence - read it,
don't guess" led an agent to cat the journal file instead of asking the
books. The template now says to trace a figure through books.get_entry,
books.search_entries, or books.export (journal or general_ledger), and a
new bullet says the books are reached only through the wanderbooks MCP
tools: never open, list or parse the books' files, not even to look; if
a tool can't answer, say so. The other lines that sent the agent to the
data dir go too: onboarding is triggered by an empty books.chart instead
of a missing journal.jsonl, the CPA paragraph drops the ledger-gui
--data-dir books-data command, and the backup note no longer names the
journal's path. Test agents_md_reaches_the_books_only_through_tools
pins this; agents_md_carries_the_contract no longer expects journal.jsonl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-16 18:18:05 -06:00
swaitsandClaude Opus 5.5 188c90ea99 STATUS: Served planned; P0 paused mid-build, with resume steps
check / check (push) Canceled after 0s
Records where the Served work stopped on 2026-10-16 (owner's usage
credits): which P0 pieces are done, which are work in progress in which
jj workspace, how to resume, and where the before-snapshots of the first
company's reports live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-16 15:49:32 -06:00
swaitsandClaude Opus 5.5 a968d7a19a plan: Served — books behind the MCP, local and self-hosted
check / check (push) Canceled after 0s
The 2026-10-16 audit of the first company's books found an agent reading
the journal file directly, the instructions truncated by clients, and 20
engine defects. plan/served.md is the plan to fix all of it: the books
become a server that owns its data, every agent reaches them only
through the MCP from any directory or client, settings and conventions
move into the journal, and the scaffold goes away. Phases P0-P10, ending
with a full re-audit through the MCP alone.

Chosen from three independent designs by a three-judge panel, then
revised for the owner's decisions: books kept as the user in a private
data root (advisory isolation, labelled so; enforcement is the self-host
deployment), the viewer reads the data directly, no added permission
prompts, self-hosting now and multi-tenant hosting later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-16 15:27:19 -06:00
swaitsandClaude Opus 5.5 579ab743d4 Viewer: clickable breadcrumbs
check / check (push) Canceled after 0s
Every crumb above the page you're on links to where it names: the
section goes to its home (Parties › Stephen Waits → Parties), and a
reconciliation's trail runs Accounts › its account › it. Same look; a
pointer and "Go to …" on hover. A crumb naming the current page isn't a
link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-16 08:41:39 -06:00
swaitsandClaude Opus 5.5 a44a8873df Viewer: click a vault TIN to copy the full number
check / check (push) Canceled after 0s
The party page's "TIN on file" and the W-9 card's TIN copy the full,
formatted number on click (right-click: digits only). The viewer reads
it from the vault only at that moment, with the server's key file in
force, never creates a key, refuses a key inside the books dir, and
never draws more than the last four. A locked vault copies nothing and
says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-16 08:27:13 -06:00
swaitsandClaude Opus 5.5 9927e56431 Add the government_agency party role
check / check (push) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
- 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) Canceled after 0s
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) Canceled after 0s
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) Canceled after 0s
- 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) Canceled after 0s
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) Canceled after 0s
- 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
swaitsandClaude Opus 5.5 3609bf065e feat(core): prove the accounting laws (Kani + Lean); fix three engine gaps
The money arithmetic every invariant rests on now lives in one kernel,
ledger-core/src/kernel.rs: signing a line, totals, net, per-account
movements, reversal, normal balances, trial-balance columns, closing
lines, and the rounding plug for member splits. It is generic over a
checked `Amount` trait. The engine runs it on Decimal (validate,
movements, reverse_entry, account_balance, close_period_to, allocate,
trial_balance): there is one copy of the arithmetic, and it is the one
under proof.

Three layers, docs/verification.md maps every law to its proofs:

- Kani (src/proofs.rs): 17 harnesses prove the kernel at i64 for every
  input up to 4 lines over 3 accounts. An entry is accepted exactly
  when it is two or more positive, in-range lines whose debits equal
  credits; accepted entries net to zero; grouping by account loses
  nothing; a reversal mirrors and cancels its original; trial-balance
  columns balance iff the books net to zero; closing lines zero their
  accounts and a close balances however the net is split; the plug
  makes parts sum to the whole. No panics, no overflow. kani::cover!
  shows the proofs are not vacuous.
- Lean 4 (verify/lean): 24 headline theorems for books of any size. The
  ledger is a state machine (add account, draft, post, reverse, open,
  close), and in every reachable state the books net to zero, the trial
  balance balances (as of any date), and the accounting equation holds.
  Posting only appends ("posted is forever"), closed periods are
  frozen, a close zeroes revenue and expenses and credits exactly the net
  income, and period statements add up. Audit.lean shows standard axioms
  only, no sorry.
- Bridge (tests/kernel_bridge.rs): the Decimal and i64 kernels agree.

What the proofs found (each one missed by every test, fuzzer, and
mutant):

- The engine accepted negative line amounts; only MCP refused them. Now
  NegativeAmountLine: direction is the side's job.
- Summing lines near Decimal::MAX panicked. Lines are now capped at one
  quadrillion and ten decimal places (AmountOutOfRange), which keeps
  every realistic sum exact, and the kernel's sums are checked.
- FX conversion (foreign_amount * fx_rate) could panic the same way; it
  is now a checked multiply.
- Kani itself: 0.68 mis-models symbolic-length copy_from_slice
  (model-checking/kani#4904) and reported a reversal that doesn't net to
  zero. The counterexample, replayed natively, passes, and a 15-line
  standalone crate reproduces the bug. Harnesses avoid bulk copies, and
  a should_panic canary will flag the upstream fix.

`just prove` runs it all (Kani memory- and time-capped, fails on an
unsatisfiable cover or a non-standard Lean axiom). AGENTS.md now makes
formal coverage part of every core change.

Also, docs: GnuCash is *an* oracle, never *the* oracle (Stephen). The
spec is accounting principles, proved. Golden files are recomputed from
principles. External implementations (GnuCash, the old Sheet, QBO/Xero)
are cross-checks whose disagreements get adjudicated, never obeyed.
docs/oracle.md gains "Who is right"; AGENTS, README, architecture,
roadmap, parity, and STATUS are reworded to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 11:12:13 -06:00
swaitsandClaude Opus 5.5 d5c4e38b18 build: pin every dev tool in mise.toml
Rust (with clippy and rustfmt), just, Node (for deploys), Kani, Lean,
cargo-mutants, and cargo-fuzz, so `mise install` is the whole bootstrap
and nothing gets installed by hand. Lean is 4.34.1, the newest release
past mise's minimum release age; verify/lean/lean-toolchain matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 11:11:56 -06:00
swaitsandClaude Opus 5.5 bea2ee2dc2 gui: Save PDF on the general ledger too
The general ledger draws its own per-account tables, so it had no PDF
button. It now has one: each account a section, a line per posting
(date, entry, description, the other side) with debit, credit, and
running balance, its beginning balance, and totals with the ending
balance, typeset through the same statement template.

The PDF plumbing (button, status, background job) is shared by both
paths now. The ledger's rows also measure and clip their text and
scroll sideways in narrow windows, like every other table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:21:20 -06:00
swaitsandClaude Opus 5.5 a6566e801f Cargo.lock: ledger-gui depends on ledger-import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:14:45 -06:00
swaitsandClaude Opus 5.5 12308fe3e9 gui: Save PDF on every report; short and narrow windows hold up
Reports get a Save PDF button beside Copy for a spreadsheet. It typesets
the report as it's set up on screen (range, months/quarters/years,
comparison) through ledger-import's Typst statement template, the same
one the CPA package uses, in the background, writes it to the Downloads
folder (never the books: the viewer stays read-only), and opens it.

- ledger-import: Statement.percent_from marks percentage columns
  (comparison %, % of income), typeset as 12.5% with no dollar sign;
  wide statements turn the page, and past seven columns set smaller so
  a year of months fits; statement() is public for other callers.
- Compared columns that read the same (Jan 1 - Oct 12 vs a year
  earlier) now say which year, on screen and on paper.
- Short windows: the sidebar's and rail's links scroll above the status
  box instead of under it. The search palette fits the window.
- A test typesets every table-backed report from the demo books,
  plain, by month, and compared (PDF_DIR keeps the files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:14:35 -06:00
swaitsandClaude Opus 5.5 ea996f6e7e gui: every screen lays itself out to fit, down to a 360-point tiling slot
Narrow windows overlapped and clipped: the top bar's crumb, date, and
search ran into each other; big numbers wrapped mid-figure; tables
painted columns over each other; the entry and document panes squeezed
their lists to slivers; anything too wide widened the whole page.

- Below 1000 points the sidebar becomes an icon rail (names, attention,
  and integrity checks in tooltips).
- The top bar lays out right to left: search shrinks to its icon, the
  date drops, the crumb truncates.
- Pages are pinned to the window width with narrower margins when tight,
  so one wide thing can't push everything off screen.
- Tiles (KPIs, cash accounts, documents) fit as many columns as the
  width allows, balanced across rows; KPI figures shrink rather than wrap.
- Painted tables shrink their amount columns, clip labels with an
  ellipsis, then scroll sideways rather than overlap.
- List/detail splits show only the detail when both won't fit.
- Reports: the side list becomes a picker; controls wrap by known width.
- Smaller fixes: chart labels thin out, company facts stack, journal
  timestamps yield, transaction filters become a dropdown.
- Min window size drops to 360x400; the screenshot test covers every
  report, a register, a document, and a journal event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:02:48 -06:00
swaitsandClaude Opus 5.5 9eba3180b4 cli: the viewer check is just session-start, hooked for Claude Code and Codex
New books dirs get .codex/hooks.json beside .claude/settings.json; both
SessionStart hooks run one justfile recipe, which reports the viewer's
state per [viewer] open_at_start (and opens it itself for "always").
Codex runs project hooks only once the dir is trusted and the hook
approved, so new --codex says so; AGENTS.md stays the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tpv1gBaepKjQiAVJqejGbA
2026-10-12 19:44:04 -06:00
swaitsandClaude Opus 5.5 799aee58ea cli: new books dirs get a SessionStart hook that checks the viewer
The viewer offer (open_at_start) lived only in AGENTS.md, so it depended
on the agent remembering it on its first turn. The scaffold's
.claude/settings.json now runs just gui-status at session start and hands
the agent the result plus open_at_start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tpv1gBaepKjQiAVJqejGbA
2026-10-12 19:41:48 -06:00
swaitsandClaude Opus 5.5 b38c9e39cc cli: just gui runs the viewer in the background; gui-fg, gui-status, [viewer] open_at_start
The scaffolded justfile now detaches the viewer (log in books-data/gui.log)
and won't open a second one for the same books. AGENTS.md tells the agent
to check gui-status at session start and follow open_at_start
(ask/always/never) in wanderbooks.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DeBdcyb3YyRkcpicBZH5wh
2026-10-12 19:38:33 -06:00
swaitsandClaude Opus 5.5 024c9896f2 fix(store): verify no longer chokes on a draft before a posted entry
The SQLite cross-check rebuilt entries by id and assumed no gaps, but
drafts aren't projected until posted: a draft #8 before a posted #9 made
the server refuse to start. Gaps are now held with empty drafts (which no
report counts), and a projection that can't be read back counts as drift
to heal, not a reason to refuse to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 19:09:08 -06:00
swaitsandClaude Opus 5.5 475d08b28e feat: books.show — the agent puts things on the viewer's screen
books.show asks the viewer to open an entry, an account register, a
report (dates, month/quarter/year columns, prior-period or prior-year
comparison), a document, a search, or a page. The request lives outside
the books (runtime dir, keyed by data dir, written atomically); the
viewer watches it beside the journal and applies books before
navigating. 66 tools.

Site: the dashboard is the hero again; 'Here's what using it looks like'
shows the session beside the viewer, ending in books.show; the cash page
replaces the repeated overview lower down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 19:03:19 -06:00
swaitsandClaude Opus 5.5 8637c1b05f site: dark screenshots; the hero shows a Claude Code session beside the viewer, same entry
The session (an HTML terminal pane, demo company) records the Adobe
receipt; the viewer shows entry #245 with its lines and rendered receipt.
Screenshot harness: SHOT_LIVE labels a fixed-books app as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:56:09 -06:00
swaitsandClaude Opus 5.5 17eed43ab9 site, README: the GUI is the viewer; you work by talking to Claude or Codex
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:48:36 -06:00
swaitsandClaude Opus 5.5 a9850b6b05 feat: Codex support in wanderbooks new; the site leads with 'free, turn Claude or Codex into a full bookkeeping system'
wanderbooks new --codex registers the books' MCP server with Codex (as
wanderbooks-<dir>) and starts Codex; the scaffolded justfile gains codex
and codex-setup. The site drops 'hire', says free up front, shows
Claude Code or Codex + wanderbooks = a complete bookkeeping system, and
explains MCP and cost in the FAQ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:47:51 -06:00
swaitsandClaude Opus 5.5 3f20858df3 fix(gui): needs-attention grows to fit with 'show N more' instead of a hidden inner scroll; receipt wording follows the policy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:39:48 -06:00
swaitsandClaude Opus 5.5 2a00f4ef33 fix(gui): recent activity rows clip before the timestamp column; SHOT_HEIGHT for tall screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:37:18 -06:00
swaitsandClaude Opus 5.5 54d2775a33 fix(gui): clip long row text instead of drawing over amounts; drafts aren't payments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:32:50 -07:00
swaitsandClaude Opus 5.5 01d41c0daf fix: tell the agent the receipt policy the books actually use
The server's instructions hardcoded '$25.00'; they now state the
configured threshold, and a zero threshold reads 'every entry needs at
least one receipt'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:30:35 -07:00
swaitsandClaude Opus 5.5 005a96055a site: wanderbooks.ai — one-liner, canonical and social tags, Netlify config and deploy recipes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:24:10 -07:00
swaitsandClaude Opus 5.5 45784266b5 feat: the CPA package carries the evidence; the site answers 'why not QuickBooks?'
CPA package: every receipt behind the period's entries with an index,
company documents, the journal as IIF, the 1099 worksheet, and a README;
include_receipts=false for a light package. The scaffolded AGENTS.md
covers receipts from email, switching from QuickBooks/Xero/spreadsheets,
and working with the CPA. GUI: a books copy without a server database
reads 'Read-only copy'. Site: example prompts, QuickBooks comparison,
what it does and doesn't do yet, the CPA section, FAQ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:14:42 -07:00
swaitsandClaude Opus 5.5 9694d2a89a feat: end-to-end integrity checks with self-healing; website; installer and Windows fixes
Store::verify compares the whole SQLite projection, every table row for
row, against a fresh projection of the journal; verify_and_heal rebuilds a
drifted database (keeping the old file) at every server start and via the
new books.verify tool, which also re-hashes every blob. The GUI checks the
server's database read-only and every stored file, and shows the result.
rebuild() also drops stale WAL/SHM files.

site/: the wanderbooks website (just site, just publish). install.sh:
OS detection (Windows points to WSL2), C compiler and git checks,
WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup.
Repo justfile: check, build, install, demo, site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:05:05 -07:00
swaitsandClaude Opus 5.5 714867ed58 feat: one-command onboarding — wanderbooks new, install.sh, justfile
wanderbooks new DIR installs the server and GUI if missing, writes the
books dir (AGENTS.md + CLAUDE.md, wanderbooks.toml, justfile, .mcp.json,
.claude/settings.json approving the server), commits, and starts claude
onboarding. install.sh: Rust + cargo install + wanderbooks new in one
line. The tax summary labels its net when accounts are uncategorized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:29:29 -07:00
swaitsandClaude Opus 5.5 105fd52694 feat: ledger-gui — a read-only, live desktop view of the books
egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:20:14 -07:00
swaitsandClaude Opus 5.5 b48addd0e0 feat: 1099 tracking — vendor forms, payment tags, year-end worksheet
Vendors carry form_1099 (NEC/MISC) and tin_on_file (never the TIN).
Entries carry the vendor they paid; bills, bill payments, and reversals
are tagged automatically. books.vendor_1099 counts cash paid per vendor
per calendar year (card payments excluded) against $600 through 2025 and
$2,000 from 2026, or an override, and flags missing W-9s. Four new tools
(64), two journal events, schema v7. Docs: finished R-FUTURE work is no
longer listed as future.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:53:56 -07:00
swaitsandClaude Opus 5.5 8164abb2b8 fix: period close sweeps only through the period end; partnerships close into members' capital
The close zeroed every revenue and expense account's all-time movement,
so closing a period after later entries were posted swept those too:
close January in March and February's and March's income landed in
January's closing entry. The preview had the same bug and never showed
where the net went. Now:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:29:12 -07:00
swaitsandClaude Opus 5.5 02b3b9c053 feat: general ledger by account, trial balance as of, members' equity
Three reports a CPA expects that the engine could not produce:

- general_ledger(start, end): the canonical general ledger, organized by
  account in code order. Each account has its beginning balance, every
  posting in the period (date, entry, description, debit, credit, running
  balance on the normal side) and its ending balance, which ties to the
  trial balance. Each posting names its split: the one account on the
  opposite side of its entry, or "-Split-" when that side has several,
  so a compound entry (one debit, several credits) reads correctly from
  every account it touches. The entry-ordered listing the exports called a
  "general ledger" is really the general journal.
- trial_balance_as_of(as_of): a trial balance is always as of a date.
- members_equity(start, end, members): the statement of changes in
  members' equity for a multi-member LLC taxed as a partnership. Each
  member's capital roll-forward: beginning (capital plus their share of
  undistributed earnings), contributions, allocated net income (loss),
  distributions, ending. Allocation is by ownership share to the cent,
  the last member absorbing rounding, so totals are exact and the ending
  total equals balance-sheet equity; any equity change the roll-forward
  can't explain shows as "other" instead of disappearing. Shares must be
  positive and sum to exactly 1; capital accounts must be distinct
  equity accounts (LedgerError::InvalidMembers).
- CashFlowLine.account: the account a line is about, so renderers can
  name members instead of account codes.

Tests (every expectation computed by hand): trial balance as of Jan 31;
February general ledger with beginning balances, running balances, a
card that nets to zero, an inactive account still listed, and every
ending balance tied to the trial balance; a compound entry's splits from
all three sides; a two-member partnership where 666.67 and 100.01 split
unevenly (333.34/333.33, 50.01/50.00), with and without closing January,
tying to the balance sheet each month; bad member configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:16:25 -07:00
384 changed files with 148920 additions and 3473 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_settings_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
+5 -1
View File
@@ -1,4 +1,8 @@
/target/
Cargo.lock
*.orig
.DS_Store
site/dist/
.netlify/
# cargo-mutants output (just mutants*)
/mutants.out*/
/mutants-*/
+81 -6
View File
@@ -12,9 +12,13 @@ what they haven't demonstrated.
semantics, built to become a full bookkeeping application (engine now, UI
later). The reference points:
- **Semantic oracle:** GnuCash. It is open source with open file formats
(XML/SQLite), which makes it both the spec and the reference implementation
we diff against. See `docs/oracle.md`.
- **Spec:** accounting principles, written down as proofs
(`docs/verification.md`). That is the only authority.
- **Cross-check oracles:** GnuCash (open source, open file formats), the
old Google Sheet, QBO/Xero exports. Useful to diff against, *never*
trusted to be right: a disagreement is adjudicated from principles, and
nothing may rest on another program's behavior alone. See
`docs/oracle.md`, "Who is right".
- **Workflow reference:** QuickBooks Online and Xero — for *what the UI must
do*, never for code. We observe behavior; we write our own implementation.
- **Method reference:** storytold/ArtCraft's app-cloning pipeline
@@ -37,6 +41,10 @@ later). The reference points:
IRS publications for tax-adjacent logic later. GnuCash itself is GPL —
read its docs, never copy its code. This repo is MIT OR Apache-2.0.
Never disassemble or copy from proprietary software.
6. **Accounting laws are proved, not just tested.** Arithmetic that money
flows through lives in `kernel.rs` and carries Kani proofs; the laws
it serves carry Lean proofs. `just prove` stays green. See
"Formal methods" below.
## Verification protocol (read this twice)
@@ -60,6 +68,68 @@ numbers**. The analog of storytold's screenshot-in-the-loop:
- The randomized invariant test (`random_balanced_journals_preserve_invariants`)
is the always-on property oracle. Extend it when you add invariants.
## Formal methods: the bar (read this three times)
Tests check the cases someone thought of; proofs check all of them. The
first formal pass on this engine (2026-10, `docs/verification.md`) paid
for itself several times over, on code that already had 190+ tests,
property tests, mutation testing, and 11.5M fuzz executions:
- **Negative line amounts were accepted by the engine.** Only the MCP
layer refused them. Any other caller could post them.
- **A large enough amount crashed the engine** (a `Decimal` overflow
panic while summing lines) instead of returning an error.
- **Currency conversion could crash** the same way.
- **The verifier itself had a soundness bug.** Kani 0.68 mis-models
`copy_from_slice` of symbolic length (model-checking/kani#4904): it
reported a reversal that doesn't net to zero. We didn't wave it away.
Replaying the counterexample natively, then shrinking it to a 15-line
crate, proved it was the tool, not the books. Now a canary harness
watches for the fix, and covers prove no proof is vacuous.
- **Writing the spec simplified the code.** `credit_line` and
`closing_line` turned out to be the same function. Lean also proved
that no draft can exist in a closed period, the invariant that lets
the store journal a post before applying it.
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
gets a second copy.
2. **Every kernel function gets a Kani harness** in `src/proofs.rs`, on
fixed arrays (no symbolic-length `Vec` growth, no `copy_from_slice`),
with a `kani::cover!` showing the interesting case is reached.
3. **Every new law gets a Lean theorem** in `verify/lean`. Laws about
the books over time are theorems about `Reachable` states. A new
`Ledger` command is a new `Step` constructor, with its preservation
proof. No `sorry`, ever; `Audit.lean` lists every headline theorem.
4. **Every kernel instantiation gets a bridge property** in
`tests/kernel_bridge.rs`, so what Kani proved on `i64` holds on
`Decimal`.
5. **A failing proof is a finding, not an obstacle.** Get the
counterexample, replay it natively, and decide on evidence: bug in
our code, bug in the spec, or bug in the tool. Never weaken an
assertion, raise a bound, or delete a harness to make red go green
without writing down why in `docs/verification.md`.
6. **More, not less.** When you touch an area the proofs don't cover
yet (A/R and A/P documents, payroll, depreciation, FX, persistence),
leave it with more formal coverage than you found it. Proptest,
mutation testing, fuzzing, Kani, and Lean all stack. Use all of them.
`just check` and `just prove` both pass before every commit that touches
`ledger-core`.
## Parity tracking
`docs/parity.md` is the parity catalog: every semantic feature we intend to
@@ -85,8 +155,11 @@ clone, with status. Rules:
## Working here
- Rust stable, edition 2024. `cargo test` must pass; `cargo clippy` should be
clean; `cargo fmt` before committing.
- `mise install` sets up every tool (pinned in `mise.toml`): Rust,
just, Kani, Lean, cargo-mutants, cargo-fuzz. If a recipe needs a tool,
it goes in `mise.toml`; nothing gets installed by hand.
- Rust stable, edition 2024. `just check` must pass: fmt, clippy with zero
warnings, every test. `just prove` must pass for core changes.
- Conventional commits: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
One logical change per commit. The commit message explains the *accounting*
reasoning, not just the code.
@@ -102,7 +175,9 @@ 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 prove correctness: golden files, property tests, GnuCash diffing |
| `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 |
| `docs/mcp-interface.md` | The MCP tool surface: the primary interface spec |
Generated
+4151 -80
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"]
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"
@@ -9,3 +9,5 @@ repository = "https://git.swaits.com/swaits/wanderbooks"
[workspace.lints.rust]
missing_docs = "warn"
# `cargo kani` sets cfg(kani) for the proof harnesses.
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] }
+48 -8
View File
@@ -15,25 +15,44 @@ through MCP. Self-hosted, no rent.
append-only, hash-chained JSONL journal; SQLite is a disposable
projection rebuilt from it; receipts live in a content-addressed blob
store. Drafts are durable, corrupt projections self-heal.
- **`ledger-mcp`** — the AI-first interface. 27 MCP tools over stdio or
- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or
Streamable HTTP: draft → preview → post workflow, server-side receipt
policy (>$25), anti-splitting detection, asset management, digest for
human review, plus read-only resources for dashboards.
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks
Desktop) and XLSX reports.
- **`ledger-config`** — the company settings in a books dir's
`wanderbooks.toml`.
- **`ledger-gui`** — the viewer: a read-only, live window on the books for owners and their
accountant (egui). Answers first (cash, runway, profit, what needs
attention), then every number drills down to its lines, its entry, the
rendered receipt, and the journal history. Follows the journal live:
a change the agent makes appears in milliseconds. `--demo` opens a
realistic demo company.
## Verification
This is the storytold way applied to accounting: agents verify against
oracles, never ship what they haven't demonstrated.
- 70 workspace tests: invariant tests encoding accounting law, golden-file
- 190+ workspace tests: invariant tests encoding accounting law, golden-file
report snapshots, property tests (proptest), end-to-end MCP stdio tests.
- **Formal proofs of the accounting laws** (`just prove`,
`docs/verification.md`). Kani model-checks the engine's arithmetic
kernel exhaustively within bounds. Lean 4 proves that, after any
sequence of commands, the trial balance balances, the accounting
equation holds, posted entries are forever, closed periods stay
frozen, and a close moves exactly the net income to equity. The first
pass found three engine bugs that every test, fuzzer, and mutant had
missed: negative amounts accepted, and two crashes on oversized
amounts. It also caught a soundness bug in the model checker itself.
- Mutation testing via cargo-mutants; fuzzing via cargo-fuzz (11.5M+
executions, zero crashes).
- GnuCash is the behavioral oracle (never a code source — clean room,
MIT OR Apache-2.0).
- GnuCash is *a* behavioral cross-check, never the authority and never
a code source (clean room, MIT OR Apache-2.0). Accounting principles,
proved, are the spec. Where any other program disagrees, we work out
from principles who is right.
## Status
@@ -49,19 +68,40 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
```bash
cargo test # whole suite
cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
cargo run -p ledger-gui -- --demo # the viewer, on a demo company
```
New books, one command (installs Rust and wanderbooks if needed, makes the
books dir with the MCP server registered for it, and starts the agent):
```bash
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
```
Already installed: `wanderbooks new ~/Books/Acme` (add `--codex` to keep the
books with Codex instead of Claude Code). In a books dir, `just`
lists the shortcuts (`just gui`, `just agent`, `just backup`).
## Developing
Every tool the recipes use (Rust, just, Kani, Lean, cargo-mutants,
cargo-fuzz, Node for deploys) is pinned in `mise.toml`:
```sh
mise install
just # lists the recipes: check, build, prove, demo, ...
```
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
## Layout
```
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
crates/ledger-store/ # journal + SQLite projection + blob store
crates/ledger-mcp/ # MCP server: 27 tools, resources, receipt policy
crates/ledger-mcp/ # MCP server: 108 tools, resources, handbook, receipt policy
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
crates/ledger-config/ # wanderbooks.toml company settings
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
crates/ledger-gui/ # the viewer: read-only, live (egui)
docs/ # requirements, architecture, oracle, parity, glossary
plan/ # STATUS.md, roadmap.md
AGENTS.md # operating manual for agents working here
+16 -1
View File
@@ -4,13 +4,28 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "wanderbooks CLI: scaffold new books directories (mise.toml + AGENT.md)."
description = "wanderbooks CLI: one command from nothing to a working books directory."
[[bin]]
name = "wanderbooks"
path = "src/main.rs"
[features]
default = ["gui"]
# The viewer. Off in the server-only build for another machine (plan
# D-22, P9), so a headless machine carries no window libraries.
gui = ["dep:ledger-gui"]
[dependencies]
ledger-mcp = { path = "../ledger-mcp" }
ledger-store = { path = "../ledger-store" }
ledger-gui = { path = "../ledger-gui", optional = true }
[dev-dependencies]
chrono = "0.4"
ledger-config = { path = "../ledger-config" }
ledger-core = { path = "../ledger-core" }
serde_json = "1"
toml = "1"
tempfile = "3"
File diff suppressed because it is too large. Load diff
+390 -54
View File
@@ -1,109 +1,211 @@
//! Integration tests for `wanderbooks init`: the scaffold lands complete,
//! the TOML parses, re-init is refused without `--force`.
//! Integration tests for `wanderbooks new`: one command lands a complete
//! books dir with the MCP server registered for it, the TOML and JSON
//! parse, and re-running is refused without `--force` (which never
//! touches the company profile).
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::Command;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
}
fn run_init(dir: &std::path::Path, extra: &[&str]) -> (bool, String) {
let mut cmd = Command::new(bin());
cmd.arg("init").arg(dir);
for e in extra {
cmd.arg(e);
/// Stand-ins for the installed binaries, so tests never run cargo install.
fn fake_bins(tmp: &Path) -> PathBuf {
let dir = tmp.join("bin");
fs::create_dir_all(&dir).unwrap();
for b in ["ledger-mcp", "ledger-gui"] {
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
}
let out = cmd.output().expect("run wanderbooks");
dir
}
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
let path = format!(
"{}:{}",
fake_bins(tmp).display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(args)
.arg("--no-start")
.env("PATH", path)
.output()
.expect("run wanderbooks");
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), s)
}
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
let mut args = vec!["new", books.to_str().unwrap()];
args.extend(extra);
run(tmp, &args)
}
#[test]
fn scaffold_lands_complete() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("acme-books");
let (ok, _) = run_init(&books, &[]);
assert!(ok, "init should succeed");
for f in ["mise.toml", "AGENT.md", "wanderbooks.toml", ".gitignore"] {
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(ok, "new should succeed: {out}");
for f in [
"justfile",
"AGENTS.md",
"CLAUDE.md",
"wanderbooks.toml",
".gitignore",
".mcp.json",
".claude/settings.json",
".codex/hooks.json",
] {
let p = books.join(f);
assert!(p.exists(), "{f} exists");
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
}
assert!(books.join("books-data").is_dir(), "books-data dir exists");
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&books)
.output()
.unwrap();
assert!(
books.join(".git").is_dir(),
"fresh dir gets git init for the journal archive"
String::from_utf8_lossy(&log.stdout).contains("init"),
"scaffold committed"
);
}
#[test]
fn mise_toml_parses_and_names_repo_and_books() {
fn the_server_is_registered_for_this_dir() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
let (ok, _) = run_init(&books, &[]);
assert!(ok);
let raw = fs::read_to_string(books.join("mise.toml")).unwrap();
let doc: toml::Value = toml::from_str(&raw).expect("mise.toml parses");
assert!(doc.get("tools").is_some(), "[tools] present");
let tasks = doc.get("tasks").expect("[tasks] present");
for t in ["build", "mcp-add", "backup"] {
assert!(tasks.get(t).is_some(), "task {t} present");
assert!(new(tmp.path(), &books, &[]).0);
let books = books.canonicalize().unwrap();
let mcp: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
let server = &mcp["mcpServers"]["wanderbooks"];
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
assert_eq!(
server["args"],
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["enabledMcpjsonServers"],
serde_json::json!(["wanderbooks"])
);
// Every session starts by checking the viewer against open_at_start.
let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
let codex: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap())
.unwrap();
let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("Codex SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
// The justfile opens this dir's books in the GUI.
let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\""));
for recipe in [
"agent:",
"gui:",
"gui-fg:",
"gui-status:",
"session-start:",
"backup:",
"update:",
] {
assert!(just.contains(recipe), "justfile has {recipe}");
}
// The build task must point at the repo checkout; mcp-add at the books dir.
let build_run = tasks["build"]["run"].as_str().unwrap();
assert!(
build_run.contains("cargo build"),
"build runs cargo: {build_run}"
);
let mcp_run = tasks["mcp-add"]["run"].as_str().unwrap();
assert!(
mcp_run.contains("ledger-mcp") && mcp_run.contains("books-data"),
"mcp-add wires the server to this books dir: {mcp_run}"
);
let books_s = books.to_string_lossy();
assert!(
mcp_run.contains(books_s.as_ref()),
"mcp-add references the books dir path"
assert_eq!(
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
"@AGENTS.md"
);
let toml: toml::Value =
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
}
#[test]
fn agent_md_carries_the_contract() {
fn agents_md_carries_the_contract() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
let (ok, _) = run_init(&books, &[]);
assert!(ok);
let md = fs::read_to_string(books.join("AGENT.md")).unwrap();
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"preview",
"Posted is forever",
"reversing entries",
"receipt",
"journal.jsonl",
"onboard",
"chart_template",
"to the cent",
"just gui",
"just gui-status",
"open_at_start",
] {
assert!(md.contains(phrase), "AGENT.md mentions {phrase:?}");
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
}
}
/// The agent reaches the books only through the MCP tools. The scaffolded
/// AGENTS.md once said "the journal is the evidence — read it", and an
/// agent `cat`ed the journal file instead of asking the books.
#[test]
fn agents_md_reaches_the_books_only_through_tools() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"only through the",
"wanderbooks MCP tools",
"Never open, list or parse",
"books.get_entry",
"books.search_entries",
"general_ledger",
"can't answer, say so.",
] {
assert!(md.contains(phrase), "AGENTS.md says {phrase:?}");
}
// Nothing points the agent at the books' files.
for path in ["books-data", "journal.jsonl", "wanderbooks.db", "read it"] {
assert!(!md.contains(path), "AGENTS.md never mentions {path:?}");
}
}
#[test]
fn reinit_refused_without_force() {
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(run_init(&books, &[]).0);
let (ok, out) = run_init(&books, &[]);
assert!(!ok, "second init without --force fails");
assert!(new(tmp.path(), &books, &[]).0);
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(!ok, "second new without --force fails");
assert!(out.contains("--force"), "error points at --force: {out}");
let (ok, _) = run_init(&books, &["--force"]);
assert!(ok, "second init with --force succeeds");
fs::write(
books.join("wanderbooks.toml"),
"[company]\nname = \"Acme LLC\"\n",
)
.unwrap();
let (ok, _) = new(tmp.path(), &books, &["--force"]);
assert!(ok, "second new with --force succeeds");
assert!(
fs::read_to_string(books.join("wanderbooks.toml"))
.unwrap()
.contains("Acme LLC"),
"--force rewrites the scaffold, never the company profile"
);
}
#[test]
fn init_inside_existing_repo_skips_git_init() {
fn init_is_an_alias_and_respects_an_existing_repo() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
fs::create_dir_all(&books).unwrap();
@@ -115,8 +217,242 @@ fn init_inside_existing_repo_skips_git_init() {
.unwrap()
.success()
);
let (ok, _) = run_init(&books, &[]);
assert!(ok);
// Still a valid repo (we didn't nest another one) and scaffold landed.
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
assert!(ok, "{out}");
assert!(books.join("wanderbooks.toml").exists());
}
#[test]
fn codex_gets_its_own_named_server() {
let tmp = tempfile::tempdir().unwrap();
let bin = fake_bins(tmp.path());
let log = tmp.path().join("codex-args");
// A stand-in codex that records how it was called.
fs::write(
bin.join("codex"),
format!("#!/bin/sh\necho \"$@\" > {}\n", log.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755)).unwrap();
let books = tmp.path().join("Acme Books");
let (ok, out) = new(tmp.path(), &books, &["--codex"]);
assert!(ok, "{out}");
let args = fs::read_to_string(&log).unwrap();
let books = books.canonicalize().unwrap();
assert!(
args.starts_with("mcp add wanderbooks-acme-books -- "),
"{args}"
);
assert!(
args.trim_end().ends_with(&format!(
"--data-dir {}",
books.join("books-data").display()
)),
"{args}"
);
let just = fs::read_to_string(books.join("justfile")).unwrap();
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");
}
}
/// Moving the scaffolded settings file, and every example in it, into
/// books: each imports, reads as the file does (the import refuses
/// otherwise), and exporting it back and importing the export changes
/// nothing (plan/served.md P1: toml_round_trip_zero_events).
#[test]
fn the_toml_and_its_examples_import_and_round_trip() {
use ledger_core::settings::{Settings, Source};
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let single_owner = |ex: &str| ex.starts_with("close_into ");
let source = Source {
decided_by: "onboarding".into(),
decided_on: chrono_date(),
citation: None,
note: None,
};
for file in [
text.clone(),
uncomment(&text, |ex| !single_owner(ex)),
uncomment(&text, single_owner),
] {
let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD")
.unwrap_or_else(|e| panic!("imports: {e}\n{file}"));
assert!(import.unknown.is_empty(), "{:?}", import.unknown);
let mut books = Settings::default();
let changes = books.check_changes(&import.changes, &source).unwrap();
books.apply(&changes, &source, 1).unwrap();
let export = ledger_config::codec::export(&books, "USD");
let again = ledger_config::codec::import(&export, None, &books, "USD")
.unwrap_or_else(|e| panic!("the export imports: {e}\n{export}"));
assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes);
}
}
fn chrono_date() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
}
+220
View File
@@ -0,0 +1,220 @@
//! One executable (plan D-22): `wanderbooks` runs the books server
//! (`dev-stdio`) and the viewer (`gui`) as modes of itself, each with its
//! own flags, and the server-only build has no viewer.
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
fn wanderbooks() -> Command {
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
}
/// One JSON-RPC request over the child's stdio, its response parsed.
fn ask(
stdin: &mut std::process::ChildStdin,
stdout: &mut BufReader<std::process::ChildStdout>,
id: u64,
method: &str,
params: Value,
) -> Value {
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
writeln!(stdin, "{msg}").unwrap();
stdin.flush().unwrap();
let mut line = String::new();
stdout.read_line(&mut line).expect("read a response");
let resp: Value = serde_json::from_str(&line).expect("parse the response");
assert_eq!(resp["id"], id, "{resp}");
resp
}
#[test]
fn dev_stdio_is_the_books_server() {
let dir = tempfile::Builder::new()
.prefix("wb-one-exe-")
.tempdir()
.unwrap();
let data = dir.path().join("books").join("books-data");
// An import root may not be inside the books dir (the data dir's
// parent), so it is a sibling of it.
let inbox = dir.path().join("inbox");
std::fs::create_dir(&inbox).unwrap();
let mut child = wanderbooks()
.arg("dev-stdio")
.arg("--data-dir")
.arg(&data)
.arg("--import-root")
.arg(&inbox)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn wanderbooks dev-stdio");
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let init = ask(
&mut stdin,
&mut stdout,
1,
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "one-executable", "version": "0"}}),
);
assert!(init["result"]["serverInfo"].is_object(), "{init}");
writeln!(
stdin,
"{}",
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.unwrap();
let tools = ask(&mut stdin, &mut stdout, 2, "tools/list", json!({}));
let names: Vec<&str> = tools["result"]["tools"]
.as_array()
.expect("a tool list")
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(names.contains(&"books.start"), "{names:?}");
// The books it serves are real books: a fresh store was created.
let tb = ask(
&mut stdin,
&mut stdout,
3,
"tools/call",
json!({"name": "books.trial_balance", "arguments": {}}),
);
assert!(tb.get("error").is_none(), "{tb}");
assert!(data.join("journal.jsonl").is_file());
drop(stdin);
let status = child.wait().unwrap();
assert!(status.success(), "{status}");
}
#[test]
fn each_mode_names_itself_in_its_usage() {
let out = wanderbooks()
.args(["dev-stdio", "--help"])
.output()
.unwrap();
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("usage: wanderbooks dev-stdio [--data-dir PATH]"),
"{err}"
);
let out = wanderbooks().arg("--help").output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
for line in [
"wanderbooks gui [DIR",
"wanderbooks dev-stdio",
"dev-stdio run the books server",
] {
assert!(err.contains(line), "{line:?} missing from {err}");
}
}
#[cfg(feature = "gui")]
#[test]
fn the_viewer_names_itself_in_its_usage() {
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout);
assert!(
text.contains("usage: wanderbooks gui [--data-dir PATH]"),
"{text}"
);
}
#[test]
fn a_mode_refuses_what_it_does_not_know() {
let modes: &[&str] = if cfg!(feature = "gui") {
&["dev-stdio", "gui"]
} else {
&["dev-stdio"]
};
for &mode in modes {
let out = wanderbooks()
.args([mode, "--no-such-flag"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
}
// A mode's flags are its own: the CLI's `--force` means nothing there.
let out = wanderbooks()
.args(["dev-stdio", "--force"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
}
#[test]
fn a_mode_comes_first() {
for mode in ["gui", "dev-stdio"] {
let out = wanderbooks().args(["--codex", mode]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(&format!("`{mode}` must come first")), "{err}");
}
}
#[test]
fn a_server_that_cannot_start_says_why_and_exits_1() {
// A flag missing its value is an error, not a usage slip (exit 2).
let out = wanderbooks()
.args(["dev-stdio", "--data-dir"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1), "{out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("error: --data-dir needs a path"), "{err}");
}
/// With no display to open a window on, the viewer fails and says so
/// (Linux only: elsewhere there is always a display, and a window would
/// open).
#[cfg(all(feature = "gui", target_os = "linux"))]
#[test]
fn a_viewer_that_cannot_open_a_window_exits_1() {
let dir = tempfile::Builder::new()
.prefix("wb-one-exe-")
.tempdir()
.unwrap();
let mut child = wanderbooks()
.args(["gui", "--data-dir"])
.arg(dir.path().join("books-data"))
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
panic!("the viewer neither opened nor failed within a minute");
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
assert_eq!(status.code(), Some(1), "{status}");
let mut err = String::new();
std::io::Read::read_to_string(&mut child.stderr.take().unwrap(), &mut err).unwrap();
assert!(err.contains("error: "), "{err}");
}
#[cfg(not(feature = "gui"))]
#[test]
fn the_server_only_build_has_no_viewer() {
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("the server-only build: it has no viewer"),
"{err}"
);
}
+124
View File
@@ -0,0 +1,124 @@
//! `wanderbooks setup`: the books' private folder, made from the command
//! line, with its location never printed.
#![cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
fn wanderbooks() -> Command {
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
}
fn mode(path: &std::path::Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn setup_makes_the_private_folder_and_never_names_it() {
let home = tempfile::Builder::new()
.prefix("wb-setup-")
.tempdir()
.unwrap();
let root = home.path().join(".local/share/wanderbooks");
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("The books' private folder: made"), "{text}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("--home isn't this account's home"),
"{out:?}"
);
assert!(
!text.contains(home.path().to_str().unwrap()),
"names the folder: {text}"
);
assert!(!String::from_utf8_lossy(&out.stderr).contains(home.path().to_str().unwrap()));
assert_eq!(mode(&root), 0o700);
for sub in ["books", "run", "legacy"] {
assert_eq!(mode(&root.join(sub)), 0o700, "{sub}");
}
// Again: already there.
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&out.stdout).contains("already there."),
"{out:?}"
);
// Others could open it: made private again.
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&out.stdout).contains("made private again"),
"{out:?}"
);
assert_eq!(mode(&root), 0o700);
}
#[test]
fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
let home = tempfile::Builder::new()
.prefix("wb-setup-xdg-")
.tempdir()
.unwrap();
let xdg = home.path().join("xdg");
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env("XDG_DATA_HOME", &xdg)
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("noted for next time"),
"{out:?}"
);
assert_eq!(mode(&xdg.join("wanderbooks")), 0o700);
let noted =
std::fs::read_to_string(home.path().join(".local/share/wanderbooks.location")).unwrap();
assert_eq!(
noted.trim_end(),
xdg.join("wanderbooks").display().to_string()
);
}
#[test]
fn setup_refuses_what_it_does_not_know_and_unsafe_folders() {
let out = wanderbooks().args(["setup", "--bogus"]).output().unwrap();
assert_eq!(out.status.code(), Some(2));
let out = wanderbooks().args(["setup", "--root"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--root needs a path"));
let home = tempfile::Builder::new()
.prefix("wb-setup-link-")
.tempdir()
.unwrap();
let real = home.path().join("real");
std::fs::create_dir(&real).unwrap();
std::os::unix::fs::symlink(&real, home.path().join("link")).unwrap();
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.arg("--root")
.arg(home.path().join("link"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("is a symlink"));
// A mode comes first.
let out = wanderbooks().args(["--force", "setup"]).output().unwrap();
assert_eq!(out.status.code(), Some(2));
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "ledger-config"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Company settings for wanderbooks: the books dir's wanderbooks.toml."
[dependencies]
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
+766
View File
@@ -0,0 +1,766 @@
//! Settings files as text in and out of the books (plan/served.md 5.4).
//!
//! Books keep their settings in the journal (`ledger_core::settings`).
//! A `wanderbooks.toml` is text to import from and export to: the server
//! never reads one once the books hold their settings.
//!
//! ```text
//! GOAL: move a company's settings file into its books without changing
//! how a single setting reads, and write them back out as a file.
//! SOURCE: plan/served.md I-3, 5.4 (toml_round_trip_zero_events); the
//! settings file's own rules (crate::parse).
//! INVARIANTS:
//! 1. An import is exactly the changes that make the books' settings
//! what the file (and config.json's receipt policy, if given) says:
//! keys the file sets and the books don't, or set differently, are
//! set; keys the books set and the file doesn't are cleared. Nothing
//! else.
//! 2. NEVER an import that reads differently than the file: the
//! settings after it give the same company, receipt policy, cash
//! flow roles, asset tag prefix, party policy and time zone that
//! the file gives the server today (crate::parse,
//! crate::effective_receipt_policy_text), or the import is refused.
//! 3. Nothing in the file is dropped silently: what isn't a books
//! setting is a note (where it goes instead) or unknown (not
//! imported; the caller shows it before anything is journaled).
//! 4. Round trip: importing an export changes nothing (identity values
//! are exported masked, and a masked value imports as "unchanged").
//! ```
use crate::{BooksSettings, ReceiptPolicy};
use ledger_core::settings::{self, Change, KEYS, Kind, Sensitivity, Settings, Source};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::fmt::Write as _;
/// What importing a settings file would do.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Import {
/// The changes that make the books' settings what the file says
/// (INVARIANT 1); empty when they already are.
pub changes: Vec<Change>,
/// What the file says that isn't a books setting, and where it goes
/// instead (INVARIANT 3).
pub notes: Vec<String>,
/// Entries the books don't know: not imported (INVARIANT 3).
pub unknown: Vec<String>,
/// `[vault] key_file`, if the file sets one: the server's to use
/// (`--vault-key-file`), not a books setting.
pub vault_key_file: Option<String>,
/// The file's comments, in order: a settings file's notes (where a
/// value came from, what it waits on) are not settings, so they are
/// listed for the caller to keep elsewhere (a convention, a decision, a
/// setting's note) rather than dropped unseen (INVARIANT 3).
pub comments: Vec<String>,
}
/// The comments of TOML text: what follows each `#` outside a string, in
/// order, trimmed, blank ones left out.
pub fn comments(text: &str) -> Vec<String> {
let mut out = Vec::new();
for line in text.lines() {
let mut quote: Option<char> = None;
let mut escaped = false;
for (i, c) in line.char_indices() {
match quote {
Some(q) => {
if escaped {
escaped = false;
} else if c == '\\' && q == '"' {
escaped = true;
} else if c == q {
quote = None;
}
}
None if c == '"' || c == '\'' => quote = Some(c),
None if c == '#' => {
let rest = line[i + 1..].trim();
if !rest.is_empty() {
out.push(rest.to_string());
}
break;
}
None => {}
}
}
}
out
}
/// A TOML value as the JSON a setting takes: strings stay strings; a
/// number, boolean or date in a text setting reads as its TOML spelling;
/// arrays and tables convert element by element.
fn json_of(v: &toml::Value) -> Value {
match v {
toml::Value::String(s) => json!(s),
toml::Value::Integer(i) => json!(i.to_string()),
toml::Value::Float(f) => json!(f.to_string()),
toml::Value::Boolean(b) => json!(b.to_string()),
toml::Value::Datetime(d) => json!(d.to_string()),
toml::Value::Array(a) => Value::Array(a.iter().map(json_of).collect()),
toml::Value::Table(t) => {
Value::Object(t.iter().map(|(k, v)| (k.clone(), json_of(v))).collect())
}
}
}
fn key_at(table: &str, name: &str) -> Option<&'static settings::Key> {
KEYS.iter().find(|k| k.toml == (table, name))
}
/// What importing `text` (a `wanderbooks.toml`) and, if given,
/// `config_json` (its `receipt_policy` wins over the file's receipt
/// settings, as it does for the server) would change in books holding
/// `current`, kept in `base_currency`.
pub fn import(
text: &str,
config_json: Option<&str>,
current: &Settings,
base_currency: &str,
) -> Result<Import, String> {
// The file as the server reads it today: what the import must match.
let legacy = crate::parse(text)?;
let legacy_policy = crate::effective_receipt_policy_text(config_json, &legacy)?;
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut out = Import {
comments: comments(text),
..Import::default()
};
let mut wanted: BTreeMap<&'static str, Value> = BTreeMap::new();
for (section, body) in &table {
let toml::Value::Table(body) = body else {
out.unknown.push(format!("{section} (outside any table)"));
continue;
};
for (name, v) in body {
match (section.as_str(), name.as_str()) {
("company", "base_currency") => {
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
if c != base_currency {
return Err(format!(
"[company] base_currency {c:?}: these books are kept in {base_currency}, fixed when they were created"
));
}
out.notes.push(format!(
"[company] base_currency: the books keep it themselves ({base_currency}, fixed when they were created)"
));
}
("books", "data_dir") => out.notes.push(
"[books] data_dir: where the books live is the server's to know, not a setting; dropped".into(),
),
("vault", "key_file") => {
out.vault_key_file = v.as_str().map(str::to_string);
out.notes.push(
"[vault] key_file: the server's to know (start it with --vault-key-file), not a books setting; dropped".into(),
);
}
("viewer", n) => out.notes.push(format!(
"[viewer] {n}: the agent's preference, not a books setting; dropped (record it as a convention if it matters)"
)),
(s, n) => match key_at(s, n) {
Some(k) => {
wanted.insert(k.name, json_of(v));
}
None => out.unknown.push(format!("[{s}] {n}")),
},
}
}
}
if let Some(raw) = config_json {
let j: Value = serde_json::from_str(raw).map_err(|e| format!("bad config: {e}"))?;
let obj = j.as_object().ok_or("bad config: not a JSON object")?;
for (k, v) in obj {
match k.as_str() {
"receipt_policy" => {
// Its policy replaces the file's, whole (crate::
// effective_receipt_policy_text).
let p: ReceiptPolicy = serde_json::from_value(v.clone())
.map_err(|e| format!("bad config: {e}"))?;
wanted.insert("policy.evidence.threshold", json!(p.require_receipts_above));
wanted.insert(
"policy.evidence.required_accounts",
json!(p.receipt_required_accounts),
);
wanted.insert(
"policy.evidence.exempt_accounts",
json!(p.receipt_exempt_accounts),
);
}
"base_currency" => {
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
if c != base_currency {
return Err(format!(
"config.json base_currency {c:?}: these books are kept in {base_currency}"
));
}
}
other => out.unknown.push(format!("config.json {other}")),
}
}
}
// Canonical values, and the diff against what the books hold. An
// identity value exported masked ("**-***5893") never imports: it is
// what the books hold already when its last four match, else a note,
// and either way the books' own value stays (INVARIANT 4).
let mut canon: BTreeMap<&'static str, Value> = BTreeMap::new();
let mut keep: Vec<&'static str> = Vec::new();
for (name, v) in &wanted {
let k = settings::key(name).expect("taken from KEYS");
let masked =
k.sensitivity == Sensitivity::Identity && v.as_str().is_some_and(|s| s.contains('*'));
if masked {
let same = current.get(name).is_some_and(|r| {
v.as_str() == Some(settings::display(k, &r.value, false).as_str())
});
if !same {
out.notes.push(format!(
"[{}] {}: masked in this file, so not imported; set it with books.set_setting",
k.toml.0, k.toml.1
));
}
keep.push(k.name);
continue;
}
let value =
settings::check(k, v).map_err(|e| format!("[{}] {}: {e}", k.toml.0, k.toml.1))?;
// An empty list is the same as no list: not a change.
if value.as_array().is_some_and(Vec::is_empty) && k.kind != Kind::Members {
continue;
}
canon.insert(k.name, value);
}
for k in KEYS {
let have = current.get(k.name).map(|r| &r.value);
match (canon.get(k.name), have) {
(Some(want), Some(have)) if want == have => {}
(Some(want), _) => out.changes.push(Change {
key: k.name.into(),
value: Some(want.clone()),
}),
(None, Some(_)) if keep.contains(&k.name) => {}
(None, Some(_)) => out.changes.push(Change {
key: k.name.into(),
value: None,
}),
(None, None) => {}
}
}
// INVARIANT 2: the books, after the import, read as the file does.
let mut next = current.clone();
let probe = Source {
decided_by: "import".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("a date"),
citation: None,
note: None,
};
next.apply(&out.changes, &probe, u64::MAX)?;
next.check_whole()?;
let ours = books_settings(&next, base_currency);
let our_policy = receipt_policy(&next);
let differs = differences(&legacy, &legacy_policy, &ours, &our_policy);
if !differs.is_empty() {
return Err(format!(
"importing would read these settings differently than the file does: {}",
differs.join(", ")
));
}
Ok(out)
}
/// Where two readings of the settings differ, by field name.
fn differences(
a: &BooksSettings,
ap: &ReceiptPolicy,
b: &BooksSettings,
bp: &ReceiptPolicy,
) -> Vec<&'static str> {
let (ca, cb) = (&a.company, &b.company);
let threshold = |p: &ReceiptPolicy| crate::decimal(p.require_receipts_above.trim());
[
("company name", ca.name == cb.name),
(
"fiscal year start",
ca.fiscal_year_start == cb.fiscal_year_start,
),
("members", ca.members == cb.members),
("accounting basis", ca.basis == cb.basis),
("tax form", ca.tax_form == cb.tax_form),
("close_into", ca.close_into == cb.close_into),
("close_sweep", ca.close_sweep == cb.close_sweep),
("receipt threshold", threshold(ap) == threshold(bp)),
(
"receipt required accounts",
ap.receipt_required_accounts == bp.receipt_required_accounts,
),
(
"receipt exempt accounts",
ap.receipt_exempt_accounts == bp.receipt_exempt_accounts,
),
("cash accounts", a.cash_accounts == b.cash_accounts),
(
"financing accounts",
a.financing_accounts == b.financing_accounts,
),
(
"investing accounts",
a.investing_accounts == b.investing_accounts,
),
(
"asset tag prefix",
a.asset_tag_prefix() == b.asset_tag_prefix(),
),
("parties", a.parties == b.parties),
("time zone", a.timezone == b.timezone),
]
.into_iter()
.filter(|(_, same)| !same)
.map(|(n, _)| n)
.collect()
}
/// The settings as the server and the viewer use them, from the books'
/// own (`ledger_core::settings`): what [`crate::parse`] gives for a
/// settings file. `[vault] key_file` is never a books setting.
pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings {
BooksSettings {
company: s.company(base_currency),
receipt_threshold: s.receipt_threshold().map(str::to_string),
receipt_required_accounts: s.receipt_required_accounts(),
receipt_exempt_accounts: s.receipt_exempt_accounts(),
cash_accounts: s.cash_accounts(),
financing_accounts: s.financing_accounts(),
investing_accounts: s.investing_accounts(),
asset_tag_prefix: s.asset_tag_prefix().map(str::to_string),
parties: s.parties(),
vault_key_file: None,
timezone: s.timezone().map(str::to_string),
}
}
/// The receipt policy the books' own settings set.
pub fn receipt_policy(s: &Settings) -> ReceiptPolicy {
ReceiptPolicy {
require_receipts_above: s
.receipt_threshold()
.unwrap_or(settings::DEFAULT_THRESHOLD)
.to_string(),
receipt_required_accounts: s.receipt_required_accounts(),
receipt_exempt_accounts: s.receipt_exempt_accounts(),
}
}
/// One TOML line `name = value`, the value spelled as TOML.
fn toml_line(name: &str, v: &Value) -> String {
let tv = match v {
Value::String(s) => toml::Value::String(s.clone()),
Value::Array(a) => toml::Value::Array(
a.iter()
.map(|x| toml::Value::String(x.as_str().unwrap_or_default().to_string()))
.collect(),
),
other => toml::Value::String(other.to_string()),
};
let mut t = toml::Table::new();
t.insert(name.to_string(), tv);
toml::to_string(&t)
.unwrap_or_default()
.trim_end()
.to_string()
}
fn provenance(out: &mut String, r: &settings::Recorded) {
let s = &r.source;
let _ = writeln!(
out,
"# {}, {} (journal line {})",
s.decided_by, s.decided_on, r.seq
);
for extra in [&s.citation, &s.note].into_iter().flatten() {
for line in extra.lines() {
let _ = writeln!(out, "# {line}");
}
}
}
/// The books' settings as a settings file (`wanderbooks.toml` text):
/// every setting set, under its table, with who decided it and when as a
/// comment; identity values masked (INVARIANT 4).
pub fn export(s: &Settings, base_currency: &str) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"# wanderbooks settings, exported from the books{}.",
s.last_seq()
.map(|q| format!(" (through journal line {q})"))
.unwrap_or_default()
);
out.push_str(
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing until it is imported (books.import_settings).\n",
);
for table in ["company", "books"] {
let _ = write!(out, "\n[{table}]\n");
if table == "company" {
let _ = writeln!(
out,
"# Fixed when the books were created.\nbase_currency = {:?}",
base_currency
);
}
let mut members = None;
for (k, r) in s.iter().filter(|(k, _)| k.toml.0 == table) {
if k.kind == Kind::Members {
members = Some((k, r));
continue;
}
provenance(&mut out, r);
let shown = match k.sensitivity {
Sensitivity::Identity => Value::String(settings::display(k, &r.value, false)),
Sensitivity::Public => r.value.clone(),
};
let _ = writeln!(out, "{}", toml_line(k.toml.1, &shown));
}
if let Some((_, r)) = members {
let _ = writeln!(out);
provenance(&mut out, r);
for m in r.value.as_array().into_iter().flatten() {
out.push_str("[[company.members]]\n");
for f in [
"name",
"share",
"capital_account",
"distribution_account",
"files_jointly_with",
] {
if let Some(v) = m.get(f) {
let _ = writeln!(out, "{}", toml_line(f, v));
}
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const WANDERLAND: &str = r#"
[company]
name = "Wanderland Labs LLC"
entity_type = "LLC, multi-member, member-managed"
tax_classification = "partnership"
base_currency = "USD"
fiscal_year_start = "01-01"
formation_state = "UT"
formed = "2026-10-04"
state_entity_number = "14745117-0160"
ein = "43-2215893"
irs_name_control = "WALN"
duns = "149976972"
address = "15281 S Tall Woods Dr, Draper, UT 84020"
registered_agent = "Stephen Waits"
partnership_representative = "Stephen Waits"
cpa = "Krista Baker"
[[company.members]]
name = "Stephen Waits"
share = "50%"
capital_account = "3000"
files_jointly_with = "Katherine Pendergast"
[[company.members]]
name = "Katherine Pendergast"
share = "50%"
capital_account = "3001"
files_jointly_with = "Stephen Waits"
[books]
data_dir = "books-data"
accounting_basis = "accrual"
receipt_threshold = "0.00"
cash_accounts = ["1000"]
financing_accounts = ["2100"]
parties = "require"
[viewer]
open_at_start = "ask"
"#;
fn src() -> Source {
Source {
decided_by: "Owner".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: None,
note: Some("imported".into()),
}
}
fn imported(text: &str) -> Settings {
let i = import(text, None, &Settings::default(), "USD").unwrap();
let mut s = Settings::default();
let changes = s.check_changes(&i.changes, &src()).unwrap();
s.apply(&changes, &src(), 1).unwrap();
s
}
/// INVARIANTS 1-3 on a real company's file.
#[test]
fn a_real_file_imports_whole() {
let i = import(WANDERLAND, None, &Settings::default(), "USD").unwrap();
assert!(i.unknown.is_empty(), "{:?}", i.unknown);
assert_eq!(i.notes.len(), 3, "{:?}", i.notes);
// 14 company facts, the members, and 5 [books] settings.
assert_eq!(i.changes.len(), 20);
let s = imported(WANDERLAND);
assert_eq!(s.get("company.ein").unwrap().value, json!("43-2215893"));
assert_eq!(s.parties(), crate::PartyPolicy::Require);
// Already imported: nothing to change.
let again = import(WANDERLAND, None, &s, "USD").unwrap();
assert!(again.changes.is_empty(), "{:?}", again.changes);
}
/// INVARIANT 4: an export imports as no change at all.
#[test]
fn export_round_trips() {
let s = imported(WANDERLAND);
let text = export(&s, "USD");
assert!(text.contains("ein = \"**-***5893\""), "{text}");
assert!(!text.contains("2215893"), "{text}");
let back = import(&text, None, &s, "USD").unwrap();
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
assert!(back.unknown.is_empty(), "{:?}", back.unknown);
// And the export reads, as a settings file, as the books do.
assert_eq!(
crate::parse(&text).unwrap().company.name,
"Wanderland Labs LLC"
);
}
/// INVARIANT 1: keys the file leaves out are cleared.
#[test]
fn what_the_file_leaves_out_is_cleared() {
let s = imported(WANDERLAND);
let i = import(
"[company]\nname = \"Wanderland Labs LLC\"\n",
None,
&s,
"USD",
)
.unwrap();
assert!(
i.changes
.iter()
.any(|c| c.key == "company.members" && c.value.is_none())
);
assert!(!i.changes.iter().any(|c| c.key == "company.name"));
}
/// INVARIANT 3, and the currency is the books'.
#[test]
fn unknown_and_currency() {
let i = import(
"[company]\nwebsite = \"x\"\n[extra]\na = 1\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert_eq!(i.unknown, ["[company] website", "[extra] a"]);
assert!(
import(
"[company]\nbase_currency = \"EUR\"\n",
None,
&Settings::default(),
"USD"
)
.is_err()
);
}
/// config.json's receipt policy wins, whole, as it does for the server.
#[test]
fn config_json_policy_wins() {
let j = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
let i = import(
"[books]\nreceipt_threshold = \"0.00\"\n",
Some(j),
&Settings::default(),
"USD",
)
.unwrap();
let mut s = Settings::default();
s.apply(&i.changes, &src(), 1).unwrap();
let p = receipt_policy(&s);
assert_eq!(p.require_receipts_above, "10.00");
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
}
/// INVARIANT 2: a value the books would read differently is refused
/// (here: a time zone the file names that isn't one).
#[test]
fn never_reads_differently() {
assert!(
import(
"[company]\ntimezone = \"Nowhere/Land\"\n",
None,
&Settings::default(),
"USD"
)
.is_err()
);
assert!(
import(
"[company]\nfiscal_year_start = \"07-01\"\n",
None,
&Settings::default(),
"USD"
)
.is_ok()
);
}
/// A comment is what follows a `#` outside a string: a `#` inside a
/// basic string (even after an escaped quote) or a literal string
/// (where a backslash escapes nothing) is text; blank comments are
/// left out.
#[test]
fn comments_are_what_follows_a_hash_outside_strings() {
let text = "# top\n\
name = \"a # not\" # one\n\
esc = \"q \\\" # still\" # two\n\
lit = 'c:\\' # three\n\
#\n\
x = 1 # \n\
y = 2#five\n";
assert_eq!(comments(text), ["top", "one", "two", "three", "five"]);
assert!(comments("a = 'x' \"y\"\n").is_empty());
}
/// The import lists the file's comments (INVARIANT 3).
#[test]
fn an_import_lists_the_comments() {
let i = import(
"# Kept settings\n[company]\nname = \"X LLC\" # from the certificate\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert_eq!(i.comments, ["Kept settings", "from the certificate"]);
}
/// config.json's base_currency must be the books'.
#[test]
fn config_json_currency_is_the_books() {
let ok = import(
"",
Some(r#"{"base_currency": " usd "}"#),
&Settings::default(),
"USD",
)
.unwrap();
assert!(ok.changes.is_empty() && ok.unknown.is_empty(), "{ok:?}");
let err = import(
"",
Some(r#"{"base_currency": "EUR"}"#),
&Settings::default(),
"USD",
)
.unwrap_err();
assert!(err.contains("config.json base_currency \"EUR\""), "{err}");
}
/// INVARIANT 4: a masked identity value never imports. It keeps the
/// books' own value either way, with a note only when its last four
/// differ from what the books hold.
#[test]
fn a_masked_identity_keeps_the_books_value() {
let s = imported(WANDERLAND);
let masked =
|ein: &str| WANDERLAND.replace("ein = \"43-2215893\"", &format!("ein = \"{ein}\""));
let note =
"[company] ein: masked in this file, so not imported; set it with books.set_setting";
let same = import(&masked("**-***5893"), None, &s, "USD").unwrap();
assert!(same.changes.is_empty(), "{:?}", same.changes);
assert!(!same.notes.iter().any(|n| n == note), "{:?}", same.notes);
let other = import(&masked("**-***0000"), None, &s, "USD").unwrap();
assert!(other.changes.is_empty(), "{:?}", other.changes);
assert!(other.notes.iter().any(|n| n == note), "{:?}", other.notes);
// Into books that hold none: still not imported, and noted.
let empty = import(
"[company]\nein = \"**-***5893\"\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert!(empty.changes.is_empty(), "{:?}", empty.changes);
assert_eq!(empty.notes, [note]);
}
/// An empty list is no list: not a change.
#[test]
fn an_empty_list_is_no_change() {
let i = import(
"[books]\ncash_accounts = []\nfinancing_accounts = []\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert!(i.changes.is_empty(), "{:?}", i.changes);
}
/// A value the file changes is a change, exactly that one.
#[test]
fn a_changed_value_is_one_change() {
let s = imported(WANDERLAND);
let text = WANDERLAND.replace("\"Wanderland Labs LLC\"", "\"Other Labs LLC\"");
let i = import(&text, None, &s, "USD").unwrap();
assert_eq!(
i.changes,
[Change {
key: "company.name".into(),
value: Some(json!("Other Labs LLC")),
}]
);
}
/// INVARIANT 2's check names each field read differently.
#[test]
fn differences_name_each_field() {
let a = BooksSettings::default();
let p = ReceiptPolicy::default();
assert!(differences(&a, &p, &a, &p).is_empty());
let mut b = a.clone();
b.company.name = "Other".into();
b.timezone = Some("America/Denver".into());
let mut q = p.clone();
q.require_receipts_above = format!("{}0", p.require_receipts_above);
// The default asset tag prefix is made from the company's name.
let named = ["company name", "asset tag prefix", "time zone"];
assert_eq!(differences(&a, &p, &b, &q), named);
q.require_receipts_above = "1.00".into();
assert_eq!(
differences(&a, &p, &b, &q),
[
"company name",
"receipt threshold",
"asset tag prefix",
"time zone"
]
);
}
/// An export says, for each setting, who decided it, when, and its
/// note.
#[test]
fn an_export_keeps_each_settings_source() {
let text = export(&imported(WANDERLAND), "USD");
assert!(
text.contains("\n# Owner, 2026-10-17 (journal line 1)\n# imported\n"),
"{text}"
);
}
}
+728
View File
@@ -0,0 +1,728 @@
//! Company settings from the books dir's `wanderbooks.toml`.
//!
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
//! fills in `[company]`. The server reads the company name (report headers),
//! the fiscal year start (fiscal-year-to-date reports), the members of a
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
//! for the statement of members' equity), `[books] accounting_basis`
//! (report footers), and `[books] receipt_threshold` (the receipt policy,
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before
//! the next start, so the two can't disagree.
pub mod codec;
pub mod receipt_policy;
pub use ledger_core::settings::{Company, Member, PartyPolicy, decimal};
pub use receipt_policy::ReceiptPolicy;
use rust_decimal::Decimal;
use serde::Deserialize;
use std::path::Path;
/// What the server takes from `wanderbooks.toml`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BooksSettings {
/// `[company]`.
pub company: Company,
/// `[books] receipt_threshold`, validated as a decimal string.
pub receipt_threshold: Option<String>,
/// `[books] receipt_required_accounts`: account codes whose entries
/// always need a receipt (unless `config.json` sets a policy).
pub receipt_required_accounts: Vec<String>,
/// `[books] receipt_exempt_accounts`: account codes exempt from the
/// receipt rule (unless `config.json` sets a policy).
pub receipt_exempt_accounts: Vec<String>,
/// `[books] cash_accounts`: account codes that hold cash (bank,
/// 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>,
/// `[company] timezone`: the company's time zone (an IANA name), what
/// "today" means for the books. Unset: the computer's.
pub timezone: Option<String>,
}
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)]
struct BooksToml {
#[serde(default)]
company: CompanyToml,
#[serde(default)]
books: BooksSection,
#[serde(default)]
vault: VaultSection,
}
#[derive(Default, Deserialize)]
struct VaultSection {
key_file: Option<String>,
}
#[derive(Default, Deserialize)]
struct CompanyToml {
name: Option<String>,
fiscal_year_start: Option<String>,
tax_form: Option<String>,
tax_classification: Option<String>,
timezone: Option<String>,
#[serde(default)]
members: Vec<MemberToml>,
}
#[derive(Deserialize)]
struct MemberToml {
name: String,
share: String,
capital_account: Option<String>,
distribution_account: Option<String>,
files_jointly_with: Option<String>,
}
#[derive(Default, Deserialize)]
struct BooksSection {
receipt_threshold: Option<String>,
#[serde(default)]
receipt_required_accounts: Vec<String>,
#[serde(default)]
receipt_exempt_accounts: Vec<String>,
accounting_basis: Option<String>,
close_into: Option<String>,
#[serde(default)]
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>,
}
/// Parse a share: "50%" or "0.5".
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
let s = s.trim();
let bad = || format!("member {name}: share {s:?} must be like \"50%\" or \"0.5\"");
let share = match s.strip_suffix('%') {
Some(pct) => pct.trim().parse::<Decimal>().map_err(|_| bad())? / Decimal::from(100),
None => s.parse::<Decimal>().map_err(|_| bad())?,
};
if share <= Decimal::ZERO || share > Decimal::ONE {
return Err(bad());
}
Ok(share)
}
/// Parse `"MM-DD"`: 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())?);
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()
}
/// 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> {
let raw = if config_json.exists() {
Some(
std::fs::read_to_string(config_json)
.map_err(|e| format!("{}: {e}", config_json.display()))?,
)
} else {
None
};
effective_receipt_policy_text(raw.as_deref(), settings).map_err(|e| {
e.replace(
"bad config",
&format!("bad config {}", config_json.display()),
)
})
}
/// [`effective_receipt_policy`] from `config.json`'s text (`None`: no
/// such file).
pub fn effective_receipt_policy_text(
config_json: Option<&str>,
settings: &BooksSettings,
) -> Result<ReceiptPolicy, String> {
#[derive(Deserialize)]
struct ConfigJson {
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
}
let from_json = match config_json {
Some(raw) => {
serde_json::from_str::<ConfigJson>(raw)
.map_err(|e| format!("bad config: {e}"))?
.receipt_policy
}
None => None,
};
let policy = match from_json {
Some(p) => p,
None => ReceiptPolicy {
require_receipts_above: settings
.receipt_threshold
.clone()
.unwrap_or_else(|| ledger_core::settings::DEFAULT_THRESHOLD.to_string()),
receipt_required_accounts: settings.receipt_required_accounts.clone(),
receipt_exempt_accounts: settings.receipt_exempt_accounts.clone(),
},
};
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();
if let Some(name) = raw.company.name.filter(|n| !n.trim().is_empty()) {
company.name = name.trim().to_string();
}
if let Some(fy) = raw.company.fiscal_year_start {
company.fiscal_year_start = parse_month_day(fy.trim())?;
}
for m in raw.company.members {
let share = parse_share(&m.name, &m.share)?;
let capital_account = m
.capital_account
.filter(|c| !c.trim().is_empty())
.ok_or_else(|| format!("member {}: capital_account is required", m.name))?;
company.members.push(Member {
name: m.name.trim().to_string(),
share,
capital_account: capital_account.trim().to_string(),
distribution_account: m
.distribution_account
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty()),
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 {
return Err(format!("member shares sum to {total}, not 100%"));
}
}
company.tax_form =
match (&raw.company.tax_form, &raw.company.tax_classification) {
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
})?),
(None, Some(c)) => Some(ledger_core::settings::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() {
return Err(
"close_into is for single-owner businesses; with [[company.members]] a close goes to each member's capital account"
.to_string(),
);
}
company.close_into = 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(),
"cash" => "Cash".to_string(),
other => {
return Err(format!(
"accounting_basis {other:?}: expected accrual or cash"
));
}
};
}
let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => {
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,
};
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
if let Some(toml::Value::Table(c)) = table.get("company") {
company.profile = c
.iter()
.filter_map(|(k, v)| {
let s = match v {
toml::Value::String(s) => s.clone(),
toml::Value::Integer(i) => i.to_string(),
toml::Value::Float(f) => f.to_string(),
toml::Value::Boolean(b) => b.to_string(),
toml::Value::Datetime(d) => d.to_string(),
_ => return None,
};
Some((k.clone(), s))
})
.collect();
}
let timezone = match raw.company.timezone {
Some(z) => {
let key = ledger_core::settings::key("company.timezone").expect("a known key");
let v = ledger_core::settings::check(key, &toml_json(&z))?;
v.as_str().map(str::to_string)
}
None => None,
};
Ok(BooksSettings {
company,
receipt_threshold,
receipt_required_accounts: codes(
"receipt_required_accounts",
raw.books.receipt_required_accounts,
)?,
receipt_exempt_accounts: codes(
"receipt_exempt_accounts",
raw.books.receipt_exempt_accounts,
)?,
timezone,
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\""
));
}
},
})
}
fn toml_json(s: &str) -> serde_json::Value {
serde_json::Value::String(s.to_string())
}
/// Load `wanderbooks.toml` from a path.
pub fn load(path: &Path) -> Result<BooksSettings, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
/// 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()
}
#[test]
fn calendar_year_by_default() {
let c = parse("").unwrap().company;
assert_eq!(c.fiscal_year_start, (1, 1));
assert_eq!(
c.fiscal_year_containing(d(2026, 10, 11)),
(d(2026, 1, 1), d(2026, 12, 31))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 1, 1)),
(d(2026, 1, 1), d(2026, 12, 31))
);
}
#[test]
fn a_july_fiscal_year_spans_two_calendar_years() {
let c = parse("[company]\nfiscal_year_start = \"07-01\"\n")
.unwrap()
.company;
assert_eq!(
c.fiscal_year_containing(d(2026, 3, 15)),
(d(2025, 7, 1), d(2026, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 7, 1)),
(d(2026, 7, 1), d(2027, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(d(2026, 6, 30)),
(d(2025, 7, 1), d(2026, 6, 30))
);
}
#[test]
fn reads_the_scaffolded_file() {
let s = parse(
"[company]\nname = \"Wanderland Labs LLC\"\nentity_type = \"x\"\nfiscal_year_start = \"01-01\"\n\n[books]\ndata_dir = \"books-data\"\nreceipt_threshold = \"25.00\"\n",
)
.unwrap();
assert_eq!(s.company.name, "Wanderland Labs LLC");
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
// An empty name (fresh scaffold) keeps the placeholder.
assert_eq!(
parse("[company]\nname = \"\"\n").unwrap().company.name,
"Unnamed company"
);
}
#[test]
fn 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\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n",
)
.unwrap();
assert_eq!(
s.company.profile,
[
("name".to_string(), "Acme LLC".to_string()),
("ein".to_string(), "12-3456789".to_string()),
("formed".to_string(), "2026-10-04".to_string()),
]
);
assert_eq!(s.cash_accounts, ["1000", "1010"]);
assert_eq!(s.financing_accounts, ["2100"]);
assert_eq!(s.investing_accounts, ["1400"]);
}
#[test]
fn reads_members_and_basis() {
let s = parse(
"[company]\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"0.5\"\ncapital_account = \"3001\"\n[books]\naccounting_basis = \"cash\"\n",
)
.unwrap();
let half = Decimal::new(5, 1);
assert_eq!(
s.company.members,
vec![
Member {
name: "Stephen Waits".into(),
share: half,
capital_account: "3000".into(),
distribution_account: None,
files_jointly_with: None,
},
Member {
name: "Katherine Pendergast".into(),
share: half,
capital_account: "3001".into(),
distribution_account: None,
files_jointly_with: None,
},
]
);
assert_eq!(s.company.basis, "Cash");
assert_eq!(parse("").unwrap().company.basis, "Accrual");
// Shares must add up; every member needs a capital account.
let two = |a: &str, b: &str| {
format!(
"[company]\n[[company.members]]\nname = \"A\"\nshare = \"{a}\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"{b}\"\ncapital_account = \"3001\"\n"
)
};
assert!(parse(&two("50%", "40%")).is_err());
assert!(parse(&two("60%", "40%")).is_ok());
assert!(parse("[company]\n[[company.members]]\nname = \"A\"\nshare = \"100%\"\n").is_err());
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
}
#[test]
fn tax_form_from_setting_or_classification() {
use ledger_core::TaxForm;
let form = |toml: &str| parse(toml).unwrap().company.tax_form;
assert_eq!(
form("[company]\ntax_form = \"1120s\"\n"),
Some(TaxForm::Form1120S)
);
assert_eq!(
form("[company]\ntax_classification = \"partnership\"\n"),
Some(TaxForm::Form1065)
);
assert_eq!(
form("[company]\ntax_classification = \"S corporation\"\n"),
Some(TaxForm::Form1120S)
);
assert_eq!(
form("[company]\ntax_classification = \"disregarded entity\"\n"),
Some(TaxForm::ScheduleC)
);
assert_eq!(
form("[company]\ntax_classification = \"partnership\"\ntax_form = \"1120\"\n"),
Some(TaxForm::Form1120)
);
assert_eq!(form(""), None);
assert!(parse("[company]\ntax_form = \"990\"\n").is_err());
}
#[test]
fn rejects_bad_settings() {
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
assert!(parse("[company]\nfiscal_year_start = \"13-01\"\n").is_err());
assert!(parse("[company]\nfiscal_year_start = \"Jan 1\"\n").is_err());
assert!(parse("[books]\nreceipt_threshold = \"25 dollars\"\n").is_err());
}
}
+513
View File
@@ -0,0 +1,513 @@
//! 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). A
/// draft's lines are as journaled, of any size (rules.md B-28): a total
/// past exact arithmetic is refused (TooLargeToCompute), never a panic.
pub fn entry_debits(entry: &JournalEntry) -> Result<Decimal, ledger_core::LedgerError> {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount))
.ok_or_else(|| {
ledger_core::LedgerError::TooLargeToCompute(format!(
"entry {}'s debits",
entry.id()
))
})
}
/// True when posting this entry requires at least one receipt. Debits
/// past exact arithmetic are past any threshold.
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();
let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX);
self.receipt_required_for(&refs, debits)
}
/// 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 in force, phrased for the agent's operating
/// instructions, exactly: the threshold as configured, in the books'
/// base currency (`currency`, e.g. "USD"), never rounded; the accounts
/// that always need a receipt and those exempt; and that reversals
/// need none (the original carries the evidence).
pub fn rule(&self, currency: &str) -> String {
self.rule_listing(currency, RULE_CODES)
}
/// [`Self::rule`], listing at most `codes` account codes of each kind
/// (the rest counted, books.settings listing them); with `codes` 0,
/// only how many accounts of each kind there are, and where they are
/// listed: for a text with a budget.
pub fn rule_listing(&self, currency: &str, codes: usize) -> String {
let mut rule = 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(),
plain_code(currency)
)
};
let (required, exempt) = (
self.receipt_required_accounts.len(),
self.receipt_exempt_accounts.len(),
);
if codes == 0 && required + exempt > 0 {
rule.push_str(&format!(
"; books.settings lists {required} account(s) whose entries always need one and {exempt} whose entries need none"
));
} else {
if required > 0 {
rule.push_str(&format!(
"; so does any entry touching {}, whatever its amount",
accounts(&self.receipt_required_accounts, codes)
));
}
if exempt > 0 {
rule.push_str(&format!(
"; an entry whose every line is in {} needs none{}",
accounts(&self.receipt_exempt_accounts, codes),
if required == 0 {
""
} else {
" (unless it touches a required one)"
}
));
}
}
rule.push_str("; reversals need none (the original carries the evidence)");
rule
}
/// 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> {
// Debits past exact arithmetic are past the threshold too.
let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX);
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());
// A sum past exact arithmetic is past the threshold.
total = Self::entry_debits(other)
.ok()
.and_then(|d| total.checked_add(d))
.unwrap_or(Decimal::MAX);
}
if !group.is_empty() && total > self.threshold() {
group
} else {
Vec::new()
}
}
}
/// The most account codes [`ReceiptPolicy::rule`] lists, and the most
/// characters of each: the rule goes into a connect text with a budget.
const RULE_CODES: usize = 6;
const RULE_CODE_CHARS: usize = 12;
/// A code as plain text for the rule: no control or format characters
/// (a code comes from the settings file, and must not carry text that
/// reads as the server's own), cut on a character.
fn plain_code(code: &str) -> String {
let plain: String = code
.chars()
.filter(|c| !c.is_control() && !c.is_whitespace() && !is_format_char(*c))
.collect();
if plain.chars().count() > RULE_CODE_CHARS {
let cut: String = plain.chars().take(RULE_CODE_CHARS - 1).collect();
format!("{cut}…")
} else {
plain
}
}
/// "account 6000", "accounts 6000, 6010", "accounts 6000, ... and 3 more
/// (books.settings lists them)": at most `max` codes, `max` at least 1.
fn accounts(codes: &[String], max: usize) -> String {
let max = max.max(1);
let shown: Vec<String> = codes.iter().take(max).map(|c| plain_code(c)).collect();
let more = codes.len().saturating_sub(max);
let list = shown.join(", ");
match (codes.len(), more) {
(1, _) => format!("account {list}"),
(_, 0) => format!("accounts {list}"),
_ => format!("accounts {list} and {more} more (books.settings lists them)"),
}
}
/// Whether `c` is a Unicode format character (general category Cf):
/// invisible, but it reorders or joins the text around it (bidi
/// overrides, zero-width spaces and joiners, the byte-order mark).
/// SOURCE: Unicode 15.1, UnicodeData.txt, category Cf.
pub fn is_format_char(c: char) -> bool {
matches!(
c as u32,
0x00AD
| 0x0600..=0x0605
| 0x061C
| 0x06DD
| 0x070F
| 0x0890..=0x0891
| 0x08E2
| 0x180E
| 0x200B..=0x200F
| 0x202A..=0x202E
| 0x2060..=0x2064
| 0x2066..=0x206F
| 0xFEFF
| 0xFFF9..=0xFFFB
| 0x110BD
| 0x110CD
| 0x13430..=0x1343F
| 0x1BCA0..=0x1BCA3
| 0x1D173..=0x1D17A
| 0xE0001
| 0xE0020..=0xE007F
)
}
#[cfg(test)]
mod rule_tests {
use super::*;
fn policy(t: &str, required: &[&str], exempt: &[&str]) -> ReceiptPolicy {
ReceiptPolicy {
require_receipts_above: t.to_string(),
receipt_required_accounts: required.iter().map(|s| s.to_string()).collect(),
receipt_exempt_accounts: exempt.iter().map(|s| s.to_string()).collect(),
}
}
/// The rule the agent is told is the rule enforced: the threshold
/// exactly as configured (0.001 stays 0.001, never "$0.00"), in the
/// base currency; the required and exempt accounts; reversals exempt.
#[test]
fn the_rule_told_is_the_rule_enforced() {
let p = policy("0.001", &[], &[]);
assert_eq!(
p.rule("EUR"),
"any entry moving more than 0.001 EUR in debits needs at least one receipt; \
reversals need none (the original carries the evidence)"
);
// And it is the threshold receipt_required_for applies.
assert!(!p.receipt_required_for(&["6000"], Decimal::new(1, 3)));
assert!(p.receipt_required_for(&["6000"], Decimal::new(2, 3)));
let p = policy("25.00", &["6000"], &["1500", "1510"]);
let rule = p.rule("USD");
for must in [
"more than 25.00 USD in debits",
"so does any entry touching account 6000, whatever its amount",
"an entry whose every line is in accounts 1500, 1510 needs none (unless it touches a required one)",
"reversals need none",
] {
assert!(rule.contains(must), "{must:?}: {rule}");
}
assert!(!rule.contains('$'), "{rule}");
assert_eq!(
policy("0.00", &[], &[]).rule("USD"),
"every entry needs at least one receipt; reversals need none (the original carries the evidence)"
);
}
/// A long list is cut, saying books.settings lists them (it does,
/// every code uncut, in receipt_required_accounts and
/// receipt_exempt_accounts: ledger-mcp's
/// every_receipt_account_left_out_of_the_connect_text_is_in_books_settings),
/// and a code carries no control or format characters.
#[test]
fn a_long_or_odd_account_list_stays_short_and_plain() {
let many: Vec<String> = (0..20).map(|i| format!("{}", 6000 + i)).collect();
let refs: Vec<&str> = many.iter().map(String::as_str).collect();
let rule = policy("1", &refs, &[]).rule("USD");
assert!(
rule.contains("6005 and 14 more (books.settings lists them)"),
"{rule}"
);
assert!(!rule.contains("6006"), "{rule}");
let rule = policy("1", &refs, &refs[..2]).rule_listing("USD", 0);
assert!(
rule.contains(
"; books.settings lists 20 account(s) whose entries always need one and 2 whose entries need none;"
),
"{rule}"
);
let rule = policy("1", &["60\n00\u{202e}\u{200b}XYZ-VERY-LONG-CODE"], &[]).rule("U\nSD");
assert!(
!rule.chars().any(|c| c.is_control() || is_format_char(c)),
"{rule:?}"
);
assert!(rule.contains("account 6000XYZ-VER…"), "{rule}");
assert!(rule.contains("1 USD"), "{rule}");
// A code of exactly the most characters is kept whole; one more
// is cut.
let twelve = "ABCDEFGHIJKL";
assert_eq!(twelve.chars().count(), RULE_CODE_CHARS);
let rule = policy("1", &[twelve], &[]).rule("USD");
assert!(
rule.contains(&format!("account {twelve}, whatever")),
"{rule}"
);
let rule = policy("1", &["ABCDEFGHIJKLM"], &[]).rule("USD");
assert!(rule.contains("account ABCDEFGHIJK…, whatever"), "{rule}");
}
/// The short form (no codes) counts each kind of account, whichever
/// kinds there are, and says nothing of accounts when there are none.
#[test]
fn the_short_rule_counts_each_kind_of_account() {
for (required, exempt, counts) in [
(&["6000"][..], &[][..], Some((1, 0))),
(&[][..], &["1500", "1510"][..], Some((0, 2))),
(&["6000", "6010"][..], &["1500"][..], Some((2, 1))),
(&[][..], &[][..], None),
] {
let rule = policy("1", required, exempt).rule_listing("USD", 0);
let tail = "; reversals need none (the original carries the evidence)";
let want = match counts {
Some((r, e)) => format!(
"any entry moving more than 1 USD in debits needs at least one receipt; books.settings lists {r} account(s) whose entries always need one and {e} whose entries need none{tail}"
),
None => format!(
"any entry moving more than 1 USD in debits needs at least one receipt{tail}"
),
};
assert_eq!(rule, want);
}
}
/// A draft an earlier version journaled may hold debits exact
/// arithmetic can't sum (rules.md B-28): its total is refused, never a
/// panic, and for the policy it is past any threshold: a receipt is
/// required, and it is no part of a split.
#[test]
fn debits_past_exact_arithmetic_are_past_any_threshold() {
use ledger_core::{AccountType, Currency, Money, Side};
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let rent = l
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let usd = |a: Decimal| Money::new(a, Currency::USD);
let date = chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
let huge = l.create_entry(date, "huge");
for _ in 0..2 {
l.replay_add_line(huge, rent, Side::Debit, usd(Decimal::MAX))
.unwrap();
}
let p = policy("1000000", &[], &["6000"]);
let entry = l.get_entry(huge).unwrap().clone();
assert!(matches!(
ReceiptPolicy::entry_debits(&entry),
Err(ledger_core::LedgerError::TooLargeToCompute(_))
));
// Exempt accounts only, yet past any threshold: still exempt.
assert!(!p.receipt_required(&l, &entry));
let p = policy("1000000", &[], &[]);
assert!(p.receipt_required(&l, &entry));
assert!(p.split_group(&l, huge, &entry).is_empty());
// Two posted entries of the same signature, one of them huge: the
// group's total is past the threshold.
let mut post = |amount: Decimal| {
let e = l.create_entry(date, "Coffee");
l.add_line(e, rent, Side::Debit, usd(amount)).unwrap();
l.add_line(e, cash, Side::Credit, usd(amount)).unwrap();
l.post_entry(e).unwrap();
e
};
let small = post(Decimal::new(500, 2));
let big = post(ledger_core::kernel::MAX_LINE_AMOUNT);
let draft = l.create_entry(date, "coffee");
l.add_line(draft, rent, Side::Debit, usd(Decimal::new(500, 2)))
.unwrap();
l.add_line(draft, cash, Side::Credit, usd(Decimal::new(500, 2)))
.unwrap();
let entry = l.get_entry(draft).unwrap().clone();
let p = policy("10.00", &[], &[]);
assert_eq!(p.split_group(&l, draft, &entry), vec![small, big]);
assert_eq!(
ReceiptPolicy::entry_debits(&entry),
Ok(Decimal::new(500, 2))
);
}
}
@@ -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 71eaf212cfff55fb9489fddf65627997660881f193be6bcbb21bc2a2add6a420 # shrinks to picks = [(44, false)]
+122
View File
@@ -0,0 +1,122 @@
//! The settings file codec over settings built at random from valid and
//! refused changes (plan/served.md P1: toml_round_trip_zero_events).
//!
//! ```text
//! INVARIANTS (crate::codec):
//! 1. An export imports into the same books as no change at all.
//! 2. An export imports into empty books as exactly those settings,
//! identity values aside (exported masked: they stay unset).
//! 3. The export reads, as a settings file, as the books do.
//! ```
use chrono::NaiveDate;
use ledger_config::codec;
use ledger_core::settings::{Change, Settings, Source};
use proptest::prelude::*;
use serde_json::{Value, json};
fn src() -> Source {
Source {
decided_by: "Pat Rivera".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: Some("Operating agreement §4.1\nsecond line".into()),
note: None,
}
}
fn candidates() -> Vec<(&'static str, Value)> {
vec![
("company.name", json!("Acme \"Widgets\" LLC")),
("company.name", json!("Ünïcödé Holdings")),
("company.entity_type", json!("LLC, multi-member")),
("company.tax_classification", json!("S corporation")),
("company.tax_classification", json!("partnership")),
("company.tax_form", json!("1065")),
("company.fiscal_year_start", json!("07-01")),
("company.timezone", json!("America/Denver")),
("company.formation_state", json!("UT")),
("company.formed", json!("2026-10-04")),
("company.state_entity_number", json!("14745117-0160")),
("company.ein", json!("43-2215893")),
("company.irs_name_control", json!("waln")),
("company.duns", json!("149-976-972")),
("company.address", json!("1 Main St\nSuite 2, Draper, UT")),
("company.registered_agent", json!("Pat Rivera")),
("company.partnership_representative", json!("Pat Rivera")),
("company.cpa", json!("Krista Baker")),
(
"company.members",
json!([{"name": "A", "share": "50%", "capital_account": "3000",
"distribution_account": "3100", "files_jointly_with": "B"},
{"name": "B", "share": "0.5", "capital_account": "3001",
"files_jointly_with": "A"}]),
),
(
"company.members",
json!([{"name": "Solo", "share": "100%", "capital_account": "3000"}]),
),
("books.accounting_basis", json!("cash")),
("books.cash_accounts", json!(["1000", "1010"])),
("books.financing_accounts", json!(["2100"])),
("books.investing_accounts", json!(["1400"])),
("books.close_into", json!("3000")),
("books.close_sweep", json!(["3100"])),
("books.asset_tag_prefix", json!("WL")),
("policy.evidence.threshold", json!("0.00")),
("policy.evidence.threshold", json!("25.5")),
("policy.evidence.required_accounts", json!(["6200"])),
("policy.evidence.exempt_accounts", json!(["6999", "6998"])),
("policy.parties", json!("require")),
("policy.approval_rules", json!("enforce")),
]
}
fn build(picks: &[(usize, bool)]) -> Settings {
let pool = candidates();
let mut s = Settings::default();
for (seq, (i, clear)) in picks.iter().enumerate() {
let (k, v) = &pool[i % pool.len()];
let change = Change {
key: (*k).into(),
value: (!clear).then(|| v.clone()),
};
// Refused changes (settings that wouldn't hold together) are
// skipped, as the books skip them.
if let Ok(made) = s.check_changes(&[change], &src()) {
s.apply(&made, &src(), seq as u64 + 1).unwrap();
}
}
s
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn exports_round_trip(picks in proptest::collection::vec((0usize..64, prop::bool::weighted(0.15)), 0..30)) {
let books = build(&picks);
let text = codec::export(&books, "USD");
// INVARIANT 3: the export reads as a settings file.
let file = ledger_config::parse(&text)
.unwrap_or_else(|e| panic!("the export reads as a settings file: {e}\n{text}"));
prop_assert_eq!(&file.company.name, &books.company("USD").name);
// INVARIANT 1.
let same = codec::import(&text, None, &books, "USD")
.unwrap_or_else(|e| panic!("{e}\n{text}"));
prop_assert!(same.changes.is_empty(), "{:?}\n{}", same.changes, text);
prop_assert!(same.unknown.is_empty(), "{:?}", same.unknown);
// INVARIANT 2.
let fresh = codec::import(&text, None, &Settings::default(), "USD")
.unwrap_or_else(|e| panic!("{e}\n{text}"));
let mut other = Settings::default();
other.apply(&fresh.changes, &src(), 1).unwrap();
for (k, r) in books.iter() {
if k.name == "company.ein" {
prop_assert!(other.get(k.name).is_none());
} else {
prop_assert_eq!(other.get(k.name).map(|o| &o.value), Some(&r.value), "{}", k.name);
}
}
prop_assert_eq!(other.iter().count() + usize::from(books.get("company.ein").is_some()), books.iter().count());
}
}
@@ -0,0 +1,56 @@
//! The receipt policy in force when the settings come from files:
//! config.json's when it sets one, else the settings file's threshold.
use ledger_config::{BooksSettings, effective_receipt_policy};
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn dir(tag: &str) -> TestDir {
let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TestDir(d)
}
#[test]
fn config_json_wins_else_the_settings_file() {
let d = dir("files");
let json = d.join("config.json");
let settings = BooksSettings {
receipt_threshold: Some("0.00".into()),
..BooksSettings::default()
};
// No config.json: the settings file's threshold.
let p = effective_receipt_policy(&json, &settings).unwrap();
assert_eq!(p.require_receipts_above, "0.00");
// config.json's policy, whole.
std::fs::write(
&json,
r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#,
)
.unwrap();
let p = effective_receipt_policy(&json, &settings).unwrap();
assert_eq!(p.require_receipts_above, "10.00");
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
// A broken one is refused, by name.
std::fs::write(&json, "{ not json").unwrap();
let err = effective_receipt_policy(&json, &settings).unwrap_err();
assert!(
err.starts_with(&format!("bad config {}", json.display())),
"{err}"
);
}
File diff suppressed because it is too large. Load diff
+8
View File
@@ -12,9 +12,17 @@ thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# company.timezone: IANA names, with the zone database built in so every
# computer reads a name the same way.
jiff = { version = "0.2.35", default-features = false, features = ["std", "tz-system", "tzdb-bundle-always"] }
[lints]
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);
+45 -2
View File
@@ -6,6 +6,7 @@
//! grouping on type, so getting this right is the whole game.
use crate::{Currency, LedgerError};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -56,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,
@@ -72,10 +73,18 @@ pub struct Account {
pub placeholder: bool,
/// Day one: always the ledger's base currency (see R-5).
pub currency: Currency,
/// Tax category key (see [`crate::tax`]), for revenue and expense
/// accounts.
#[serde(default)]
pub tax_category: Option<String>,
/// Percent deductible, overriding the category's default (e.g. 50 for
/// meals, 0 for penalties).
#[serde(default)]
pub deductible_percent: Option<Decimal>,
}
/// The registry of accounts. Owns id allocation for accounts it holds.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ChartOfAccounts {
accounts: HashMap<AccountId, Account>,
by_code: HashMap<String, AccountId>,
@@ -116,6 +125,40 @@ 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,
id: AccountId,
category: Option<String>,
deductible_percent: Option<Decimal>,
) -> Result<(), LedgerError> {
let a = self
.accounts
.get_mut(&id)
.ok_or(LedgerError::UnknownAccount(id))?;
a.tax_category = category;
a.deductible_percent = deductible_percent;
Ok(())
}
/// Number of accounts in the chart.
pub fn len(&self) -> usize {
self.accounts.len()
+286
View File
@@ -0,0 +1,286 @@
//! Time series over the books, for dashboards and interval-column reports.
//!
//! Statements answer "what was true for this range"; these answer "how did
//! it change": a range split into months, quarters, or years; balances of a
//! set of accounts on a series of dates; profit and loss per interval. They
//! are built from the same posted entries and statements as everything
//! else, so a number on a chart always ties to a number on a statement.
use crate::reports::profit_and_loss_for_period;
use crate::{AccountId, AccountType, Ledger, LedgerError, Side};
use chrono::{Datelike, Days, Months, NaiveDate};
use rust_decimal::Decimal;
use std::collections::HashSet;
/// How to split a date range into columns.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Interval {
/// Calendar months.
Month,
/// Calendar quarters.
Quarter,
/// Calendar years.
Year,
}
impl Interval {
fn months(self) -> u32 {
match self {
Interval::Month => 1,
Interval::Quarter => 3,
Interval::Year => 12,
}
}
}
/// The first day of the calendar interval containing `d`.
pub fn interval_start(d: NaiveDate, interval: Interval) -> NaiveDate {
let month0 = (d.month0() / interval.months()) * interval.months();
// The first of a month of `d`'s own year: every year chrono holds has
// all twelve.
NaiveDate::from_ymd_opt(d.year(), month0 + 1, 1).expect("valid")
}
/// `[start, end]` split on calendar interval boundaries, first and last
/// pieces clipped to the range. Empty when `end < start`. Never panics:
/// an interval that would end past chrono's last day ends at `end`
/// (rules.md B-28: books an earlier version wrote may hold such dates).
pub fn intervals(
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Vec<(NaiveDate, NaiveDate)> {
let mut out = Vec::new();
let mut from = interval_start(start, interval);
while from <= end {
let Some(next) = from.checked_add_months(Months::new(interval.months())) else {
// The interval holding chrono's last day: nothing after it.
out.push((from.max(start), end));
break;
};
let to = (next - Days::new(1)).min(end);
out.push((from.max(start), to));
from = next;
}
out
}
/// The combined normal-side balance of `accounts` at the end of each date
/// in `dates` (which must be ascending). Assets and expenses count debits
/// as positive; liabilities, equity, and revenue count credits. Mixing
/// types is allowed but rarely meaningful.
pub fn balance_series(
ledger: &Ledger,
accounts: &[AccountId],
dates: &[NaiveDate],
) -> Vec<Decimal> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut postings: Vec<(NaiveDate, Decimal)> = Vec::new();
for entry in ledger.posted_entries() {
for line in entry.lines() {
if !set.contains(&line.account) {
continue;
}
let Some(account) = ledger.chart().get(line.account) else {
continue;
};
let signed = if line.side == account.account_type.normal_side() {
line.amount.amount
} else {
-line.amount.amount
};
postings.push((entry.date(), signed));
}
}
postings.sort_by_key(|(d, _)| *d);
let mut out = Vec::with_capacity(dates.len());
let mut running = Decimal::ZERO;
let mut i = 0;
for date in dates {
while i < postings.len() && postings[i].0 <= *date {
running += postings[i].1;
i += 1;
}
out.push(running);
}
out
}
/// Every date `accounts` moved, with the combined balance at the end of
/// that day: the exact shape of a balance-over-time chart.
pub fn balance_history(ledger: &Ledger, accounts: &[AccountId]) -> Vec<(NaiveDate, Decimal)> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut dates: Vec<NaiveDate> = ledger
.posted_entries()
.filter(|e| e.lines().iter().any(|l| set.contains(&l.account)))
.map(|e| e.date())
.collect();
dates.sort();
dates.dedup();
let balances = balance_series(ledger, accounts, &dates);
dates.into_iter().zip(balances).collect()
}
/// One interval's profit and loss.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IntervalPnl {
/// First day.
pub start: NaiveDate,
/// Last day.
pub end: NaiveDate,
/// Total revenue.
pub revenue: Decimal,
/// Total expenses.
pub expenses: Decimal,
}
impl IntervalPnl {
/// Revenue minus expenses.
pub fn net(&self) -> Decimal {
self.revenue - self.expenses
}
}
/// Profit and loss per interval across `[start, end]` (closing entries
/// excluded, as on every period statement).
pub fn pnl_by_interval(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Result<Vec<IntervalPnl>, LedgerError> {
intervals(start, end, interval)
.into_iter()
.map(|(s, e)| {
let pl = profit_and_loss_for_period(ledger, s, e, None)?;
Ok(IntervalPnl {
start: s,
end: e,
revenue: pl.total_revenue,
expenses: pl.total_expenses,
})
})
.collect()
}
/// Money in and out of `accounts` per interval: (inflows, outflows), each
/// positive. Each entry counts by its net debit (in) or credit (out) to the
/// accounts together: for cash, deposits and payments; a transfer between
/// two of them (checking to savings) is neither; with a credit card in the
/// set, a charge is money out and paying the card moves nothing.
pub fn flows_by_interval(
ledger: &Ledger,
accounts: &[AccountId],
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut out: Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> = intervals(start, end, interval)
.into_iter()
.map(|(s, e)| (s, e, Decimal::ZERO, Decimal::ZERO))
.collect();
for entry in ledger.posted_entries() {
let d = entry.date();
let Some(slot) = out.iter_mut().find(|(s, e, _, _)| *s <= d && d <= *e) else {
continue;
};
// Debits in, credits out, for every account in the set: cash
// received is a debit to cash, and a charge on a card in the set is
// a credit to the card.
let mut net = Decimal::ZERO;
for line in entry.lines().iter().filter(|l| set.contains(&l.account)) {
match line.side {
Side::Debit => net += line.amount.amount,
Side::Credit => net -= line.amount.amount,
}
}
if net > Decimal::ZERO {
slot.2 += net;
} else {
slot.3 -= net;
}
}
out
}
/// Accounts of the given types, in chart order.
pub fn accounts_of_type(ledger: &Ledger, types: &[AccountType]) -> Vec<AccountId> {
ledger
.chart()
.all()
.filter(|a| types.contains(&a.account_type))
.map(|a| a.id)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn intervals_clip_to_the_range() {
assert_eq!(
intervals(d(2026, 1, 15), d(2026, 3, 10), Interval::Month),
[
(d(2026, 1, 15), d(2026, 1, 31)),
(d(2026, 2, 1), d(2026, 2, 28)),
(d(2026, 3, 1), d(2026, 3, 10)),
]
);
assert_eq!(
intervals(d(2026, 2, 1), d(2026, 12, 31), Interval::Quarter),
[
(d(2026, 2, 1), d(2026, 3, 31)),
(d(2026, 4, 1), d(2026, 6, 30)),
(d(2026, 7, 1), d(2026, 9, 30)),
(d(2026, 10, 1), d(2026, 12, 31)),
]
);
assert_eq!(
intervals(d(2025, 6, 1), d(2026, 2, 1), Interval::Year),
[
(d(2025, 6, 1), d(2025, 12, 31)),
(d(2026, 1, 1), d(2026, 2, 1)),
]
);
assert!(intervals(d(2026, 2, 1), d(2026, 1, 1), Interval::Month).is_empty());
}
/// At chrono's last day (books an earlier version wrote may hold it):
/// the last interval ends there, never a panic (rules.md B-28).
#[test]
fn intervals_reach_chronos_last_day() {
let y = NaiveDate::MAX.year();
assert_eq!(
intervals(d(y, 11, 20), NaiveDate::MAX, Interval::Month),
[(d(y, 11, 20), d(y, 11, 30)), (d(y, 12, 1), NaiveDate::MAX)]
);
assert_eq!(
intervals(d(y, 2, 2), NaiveDate::MAX, Interval::Year),
[(d(y, 2, 2), NaiveDate::MAX)]
);
assert_eq!(
intervals(d(y, 12, 31), NaiveDate::MAX, Interval::Quarter),
[(NaiveDate::MAX, NaiveDate::MAX)]
);
assert_eq!(
intervals(
NaiveDate::MIN,
d(NaiveDate::MIN.year(), 2, 3),
Interval::Month
),
[
(NaiveDate::MIN, d(NaiveDate::MIN.year(), 1, 31)),
(
d(NaiveDate::MIN.year(), 2, 1),
d(NaiveDate::MIN.year(), 2, 3)
),
]
);
}
}
+171 -25
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,12 +36,20 @@ 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,
/// Display name.
pub name: String,
/// The information return this vendor gets, if any (see
/// [`crate::form1099`]).
#[serde(default)]
pub form_1099: Option<crate::form1099::Form1099>,
/// A W-9 (taxpayer identification number) is on file. The number
/// itself is never stored here.
#[serde(default)]
pub tin_on_file: bool,
}
/// Payment status of an invoice or bill.
@@ -56,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,
@@ -65,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,
@@ -85,20 +93,42 @@ pub struct Invoice {
pub entry_id: u64,
}
/// The sum of a document's lines; refused (TooLargeToCompute), never a
/// panic, past exact arithmetic. A document is restored from its journal
/// line as written (rules.md B-28), so its lines are bounded only by what
/// the version that wrote it checked.
pub fn document_total(lines: &[DocumentLine], what: &str) -> Result<Decimal, LedgerError> {
lines
.iter()
.try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount))
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s total")))
}
/// What is still owed on a document: its total less what was paid.
fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result<Decimal, LedgerError> {
total
.checked_sub(paid)
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s balance")))
}
impl Invoice {
/// Total of all lines.
pub fn total(&self) -> Decimal {
self.lines.iter().map(|l| l.amount).sum()
/// Total of all lines ([`document_total`]).
pub fn total(&self) -> Result<Decimal, LedgerError> {
document_total(&self.lines, &format!("invoice {}", self.id))
}
/// Amount still owed.
pub fn balance(&self) -> Decimal {
self.total() - self.amount_paid
pub fn balance(&self) -> Result<Decimal, LedgerError> {
document_balance(
self.total()?,
self.amount_paid,
&format!("invoice {}", self.id),
)
}
}
/// 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,
@@ -119,18 +149,24 @@ pub struct Bill {
}
impl Bill {
/// Total of all lines.
pub fn total(&self) -> Decimal {
self.lines.iter().map(|l| l.amount).sum()
/// Total of all lines ([`document_total`]).
pub fn total(&self) -> Result<Decimal, LedgerError> {
document_total(&self.lines, &format!("bill {}", self.id))
}
/// Amount still owed.
pub fn balance(&self) -> Decimal {
self.total() - self.amount_paid
pub fn balance(&self) -> Result<Decimal, LedgerError> {
document_balance(
self.total()?,
self.amount_paid,
&format!("bill {}", self.id),
)
}
}
/// Validate document lines: at least one, all positive amounts.
/// Validate document lines: at least one, all positive amounts, each in a
/// journal line's range. For new documents; replay restores them as
/// journaled (rules.md B-28).
pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
if lines.is_empty() {
return Err(LedgerError::EmptyDocumentLines);
@@ -139,12 +175,24 @@ pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
if line.amount <= Decimal::ZERO {
return Err(LedgerError::InvalidDocumentLineAmount(line.amount));
}
// No larger than a journal line may be (kernel::MAX_LINE_AMOUNT):
// the document's total is summed from them (P0 review round 3, S1).
if !crate::kernel::Amount::in_line_range(line.amount) {
return Err(LedgerError::ValueOutOfRange(format!(
"document line amount {}",
line.amount
)));
}
}
Ok(())
}
/// Validate that due date is not before the document date.
/// Validate that due date is not before the document date, and both are
/// in the calendar the books keep (P0 review round 3, S1). For new
/// documents; replay restores them as journaled (rules.md B-28).
pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), LedgerError> {
crate::calendar::check(date)?;
crate::calendar::check(due_date)?;
if due_date < date {
return Err(LedgerError::DueBeforeDate(due_date.to_string()));
}
@@ -180,13 +228,59 @@ pub struct AgingRow {
}
impl AgingRow {
/// Total outstanding.
pub fn total(&self) -> Decimal {
self.current + self.days_30 + self.days_60 + self.days_90 + self.days_90_plus
/// Total outstanding; refused past exact arithmetic.
pub fn total(&self) -> Result<Decimal, LedgerError> {
[self.days_30, self.days_60, self.days_90, self.days_90_plus]
.into_iter()
.try_fold(self.current, Decimal::checked_add)
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{}'s aging total", self.name)))
}
/// Add `other`'s buckets to this row's; refused past exact arithmetic.
pub(crate) fn add(&mut self, other: &AgingRow) -> Result<(), LedgerError> {
let too_large = || LedgerError::TooLargeToCompute(format!("{}'s aging", self.name));
let sum = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large);
let (current, days_30, days_60, days_90, days_90_plus) = (
sum(self.current, other.current)?,
sum(self.days_30, other.days_30)?,
sum(self.days_60, other.days_60)?,
sum(self.days_90, other.days_90)?,
sum(self.days_90_plus, other.days_90_plus)?,
);
(
self.current,
self.days_30,
self.days_60,
self.days_90,
self.days_90_plus,
) = (current, days_30, days_60, days_90, days_90_plus);
Ok(())
}
}
/// 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(),
@@ -196,7 +290,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();
@@ -241,7 +335,7 @@ mod tests {
)
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.total(), "1000.00".parse().unwrap());
assert_eq!(invoice.total().unwrap(), "1000.00".parse().unwrap());
assert_eq!(invoice.status, DocumentStatus::Open);
// A/R has a debit balance of 1000.
@@ -259,7 +353,7 @@ mod tests {
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.status, DocumentStatus::Partial);
assert_eq!(invoice.balance(), "600.00".parse().unwrap());
assert_eq!(invoice.balance().unwrap(), "600.00".parse().unwrap());
// Full payment.
l.receive_payment(
@@ -340,6 +434,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();
@@ -367,7 +513,7 @@ mod tests {
)
.unwrap();
let as_of = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
let aging = l.ar_aging(as_of);
let aging = l.ar_aging(as_of).unwrap();
assert_eq!(aging.len(), 1);
assert_eq!(aging[0].current, "100".parse().unwrap());
assert_eq!(aging[0].days_60, "200".parse().unwrap());
+267 -18
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,223 @@ 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, and no larger than a cost may be
/// (kernel::MAX_LINE_AMOUNT: the register sums them; P0 review round 3,
/// S1). Returns why not. Needs no asset, so a caller can check before
/// registering one. Replay checks only [`Self::check_journaled`].
pub fn check(&self) -> Result<(), String> {
self.check_journaled()?;
match self.contributor_basis {
Some(b) if b > crate::kernel::MAX_LINE_AMOUNT => {
Err("a basis is at most one quadrillion".into())
}
_ => Ok(()),
}
}
/// The checks every journaled `asset.tax_basis_set` passed when it was
/// written (the rule through 2026-10-16): [`Self::check`] without the
/// upper bound. Replay applies exactly these (rules.md B-28).
pub fn check_journaled(&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)
@@ -105,14 +322,23 @@ pub struct ScheduleLine {
pub book_value: Decimal,
}
/// The days in a month of a year chrono holds, without stepping to the
/// next month, so chrono's last month (December 262142, whose next month
/// chrono can't hold) has its 31 days too (rules.md B-28: a schedule an
/// earlier version accepted is computed, never a panic).
fn days_in_month(year: i32, month: u32) -> u32 {
let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
let next = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid month")
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid month")
};
(next - first).num_days() as u32
match month {
2 if NaiveDate::from_ymd_opt(year, 2, 29).is_some() => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
/// The first day of the month after `month_start`'s, or `None` past
/// chrono's last day (the month containing NaiveDate::MAX).
fn next_month_start(month_start: NaiveDate) -> Option<NaiveDate> {
month_start.checked_add_months(Months::new(1))
}
/// The last day depreciation accrues: purchase + life, or disposal, whichever
@@ -128,8 +354,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
@@ -158,18 +402,18 @@ pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleL
let mut accumulated_rounded = Decimal::ZERO;
// Walk calendar months from the purchase month.
let (mut year, mut month) = (asset.purchase_date.year(), asset.purchase_date.month());
loop {
let month_start = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
// Past chrono's last month there is no month to walk, and nothing left
// to accrue: accrual_end is at most NaiveDate::MAX.
while let Some(month_start) = NaiveDate::from_ymd_opt(year, month, 1) {
if month_start >= accrual_end {
break;
}
let dim = days_in_month(year, month);
let month_end_excl = month_start
.checked_add_months(Months::new(1))
.expect("month arithmetic");
// Days held this month: overlap of [purchase_date, accrual_end) with the month.
// Days held this month: overlap of [purchase_date, accrual_end) with
// the month. In chrono's last month there is no next month to end
// at, and accrual_end (at most NaiveDate::MAX) is the bound.
let held_start = asset.purchase_date.max(month_start);
let held_end = accrual_end.min(month_end_excl);
let held_end = next_month_start(month_start).map_or(accrual_end, |n| accrual_end.min(n));
let days_held = (held_end - held_start).num_days().max(0) as u32;
if days_held > 0 {
let exact = monthly_rate * Decimal::from(days_held) / Decimal::from(dim);
@@ -196,9 +440,12 @@ pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleL
// absorb any month-length drift so the lifetime total is exactly
// (cost − salvage). The drift is bounded by a fraction of one month's
// charge, so the final month stays positive.
// The final month named as the lines name theirs: chrono's "%Y" signs
// a year past 9999 ("+10004"), and the lines don't, so books an
// earlier version wrote with such a life true up too (rules.md B-28).
if let Some(last) = out.last_mut()
&& let Some(day_before_end) = end.pred_opt()
&& last.period == day_before_end.format("%Y-%m").to_string()
&& last.period == format!("{:04}-{:02}", day_before_end.year(), day_before_end.month())
{
let plug = depreciable - accumulated_rounded;
last.amount += plug;
@@ -234,6 +481,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,
+82
View File
@@ -0,0 +1,82 @@
//! The calendar the books keep: four-digit years, 0001-01-01 through
//! 9999-12-31.
//!
//! ```text
//! GOAL: no date the books hold, or derive (a schedule's last month, an
//! asset's last day of depreciation, a fiscal year's end), is one
//! date arithmetic can't reach, or one a report can't print.
//! SOURCE: CONVENTION: ISO 8601's basic calendar dates have four-digit
//! years (0000-9999; year 0 is no Gregorian year, so 0001 is the
//! first), as do the spreadsheet and PDF formats the books export
//! to; DECISION (P0 review round 3, 2026-10-16, finding S1): a date
//! outside them is refused, never a crash.
//! INVARIANTS:
//! 1. in_calendar(d) iff FIRST_DAY <= d <= LAST_DAY.
//! 2. Every date from the calendar plus a year or 600 months less than
//! ten thousand years is a date chrono represents (its range is about
//! +-262,000 years), so arithmetic on calendar dates never overflows.
//! ```
use crate::LedgerError;
use chrono::NaiveDate;
/// The first day the books keep.
pub const FIRST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(1, 1, 1) {
Some(d) => d,
None => panic!("0001-01-01 is a date"),
};
/// The last day the books keep.
pub const LAST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(9999, 12, 31) {
Some(d) => d,
None => panic!("9999-12-31 is a date"),
};
/// Whether `d` is in the calendar the books keep (INVARIANT 1).
pub fn in_calendar(d: NaiveDate) -> bool {
(FIRST_DAY..=LAST_DAY).contains(&d)
}
/// `d`, if it is in the calendar; else [`LedgerError::DateOutOfRange`].
pub fn check(d: NaiveDate) -> Result<NaiveDate, LedgerError> {
if in_calendar(d) {
Ok(d)
} else {
Err(LedgerError::DateOutOfRange(d))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_calendar_is_four_digit_years() {
assert!(in_calendar(FIRST_DAY));
assert!(in_calendar(LAST_DAY));
assert!(!in_calendar(FIRST_DAY.pred_opt().unwrap()));
assert!(!in_calendar(LAST_DAY.succ_opt().unwrap()));
assert!(!in_calendar(NaiveDate::MAX));
assert!(!in_calendar(NaiveDate::MIN));
assert_eq!(FIRST_DAY.to_string(), "0001-01-01");
assert_eq!(LAST_DAY.to_string(), "9999-12-31");
assert_eq!(check(LAST_DAY), Ok(LAST_DAY));
assert_eq!(
check(NaiveDate::MAX),
Err(LedgerError::DateOutOfRange(NaiveDate::MAX))
);
}
/// INVARIANT 2: the arithmetic the books do on calendar dates (a year
/// on, 600 months on, a day back) stays inside chrono's range.
#[test]
fn arithmetic_on_calendar_dates_never_overflows() {
for d in [FIRST_DAY, LAST_DAY] {
assert!(d.checked_add_months(chrono::Months::new(600)).is_some());
assert!(d.checked_sub_months(chrono::Months::new(600)).is_some());
assert!(NaiveDate::from_ymd_opt(chrono::Datelike::year(&d) + 1, 1, 1).is_some());
assert!(NaiveDate::from_ymd_opt(chrono::Datelike::year(&d) - 1, 12, 31).is_some());
assert!(d.pred_opt().is_some() && d.succ_opt().is_some());
}
}
}
+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));
File diff suppressed because it is too large. Load diff
+295 -2
View File
@@ -26,6 +26,48 @@ pub enum LedgerError {
#[error("journal line has a zero amount")]
ZeroAmountLine,
/// Line amounts are positive; the side says which way the money moves.
#[error("journal line has a negative amount; use the other side instead")]
NegativeAmountLine,
/// A line larger than one quadrillion, or with more than ten decimal
/// places, is a mistake, and one that could overflow exact arithmetic.
#[error(
"journal line amount is out of range (at most one quadrillion, with ten decimal places)"
)]
AmountOutOfRange,
/// A date outside the calendar the books keep (`crate::calendar`):
/// four-digit years, 0001-01-01 through 9999-12-31.
#[error("date {0} is outside the calendar the books keep (0001-01-01 through 9999-12-31)")]
DateOutOfRange(chrono::NaiveDate),
/// An amount other than a journal line's (a document line, a payout, a
/// payroll figure, a statement balance, a share, a basis) larger than
/// one quadrillion or with more than ten decimal places: a mistake, and
/// one that could overflow exact arithmetic.
#[error("{0} is out of range (at most one quadrillion, with ten decimal places)")]
ValueOutOfRange(String),
/// A figure the books derive (a sum, or a share of one) too large for
/// exact arithmetic: refused, never rounded or wrapped.
#[error("{0} is too large to compute exactly")]
TooLargeToCompute(String),
/// 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),
@@ -56,6 +98,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`]). The text names the authority, never this repo:
/// it reaches agents.
#[error(
"wanderbooks knows the {rule} only through {known_through}, not for {year}. \
Find the {year} figure from the official source, {authority}, or ask the books' \
owner or CPA, and pass it explicitly. Then tell the human the figure and where \
it came from, so the rule can be updated for everyone."
)]
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 official source of the rule (`DatedRule::authority`).
authority: 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),
@@ -68,6 +301,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),
@@ -132,6 +370,24 @@ pub enum LedgerError {
#[error("purchase date plus useful life overflows the calendar")]
AssetDateOverflow,
/// An amortization schedule or recurring entry is invalid, unknown, or
/// already posted for that date.
#[error("invalid schedule: {0}")]
InvalidSchedule(String),
/// A payout doesn't reconcile or uses the wrong accounts.
#[error("invalid payout: {0}")]
InvalidPayout(String),
/// A tax category doesn't exist, doesn't fit the account, or has a bad
/// deductible percent.
#[error("invalid tax category: {0}")]
InvalidTaxCategory(String),
/// Partnership members are misconfigured (shares, capital accounts).
#[error("invalid members: {0}")]
InvalidMembers(String),
/// Cash-flow period end is before period start.
#[error("invalid date range: end {end} is before start {start}")]
InvalidDateRange {
@@ -162,6 +418,10 @@ pub enum LedgerError {
#[error("cannot close period {0}: draft entries exist with dates in the period")]
DraftsInPeriod(u64),
/// A period can't close while a later period is already closed.
#[error("period {0} can't close: later period {1} is already closed (close in order)")]
CloseOutOfOrder(u64, u64),
/// Entries cannot be created or posted in a closed period.
#[error("period {0} is closed; entries dated {1} are rejected")]
ClosedPeriod(u64, String),
@@ -223,8 +483,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.
@@ -250,4 +511,36 @@ pub enum LedgerError {
/// The referenced class does not exist.
#[error("unknown class id {0}")]
UnknownClass(u32),
/// A setting change the books can't honor (crate::settings): an
/// unknown key, a value its key refuses, or settings that wouldn't
/// hold together.
#[error("{0}")]
InvalidSetting(String),
/// A convention, decision, open item or approval the books can't
/// take (crate::conventions).
#[error("{0}")]
InvalidConvention(String),
/// Approval rules are enforced (`policy.approval_rules = enforce`) and
/// rules cover this entry that its approvers haven't approved as it
/// stands.
#[error(
"entry {entry} needs approval before it posts: {missing}; record each with books.approve_entry"
)]
ApprovalRequired {
/// The draft.
entry: u64,
/// Each rule and its approver, e.g. `over-500 (approver: Pat)`.
missing: String,
},
/// Approval rules are enforced, and the entry an operation would post
/// at once (an invoice, a bill, a payment, payroll, a disposal) is one
/// a rule covers: it has no draft to approve.
#[error(
"approval rules cover the entry this would post at once ({0}): draft it instead (books.draft_entry), have it approved (books.approve_entry), then post it"
)]
ApprovalNeedsDraft(String),
}
+370
View File
@@ -0,0 +1,370 @@
//! 1099 tracking: what was paid to each vendor in a calendar year.
//!
//! A vendor is marked with the information return it gets (1099-NEC for
//! nonemployee compensation, 1099-MISC for rents and the like) and whether
//! its W-9 is on file. Entries are tagged with the vendor they paid
//! (`pay_bill` tags automatically). The amount paid is 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, LedgerError, VendorId};
use chrono::Datelike;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// The information return a vendor gets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Form1099 {
/// 1099-NEC: nonemployee compensation (contractors, attorneys' fees).
Nec,
/// 1099-MISC: rents, royalties, prizes, other income.
Misc,
}
impl Form1099 {
/// Parse "nec", "1099-NEC", "misc", "1099-MISC".
pub fn parse(s: &str) -> Option<Form1099> {
let s = s.trim().to_lowercase();
match s.strip_prefix("1099-").unwrap_or(&s) {
"nec" => Some(Form1099::Nec),
"misc" => Some(Form1099::Misc),
_ => None,
}
}
/// "1099-NEC" or "1099-MISC".
pub fn label(self) -> &'static str {
match self {
Form1099::Nec => "1099-NEC",
Form1099::Misc => "1099-MISC",
}
}
}
/// The reporting threshold for payments made in `year`.
///
/// ```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,
authority: rule.authority.to_string(),
});
}
Ok(if year <= 2025 {
Decimal::new(600, 0)
} else {
Decimal::new(2000, 0)
})
}
/// One vendor's year.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Line {
/// The vendor.
pub vendor_id: VendorId,
/// Name.
pub name: String,
/// The return it gets, if marked.
pub form: Option<Form1099>,
/// W-9 on file.
pub tin_on_file: bool,
/// Cash paid in the year (net of refunds and reversals).
pub paid: Decimal,
/// Marked for a 1099 and paid at least the threshold.
pub reportable: bool,
}
/// The year's 1099 report.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Report {
/// Calendar year.
pub year: i32,
/// The threshold applied.
pub threshold: Decimal,
/// Every vendor marked for a 1099 or paid in the year, by name.
pub vendors: Vec<Vendor1099Line>,
/// 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
/// with the vendor.
pub fn vendor_1099_report(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
) -> 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).
/// Refused, never wrapped or rounded early, when payment × share is
/// beyond exact arithmetic (both near the line limit; P0 review round 3,
/// S1).
fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Decimal, LedgerError> {
if payment == target {
Ok(share)
} else if target.is_zero() {
Ok(Decimal::ZERO)
} else {
let scaled = payment
.checked_mul(share)
.and_then(|x| x.checked_div(target))
.ok_or_else(|| {
LedgerError::TooLargeToCompute(format!(
"a vendor's share ({share}) of a payment of {payment}"
))
})?;
Ok(scaled.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;
}
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
continue;
};
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
}
}
};
let total = paid.entry(vendor).or_default();
*total = total.checked_add(amount).ok_or_else(|| {
LedgerError::TooLargeToCompute("a vendor's payments for the year".into())
})?;
}
let mut vendors: Vec<Vendor1099Line> = ledger
.list_vendors()
.into_iter()
.filter(|v| v.form_1099.is_some() || paid.get(&v.id).is_some_and(|p| !p.is_zero()))
.map(|v| {
let amount = paid.get(&v.id).copied().unwrap_or_default();
Vendor1099Line {
vendor_id: v.id,
name: v.name.clone(),
form: v.form_1099,
// 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)));
Ok(Vendor1099Report {
year,
threshold,
vendors,
unresolved,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// vendor_part: the vendor's share scaled from the share target to the
/// payment, exactly, rounded to the cent; refused, never a panic, when
/// payment x share is past exact arithmetic (P0 review round 3, S1).
#[test]
fn a_vendor_part_is_scaled_exactly_or_refused() {
let d = |s: &str| s.parse::<Decimal>().unwrap();
assert_eq!(vendor_part(d("100"), d("40"), d("100")), Ok(d("40")));
assert_eq!(vendor_part(d("50"), d("40"), d("100")), Ok(d("20")));
assert_eq!(vendor_part(d("1"), d("1"), d("3")), Ok(d("0.33")));
assert_eq!(vendor_part(d("2"), d("1"), d("3")), Ok(d("0.67")));
assert_eq!(vendor_part(d("5"), d("1"), d("0")), Ok(Decimal::ZERO));
let near = d("450000000000000");
assert!(matches!(
vendor_part(near * d("2"), near, near - d("1")),
Err(LedgerError::TooLargeToCompute(_))
));
assert!(matches!(
vendor_part(Decimal::MAX, Decimal::MAX, d("1")),
Err(LedgerError::TooLargeToCompute(_))
));
}
}
+29 -29
View File
@@ -4,6 +4,7 @@
//! total credits. Entries are born as drafts, validated, then posted. Posting
//! is one-way; a posted entry can only be corrected by a reversing entry.
use crate::kernel::{self, LineError};
use crate::{AccountId, ChartOfAccounts, ClassId, Currency, LedgerError, Money, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
@@ -23,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,
@@ -50,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,
@@ -126,14 +127,11 @@ impl JournalEntry {
}
/// Validate the entry against the chart of accounts. This is the heart of
/// R-1: at least two lines, no zero amounts, no placeholder or unknown
/// accounts, one currency throughout, and debits exactly equal to credits.
/// R-1: at least two lines, every amount positive and within line range,
/// no placeholder or unknown accounts, one currency throughout, and
/// debits exactly equal to credits. The arithmetic is
/// [`kernel::check_lines`].
pub fn validate(&self, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
if self.lines.len() < 2 {
return Err(LedgerError::TooFewLines);
}
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
let mut currency: Option<Currency> = None;
for line in &self.lines {
let account = chart
@@ -142,9 +140,6 @@ impl JournalEntry {
if account.placeholder {
return Err(LedgerError::PlaceholderAccount(account.code.clone()));
}
if line.amount.is_zero() {
return Err(LedgerError::ZeroAmountLine);
}
match currency {
None => currency = Some(line.amount.currency),
Some(c) if c != line.amount.currency => {
@@ -161,28 +156,33 @@ impl JournalEntry {
found: line.amount.currency.code().to_string(),
});
}
match line.side {
Side::Debit => debits += line.amount.amount,
Side::Credit => credits += line.amount.amount,
}
let sides: Vec<(Side, Decimal)> = self
.lines
.iter()
.map(|l| (l.side, l.amount.amount))
.collect();
kernel::check_lines(&sides).map_err(|e| match e {
LineError::TooFewLines => LedgerError::TooFewLines,
LineError::ZeroAmount => LedgerError::ZeroAmountLine,
LineError::NegativeAmount => LedgerError::NegativeAmountLine,
LineError::OutOfRange => LedgerError::AmountOutOfRange,
LineError::Unbalanced { debits, credits } => {
LedgerError::UnbalancedEntry { debits, credits }
}
}
if debits != credits {
return Err(LedgerError::UnbalancedEntry { debits, credits });
}
Ok(())
})
}
/// Net movement per account within this entry: debits minus credits.
/// Sums to zero across accounts for any valid entry.
pub fn movements(&self) -> HashMap<AccountId, Decimal> {
let mut out = HashMap::new();
for line in &self.lines {
let delta = match line.side {
Side::Debit => line.amount.amount,
Side::Credit => -line.amount.amount,
};
*out.entry(line.account).or_insert(Decimal::ZERO) += delta;
}
out
kernel::movements(
self.lines
.iter()
.map(|l| (l.account, l.side, l.amount.amount)),
)
.expect("posted lines are in range, so their sums are")
.into_iter()
.collect()
}
}
+898
View File
@@ -0,0 +1,898 @@
//! 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.
/// Refused when whole × credit is beyond exact arithmetic (credits near
/// the line limit; P0 review round 3, S1).
fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
use rust_decimal::RoundingStrategy::ToZero;
let too_large =
|| LedgerError::TooLargeToCompute("a member's share of contributed property".into());
let total = credits
.iter()
.try_fold(Decimal::ZERO, |t, c| t.checked_add(*c))
.ok_or_else(too_large)?;
let whole = pool.min(total);
if whole <= Decimal::ZERO || total <= Decimal::ZERO {
return Ok(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.checked_mul(*c).and_then(|x| x.checked_div(total)))
.collect::<Option<_>>()
.ok_or_else(too_large)?;
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");
Ok(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")]).unwrap(),
vec![d("300.75"), d("100.25")]
);
assert_eq!(
share_property(d("0.01"), &[d("5"), d("5")]).unwrap(),
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")]).unwrap(),
vec![d("300"), d("100")]
);
assert_eq!(
share_property(d("50"), &[d("0"), d("0")]).unwrap(),
vec![d("0"), d("0")]
);
// Credits near the line limit: whole x credit is past exact
// arithmetic, so the split is refused, never a panic (P0 review
// round 3, S1); at a quarter of it, it still splits exactly.
let near = d("900000000000000");
assert!(matches!(
share_property(near, &[near, near]),
Err(LedgerError::TooLargeToCompute(_))
));
assert!(matches!(
share_property(near, &[Decimal::MAX, Decimal::MAX]),
Err(LedgerError::TooLargeToCompute(_))
));
let quarter = d("250000000000");
assert_eq!(
share_property(quarter, &[quarter, quarter]).unwrap(),
vec![d("125000000000"), d("125000000000")]
);
}
/// 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));
}
}
+451
View File
@@ -0,0 +1,451 @@
//! The accounting kernel: the arithmetic every bookkeeping invariant rests
//! on, in one place.
//!
//! Each function here is small, pure, and generic over [`Amount`]. The
//! engine runs it on [`Decimal`]; the Kani harnesses in `proofs.rs` run the
//! very same code on machine integers and prove it correct for every input
//! within their bounds; and the Lean model in `verify/lean` proves the
//! 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 (each traced to its authority in
//! `docs/spec/rules.md`):
//!
//! * 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 (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 (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 (B-5, B-7);
//! * a trial balance puts each movement in exactly one column, so its
//! 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;
/// What the kernel needs of a money amount: exact, ordered, and checked
/// arithmetic that reports overflow instead of rounding or wrapping.
pub trait Amount: Copy + Ord {
/// Zero.
const ZERO: Self;
/// `self + rhs`, or `None` when it cannot be represented exactly.
fn checked_add(self, rhs: Self) -> Option<Self>;
/// `-self`, or `None` when it cannot be represented.
fn checked_neg(self) -> Option<Self>;
/// True when the amount is small and precise enough for a journal line
/// (see [`MAX_LINE_AMOUNT`] and [`MAX_LINE_SCALE`] for [`Decimal`]).
fn in_line_range(self) -> bool;
/// `self - rhs`, or `None` when it cannot be represented exactly.
fn checked_sub(self, rhs: Self) -> Option<Self> {
self.checked_add(rhs.checked_neg()?)
}
}
/// The largest amount one journal line may carry: one quadrillion units of
/// the base currency. Far beyond any real transaction, and far enough
/// below [`Decimal::MAX`] that no realistic sum of lines can overflow.
pub const MAX_LINE_AMOUNT: Decimal = Decimal::from_parts(0xA4C6_8000, 0x0003_8D7E, 0, false, 0);
/// The most decimal places a journal line may carry. Cents need two;
/// foreign-currency lines (amount times rate) need more; ten keeps every
/// sum of lines exact as long as the total stays under 7.9 quintillion.
pub const MAX_LINE_SCALE: u32 = 10;
impl Amount for Decimal {
const ZERO: Self = Decimal::ZERO;
fn checked_add(self, rhs: Self) -> Option<Self> {
Decimal::checked_add(self, rhs)
}
fn checked_neg(self) -> Option<Self> {
Some(-self)
}
fn in_line_range(self) -> bool {
self.abs() <= MAX_LINE_AMOUNT && self.normalize().scale() <= MAX_LINE_SCALE
}
}
impl Amount for i64 {
const ZERO: Self = 0;
fn checked_add(self, rhs: Self) -> Option<Self> {
i64::checked_add(self, rhs)
}
fn checked_neg(self) -> Option<Self> {
i64::checked_neg(self)
}
fn in_line_range(self) -> bool {
// Scaled down with the same headroom as Decimal's range: lines
// this size can be summed many times over without overflow.
self.unsigned_abs() <= (1 << 40)
}
}
/// Why the kernel rejected an entry's lines.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineError<T> {
/// Fewer than two lines.
TooFewLines,
/// A line with a zero amount.
ZeroAmount,
/// A line with a negative amount: direction is the side's job.
NegativeAmount,
/// A line too large or too precise for a journal line.
OutOfRange,
/// Debits and credits differ.
Unbalanced {
/// Sum of debit lines.
debits: T,
/// Sum of credit lines.
credits: T,
},
}
/// The other side.
pub fn flip(side: Side) -> Side {
match side {
Side::Debit => Side::Credit,
Side::Credit => Side::Debit,
}
}
/// A line's signed amount: positive for a debit, negative for a credit.
pub fn signed<T: Amount>(side: Side, amount: T) -> Option<T> {
match side {
Side::Debit => Some(amount),
Side::Credit => amount.checked_neg(),
}
}
/// Total debits and total credits of some lines.
pub fn totals<T: Amount>(lines: impl IntoIterator<Item = (Side, T)>) -> Option<(T, T)> {
let mut debits = T::ZERO;
let mut credits = T::ZERO;
for (side, amount) in lines {
match side {
Side::Debit => debits = debits.checked_add(amount)?,
Side::Credit => credits = credits.checked_add(amount)?,
}
}
Some((debits, credits))
}
/// The net of some lines: debits minus credits.
pub fn net<T: Amount>(lines: impl IntoIterator<Item = (Side, T)>) -> Option<T> {
let mut total = T::ZERO;
for (side, amount) in lines {
total = total.checked_add(signed(side, amount)?)?;
}
Some(total)
}
/// Check an entry's lines: two or more, every amount positive and within
/// line range, debits equal to credits. Accounts and currencies are the
/// caller's to check; this is the arithmetic.
pub fn check_lines<T: Amount>(lines: &[(Side, T)]) -> Result<(), LineError<T>> {
if lines.len() < 2 {
return Err(LineError::TooFewLines);
}
for &(_, amount) in lines {
if amount == T::ZERO {
return Err(LineError::ZeroAmount);
}
if amount < T::ZERO {
return Err(LineError::NegativeAmount);
}
if !amount.in_line_range() {
return Err(LineError::OutOfRange);
}
}
let (debits, credits) = totals(lines.iter().copied()).ok_or(LineError::OutOfRange)?;
if debits != credits {
return Err(LineError::Unbalanced { debits, credits });
}
Ok(())
}
/// Net movement per account (debits minus credits), in order of first
/// appearance.
pub fn movements<K: Copy + Eq, T: Amount>(
lines: impl IntoIterator<Item = (K, Side, T)>,
) -> Option<Vec<(K, T)>> {
let lines: Vec<_> = lines.into_iter().collect();
let Some(&(first, _, _)) = lines.first() else {
return Some(Vec::new());
};
// At most one account per line.
let mut out = vec![(first, T::ZERO); lines.len()];
let n = movements_into(&lines, &mut out)?;
out.truncate(n);
Some(out)
}
/// [`movements`] into a caller's buffer, without allocating: returns how
/// many accounts it wrote, or `None` on overflow or if `out` is too small.
pub fn movements_into<K: Copy + Eq, T: Amount>(
lines: &[(K, Side, T)],
out: &mut [(K, T)],
) -> Option<usize> {
let mut n = 0;
for &(account, side, amount) in lines {
let delta = signed(side, amount)?;
match out[..n].iter_mut().find(|(k, _)| *k == account) {
Some((_, total)) => *total = total.checked_add(delta)?,
None => {
*out.get_mut(n)? = (account, delta);
n += 1;
}
}
}
Some(n)
}
/// The lines of a reversing entry: every side flipped, accounts and
/// amounts unchanged.
pub fn reversal<K: Copy, T: Amount>(lines: &[(K, Side, T)]) -> Vec<(K, Side, T)> {
lines.iter().map(|&line| reverse_line(line)).collect()
}
/// One line of a reversing entry: the side flipped, account and amount
/// unchanged.
pub fn reverse_line<K, T: Amount>((account, side, amount): (K, Side, T)) -> (K, Side, T) {
(account, flip(side), amount)
}
/// An account's balance read on its normal side: positive is a normal
/// balance, negative a contra balance.
pub fn normal_balance<T: Amount>(account_type: AccountType, movement: T) -> Option<T> {
match account_type.normal_side() {
Side::Debit => Some(movement),
Side::Credit => movement.checked_neg(),
}
}
/// A movement in trial-balance columns: `(debit, credit)`, one of them
/// zero, both non-negative.
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()?))
}
}
/// The line that zeroes an account with this movement: the opposite side,
/// for the same amount. `None` for a zero movement (nothing to close).
pub fn closing_line<T: Amount>(movement: T) -> Option<(Side, T)> {
if movement > T::ZERO {
Some((Side::Credit, movement))
} else if movement < T::ZERO {
Some((Side::Debit, movement.checked_neg()?))
} else {
None
}
}
/// The line that credits `credit` to an account (a negative credit is a
/// debit). `None` for zero. This is [`closing_line`]: crediting `c` and
/// zeroing a movement of `c` are the same line.
pub fn credit_line<T: Amount>(credit: T) -> Option<(Side, T)> {
closing_line(credit)
}
/// Make rounded parts sum to the whole: the last part becomes `whole`
/// minus the others, absorbing every unit of rounding. Returns `None` on
/// overflow, leaving `parts` unchanged.
pub fn plug_last<T: Amount>(whole: T, parts: &mut [T]) -> Option<()> {
let Some((last, rest)) = parts.split_last_mut() else {
return Some(());
};
let mut others = T::ZERO;
for &p in rest.iter() {
others = others.checked_add(p)?;
}
*last = whole.checked_sub(others)?;
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::*;
#[test]
fn max_line_amount_is_one_quadrillion() {
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
assert!(MAX_LINE_AMOUNT.in_line_range());
assert!(!(MAX_LINE_AMOUNT + Decimal::ONE).in_line_range());
assert!("0.0000000001".parse::<Decimal>().unwrap().in_line_range());
assert!(!"0.00000000001".parse::<Decimal>().unwrap().in_line_range());
// Trailing zeros are not precision.
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)));
assert_eq!(credit_line(-5i64), Some((Side::Debit, 5)));
assert_eq!(credit_line(0i64), None);
}
}
File diff suppressed because it is too large. Load diff
+48 -12
View File
@@ -12,21 +12,36 @@
//! * 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.
pub mod account;
pub mod analysis;
pub mod arap;
pub mod assets;
pub mod calendar;
pub mod classes;
pub mod conventions;
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 settings;
pub mod shares;
pub mod tax;
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
pub use arap::{
@@ -34,18 +49,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 journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{Period, PeriodId, PeriodStatus};
pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period,
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::{
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, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
};
pub use reports::{
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);
+771
View File
@@ -0,0 +1,771 @@
//! 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,
) -> Result<EntryFlow, crate::LedgerError> {
use crate::Side;
use rust_decimal::Decimal;
// A draft's lines are as journaled, of any size (rules.md B-28): every
// sum is checked, and one past exact arithmetic is refused.
let too_large =
|| crate::LedgerError::TooLargeToCompute(format!("entry {}'s money flow", entry.id()));
let plus = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large);
let minus = |a: Decimal, b: Decimal| a.checked_sub(b).ok_or_else(too_large);
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 = plus(cash_cr, a)?,
(true, _, Side::Debit) => cash_dr = plus(cash_dr, a)?,
(false, true, Side::Credit) => liab_cr = plus(liab_cr, a)?,
(false, true, Side::Debit) => liab_dr = plus(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|
-> Result<(), crate::LedgerError> {
if !part.is_zero() {
if !by.contains_key(&account) {
order.push(account);
}
let sum = by.entry(account).or_default();
*sum = plus(*sum, part)?;
}
Ok(())
};
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 = plus(*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 = left
.iter()
.map(|(_, v)| *v)
.filter(|v| *v > Decimal::ZERO)
.try_fold(Decimal::ZERO, &plus)?;
let owed_up = -left
.iter()
.map(|(_, v)| *v)
.filter(|v| *v < Decimal::ZERO)
.try_fold(Decimal::ZERO, &plus)?;
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 = plus(
plus(minus(cash_cr, cash_dr)?, minus(liab_cr, liab_dr)?)?,
plus(minus(down, up)?, across)?,
)?;
Ok(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);
// A posted entry's lines are in a line's range, so its flow computes;
// one that doesn't counts nothing and is listed as unresolved.
let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).map(|f| f.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 Ok(net) = net_out(e) else {
unresolved.insert(id);
continue;
};
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(Ok(net), net_out)
.unwrap_or(net),
None => net,
};
if side > Decimal::ZERO {
paid += share;
} else {
received -= share;
}
}
Activity {
entries,
paid,
received,
unresolved,
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Payouts: a platform deposit grossed up into what really happened.
//!
//! App stores, payment processors, and marketplaces collect revenue, keep
//! their fees, and deposit the difference. Booking only the deposit as
//! revenue understates income and hides the fees; the correct entry grosses
//! it up:
//!
//! ```text
//! Dr bank net deposit
//! Dr platform fees fees withheld
//! Dr/Cr adjustments refunds, chargebacks, reserves, tax withheld, ...
//! Cr revenue gross sales (one line per product or class)
//! ```
//!
//! [`payout_plan`] builds those lines and refuses unless gross − fees ±
//! adjustments equals the deposit exactly, naming the difference. It knows
//! no platform: callers (or a format-specific parser) supply the numbers
//! from the payout statement, which is the entry's receipt.
use crate::{AccountId, AccountType, ChartOfAccounts, ClassId, LedgerError, Side};
use rust_decimal::Decimal;
/// A revenue or fee line of a payout.
#[derive(Clone, Debug, PartialEq)]
pub struct PayoutLine {
/// The account.
pub account: AccountId,
/// Positive amount.
pub amount: Decimal,
/// Optional class (product line, app, store).
pub class: Option<ClassId>,
}
/// Anything else on the statement: refunds, chargebacks, reserves held or
/// released, taxes withheld, currency conversion. A debit reduces the
/// deposit; a credit increases it.
#[derive(Clone, Debug, PartialEq)]
pub struct PayoutAdjustment {
/// The account.
pub account: AccountId,
/// Debit (reduces the deposit) or credit (increases it).
pub side: Side,
/// Positive amount.
pub amount: Decimal,
/// Optional class.
pub class: Option<ClassId>,
}
/// A payout to gross up.
#[derive(Clone, Debug, PartialEq)]
pub struct Payout {
/// Where the money landed.
pub deposit_account: AccountId,
/// The net deposit. Negative when the platform debited the account
/// (fees and refunds exceeded sales).
pub deposit: Decimal,
/// Gross revenue, credited.
pub revenue: Vec<PayoutLine>,
/// Fees withheld, debited.
pub fees: Vec<PayoutLine>,
/// Everything else.
pub adjustments: Vec<PayoutAdjustment>,
}
/// The payout's totals.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayoutTotals {
/// Sum of revenue lines.
pub gross: Decimal,
/// Sum of fee lines.
pub fees: Decimal,
/// Net effect of adjustments on the deposit (credits − debits).
pub adjustments: Decimal,
/// The deposit.
pub deposit: Decimal,
}
/// One planned line: account, side, amount, class.
pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
/// Validate a payout and build its entry's lines: the deposit first, then
/// fees, adjustments, and revenue. Errors unless every amount is positive,
/// every account is postable and fits its role (revenue lines credit
/// revenue or liability accounts, e.g. deferred revenue or sales tax
/// collected; fees debit expense accounts), and the payout reconciles to
/// the cent.
/// 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,
) -> Result<(Vec<PayoutPlanLine>, PayoutTotals), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidPayout(m));
if payout.revenue.is_empty() {
return bad("a payout needs at least one revenue line".into());
}
let check = |account: AccountId,
amount: Decimal,
role: &str,
allowed: &[AccountType]|
-> Result<(), LedgerError> {
let a = chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if a.placeholder {
return Err(LedgerError::InvalidPayout(format!(
"{role} account {} is a placeholder",
a.code
)));
}
if !allowed.is_empty() && !allowed.contains(&a.account_type) {
return Err(LedgerError::InvalidPayout(format!(
"{role} account {} is {:?}; expected {}",
a.code,
a.account_type,
allowed
.iter()
.map(|t| format!("{t:?}"))
.collect::<Vec<_>>()
.join(" or ")
)));
}
if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidPayout(format!(
"{role} amounts must be positive, got {amount} on {}",
a.code
)));
}
// No larger than a journal line may be: the payout's totals are
// sums of them (P0 review round 3, S1).
if !crate::kernel::Amount::in_line_range(amount) {
return Err(LedgerError::ValueOutOfRange(format!(
"{role} amount {amount} on {}",
a.code
)));
}
Ok(())
};
if !crate::kernel::Amount::in_line_range(payout.deposit) {
return Err(LedgerError::ValueOutOfRange(format!(
"deposit {}",
payout.deposit
)));
}
let deposit_account = chart
.get(payout.deposit_account)
.ok_or(LedgerError::UnknownAccount(payout.deposit_account))?;
if deposit_account.placeholder || deposit_account.account_type != AccountType::Asset {
return bad(format!(
"deposit account {} must be a postable asset account",
deposit_account.code
));
}
for l in &payout.revenue {
check(
l.account,
l.amount,
"revenue",
&[AccountType::Revenue, AccountType::Liability],
)?;
}
for l in &payout.fees {
check(l.account, l.amount, "fee", &[AccountType::Expense])?;
}
for a in &payout.adjustments {
check(a.account, a.amount, "adjustment", &[])?;
}
let gross: Decimal = payout.revenue.iter().map(|l| l.amount).sum();
let fees: Decimal = payout.fees.iter().map(|l| l.amount).sum();
let adjustments: Decimal = payout
.adjustments
.iter()
.map(|a| match a.side {
Side::Credit => a.amount,
Side::Debit => -a.amount,
})
.sum();
let expected = gross - fees + adjustments;
if expected != payout.deposit {
return bad(format!(
"payout doesn't reconcile: gross {gross} − fees {fees} + adjustments \
{adjustments} = {expected}, but the deposit is {} (off by {})",
payout.deposit,
payout.deposit - expected
));
}
let mut plan = Vec::new();
if payout.deposit > Decimal::ZERO {
plan.push((payout.deposit_account, Side::Debit, payout.deposit, None));
} else if payout.deposit < Decimal::ZERO {
plan.push((payout.deposit_account, Side::Credit, -payout.deposit, None));
}
plan.extend(
payout
.fees
.iter()
.map(|l| (l.account, Side::Debit, l.amount, l.class)),
);
plan.extend(
payout
.adjustments
.iter()
.map(|a| (a.account, a.side, a.amount, a.class)),
);
plan.extend(
payout
.revenue
.iter()
.map(|l| (l.account, Side::Credit, l.amount, l.class)),
);
Ok((
plan,
PayoutTotals {
gross,
fees,
adjustments,
deposit: payout.deposit,
},
))
}
+72 -24
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.
@@ -44,14 +48,20 @@ pub struct PayrollLine {
impl PayrollLine {
/// Gross minus all employee withholdings and deductions. Must be >= 0:
/// you cannot withhold more than the employee earned.
pub fn net_pay(&self) -> Decimal {
self.gross_pay
- self.federal_withholding
- self.state_withholding
- self.ss_employee
- self.medicare_employee
- self.other_deductions
/// you cannot withhold more than the employee earned. Refused
/// (TooLargeToCompute), never a panic, past exact arithmetic: a run is
/// restored as journaled (rules.md B-28).
pub fn net_pay(&self) -> Result<Decimal, LedgerError> {
[
self.federal_withholding,
self.state_withholding,
self.ss_employee,
self.medicare_employee,
self.other_deductions,
]
.into_iter()
.try_fold(self.gross_pay, Decimal::checked_sub)
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{}'s net pay", self.employee_name)))
}
}
@@ -91,7 +101,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 {
@@ -109,13 +122,40 @@ pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError>
return Err(LedgerError::NegativePayrollAmount(amount));
}
}
if line.net_pay() < Decimal::ZERO {
check_payroll_ranges(std::slice::from_ref(line))?;
if line.net_pay()? < Decimal::ZERO {
return Err(LedgerError::NegativeNetPay(line.employee_name.clone()));
}
}
Ok(())
}
/// Every amount of every line no larger than a journal line may be
/// (kernel::MAX_LINE_AMOUNT, ten decimal places), so net pay and the run's
/// totals are exact (P0 review round 3, S1). For new runs only: replay
/// restores a run as journaled (rules.md B-28).
pub fn check_payroll_ranges(lines: &[PayrollLine]) -> Result<(), LedgerError> {
for line in lines {
for amount in [
line.gross_pay,
line.federal_withholding,
line.state_withholding,
line.ss_employee,
line.medicare_employee,
line.ss_employer,
line.medicare_employer,
line.other_deductions,
] {
if !crate::kernel::Amount::in_line_range(amount) {
return Err(LedgerError::ValueOutOfRange(format!(
"payroll amount {amount}"
)));
}
}
}
Ok(())
}
/// The auto-posted payroll entry's lines, in posting order: Dr 6100 gross,
/// Dr 6110 the employer FICA share, Cr 2110–2150 the payables, Cr cash net
/// pay. Zero totals are skipped — the engine rejects zero-amount lines.
@@ -124,21 +164,28 @@ pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError>
/// journaled entry and the posted entry are built from the same math. The
/// plan always balances: debits are gross + employer FICA, credits are the
/// payables + net, and net is defined as gross minus employee withholdings.
/// Refused (TooLargeToCompute) when a total is past exact arithmetic.
pub fn payroll_entry_plan(
lines: &[PayrollLine],
accounts: PayrollAccounts,
cash_account: AccountId,
base: Currency,
) -> Vec<(AccountId, Side, Money)> {
let sum = |f: fn(&PayrollLine) -> Decimal| lines.iter().map(f).sum::<Decimal>();
let gross = sum(|l| l.gross_pay);
let employer = sum(|l| l.ss_employer + l.medicare_employer);
let federal = sum(|l| l.federal_withholding);
let state = sum(|l| l.state_withholding);
let ss = sum(|l| l.ss_employee + l.ss_employer);
let medicare = sum(|l| l.medicare_employee + l.medicare_employer);
let other = sum(|l| l.other_deductions);
let net = sum(|l| l.net_pay());
) -> Result<Vec<(AccountId, Side, Money)>, LedgerError> {
let too_large = || LedgerError::TooLargeToCompute("the payroll run's totals".into());
let sum = |f: &dyn Fn(&PayrollLine) -> Option<Decimal>| {
lines
.iter()
.try_fold(Decimal::ZERO, |t, l| t.checked_add(f(l)?))
.ok_or_else(too_large)
};
let gross = sum(&|l| Some(l.gross_pay))?;
let employer = sum(&|l| l.ss_employer.checked_add(l.medicare_employer))?;
let federal = sum(&|l| Some(l.federal_withholding))?;
let state = sum(&|l| Some(l.state_withholding))?;
let ss = sum(&|l| l.ss_employee.checked_add(l.ss_employer))?;
let medicare = sum(&|l| l.medicare_employee.checked_add(l.medicare_employer))?;
let other = sum(&|l| Some(l.other_deductions))?;
let net = sum(&|l| l.net_pay().ok())?;
let mut plan = Vec::new();
let mut push = |account: AccountId, side: Side, amount: Decimal| {
if !amount.is_zero() {
@@ -153,7 +200,7 @@ pub fn payroll_entry_plan(
push(accounts.medicare_payable, Side::Credit, medicare);
push(accounts.other_payable, Side::Credit, other);
push(cash_account, Side::Credit, net);
plan
Ok(plan)
}
/// The remittance entry's lines: Dr each payable with a non-zero balance,
@@ -191,6 +238,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
@@ -214,7 +262,7 @@ mod tests {
fn net_pay_math() {
let l = line("Ada");
// 5000 - 750 - 250 - 310 - 72.50 - 100 = 3517.50
assert_eq!(l.net_pay(), d(351_750));
assert_eq!(l.net_pay().unwrap(), d(351_750));
}
#[test]
@@ -228,7 +276,7 @@ mod tests {
);
let mut over = line("Cat");
over.other_deductions = d(99_999_999);
assert!(over.net_pay() < Decimal::ZERO);
assert!(over.net_pay().unwrap() < Decimal::ZERO);
assert_eq!(
validate_payroll_lines(std::slice::from_ref(&over)),
Err(LedgerError::NegativeNetPay("Cat".to_string()))
@@ -252,7 +300,7 @@ mod tests {
let mut l = line("Ada");
l.state_withholding = Decimal::ZERO;
l.other_deductions = Decimal::ZERO;
let plan = payroll_entry_plan(&[l], accounts, cash, Currency::USD);
let plan = payroll_entry_plan(&[l], accounts, cash, Currency::USD).unwrap();
assert_eq!(plan.len(), 6); // gross, employer tax, fed, ss, medicare, cash
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
+128 -3
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).
@@ -29,8 +29,105 @@ pub enum PeriodStatus {
Closed,
}
/// Where a period close puts the period's net income, and which owner
/// accounts (draws, distributions, dividends) it zeroes into equity too.
#[derive(Clone, Debug, PartialEq)]
pub enum CloseTo {
/// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
/// missing): the corporate convention. `sweep` lists dividend or
/// distribution accounts to close into it.
RetainedEarnings {
/// Equity accounts zeroed into Retained Earnings.
sweep: Vec<crate::AccountId>,
},
/// One equity account, e.g. a sole proprietor's capital, with `sweep`
/// (e.g. the owner's draws) zeroed into it.
Account {
/// The equity account receiving the net.
into: crate::AccountId,
/// Equity accounts zeroed into it.
sweep: Vec<crate::AccountId>,
},
/// Members' capital accounts by share (shares sum to 1): the
/// partnership convention, where each member's capital account carries
/// their allocated earnings and absorbs their distributions. 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.
#[derive(Clone, Debug, PartialEq)]
pub struct MemberClose {
/// The member's capital account.
pub capital: crate::AccountId,
/// Their share of net income.
pub share: rust_decimal::Decimal,
/// Their distributions account, zeroed into their capital, if they
/// have one.
pub distributions: Option<crate::AccountId>,
}
/// Split `amount` by `shares` (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
.iter()
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
.collect();
crate::kernel::plug_last(amount, &mut parts).expect("allocated amounts are in range");
parts
}
/// Code of the Retained Earnings account that closing entries credit (or
/// debit), created on the first close if the chart lacks it.
pub const RETAINED_EARNINGS_CODE: &str = "3999";
/// A named accounting period: a month, quarter, or year.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Period {
/// Opaque handle.
pub id: PeriodId,
@@ -44,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 {
@@ -54,7 +168,18 @@ impl Period {
}
/// Validate a period's date range before creation.
/// [`validate_period_order`], and both ends inside the calendar the books
/// keep (crate::calendar; P0 review round 3, S1). For new periods; replay
/// checks only the order (rules.md B-28).
pub fn validate_period_range(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
crate::calendar::check(start)?;
crate::calendar::check(end)?;
validate_period_order(start, end)
}
/// The check every journaled `period.created` passed when it was written
/// (the rule through 2026-10-16): the end is not before the start.
pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
if end < start {
return Err(LedgerError::InvalidPeriodRange {
start: start.to_string(),
@@ -130,7 +255,7 @@ mod tests {
assert_eq!(l.account_balance(rev).unwrap(), rust_decimal::Decimal::ZERO);
assert_eq!(l.account_balance(exp).unwrap(), rust_decimal::Decimal::ZERO);
// Retained earnings holds the net: 1000 - 400 = 600 (credit-normal).
let re = l.chart().find_by_code("3999").unwrap().id;
let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id;
assert_eq!(
l.account_balance(re).unwrap(),
"600".parse::<rust_decimal::Decimal>().unwrap()
+568
View File
@@ -0,0 +1,568 @@
//! Kani proof harnesses for the accounting kernel.
//!
//! `cargo kani -p ledger-core` (or `just prove`) model-checks each
//! harness: CBMC explores *every* input within the stated bounds, not a
//! sample, and fails if any one of them breaks an assertion, panics, or
//! overflows. The code under proof is the engine's own [`crate::kernel`],
//! instantiated at `i64` (exact integer money, in the smallest unit); the
//! engine runs the same functions on `Decimal`, and the `kernel_bridge`
//! property test checks the two instantiations agree.
//!
//! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and
//! amounts up to 2^40 in magnitude. The Lean model in `verify/lean`
//! proves the same laws for any number of lines and accounts.
use crate::kernel::{self, Amount, LineError};
use crate::{AccountType, Side};
/// Most lines per entry the harnesses explore.
const N: usize = 4;
/// Accounts the harnesses' lines draw from.
const K: u8 = 3;
fn any_side() -> Side {
if kani::any() {
Side::Debit
} else {
Side::Credit
}
}
fn any_type() -> AccountType {
// An assumption, not `% 5`: modulo is costly for the SAT solver.
let t: u8 = kani::any();
kani::assume(t < 5);
match t {
0 => AccountType::Asset,
1 => AccountType::Liability,
2 => AccountType::Equity,
3 => AccountType::Revenue,
_ => AccountType::Expense,
}
}
/// Any amount a journal line could carry, sign included.
fn any_amount() -> i64 {
let a: i64 = kani::any();
kani::assume(a.in_line_range());
a
}
/// Any count up to `N`. Harnesses fill fixed arrays and take a prefix of
/// this length: fixed arrays keep the model free of allocation (which is
/// also why they call `movements_into` rather than `movements`).
fn any_len() -> usize {
let n: usize = kani::any();
kani::assume(n <= N);
n
}
/// `N` lines, any sides, any in-range amounts (use a prefix).
fn any_lines() -> [(Side, i64); N] {
core::array::from_fn(|_| (any_side(), any_amount()))
}
/// `N` lines over `K` accounts (use a prefix).
fn any_account_lines() -> [(u8, Side, i64); N] {
core::array::from_fn(|_| {
let k: u8 = kani::any();
kani::assume(k < K);
(k, any_side(), any_amount())
})
}
/// The mathematical signed amount. Amounts are at most 2^40 and no
/// harness sums more than eight, so `i64` holds every spec sum exactly
/// (and Kani would flag it if not).
fn wide(side: Side, amount: i64) -> i64 {
match side {
Side::Debit => amount,
Side::Credit => -(amount),
}
}
fn wide_net(lines: &[(Side, i64)]) -> i64 {
lines.iter().map(|&(s, a)| wide(s, a)).sum()
}
/// The sides and amounts of some account lines, into a fixed array.
fn sides(lines: &[(u8, Side, i64)]) -> [(Side, i64); N] {
let mut out = [(Side::Debit, 0); N];
for (o, &(_, s, a)) in out.iter_mut().zip(lines) {
*o = (s, a);
}
out
}
// ---------------------------------------------------------------------------
// Lines and entries
// ---------------------------------------------------------------------------
/// Flipping a side negates its signed amount, and flipping twice is the
/// identity.
#[kani::proof]
fn flip_negates() {
let side = any_side();
let a = any_amount();
assert_eq!(kernel::flip(kernel::flip(side)), side);
assert_ne!(kernel::flip(side), side);
assert_eq!(kernel::signed(kernel::flip(side), a), Some(-wide(side, a)));
}
/// In-range lines never overflow the kernel's sums: `totals` and `net`
/// always succeed, and agree with the mathematical sums. (`movements`
/// is covered by its own harnesses.)
#[kani::proof]
#[kani::unwind(6)]
fn sums_are_exact() {
let all = any_account_lines();
let lines = &all[..any_len()];
let all_sides = sides(lines);
let s = &all_sides[..lines.len()];
let (debits, credits) = kernel::totals(s.iter().copied()).expect("no overflow");
let net = kernel::net(s.iter().copied()).expect("no overflow");
assert_eq!(net, wide_net(s));
assert_eq!(debits - credits, wide_net(s));
}
/// The kernel accepts an entry exactly when it has two or more lines,
/// every amount is positive and in range, and debits equal credits.
#[kani::proof]
#[kani::unwind(6)]
fn check_lines_is_exact() {
let all = any_lines();
let lines = &all[..any_len()];
let positive = lines.iter().all(|&(_, a)| a > 0);
let debits: i64 = lines
.iter()
.filter(|l| l.0 == Side::Debit)
.map(|l| l.1)
.sum();
let credits: i64 = lines
.iter()
.filter(|l| l.0 == Side::Credit)
.map(|l| l.1)
.sum();
let spec = lines.len() >= 2 && positive && debits == credits;
let got = kernel::check_lines(lines);
assert_eq!(got.is_ok(), spec);
if let Err(LineError::Unbalanced {
debits: d,
credits: c,
}) = got
{
assert_eq!((d, c), (debits, credits));
}
}
/// An out-of-range amount is always rejected.
#[kani::proof]
fn out_of_range_is_rejected() {
let big: i64 = kani::any();
kani::assume(big > 0 && !big.in_line_range());
let side = any_side();
let lines = [(side, big), (kernel::flip(side), big)];
assert_eq!(kernel::check_lines(&lines), Err(LineError::OutOfRange));
}
/// An accepted entry nets to zero.
#[kani::proof]
#[kani::unwind(6)]
fn accepted_entry_nets_to_zero() {
let all = any_lines();
let lines = &all[..any_len()];
if kernel::check_lines(lines).is_ok() {
assert_eq!(kernel::net(lines.iter().copied()), Some(0));
kani::cover!(lines.len() == N, "an accepted four-line entry");
}
}
/// Movements group lines by account without losing or inventing money:
/// any account appears at most once, and its movement is the net of its
/// own lines (zero if it has none).
#[kani::proof]
#[kani::unwind(6)]
fn movements_are_per_account() {
let all = any_account_lines();
let lines = &all[..any_len()];
let mut out = [(0u8, 0i64); N];
let n = kernel::movements_into(lines, &mut out).expect("no overflow, room for all");
let moves = &out[..n];
let k: u8 = kani::any();
kani::assume(k < K);
let mut own = 0i64;
let mut has = false;
for &(a, s, x) in lines {
if a == k {
own += wide(s, x);
has = true;
}
}
let mut found = 0;
let mut movement = 0i64;
for &(a, m) in moves {
if a == k {
found += 1;
movement = m;
}
}
assert_eq!(found, usize::from(has));
assert_eq!(movement, own);
kani::cover!(n == 3, "three distinct accounts");
}
/// The movements sum to the lines' net: zero for an accepted entry.
#[kani::proof]
#[kani::unwind(6)]
fn movements_sum_to_the_net() {
let all = any_account_lines();
let lines = &all[..any_len()];
let mut out = [(0u8, 0i64); N];
let n = kernel::movements_into(lines, &mut out).expect("no overflow, room for all");
let total: i64 = out[..n].iter().map(|&(_, m)| m).sum();
let all_sides = sides(lines);
let s = &all_sides[..lines.len()];
assert_eq!(total, wide_net(s));
if kernel::check_lines(s).is_ok() {
assert_eq!(total, 0);
kani::cover!(
n >= 2 && lines.len() == N,
"an accepted entry over several accounts"
);
}
}
/// A reversal flips every side and keeps every account and amount, so it
/// is accepted exactly when the original is. (`kernel::reversal` is
/// `reverse_line` mapped over the lines; proving it line by line keeps
/// the model free of allocation.)
#[kani::proof]
#[kani::unwind(6)]
fn reversal_mirrors() {
let all = any_account_lines();
let lines = &all[..any_len()];
let mut rev = [(0u8, Side::Debit, 0i64); N];
for i in 0..lines.len() {
rev[i] = kernel::reverse_line(lines[i]);
assert_eq!((rev[i].0, rev[i].2), (lines[i].0, lines[i].2));
assert_eq!(rev[i].1, kernel::flip(lines[i].1));
}
let n = lines.len();
assert_eq!(
kernel::check_lines(&sides(&rev[..n])[..n]).is_ok(),
kernel::check_lines(&sides(lines)[..n]).is_ok()
);
kani::cover!(
n == N && kernel::check_lines(&sides(lines)[..n]).is_ok(),
"an accepted four-line entry, reversed"
);
}
/// An entry and its reversal together net every account to zero.
#[kani::proof]
#[kani::unwind(10)]
fn reversal_cancels() {
let all = any_account_lines();
let n = any_len();
let lines = &all[..n];
// Element by element, never `copy_from_slice`: Kani 0.68 mis-models a
// bulk copy of symbolic length (see `kani_symbolic_copy_canary`).
let mut both = [(0u8, Side::Debit, 0i64); 2 * N];
for i in 0..n {
let (k, s, a) = lines[i];
both[i] = lines[i];
both[n + i] = kernel::reverse_line((k, s, a));
}
let mut out = [(0u8, 0i64); 2 * N];
let m = kernel::movements_into(&both[..2 * n], &mut out).expect("no overflow");
for &(_, total) in &out[..m] {
assert_eq!(total, 0);
}
// Not vacuous: an account with several lines on both sides is reached.
kani::cover!(n == N && m == 1, "four lines on one account, reversed");
}
// ---------------------------------------------------------------------------
// Balances and statements
// ---------------------------------------------------------------------------
/// A normal balance is the movement for assets and expenses, and its
/// negation for liabilities, equity, and revenue. This is the fact the
/// accounting equation rests on: the Lean theorem `accounting_equation`
/// proves it gives Assets = Liabilities + Equity + (Revenue − Expenses)
/// for any number of accounts whose movements net to zero.
#[kani::proof]
fn normal_balance_is_signed() {
let t = any_type();
let m = any_amount();
let b = kernel::normal_balance(t, m).expect("in range");
match t {
AccountType::Asset | AccountType::Expense => assert_eq!(b, m),
AccountType::Liability | AccountType::Equity | AccountType::Revenue => {
assert_eq!(b, -m)
}
}
}
/// The accounting equation end to end on the code, for up to three
/// accounts: whenever movements net to zero,
/// Assets = Liabilities + Equity + (Revenue − Expenses). (Regrouping a
/// sum of signed 64-bit values is hard for a SAT solver, so the bound is
/// small here; `normal_balance_is_signed` and the Lean proof cover the
/// rest.)
#[kani::proof]
#[kani::unwind(4)]
fn accounting_equation() {
const EQ: usize = 3;
let all: [(AccountType, i64); EQ] = core::array::from_fn(|_| (any_type(), any_amount()));
let n: usize = kani::any();
kani::assume(n <= EQ);
let accounts = &all[..n];
let (mut net, mut a, mut l, mut e, mut r, mut x) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64);
for &(t, m) in accounts {
net += m;
let b = kernel::normal_balance(t, m).expect("in range");
match t {
AccountType::Asset => a += b,
AccountType::Liability => l += b,
AccountType::Equity => e += b,
AccountType::Revenue => r += b,
AccountType::Expense => x += b,
}
}
assert_eq!(a - (l + e + (r - x)), net);
if net == 0 {
assert_eq!(a, l + e + (r - x));
kani::cover!(n == EQ, "a balanced three-account chart");
}
}
/// A normal-side line raises an account's balance by its amount; an
/// opposite-side line lowers it.
#[kani::proof]
fn normal_side_increases() {
let t = any_type();
let a = any_amount();
let side = any_side();
let movement = kernel::signed(side, a).expect("in range");
let bal = kernel::normal_balance(t, movement).expect("in range");
if side == t.normal_side() {
assert_eq!(bal, a);
} else {
assert_eq!(bal, -a);
}
}
/// A trial balance puts each movement in exactly one non-negative column,
/// so its columns balance exactly when movements net to zero.
#[kani::proof]
#[kani::unwind(6)]
fn trial_balance_columns() {
let all: [i64; N] = core::array::from_fn(|_| any_amount());
let moves = &all[..any_len()];
let (mut debits, mut credits) = (0i64, 0i64);
for &m in moves {
let (d, c) = kernel::columns(m).expect("in range");
assert!(d >= 0 && c >= 0);
assert!(d == 0 || c == 0);
assert_eq!(d - c, m);
debits += d;
credits += c;
}
let net: i64 = moves.iter().map(|&m| m).sum();
assert_eq!(debits - credits, net);
assert_eq!(debits == credits, net == 0);
}
// ---------------------------------------------------------------------------
// Closing
// ---------------------------------------------------------------------------
/// A closing line zeroes its account: opposite side, same amount, nothing
/// for a zero balance.
#[kani::proof]
fn closing_line_zeroes() {
let m = any_amount();
match kernel::closing_line(m) {
None => assert_eq!(m, 0),
Some((side, amount)) => {
assert!(amount > 0);
assert_eq!(m + wide(side, amount), 0);
}
}
}
/// `credit_line` credits exactly what it is asked to.
#[kani::proof]
fn credit_line_credits() {
let c = any_amount();
match kernel::credit_line(c) {
None => assert_eq!(c, 0),
Some((side, amount)) => {
assert!(amount > 0);
assert_eq!(wide(side, amount), -c);
}
}
}
/// A period close balances and zeroes every revenue and expense account:
/// one closing line per nonzero nominal account, plus the net credited to
/// equity (whole, or split among members): the entry's lines net to
/// zero, and each nominal account ends at zero.
#[kani::proof]
#[kani::unwind(6)]
fn closing_entry_balances() {
const NOMINAL: usize = 3;
let nominal: [i64; NOMINAL] = core::array::from_fn(|_| any_amount());
// Each nominal account's closing line zeroes it.
let mut entry = [(Side::Debit, 0i64); NOMINAL + 2];
let mut len = 0;
for &m in &nominal {
if let Some((side, amount)) = kernel::closing_line(m) {
assert_eq!(m + wide(side, amount), 0);
entry[len] = (side, amount);
len += 1;
} else {
assert_eq!(m, 0);
}
}
let net = kernel::net(entry[..len].iter().copied()).expect("no overflow");
// Split the net between two members, the first share rounded any way.
let mut parts = [any_amount(), 0];
kernel::plug_last(net, &mut parts).expect("no overflow");
for p in parts {
if let Some(line) = kernel::credit_line(p) {
entry[len] = line;
len += 1;
}
}
assert_eq!(wide_net(&entry[..len]), 0);
kani::cover!(len == NOMINAL + 2, "three accounts closed to two members");
}
/// An allocation's parts sum to the whole exactly, whatever the rounding
/// of the others, and only the last part changes.
#[kani::proof]
#[kani::unwind(6)]
fn plug_last_sums_exactly() {
let whole = any_amount();
let n = any_len();
let mut all: [i64; N] = core::array::from_fn(|_| any_amount());
let before = all;
let parts = &mut all[..n];
kernel::plug_last(whole, parts).expect("no overflow");
if n > 0 {
let sum: i64 = parts.iter().map(|&p| p).sum();
assert_eq!(sum, whole);
for i in 0..n - 1 {
assert_eq!(parts[i], before[i]);
}
}
}
/// 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
// ---------------------------------------------------------------------------
/// A canary for a known Kani bug: Kani 0.68 (CBMC 6.11) mis-models a bulk
/// copy whose length is symbolic, so this correct copy "fails"
/// (model-checking/kani#4904, closed as a duplicate, unfixed in 0.68). The
/// bug is unsound in both directions, so no harness above copies a slice
/// of symbolic length. This harness is expected to fail; when a Kani
/// upgrade fixes the bug it will start passing, `should_panic` will then
/// report it, and this canary and the comment in `reversal_cancels` can go.
#[kani::proof]
#[kani::unwind(10)]
#[kani::should_panic]
fn kani_symbolic_copy_canary() {
let src: [i64; N] = kani::any();
let n = any_len();
let mut dst = [0i64; 2 * N];
dst[..n].copy_from_slice(&src[..n]);
for i in 0..n {
assert_eq!(dst[i], src[i]);
}
}
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
//! 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 (a path in this repo,
/// for the people who update the rule).
pub source: &'static str,
/// The authority itself, as an agent or a human can find it: the
/// statute or publication, with its official address. What every
/// agent-facing text names, never the repo's copy.
pub authority: &'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",
authority: "26 U.S.C. §6041(a) and (h) and the IRS's yearly figure in the Instructions for Forms 1099-MISC and 1099-NEC (https://www.irs.gov/instructions/i1099mec)",
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()
}
+516
View File
@@ -0,0 +1,516 @@
//! Things that happen on a schedule.
//!
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
//!
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
//! evenly over the months it covers: a year of insurance paid up front is
//! an asset expensed 1/12 a month; a year of subscription revenue
//! collected up front is a liability earned 1/12 a month. Like
//! depreciation, the schedule is the evidence for its postings, so they
//! need no receipts. Each month posts once (idempotent runs).
//! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts*
//! on their dates, never posted entries: the agent attaches the actual
//! receipt and posts through the normal preview, so the receipt policy is
//! never bypassed.
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side};
use chrono::{Datelike, Days, Months, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
/// Opaque handle for an amortization schedule.
pub type ScheduleId = u64;
/// Opaque handle for a recurring entry.
pub type RecurringId = u64;
/// One line of a template entry: account, side, amount.
pub type TemplateLine = (AccountId, Side, Money);
/// What an amortization schedule recognizes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleKind {
/// A prepaid expense: each month debits the expense account and
/// credits the prepaid (asset) account.
PrepaidExpense,
/// Deferred (unearned) revenue: each month debits the deferred revenue
/// (liability) account and credits the revenue account.
DeferredRevenue,
}
/// An amortization schedule.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AmortizationSchedule {
/// Opaque handle.
pub id: ScheduleId,
/// What it is, e.g. "2027 general liability insurance".
pub description: String,
/// Prepaid expense or deferred revenue.
pub kind: ScheduleKind,
/// The amount to spread.
pub total: Money,
/// Any date in the first month covered.
pub start: NaiveDate,
/// Months covered.
pub months: u32,
/// The balance-sheet account: prepaid (asset) or deferred revenue
/// (liability).
pub balance_account: AccountId,
/// The P&L account: expense or revenue.
pub pl_account: AccountId,
/// Months already posted, as their month-end dates.
pub posted: BTreeSet<NaiveDate>,
}
/// Parameters for a new schedule.
#[derive(Clone, Debug)]
pub struct NewSchedule {
/// What it is.
pub description: String,
/// Prepaid expense or deferred revenue.
pub kind: ScheduleKind,
/// The amount to spread (positive).
pub total: Money,
/// Any date in the first month covered.
pub start: NaiveDate,
/// Months covered (at least 1).
pub months: u32,
/// Prepaid (asset) or deferred revenue (liability) account.
pub balance_account: AccountId,
/// Expense or revenue account.
pub pl_account: AccountId,
}
/// The last day of `d`'s month: in chrono's last month (December 262142),
/// NaiveDate::MAX, never a panic.
pub fn month_end(d: NaiveDate) -> NaiveDate {
d.with_day(1)
.and_then(|first| first.checked_add_months(Months::new(1)))
.and_then(|next| next.checked_sub_days(Days::new(1)))
.unwrap_or(NaiveDate::MAX)
}
/// The smallest total whose plan isn't computed exactly in cents (10^26):
/// 26 integer digits and 2 decimals fill Decimal's 28 significant digits.
pub const EXACT_LIMIT: Decimal =
Decimal::from_parts(0xe400_0000, 0xdcc8_0cd2, 0x0052_b7d2, false, 0);
/// The schedule's monthly postings: (month-end date, amount). Equal
/// amounts to the cent, the last month absorbing rounding, so they sum to
/// the total exactly.
///
/// ```text
/// INVARIANTS:
/// 1. The amounts sum to `total` exactly; all but the last are equal.
/// 2. NEVER panics: a schedule an earlier version journaled whose months
/// run past chrono's last day is refused with DateOutOfRange, and one
/// whose amounts exact arithmetic can't hold with TooLargeToCompute
/// (rules.md B-28). `months` is at least 1 (validate_schedule_shape).
/// 3. NEVER rounds silently: a total of EXACT_LIMIT or more is refused
/// with TooLargeToCompute. Below it every amount in cents, and every
/// sum of them (none exceeds the total), fits Decimal's 28 significant
/// digits, so INVARIANT 1 holds exactly; at it, Decimal's checked
/// operations round instead of failing.
/// ```
///
/// Replay is unaffected: a posted schedule month passed the line limit
/// (kernel::MAX_LINE_AMOUNT), so no journaled schedule.posted belongs to a
/// schedule with a total anywhere near EXACT_LIMIT.
pub fn amortization_plan(
total: Decimal,
start: NaiveDate,
months: u32,
) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
let too_large = || LedgerError::TooLargeToCompute(format!("a {months}-month plan of {total}"));
if total.abs() >= EXACT_LIMIT {
return Err(too_large());
}
let each = total
.checked_div(Decimal::from(months))
.ok_or_else(too_large)?
.round_dp(2);
let first = start
.with_day(1)
.ok_or(LedgerError::DateOutOfRange(start))?;
(0..months)
.map(|i| {
let month = first
.checked_add_months(Months::new(i))
.ok_or(LedgerError::DateOutOfRange(start))?;
let amount = if i + 1 == months {
each.checked_mul(Decimal::from(months - 1))
.and_then(|before| total.checked_sub(before))
.ok_or_else(too_large)?
} else {
each
};
Ok((month_end(month), amount))
})
.collect()
}
/// Validate a new schedule against the chart: [`validate_schedule_shape`],
/// the total in range and in cents, and its first and last months inside
/// the calendar the books keep (crate::calendar; P0 review round 3, S1),
/// so every month end of the plan is a date.
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
validate_schedule_shape(new, chart)?;
crate::calendar::check(new.start)?;
if !new
.start
.checked_add_months(Months::new(new.months - 1))
.is_some_and(crate::calendar::in_calendar)
{
return Err(LedgerError::InvalidSchedule(format!(
"{} months from {} run past 9999-12-31, the last day the books keep",
new.months, new.start
)));
}
// 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 or the calendar bound added in P0: the checks every
/// journaled `schedule.registered` passed when it was written, for
/// replaying journals written before them (rules.md B-28).
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());
}
if new.total.amount <= Decimal::ZERO {
return bad(format!("total must be positive, got {}", new.total.amount));
}
if new.months == 0 || new.months > 600 {
return bad(format!("months must be 1 to 600, got {}", new.months));
}
let (want_balance, want_pl) = match new.kind {
ScheduleKind::PrepaidExpense => (AccountType::Asset, AccountType::Expense),
ScheduleKind::DeferredRevenue => (AccountType::Liability, AccountType::Revenue),
};
for (id, want) in [
(new.balance_account, want_balance),
(new.pl_account, want_pl),
] {
let a = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
if a.account_type != want || a.placeholder {
return bad(format!(
"account {} must be a postable {want:?} account for a {:?} schedule",
a.code, new.kind
));
}
}
Ok(())
}
impl AmortizationSchedule {
/// The postings for one month: (debit account, credit account).
pub fn accounts(&self) -> (AccountId, AccountId) {
match self.kind {
ScheduleKind::PrepaidExpense => (self.pl_account, self.balance_account),
ScheduleKind::DeferredRevenue => (self.balance_account, self.pl_account),
}
}
/// Its monthly postings ([`amortization_plan`]); refused, never a
/// panic, for one that can't be computed.
pub fn plan(&self) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
amortization_plan(self.total.amount, self.start, self.months)
}
/// Months due through `through` and not yet posted.
pub fn due(&self, through: NaiveDate) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
Ok(self
.plan()?
.into_iter()
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
.collect())
}
/// The amount not yet recognized: the sum of the plan's unposted
/// months.
pub fn remaining(&self) -> Result<Decimal, LedgerError> {
self.plan()?
.into_iter()
.filter(|(d, _)| !self.posted.contains(d))
.try_fold(Decimal::ZERO, |sum, (_, a)| sum.checked_add(a))
.ok_or_else(|| {
LedgerError::TooLargeToCompute(format!("schedule {}'s remaining amount", self.id))
})
}
}
/// How often a recurring entry happens.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Frequency {
/// Every 7 days.
Weekly,
/// Every month, on the start date's day (clamped to month end).
Monthly,
/// Every three months.
Quarterly,
/// Every year.
Yearly,
}
impl Frequency {
/// Parse "weekly", "monthly", "quarterly", "yearly" ("annually").
pub fn parse(s: &str) -> Option<Frequency> {
match s.trim().to_lowercase().as_str() {
"weekly" => Some(Frequency::Weekly),
"monthly" => Some(Frequency::Monthly),
"quarterly" => Some(Frequency::Quarterly),
"yearly" | "annually" | "annual" => Some(Frequency::Yearly),
_ => None,
}
}
/// The `k`th occurrence from `start`, computed from the start each
/// time so month-end dates don't drift (Jan 31 → Feb 28 → Mar 31).
pub fn nth(&self, start: NaiveDate, k: u32) -> Option<NaiveDate> {
match self {
Frequency::Weekly => start.checked_add_days(Days::new(7 * k as u64)),
Frequency::Monthly => start.checked_add_months(Months::new(k)),
Frequency::Quarterly => start.checked_add_months(Months::new(3 * k)),
Frequency::Yearly => start.checked_add_months(Months::new(12 * k)),
}
}
}
/// A recurring entry: a balanced template and when it happens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RecurringEntry {
/// Opaque handle.
pub id: RecurringId,
/// Description for each generated entry.
pub description: String,
/// The balanced lines.
pub lines: Vec<TemplateLine>,
/// How often.
pub frequency: Frequency,
/// First occurrence.
pub start: NaiveDate,
/// Last date an occurrence may fall on, if it ends.
pub end: Option<NaiveDate>,
/// Occurrences already generated, by date.
pub generated: BTreeSet<NaiveDate>,
}
/// Parameters for a new recurring entry.
#[derive(Clone, Debug)]
pub struct NewRecurring {
/// Description for each generated entry.
pub description: String,
/// The balanced lines.
pub lines: Vec<TemplateLine>,
/// How often.
pub frequency: Frequency,
/// First occurrence.
pub start: NaiveDate,
/// Optional last date.
pub end: Option<NaiveDate>,
}
/// Validate a new recurring entry: [`validate_recurring_journaled`], its
/// dates inside the calendar the books keep, and no line larger than a
/// journal line may be (P0 review round 3, S1).
pub fn validate_recurring(new: &NewRecurring, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
// The size first: a line past the limit is out of range, whatever its
// sum would be.
if new
.lines
.iter()
.any(|(_, _, money)| money.amount > crate::kernel::MAX_LINE_AMOUNT)
{
return Err(LedgerError::AmountOutOfRange);
}
validate_recurring_journaled(new, chart)?;
crate::calendar::check(new.start)?;
if let Some(end) = new.end {
crate::calendar::check(end)?;
}
Ok(())
}
/// The checks every journaled `recurring.registered` passed when it was
/// written (the rule through 2026-10-16): at least two lines, positive
/// amounts, postable accounts, balanced, and an end not before the start.
/// Replay applies exactly these (rules.md B-28). Sums too large for exact
/// arithmetic are refused (TooLargeToCompute), never a panic.
pub fn validate_recurring_journaled(
new: &NewRecurring,
chart: &ChartOfAccounts,
) -> Result<(), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
if new.description.trim().is_empty() {
return bad("description is required".into());
}
if new.lines.len() < 2 {
return bad("a recurring entry needs at least two lines".into());
}
if new.end.is_some_and(|e| e < new.start) {
return bad("end is before start".into());
}
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
for (account, side, money) in &new.lines {
let a = chart
.get(*account)
.ok_or(LedgerError::UnknownAccount(*account))?;
if a.placeholder {
return bad(format!("account {} is a placeholder", a.code));
}
if money.amount <= Decimal::ZERO {
return bad(format!(
"line amounts must be positive, got {}",
money.amount
));
}
let total = match side {
Side::Debit => &mut debits,
Side::Credit => &mut credits,
};
*total = total
.checked_add(money.amount)
.ok_or_else(|| LedgerError::TooLargeToCompute("the recurring entry's total".into()))?;
}
if debits != credits {
return bad(format!(
"lines don't balance: debits {debits}, credits {credits}"
));
}
Ok(())
}
impl RecurringEntry {
/// Occurrences on or before `through` (and the end), not yet generated.
pub fn due(&self, through: NaiveDate) -> Vec<NaiveDate> {
let last = self.end.map_or(through, |e| e.min(through));
(0..)
.map_while(|k| self.frequency.nth(self.start, k).filter(|d| *d <= last))
.filter(|d| !self.generated.contains(d))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn plans_sum_exactly_and_land_on_month_ends() {
let plan = amortization_plan(Decimal::new(100_000, 2), d(2026, 11, 15), 3).unwrap();
// 1,000.00 / 3 = 333.33, 333.33, 333.34.
assert_eq!(
plan,
[
(d(2026, 11, 30), Decimal::new(33_333, 2)),
(d(2026, 12, 31), Decimal::new(33_333, 2)),
(d(2027, 1, 31), Decimal::new(33_334, 2)),
]
);
let leap = amortization_plan(Decimal::new(1200, 0), d(2028, 2, 1), 1).unwrap();
assert_eq!(leap, [(d(2028, 2, 29), Decimal::new(1200, 0))]);
}
/// A schedule an earlier version journaled, at chrono's last month or
/// with Decimal's largest total: its plan is computed where it can be,
/// refused where it can't, never a panic (rules.md B-28).
#[test]
fn plans_at_the_edges_are_computed_or_refused() {
assert_eq!(month_end(NaiveDate::MAX), NaiveDate::MAX);
assert_eq!(month_end(d(262_142, 12, 1)), NaiveDate::MAX);
assert_eq!(month_end(d(262_142, 11, 5)), d(262_142, 11, 30));
assert_eq!(month_end(NaiveDate::MIN), d(NaiveDate::MIN.year(), 1, 31));
// The last month chrono holds is a month of the plan.
assert_eq!(
amortization_plan(Decimal::new(300, 0), d(262_142, 11, 9), 2).unwrap(),
[
(d(262_142, 11, 30), Decimal::new(150, 0)),
(NaiveDate::MAX, Decimal::new(150, 0)),
]
);
// One month further is none.
assert_eq!(
amortization_plan(Decimal::new(300, 0), d(262_142, 11, 9), 3),
Err(LedgerError::DateOutOfRange(d(262_142, 11, 9)))
);
// Past the line limit (replay takes any positive total): exact, the
// last month the remainder, right up to just under EXACT_LIMIT.
for big in ["2000000000000000.00", "99999999999999999999999999.99"] {
let big: Decimal = big.parse().unwrap();
let plan = amortization_plan(big, d(2026, 1, 1), 600).unwrap();
assert_eq!(plan.len(), 600);
assert_eq!(
plan.iter()
.try_fold(Decimal::ZERO, |s, (_, a)| s.checked_add(*a)),
Some(big)
);
}
// At EXACT_LIMIT and beyond, Decimal would round: refused, never
// rounded (INVARIANT 3), in either sign.
assert_eq!(
EXACT_LIMIT,
Decimal::from_i128_with_scale(10_i128.pow(26), 0)
);
for total in [EXACT_LIMIT, -EXACT_LIMIT, Decimal::MAX, Decimal::MIN] {
assert!(matches!(
amortization_plan(total, d(2026, 1, 1), 600),
Err(LedgerError::TooLargeToCompute(_))
));
}
let s = AmortizationSchedule {
id: 1,
description: "edge".into(),
kind: ScheduleKind::PrepaidExpense,
total: Money::new(Decimal::new(300, 0), crate::Currency::USD),
start: d(262_142, 11, 9),
months: 3,
balance_account: 1,
pl_account: 2,
posted: BTreeSet::new(),
};
assert!(s.due(NaiveDate::MAX).is_err());
assert!(s.remaining().is_err());
let s = AmortizationSchedule { months: 2, ..s };
assert_eq!(s.due(NaiveDate::MAX).unwrap().len(), 2);
assert_eq!(s.remaining(), Ok(Decimal::new(300, 0)));
}
#[test]
fn monthly_occurrences_do_not_drift() {
let f = Frequency::Monthly;
let dates: Vec<NaiveDate> = (0..4).map(|k| f.nth(d(2026, 1, 31), k).unwrap()).collect();
assert_eq!(
dates,
[
d(2026, 1, 31),
d(2026, 2, 28),
d(2026, 3, 31),
d(2026, 4, 30)
]
);
assert_eq!(
Frequency::Quarterly.nth(d(2026, 1, 15), 3),
Some(d(2026, 10, 15))
);
assert_eq!(
Frequency::Weekly.nth(d(2026, 1, 1), 2),
Some(d(2026, 1, 15))
);
assert_eq!(Frequency::parse("Annually"), Some(Frequency::Yearly));
}
}
File diff suppressed because it is too large. Load diff
+363
View File
@@ -0,0 +1,363 @@
//! 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.
///
/// For new shares: [`check_shares_journaled`], and no amount larger than a
/// line may be (kernel::MAX_LINE_AMOUNT; P0 review round 3, S1). Replay
/// checks only [`check_shares_journaled`] (rules.md B-28).
pub fn check_shares(shares: &[PartyShare], known: impl Fn(PartyId) -> bool) -> Result<(), String> {
check_shares_journaled(shares, known)?;
for s in shares {
if let Share::Amount(a) = s.share
&& a > crate::kernel::MAX_LINE_AMOUNT
{
return Err(format!(
"party {}'s amount {a} is larger than any entry may move (at most one quadrillion)",
s.party
));
}
}
Ok(())
}
/// The checks every journaled `entry.parties_set` passed when it was
/// written (the rule through 2026-10-16): [`check_shares`] without the
/// amount's upper bound. Replay applies exactly these, so books an earlier
/// version wrote always open (rules.md B-28).
pub fn check_shares_journaled(
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).
/// Refused (TooLargeToCompute), never a panic, when a sum is past exact
/// arithmetic: a draft's lines and an entry's shares are as journaled, of
/// any size (rules.md B-28).
pub fn share_target(
entry: &JournalEntry,
money: &crate::parties::MoneyAccounts,
) -> Result<Decimal, crate::LedgerError> {
let net = crate::parties::entry_flow(entry, money)?.net;
if net.is_zero() {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount))
.ok_or_else(|| too_large(format!("entry {}'s debits", entry.id())))
} else {
Ok(net)
}
}
/// The refusal of a sum past exact arithmetic.
fn too_large(what: String) -> crate::LedgerError {
crate::LedgerError::TooLargeToCompute(what)
}
/// The sum of `values`, or None past exact arithmetic.
fn checked_sum(values: impl IntoIterator<Item = Decimal>) -> Option<Decimal> {
values
.into_iter()
.try_fold(Decimal::ZERO, |s, v| s.checked_add(v))
}
/// Split `magnitude` (non-negative) by `weights` (positive, any sum): each
/// part rounded toward zero to the cent, the cents left over to the first.
/// None when a product or sum is past exact arithmetic.
fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Option<Vec<Decimal>> {
use rust_decimal::RoundingStrategy::ToZero;
let total = checked_sum(weights.iter().copied())?;
if weights.is_empty() || total.is_zero() {
return Some(vec![Decimal::ZERO; weights.len()]);
}
let mut parts: Vec<Decimal> = weights
.iter()
.map(|w| {
magnitude
.checked_mul(*w)
.and_then(|x| x.checked_div(total))
.map(|x| x.round_dp_with_strategy(2, ToZero))
})
.collect::<Option<_>>()?;
let left = magnitude.checked_sub(checked_sum(parts.iter().copied())?)?;
parts[0] = parts[0].checked_add(left)?;
Some(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).map_err(|e| e.to_string())?;
let past_exact = || too_large(format!("entry {}'s shares", entry.id())).to_string();
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 = checked_sum(amounts.iter().copied()).ok_or_else(past_exact)?;
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).ok_or_else(past_exact)?
}
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| {
checked_sum(entry.lines().iter().filter(|l| on(m, l.account)).map(|l| {
match l.side {
Side::Credit => l.amount.amount,
Side::Debit => -l.amount.amount,
}
}))
.map(|s| s.abs())
})
.collect::<Option<_>>()
.ok_or_else(past_exact)?;
let sum = checked_sum(parts.iter().copied()).ok_or_else(past_exact)?;
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_or_else(past_exact)?
}
}
};
Ok(shares
.iter()
.zip(parts)
.map(|(s, v)| (s.party, signed(v)))
.collect())
}
+536
View File
@@ -0,0 +1,536 @@
//! Tax categories: where each revenue and expense account lands on the
//! business's income tax return, and how much of it is deductible.
//!
//! The books stay book-basis; this is the bridge a CPA (or tax software)
//! walks across. Categories are *stable semantic keys* ("meals",
//! "guaranteed_payments") rather than form line numbers, which move from
//! year to year; each category says which returns it applies to. A
//! deductible percentage (meals 50%, penalties 0%) turns book amounts into
//! deductible amounts and surfaces the book-to-tax differences (Schedule
//! M-1 items) instead of hiding them.
//!
//! Scope is deliberate, like fixed assets: no tax depreciation (MACRS,
//! Section 179, bonus), no basis computations, no filing. Not tax advice.
use crate::{AccountType, Ledger, LedgerError};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The federal income tax return a business files.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TaxForm {
/// Sole proprietorship or single-member LLC: Schedule C (Form 1040).
ScheduleC,
/// Partnership or multi-member LLC: Form 1065.
Form1065,
/// S corporation: Form 1120-S.
Form1120S,
/// C corporation: Form 1120.
Form1120,
}
impl TaxForm {
/// Every form.
pub const ALL: [TaxForm; 4] = [
TaxForm::ScheduleC,
TaxForm::Form1065,
TaxForm::Form1120S,
TaxForm::Form1120,
];
/// "Form 1065", "Schedule C", ...
pub fn label(&self) -> &'static str {
match self {
TaxForm::ScheduleC => "Schedule C",
TaxForm::Form1065 => "Form 1065",
TaxForm::Form1120S => "Form 1120-S",
TaxForm::Form1120 => "Form 1120",
}
}
/// Parse "schedule_c", "1065", "1120s", "1120" (and their labels).
pub fn parse(s: &str) -> Option<TaxForm> {
let k: String = s
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
match k.as_str() {
"schedulec" | "c" => Some(TaxForm::ScheduleC),
"1065" | "form1065" => Some(TaxForm::Form1065),
"1120s" | "form1120s" => Some(TaxForm::Form1120S),
"1120" | "form1120" => Some(TaxForm::Form1120),
_ => None,
}
}
}
/// Where a category sits on the return.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxKind {
/// Income (revenue accounts).
Income,
/// Cost of goods sold (expense accounts).
CostOfGoods,
/// A deduction (expense accounts).
Deduction,
}
/// One tax category.
#[derive(Clone, Copy, Debug, Serialize)]
pub struct TaxCategory {
/// Stable key, e.g. `"meals"`.
pub key: &'static str,
/// Human label, e.g. `"Meals"`.
pub label: &'static str,
/// Income, COGS, or deduction.
pub kind: TaxKind,
/// The returns it appears on.
pub forms: &'static [TaxForm],
/// Percent deductible by default (100 for most; meals 50).
pub default_deductible_percent: u32,
/// What to know about it, for the agent and the CPA.
pub note: &'static str,
}
const ALL: &[TaxForm] = &TaxForm::ALL;
const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120];
/// Every return but Schedule C.
const ENTITIES: &[TaxForm] = &[TaxForm::Form1065, TaxForm::Form1120S, TaxForm::Form1120];
const fn cat(
key: &'static str,
label: &'static str,
kind: TaxKind,
forms: &'static [TaxForm],
pct: u32,
note: &'static str,
) -> TaxCategory {
TaxCategory {
key,
label,
kind,
forms,
default_deductible_percent: pct,
note,
}
}
use TaxKind::{CostOfGoods, Deduction, Income};
/// The catalog.
pub const CATEGORIES: &[TaxCategory] = &[
cat(
"gross_receipts",
"Gross receipts or sales",
Income,
ALL,
100,
"Sales recorded gross (platform and processor fees are a separate deduction).",
),
cat(
"returns_allowances",
"Returns and allowances",
Income,
ALL,
100,
"Refunds and allowances; reduces gross receipts.",
),
cat(
"interest_income",
"Interest income",
Income,
ALL,
100,
"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(
"cost_of_goods_sold",
"Cost of goods sold",
CostOfGoods,
ALL,
100,
"Inventory and direct costs of what was sold.",
),
cat("advertising", "Advertising", Deduction, ALL, 100, ""),
cat(
"bad_debts",
"Bad debts",
Deduction,
ALL,
100,
"Only debts previously included in income (accrual basis).",
),
cat(
"car_truck",
"Car and truck expenses",
Deduction,
ALL,
100,
"Actual costs or the standard mileage rate, not both.",
),
cat(
"charitable_contributions",
"Charitable contributions",
Deduction,
ENTITIES,
100,
"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",
"Commissions and fees",
Deduction,
ALL,
100,
"Includes app store, marketplace, and payment processor fees.",
),
cat(
"contract_labor",
"Contract labor",
Deduction,
ALL,
100,
"Payments to non-employees; see 1099 reporting.",
),
cat(
"depreciation",
"Depreciation (book)",
Deduction,
ALL,
100,
"Book depreciation; tax depreciation (MACRS, Section 179, bonus) is computed separately by the CPA.",
),
cat(
"employee_benefits",
"Employee benefit programs",
Deduction,
ALL,
100,
"",
),
cat(
"guaranteed_payments",
"Guaranteed payments to partners",
Deduction,
&[TaxForm::Form1065],
100,
"Payments to partners for services or capital, regardless of profit; reported on each partner's K-1.",
),
cat(
"insurance",
"Insurance (other than health)",
Deduction,
ALL,
100,
"",
),
cat("interest_expense", "Interest", Deduction, ALL, 100, ""),
cat(
"legal_professional",
"Legal and professional services",
Deduction,
ALL,
100,
"Attorneys, accountants, consultants.",
),
cat(
"meals",
"Meals",
Deduction,
ALL,
50,
"Generally 50% deductible; the other half is a book-to-tax difference.",
),
cat("office_expense", "Office expense", Deduction, ALL, 100, ""),
cat(
"officer_compensation",
"Compensation of officers",
Deduction,
CORPS,
100,
"Wages paid to officer-shareholders through payroll.",
),
cat(
"organization_startup",
"Organizational and start-up costs",
Deduction,
ALL,
100,
"Tax: up to $5,000 each deductible in the first year, the rest amortized over 180 months; the CPA computes it.",
),
cat(
"pension_profit_sharing",
"Pension and profit-sharing plans",
Deduction,
ALL,
100,
"",
),
cat(
"rent_equipment",
"Rent or lease: vehicles, machinery, equipment",
Deduction,
ALL,
100,
"",
),
cat(
"rent_property",
"Rent or lease: other business property",
Deduction,
ALL,
100,
"",
),
cat(
"repairs",
"Repairs and maintenance",
Deduction,
ALL,
100,
"",
),
cat(
"salaries_wages",
"Salaries and wages",
Deduction,
ALL,
100,
"Employees (not owners of a partnership, whose pay is guaranteed payments).",
),
cat("supplies", "Supplies", Deduction, ALL, 100, ""),
cat(
"taxes_licenses",
"Taxes and licenses",
Deduction,
ALL,
100,
"Payroll taxes, business licenses, state fees; not federal income tax.",
),
cat(
"travel",
"Travel",
Deduction,
ALL,
100,
"Lodging and transportation away from home; meals go under Meals.",
),
cat("utilities", "Utilities", Deduction, ALL, 100, ""),
cat(
"other_deductions",
"Other deductions",
Deduction,
ALL,
100,
"Software, subscriptions, bank fees, dues, and the rest; the CPA itemizes.",
),
cat(
"nondeductible",
"Nondeductible expenses",
Deduction,
ALL,
0,
"Fines, penalties, political contributions, federal income tax: a book expense with no deduction.",
),
];
/// Look up a category by key.
pub fn category(key: &str) -> Option<&'static TaxCategory> {
CATEGORIES.iter().find(|c| c.key == key)
}
/// Check that `key` can tag an account of `account_type` and, when a form
/// is known, that it appears on that form.
pub fn check_category(
key: &str,
account_type: AccountType,
form: Option<TaxForm>,
) -> Result<&'static TaxCategory, LedgerError> {
let c = category(key)
.ok_or_else(|| LedgerError::InvalidTaxCategory(format!("unknown tax category {key:?}")))?;
let fits = match c.kind {
TaxKind::Income => matches!(account_type, AccountType::Revenue | AccountType::Expense),
TaxKind::CostOfGoods | TaxKind::Deduction => account_type == AccountType::Expense,
};
if !fits {
return Err(LedgerError::InvalidTaxCategory(format!(
"{key} is for {} accounts, not {account_type:?}",
if c.kind == TaxKind::Income {
"revenue"
} else {
"expense"
}
)));
}
if let Some(f) = form
&& !c.forms.contains(&f)
{
return Err(LedgerError::InvalidTaxCategory(format!(
"{key} does not appear on {}",
f.label()
)));
}
Ok(c)
}
/// One category's line on the tax summary.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TaxSummaryLine {
/// Category key.
pub category: String,
/// Category label.
pub label: String,
/// The accounts that roll into it, `"CODE Name"`.
pub accounts: Vec<String>,
/// Book amount: revenue for income, expense for COGS and deductions.
pub book: Decimal,
/// Deductible (or taxable) amount after the deductible percentage.
pub tax: Decimal,
}
/// An account with P&L activity and no tax category.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Uncategorized {
/// `"CODE Name"`.
pub account: String,
/// Revenue or Expense.
pub account_type: AccountType,
/// Book amount (normal side).
pub book: Decimal,
}
/// The tax summary for a period: book amounts by tax category, deductible
/// amounts, and every account still needing a category.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaxSummary {
/// First date, inclusive.
pub start: NaiveDate,
/// Last date, inclusive.
pub end: NaiveDate,
/// The return, when known.
pub form: Option<TaxForm>,
/// Income categories.
pub income: Vec<TaxSummaryLine>,
/// Cost of goods sold.
pub cost_of_goods: Vec<TaxSummaryLine>,
/// Deductions.
pub deductions: Vec<TaxSummaryLine>,
/// 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>,
}
impl TaxSummary {
/// Book net income of the categorized accounts.
pub fn book_net(&self) -> Decimal {
sum(&self.income, |l| l.book)
- sum(&self.cost_of_goods, |l| l.book)
- sum(&self.deductions, |l| l.book)
}
/// Income less deductible amounts, before the CPA's other adjustments.
pub fn tax_net(&self) -> Decimal {
sum(&self.income, |l| l.tax)
- sum(&self.cost_of_goods, |l| l.tax)
- sum(&self.deductions, |l| l.tax)
}
}
fn sum(lines: &[TaxSummaryLine], f: fn(&TaxSummaryLine) -> Decimal) -> Decimal {
lines.iter().map(f).sum()
}
/// Build the tax summary for `[start, end]`, closing entries excluded (like
/// the P&L). Deductible amounts round to the cent, half away from zero.
pub fn tax_summary(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
form: Option<TaxForm>,
) -> Result<TaxSummary, LedgerError> {
let pl = crate::reports::profit_and_loss_for_period(ledger, start, end, None)?;
let mut by_cat: BTreeMap<&'static str, TaxSummaryLine> = BTreeMap::new();
let mut uncategorized = Vec::new();
for line in pl.revenue.iter().chain(pl.expenses.iter()) {
let account = ledger.chart().find_by_code(&line.code).ok_or_else(|| {
LedgerError::InvalidTaxCategory(format!("unknown account {}", line.code))
})?;
let name = format!("{} {}", account.code, account.name);
let Some(c) = account.tax_category.as_deref().and_then(category) else {
uncategorized.push(Uncategorized {
account: name,
account_type: account.account_type,
book: line.amount,
});
continue;
};
// Income from an expense-type account (e.g. a contra) counts with
// its sign flipped, so returns reduce receipts.
let book = if c.kind == TaxKind::Income && account.account_type == AccountType::Expense {
-line.amount
} else {
line.amount
};
let pct = account
.deductible_percent
.unwrap_or_else(|| Decimal::from(c.default_deductible_percent));
let tax = (book * pct / Decimal::from(100))
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
let entry = by_cat.entry(c.key).or_insert_with(|| TaxSummaryLine {
category: c.key.to_string(),
label: c.label.to_string(),
accounts: vec![],
book: Decimal::ZERO,
tax: Decimal::ZERO,
});
entry.accounts.push(name);
entry.book += book;
entry.tax += tax;
}
let mut out = TaxSummary {
start,
end,
form,
income: vec![],
cost_of_goods: vec![],
deductions: vec![],
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),
TaxKind::Deduction => out.deductions.push(line),
}
}
}
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")
}
+115
View File
@@ -0,0 +1,115 @@
//! Time series. Numbers computed by hand.
//!
//! Cash 1000 and card 2100. Jan 5: owner puts in 5,000. Jan 20: 300 of
//! software on the card. Feb 3: 1,200 of sales into cash. Feb 10: pay the
//! card 300. Mar 15: 800 rent from cash.
use chrono::NaiveDate;
use ledger_core::analysis::{
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;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(n: i64) -> Decimal {
Decimal::from(n)
}
#[test]
fn balances_pnl_and_flows_over_time() {
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 equity = a("3000", AccountType::Equity);
let sales = a("4000", AccountType::Revenue);
let software = a("6300", AccountType::Expense);
let rent = a("6600", AccountType::Expense);
for (date, dr, cr, amt) in [
(d(1, 5), cash, equity, 5000),
(d(1, 20), software, card, 300),
(d(2, 3), cash, sales, 1200),
(d(2, 10), card, cash, 300),
(d(3, 15), rent, cash, 800),
] {
let e = l.create_entry(date, "x");
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();
}
assert_eq!(
balance_series(&l, &[cash], &[d(1, 1), d(1, 31), d(2, 28), d(3, 31)]),
[dec(0), dec(5000), dec(5900), dec(5100)]
);
// The card is a liability: owed 300, then paid off.
assert_eq!(
balance_series(&l, &[card], &[d(1, 31), d(2, 28)]),
[dec(300), dec(0)]
);
assert_eq!(
balance_history(&l, &[cash]),
[
(d(1, 5), dec(5000)),
(d(2, 3), dec(6200)),
(d(2, 10), dec(5900)),
(d(3, 15), dec(5100))
]
);
let pnl = pnl_by_interval(&l, d(1, 1), d(3, 31), Interval::Month).unwrap();
let nets: Vec<Decimal> = pnl.iter().map(|p| p.net()).collect();
assert_eq!(nets, [dec(-300), dec(1200), dec(-800)]);
let flows = flows_by_interval(&l, &[cash], d(1, 1), d(3, 31), Interval::Month);
let io: Vec<(Decimal, Decimal)> = flows.iter().map(|f| (f.2, f.3)).collect();
assert_eq!(
io,
[
(dec(5000), dec(0)),
(dec(1200), dec(300)),
(dec(0), dec(800))
]
);
// Cash and card together: a card charge is money out when it's made,
// and paying the card moves nothing out of the pair.
let both = flows_by_interval(&l, &[cash, card], d(1, 1), d(2, 28), Interval::Month);
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());
}
+141 -9
View File
@@ -231,7 +231,37 @@ fn registration_rejects_date_overflow() {
expense_account: expense,
})
.unwrap_err();
assert!(matches!(err, ledger_core::LedgerError::AssetDateOverflow));
assert!(matches!(
err,
ledger_core::LedgerError::DateOutOfRange(d) if d == NaiveDate::MAX
));
// Inside the calendar the books keep (ledger_core::calendar), but a
// life that runs past its last day: refused too, whatever chrono
// could still reach (P0 review round 3, S1).
let new = |date: NaiveDate, months: u32| NewAsset {
description: "Late machine".to_string(),
purchase_date: date,
cost: usd("1000"),
salvage_value: usd("0"),
useful_life_months: months,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
};
let d = |y, m, day| NaiveDate::from_ymd_opt(y, m, day).unwrap();
for (date, months) in [
(d(9999, 6, 1), 12),
(d(9999, 12, 31), 1),
(d(2026, 1, 1), u32::MAX),
] {
assert!(matches!(
ledger.register_asset(new(date, months)).unwrap_err(),
ledger_core::LedgerError::AssetDateOverflow
));
}
// A life that ends on the calendar's last day is kept.
ledger.register_asset(new(d(9999, 11, 30), 1)).unwrap();
ledger.register_asset(new(d(1, 1, 1), 12)).unwrap();
}
#[test]
@@ -261,18 +291,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 +430,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));
}
@@ -0,0 +1,359 @@
//! Conventions, approvals and templates in the engine, value by value:
//! slots, summaries, template bounds, the approval fingerprint (journaled,
//! so its meaning is fixed forever), which entries a rule covers, and
//! when a convention is a revision at all.
//!
//! ```text
//! GOAL: every bound and every rule says exactly what it means; no more,
//! no less.
//! SOURCE: plan/served.md P1; P1 mutation run (2026-10-17).
//! ```
use chrono::NaiveDate;
use ledger_core::conventions::{self, Convention, ConventionKind, NewConvention};
use ledger_core::settings::{Change, Source};
use ledger_core::{AccountType, Currency, EntryId, Ledger, LedgerError, Money, Side};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::str::FromStr;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn src() -> Source {
Source {
decided_by: "A".into(),
decided_on: d(2026, 10, 17),
citation: None,
note: None,
}
}
fn convention(id: &str, kind: ConventionKind, structured: Option<Value>) -> NewConvention {
NewConvention {
id: id.into(),
kind,
title: "Title".into(),
body_md: "Body.".into(),
structured,
source: src(),
}
}
fn template(lines: Vec<Value>) -> NewConvention {
convention(
"t",
ConventionKind::PostingTemplate,
Some(json!({ "lines": lines })),
)
}
fn line(account: &str, side: &str, share: &str) -> Value {
json!({"account": account, "side": side, "share": share})
}
fn refusal(c: &NewConvention) -> String {
conventions::check_new(c).expect_err("refused")
}
/// A slot is a name in braces: lowercase letters, digits and `_`, 1 to 32
/// of them.
#[test]
fn a_slot_is_a_short_lowercase_name_in_braces() {
for (account, want) in [
("{expense}", Some("expense")),
("{a_1}", Some("a_1")),
("{_}", Some("_")),
("{7}", Some("7")),
(
"{abcdefghijklmnopqrstuvwxyz012345}",
Some("abcdefghijklmnopqrstuvwxyz012345"),
),
("{abcdefghijklmnopqrstuvwxyz0123456}", None),
("{}", None),
("{Expense}", None),
("{a-b}", None),
("{a b}", None),
("expense", None),
("{expense", None),
("6000", None),
] {
assert_eq!(conventions::slot(account), want, "{account}");
}
}
/// A summary is the first line with text, trimmed, at most 160
/// characters, with an ellipsis only when it was cut.
#[test]
fn a_summary_is_the_first_line_with_text() {
let summary = |body: &str| {
Convention {
id: "c".into(),
kind: ConventionKind::Note,
title: "T".into(),
body_md: body.into(),
structured: None,
source: src(),
seq: 1,
revision: 1,
retired: None,
}
.summary()
};
assert_eq!(summary("\n \n First line \nsecond"), "First line");
assert_eq!(summary(""), "");
assert_eq!(summary("\n \n"), "");
let exactly = "é".repeat(160);
assert_eq!(summary(&exactly), exactly, "160 characters: whole");
let over = "é".repeat(161);
assert_eq!(summary(&over), format!("{exactly}…"), "161: cut");
assert_eq!(summary(&format!(" {exactly} ")), exactly, "trimmed first");
}
/// A posting template has 2 to 50 lines, each share in (0, 1], each
/// side summing to exactly 1; a refusal names the line.
#[test]
fn template_bounds_are_exact() {
// 50 lines: one debit, 49 credits summing to 1.
let mut fifty = vec![line("6000", "debit", "1"), line("1000", "credit", "0.04")];
fifty.extend((0..48).map(|_| line("1000", "credit", "0.02")));
assert_eq!(fifty.len(), 50);
conventions::check_new(&template(fifty)).expect("50 lines");
let mut fifty_one = vec![line("6000", "debit", "1")];
fifty_one.extend((0..50).map(|_| line("1000", "credit", "0.02")));
assert!(refusal(&template(fifty_one)).contains("2 to 50 lines"));
assert!(refusal(&template(vec![])).contains("2 to 50 lines"));
// One line can't balance: refused by its count, not its shares.
assert!(refusal(&template(vec![line("6000", "debit", "1")])).contains("2 to 50 lines"));
conventions::check_new(&template(vec![
line("6000", "debit", "1"),
line("1000", "credit", "1"),
]))
.expect("2 lines");
// A zero share is no share.
let e = refusal(&template(vec![
line("6000", "debit", "1"),
line("6000", "debit", "0"),
line("1000", "credit", "1"),
]));
assert!(e.starts_with("template line 2: share \"0\""), "{e}");
// Over 1, or below zero, even when the side still sums to 1.
let e = refusal(&template(vec![
line("6000", "debit", "1.5"),
line("6000", "debit", "-0.5"),
line("1000", "credit", "1"),
]));
assert!(e.starts_with("template line 1: share \"1.5\""), "{e}");
let e = refusal(&template(vec![
line("6000", "debit", "1"),
line("1000", "credit", "1.5"),
line("1000", "credit", "-0.5"),
]));
assert!(e.starts_with("template line 2: share \"1.5\""), "{e}");
let e = refusal(&template(vec![
line("6000", "debit", "1"),
line("1000", "credit", "0.5"),
]));
assert!(e.contains("sum to exactly 1"), "{e}");
}
/// FNV-1a, 64 bits, written out here independently of the engine.
fn fnv1a(text: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in text.bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
/// The approval fingerprint is journaled with every approval and
/// recomputed at post: its text and hash are fixed forever.
#[test]
fn the_fingerprint_is_fixed_forever() {
let mut l = Ledger::new(Currency::USD);
let exp = l
.add_account("6000", "Software", AccountType::Expense, None, false)
.unwrap();
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let e = l.create_entry(d(2026, 10, 2), "Laptop");
let m = Money::new(dec("900.50"), Currency::USD);
l.add_line(e, exp, Side::Debit, m).unwrap();
l.add_line(e, bank, Side::Credit, m).unwrap();
let entry = l.get_entry(e).unwrap();
let text = format!(
"2026-10-02\u{1f}Laptop\
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
);
let plain = conventions::fingerprint(entry, &[], None);
assert_eq!(plain, fnv1a(&text));
assert_eq!(fnv1a(""), "cbf29ce484222325", "FNV-1a's offset basis");
assert_eq!(fnv1a("a"), "af63dc4c8601ec8c", "FNV-1a's published value");
let none = conventions::fingerprint(entry, &[], Some("bank fee"));
let with_none = format!(
"2026-10-02\u{1f}Laptop\u{1d}none\u{1f}bank fee\
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
);
assert_eq!(none, fnv1a(&with_none));
assert_ne!(none, plain);
}
/// Books enforcing approval rules, with an expense and a bank account.
fn enforcing() -> (Ledger, ledger_core::AccountId, ledger_core::AccountId) {
let mut l = Ledger::new(Currency::USD);
let exp = l
.add_account("6000", "Software", AccountType::Expense, None, false)
.unwrap();
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let on = [Change {
key: "policy.approval_rules".into(),
value: Some(json!("enforce")),
}];
let made = l.check_settings(&on, &src()).unwrap();
l.apply_settings(&made, &src(), 1).unwrap();
(l, exp, bank)
}
fn set_convention(l: &mut Ledger, c: &NewConvention, seq: u64) {
let c = l.check_convention(c).unwrap();
l.apply_convention(c, seq).unwrap();
}
fn draft(l: &mut Ledger, lines: &[(ledger_core::AccountId, Side, &str)]) -> EntryId {
let e = l.create_entry(d(2026, 10, 2), "Spend");
for (a, side, amount) in lines {
l.add_line(e, *a, *side, Money::new(dec(amount), Currency::USD))
.unwrap();
}
e
}
/// A rule's amount is "over": an entry whose debits are exactly it isn't
/// covered; one a cent over is. It counts debits, not credits, even on a
/// draft that doesn't balance yet.
#[test]
fn a_rule_covers_debits_strictly_over_its_amount() {
let (mut l, exp, bank) = enforcing();
set_convention(
&mut l,
&convention(
"big",
ConventionKind::ApprovalRule,
Some(json!({"approver": "B", "amount_over": "500"})),
),
2,
);
let want = vec![("big".to_string(), "B".to_string())];
let at = draft(
&mut l,
&[(exp, Side::Debit, "500"), (bank, Side::Credit, "500")],
);
assert!(l.approvals_missing(at).unwrap().is_empty(), "exactly 500");
let over = draft(
&mut l,
&[(exp, Side::Debit, "500.01"), (bank, Side::Credit, "500.01")],
);
assert_eq!(l.approvals_missing(over).unwrap(), want);
let debits = draft(&mut l, &[(exp, Side::Debit, "600")]);
assert_eq!(l.approvals_missing(debits).unwrap(), want, "debits count");
let credits = draft(&mut l, &[(bank, Side::Credit, "600")]);
assert!(
l.approvals_missing(credits).unwrap().is_empty(),
"credits don't"
);
}
/// What check_settings returns is what will be journaled.
#[test]
fn check_settings_returns_the_changes_it_will_journal() {
let l = Ledger::new(Currency::USD);
let name = Change {
key: "company.name".into(),
value: Some(json!(" Acme LLC ")),
};
let made = l.check_settings(&[name], &src()).unwrap();
assert_eq!(
made,
[Change {
key: "company.name".into(),
value: Some(json!("Acme LLC")),
}]
);
assert!(!l.settings().is_journaled(), "checking changes nothing");
}
/// A convention set again unchanged is refused; a change to its title,
/// its body or its structure alone is a revision.
#[test]
fn any_one_change_is_a_revision() {
let (mut l, _, _) = enforcing();
let base = convention(
"big",
ConventionKind::ApprovalRule,
Some(json!({"approver": "B", "amount_over": "500"})),
);
set_convention(&mut l, &base, 2);
let e = l.check_convention(&base).unwrap_err();
assert!(
matches!(&e, LedgerError::InvalidConvention(m) if m.contains("already says exactly that")),
"{e}"
);
let title = NewConvention {
title: "New title".into(),
..base.clone()
};
let body = NewConvention {
body_md: "New body.".into(),
..base.clone()
};
let structured = NewConvention {
structured: Some(json!({"approver": "B", "amount_over": "600"})),
..base.clone()
};
for c in [title, body, structured] {
assert!(l.check_convention(&c).is_ok(), "{c:?}");
}
let revised = NewConvention {
title: "New title".into(),
..base.clone()
};
set_convention(&mut l, &revised, 3);
assert_eq!(l.conventions().convention("big").unwrap().revision, 2);
}
/// A template drafts any amount up to a line's most, and refuses above
/// it as out of range.
#[test]
fn a_template_drafts_up_to_a_lines_most() {
let mut l = Ledger::new(Currency::USD);
for (code, t) in [("6000", AccountType::Expense), ("1000", AccountType::Asset)] {
l.add_account(code, code, t, None, false).unwrap();
}
set_convention(
&mut l,
&template(vec![
line("6000", "debit", "1"),
line("1000", "credit", "1"),
]),
1,
);
let max = ledger_core::kernel::MAX_LINE_AMOUNT;
let (_, lines) = l.template_lines("t", max, &BTreeMap::new()).unwrap();
assert!(lines.iter().all(|(_, _, m)| m.amount == max));
assert!(matches!(
l.template_lines("t", max + dec("0.01"), &BTreeMap::new()),
Err(LedgerError::AmountOutOfRange)
));
}
+663
View File
@@ -0,0 +1,663 @@
//! 1099 tracking. Numbers computed by hand.
//!
//! 2026 payments:
//! - Dana (1099-NEC, W-9): bill 1,500 paid by check + 700 direct = 2,200,
//! over the 2,000 threshold. A 300 payment on 2025-12-30 counts in 2025.
//! - Ace Rentals (1099-MISC): 1,200 twice, one reversed = 1,200, under.
//! - Sam (1099-NEC): 5,000 by credit card = 0 cash: the card processor's
//! 1099-K covers it.
//! - Bigco (no 1099): 9,000, listed but never reportable.
use chrono::NaiveDate;
use ledger_core::arap::DocumentLine;
use ledger_core::form1099::default_threshold;
use ledger_core::{
AccountType, Currency, Form1099, Ledger, LedgerError, Money, Side, vendor_1099_report,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn books() -> Ledger {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2100", "Credit Card", AccountType::Liability, None, false)
.unwrap();
let contract = l
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let rent = l
.add_account("6600", "Rent", AccountType::Expense, None, false)
.unwrap();
let dana = l.add_vendor("Dana").unwrap();
let ace = l.add_vendor("Ace Rentals").unwrap();
let sam = l.add_vendor("Sam").unwrap();
let bigco = l.add_vendor("Bigco").unwrap();
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
l.set_vendor_1099(ace, Some(Form1099::Misc), false).unwrap();
l.set_vendor_1099(sam, Some(Form1099::Nec), true).unwrap();
let pay = |l: &mut Ledger, date, vendor, dr, cr, amt: &str| {
let e = l.create_entry(date, "payment");
let m = Money::new(dec(amt), Currency::USD);
l.add_line(e, dr, Side::Debit, m).unwrap();
l.add_line(e, cr, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.set_entry_vendor(e, Some(vendor)).unwrap();
e
};
let bill = l
.create_bill(
dana,
d(2026, 3, 1),
d(2026, 3, 31),
vec![DocumentLine {
description: "Design work".into(),
amount: dec("1500"),
}],
)
.unwrap();
l.pay_bill(bill, d(2026, 3, 20), dec("1500"), bank).unwrap();
pay(&mut l, d(2026, 6, 1), dana, contract, bank, "700");
pay(&mut l, d(2025, 12, 30), dana, contract, bank, "300");
pay(&mut l, d(2026, 1, 1), ace, rent, bank, "1200");
let wrong = pay(&mut l, d(2026, 2, 1), ace, rent, bank, "1200");
l.reverse_entry(wrong, d(2026, 2, 2), "duplicate").unwrap();
pay(&mut l, d(2026, 4, 1), sam, contract, card, "5000");
pay(&mut l, d(2026, 5, 1), bigco, contract, bank, "9000");
// Untagged spending is not anyone's 1099.
let e = l.create_entry(d(2026, 7, 1), "untagged");
let m = Money::new(dec("50"), Currency::USD);
l.add_line(e, contract, Side::Debit, m).unwrap();
l.add_line(e, bank, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l
}
#[test]
fn cash_paid_per_vendor_and_who_is_reportable() {
let l = books();
let r = vendor_1099_report(&l, 2026, None).unwrap();
assert_eq!(r.threshold, dec("2000"));
let rows: Vec<(&str, Decimal, bool)> = r
.vendors
.iter()
.map(|v| (v.name.as_str(), v.paid, v.reportable))
.collect();
assert_eq!(
rows,
[
("Ace Rentals", dec("1200"), false),
("Bigco", dec("9000"), false),
("Dana", dec("2200"), true),
("Sam", dec("0"), false),
]
);
let ace = &r.vendors[0];
assert_eq!(ace.form, Some(Form1099::Misc));
assert!(!ace.tin_on_file);
}
#[test]
fn years_and_thresholds() {
let l = books();
// 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"))).unwrap();
assert!(
r.vendors
.iter()
.any(|v| v.name == "Ace Rentals" && v.reportable)
);
}
#[test]
fn tags_need_real_entries_and_vendors() {
let mut l = books();
assert!(matches!(
l.set_entry_vendor(999, Some(1)),
Err(LedgerError::UnknownEntry(999))
));
assert!(matches!(
l.set_entry_vendor(1, Some(999)),
Err(LedgerError::UnknownVendor(999))
));
// The bill and its payment were tagged automatically.
assert_eq!(l.entry_vendor(1), Some(1));
assert_eq!(l.entry_vendor(2), Some(1));
l.set_entry_vendor(2, None).unwrap();
assert_eq!(l.entry_vendor(2), None);
assert_eq!(Form1099::parse("1099-NEC"), Some(Form1099::Nec));
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
assert_eq!(Form1099::parse("K"), None);
}
/// 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,
authority,
} = &err
else {
panic!("expected RuleNotKnown, got {err:?}");
};
assert_eq!(
(rule.as_str(), *year, *known_through),
("1099 reporting threshold", 2027, 2026)
);
assert!(authority.contains("https://www.irs.gov/"), "{authority}");
let why = err.to_string();
for needle in [
"2027",
"official source",
"CPA",
"irs.gov",
"tell the human",
] {
assert!(why.contains(needle), "missing {needle:?} in: {why}");
}
// It reaches agents: it names the authority, never this repo.
for never in ["github", "pull request", "docs/", ".md"] {
assert!(!why.to_lowercase().contains(never), "{never:?} 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"
}
]
+859 -5
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;
@@ -181,14 +182,162 @@ fn zero_amount_line_is_rejected() {
);
}
#[test]
fn negative_amount_line_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Negative line");
let minus = Money::new(Decimal::from(-100), Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, minus).unwrap();
ledger.add_line(id, c.equity, Side::Credit, minus).unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::NegativeAmountLine
);
}
fn kernel_max() -> Decimal {
ledger_core::kernel::MAX_LINE_AMOUNT
}
#[test]
fn out_of_range_amount_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
// Big enough to overflow a sum: an error, never a panic, as soon as
// the line is added (a draft is summed before it posts: its debits,
// its parties' shares; P0 review round 3, S1). Replayed as an earlier
// version wrote it (rules.md B-28): accepted into its draft, whose
// post refuses it, never a panic.
let id = ledger.create_entry(date(21), "Too big");
let old = ledger.create_entry(date(21), "Too big, journaled before the bound");
for amount in [
Decimal::MAX,
Decimal::MIN,
kernel_max() + Decimal::new(1, 2),
] {
let big = Money::new(amount, Currency::USD);
assert_eq!(
ledger.add_line(id, c.cash, Side::Debit, big).unwrap_err(),
LedgerError::AmountOutOfRange
);
ledger
.replay_add_line(old, c.cash, Side::Debit, big)
.unwrap();
}
let max_usd = Money::new(Decimal::MAX, Currency::USD);
ledger
.replay_add_line(old, c.equity, Side::Credit, max_usd)
.unwrap();
assert_eq!(
ledger.post_entry(old).unwrap_err(),
LedgerError::AmountOutOfRange
);
assert_eq!(
ledger.replay_post_entry(old).unwrap_err(),
LedgerError::AmountOutOfRange
);
// The largest line goes in, and posts.
let max = Money::new(kernel_max(), Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, max).unwrap();
ledger.add_line(id, c.equity, Side::Credit, max).unwrap();
ledger.post_entry(id).unwrap();
// 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);
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.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();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Huge FX");
assert_eq!(
ledger
.add_line_fx(
id,
c.cash,
Side::Debit,
Currency::EUR,
Decimal::MAX,
Decimal::from(2)
)
.unwrap_err(),
LedgerError::AmountOutOfRange
);
}
#[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(),
@@ -204,7 +353,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();
@@ -230,6 +386,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();
@@ -295,6 +484,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.
// ---------------------------------------------------------------------------
@@ -326,7 +597,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));
@@ -485,6 +756,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
end: date(31),
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
}
}
@@ -633,3 +905,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().unwrap(), 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));
}
+174
View File
@@ -0,0 +1,174 @@
//! The bridge between the proofs and the engine.
//!
//! The Kani harnesses (`src/proofs.rs`) prove the accounting kernel on
//! `i64` money in the smallest unit; the engine runs the same kernel on
//! `Decimal`. These properties check the two instantiations agree, value
//! for value, on cents: whatever Kani proved of the `i64` kernel holds of
//! the `Decimal` one for the same amounts.
use ledger_core::kernel::{self, Amount, LineError};
use ledger_core::{AccountType, Side};
use proptest::prelude::*;
use rust_decimal::Decimal;
/// Cents within both instantiations' line range.
const MAX_CENTS: i64 = 1 << 40;
fn dec(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
fn arb_side() -> impl Strategy<Value = Side> {
prop_oneof![Just(Side::Debit), Just(Side::Credit)]
}
fn arb_type() -> impl Strategy<Value = AccountType> {
prop_oneof![
Just(AccountType::Asset),
Just(AccountType::Liability),
Just(AccountType::Equity),
Just(AccountType::Revenue),
Just(AccountType::Expense),
]
}
fn arb_cents() -> impl Strategy<Value = i64> {
prop_oneof![
-MAX_CENTS..=MAX_CENTS,
// Small amounts collide, so balanced entries and zeros show up.
-3i64..=3,
]
}
/// Lines over four accounts. Balanced entries are rare at random, so half
/// the time the last line plugs the difference.
fn arb_lines() -> impl Strategy<Value = Vec<(u8, Side, i64)>> {
(
prop::collection::vec((0u8..4, arb_side(), arb_cents()), 0..7),
any::<bool>(),
)
.prop_map(|(mut lines, plug)| {
if plug && !lines.is_empty() {
let net: i64 = lines[..lines.len() - 1]
.iter()
.map(|&(_, s, a)| if s == Side::Debit { a } else { -a })
.sum();
let last = lines.len() - 1;
if net.abs() > MAX_CENTS {
// Out of both ranges, where the two differ by design.
return lines;
}
lines[last].1 = if net > 0 { Side::Credit } else { Side::Debit };
lines[last].2 = net.abs();
}
lines
})
}
fn to_dec(lines: &[(u8, Side, i64)]) -> Vec<(u8, Side, Decimal)> {
lines.iter().map(|&(k, s, a)| (k, s, dec(a))).collect()
}
fn sides<T: Copy>(lines: &[(u8, Side, T)]) -> Vec<(Side, T)> {
lines.iter().map(|&(_, s, a)| (s, a)).collect()
}
fn map_err(e: LineError<i64>) -> LineError<Decimal> {
match e {
LineError::TooFewLines => LineError::TooFewLines,
LineError::ZeroAmount => LineError::ZeroAmount,
LineError::NegativeAmount => LineError::NegativeAmount,
LineError::OutOfRange => LineError::OutOfRange,
LineError::Unbalanced { debits, credits } => LineError::Unbalanced {
debits: dec(debits),
credits: dec(credits),
},
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
#[test]
fn check_lines_agrees(lines in arb_lines()) {
let i = kernel::check_lines(&sides(&lines)).map_err(map_err);
let d = kernel::check_lines(&sides(&to_dec(&lines)));
prop_assert_eq!(i, d);
}
#[test]
fn sums_agree(lines in arb_lines()) {
let (i, d) = (sides(&lines), sides(&to_dec(&lines)));
let ti = kernel::totals(i.iter().copied()).unwrap();
let td = kernel::totals(d.iter().copied()).unwrap();
prop_assert_eq!((dec(ti.0), dec(ti.1)), td);
prop_assert_eq!(
kernel::net(i.iter().copied()).map(dec),
kernel::net(d.iter().copied())
);
let mi: Vec<_> = kernel::movements(lines.iter().copied())
.unwrap()
.into_iter()
.map(|(k, m)| (k, dec(m)))
.collect();
prop_assert_eq!(Some(mi), kernel::movements(to_dec(&lines)));
}
#[test]
fn reversal_agrees(lines in arb_lines()) {
let ri: Vec<_> = kernel::reversal(&lines)
.into_iter()
.map(|(k, s, a)| (k, s, dec(a)))
.collect();
prop_assert_eq!(ri, kernel::reversal(&to_dec(&lines)));
}
#[test]
fn balances_agree(t in arb_type(), m in arb_cents()) {
prop_assert_eq!(kernel::normal_balance(t, m).map(dec), kernel::normal_balance(t, dec(m)));
let ci = kernel::columns(m).unwrap();
prop_assert_eq!(Some((dec(ci.0), dec(ci.1))), kernel::columns(dec(m)));
prop_assert_eq!(
kernel::closing_line(m).map(|(s, a)| (s, dec(a))),
kernel::closing_line(dec(m))
);
prop_assert_eq!(
kernel::credit_line(m).map(|(s, a)| (s, dec(a))),
kernel::credit_line(dec(m))
);
}
#[test]
fn plug_last_agrees(whole in arb_cents(), parts in prop::collection::vec(arb_cents(), 0..5)) {
let mut pi = parts.clone();
let mut pd: Vec<Decimal> = parts.iter().copied().map(dec).collect();
kernel::plug_last(whole, &mut pi).unwrap();
kernel::plug_last(dec(whole), &mut pd).unwrap();
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")
);
}
+185
View File
@@ -0,0 +1,185 @@
//! Payout gross-up. Numbers computed by hand.
//!
//! An app-store month: App A sold 1,000.00, App B 500.00; the store kept
//! 15% (225.00) and clawed back a 9.99 refund. Deposit: 1,500.00 − 225.00
//! − 9.99 = 1,265.01.
use chrono::NaiveDate;
use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{
AccountType, Currency, Ledger, LedgerError, Money, Payout, PayoutAdjustment, PayoutLine, Side,
payout_plan,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn d(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 11, day).unwrap()
}
struct Books {
l: Ledger,
bank: u64,
sales: u64,
fees: u64,
refunds: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = a("1000", "Bank", AccountType::Asset);
let sales = a("4000", "App Sales", AccountType::Revenue);
let fees = a("6000", "Platform Fees", AccountType::Expense);
let refunds = a("4100", "Refunds", AccountType::Expense);
Books {
l,
bank,
sales,
fees,
refunds,
}
}
fn line(account: u64, amount: &str) -> PayoutLine {
PayoutLine {
account,
amount: dec(amount),
class: None,
}
}
fn month(b: &Books, deposit: &str) -> Payout {
Payout {
deposit_account: b.bank,
deposit: dec(deposit),
revenue: vec![line(b.sales, "1000.00"), line(b.sales, "500.00")],
fees: vec![line(b.fees, "225.00")],
adjustments: vec![PayoutAdjustment {
account: b.refunds,
side: Side::Debit,
amount: dec("9.99"),
class: None,
}],
}
}
#[test]
fn a_payout_is_grossed_up_to_revenue_and_fees() {
let mut b = books();
let (plan, totals) = payout_plan(&month(&b, "1265.01"), b.l.chart()).unwrap();
assert_eq!(totals.gross, dec("1500.00"));
assert_eq!(totals.fees, dec("225.00"));
assert_eq!(totals.adjustments, dec("-9.99"));
assert_eq!(
plan.iter()
.map(|(a, s, m, _)| (*a, *s, *m))
.collect::<Vec<_>>(),
[
(b.bank, Side::Debit, dec("1265.01")),
(b.fees, Side::Debit, dec("225.00")),
(b.refunds, Side::Debit, dec("9.99")),
(b.sales, Side::Credit, dec("1000.00")),
(b.sales, Side::Credit, dec("500.00")),
]
);
let e = b.l.create_entry(d(30), "App store payout");
for (a, s, m, _) in plan {
b.l.add_line(e, a, s, Money::new(m, Currency::USD)).unwrap();
}
b.l.post_entry(e).unwrap();
let pl = profit_and_loss_for_period(&b.l, d(1), d(30), None).unwrap();
assert_eq!(pl.total_revenue, dec("1500.00"));
assert_eq!(pl.total_expenses, dec("234.99"));
}
#[test]
fn a_payout_must_reconcile_to_the_cent() {
let b = books();
let err = payout_plan(&month(&b, "1265.00"), b.l.chart()).unwrap_err();
let LedgerError::InvalidPayout(msg) = err else {
panic!("{err:?}")
};
assert!(msg.contains("= 1265.01"), "{msg}");
assert!(msg.contains("off by -0.01"), "{msg}");
}
#[test]
fn a_negative_payout_credits_the_bank() {
let b = books();
// 10.00 of sales, 3.00 of fees, a 50.00 refund: the store debits 43.00.
let p = Payout {
deposit_account: b.bank,
deposit: dec("-43.00"),
revenue: vec![line(b.sales, "10.00")],
fees: vec![line(b.fees, "3.00")],
adjustments: vec![PayoutAdjustment {
account: b.refunds,
side: Side::Debit,
amount: dec("50.00"),
class: None,
}],
};
let (plan, _) = payout_plan(&p, b.l.chart()).unwrap();
assert_eq!(plan[0], (b.bank, Side::Credit, dec("43.00"), None));
}
#[test]
fn accounts_must_fit_their_roles() {
let b = books();
let invalid = |p: Payout| {
matches!(
payout_plan(&p, b.l.chart()),
Err(LedgerError::InvalidPayout(_))
)
};
let mut p = month(&b, "1265.01");
p.fees[0].account = b.sales;
assert!(invalid(p), "fees must be expenses");
let mut p = month(&b, "1265.01");
p.revenue[0].account = b.fees;
assert!(invalid(p), "revenue must be revenue or a liability");
let mut p = month(&b, "1265.01");
p.deposit_account = b.sales;
assert!(invalid(p), "deposits land in an asset");
let mut p = month(&b, "1265.01");
p.fees[0].amount = dec("-225.00");
assert!(invalid(p), "amounts are positive");
let mut p = month(&b, "1265.01");
p.revenue.clear();
assert!(invalid(p), "needs revenue");
}
#[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<_>>());
}
}
+417
View File
@@ -0,0 +1,417 @@
//! Amortization schedules and recurring entries. Numbers computed by hand.
use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
use ledger_core::{
AccountType, Currency, EntryStatus, Frequency, Ledger, LedgerError, Money, NewRecurring,
NewSchedule, ScheduleKind, Side,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
struct Books {
l: Ledger,
cash: u64,
prepaid: u64,
deferred: u64,
insurance: u64,
revenue: u64,
rent: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = a("1000", "Cash", AccountType::Asset);
let prepaid = a("1300", "Prepaid Expenses", AccountType::Asset);
let deferred = a("2400", "Deferred Revenue", AccountType::Liability);
let insurance = a("6300", "Insurance", AccountType::Expense);
let revenue = a("4000", "Subscription Revenue", AccountType::Revenue);
let rent = a("6600", "Rent", AccountType::Expense);
Books {
l,
cash,
prepaid,
deferred,
insurance,
revenue,
rent,
}
}
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
l.post_entry(e).unwrap();
}
#[test]
fn a_prepaid_expense_is_expensed_month_by_month() {
let mut b = books();
// A year of insurance, paid March 15: an asset, not an expense yet.
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
let id =
b.l.register_schedule(NewSchedule {
description: "Liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("1200"),
start: d(3, 15),
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
})
.unwrap();
// Through May: March, April, May, at month end, 100 each.
let due: Vec<_> = b.l.get_schedule(id).unwrap().due(d(5, 31)).unwrap();
assert_eq!(
due,
[
(d(3, 31), dec("100")),
(d(4, 30), dec("100")),
(d(5, 31), dec("100"))
]
);
for (month, _) in due {
b.l.post_schedule_month(id, month).unwrap();
}
assert!(
b.l.get_schedule(id)
.unwrap()
.due(d(5, 31))
.unwrap()
.is_empty(),
"idempotent"
);
assert!(matches!(
b.l.post_schedule_month(id, d(4, 30)),
Err(LedgerError::InvalidSchedule(_))
));
assert_eq!(
b.l.get_schedule(id).unwrap().remaining().unwrap(),
dec("900")
);
let bs = balance_sheet_as_of(&b.l, d(5, 31)).unwrap();
let prepaid = bs.assets.iter().find(|a| a.code == "1300").unwrap();
assert_eq!(prepaid.amount, dec("900"));
let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(5, 31), None).unwrap();
assert_eq!(pl.total_expenses, dec("300"));
}
#[test]
fn deferred_revenue_is_earned_to_the_cent() {
let mut b = books();
// Three months of a subscription collected up front.
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
let id =
b.l.register_schedule(NewSchedule {
description: "Quarterly plan, Acme".into(),
kind: ScheduleKind::DeferredRevenue,
total: usd("1000"),
start: d(1, 5),
months: 3,
balance_account: b.deferred,
pl_account: b.revenue,
})
.unwrap();
let plan: Vec<Decimal> =
b.l.get_schedule(id)
.unwrap()
.due(d(12, 31))
.unwrap()
.into_iter()
.map(|(_, a)| a)
.collect();
assert_eq!(plan, [dec("333.33"), dec("333.33"), dec("333.34")]);
for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)).unwrap() {
b.l.post_schedule_month(id, m).unwrap();
}
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
assert_eq!(pl.total_revenue, dec("1000"));
let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap();
assert!(bs.liabilities.is_empty(), "fully earned");
}
#[test]
fn schedules_respect_closed_periods_and_account_types() {
let mut b = books();
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
b.l.close_period(jan).unwrap();
let id =
b.l.register_schedule(NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("120"),
start: d(1, 1),
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
})
.unwrap();
assert!(matches!(
b.l.post_schedule_month(id, d(1, 31)),
Err(LedgerError::ClosedPeriod(..))
));
b.l.post_schedule_month(id, d(2, 28)).unwrap();
// A prepaid schedule needs an asset and an expense, not a liability.
let bad = b.l.register_schedule(NewSchedule {
description: "x".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("120"),
start: d(1, 1),
months: 12,
balance_account: b.deferred,
pl_account: b.insurance,
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
}
#[test]
fn recurring_entries_generate_drafts_never_posts() {
let mut b = books();
let id =
b.l.register_recurring(NewRecurring {
description: "Office rent".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("2000")),
],
frequency: Frequency::Monthly,
start: d(1, 31),
end: Some(d(4, 30)),
})
.unwrap();
// Through March: Jan 31, Feb 28, Mar 31 (month ends, no drift).
let due = b.l.recurring_entries().next().unwrap().due(d(3, 31));
assert_eq!(due, [d(1, 31), d(2, 28), d(3, 31)]);
let drafts: Vec<_> = due
.iter()
.map(|date| b.l.generate_recurring(id, *date).unwrap())
.collect();
for e in &drafts {
assert_eq!(b.l.get_entry(*e).unwrap().status(), EntryStatus::Draft);
}
// Drafts don't touch the books until posted.
let q1 = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
assert_eq!(q1.total_expenses, Decimal::ZERO);
b.l.post_entry(drafts[1]).unwrap();
let feb = profit_and_loss_for_period(&b.l, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.total_expenses, dec("2000"));
// Never twice; nothing after the end date.
assert!(matches!(
b.l.generate_recurring(id, d(2, 28)),
Err(LedgerError::InvalidSchedule(_))
));
assert_eq!(
b.l.recurring_entries().next().unwrap().due(d(12, 31)),
[d(4, 30)]
);
// Templates must balance.
let bad = b.l.register_recurring(NewRecurring {
description: "x".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("1999")),
],
frequency: Frequency::Monthly,
start: d(1, 1),
end: None,
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
let _ = b.revenue;
}
/// 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)
);
}
+425
View File
@@ -0,0 +1,425 @@
//! The settings registry's small parts, value by value: each accessor, each
//! check boundary, each tax classification, each masked or joined display.
//! `settings.rs`'s own tests cover the invariants; these pin the exact
//! outputs the server and viewer rely on (P1 mutation testing,
//! 2026-10-17).
use chrono::{Datelike, NaiveDate};
use ledger_core::settings::{
Change, Company, Member, PartyPolicy, Settings, Source, check, display,
form_for_classification, holds_printed_tin, key, line_text, long_text, parse_month_day,
parse_share, shape, today_in,
};
use ledger_core::{AccountType, Currency, Ledger, PartyField, Role, TaxForm};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::str::FromStr;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn src() -> Source {
Source {
decided_by: "Owner".into(),
decided_on: date(2026, 10, 17),
citation: None,
note: None,
}
}
/// Settings holding `pairs`, checked then applied as one change.
fn settings(pairs: &[(&str, Value)]) -> Settings {
let changes: Vec<Change> = pairs
.iter()
.map(|(k, v)| Change {
key: (*k).into(),
value: Some(v.clone()),
})
.collect();
let mut s = Settings::default();
let checked = s.check_changes(&changes, &src()).unwrap();
s.apply(&checked, &src(), 1).unwrap();
s
}
fn checked(name: &str, v: Value) -> Result<Value, String> {
check(key(name).unwrap(), &v)
}
#[test]
fn accessors_return_what_was_set() {
let s = settings(&[
("policy.evidence.threshold", json!("0.00")),
("policy.evidence.required_accounts", json!(["1000", "2050"])),
("policy.evidence.exempt_accounts", json!(["6900"])),
("books.cash_accounts", json!(["1000", "1010"])),
("books.financing_accounts", json!(["2500"])),
("books.investing_accounts", json!(["1500", "1510"])),
("books.asset_tag_prefix", json!("WL")),
("policy.parties", json!("off")),
("books.close_sweep", json!(["3100"])),
]);
assert_eq!(s.receipt_threshold(), Some("0.00"));
assert_eq!(s.receipt_required_accounts(), ["1000", "2050"]);
assert_eq!(s.receipt_exempt_accounts(), ["6900"]);
assert_eq!(s.cash_accounts(), ["1000", "1010"]);
assert_eq!(s.financing_accounts(), ["2500"]);
assert_eq!(s.investing_accounts(), ["1500", "1510"]);
assert_eq!(s.asset_tag_prefix(), Some("WL"));
assert_eq!(s.parties(), PartyPolicy::Off);
assert_eq!(s.company("USD").close_sweep, ["3100"]);
let s = settings(&[("policy.parties", json!("require"))]);
assert_eq!(s.parties(), PartyPolicy::Require);
// Unset: nothing, and the default policy.
let s = Settings::default();
assert_eq!(s.receipt_threshold(), None);
assert!(s.cash_accounts().is_empty());
assert_eq!(s.asset_tag_prefix(), None);
assert_eq!(s.parties(), PartyPolicy::Suggest);
}
#[test]
fn the_company_profile_is_its_company_keys_name_first() {
let s = settings(&[
("company.ein", json!("12-3456789")),
("company.name", json!("Acme LLC")),
("company.duns", json!("149976972")),
(
"company.members",
json!([
{"name": "Kat", "share": "50%", "capital_account": "3001"},
{"name": "Stephen", "share": "50%", "capital_account": "3000"},
]),
),
("books.accounting_basis", json!("cash")),
("books.asset_tag_prefix", json!("WL")),
]);
let c = s.company("USD");
let profile: Vec<(&str, &str)> = c
.profile
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
profile,
[
("name", "Acme LLC"),
("base_currency", "USD"),
("ein", "12-3456789"),
("duns", "149976972"),
]
);
assert_eq!(c.name, "Acme LLC");
assert_eq!(c.basis, "Cash");
assert_eq!(c.members.len(), 2);
}
#[test]
fn party_policy_spellings() {
assert_eq!(PartyPolicy::Off.key(), "off");
assert_eq!(PartyPolicy::Suggest.key(), "suggest");
assert_eq!(PartyPolicy::Require.key(), "require");
assert_eq!(PartyPolicy::parse("off"), Some(PartyPolicy::Off));
assert_eq!(PartyPolicy::parse("suggest"), Some(PartyPolicy::Suggest));
assert_eq!(PartyPolicy::parse("require"), Some(PartyPolicy::Require));
assert_eq!(PartyPolicy::parse("required"), Some(PartyPolicy::Require));
assert_eq!(PartyPolicy::parse("Require"), None);
assert_eq!(PartyPolicy::parse(""), None);
}
#[test]
fn fiscal_year_bounds() {
let c = Company {
fiscal_year_start: (7, 1),
..Company::default()
};
assert_eq!(
c.fiscal_year_containing(date(2026, 3, 15)),
(date(2025, 7, 1), date(2026, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(date(2026, 6, 30)),
(date(2025, 7, 1), date(2026, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(date(2026, 7, 1)),
(date(2026, 7, 1), date(2027, 6, 30))
);
let calendar = Company::default();
assert_eq!(
calendar.fiscal_year_containing(date(2024, 2, 29)),
(date(2024, 1, 1), date(2024, 12, 31))
);
}
/// A member's party is the live one of their name, else the most recent
/// archived one of their name: never another archived member's.
#[test]
fn party_members_find_each_members_own_party() {
let mut l = Ledger::new(Currency::USD);
let capital = l
.add_account("3001", "Capital — Kat", AccountType::Equity, None, false)
.unwrap();
l.add_account(
"3000",
"Capital — Stephen",
AccountType::Equity,
None,
false,
)
.unwrap();
let kat = l.add_party("Kat", &[Role::Member]).unwrap();
let other = l.add_party("Pat", &[Role::Member]).unwrap();
l.update_party(kat, PartyField::Archived(true)).unwrap();
l.update_party(other, PartyField::Archived(true)).unwrap();
let c = Company {
members: vec![Member {
name: "kat ".into(),
share: Decimal::ONE,
capital_account: "3001".into(),
distribution_account: None,
files_jointly_with: None,
}],
..Company::default()
};
let got = c.party_members(&l);
assert_eq!(got.len(), 1);
assert_eq!(got[0].party, kat);
assert_eq!(got[0].capital, capital);
assert_eq!(got[0].share, Decimal::ONE);
assert_eq!(got[0].distributions, None);
}
#[test]
fn printed_tin_joiners() {
// Three spaces is not a joiner; a 3-6 split is no TIN's shape; two
// dashes is not one joiner.
assert!(!holds_printed_tin("123 45 6789"));
assert!(!holds_printed_tin("123 456789"));
assert!(!holds_printed_tin("123--45--6789"));
// Two spaces, and a dash with a space each side, are.
assert!(holds_printed_tin("123 45 6789"));
assert!(holds_printed_tin("123 - 45 - 6789"));
assert!(holds_printed_tin("12 3456789"));
}
#[test]
fn text_length_boundaries() {
assert_eq!(line_text("x", "aaaaa", 5).unwrap(), "aaaaa");
assert_eq!(
line_text("x", "aaaaaa", 5).unwrap_err(),
"x is longer than 5 characters"
);
assert_eq!(long_text("x", "aaaaa", 5).unwrap(), "aaaaa");
assert_eq!(
long_text("x", "aaaaaa", 5).unwrap_err(),
"x is longer than 5 characters"
);
assert_eq!(long_text("x", "a\nb\tc", 10).unwrap(), "a\nb\tc");
assert_eq!(
long_text("x", "a\u{7}b", 10).unwrap_err(),
"x holds a control character"
);
}
#[test]
fn share_boundaries() {
assert_eq!(parse_share("K", "1").unwrap(), Decimal::ONE);
assert_eq!(parse_share("K", "100%").unwrap(), Decimal::ONE);
assert_eq!(parse_share("K", "0.5").unwrap(), dec("0.5"));
assert_eq!(parse_share("K", "50%").unwrap(), dec("0.5"));
assert_eq!(parse_share("K", "0.0001").unwrap(), dec("0.0001"));
for bad in ["0", "0%", "-0.5", "1.0001", "101%", "2"] {
assert_eq!(
parse_share("K", bad).unwrap_err(),
format!("member K: share {bad:?} must be like \"50%\" or \"0.5\""),
);
}
}
#[test]
fn month_day_digits_only() {
assert_eq!(parse_month_day("07-01").unwrap(), (7, 1));
for bad in ["+1-01", "01-+1", "-01", "01-"] {
assert_eq!(
parse_month_day(bad).unwrap_err(),
format!("fiscal_year_start {bad:?}: expected \"MM-DD\", e.g. \"01-01\""),
);
}
}
#[test]
fn every_classification_names_its_return() {
use TaxForm::*;
for (c, want) in [
("partnership", Some(Form1065)),
("General Partnership", Some(Form1065)),
("S corporation", Some(Form1120S)),
("S-corp", Some(Form1120S)),
("s corp", Some(Form1120S)),
("scorp", Some(Form1120S)),
("C corporation", Some(Form1120)),
("ccorp", Some(Form1120)),
("corporation", Some(Form1120)),
("corp", Some(Form1120)),
("association", Some(Form1120)),
("disregarded entity", Some(ScheduleC)),
("sole proprietor", Some(ScheduleC)),
("single-member LLC", Some(ScheduleC)),
("member single", None),
("corporation or S corporation", None),
("partnership or disregarded", None),
("LLC", None),
("", None),
] {
assert_eq!(form_for_classification(c), want, "{c:?}");
}
}
#[test]
fn codes_and_choices_keep_their_spelling() {
assert_eq!(
checked("books.close_into", json!(" 3000 ")),
Ok(json!("3000"))
);
assert_eq!(
checked("books.cash_accounts", json!([" 1000", "1010 "])),
Ok(json!(["1000", "1010"]))
);
for (k, v) in [
("books.accounting_basis", "accrual"),
("books.accounting_basis", "cash"),
("policy.parties", "off"),
("policy.parties", "suggest"),
("policy.parties", "require"),
("policy.approval_rules", "off"),
("policy.approval_rules", "enforce"),
] {
assert_eq!(checked(k, json!(v)), Ok(json!(v)), "{k} {v}");
}
}
#[test]
fn amount_bound_is_inclusive() {
let max = ledger_core::kernel::MAX_LINE_AMOUNT.to_string();
assert_eq!(
checked("policy.evidence.threshold", json!(max)),
Ok(json!(max))
);
let over = format!("{max}.01");
assert_eq!(
checked("policy.evidence.threshold", json!(over)),
Err(format!(
"policy.evidence.threshold {over:?} is out of range"
))
);
}
#[test]
fn ein_shapes() {
let ein = |s: &str| checked("company.ein", json!(s));
assert_eq!(ein("12-3456789"), Ok(json!("12-3456789")));
assert_eq!(ein("123456789"), Ok(json!("12-3456789")));
let refused = Err("company.ein: expected an EIN, NN-NNNNNNN".to_string());
for bad in [
"12345678",
"12-34-567",
"12345678x",
"123-456789",
"1-23456789",
] {
assert_eq!(ein(bad), refused, "{bad:?}");
}
}
#[test]
fn name_control_and_tag_prefix_bounds() {
let nc = |s: &str| checked("company.irs_name_control", json!(s));
for ok in ["WAND", "A&B-", "A&", "A-B", "w"] {
assert_eq!(nc(ok), Ok(json!(ok.to_uppercase())), "{ok:?}");
}
assert_eq!(
nc("WANDE"),
Err("company.irs_name_control \"WANDE\": one to four letters, digits, & or -".into())
);
assert!(nc("W.A").is_err());
let tag = |s: &str| checked("books.asset_tag_prefix", json!(s));
assert_eq!(tag("WL"), Ok(json!("WL")));
assert_eq!(tag("ABCDEFGH"), Ok(json!("ABCDEFGH")));
assert_eq!(
tag("ABCDEFGHI"),
Err("books.asset_tag_prefix \"ABCDEFGHI\": 1 to 8 letters or digits".into())
);
assert!(tag("").is_err());
assert!(tag("W-L").is_err());
}
/// Replay checks an amount and a fiscal year start by their shape.
#[test]
fn replay_shapes_amounts_and_month_starts() {
let threshold = key("policy.evidence.threshold").unwrap();
assert!(shape(threshold, &json!("25.00")).is_ok());
assert_eq!(
shape(threshold, &json!("abc")),
Err("policy.evidence.threshold \"abc\" is not a decimal".into())
);
let fy = key("company.fiscal_year_start").unwrap();
assert!(shape(fy, &json!("07-15")).is_ok());
for bad in ["13-01", "02-30", "07"] {
assert_eq!(
shape(fy, &json!(bad)),
Err(format!("fiscal_year_start {bad:?}: expected \"MM-DD\"")),
);
}
}
#[test]
fn today_is_the_companys_today() {
let utc = chrono::Utc::now().date_naive();
let near = |d: NaiveDate| (d - utc).num_days().abs() <= 1;
let s = settings(&[("company.timezone", json!("America/Denver"))]);
assert!(near(s.today()), "{}", s.today());
assert!(near(today_in(None)));
// UTC+14 and UTC-11 are 25 hours apart: never the same day.
let east = today_in(Some("Pacific/Kiritimati"));
let west = today_in(Some("Pacific/Pago_Pago"));
assert!(near(east) && near(west));
assert!(east > west, "{east} {west}");
assert!(east.year() >= 2026);
}
#[test]
fn display_joins_members_and_lists() {
let members = key("company.members").unwrap();
let v = json!([
{"name": "Kat", "share": "0.5", "capital_account": "3001"},
{"name": "Stephen", "share": "0.5", "capital_account": "3000"},
]);
assert_eq!(display(members, &v, false), "Kat 0.5, Stephen 0.5");
let cash = key("books.cash_accounts").unwrap();
assert_eq!(display(cash, &json!(["1000", "1010"]), false), "1000, 1010");
let ein = key("company.ein").unwrap();
assert_eq!(display(ein, &json!("12-3456789"), false), "**-***6789");
assert_eq!(display(ein, &json!("12-3456789"), true), "12-3456789");
}
/// A date is taken only as written YYYY-MM-DD, one spelling per day.
#[test]
fn dates_take_one_spelling() {
let formed = |s: &str| checked("company.formed", json!(s));
assert_eq!(formed("2026-10-04"), Ok(json!("2026-10-04")));
assert_eq!(formed(" 2026-10-04 "), Ok(json!("2026-10-04")));
for bad in ["2026-1-4", "2026-10-4", "+2026-10-04"] {
assert_eq!(
formed(bad),
Err(format!(
"company.formed {bad:?}: expected a date, YYYY-MM-DD"
)),
);
}
}
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
+792
View File
@@ -0,0 +1,792 @@
//! No command reaches an overflow or a date chrono can't hold: every amount
//! a new command takes is at most a journal line's
//! (kernel::MAX_LINE_AMOUNT), every date is in the calendar the books keep
//! (ledger_core::calendar), and what can't be computed exactly is refused,
//! never wrapped or panicked on (P0 review round 3, finding S1: a panic
//! stops the books server). The bounds are the commands' only: replay
//! accepts every line an earlier version accepted (rules.md B-28, posted
//! is forever; P0 review round 4), so each bound here has its pair, a
//! replay of an old line past it, accepted.
//!
//! ```text
//! GOAL: a command the books can't hold is refused with a reason, the
//! ledger unchanged; a replayed line an earlier version wrote past
//! the same bound is accepted exactly as written.
//! SOURCE: DECISION (P0 review round 3, 2026-10-16, S1; round 4,
//! 2026-10-17); kernel.rs MAX_LINE_AMOUNT (rules.md: a line over a
//! quadrillion is a mistake); calendar.rs (four-digit years).
//! ```
use chrono::{Datelike, NaiveDate};
use ledger_core::kernel::MAX_LINE_AMOUNT;
use ledger_core::{
AccountId, AccountType, Currency, DocumentLine, Frequency, Ledger, LedgerError, Money,
NewRecurring, NewSchedule, PayrollLine, Side,
};
use rust_decimal::Decimal;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(a: Decimal) -> Money {
Money::new(a, Currency::USD)
}
/// One past the largest a line may be.
fn too_big() -> Decimal {
MAX_LINE_AMOUNT + Decimal::new(1, 2)
}
struct Books {
ledger: Ledger,
cash: AccountId,
prepaid: AccountId,
revenue: AccountId,
expense: AccountId,
}
fn books() -> Books {
let mut ledger = Ledger::new(Currency::USD);
let mut add = |code: &str, ty| ledger.add_account(code, code, ty, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let prepaid = add("1300", AccountType::Asset);
let revenue = add("4000", AccountType::Revenue);
let expense = add("6000", AccountType::Expense);
add("3000", AccountType::Equity);
Books {
ledger,
cash,
prepaid,
revenue,
expense,
}
}
/// A date outside the calendar is refused wherever a command takes one:
/// a draft, a period, a schedule's months, a recurring entry, a document.
#[test]
fn dates_outside_the_calendar_are_refused_everywhere() {
let mut b = books();
for far in [NaiveDate::MAX, NaiveDate::MIN, d(10000, 1, 1), d(0, 12, 31)] {
assert_eq!(
b.ledger.try_create_entry(far, "far").unwrap_err(),
LedgerError::DateOutOfRange(far)
);
assert_eq!(
b.ledger.create_period("far", far, far).unwrap_err(),
LedgerError::DateOutOfRange(far)
);
}
assert_eq!(
b.ledger
.create_period("far", d(2026, 1, 1), d(10000, 1, 1))
.unwrap_err(),
LedgerError::DateOutOfRange(d(10000, 1, 1))
);
b.ledger.try_create_entry(d(1, 1, 1), "first day").unwrap();
b.ledger
.try_create_entry(d(9999, 12, 31), "last day")
.unwrap();
b.ledger
.create_period("last", d(9999, 12, 1), d(9999, 12, 31))
.unwrap();
// A schedule whose months run past the calendar's last day.
let schedule = |start: NaiveDate, months: u32| NewSchedule {
description: "Insurance".into(),
kind: ledger_core::schedules::ScheduleKind::PrepaidExpense,
total: usd(Decimal::new(30000, 2)),
start,
months,
balance_account: b.prepaid,
pl_account: b.expense,
};
for (start, months) in [
(d(9999, 12, 15), 2),
(d(9999, 1, 1), 13),
(NaiveDate::MAX, 1),
(d(0, 6, 1), 1),
] {
assert!(
b.ledger.register_schedule(schedule(start, months)).is_err(),
"{start} + {months}"
);
}
b.ledger
.register_schedule(schedule(d(9999, 1, 1), 12))
.unwrap();
// A recurring entry starting or ending outside it.
let recurring = |start: NaiveDate, end: Option<NaiveDate>| NewRecurring {
description: "Rent".into(),
lines: vec![
(b.expense, Side::Debit, usd(Decimal::ONE)),
(b.cash, Side::Credit, usd(Decimal::ONE)),
],
frequency: Frequency::Monthly,
start,
end,
};
for (start, end) in [
(NaiveDate::MIN, None),
(d(2026, 1, 1), Some(NaiveDate::MAX)),
] {
assert!(matches!(
b.ledger.register_recurring(recurring(start, end)),
Err(LedgerError::DateOutOfRange(_))
));
}
// An invoice due outside it.
let customer = b.ledger.add_customer("Client").unwrap();
let line = vec![DocumentLine {
description: "work".into(),
amount: Decimal::ONE,
}];
assert_eq!(
b.ledger
.create_invoice(customer, d(2026, 1, 1), NaiveDate::MAX, line.clone())
.unwrap_err(),
LedgerError::DateOutOfRange(NaiveDate::MAX)
);
let _ = b.revenue;
}
/// The pairs of dates_outside_the_calendar_are_refused_everywhere: each
/// replay of an old line dated past the calendar is accepted as written.
#[test]
fn replayed_dates_outside_the_calendar_are_accepted() {
let mut b = books();
for far in [NaiveDate::MAX, NaiveDate::MIN, d(10000, 1, 1), d(0, 12, 31)] {
let e = b.ledger.create_entry(far, "far");
assert_eq!(b.ledger.get_entry(e).unwrap().date(), far);
let p = b.ledger.replay_create_period("far", far, far).unwrap();
assert_eq!(b.ledger.get_period(p).unwrap().start, far);
}
// Only the order is checked on replay.
assert!(matches!(
b.ledger
.replay_create_period("backwards", d(2026, 2, 1), d(2026, 1, 1)),
Err(LedgerError::InvalidPeriodRange { .. })
));
let schedule = |start: NaiveDate, months: u32| NewSchedule {
description: "Insurance".into(),
kind: ledger_core::schedules::ScheduleKind::PrepaidExpense,
total: usd(Decimal::new(30000, 2)),
start,
months,
balance_account: b.prepaid,
pl_account: b.expense,
};
for (start, months) in [
(d(9999, 12, 15), 2),
(d(9999, 6, 1), 12),
(NaiveDate::MAX, 1),
(d(0, 6, 1), 1),
] {
let id = b
.ledger
.replay_register_schedule(schedule(start, months))
.unwrap_or_else(|e| panic!("{start} + {months}: {e}"));
assert_eq!(b.ledger.get_schedule(id).unwrap().start, start);
}
// The month bounds are main's: 1 to 600.
assert!(
b.ledger
.replay_register_schedule(schedule(d(2026, 1, 1), 601))
.is_err()
);
let recurring = |start: NaiveDate, end: Option<NaiveDate>| NewRecurring {
description: "Rent".into(),
lines: vec![
(b.expense, Side::Debit, usd(Decimal::ONE)),
(b.cash, Side::Credit, usd(Decimal::ONE)),
],
frequency: Frequency::Monthly,
start,
end,
};
for (start, end) in [
(NaiveDate::MIN, None),
(d(2026, 1, 1), Some(NaiveDate::MAX)),
] {
b.ledger
.replay_register_recurring(recurring(start, end))
.unwrap();
}
let customer = b.ledger.add_customer("Client").unwrap();
let vendor = b.ledger.add_vendor("Supplier").unwrap();
let line = vec![DocumentLine {
description: "work".into(),
amount: Decimal::ONE,
}];
b.ledger
.replay_invoice(1, customer, NaiveDate::MIN, NaiveDate::MAX, line.clone(), 1)
.unwrap();
b.ledger
.replay_bill(1, vendor, d(0, 1, 1), d(10000, 1, 1), line, 1)
.unwrap();
let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap();
let equipment = add("1500", AccountType::Asset);
let accum = add("1501", AccountType::Asset);
let depreciation = add("6100", AccountType::Expense);
let asset = |purchase_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset {
description: "Late".into(),
purchase_date,
cost: usd(Decimal::new(120000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
};
for (date, life) in [
(d(9999, 1, 1), 60),
(d(2026, 1, 1), 120_000),
(d(0, 1, 1), 1),
] {
assert_eq!(
b.ledger.register_asset(asset(date, life)).unwrap_err(),
if date.year() == 0 {
LedgerError::DateOutOfRange(date)
} else {
LedgerError::AssetDateOverflow
}
);
b.ledger.replay_register_asset(asset(date, life)).unwrap();
}
// Replay keeps main's one date rule: the life's end is a date chrono
// holds.
assert_eq!(
b.ledger
.replay_register_asset(asset(d(262_142, 1, 1), 12))
.unwrap_err(),
LedgerError::AssetDateOverflow
);
let id = b
.ledger
.replay_register_asset(asset(d(2026, 1, 1), 12))
.unwrap();
let fix = |purchase_date: NaiveDate| ledger_core::AssetCorrection {
cost: usd(Decimal::new(120000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 60,
purchase_date,
};
assert_eq!(
b.ledger.correct_asset(id, fix(d(9999, 1, 1))).unwrap_err(),
LedgerError::AssetDateOverflow
);
b.ledger
.replay_correct_asset(id, fix(d(9999, 1, 1)))
.unwrap();
assert_eq!(b.ledger.get_asset(id).unwrap().purchase_date, d(9999, 1, 1));
// A disposal dated past the calendar.
let proceeds = usd(Decimal::ZERO);
assert_eq!(
b.ledger
.dispose_asset(id, d(10000, 1, 1), proceeds)
.unwrap_err(),
LedgerError::DateOutOfRange(d(10000, 1, 1))
);
b.ledger
.replay_dispose_asset(id, d(10000, 1, 1), proceeds)
.unwrap();
}
/// An amount larger than a line may be is refused wherever a command takes
/// one, so every sum the books make of them is exact.
#[test]
fn amounts_larger_than_a_line_are_refused_everywhere() {
let mut b = books();
let big = too_big();
// A draft line, as it is added; replayed as an older version wrote it.
let e = b.ledger.try_create_entry(d(2026, 1, 1), "big").unwrap();
assert_eq!(
b.ledger
.add_line(e, b.expense, Side::Debit, usd(big))
.unwrap_err(),
LedgerError::AmountOutOfRange
);
for a in [big, Decimal::MAX, -Decimal::MAX] {
b.ledger
.replay_add_line(e, b.expense, Side::Debit, usd(a))
.unwrap();
}
assert_eq!(b.ledger.get_entry(e).unwrap().lines().len(), 3);
// Its post refuses it, never a panic.
assert!(b.ledger.post_entry(e).is_err());
// Document lines, and their replay.
let customer = b.ledger.add_customer("Client").unwrap();
let lines = |a: Decimal| {
vec![
DocumentLine {
description: "a".into(),
amount: a,
},
DocumentLine {
description: "b".into(),
amount: Decimal::ONE,
},
]
};
for a in [big, Decimal::MAX, Decimal::new(1, 11)] {
assert!(matches!(
b.ledger
.create_invoice(customer, d(2026, 1, 1), d(2026, 2, 1), lines(a)),
Err(LedgerError::ValueOutOfRange(_))
));
}
// Replayed as journaled: whatever its lines, a document is restored;
// a total past exact arithmetic is refused where it is computed.
for (id, a) in [(1, big), (2, Decimal::new(1, 11)), (3, Decimal::MAX)] {
b.ledger
.replay_invoice(id, customer, d(2026, 1, 1), d(2026, 2, 1), lines(a), id)
.unwrap();
}
assert_eq!(
b.ledger.get_invoice(2).unwrap().total(),
Ok(Decimal::new(100_000_000_001, 11))
);
assert!(matches!(
b.ledger.get_invoice(3).unwrap().total(),
Err(LedgerError::TooLargeToCompute(_))
));
assert!(matches!(
b.ledger.ar_aging(d(2026, 3, 1)),
Err(LedgerError::TooLargeToCompute(_))
));
// A recurring template's lines.
let recurring = NewRecurring {
description: "x".into(),
lines: vec![
(b.expense, Side::Debit, usd(Decimal::MAX)),
(b.expense, Side::Debit, usd(Decimal::ONE)),
(b.cash, Side::Credit, usd(Decimal::ONE)),
],
frequency: Frequency::Monthly,
start: d(2026, 1, 1),
end: None,
};
assert_eq!(
b.ledger.register_recurring(recurring.clone()).unwrap_err(),
LedgerError::AmountOutOfRange
);
// On replay, one whose debits overflow Decimal is refused (no version
// could have written it: main panicked on it), never a panic; one past
// the line limit that balances is accepted.
assert!(matches!(
b.ledger.replay_register_recurring(recurring),
Err(LedgerError::TooLargeToCompute(_))
));
let half = Decimal::MAX / Decimal::TWO;
b.ledger
.replay_register_recurring(NewRecurring {
description: "x".into(),
lines: vec![
(b.expense, Side::Debit, usd(half)),
(b.cash, Side::Credit, usd(half)),
],
frequency: Frequency::Monthly,
start: d(2026, 1, 1),
end: None,
})
.unwrap();
// Payroll figures, run and replayed.
let payroll = |fed: Decimal, ss: Decimal| PayrollLine {
employee_name: "Dana".into(),
employee: None,
gross_pay: Decimal::ONE,
federal_withholding: fed,
state_withholding: fed,
ss_employee: Decimal::ZERO,
medicare_employee: Decimal::ZERO,
ss_employer: ss,
medicare_employer: Decimal::ONE,
other_deductions: Decimal::ZERO,
};
for line in [
payroll(Decimal::MAX, Decimal::ONE),
payroll(Decimal::ZERO, big),
] {
assert!(matches!(
b.ledger.run_payroll(d(2026, 1, 31), vec![line], b.cash),
Err(LedgerError::ValueOutOfRange(_))
));
}
// Replayed as journaled, sub-cent and past the limit alike; a net
// past exact arithmetic is refused where it is computed.
let fine = payroll(Decimal::new(1, 12), Decimal::ONE);
let huge = payroll(Decimal::ZERO, big);
b.ledger
.replay_payroll_run(1, d(2026, 1, 31), vec![fine, huge], 1)
.unwrap();
let mut over = payroll(Decimal::ZERO, Decimal::ONE);
over.gross_pay = -Decimal::MAX;
over.federal_withholding = Decimal::MAX;
assert!(matches!(
over.net_pay(),
Err(LedgerError::TooLargeToCompute(_))
));
assert!(
ledger_core::payroll::check_payroll_ranges(&[payroll(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT)])
.is_ok()
);
// A statement balance in a reconciliation.
for balance in [-Decimal::MAX, big, -big] {
assert!(matches!(
b.ledger
.reconciliation_report(b.cash, d(2026, 1, 31), balance),
Err(LedgerError::ValueOutOfRange(_))
));
}
let report = b
.ledger
.reconciliation_report(b.cash, d(2026, 1, 31), -MAX_LINE_AMOUNT)
.unwrap();
assert_eq!(report.difference, MAX_LINE_AMOUNT);
// A payout's lines and its deposit.
let payout = |revenue: Decimal, deposit: Decimal| ledger_core::Payout {
deposit_account: b.cash,
deposit,
revenue: vec![ledger_core::PayoutLine {
account: b.revenue,
amount: revenue,
class: None,
}],
fees: vec![],
adjustments: vec![],
};
for p in [payout(big, big), payout(Decimal::ONE, -Decimal::MAX)] {
assert!(matches!(
ledger_core::payout_plan(&p, b.ledger.chart()),
Err(LedgerError::ValueOutOfRange(_))
));
}
assert!(
ledger_core::payout_plan(&payout(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT), b.ledger.chart())
.is_ok()
);
// A contributor's basis.
let basis = |b: Decimal| ledger_core::TaxBasis {
rule: ledger_core::TaxBasisRule::Carryover,
contributor_basis: Some(b),
};
assert!(basis(big).check().is_err());
assert!(basis(MAX_LINE_AMOUNT).check().is_ok());
// On replay: every journaled basis passed check_journaled.
let cents_max = Decimal::MAX.round_dp(2) / Decimal::ONE_HUNDRED;
assert!(basis(big.round_dp(2)).check_journaled().is_ok());
assert!(basis(cents_max.round_dp(2)).check_journaled().is_ok());
assert!(basis(-Decimal::ONE).check_journaled().is_err());
// A party's share of an entry.
let share = |a: Decimal| {
vec![ledger_core::PartyShare {
party: 1,
share: ledger_core::shares::Share::Amount(a),
}]
};
let known = |_| true;
assert!(ledger_core::shares::check_shares(&share(big.round_dp(2)), known).is_err());
assert!(ledger_core::shares::check_shares(&share(MAX_LINE_AMOUNT), known).is_ok());
// On replay: every journaled share passed check_shares_journaled.
assert!(ledger_core::shares::check_shares_journaled(&share(big.round_dp(2)), known).is_ok());
assert!(ledger_core::shares::check_shares_journaled(&share(Decimal::MAX), known).is_ok());
assert!(ledger_core::shares::check_shares_journaled(&share(Decimal::ZERO), known).is_err());
}
/// The ledger-level pairs for a basis and shares: the command refuses past
/// the limit; replay of the old line is accepted, and what it feeds (the
/// register, the shares' resolution) refuses what it can't compute.
#[test]
fn replayed_bases_and_shares_past_the_limit_are_accepted() {
let mut b = books();
let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap();
let equipment = add("1500", AccountType::Asset);
let accum = add("1501", AccountType::Asset);
let depreciation = add("6100", AccountType::Expense);
// Decimal's largest is a whole number of cents, as every version
// required of a basis and a share.
let cents_max = Decimal::MAX;
let basis = ledger_core::TaxBasis {
rule: ledger_core::TaxBasisRule::Carryover,
contributor_basis: Some(cents_max),
};
for _ in 0..2 {
let id = b
.ledger
.register_asset(ledger_core::NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 1, 1),
cost: usd(Decimal::new(240000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 24,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
})
.unwrap();
assert!(matches!(
b.ledger.set_asset_tax_basis(id, basis),
Err(LedgerError::InvalidTaxBasis(_))
));
b.ledger.replay_set_asset_tax_basis(id, basis).unwrap();
assert_eq!(b.ledger.get_asset(id).unwrap().tax, Some(basis));
}
assert!(matches!(
ledger_core::fixed_asset_register(&b.ledger, d(2026, 12, 31)),
Err(LedgerError::TooLargeToCompute(_))
));
let a = b
.ledger
.add_party("A", &[ledger_core::Role::Member])
.unwrap();
let c = b
.ledger
.add_party("C", &[ledger_core::Role::Member])
.unwrap();
let e = b.ledger.try_create_entry(d(2026, 3, 1), "rent").unwrap();
b.ledger
.add_line(e, b.expense, Side::Debit, usd(Decimal::TEN))
.unwrap();
b.ledger
.add_line(e, b.cash, Side::Credit, usd(Decimal::TEN))
.unwrap();
b.ledger.post_entry(e).unwrap();
let shares = [a, c].map(|party| ledger_core::PartyShare {
party,
share: ledger_core::shares::Share::Amount(cents_max),
});
assert!(matches!(
b.ledger.set_entry_parties(e, &shares),
Err(LedgerError::InvalidParty(_))
));
b.ledger.replay_set_entry_parties(e, &shares).unwrap();
assert_eq!(b.ledger.entry_parties(e), &shares);
let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash]);
let why = ledger_core::shares::resolve(b.ledger.get_entry(e).unwrap(), &shares, &money, &[])
.unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
// Replay still checks what every version checked.
assert_eq!(
b.ledger.replay_set_entry_parties(99, &shares).unwrap_err(),
LedgerError::UnknownEntry(99)
);
}
/// A replayed payment is as journaled: no bound but exact arithmetic (a
/// paid amount past it is refused, never wrapped). The command's bound is
/// the balance (Overpayment).
#[test]
fn replayed_payments_are_as_journaled() {
let mut b = books();
let customer = b.ledger.add_customer("Client").unwrap();
let invoice = b
.ledger
.create_invoice(
customer,
d(2026, 1, 1),
d(2026, 2, 1),
vec![DocumentLine {
description: "work".into(),
amount: Decimal::new(10000, 2),
}],
)
.unwrap();
assert!(matches!(
b.ledger
.receive_payment(invoice, d(2026, 1, 15), too_big(), b.cash),
Err(LedgerError::Overpayment(..))
));
b.ledger
.replay_invoice_payment(invoice, too_big(), 98)
.unwrap();
b.ledger
.replay_invoice_payment(invoice, Decimal::MAX - too_big(), 99)
.unwrap();
assert_eq!(
b.ledger.get_invoice(invoice).unwrap().amount_paid,
Decimal::MAX
);
assert!(matches!(
b.ledger.replay_invoice_payment(invoice, Decimal::ONE, 100),
Err(LedgerError::TooLargeToCompute(_))
));
// Refused before anything changed.
assert_eq!(
b.ledger.get_invoice(invoice).unwrap().amount_paid,
Decimal::MAX
);
}
/// A disposal's proceeds are a line of its entry, and its date a day of
/// the calendar, for a new disposal; a replayed one is as journaled, and
/// its amounts (disposal_amounts, pure) are computed, never a panic.
#[test]
fn disposal_proceeds_and_dates_are_bounded() {
let mut b = books();
let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap();
let equipment = add("1500", AccountType::Asset);
let accum = add("1501", AccountType::Asset);
let depreciation = add("6100", AccountType::Expense);
let laptop = || ledger_core::NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 1, 1),
cost: usd(Decimal::new(240000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 24,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
};
let id = b.ledger.register_asset(laptop()).unwrap();
for proceeds in [Decimal::MAX, too_big()] {
assert_eq!(
b.ledger
.dispose_asset(id, d(2026, 6, 30), usd(proceeds))
.unwrap_err(),
LedgerError::AmountOutOfRange
);
let asset = b.ledger.get_asset(id).unwrap().clone();
let amounts = ledger_core::disposal_amounts(&asset, d(2026, 6, 30), usd(proceeds)).unwrap();
assert_eq!(amounts.gain_loss, proceeds - amounts.book_value);
}
assert_eq!(
b.ledger
.dispose_asset(id, NaiveDate::MAX, usd(Decimal::ONE))
.unwrap_err(),
LedgerError::DateOutOfRange(NaiveDate::MAX)
);
// Disposal amounts at chrono's last day: computed.
let asset = b.ledger.get_asset(id).unwrap().clone();
let amounts = ledger_core::disposal_amounts(&asset, NaiveDate::MAX, usd(Decimal::ONE)).unwrap();
assert_eq!(amounts.book_value, Decimal::ZERO);
b.ledger
.dispose_asset(id, d(2026, 6, 30), usd(MAX_LINE_AMOUNT))
.unwrap();
// Replayed past both bounds: accepted as journaled.
let old = b.ledger.register_asset(laptop()).unwrap();
b.ledger
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::MAX))
.unwrap();
assert!(matches!(
b.ledger.get_asset(old).unwrap().status,
ledger_core::AssetStatus::Disposed { date, .. } if date == NaiveDate::MAX
));
// Replay keeps what every version checked: no disposal twice, none
// before the purchase, no negative proceeds.
assert_eq!(
b.ledger
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::ONE))
.unwrap_err(),
LedgerError::AssetAlreadyDisposed(old)
);
let early = b.ledger.register_asset(laptop()).unwrap();
assert!(matches!(
b.ledger
.replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)),
Err(LedgerError::DisposalBeforePurchase(_))
));
assert!(matches!(
b.ledger
.replay_dispose_asset(early, d(2026, 2, 1), usd(-Decimal::ONE)),
Err(LedgerError::NegativeProceeds(_))
));
}
/// The bounds are the line limit exactly: a new draft line (base or
/// foreign) and a recurring template's line may be as large as a line, and
/// no larger; a replayed one past it is accepted as journaled.
#[test]
fn the_bound_is_the_line_limit_itself() {
let mut b = books();
let e = b.ledger.try_create_entry(d(2026, 1, 1), "max").unwrap();
for amount in [MAX_LINE_AMOUNT, -MAX_LINE_AMOUNT] {
b.ledger
.add_line(e, b.cash, Side::Debit, usd(amount))
.unwrap();
}
for amount in [too_big().round_dp(2), -too_big().round_dp(2)] {
assert_eq!(
b.ledger
.add_line(e, b.cash, Side::Debit, usd(amount))
.unwrap_err(),
LedgerError::AmountOutOfRange
);
}
// A foreign line: its base amount, foreign x rate, is what's bounded.
b.ledger
.add_line_fx(
e,
b.cash,
Side::Debit,
Currency::EUR,
MAX_LINE_AMOUNT,
Decimal::ONE,
)
.unwrap();
assert_eq!(
b.ledger
.add_line_fx(
e,
b.cash,
Side::Debit,
Currency::EUR,
MAX_LINE_AMOUNT,
Decimal::TWO
)
.unwrap_err(),
LedgerError::AmountOutOfRange
);
// Replayed as an earlier version booked them: past the limit too.
for amount in [too_big(), -too_big(), Decimal::MAX] {
b.ledger
.replay_add_line_fx(
e,
b.cash,
Side::Debit,
usd(amount),
Currency::EUR,
Decimal::ONE,
)
.unwrap();
b.ledger
.replay_add_line(e, b.cash, Side::Debit, usd(amount))
.unwrap();
}
assert_eq!(b.ledger.get_entry(e).unwrap().lines().len(), 9);
let recurring = |a: Decimal| NewRecurring {
description: "max".into(),
lines: vec![
(b.expense, Side::Debit, usd(a)),
(b.cash, Side::Credit, usd(a)),
],
frequency: Frequency::Monthly,
start: d(2026, 1, 1),
end: None,
};
b.ledger
.register_recurring(recurring(MAX_LINE_AMOUNT))
.unwrap();
assert_eq!(
b.ledger
.register_recurring(recurring(too_big()))
.unwrap_err(),
LedgerError::AmountOutOfRange
);
let old = b
.ledger
.replay_register_recurring(recurring(too_big()))
.unwrap();
// Its occurrences generate drafts as journaled; their post refuses.
let draft = b
.ledger
.replay_generate_recurring(old, d(2026, 1, 1))
.unwrap();
assert_eq!(
b.ledger.get_entry(draft).unwrap().lines()[0].amount.amount,
too_big()
);
assert_eq!(
b.ledger.post_entry(draft).unwrap_err(),
LedgerError::AmountOutOfRange
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,412 @@
//! Figures derived from what replay accepts: computed exactly where exact
//! arithmetic and chrono's calendar reach, refused (TooLargeToCompute,
//! DateOutOfRange) where they don't, never a panic (rules.md B-28; P0
//! review round 4, 2026-10-17).
//!
//! ```text
//! GOAL: books an earlier version wrote (a draft line of Decimal's
//! largest, shares and bases past the line limit, a document's lines
//! or a payroll run as journaled, dates at chrono's last day) can be
//! read every way the engine reads books, and the server stays up.
//! SOURCE: DECISION (P0 review round 4: posted is forever; derived
//! computations refuse rather than panic).
//! ```
use chrono::{Datelike, NaiveDate};
use ledger_core::{
AccountType, Currency, DocumentLine, Ledger, LedgerError, Money, NewAsset, PartyShare,
PayrollLine, Side,
};
use rust_decimal::Decimal;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(a: Decimal) -> Money {
Money::new(a, Currency::USD)
}
fn too_large(r: Result<impl std::fmt::Debug, LedgerError>) -> bool {
matches!(r, Err(LedgerError::TooLargeToCompute(_)))
}
/// An asset whose life ends in chrono's last month (an earlier version
/// accepted any life whose end chrono holds): its schedule walks into
/// December 262142, prorates that month by its 31 days, and sums to its
/// cost exactly.
#[test]
fn depreciation_reaches_chronos_last_month() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
let cost_account = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let expense = add("6100", AccountType::Expense);
let y = NaiveDate::MAX.year();
let id = l
.replay_register_asset(NewAsset {
description: "edge".into(),
purchase_date: d(y, 1, 15),
cost: usd(Decimal::new(120000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 11,
cost_account,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let a = l.get_asset(id).unwrap();
let full = ledger_core::depreciation_schedule(a, NaiveDate::MAX);
assert_eq!(full.len(), 12);
assert_eq!(full[0].period, format!("{y}-01"));
assert_eq!(full[11].period, format!("{y}-12"));
assert_eq!(full[11].accumulated, Decimal::new(120000, 2));
assert_eq!(
full.iter().map(|l| l.amount).sum::<Decimal>(),
Decimal::new(120000, 2)
);
// Through November: eleven months, December not yet.
let nov = ledger_core::depreciation_schedule(a, d(y, 11, 30));
assert_eq!(nov.len(), 11);
assert!(nov.last().unwrap().accumulated < Decimal::new(120000, 2));
// December is walked without stepping past chrono's last day.
let dec = ledger_core::depreciation_schedule(a, d(y, 12, 10));
assert_eq!(dec.last().unwrap().period, format!("{y}-12"));
assert_eq!(
ledger_core::accumulated_depreciation(a, NaiveDate::MAX),
Decimal::new(120000, 2)
);
assert_eq!(ledger_core::book_value(a, NaiveDate::MAX), Decimal::ZERO);
}
/// An asset whose life runs past 9999 (or starts before year 1) trues up
/// in its final month like any other: its lifetime is exactly its cost.
#[test]
fn a_life_past_9999_trues_up_to_the_cent() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
let cost_account = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let expense = add("6100", AccountType::Expense);
// March 17th to April 17th: a 31-day first month and a 30-day last, so
// the partial months drift and the true-up is needed.
for (start, months) in [
(d(9999, 3, 17), 61),
(d(-10, 3, 17), 61),
(d(9998, 3, 17), 25),
] {
let id = l
.replay_register_asset(NewAsset {
description: "far".into(),
purchase_date: start,
cost: usd(Decimal::new(100_000, 2)),
salvage_value: usd(Decimal::new(1, 2)),
useful_life_months: months,
cost_account,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let a = l.get_asset(id).unwrap();
let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX);
assert_eq!(
s.last().unwrap().accumulated,
Decimal::new(99_999, 2),
"{start} + {months}"
);
assert_eq!(
s.iter().map(|l| l.amount).sum::<Decimal>(),
Decimal::new(99_999, 2),
"{start} + {months}"
);
}
}
/// February has 29 days exactly in the years chrono calls leap, from its
/// first year to its last (year 0 and the negative years included), so a
/// schedule in any of them prorates February rightly.
#[test]
fn february_is_prorated_by_chronos_calendar_in_every_year() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
let cost_account = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let expense = add("6100", AccountType::Expense);
let min = NaiveDate::MIN.year();
let max = NaiveDate::MAX.year();
for year in [
min,
min + 1,
-400,
-100,
-4,
-1,
0,
1,
4,
100,
400,
1900,
2000,
2024,
2026,
max - 1,
max,
] {
let leap = NaiveDate::from_ymd_opt(year, 2, 29).is_some();
// Bought February 1st, two months of life, so February is a whole
// month of it.
let id = l
.replay_register_asset(NewAsset {
description: format!("{year}"),
purchase_date: d(year, 2, 1),
cost: usd(Decimal::from(2900)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 2,
cost_account,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let a = l.get_asset(id).unwrap();
// Through the 14th: 14 of February's days.
let half = ledger_core::depreciation_schedule(a, d(year, 2, 14));
let days = if leap { 29 } else { 28 };
let want = (Decimal::from(1450) * Decimal::from(14) / Decimal::from(days))
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
assert_eq!(half.last().unwrap().amount, want, "{year}");
}
}
/// A draft an earlier version journaled with lines of Decimal's largest:
/// its money flow and debits are refused, its shares don't resolve, its
/// post refuses it.
#[test]
fn a_draft_of_decimals_largest_is_refused_where_summed() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let rent = l
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let p = l
.add_party("Acme", &[ledger_core::Role::Contractor])
.unwrap();
let q = l
.add_party("Beta", &[ledger_core::Role::Contractor])
.unwrap();
let money = ledger_core::parties::money_accounts(&l, &[cash]);
for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] {
let e = l.create_entry(d(2026, 3, 1), "huge");
for side in [Side::Debit, Side::Debit] {
l.replay_add_line(e, dr, side, usd(Decimal::MAX)).unwrap();
}
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
.unwrap();
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
.unwrap();
let entry = l.get_entry(e).unwrap();
assert!(too_large(ledger_core::parties::entry_flow(entry, &money)));
assert!(too_large(ledger_core::shares::share_target(entry, &money)));
let why =
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange);
}
// One line of Decimal's largest flows, and its share resolves whole.
let e = l.create_entry(d(2026, 3, 1), "one huge");
l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX))
.unwrap();
l.replay_add_line(e, cash, Side::Credit, usd(Decimal::MAX))
.unwrap();
let entry = l.get_entry(e).unwrap();
assert_eq!(
ledger_core::parties::entry_flow(entry, &money).unwrap().net,
Decimal::MAX
);
assert_eq!(
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap(),
[(p, Decimal::MAX)]
);
// A percent of it: the product is past exact arithmetic.
let halves = [p, q].map(|party| PartyShare {
party,
share: ledger_core::shares::Share::Percent(Decimal::from(50)),
});
let why = ledger_core::shares::resolve(entry, &halves, &money, &[]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
}
/// A document is restored as journaled: its total and balance are exact,
/// or refused past exact arithmetic; aging sums by party, checked.
#[test]
fn documents_and_aging_are_exact_or_refused() {
let mut l = Ledger::new(Currency::USD);
let customer = l.add_customer("Client").unwrap();
let line = |a: Decimal| DocumentLine {
description: "x".into(),
amount: a,
};
let fine = vec![
line(Decimal::new(1, 11)),
line("9.99999999999".parse().unwrap()),
];
l.replay_invoice(1, customer, d(2026, 3, 1), d(2026, 3, 31), fine, 1)
.unwrap();
let inv = l.get_invoice(1).unwrap();
assert_eq!(inv.total(), Ok(Decimal::TEN));
assert_eq!(inv.balance(), Ok(Decimal::TEN));
let rows = l.ar_aging(d(2026, 6, 1)).unwrap();
// 62 days past due on June 1st: 61-90.
assert_eq!(rows[0].days_90, Decimal::TEN);
assert_eq!(rows[0].total(), Ok(Decimal::TEN));
// Two documents of half Decimal's largest each: each is exact, the
// party's aging bucket is not.
let half = Decimal::MAX / Decimal::TWO;
for id in [2, 3] {
l.replay_invoice(
id,
customer,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line(half)],
id,
)
.unwrap();
assert_eq!(l.get_invoice(id).unwrap().total(), Ok(half));
}
assert!(too_large(l.ar_aging(d(2026, 6, 1))));
// Paid more than its total, as journaled: a negative balance, exact.
l.replay_invoice_payment(1, Decimal::from(11), 9).unwrap();
assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE));
// A total of lines that overflow, and its balance.
l.replay_invoice(
4,
customer,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line(Decimal::MAX), line(Decimal::MAX)],
4,
)
.unwrap();
assert!(too_large(l.get_invoice(4).unwrap().total()));
assert!(too_large(l.get_invoice(4).unwrap().balance()));
// A balance past exact arithmetic: a total of Decimal's largest paid
// a negative amount.
l.replay_invoice(
5,
customer,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line(Decimal::MAX)],
5,
)
.unwrap();
l.replay_invoice_payment(5, -Decimal::ONE, 10).unwrap();
assert!(too_large(l.get_invoice(5).unwrap().balance()));
// Bills alike.
let vendor = l.add_vendor("Supplier").unwrap();
l.replay_bill(
1,
vendor,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line(Decimal::MAX), line(Decimal::ONE)],
6,
)
.unwrap();
assert!(too_large(l.get_bill(1).unwrap().total()));
assert!(too_large(l.ap_aging(d(2026, 6, 1))));
let row = ledger_core::AgingRow {
name: "x".into(),
current: Decimal::MAX,
days_30: Decimal::ONE,
days_60: Decimal::ZERO,
days_90: Decimal::ZERO,
days_90_plus: Decimal::ZERO,
};
assert!(too_large(row.total()));
for (i, one) in [
(0, Decimal::ONE),
(1, Decimal::TWO),
(2, Decimal::TEN),
(3, Decimal::ONE_HUNDRED),
(4, Decimal::ONE_THOUSAND),
] {
let mut r = ledger_core::AgingRow {
current: Decimal::ZERO,
..row.clone()
};
r.days_30 = Decimal::ZERO;
match i {
0 => r.current = one,
1 => r.days_30 = one,
2 => r.days_60 = one,
3 => r.days_90 = one,
_ => r.days_90_plus = one,
}
assert_eq!(r.total(), Ok(one), "bucket {i}");
}
}
/// A payroll run restored as journaled: net pay is exact, or refused past
/// exact arithmetic; a run's plan refuses totals it can't hold.
#[test]
fn payroll_figures_are_exact_or_refused() {
let line = |gross: Decimal, fed: Decimal| PayrollLine {
employee_name: "Dana".into(),
employee: None,
gross_pay: gross,
federal_withholding: fed,
state_withholding: Decimal::new(1, 2),
ss_employee: Decimal::new(2, 2),
medicare_employee: Decimal::new(3, 2),
ss_employer: Decimal::ZERO,
medicare_employer: Decimal::ZERO,
other_deductions: Decimal::new(4, 2),
};
assert_eq!(
line(Decimal::from(100), Decimal::new(1, 12)).net_pay(),
Ok("99.899999999999".parse().unwrap())
);
assert!(too_large(line(-Decimal::MAX, Decimal::ONE).net_pay()));
let accounts = ledger_core::payroll::PayrollAccounts {
salary_expense: 1,
payroll_tax_expense: 2,
federal_payable: 3,
state_payable: 4,
ss_payable: 5,
medicare_payable: 6,
other_payable: 7,
};
let big = line(Decimal::MAX, Decimal::ZERO);
assert!(too_large(ledger_core::payroll::payroll_entry_plan(
&[big.clone(), big],
accounts,
8,
Currency::USD
)));
let plan = ledger_core::payroll::payroll_entry_plan(
&[line(Decimal::from(100), Decimal::ONE)],
accounts,
8,
Currency::USD,
)
.unwrap();
let debits: Decimal = plan
.iter()
.filter(|(_, s, _)| *s == Side::Debit)
.map(|(_, _, m)| m.amount)
.sum();
let credits: Decimal = plan
.iter()
.filter(|(_, s, _)| *s == Side::Credit)
.map(|(_, _, m)| m.amount)
.sum();
assert_eq!(debits, Decimal::from(100));
assert_eq!(credits, Decimal::from(100));
}
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)).unwrap();
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)).unwrap();
// 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)).unwrap();
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)).unwrap();
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)).unwrap();
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)).unwrap();
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)).unwrap();
set(
&mut l,
x,
TaxBasisRule::PersonalUseConversion,
Some("2000.00"),
);
let after = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
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)).unwrap();
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
File diff suppressed because it is too large. Load diff
+344
View File
@@ -0,0 +1,344 @@
//! Tax categories and the tax summary. Expected numbers computed by hand.
//!
//! The fixture: a partnership's first quarter.
//!
//! | account | category | % | book | deductible |
//! |-----------------------|-----------------------|---|---------|------------|
//! | 4000 Sales | gross_receipts | | 10,000 | 10,000 |
//! | 4100 Refunds (exp.) | returns_allowances | | 250 | (250) |
//! | 6000 Platform fees | commissions_fees | | 1,500 | 1,500 |
//! | 6100 Meals | meals |50%| 333.33 | 166.67 |
//! | 6200 Penalties | nondeductible | 0%| 40 | 0 |
//! | 6300 Software | other_deductions | | 600 | 600 |
//! | 6400 Partner pay | guaranteed_payments | | 2,000 | 2,000 |
//! | 6500 Misc | (none) | | 75 | uncategorized |
use chrono::NaiveDate;
use ledger_core::tax::{TaxForm, check_category, tax_summary};
use ledger_core::{AccountType, Currency, Ledger, LedgerError, Money, Side};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn books() -> Ledger {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let mut acct =
|code: &str, name: &str, t: AccountType, cat: Option<&str>, pct: Option<&str>| {
let id = l.add_account(code, name, t, None, false).unwrap();
if let Some(c) = cat {
l.set_account_tax(id, Some(c), pct.map(dec)).unwrap();
}
id
};
let sales = acct(
"4000",
"Sales",
AccountType::Revenue,
Some("gross_receipts"),
None,
);
let refunds = acct(
"4100",
"Refunds",
AccountType::Expense,
Some("returns_allowances"),
None,
);
let fees = acct(
"6000",
"Platform fees",
AccountType::Expense,
Some("commissions_fees"),
None,
);
let meals = acct("6100", "Meals", AccountType::Expense, Some("meals"), None);
let pen = acct(
"6200",
"Penalties",
AccountType::Expense,
Some("nondeductible"),
None,
);
let soft = acct(
"6300",
"Software",
AccountType::Expense,
Some("other_deductions"),
None,
);
let gp = acct(
"6400",
"Partner pay",
AccountType::Expense,
Some("guaranteed_payments"),
None,
);
let misc = acct("6500", "Misc", AccountType::Expense, None, None);
let mut post = |dr, cr, amt: &str| {
let e = l.create_entry(d(2, 1), "x");
l.add_line(e, dr, Side::Debit, Money::new(dec(amt), Currency::USD))
.unwrap();
l.add_line(e, cr, Side::Credit, Money::new(dec(amt), Currency::USD))
.unwrap();
l.post_entry(e).unwrap();
};
post(cash, sales, "10000");
for (a, amt) in [
(refunds, "250"),
(fees, "1500"),
(meals, "333.33"),
(pen, "40"),
(soft, "600"),
(gp, "2000"),
(misc, "75"),
] {
post(a, cash, amt);
}
l
}
#[test]
fn the_summary_groups_by_category_and_applies_deductible_percents() {
let l = books();
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
let rows = |lines: &[ledger_core::TaxSummaryLine]| -> Vec<(String, Decimal, Decimal)> {
lines
.iter()
.map(|l| (l.category.clone(), l.book, l.tax))
.collect()
};
// Refunds (an expense account) reduce receipts as negative income.
assert_eq!(
rows(&ts.income),
[
("gross_receipts".into(), dec("10000"), dec("10000")),
("returns_allowances".into(), dec("-250"), dec("-250")),
]
);
// Catalog order; meals at 50% (333.33 → 166.665 → 166.67, half away
// from zero); penalties at 0%.
assert_eq!(
rows(&ts.deductions),
[
("commissions_fees".into(), dec("1500"), dec("1500")),
("guaranteed_payments".into(), dec("2000"), dec("2000")),
("meals".into(), dec("333.33"), dec("166.67")),
("other_deductions".into(), dec("600"), dec("600")),
("nondeductible".into(), dec("40"), dec("0")),
]
);
assert_eq!(ts.uncategorized.len(), 1);
assert_eq!(ts.uncategorized[0].account, "6500 Misc");
assert_eq!(ts.uncategorized[0].book, dec("75"));
// Book: 9,750 − 4,473.33 = 5,276.67. Tax: 9,750 − 4,266.67 = 5,483.33.
assert_eq!(ts.book_net(), dec("5276.67"));
assert_eq!(ts.tax_net(), dec("5483.33"));
}
#[test]
fn an_override_beats_the_category_default() {
let mut l = books();
let meals = l.chart().find_by_code("6100").unwrap().id;
// A company party is 100% deductible: override the 50% default.
l.set_account_tax(meals, Some("meals"), Some(dec("100")))
.unwrap();
let ts = tax_summary(&l, d(1, 1), d(3, 31), None).unwrap();
let m = ts
.deductions
.iter()
.find(|x| x.category == "meals")
.unwrap();
assert_eq!(m.tax, dec("333.33"));
}
#[test]
fn categories_must_fit_the_account_and_the_return() {
let mut l = books();
let sales = l.chart().find_by_code("4000").unwrap().id;
let meals = l.chart().find_by_code("6100").unwrap().id;
let bad = |r: Result<(), LedgerError>| matches!(r, Err(LedgerError::InvalidTaxCategory(_)));
assert!(
bad(l.set_account_tax(sales, Some("meals"), None)),
"deduction on revenue"
);
assert!(
bad(l.set_account_tax(meals, Some("no_such"), None)),
"unknown key"
);
assert!(
bad(l.set_account_tax(meals, Some("meals"), Some(dec("101")))),
"over 100%"
);
assert!(
bad(l.set_account_tax(meals, None, Some(dec("50")))),
"percent without category"
);
// Guaranteed payments exist only on a partnership return.
assert!(
check_category(
"guaranteed_payments",
AccountType::Expense,
Some(TaxForm::Form1065)
)
.is_ok()
);
assert!(
check_category(
"guaranteed_payments",
AccountType::Expense,
Some(TaxForm::ScheduleC)
)
.is_err()
);
assert!(
check_category(
"officer_compensation",
AccountType::Expense,
Some(TaxForm::Form1120S)
)
.is_ok()
);
// Clearing works.
l.set_account_tax(meals, None, None).unwrap();
assert!(l.chart().get(meals).unwrap().tax_category.is_none());
}
#[test]
fn forms_parse_from_common_spellings() {
for (s, f) in [
("schedule_c", TaxForm::ScheduleC),
("Schedule C", TaxForm::ScheduleC),
("1065", TaxForm::Form1065),
("Form 1065", TaxForm::Form1065),
("1120s", TaxForm::Form1120S),
("1120-S", TaxForm::Form1120S),
("1120", TaxForm::Form1120),
] {
assert_eq!(TaxForm::parse(s), Some(f), "{s}");
}
assert_eq!(TaxForm::parse("990"), None);
}
#[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()
);
}
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "ledger-gui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Read-only, live desktop view of wanderbooks books."
[[bin]]
name = "ledger-gui"
path = "src/main.rs"
[dependencies]
ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
ledger-config = { path = "../ledger-config" }
ledger-import = { path = "../ledger-import" }
ledger-vault = { path = "../ledger-vault" }
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
egui = "0.36"
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
egui-phosphor = "0.14"
hayro = { version = "0.8", features = ["embed-fonts"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
notify = "8"
jiff = "0.2"
chrono = "0.4"
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
chrono = "0.4"
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
proptest = "1"
[lints]
workspace = true
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+836
View File
@@ -0,0 +1,836 @@
//! The window: sidebar, top bar, and the current page.
use crate::books::Books;
use crate::data::{Loader, Source, Update};
use crate::nav::{Nav, Route, Section};
use crate::pages;
use crate::receipts::Receipts;
use crate::theme::{self, pal};
use egui::{
Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2,
};
use egui_phosphor::regular as icon;
use std::sync::Arc;
/// Window widths below this get the icon rail instead of the sidebar.
pub const COMPACT_BELOW: f32 = 1000.0;
/// Per-page state that survives navigation.
#[derive(Default)]
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,
}
/// What a page can ask of the shell.
pub struct Shell<'a> {
pub receipts: &'a mut Receipts,
pub state: &'a mut PageState,
pub go: Option<Route>,
/// Seconds since the last live update arrived (for "new" highlights).
pub since_update: f64,
}
impl Shell<'_> {
pub fn go(&mut self, r: Route) {
self.go = Some(r);
}
}
pub struct App {
loader: Option<Loader>,
books: Option<Arc<Books>>,
error: Option<String>,
nav: Nav,
receipts: Receipts,
dark: bool,
state: PageState,
palette: crate::palette::Palette,
updated_at: f64,
live_label: bool,
}
impl App {
/// The live app over a books directory.
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
let dark = cc
.storage
.and_then(|s| s.get_string("dark"))
.map(|v| v == "true")
.unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode);
let mut app = Self::bare(&cc.egui_ctx, dark);
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
app
}
/// An app showing fixed books (tests and screenshots).
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
let mut app = Self::bare(ctx, dark);
app.books = Some(Arc::new(books));
app.nav = Nav::new(start);
app
}
fn bare(ctx: &egui::Context, dark: bool) -> Self {
theme::install_fonts(ctx);
theme::apply(ctx, dark);
egui_extras::install_image_loaders(ctx);
Self {
loader: None,
books: None,
error: None,
nav: Nav::new(Route::Overview),
receipts: Receipts::new(ctx),
dark,
state: PageState::default(),
palette: Default::default(),
updated_at: -1e9,
live_label: false,
}
}
/// Navigate (tests).
pub fn goto(&mut self, r: Route) {
self.nav.go(r);
}
/// Block until every receipt on screen has rendered (screenshots).
pub fn settle(&mut self, ctx: &egui::Context) {
self.receipts.settle(ctx);
}
/// Go where the agent asked (`books.show`).
pub fn show(&mut self, target: ledger_store::viewer::ViewerTarget) {
use crate::nav::ReportKind;
use ledger_store::viewer::ViewerTarget as T;
let Some(b) = self.books.clone() else {
return;
};
let date = |s: &Option<String>| {
s.as_deref()
.and_then(|d| d.parse::<chrono::NaiveDate>().ok())
};
let route = match target {
T::Page { page } => match page.as_str() {
"overview" => Route::Overview,
"cash" => Route::Cash,
"profit" => Route::Profit,
"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,
_ => return,
},
T::Entry { entry_id } => Route::Entry(entry_id),
T::Account { code, start, end } => {
let Some(a) = b.ledger.chart().find_by_code(&code) else {
return;
};
self.state.accounts.range = match (date(&start), date(&end)) {
(None, None) => None,
(s, e) => Some((
s.unwrap_or_else(|| b.kpis.first_entry.unwrap_or(b.today)),
e.unwrap_or(b.today),
)),
};
Route::Account(a.id)
}
T::Report {
report,
start,
end,
columns,
compare,
} => {
use crate::pages::reports::{Columns, Compare};
self.state.reports.columns = match columns.as_deref() {
Some("month") => Columns::Month,
Some("quarter") => Columns::Quarter,
Some("year") => Columns::Year,
_ => Columns::Total,
};
self.state.reports.compare = match compare.as_deref() {
Some("prior_period") => Compare::PriorPeriod,
Some("prior_year") => Compare::PriorYear,
_ => Compare::None,
};
let kind = match report.as_str() {
"profit_and_loss" => ReportKind::ProfitAndLoss,
"balance_sheet" => ReportKind::BalanceSheet,
"cash_flow" => ReportKind::CashFlow,
"members_equity" => ReportKind::MembersEquity,
"trial_balance" => ReportKind::TrialBalance,
"general_ledger" => ReportKind::GeneralLedger,
"tax_summary" => ReportKind::TaxSummary,
"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))
&& (s.is_some() || e.is_some())
{
let (ds, de) = crate::pages::reports::Range::YearToDate.dates(&b);
self.state.reports.range =
crate::pages::reports::Range::Custom(s.unwrap_or(ds), e.unwrap_or(de));
}
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;
st.filter = Default::default();
st.account = None;
Route::Transactions
}
};
self.nav.go(route);
}
/// Where the app is (tests).
pub fn route(&self) -> &Route {
&self.nav.current
}
/// Page state (tests).
/// Label a fixed-books app "Live", as it reads when following a
/// journal (screenshots of what a running app looks like).
pub fn show_live_label(&mut self) {
self.live_label = true;
}
pub fn state_mut(&mut self) -> &mut PageState {
&mut self.state
}
fn handle_keys(&mut self, ui: &Ui) {
let (back, fwd, palette) = ui.input(|i| {
(
(i.modifiers.alt && i.key_pressed(Key::ArrowLeft))
|| i.pointer.button_pressed(egui::PointerButton::Extra1),
(i.modifiers.alt && i.key_pressed(Key::ArrowRight))
|| i.pointer.button_pressed(egui::PointerButton::Extra2),
i.modifiers.command && i.key_pressed(Key::K),
)
});
if back {
self.nav.back();
}
if fwd {
self.nav.forward();
}
if palette {
self.palette.open();
}
}
/// The sidebar: full below [`COMPACT_BELOW`] window widths, an icon
/// rail (names in tooltips) when the window is narrower.
fn sidebar(&mut self, ui: &mut Ui, compact: bool) {
if compact {
return self.rail(ui);
}
let p = pal(ui.ctx());
let books = self.books.clone();
egui::Panel::left("sidebar")
.exact_size(224.0)
.resizable(false)
.frame(
Frame::new()
.fill(p.sidebar)
.inner_margin(Margin::symmetric(12, 16)),
)
.show(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(8), p.accent);
let initial = books
.as_ref()
.and_then(|b| b.company_name().chars().next())
.unwrap_or('W');
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
initial,
theme::bold(15.0),
Color32::WHITE,
);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let name = books
.as_ref()
.map(|b| b.company_name().to_string())
.unwrap_or_else(|| "wanderbooks".into());
ui.add(
egui::Label::new(RichText::new(name).font(theme::semibold(13.5)))
.truncate(),
);
ui.label(
RichText::new("Books")
.font(theme::regular(11.5))
.color(p.muted),
);
});
});
ui.add_space(18.0);
let current = self.nav.current.section();
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
// Short windows: the links scroll above the status box
// instead of the box drawing over them.
let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0);
egui::ScrollArea::vertical()
.id_salt("sidebar_links")
.max_height(links_h)
.show(ui, |ui| {
for s in Section::ALL {
let on = s == current;
let (rect, resp) = ui.allocate_exact_size(
vec2(ui.available_width(), 32.0),
Sense::click(),
);
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.surface);
ui.painter().rect_stroke(
rect,
CornerRadius::same(8),
Stroke::new(1.0, p.border),
egui::StrokeKind::Inside,
);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover);
}
let color = if on { p.text } else { p.muted };
ui.painter().text(
rect.left_center() + vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
s.icon(),
egui::FontId::proportional(16.0),
if on { p.accent } else { p.muted },
);
ui.painter().text(
rect.left_center() + vec2(36.0, 0.0),
egui::Align2::LEFT_CENTER,
s.title(),
if on {
theme::semibold(13.5)
} else {
theme::regular(13.5)
},
color,
);
if s == Section::Overview && attention > 0 {
let badge = egui::Rect::from_center_size(
rect.right_center() - vec2(18.0, 0.0),
vec2(22.0, 18.0),
);
ui.painter().rect_filled(
badge,
CornerRadius::same(9),
p.warning_soft,
);
ui.painter().text(
badge.center(),
egui::Align2::CENTER_CENTER,
attention.to_string(),
theme::semibold(11.0),
p.warning,
);
}
if resp.clicked() {
self.nav.go(s.home());
}
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let label = if self.dark {
format!("{} Light mode", icon::SUN)
} else {
format!("{} Dark mode", icon::MOON)
};
if ui
.add(
egui::Button::new(
RichText::new(label)
.font(theme::regular(12.5))
.color(p.muted),
)
.frame(false),
)
.clicked()
{
self.dark = !self.dark;
theme::apply(ui.ctx(), self.dark);
}
ui.add_space(6.0);
if let Some(b) = &books {
live_status(
ui,
b,
ui.input(|i| i.time) - self.updated_at,
self.loader.is_some() || self.live_label,
);
}
});
});
}
/// The sidebar as a narrow rail of icons, for narrow windows.
fn rail(&mut self, ui: &mut Ui) {
let p = pal(ui.ctx());
let books = self.books.clone();
egui::Panel::left("rail")
.exact_size(RAIL_W)
.resizable(false)
.frame(
Frame::new()
.fill(p.sidebar)
.inner_margin(Margin::symmetric(10, 16)),
)
.show(ui, |ui| {
let name = books
.as_ref()
.map(|b| b.company_name().to_string())
.unwrap_or_else(|| "wanderbooks".into());
let (r, _) =
ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
let logo = egui::Rect::from_center_size(r.center(), vec2(28.0, 28.0));
ui.painter()
.rect_filled(logo, CornerRadius::same(8), p.accent);
ui.painter().text(
logo.center(),
egui::Align2::CENTER_CENTER,
name.chars().next().unwrap_or('W'),
theme::bold(15.0),
Color32::WHITE,
);
ui.interact(r, ui.id().with("logo"), Sense::hover())
.on_hover_text(format!("{name} · Books"));
ui.add_space(18.0);
let current = self.nav.current.section();
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0);
egui::ScrollArea::vertical()
.id_salt("rail_links")
.max_height(links_h)
.show(ui, |ui| {
for s in Section::ALL {
let on = s == current;
let (rect, resp) = ui.allocate_exact_size(
vec2(ui.available_width(), 36.0),
Sense::click(),
);
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.surface);
ui.painter().rect_stroke(
rect,
CornerRadius::same(8),
Stroke::new(1.0, p.border),
egui::StrokeKind::Inside,
);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover);
}
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
s.icon(),
egui::FontId::proportional(18.0),
if on { p.accent } else { p.muted },
);
let tip = if s == Section::Overview && attention > 0 {
ui.painter().circle_filled(
rect.center() + vec2(9.0, -8.0),
4.0,
p.warning,
);
format!("{} · {attention} need attention", s.title())
} else {
s.title().to_string()
};
if resp.on_hover_text(tip).clicked() {
self.nav.go(s.home());
}
}
});
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
let (glyph, tip) = if self.dark {
(icon::SUN, "Light mode")
} else {
(icon::MOON, "Dark mode")
};
if ui
.add(
egui::Button::new(RichText::new(glyph).size(16.0).color(p.muted))
.frame(false)
.min_size(vec2(32.0, 32.0)),
)
.on_hover_text(tip)
.clicked()
{
self.dark = !self.dark;
theme::apply(ui.ctx(), self.dark);
}
ui.add_space(6.0);
if let Some(b) = &books {
let live = self.loader.is_some() || self.live_label;
let checks = checks(b);
let ok = checks.iter().all(|c| c.0);
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::hover());
if ok {
let pulse = (ui.input(|i| i.time) - self.updated_at < 2.0) as u8 as f32;
ui.painter().circle_filled(
r.center(),
4.0 + pulse,
if live { p.positive } else { p.faint },
);
} else {
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
icon::WARNING_CIRCLE,
egui::FontId::proportional(18.0),
p.negative,
);
}
let mut tip = format!(
"{} · event {}",
if live { "Live" } else { "Snapshot" },
b.seq()
);
for (ok, text, _) in &checks {
tip.push_str(&format!(
"\n{} {text}",
if *ok {
icon::SEAL_CHECK
} else {
icon::WARNING_CIRCLE
}
));
}
resp.on_hover_text(tip);
}
});
});
}
/// Back, forward, where you are, today, and search. Squeezed, the
/// search shrinks to its icon, the date goes, and the crumb truncates:
/// nothing ever overlaps.
fn topbar(&mut self, ui: &mut Ui) {
let p = pal(ui.ctx());
egui::Panel::top("topbar")
.exact_size(52.0)
.frame(
Frame::new()
.fill(p.bg)
.inner_margin(Margin::symmetric(16, 10))
.stroke(Stroke::new(1.0, p.border)),
)
.show(ui, |ui| {
let width = ui.available_width();
ui.horizontal_centered(|ui| {
let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| {
ui.add_enabled(
enabled,
egui::Button::new(RichText::new(glyph).size(16.0))
.frame(false)
.min_size(vec2(28.0, 28.0)),
)
.on_hover_text(tip)
.clicked()
};
if nav_btn(ui, icon::ARROW_LEFT, self.nav.can_back(), "Back (Alt+←)") {
self.nav.back();
}
if nav_btn(
ui,
icon::ARROW_RIGHT,
self.nav.can_forward(),
"Forward (Alt+→)",
) {
self.nav.forward();
}
ui.add_space(6.0);
// Right side first, so the crumb gets only what's left.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (text, min_w) = if width >= 640.0 {
(
format!(
"{} Search everything… Ctrl K",
icon::MAGNIFYING_GLASS
),
260.0,
)
} else if width >= 420.0 {
(format!("{} Search", icon::MAGNIFYING_GLASS), 0.0)
} else {
(icon::MAGNIFYING_GLASS.to_string(), 30.0)
};
let search = egui::Button::new(
RichText::new(text)
.font(theme::regular(13.0))
.color(p.faint),
)
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(8))
.min_size(vec2(min_w, 30.0));
if ui
.add(search)
.on_hover_text("Search everything (Ctrl K)")
.clicked()
{
self.palette.open();
}
if let Some(b) = &self.books
&& width >= 860.0
{
ui.label(
RichText::new(format!("Today {}", crate::fmt::date(b.today)))
.font(theme::regular(12.5))
.color(p.muted),
);
}
ui.add_space(8.0);
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
let crumbs =
crate::palette::crumbs(self.books.as_deref(), &self.nav.current);
// Narrow, the last crumb alone says where you are.
let skip = if ui.available_width() >= 260.0 {
0
} else {
crumbs.len() - 1
};
let last = crumbs.len() - 1;
let mut go = None;
for (i, (text, to)) in crumbs.iter().enumerate().skip(skip) {
if i > skip && ui.available_width() >= 260.0 - 80.0 {
ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint));
}
let rich = if i == last && i > 0 {
RichText::new(text).font(theme::semibold(13.5))
} else {
RichText::new(text)
.font(theme::regular(13.5))
.color(p.muted)
};
let label = egui::Label::new(rich).truncate();
match to {
Some(to) => {
let r = ui.add(label.sense(egui::Sense::click()));
if r.hovered() {
ui.ctx()
.set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.on_hover_text(format!("Go to {text}")).clicked() {
go = Some(to.clone());
}
}
None => {
ui.add(label);
}
}
}
if let Some(to) = go {
self.nav.go(to);
}
});
});
});
});
}
}
/// Width of the icon rail.
const RAIL_W: f32 = 60.0;
/// Height kept clear under the sidebar's links for the status box and the
/// theme toggle; the rail's equivalent.
const SIDEBAR_FOOTER: f32 = 172.0;
const RAIL_FOOTER: f32 = 84.0;
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
let p = pal(ui.ctx());
Frame::new()
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(8.0, 8.0), Sense::hover());
let pulse = (since_update < 2.0) as u8 as f32;
let color = if live { p.positive } else { p.faint };
ui.painter().circle_filled(r.center(), 4.0 + pulse, color);
ui.label(
RichText::new(if live { "Live" } else { "Snapshot" })
.font(theme::semibold(12.0)),
);
ui.label(
RichText::new(format!("· event {}", b.seq()))
.font(theme::regular(12.0))
.color(p.muted),
);
});
for (ok, text, tip) in checks(b) {
let (glyph, color) = if ok {
(icon::SEAL_CHECK, p.muted)
} else {
(icon::WARNING_CIRCLE, p.negative)
};
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(),
);
});
}
}
});
}
/// The integrity checks the sidebar shows: (ok, label, explanation).
fn checks(b: &Books) -> Vec<(bool, String, String)> {
use ledger_store::DiskCheck;
let mut out = vec![(
true,
"Journal chain verified".to_string(),
format!(
"Every journal event's hash chains to the one before it.\nHead: {}\nData: {}",
b.head,
b.data_dir.display()
),
)];
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()),
});
let bad = b.integrity.file_failures.len();
out.push((
bad == 0,
if bad == 0 {
format!("{} files verified", b.integrity.files_ok)
} else {
format!("{bad} files damaged")
},
"Every stored receipt and document re-hashed against its recorded SHA-256.".into(),
));
out
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default();
for u in updates {
match u {
Update::Books(b) => {
if self.books.is_some() {
self.updated_at = ui.input(|i| i.time);
}
self.books = Some(b);
self.error = None;
}
Update::Error(e) => self.error = Some(e),
Update::Show(target) => self.show(target),
}
}
self.receipts.pump(ui.ctx());
self.handle_keys(ui);
let compact = ui.available_width() < COMPACT_BELOW;
self.sidebar(ui, compact);
self.topbar(ui);
let p = pal(ui.ctx());
let since_update = ui.input(|i| i.time) - self.updated_at;
if since_update < 6.0 {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
}
egui::CentralPanel::no_frame()
.frame(Frame::new().fill(p.bg))
.show(ui, |ui| {
if let Some(err) = &self.error {
Frame::new()
.fill(p.negative_soft)
.inner_margin(Margin::symmetric(20, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(
RichText::new(format!("{} {err}", icon::WARNING)).color(p.negative),
);
});
}
let Some(books) = self.books.clone() else {
ui.centered_and_justified(|ui| {
ui.label(RichText::new("Opening the books…").color(p.muted));
});
return;
};
let mut shell = Shell {
receipts: &mut self.receipts,
state: &mut self.state,
go: None,
since_update,
};
let route = self.nav.current.clone();
pages::show(ui, &books, &route, &mut shell);
if let Some(to) = shell.go.take() {
self.nav.go(to);
}
if let Some(to) = self.palette.show(ui.ctx(), &books) {
self.nav.go(to);
}
});
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string("dark", self.dark.to_string());
}
}
+739
View File
@@ -0,0 +1,739 @@
//! 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, 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};
use crate::fmt;
use crate::nav::Route;
use chrono::Datelike;
use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
/// A figure the books can't compute (rules.md B-28: books an earlier
/// version wrote may hold dates past chrono's calendar or amounts past
/// exact arithmetic): a Problem saying so and why, never a guess.
fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Route>) -> Attention {
Attention {
severity: Severity::Problem,
title: format!("{what} can't be computed"),
detail: format!("{why}."),
target,
prompt: format!(
"In wanderbooks, {} can't be computed ({why}). Find the record behind it and tell me how to correct it.",
what.to_lowercase()
),
}
}
/// 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, except
/// that a figure behind an item that can't be computed (an
/// amortization plan past chrono's last day, an aging past exact
/// arithmetic: books an earlier version wrote, rules.md B-28) is one
/// Problem item saying so, never a panic or a guess.
/// 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;
for e in b.entries.iter().filter(|e| e.status == EntryStatus::Draft) {
out.push(Attention {
severity: Severity::Todo,
title: format!("Draft not posted: {}", e.description),
detail: format!(
"#{} · {} · {}. Drafts appear in no report until posted.",
e.id,
fmt::date(e.date),
fmt::dollars_or(e.amount)
),
target: Some(Route::Entry(e.id)),
prompt: format!(
"In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.",
e.id,
e.description,
fmt::dollars_or(e.amount)
),
});
}
let missing: Vec<_> = b
.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted && e.missing_receipt)
.collect();
if missing.len() > 3 {
let total = missing
.iter()
.try_fold(Decimal::ZERO, |t, e| t.checked_add(e.amount?));
let list: Vec<String> = missing
.iter()
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
.collect();
out.push(Attention {
severity: Severity::Problem,
title: format!("{} without receipts", fmt::count(missing.len(), "entry", "entries")),
detail: format!(
"{} in entries with no receipt attached ({}).",
fmt::dollars_or(total),
b.receipt_rule()
),
target: Some(Route::Transactions),
prompt: format!(
"In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.",
list.join("; ")
),
});
}
for e in missing.iter().filter(|_| missing.len() <= 3) {
out.push(Attention {
severity: Severity::Problem,
title: format!("No receipt: {}", e.description),
detail: format!(
"#{} · {} · {}: {}.",
e.id,
fmt::date(e.date),
fmt::dollars_or(e.amount),
b.receipt_rule()
),
target: Some(Route::Entry(e.id)),
prompt: format!(
"In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.",
e.id,
e.description,
fmt::dollars_or(e.amount),
e.date
),
});
}
for r in b.ledger.recurring_entries() {
let due = r.due(today);
if !due.is_empty() {
out.push(Attention {
severity: Severity::Todo,
title: format!("Recurring \"{}\" is due", r.description),
detail: format!(
"{} since {}.",
fmt::count(due.len(), "occurrence", "occurrences"),
fmt::date(due[0])
),
target: Some(Route::Company),
prompt: format!(
"In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them."
),
});
}
}
for s in b.ledger.schedules() {
// A schedule an earlier version journaled may not compute (its
// months past chrono's last day): say so, never guess.
let due = match s.due(today) {
Ok(due) => due,
Err(e) => {
out.push(cant_compute(
format!("Amortization schedule \"{}\"", s.description),
&e,
Some(Route::Company),
));
continue;
}
};
if !due.is_empty() {
let Some(total) = due
.iter()
.try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(*a))
else {
out.push(cant_compute(
format!("Amortization schedule \"{}\"", s.description),
&ledger_core::LedgerError::TooLargeToCompute("the months due".into()),
Some(Route::Company),
));
continue;
};
out.push(Attention {
severity: Severity::Todo,
title: format!("Amortization due: {}", s.description),
detail: format!(
"{} totaling {} not yet posted.",
fmt::count(due.len(), "month", "months"),
fmt::dollars(total)
),
target: Some(Route::Company),
prompt: format!(
"In wanderbooks, preview books.run_schedules through {today}, then post it if it's right."
),
});
}
}
for p in b.ledger.list_periods() {
if p.status == PeriodStatus::Open && p.end < today {
out.push(Attention {
severity: Severity::Todo,
title: format!("Period {} ended but is open", p.name),
detail: format!(
"{} – {}. Closing locks it and moves its earnings to equity.",
fmt::date(p.start),
fmt::date(p.end)
),
target: Some(Route::Company),
prompt: format!(
"In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.",
p.name
),
});
}
}
let (fy_start, _) = b.fiscal_ytd();
if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) {
let uncategorized: Vec<String> = pl
.revenue
.iter()
.chain(&pl.expenses)
.filter(|l| {
b.ledger
.chart()
.find_by_code(&l.code)
.is_some_and(|a| a.tax_category.is_none())
})
.map(|l| format!("{} {}", l.code, l.name))
.collect();
if !uncategorized.is_empty() {
out.push(Attention {
severity: Severity::Info,
title: format!(
"{} without a tax category",
fmt::count(uncategorized.len(), "account", "accounts")
),
detail: format!(
"{}. The tax summary lists them as uncategorized.",
uncategorized.join(", ")
),
target: Some(Route::Accounts),
prompt: format!(
"In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.",
uncategorized.join(", ")
),
});
}
}
// Rules set by law that wanderbooks may no longer know (rules.md;
// Stephen, 2026-10-13): the agent finds the figure from the authority
// or asks, confirms it, and passes it explicitly (the handbook's
// "Rules that change with the law"); the human reports it upstream.
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!(
"In wanderbooks, the {} is known only through {}. Find the current figure from the official authority, {}, or ask me or our CPA, and confirm it with me; then pass it explicitly wherever a tool asks for it. Tell me what you found and its official source, so I can report it upstream and the rule is updated for everyone.",
rule.name, rule.known_through, rule.authority
),
});
}
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 {
// 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),
detail: format!(
"Paid {} in {year}, over the {} 1099 threshold.",
fmt::dollars(v.paid),
fmt::dollars(r.threshold)
),
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
),
});
}
}
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),
});
}
// Overdue: (parties, total), or why it can't be computed (documents
// are restored as journaled, of any size).
let overdue = |rows: Result<Vec<ledger_core::AgingRow>, ledger_core::LedgerError>| {
let too_large = || ledger_core::LedgerError::TooLargeToCompute("the overdue total".into());
let late: Vec<Decimal> = rows?
.iter()
.map(|r| {
[r.days_60, r.days_90, r.days_90_plus]
.into_iter()
.try_fold(r.days_30, Decimal::checked_add)
.ok_or_else(too_large)
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter(|d| !d.is_zero())
.collect();
let total = late
.iter()
.try_fold(Decimal::ZERO, |t, d| t.checked_add(*d))
.ok_or_else(too_large)?;
Ok::<_, ledger_core::LedgerError>((late.len(), total))
};
let (n, total) = match overdue(b.ledger.ap_aging(today)) {
Ok(v) => v,
Err(e) => {
out.push(cant_compute(
"The payables aging".into(),
&e,
Some(Route::Reports(crate::nav::ReportKind::ApAging)),
));
(0, Decimal::ZERO)
}
};
if n > 0 {
out.push(Attention {
severity: Severity::Problem,
title: format!("{} overdue to pay", fmt::dollars(total)),
detail: format!(
"{} with bills past due.",
fmt::count(n, "vendor", "vendors")
),
target: Some(Route::Reports(crate::nav::ReportKind::ApAging)),
prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(),
});
}
let (n, total) = match overdue(b.ledger.ar_aging(today)) {
Ok(v) => v,
Err(e) => {
out.push(cant_compute(
"The receivables aging".into(),
&e,
Some(Route::Reports(crate::nav::ReportKind::ArAging)),
));
(0, Decimal::ZERO)
}
};
if n > 0 {
out.push(Attention {
severity: Severity::Problem,
title: format!("{} overdue to collect", fmt::dollars(total)),
detail: format!(
"{} with invoices past due.",
fmt::count(n, "customer", "customers")
),
target: Some(Route::Reports(crate::nav::ReportKind::ArAging)),
prompt:
"In wanderbooks, show me the A/R aging and draft reminders for past-due invoices."
.into(),
});
}
for a in b.ledger.chart().all().filter(|a| {
a.account_type == AccountType::Liability && {
let n = a.name.to_lowercase();
n.contains("due to") || n.contains("owed to")
}
}) {
let bal = b.balance_on(a.id, today);
if bal > Decimal::ZERO {
out.push(Attention {
severity: Severity::Info,
title: format!("{} owed on {}", fmt::dollars(bal), a.name),
detail: "Expenses someone paid personally, not yet reimbursed.".into(),
target: Some(Route::Account(a.id)),
prompt: format!(
"In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.",
fmt::dollars(bal),
a.code,
a.name
),
});
}
}
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("; ")
),
});
}
}
// The register computes unless a contributor basis an earlier version
// journaled is past exact arithmetic: then one item says so.
let (due, register) = match (b.depreciation_due(today), b.asset_register(today)) {
(Ok(due), Ok(register)) => (Some(due), Some(register)),
(Err(e), _) | (_, Err(e)) => {
out.push(cant_compute(
"The fixed asset register".into(),
&e,
Some(Route::Reports(crate::nav::ReportKind::FixedAssetRegister)),
));
(None, None)
}
};
if let Some(due) = due.filter(|d| d.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 unregistered = register.map_or(Decimal::ZERO, |r| r.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 {
DiskCheck::Differs(why) => out.push(Attention {
severity: Severity::Problem,
title: "The database doesn't match the journal".into(),
detail: format!("{why}. The journal is the record; the server rebuilds the database from it (books.verify, or on its next start)."),
target: Some(Route::Journal),
prompt: verify_prompt.clone(),
}),
DiskCheck::Unreadable(why) => out.push(Attention {
severity: Severity::Problem,
title: "Couldn't check the database".into(),
detail: format!("{why}."),
target: Some(Route::Journal),
prompt: verify_prompt.clone(),
}),
_ => {}
}
if !b.integrity.file_failures.is_empty() {
let ids: Vec<String> = b
.integrity
.file_failures
.iter()
.map(|(id, _)| id.to_string())
.collect();
out.push(Attention {
severity: Severity::Problem,
title: format!("{} damaged or missing", fmt::count(ids.len(), "stored file is", "stored files are")),
detail: format!(
"Attachment {}: the bytes on disk don't match their recorded hash. Restore them from a backup (the books dir's git history).",
ids.join(", ")
),
target: Some(Route::Documents),
prompt: verify_prompt,
});
}
out.sort_by_key(|a| a.severity);
out
}
#[cfg(test)]
mod tests {
use super::*;
/// Empty books opened as of `today`.
fn books(tag: &str, today: chrono::NaiveDate) -> Books {
let dir =
std::env::temp_dir().join(format!("ledger-gui-attention-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// Loaded books need no files: removed once loaded, pass or panic.
let _cleanup = crate::test_support::TempDir(dir.clone());
let data_dir = dir.join("books-data");
ledger_store::Store::create(&data_dir, ledger_core::Currency::USD).unwrap();
crate::data::load_once(&crate::data::Source {
data_dir,
books_toml: None,
today: Some(today),
})
.unwrap()
}
/// INVARIANTS 2 and 4: a dated rule past its review is one Todo item
/// whose prompt asks the agent to find the figure from the official
/// authority (named, never the repo's copy), confirm it with the human
/// and pass it explicitly, and to tell the human so it can be reported
/// upstream; before the review there is no such item. No prompt points
/// the agent at files, git, GitHub or pull requests.
#[test]
fn a_stale_rule_asks_for_the_authority_and_the_humans_confirmation() {
let rule = ledger_core::rules::THRESHOLD_1099;
let review = rule.review_after();
assert!(
find(&books("fresh", review.pred_opt().unwrap()))
.iter()
.all(|a| !a.title.contains(rule.name)),
"no item before the review date"
);
let items = find(&books("stale", review));
let stale: Vec<_> = items
.iter()
.filter(|a| a.title == format!("Check the {}", rule.name))
.collect();
assert_eq!(stale.len(), 1, "{items:?}");
let a = stale[0];
assert_eq!(a.severity, Severity::Todo);
assert!(
a.detail.contains(&rule.known_through.to_string()),
"{}",
a.detail
);
for want in [
"In wanderbooks,",
rule.name,
&rule.known_through.to_string(),
rule.authority,
"confirm it with me",
"pass it explicitly",
"report it upstream",
] {
assert!(a.prompt.contains(want), "{want:?} missing: {}", a.prompt);
}
for item in &items {
let lower = item.prompt.to_lowercase();
for never in ["pull request", ".md", "docs/", rule.source] {
assert!(!lower.contains(never), "{never:?} in {}", item.prompt);
}
for word in lower.split(|c: char| !c.is_alphanumeric()) {
assert!(
!["git", "github", "repo", "repository", "issue"].contains(&word),
"{word:?} in {}",
item.prompt
);
}
}
}
}
File diff suppressed because it is too large. Load diff
+494
View File
@@ -0,0 +1,494 @@
//! Charts, painted directly: a balance line (step-shaped, because a
//! balance holds until the next transaction), grouped bars, ranked
//! horizontal bars, and sparklines. Every point answers hover with its
//! exact value; bars report clicks so they can drill down.
use crate::fmt;
use crate::theme::{self, pal};
use chrono::NaiveDate;
use egui::{
Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2,
};
use rust_decimal::Decimal;
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].
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();
}
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 {
fmt::compact(Decimal::from_f64_retain(v).unwrap_or_default())
}
/// A balance over time as a step line with a soft fill. `points` are
/// (date, balance at end of day), ascending; the line runs to `until`.
pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDate, height: f32) {
let p = pal(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
let painter = ui.painter_at(rect);
if points.is_empty() {
painter.text(
rect.center(),
Align2::CENTER_CENTER,
"No cash activity yet",
theme::regular(12.5),
p.faint,
);
return;
}
let first = points[0].0;
let last = until.max(points[points.len() - 1].0);
let days = ((last - first).num_days().max(1)) as f64;
let vals: Vec<f64> = points.iter().map(|(_, v)| f(*v)).collect();
let lo = vals.iter().cloned().fold(0.0f64, f64::min);
let hi = vals.iter().cloned().fold(f64::MIN, f64::max).max(lo + 1.0);
let ts = ticks(lo, hi, 4);
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(8.0, 22.0));
let x =
|d: NaiveDate| plot.left() + ((d - first).num_days() as f64 / days) as f32 * plot.width();
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
for t in &ts {
let yy = y(*t);
painter.line_segment(
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
Stroke::new(1.0, p.border),
);
painter.text(
pos2(plot.left() - 8.0, yy),
Align2::RIGHT_CENTER,
money_label(*t),
theme::regular(11.0),
p.faint,
);
}
// X labels: first, last, and a few between.
let n_labels = ((plot.width() / 90.0) as usize).clamp(1, 4);
for i in 0..=n_labels {
// At most `last` (a one-day chart is one day wide, and its first day
// may be chrono's last).
let d = first
.checked_add_days(chrono::Days::new(
((days * i as f64) / n_labels as f64).round() as u64,
))
.map_or(last, |d| d.min(last));
let align = match i {
0 => Align2::LEFT_TOP,
i if i == n_labels => Align2::RIGHT_TOP,
_ => Align2::CENTER_TOP,
};
painter.text(
pos2(x(d), plot.bottom() + 6.0),
align,
fmt::date_short(d),
theme::regular(11.0),
p.faint,
);
}
// Step path.
let mut line: Vec<Pos2> = Vec::new();
for (i, (d, v)) in points.iter().enumerate() {
let px = x(*d);
if i > 0 {
line.push(pos2(px, line.last().unwrap().y));
}
line.push(pos2(px, y(f(*v))));
}
line.push(pos2(x(last), line.last().unwrap().y));
let base = y(0.0f64.clamp(lo, hi));
let mut fill = line.clone();
fill.push(pos2(x(last), base));
fill.push(pos2(x(first), base));
let soft = Color32::from_rgba_unmultiplied(p.accent.r(), p.accent.g(), p.accent.b(), 28);
// Fill: one mesh of strips under each flat step (the outline may be
// non-convex). A mesh has no anti-aliased edges, so strips meet without
// seams.
let mut mesh = egui::Mesh::default();
for w in line.windows(2) {
if (w[0].y - w[1].y).abs() < 0.01 && w[1].x > w[0].x {
mesh.add_colored_rect(
Rect::from_min_max(
pos2(w[0].x, w[0].y.min(base)),
pos2(w[1].x, w[0].y.max(base)),
),
soft,
);
}
}
painter.add(Shape::mesh(mesh));
let _ = fill;
painter.add(Shape::line(line.clone(), Stroke::new(2.0, p.accent)));
// Hover: the balance on the hovered day.
if let Some(hp) = resp.hover_pos()
&& plot.x_range().contains(hp.x)
{
let frac = ((hp.x - plot.left()) / plot.width()) as f64;
let day = first
.checked_add_days(chrono::Days::new((frac * days).round() as u64))
.map_or(last, |d| d.min(last));
let bal = points
.iter()
.take_while(|(d, _)| *d <= day)
.last()
.map(|(_, v)| *v);
if let Some(b) = bal {
let hx = x(day);
painter.line_segment(
[pos2(hx, plot.top()), pos2(hx, plot.bottom())],
Stroke::new(1.0, p.faint),
);
painter.circle_filled(pos2(hx, y(f(b))), 4.0, p.accent);
tooltip(
&painter,
pos2(hx, plot.top()),
&fmt::date(day),
&fmt::dollars(b),
p.surface,
p.border,
p.text,
p.muted,
rect,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn tooltip(
painter: &Painter,
at: Pos2,
title: &str,
value: &str,
bg: Color32,
border: Color32,
fg: Color32,
muted: Color32,
bounds: Rect,
) {
let t1 = painter.layout_no_wrap(title.to_string(), theme::regular(11.5), muted);
let t2 = painter.layout_no_wrap(value.to_string(), theme::semibold(13.0), fg);
let w = t1.size().x.max(t2.size().x) + 16.0;
let h = t1.size().y + t2.size().y + 12.0;
let mut r = Rect::from_min_size(pos2(at.x + 8.0, at.y), vec2(w, h));
if r.right() > bounds.right() {
r = r.translate(vec2(-w - 16.0, 0.0));
}
painter.rect(
r,
CornerRadius::same(6),
bg,
Stroke::new(1.0, border),
egui::StrokeKind::Inside,
);
painter.galley(r.min + vec2(8.0, 6.0), t1.clone(), muted);
painter.galley(r.min + vec2(8.0, 6.0 + t1.size().y), t2, fg);
}
/// One group of bars on a [`bar_chart`].
pub struct BarGroup {
pub label: String,
pub values: Vec<Decimal>,
}
/// Grouped bars (e.g. income and spending per month), with a legend.
/// Returns the clicked group, if any.
pub fn bar_chart(
ui: &mut Ui,
groups: &[BarGroup],
series: &[(&str, Color32)],
height: f32,
) -> Option<usize> {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
for (name, color) in series {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(3), *color);
ui.label(
egui::RichText::new(*name)
.font(theme::regular(12.0))
.color(p.muted),
);
ui.add_space(8.0);
}
});
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
let painter = ui.painter_at(rect);
if groups.is_empty() {
return None;
}
let all: Vec<f64> = groups
.iter()
.flat_map(|g| g.values.iter().map(|v| f(*v)))
.collect();
let lo = all.iter().cloned().fold(0.0f64, f64::min);
let hi = all.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0);
let ts = ticks(lo, hi, 4);
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(4.0, 22.0));
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
for t in &ts {
let yy = y(*t);
painter.line_segment(
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
Stroke::new(1.0, p.border),
);
painter.text(
pos2(plot.left() - 8.0, yy),
Align2::RIGHT_CENTER,
money_label(*t),
theme::regular(11.0),
p.faint,
);
}
let gw = plot.width() / groups.len() as f32;
let n = series.len().max(1) as f32;
let bw = ((gw * 0.7) / n).min(28.0);
let mut clicked = None;
let hover = resp.hover_pos();
for (gi, g) in groups.iter().enumerate() {
let gx = plot.left() + gw * gi as f32;
let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom()));
let hovered = hover.is_some_and(|h| grect.contains(h));
if hovered {
painter.rect_filled(grect, CornerRadius::same(6), p.hover);
}
let total_w = bw * n + 3.0 * (n - 1.0);
let start = gx + (gw - total_w) / 2.0;
for (si, v) in g.values.iter().enumerate() {
let color = series.get(si).map(|s| s.1).unwrap_or(p.accent);
let x0 = start + si as f32 * (bw + 3.0);
let (y0, y1) = (y(0.0), y(f(*v)));
let r = Rect::from_min_max(
pos2(x0, y0.min(y1)),
pos2(x0 + bw, y0.max(y1).max(y0.min(y1) + 1.0)),
);
painter.rect_filled(
r,
CornerRadius {
nw: 3,
ne: 3,
sw: 0,
se: 0,
},
color,
);
}
// Crowded, label every other group (or fewer), counting back from
// the latest so it's always labeled.
let every = (34.0 / gw).ceil().max(1.0) as usize;
if (groups.len() - 1 - gi).is_multiple_of(every) {
painter.text(
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
Align2::CENTER_TOP,
&g.label,
theme::regular(11.0),
p.faint,
);
}
if hovered {
let lines: Vec<String> = series
.iter()
.zip(&g.values)
.map(|((name, _), v)| format!("{name}: {}", fmt::dollars(*v)))
.collect();
tooltip(
&painter,
pos2(gx + gw / 2.0, plot.top()),
&g.label,
&lines.join("\n"),
p.surface,
p.border,
p.text,
p.muted,
rect,
);
if resp.clicked() {
clicked = Some(gi);
}
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
}
clicked
}
/// Ranked horizontal bars with labels and exact amounts. Returns the
/// clicked row.
pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<usize> {
let p = pal(ui.ctx());
let max = rows
.iter()
.map(|(_, v)| f(*v).abs())
.fold(0.0, f64::max)
.max(1e-9);
let mut clicked = None;
for (i, (label, v)) in rows.iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let painter = ui.painter_at(rect);
if resp.hovered() {
painter.rect_filled(rect, CornerRadius::same(6), p.hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let amount = fmt::dollars(*v);
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text);
let label_w = (rect.width() * 0.38).min(220.0);
let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w);
painter.galley(
pos2(rect.left() + 6.0, rect.center().y - lg.size().y / 2.0),
lg,
p.text,
);
let bar_left = rect.left() + label_w + 14.0;
let bar_max = rect.right() - ag.size().x - 18.0 - bar_left;
let w = (f(*v).abs() / max) as f32 * bar_max.max(0.0);
let bar = Rect::from_min_size(
pos2(bar_left, rect.center().y - 6.0),
vec2(w.max(2.0), 12.0),
);
painter.rect_filled(bar, CornerRadius::same(3), color);
painter.galley(
pos2(
rect.right() - ag.size().x - 6.0,
rect.center().y - ag.size().y / 2.0,
),
ag,
p.text,
);
if resp.clicked() {
clicked = Some(i);
}
}
clicked
}
/// A tiny trend line.
pub fn sparkline(painter: &Painter, rect: Rect, values: &[f64], color: Color32) {
let lo = values.iter().cloned().fold(f64::MAX, f64::min);
let hi = values.iter().cloned().fold(f64::MIN, f64::max);
let span = (hi - lo).max(1e-9);
let n = values.len().max(2) - 1;
let pts: Vec<Pos2> = values
.iter()
.enumerate()
.map(|(i, v)| {
pos2(
rect.left() + rect.width() * i as f32 / n as f32,
rect.bottom() - ((v - lo) / span) as f32 * rect.height(),
)
})
.collect();
painter.add(Shape::line(pts, Stroke::new(1.5, color)));
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::Harness;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
/// Every text a frame painted, with where it was painted.
fn painted(out: &egui::FullOutput) -> Vec<(String, egui::Pos2, f32)> {
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2, f32)>) {
match s {
egui::Shape::Text(t) => {
out.push((t.galley.text().to_string(), t.pos, t.galley.size().x))
}
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut texts = Vec::new();
for c in &out.shapes {
walk(&c.shape, &mut texts);
}
texts
}
/// charts::balance_line: the x axis is labeled with its first day, its
/// last, and days evenly between (rounded); hovering shows the balance on
/// the hovered day.
#[test]
fn the_balance_chart_labels_its_days_and_shows_the_hovered_one() {
let points = vec![
(d(2026, 1, 1), Decimal::new(10_000, 2)),
(d(2026, 1, 11), Decimal::new(5_000, 2)),
];
let mut h = Harness::builder()
.with_size(egui::vec2(800.0, 300.0))
.build_ui(move |ui| balance_line(ui, &points, d(2026, 1, 11), 200.0));
crate::theme::install_fonts(&h.ctx);
crate::theme::apply(&h.ctx, false);
h.run();
let texts = painted(h.output());
let labels: Vec<&str> = texts.iter().map(|(t, _, _)| t.as_str()).collect();
// Ten days, four gaps (the plot is wide): 0, 2.5, 5, 7.5, 10 rounded.
for want in ["Jan 1", "Jan 4", "Jan 6", "Jan 9", "Jan 11"] {
assert!(labels.contains(&want), "{want} not in {labels:?}");
}
// The plot runs from the first label's left edge to the last's right.
let (_, first, _) = texts.iter().find(|(t, _, _)| t == "Jan 1").unwrap();
let (_, last, w) = texts.iter().find(|(t, _, _)| t == "Jan 11").unwrap();
let (left, right) = (first.x, last.x + w);
for (frac, day) in [(0.3, d(2026, 1, 4)), (0.5, d(2026, 1, 6))] {
h.hover_at(egui::pos2(left + (right - left) * frac, first.y - 40.0));
h.run();
let texts = painted(h.output());
let want = crate::fmt::date(day);
assert!(
texts.iter().any(|(t, _, _)| *t == want),
"{want} not shown at {frac}: {:?}",
texts.iter().map(|(t, _, _)| t).collect::<Vec<_>>()
);
}
}
}
+245
View File
@@ -0,0 +1,245 @@
//! The live loader. A background thread owns the read-only [`Snapshot`],
//! watches the data directory, and on every change applies the new journal
//! lines and sends a fresh [`Books`] to the UI, then asks for a repaint.
//! A change made by the agent in another window shows up within a frame or
//! two. Filesystem events are the trigger; a slow poll backs them up in case
//! an event is missed (network drives, odd editors).
use crate::books::{Books, Integrity};
use chrono::NaiveDate;
use ledger_config::BooksSettings;
use ledger_store::{Snapshot, StoreError};
use notify::{RecursiveMode, Watcher};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::{Duration, SystemTime};
/// Where the books come from.
#[derive(Clone, Debug)]
pub struct Source {
/// The `books-data` directory (journal and blobs).
pub data_dir: PathBuf,
/// The company's `wanderbooks.toml`, if any.
pub books_toml: Option<PathBuf>,
/// Fix "today" (tests, screenshots); otherwise the local date.
pub today: Option<NaiveDate>,
}
pub enum Update {
Books(Arc<Books>),
Error(String),
/// The agent asked the viewer to show something (`books.show`).
Show(ledger_store::viewer::ViewerTarget),
}
pub struct Loader {
rx: Receiver<Update>,
}
impl Loader {
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
let (tx, rx) = channel();
std::thread::Builder::new()
.name("books-loader".into())
.spawn(move || run(source, ctx, tx))
.expect("spawn loader");
Loader { rx }
}
/// Every update since the last call, in order (books before the
/// navigation that refers to them).
pub fn poll(&self) -> Vec<Update> {
self.rx.try_iter().collect()
}
/// Block until the first update (tests, headless).
pub fn wait(&self, timeout: Duration) -> Option<Update> {
self.rx.recv_timeout(timeout).ok()
}
}
/// Load once, synchronously (tests and one-shot tools).
pub fn load_once(source: &Source) -> Result<Books, String> {
let snap = Snapshot::load(&source.data_dir).map_err(|e| {
problem(&e, || {
format!("Can't read the books in {}", source.data_dir.display())
})
})?;
let integrity = Integrity::check(&snap, &mut HashSet::new());
let settings = settings(source, &snap)?;
let today = today(source, &settings);
Books::build(&snap, settings, today, None, integrity)
}
/// What the viewer says when the books can't be read. Books from a newer
/// wanderbooks aren't broken, so they get the plain "update" message on its
/// own, never a failure framing; anything else is `context: error`.
pub fn problem(e: &StoreError, context: impl FnOnce() -> String) -> String {
match e {
StoreError::NewerJournal { .. } => {
let text = e.to_string();
let mut chars = text.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
_ => format!("{}: {e}", context()),
}
}
/// Today: as fixed for tests, else in the company's time zone, else the
/// computer's.
fn today(source: &Source, settings: &BooksSettings) -> NaiveDate {
source
.today
.unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref()))
}
/// The settings: the books' own when they keep them (the settings file is
/// then never read, as the server never reads it), else the settings
/// file's.
fn settings(source: &Source, snap: &Snapshot) -> Result<BooksSettings, String> {
let books = snap.ledger().settings();
if books.is_journaled() {
return Ok(ledger_config::codec::books_settings(
books,
snap.ledger().base_currency().code(),
));
}
match &source.books_toml {
Some(p) if p.exists() => ledger_config::load(p),
_ => Ok(BooksSettings::default()),
}
}
fn mtime(p: &Option<PathBuf>) -> Option<SystemTime> {
p.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
}
fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
let send = |u: Update| {
let _ = tx.send(u);
ctx.request_repaint();
};
let mut snap = loop {
match Snapshot::load(&source.data_dir) {
Ok(s) => break s,
Err(e) => {
send(Update::Error(problem(&e, || {
format!("Can't read the books in {}", source.data_dir.display())
})));
std::thread::sleep(Duration::from_secs(2));
}
}
};
let mut verified: HashSet<String> = HashSet::new();
// Build the picture; also return the database check it was built with.
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
let integrity = Integrity::check(snap, &mut verified);
let disk = integrity.disk.clone();
let update = match settings(&source, snap).and_then(|s| {
let today = today(&source, &s);
Books::build(snap, s, today, prev, integrity)
}) {
Ok(b) => Update::Books(Arc::new(b)),
Err(e) => Update::Error(e),
};
(update, disk)
};
let (first, disk) = build(&snap, None);
let mut last_disk = Some(disk);
send(first);
// Requests to show something: apply new ones, and on start one made in
// the last two minutes (the agent said "open the viewer"); never an
// old one.
let started = chrono::Utc::now();
let mut last_show = 0u64;
let mut check_show = |send: &dyn Fn(Update)| {
if let Some(cmd) = ledger_store::viewer::read(&source.data_dir)
&& cmd.seq != last_show
{
let first_look = last_show == 0;
last_show = cmd.seq;
let recent = chrono::DateTime::parse_from_rfc3339(&cmd.at)
.is_ok_and(|t| started.signed_duration_since(t) < chrono::Duration::minutes(2));
if !first_look || recent {
send(Update::Show(cmd.target));
}
}
};
check_show(&send);
let (ev_tx, ev_rx) = channel();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok() {
let _ = ev_tx.send(());
}
})
.ok();
if let Some(w) = watcher.as_mut() {
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
let cmd = ledger_store::viewer::command_path(&source.data_dir);
if let Some(dir) = cmd.parent() {
let _ = std::fs::create_dir_all(dir);
let _ = w.watch(dir, RecursiveMode::NonRecursive);
}
if let Some(t) = &source.books_toml
&& let Some(dir) = t.parent()
{
let _ = w.watch(dir, RecursiveMode::NonRecursive);
}
}
let mut toml_mtime = mtime(&source.books_toml);
let day_now = |snap: &Snapshot| {
settings(&source, snap)
.map(|s| today(&source, &s))
.unwrap_or_else(|_| today(&source, &BooksSettings::default()))
};
let mut day = day_now(&snap);
loop {
match ev_rx.recv_timeout(Duration::from_secs(3)) {
Ok(()) => {
// Let a burst of writes settle, then take them all at once.
std::thread::sleep(Duration::from_millis(30));
while ev_rx.try_recv().is_ok() {}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
std::thread::sleep(Duration::from_secs(3));
}
}
let before = snap.journal_seq();
let grew = match snap.refresh() {
Ok(n) => n > 0,
Err(e) => {
send(Update::Error(problem(&e, || {
"Reading the journal failed".into()
})));
continue;
}
};
let toml_now = mtime(&source.books_toml);
let new_day = day_now(&snap);
// The writer journals first and updates its database right after:
// a "behind" database usually catches up a moment later, so look
// again rather than leave a stale warning up.
let disk_changed = !grew
&& last_disk
.as_ref()
.is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d);
if grew || disk_changed || toml_now != toml_mtime || new_day != day {
toml_mtime = toml_now;
day = new_day;
let (update, disk) = build(&snap, grew.then_some(before));
last_disk = Some(disk);
send(update);
}
check_show(&send);
}
}
File diff suppressed because it is too large. Load diff
+135
View File
@@ -0,0 +1,135 @@
//! Files the viewer makes for you (report PDFs). They go to the
//! Downloads folder, never into the books: the viewer stays read-only.
use std::path::{Path, PathBuf};
/// Where saved files go: `$XDG_DOWNLOAD_DIR`, else `~/Downloads`, else
/// the home directory.
pub fn downloads_dir() -> PathBuf {
if let Some(d) = std::env::var_os("XDG_DOWNLOAD_DIR").map(PathBuf::from)
&& d.is_dir()
{
return d;
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let d = home.join("Downloads");
if d.is_dir() { d } else { home }
}
/// `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()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c if c.is_control() => ' ',
c => c,
})
.collect();
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
/// of that name: the same report renders the same bytes) and open it with
/// the system's viewer.
pub fn save_and_open(name: &str, ext: &str, bytes: &[u8]) -> Result<PathBuf, String> {
let path = downloads_dir().join(file_name(name, ext));
std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
open(&path);
Ok(path)
}
/// Open a file with whatever the desktop uses for its type. Best effort:
/// the file is saved either way.
fn open(path: &Path) {
#[cfg(target_os = "macos")]
let cmd = std::process::Command::new("open").arg(path).spawn();
#[cfg(target_os = "windows")]
let cmd = std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let cmd = std::process::Command::new("xdg-open").arg(path).spawn();
let _ = cmd;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_names_are_safe() {
assert_eq!(
file_name("Acme LLC — Profit and loss — Jan 1/2026: draft", "pdf"),
"Acme LLC — Profit and loss — Jan 1-2026- draft.pdf"
);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! 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, every decimal (at least two), a true minus sign:
/// `−1,234.56`.
pub fn money(d: Decimal) -> String {
let s = group(d.abs());
if is_negative(d) {
format!("\u{2212}{s}")
} else {
s
}
}
/// What the viewer shows for a figure the books can't compute (rules.md
/// B-28: an earlier version's draft lines, shares, or bases past exact
/// arithmetic): said in words, never a number.
pub const CANT_COMPUTE: &str = "too large to compute";
/// [`money`], or [`CANT_COMPUTE`] for a figure the books can't compute.
pub fn money_or(d: Option<Decimal>) -> String {
d.map_or_else(|| CANT_COMPUTE.to_string(), money)
}
/// [`dollars`], or [`CANT_COMPUTE`] for a figure the books can't compute.
pub fn dollars_or(d: Option<Decimal>) -> String {
d.map_or_else(|| CANT_COMPUTE.to_string(), dollars)
}
/// Accounting style: negatives in parentheses, `(1,234.56)`.
pub fn accounting(d: Decimal) -> String {
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 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 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 {
format!("${:.0}k", a / 1_000.0)
} else if a >= 1_000.0 {
format!("${:.1}k", a / 1_000.0)
} else {
format!("${a:.0}")
};
// 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 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() {
if i > 0 && (int.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
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() {
return None;
}
let p: f64 = prev.try_into().ok()?;
let n: f64 = now.try_into().ok()?;
Some((n - p) / p.abs() * 100.0)
}
/// `Oct 12, 2026`.
pub fn date(d: NaiveDate) -> String {
d.format("%b %-d, %Y").to_string()
}
/// `Oct 12`.
pub fn date_short(d: NaiveDate) -> String {
d.format("%b %-d").to_string()
}
/// `Oct 2026`.
pub fn month(d: NaiveDate) -> String {
d.format("%b %Y").to_string()
}
/// `1 receipt`, `3 receipts`.
pub fn count(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// An RFC 3339 timestamp from the journal, shown as local-ish UTC text:
/// `Oct 11, 2026 18:04 UTC`.
pub fn timestamp(ts: &str) -> String {
chrono::DateTime::parse_from_rfc3339(ts)
.map(|t| t.format("%b %-d, %Y %H:%M UTC").to_string())
.unwrap_or_else(|_| ts.to_string())
}
/// Bytes as `12.3 KB`.
pub fn bytes(n: u64) -> String {
let f = n as f64;
if f >= 1_048_576.0 {
format!("{:.1} MB", f / 1_048_576.0)
} else if f >= 1024.0 {
format!("{:.1} KB", f / 1024.0)
} else {
format!("{n} B")
}
}
/// `snake_case_key` as `Snake case key`, for settings shown as labels,
/// keeping acronyms upper case (`ein` → `EIN`).
pub fn label(key: &str) -> String {
const ACRONYMS: [&str; 9] = [
"ein", "duns", "irs", "cpa", "llc", "tin", "ssn", "id", "url",
];
let words: Vec<String> = key
.split(['_', ' '])
.filter(|w| !w.is_empty())
.enumerate()
.map(|(i, w)| {
if ACRONYMS.contains(&w.to_lowercase().as_str()) {
w.to_uppercase()
} else if i == 0 {
let mut c = w.chars();
c.next()
.map(|f| f.to_uppercase().collect::<String>() + c.as_str())
.unwrap_or_default()
} else {
w.to_string()
}
})
.collect();
words.join(" ")
}
/// A `CamelCase` enum name as words: `PrepaidExpense` → `Prepaid expense`.
pub fn words(debug: &str) -> String {
let mut out = String::new();
for (i, c) in debug.chars().enumerate() {
if c.is_uppercase() && i > 0 {
out.push(' ');
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
/// The tax return a form enum stands for.
pub fn tax_form(f: ledger_core::TaxForm) -> &'static str {
match f {
ledger_core::TaxForm::ScheduleC => "Schedule C (Form 1040)",
ledger_core::TaxForm::Form1065 => "Form 1065 (partnership)",
ledger_core::TaxForm::Form1120S => "Form 1120-S (S corporation)",
ledger_core::TaxForm::Form1120 => "Form 1120 (C corporation)",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
/// money_or / dollars_or: a figure as money_ or dollars does, and one
/// the books can't compute in words, never a number (rules.md B-28).
#[test]
fn a_figure_or_why_not() {
let v = Decimal::new(-123_456, 2);
assert_eq!(money_or(Some(v)), money(v));
assert_eq!(dollars_or(Some(v)), dollars(v));
assert_eq!(money_or(None), CANT_COMPUTE);
assert_eq!(dollars_or(None), CANT_COMPUTE);
assert_eq!(CANT_COMPUTE, "too large to compute");
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
#[test]
fn money_formats() {
assert_eq!(money(dec("1234567.5")), "1,234,567.50");
assert_eq!(money(dec("-12")), "\u{2212}12.00");
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")), "$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");
assert_eq!(label("state_entity_number"), "State entity number");
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);
}
}
+150
View File
@@ -0,0 +1,150 @@
//! `ledger-gui`: a read-only, live view of wanderbooks books for the people
//! who own them and the accountant who checks them. See `plan/gui.md`.
#![allow(missing_docs)]
pub mod app;
pub mod attention;
pub mod books;
pub mod charts;
pub mod data;
pub mod demo;
pub mod export;
pub mod fmt;
pub mod nav;
pub mod pages;
pub mod palette;
mod receipts;
#[cfg(test)]
mod test_support;
mod theme;
mod widgets;
pub use app::App;
pub use data::Source;
use std::path::PathBuf;
/// The vault key file named on the command line (`--vault-key-file`),
/// which wins over a settings file's `[vault] key_file`, as it does for
/// the server: books that keep their own settings never name it.
static VAULT_KEY_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Name the vault key file (once, at start).
pub fn set_vault_key_file(path: std::path::PathBuf) {
let _ = VAULT_KEY_FILE.set(path);
}
/// The vault key file named on the command line, if one was.
pub fn vault_key_file() -> Option<std::path::PathBuf> {
VAULT_KEY_FILE.get().cloned()
}
/// How [`run`] stops when it can't go on: the reason is already printed
/// on stderr, and the number is the process's exit code (2: a bad argument; 1: the demo books couldn't be built). A
/// library never ends the process itself, so the books it opened are
/// closed properly on the way out; the binaries turn this into the exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Exit(pub i32);
impl std::fmt::Display for Exit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "exit code {}", self.0)
}
}
impl std::error::Error for Exit {}
/// Run the viewer with `args` (the command line after the program name);
/// `prog` names the program in the usage text (`ledger-gui`, or
/// `wanderbooks gui`). It opens the books read-only and returns when the
/// window closes. An unknown argument returns [`Exit`] (code 2).
///
/// `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`:
/// with no flags it looks for `books-data/` and `wanderbooks.toml` in the
/// current directory; `--demo` builds a demo company in a temp directory
/// and opens that.
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut data_dir: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut today = None;
let mut demo = false;
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--data-dir" => data_dir = args.next().map(PathBuf::from),
"--books-toml" => books_toml = args.next().map(PathBuf::from),
"--vault-key-file" => {
if let Some(k) = args.next() {
crate::set_vault_key_file(PathBuf::from(k));
}
}
"--today" => today = args.next().and_then(|d| d.parse().ok()),
"--demo" => demo = true,
"-h" | "--help" => {
println!(
"usage: {prog} [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]"
);
return Ok(());
}
other => {
eprintln!("unknown argument {other}");
return Err(Box::new(Exit(2)));
}
}
}
if demo {
let d = today.unwrap_or_else(|| chrono::Local::now().date_naive());
let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}"));
let _ = std::fs::remove_dir_all(&dir);
if let Err(e) = crate::demo::build(&dir, d) {
eprintln!("building the demo books failed: {e}");
return Err(Box::new(Exit(1)));
}
eprintln!("demo books: {}", dir.display());
data_dir = Some(dir.join("books-data"));
books_toml = Some(dir.join("wanderbooks.toml"));
}
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
let books_toml = books_toml.or_else(|| {
let guess = data_dir
.parent()
.unwrap_or(&data_dir)
.join("wanderbooks.toml");
guess.exists().then_some(guess)
});
let source = Source {
data_dir,
books_toml,
today,
};
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("wanderbooks")
.with_app_id("wanderbooks")
.with_inner_size([1440.0, 920.0])
// Small on purpose: tiling window managers hand out small slots
// regardless, and every screen lays itself out to fit.
.with_min_inner_size([360.0, 400.0]),
..Default::default()
};
Ok(eframe::run_native(
"wanderbooks",
options,
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
)?)
}
#[cfg(test)]
mod exit_tests {
use super::Exit;
#[test]
fn an_exit_says_its_code() {
assert_eq!(Exit(2).to_string(), "exit code 2");
assert_eq!(Exit(1).to_string(), "exit code 1");
}
}
+12
View File
@@ -0,0 +1,12 @@
//! `ledger-gui`: the read-only, live viewer. The same viewer is
//! `wanderbooks gui`; both call [`ledger_gui::run`].
fn main() -> Result<(), Box<dyn std::error::Error>> {
match ledger_gui::run("ledger-gui", std::env::args().skip(1)) {
Err(e) => match e.downcast_ref::<ledger_gui::Exit>() {
Some(exit) => std::process::exit(exit.0),
None => Err(e),
},
done => done,
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Where you are and how you got there: a browser-style history.
use ledger_core::{AccountId, AssetId, EntryId, PartyId};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ReportKind {
ProfitAndLoss,
BalanceSheet,
CashFlow,
TrialBalance,
GeneralLedger,
MembersEquity,
TaxSummary,
ArAging,
ApAging,
Vendor1099,
FixedAssetRegister,
}
impl ReportKind {
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,
ReportKind::ApAging,
];
pub fn title(self) -> &'static str {
match self {
ReportKind::ProfitAndLoss => "Profit and loss",
ReportKind::BalanceSheet => "Balance sheet",
ReportKind::CashFlow => "Cash flow",
ReportKind::TrialBalance => "Trial balance",
ReportKind::GeneralLedger => "General ledger",
ReportKind::MembersEquity => "Owners' equity",
ReportKind::TaxSummary => "Tax summary",
ReportKind::ArAging => "Receivables aging",
ReportKind::ApAging => "Payables aging",
ReportKind::Vendor1099 => "1099 worksheet",
ReportKind::FixedAssetRegister => "Fixed asset register",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Route {
Overview,
Cash,
Profit,
Transactions,
Entry(EntryId),
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,
Journal,
JournalEvent(u64),
}
impl Route {
/// The sidebar section this route belongs to.
pub fn section(&self) -> Section {
match self {
Route::Overview => Section::Overview,
Route::Cash => Section::Cash,
Route::Profit => Section::Profit,
Route::Transactions | Route::Entry(_) => Section::Transactions,
Route::Reports(_) => Section::Reports,
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,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Section {
Overview,
Cash,
Profit,
Transactions,
Reports,
Accounts,
Assets,
Parties,
Documents,
Company,
Journal,
}
impl Section {
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,
];
pub fn title(self) -> &'static str {
match self {
Section::Overview => "Overview",
Section::Cash => "Cash",
Section::Profit => "Profit",
Section::Transactions => "Transactions",
Section::Reports => "Reports",
Section::Accounts => "Accounts",
Section::Assets => "Assets",
Section::Parties => "Parties",
Section::Documents => "Documents",
Section::Company => "Company",
Section::Journal => "Journal",
}
}
pub fn icon(self) -> &'static str {
use egui_phosphor::regular as i;
match self {
Section::Overview => i::SQUARES_FOUR,
Section::Cash => i::BANK,
Section::Profit => i::TREND_UP,
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,
}
}
pub fn home(self) -> Route {
match self {
Section::Overview => Route::Overview,
Section::Cash => Route::Cash,
Section::Profit => Route::Profit,
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,
}
}
}
/// ```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>,
forward: Vec<Route>,
pub current: Route,
}
impl Nav {
pub fn new(start: Route) -> Self {
Self {
back: Vec::new(),
forward: Vec::new(),
current: start,
}
}
pub fn go(&mut self, to: Route) {
if to != self.current {
self.back.push(std::mem::replace(&mut self.current, to));
self.forward.clear();
}
}
pub fn can_back(&self) -> bool {
!self.back.is_empty()
}
pub fn can_forward(&self) -> bool {
!self.forward.is_empty()
}
pub fn back(&mut self) {
if let Some(r) = self.back.pop() {
self.forward.push(std::mem::replace(&mut self.current, r));
}
}
pub fn forward(&mut self) {
if let Some(r) = self.forward.pop() {
self.back.push(std::mem::replace(&mut self.current, r));
}
}
}
+599
View File
@@ -0,0 +1,599 @@
//! The chart of accounts with balances, and one account's register: every
//! 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;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
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)]
pub struct State {
/// Limit the register to these dates (set by drill-downs).
pub range: Option<(NaiveDate, NaiveDate)>,
pub show_empty: bool,
}
pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "accounts", |ui| {
widgets::page_header(
ui,
"Accounts",
"Every account in the chart with today's balance. Click one for its register.",
);
ui.checkbox(
&mut shell.state.accounts.show_empty,
"Show accounts with no activity",
);
ui.add_space(10.0);
let used: std::collections::HashSet<u64> = b
.entries
.iter()
.flat_map(|e| e.accounts.iter().copied())
.collect();
for (t, title) in [
(AccountType::Asset, "Assets"),
(AccountType::Liability, "Liabilities"),
(AccountType::Equity, "Equity"),
(AccountType::Revenue, "Income"),
(AccountType::Expense, "Expenses"),
] {
let accounts: Vec<_> = b
.ledger
.chart()
.all()
.filter(|a| {
a.account_type == t && (shell.state.accounts.show_empty || used.contains(&a.id))
})
.collect();
if accounts.is_empty() {
continue;
}
let mut go = None;
widgets::titled_card(
ui,
title,
|_| {},
|ui| {
for a in &accounts {
let bal = b.balance_on(a.id, b.today);
let (r, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(r);
painter.text(
r.left_center() + vec2(8.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.code,
theme::regular(13.0),
p.muted,
);
let bal_text = fmt::accounting(bal);
let bal_w = painter
.layout_no_wrap(bal_text.clone(), theme::semibold(13.5), p.text)
.size()
.x;
// Tags sit in their own column when there's room for
// it; narrower, they move to the row's tooltip.
let tags_x = 340.0;
let tags_room = r.width() - tags_x - bal_w - 24.0;
let name_room = if tags_room >= 120.0 {
tags_x - 64.0 - 12.0
} else {
r.width() - 64.0 - bal_w - 24.0
};
widgets::clipped_text(
&painter,
r.left_center() + vec2(64.0, 0.0),
&a.name,
theme::regular(13.5),
p.text,
name_room,
);
let mut tags = Vec::new();
if b.cash_accounts.contains(&a.id) {
tags.push("cash".to_string());
}
if let Some(c) = &a.tax_category {
let pct = a
.deductible_percent
.map(|p| format!(" {}%", p.normalize()))
.unwrap_or_default();
tags.push(format!("tax: {}{pct}", c.replace('_', " ")));
} else if matches!(t, AccountType::Revenue | AccountType::Expense) {
tags.push("no tax category".into());
}
if a.placeholder {
tags.push("header".into());
}
let tags = tags.join(" · ");
if tags_room >= 120.0 {
widgets::clipped_text(
&painter,
r.left_center() + vec2(tags_x, 0.0),
&tags,
theme::regular(12.0),
p.faint,
tags_room,
);
}
painter.text(
r.right_center() - vec2(8.0, 0.0),
egui::Align2::RIGHT_CENTER,
bal_text,
theme::semibold(13.5),
p.text,
);
let resp = if tags_room < 120.0 && !tags.is_empty() {
resp.on_hover_text(format!("{} {} · {tags}", a.code, a.name))
} else {
resp
};
if resp.clicked() {
go = Some(a.id);
}
}
},
);
if let Some(id) = go {
shell.state.accounts.range = None;
shell.go(Route::Account(id));
}
ui.add_space(12.0);
}
});
}
pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(a) = b.ledger.chart().get(id) else {
return;
};
let first = b.kpis.first_entry.unwrap_or(b.today);
let (s, e) = shell
.state
.accounts
.range
.unwrap_or((first.min(b.today), b.today));
page(ui, "account", |ui| {
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);
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(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(
ui,
"Balance over time",
|_| {},
|ui| crate::charts::balance_line(ui, &hist, b.today, 180.0),
);
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!(
"{} {} – {}",
icon::CALENDAR_BLANK,
fmt::date(s),
fmt::date(e)
))
.font(theme::semibold(13.5)),
);
if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() {
shell.state.accounts.range = None;
}
if ui
.button(format!("{} Find in transactions", icon::LIST_BULLETS))
.clicked()
{
shell.state.transactions.account = Some(id);
shell.go(Route::Transactions);
}
});
ui.add_space(8.0);
let Ok(gl) = general_ledger(&b.ledger, s, e) else {
return;
};
let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::EMPTY,
"No activity",
"Nothing posted to this account in this range.",
)
});
return;
};
let mut go = None;
widgets::card(ui, |ui| {
let amt = widgets::amount_col_w(ui.available_width(), 3, 90.0 + 120.0, 120.0);
let w = ui.available_width().max(90.0 + 120.0 + amt * 3.0);
widgets::min_width(ui, "register", w, |ui| {
let header = |ui: &mut Ui, vals: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[0],
theme::semibold(12.0),
p.muted,
);
ui.painter().text(
r.left_center() + vec2(90.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[1],
theme::semibold(12.0),
p.muted,
);
for (i, v) in vals[2..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*v,
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]);
widgets::rule(ui);
let line = |ui: &mut Ui,
date: String,
desc: String,
sub: String,
d: Decimal,
c: Decimal,
bal: Decimal,
strong: bool|
-> egui::Response {
let (r, resp) = ui.allocate_exact_size(
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);
}
let painter = ui.painter_at(r);
let font = if strong {
theme::semibold(13.0)
} else {
theme::regular(13.0)
};
let y = if sub.is_empty() {
r.center().y
} else {
r.center().y - 7.0
};
painter.text(
egui::pos2(r.left() + 4.0, y),
egui::Align2::LEFT_CENTER,
date,
theme::regular(12.5),
p.muted,
);
// Leave room for the debit, credit, and balance columns.
let text_w = r.width() - 90.0 - amt * 3.0 - 12.0;
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, y),
&desc,
font.clone(),
p.text,
text_w,
);
if !sub.is_empty() {
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
&sub,
theme::regular(11.5),
p.faint,
text_w,
);
}
for (i, v) in [d, c].iter().enumerate() {
if !v.is_zero() {
painter.text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::money(*v),
font.clone(),
p.text,
);
}
}
painter.text(
egui::pos2(r.right() - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
font,
p.text,
);
resp
};
line(
ui,
fmt::date_short(s),
"Beginning balance".into(),
String::new(),
Decimal::ZERO,
Decimal::ZERO,
acct.beginning_balance,
true,
);
for post in acct.postings.iter().rev() {
let r = line(
ui,
fmt::date_short(post.date),
post.description.clone(),
format!("#{} · {}", post.entry_id, post.split),
post.debit,
post.credit,
post.balance,
false,
);
if r.clicked() {
go = Some(post.entry_id);
}
}
widgets::rule(ui);
line(
ui,
fmt::date_short(e),
"Ending balance".into(),
String::new(),
acct.total_debits,
acct.total_credits,
acct.ending_balance,
true,
);
});
});
if let Some(entry) = go {
shell.go(Route::Entry(entry));
}
});
}
/// 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));
}
}
+727
View File
@@ -0,0 +1,727 @@
//! Cash: what's in the bank, where it's been going, how long it lasts, and
//! what's coming in and out next.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Datelike, Days, NaiveDate};
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, balance_history, flows_by_interval, interval_start};
use rust_decimal::Decimal;
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "cash", |ui| {
let k = &b.kpis;
let since = b.today - Days::new(30);
let flows = flows_by_interval(
&b.ledger,
&b.cash_accounts,
since + Days::new(1),
b.today,
Interval::Year,
);
let (cin, cout): (Decimal, Decimal) =
flows.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, f| {
(a.0 + f.2, a.1 + f.3)
});
widgets::page_header(
ui,
"Cash",
&format!(
"{} across {}. In the last 30 days {} came in and {} went out.",
fmt::dollars(k.cash),
fmt::count(b.cash_accounts.len(), "account", "accounts"),
fmt::dollars(cin),
fmt::dollars(cout)
),
);
if b.cash_accounts.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::BANK,
"No cash accounts found",
"Name an asset account \"checking\", \"savings\", or \"cash\", or ask the agent to set the books' cash accounts.",
)
});
return;
}
// One tile per cash and card account.
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
// Up to four across, fewer when the window is narrow.
let n = tiles.len().clamp(1, 4);
let n = widgets::fit_columns(ui.available_width(), 200.0, 16.0, n);
for row in tiles.chunks(n) {
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
let Some((id, is_card)) = row.get(i) else {
return;
};
let hist = balance_history(&b.ledger, &[*id]);
let spark: Vec<f64> = hist
.iter()
.rev()
.take(40)
.rev()
.map(|(_, v)| (*v).try_into().unwrap_or(0.0))
.collect();
let bal = b.balance_on(*id, b.today);
let ago = b.balance_on(*id, since);
let delta = bal - ago;
let r = widgets::kpi(
ui,
widgets::Kpi {
label: &b.account_label(*id),
value: fmt::dollars(bal),
sub: Some((
if *is_card {
"owed on the card".to_string()
} else if delta.is_zero() {
"no change in 30 days".to_string()
} else {
format!(
"{}{} in 30 days",
if delta > Decimal::ZERO { "+" } else { "" },
fmt::dollars(delta)
)
},
if *is_card || delta.is_zero() {
p.muted
} else if delta > Decimal::ZERO {
p.positive
} else {
p.negative
},
)),
spark: (spark.len() > 1).then_some(&spark[..]),
how: "",
},
);
if r.interact(Sense::click()).clicked() {
shell.state.accounts.range = None;
shell.go(Route::Account(*id));
}
});
}
let first = k.first_entry.unwrap_or(b.today);
let (start, end) = b.trailing_months(12);
let start = start.max(interval_start(first, Interval::Month));
columns(ui, 980.0, &[3.0, 2.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Cash balance",
|_| {},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 240.0),
);
}
_ => {
let months =
flows_by_interval(&b.ledger, &b.cash_accounts, start, end, Interval::Month);
widgets::titled_card(
ui,
"Money in and out by month",
|_| {},
|ui| {
let groups: Vec<BarGroup> = months
.iter()
.map(|(s, _, i, o)| BarGroup {
label: s.format("%b").to_string(),
values: vec![*i, *o],
})
.collect();
charts::bar_chart(
ui,
&groups,
&[("In", p.series[1]), ("Out", p.series[2])],
210.0,
);
},
);
}
});
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => runway(ui, b),
_ => coming_up(ui, b, shell),
});
biggest_outflows(ui, b, shell);
});
}
fn runway(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let ms = b.month_start();
let three = ms - chrono::Months::new(3);
let start = three.max(
b.kpis
.first_entry
.map_or(three, |f| interval_start(f, Interval::Month)),
);
let months = flows_by_interval(
&b.ledger,
&b.cash_accounts,
start,
ms - Days::new(1),
Interval::Month,
);
widgets::titled_card(
ui,
"Burn and runway",
|_| {},
|ui| {
if months.is_empty() {
ui.label(RichText::new("Needs at least one full month of books.").color(p.muted));
return;
}
let net: Decimal = months.iter().map(|(_, _, i, o)| i - o).sum::<Decimal>()
/ Decimal::from(months.len() as u64);
let net = net.round_dp(2);
let big = |ui: &mut Ui, label: &str, value: String, color| {
ui.vertical(|ui| {
ui.label(
RichText::new(label)
.font(theme::regular(12.5))
.color(p.muted),
);
ui.label(
RichText::new(value)
.font(theme::semibold(22.0))
.color(color),
);
});
};
ui.horizontal(|ui| {
big(
ui,
"Average monthly net cash flow",
format!(
"{}{}",
if net > Decimal::ZERO { "+" } else { "" },
fmt::dollars(net)
),
if net >= Decimal::ZERO {
p.positive
} else {
p.negative
},
);
ui.add_space(32.0);
if net < Decimal::ZERO {
// Checked: a sub-cent average (lines an earlier version
// booked) makes a runway past any calendar.
let months_left = b.kpis.cash.checked_div(-net).map(|m| m.round_dp(1));
let out = months_left
.and_then(|m| m.checked_mul(Decimal::from(30)))
.and_then(|d| u64::try_from(d).ok())
.and_then(|d| b.today.checked_add_days(Days::new(d)));
big(
ui,
"Runway",
months_left
.map_or_else(|| fmt::CANT_COMPUTE.into(), |m| format!("{m} months")),
p.text,
);
ui.add_space(32.0);
big(
ui,
"Cash runs out around",
out.map_or_else(|| "beyond any calendar date".into(), fmt::month),
p.warning,
);
} else {
big(ui, "Runway", "Cash is growing".into(), p.positive);
}
});
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"Cash in minus cash out of {}, averaged over the last {} (owner contributions and draws included: this is what the bank sees).",
b.cash_accounts.iter().map(|a| b.account_name(*a)).collect::<Vec<_>>().join(" and "),
fmt::count(months.len(), "full month", "full months")
))
.font(theme::regular(12.0))
.color(p.faint),
);
},
);
}
/// A recurring entry's ungenerated occurrences dated `from..=to` (and on
/// or before its end), found from the occurrence just before `from`
/// rather than walking from its start: a weekly entry an earlier version
/// started at chrono's first day has millions of occurrences before today,
/// and this runs every frame.
fn occurrences_from(
r: &ledger_core::RecurringEntry,
from: NaiveDate,
to: NaiveDate,
) -> Vec<NaiveDate> {
use ledger_core::Frequency;
let last = r.end.map_or(to, |e| e.min(to));
// How many periods from the start to `from`, rounded down, less one
// for a month-end clamp: never past the first occurrence on or after
// `from`.
let months = |d: NaiveDate| {
(i64::from(d.year()) - i64::from(r.start.year())) * 12 + i64::from(d.month())
- i64::from(r.start.month())
};
let k0 = match r.frequency {
Frequency::Weekly => (from - r.start).num_days() / 7,
Frequency::Monthly => months(from),
Frequency::Quarterly => months(from) / 3,
Frequency::Yearly => months(from) / 12,
} - 1;
let Ok(k0) = u32::try_from(k0.max(0)) else {
return Vec::new();
};
(k0..)
.map_while(|k| r.frequency.nth(r.start, k).filter(|d| *d <= last))
.filter(|d| *d >= from && !r.generated.contains(d))
.collect()
}
struct Upcoming {
date: NaiveDate,
label: String,
/// None: it can't be computed exactly (a recurring template or a
/// document an earlier version journaled, past exact arithmetic).
amount: Option<Decimal>,
route: Option<Route>,
}
fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let horizon = b.today + Days::new(45);
let mut items: Vec<Upcoming> = Vec::new();
for r in b.ledger.recurring_entries() {
let cash = r
.lines
.iter()
.filter(|(a, _, _)| b.cash_accounts.contains(a))
.try_fold(Decimal::ZERO, |sum, (_, s, m)| {
if *s == ledger_core::Side::Debit {
sum.checked_add(m.amount)
} else {
sum.checked_sub(m.amount)
}
});
for d in occurrences_from(r, b.today, horizon) {
items.push(Upcoming {
date: d,
label: format!("{} (recurring)", r.description),
amount: cash,
route: Some(Route::Company),
});
}
}
// An open document: its balance, or None when it can't be computed
// (shown as such, never left out).
for id in 1..b.ledger.next_invoice_id() {
let Some(inv) = b.ledger.get_invoice(id) else {
continue;
};
let balance = inv.balance().ok();
if balance.is_some_and(|x| x.is_zero()) {
continue;
}
let who = b
.ledger
.get_customer(inv.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default();
let late = inv.due_date < b.today;
items.push(Upcoming {
date: inv.due_date,
label: format!(
"Invoice {id} from {who}{}",
if late { " — overdue" } else { "" }
),
amount: balance,
route: Some(Route::Entry(inv.entry_id)),
});
}
for id in 1..b.ledger.next_bill_id() {
let Some(bill) = b.ledger.get_bill(id) else {
continue;
};
let balance = bill.balance().ok();
if balance.is_some_and(|x| x.is_zero()) {
continue;
}
let who = b
.ledger
.get_vendor(bill.vendor_id)
.map(|v| v.name.clone())
.unwrap_or_default();
items.push(Upcoming {
date: bill.due_date,
label: format!("Bill {id} to {who}"),
amount: balance.map(|x| -x),
route: Some(Route::Entry(bill.entry_id)),
});
}
items.sort_by_key(|i| i.date);
let mut go = None;
widgets::titled_card(
ui,
"Coming up",
|ui| {
ui.label(
RichText::new("open invoices, bills, recurring")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if items.is_empty() {
widgets::empty(
ui,
icon::CALENDAR_CHECK,
"Nothing scheduled",
"No open invoices, bills, or recurring entries due soon.",
);
return;
}
// None: an item, or the sum, can't be computed exactly.
let (inflow, outflow) = items.iter().fold(
(Some(Decimal::ZERO), Some(Decimal::ZERO)),
|(inflow, outflow), i| match i.amount {
Some(a) if a > Decimal::ZERO => {
(inflow.and_then(|x| x.checked_add(a)), outflow)
}
Some(a) => (inflow, outflow.and_then(|x| x.checked_sub(a))),
None => (None, None),
},
);
ui.label(
RichText::new(format!(
"Expected in {} · expected out {}",
fmt::dollars_or(inflow),
fmt::dollars_or(outflow)
))
.font(theme::semibold(13.0)),
);
ui.add_space(6.0);
egui::ScrollArea::vertical()
.id_salt("upcoming")
.max_height(220.0)
.show(ui, |ui| {
for it in &items {
let (r, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let late = it.date < b.today;
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(it.date),
theme::regular(12.5),
if late { p.negative } else { p.muted },
);
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(70.0, 0.0),
&it.label,
theme::regular(13.0),
p.text,
r.width() - 70.0 - 130.0,
);
let (txt, c) = match it.amount {
Some(a) if a > Decimal::ZERO => {
(format!("+{}", fmt::money(a)), p.positive)
}
a => (fmt::money_or(a), p.text),
};
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
txt,
theme::semibold(13.0),
c,
);
if resp.clicked() {
go = it.route.clone();
}
}
});
},
);
if let Some(r) = go {
shell.go(r);
}
}
fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let since = b.today - Days::new(30);
let mut out: Vec<&crate::books::EntryInfo> = b
.entries
.iter()
.filter(|e| {
e.status == ledger_core::EntryStatus::Posted
&& e.date > since
&& e.cash_effect.is_some_and(|c| c < Decimal::ZERO)
})
.collect();
out.sort_by_key(|e| e.cash_effect);
out.truncate(8);
let mut go = None;
widgets::titled_card(
ui,
"Biggest payments, last 30 days",
|_| {},
|ui| {
if out.is_empty() {
widgets::empty(ui, icon::ARROW_UP_RIGHT, "No payments", "");
}
for e in &out {
let (r, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(e.date),
theme::regular(12.5),
p.muted,
);
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(70.0, 0.0),
&e.description,
theme::regular(13.0),
p.text,
r.width() - 70.0 - 130.0,
);
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
fmt::money_or(e.cash_effect),
theme::semibold(13.0),
p.text,
);
if resp.clicked() {
go = Some(e.id);
}
}
},
);
if let Some(id) = go {
shell.go(Route::Entry(id));
}
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::{Frequency, RecurringEntry};
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
/// occurrences_from is the recurring entry's due dates in the window,
/// exactly, without walking from the start: month ends clamped, ends
/// and generated dates honored, a start after the window, and a start
/// at chrono's first day.
#[test]
fn occurrences_in_a_window_are_the_due_dates_in_it() {
for frequency in [
Frequency::Weekly,
Frequency::Monthly,
Frequency::Quarterly,
Frequency::Yearly,
] {
for start in [
d(2020, 1, 31),
d(2024, 2, 29),
d(2026, 1, 10),
d(2026, 10, 20),
d(2027, 1, 1),
d(2027, 1, 15),
] {
for (end, (from, to)) in [None, Some(d(2026, 11, 2))].into_iter().flat_map(|end| {
// Windows a few weeks long, years long, and one
// opening before the entry starts.
[
(d(2026, 10, 17), d(2026, 12, 1)),
(d(2026, 6, 1), d(2028, 12, 31)),
(d(2025, 12, 20), d(2027, 12, 31)),
]
.map(|w| (end, w))
}) {
let mut r = RecurringEntry {
id: 1,
description: "x".into(),
lines: vec![],
frequency,
start,
end,
generated: Default::default(),
};
if let Some(first) = frequency.nth(start, 3) {
r.generated.insert(first);
}
let want: Vec<NaiveDate> =
r.due(to).into_iter().filter(|x| *x >= from).collect();
assert_eq!(
occurrences_from(&r, from, to),
want,
"{frequency:?} {start} {end:?} {from}..{to}"
);
}
}
}
let far = RecurringEntry {
id: 1,
description: "weekly since chrono's first day".into(),
lines: vec![],
frequency: Frequency::Weekly,
start: NaiveDate::MIN,
end: None,
generated: Default::default(),
};
let got = occurrences_from(&far, d(2026, 10, 17), d(2026, 11, 30));
assert!((6..=7).contains(&got.len()), "{got:?}");
assert!(got.iter().all(|x| x.weekday() == NaiveDate::MIN.weekday()));
}
/// The cash page's burn and runway, from the last three full months:
/// burning $1,000.00 a month on $7,000.00 is a 7-month runway, out
/// around May 2027 (210 days from Oct 15); an average of zero is cash
/// growing, not a runway past any date.
#[test]
fn runway_is_cash_over_the_average_burn() {
use crate::test_support::{Build, find, render};
let mut b = Build::new("runway");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
for m in [7, 8, 9] {
b.post(d(2026, m, 10), rent, cash, 100_000);
}
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Cash, |_| {});
let net = find(&texts, &fmt::dollars(Decimal::new(-100_000, 2)));
assert_eq!(net.color, p.negative);
find(&texts, "Runway");
find(&texts, "7 months");
find(&texts, "May 2027");
// Zero on average: growing (July out, August in, September quiet).
let mut b = Build::new("runway-flat");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
b.post(d(2026, 7, 10), rent, cash, 100_000);
b.post(d(2026, 8, 10), cash, capital, 100_000);
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Cash, |_| {});
assert_eq!(find(&texts, "Cash is growing").color, p.positive);
assert!(!crate::test_support::has(
&texts,
"beyond any calendar date"
));
}
/// "Coming up" lists recurring cash (its cash lines' sign: rent paid
/// out is negative, a recurring entry moving no cash zero, never
/// "+0.00"), open invoices (overdue only when due before today) and
/// open bills (negative), sorted by date, with the expected in and out.
#[test]
fn coming_up_is_what_cash_expects() {
use crate::test_support::{Build, cents, find, has, render};
use ledger_core::arap::DocumentLine;
use ledger_core::{AccountType, Currency, Frequency, Money, NewRecurring, Side};
let mut b = Build::new("coming-up");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
let accrued =
b.s.add_account("2000", "Accrued", AccountType::Liability, None, false)
.unwrap();
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
let usd = |c: i64| Money::new(cents(c), Currency::USD);
for (what, cr, c, day) in [("Rent", cash, 50_000, 20), ("Accrual", accrued, 5_000, 25)] {
b.s.register_recurring(NewRecurring {
description: what.into(),
lines: vec![(rent, Side::Debit, usd(c)), (cr, Side::Credit, usd(c))],
frequency: Frequency::Monthly,
start: d(2026, 1, day),
end: None,
})
.unwrap();
}
let line = |c: i64| {
vec![DocumentLine {
description: "work".into(),
amount: cents(c),
}]
};
let acme = b.s.add_customer("Acme").unwrap();
let beta = b.s.add_customer("Beta").unwrap();
b.s.create_invoice(acme, d(2026, 9, 1), d(2026, 10, 1), line(120_000))
.unwrap();
b.s.create_invoice(beta, d(2026, 10, 1), d(2026, 10, 15), line(20_000))
.unwrap();
let supplier = b.s.add_vendor("Supplier").unwrap();
b.s.create_bill(supplier, d(2026, 10, 1), d(2026, 11, 5), line(30_000))
.unwrap();
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Cash, |_| {});
find(&texts, "Coming up");
find(&texts, "Invoice 1 from Acme \u{2014} overdue");
find(&texts, "Invoice 2 from Beta");
find(&texts, "Bill 1 to Supplier");
find(
&texts,
"Expected in $1,400.00 \u{b7} expected out $1,300.00",
);
let plus = |c: i64| format!("+{}", fmt::money(cents(c)));
assert_eq!(find(&texts, &plus(120_000)).color, p.positive);
assert_eq!(find(&texts, &plus(20_000)).color, p.positive);
assert_eq!(find(&texts, &fmt::money(cents(-50_000))).color, p.text);
assert_eq!(find(&texts, &fmt::money(cents(-30_000))).color, p.text);
assert_eq!(find(&texts, &fmt::money(Decimal::ZERO)).color, p.text);
assert!(!has(&texts, &plus(0)));
assert!(!has(&texts, &plus(50_000)));
assert!(!has(&texts, &plus(30_000)));
}
/// "Biggest payments": posted entries of the last 30 days that took
/// cash out, never older ones, deposits, or drafts.
#[test]
fn biggest_payments_are_recent_posted_outflows() {
use crate::test_support::{Build, find, has, render};
let mut b = Build::new("outflows");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post_as("Opening", d(2026, 1, 2), cash, capital, 1_000_000);
b.post_as("Old rent", d(2026, 9, 10), rent, cash, 25_000);
b.post_as("October rent", d(2026, 10, 1), rent, cash, 25_000);
b.post_as("Deposit", d(2026, 10, 5), cash, capital, 5_000);
b.draft("Draft pay", d(2026, 10, 10), rent, cash, 7_000);
// Thirty days before today exactly: not in the last 30 days.
b.post_as("Edge payment", d(2026, 9, 15), rent, cash, 9_000);
// An expense the owner paid: no cash out.
b.post_as("Owner paid", d(2026, 10, 12), rent, capital, 4_000);
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Cash, |_| {});
find(&texts, "Biggest payments, last 30 days");
find(&texts, "October rent");
for gone in [
"Old rent",
"Deposit",
"Draft pay",
"Edge payment",
"Owner paid",
] {
assert!(!has(&texts, gone), "{gone}");
}
}
}
+777
View File
@@ -0,0 +1,777 @@
//! The company: who it is, who owns it, and the standing machinery of the
//! books — periods, fixed assets, amortization schedules, recurring
//! entries, vendors and customers.
use super::{columns, 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 egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::assets::{accumulated_depreciation, book_value};
use ledger_core::{PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
fn kv(ui: &mut Ui, k: &str, v: &str) {
let p = pal(ui.ctx());
// 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));
widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
ui.add_space(4.0);
return;
}
ui.horizontal(|ui| {
let kw = (ui.available_width() * 0.4).min(180.0);
let (r, _) = ui.allocate_exact_size(vec2(kw, 20.0), Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
k,
theme::regular(13.0),
p.muted,
);
widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
});
}
/// Who decided a setting and when, under its value.
fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
let p = pal(ui.ctx());
ui.label(
RichText::new(format!("{by} · {}", fmt::date(on)))
.font(theme::regular(11.0))
.color(p.muted),
);
}
/// How the books are kept, as the owners decided: conventions, decisions,
/// and what is open (each only when there is something to show).
fn kept_cards(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let kept = b.ledger.conventions();
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
if !conventions.is_empty() {
ui.add_space(12.0);
widgets::titled_card(
ui,
"How the books are kept",
|_| {},
|ui| {
for c in &conventions {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&c.title).font(theme::semibold(13.0)));
widgets::pill(ui, &c.kind.key().replace('_', " "), p.accent, p.accent_soft);
});
widgets::copyable(
ui,
RichText::new(c.summary()).font(theme::regular(12.5)),
&c.body_md,
);
provenance(ui, &c.source.decided_by, c.source.decided_on);
ui.add_space(6.0);
}
},
);
}
let decisions: Vec<_> = kept
.decisions()
.filter(|d| d.superseded_by.is_none())
.collect();
if !decisions.is_empty() {
ui.add_space(12.0);
widgets::titled_card(
ui,
"Decisions",
|_| {},
|ui| {
for d in decisions.iter().rev() {
ui.label(RichText::new(&d.new.subject).font(theme::semibold(13.0)));
widgets::copyable(
ui,
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
&d.new.decision_md,
);
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
ui.add_space(6.0);
}
},
);
}
let open: Vec<_> = kept.open_items().filter(|i| i.resolved.is_none()).collect();
if !open.is_empty() {
ui.add_space(12.0);
widgets::titled_card(
ui,
"Open items",
|_| {},
|ui| {
for i in open {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(icon::FLAG).color(p.warning));
ui.label(RichText::new(&i.new.title).font(theme::regular(13.0)));
if let Some(due) = i.new.due {
// Late says so in words, not only in colour.
let late = due < b.today;
let when = if late { "overdue since" } else { "due" };
ui.label(
RichText::new(format!("{when} {}", fmt::date(due)))
.font(theme::regular(12.0))
.color(if late { p.warning } else { p.muted }),
);
}
});
}
},
);
}
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "company", |ui| {
let c = &b.settings.company;
widgets::page_header(
ui,
&c.name,
"The company's profile and the standing parts of its books.",
);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Profile",
|_| {},
|ui| {
let kept = b.ledger.settings();
// The currency is always known: facts are the rest.
if c.profile.iter().all(|(k, _)| k == "base_currency") {
ui.label(
RichText::new(if kept.is_journaled() {
"No company facts set yet: the agent records them as you give them."
} else {
"No company facts in the settings file yet: the agent records them as you give them."
})
.color(p.muted),
);
}
for (k, v) in &c.profile {
kv(ui, &fmt::label(k), v);
// Who set it, and when (settings kept in the
// books carry their source).
if let Some(r) = kept.get(&format!("company.{k}")) {
provenance(ui, &r.source.decided_by, r.source.decided_on);
}
}
kv(ui, "Accounting basis", &c.basis);
kv(
ui,
"Tax return",
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
);
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
if let Some(z) = &b.settings.timezone {
kv(ui, "Time zone", z);
}
ui.label(
RichText::new(if kept.is_journaled() {
"Kept in the books: the agent changes settings, with who decided and when."
} else {
"Read from the settings file: the agent can move it into the books."
})
.font(theme::regular(12.0))
.color(p.muted),
);
},
);
kept_cards(ui, b);
}
_ => {
widgets::titled_card(
ui,
"Owners",
|_| {},
|ui| {
if c.members.is_empty() {
// A single owner or a corporation: equity by account.
for a in b
.ledger
.chart()
.all()
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
{
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!("{} {}", a.code, a.name))
.font(theme::regular(13.0)),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::accounting(
b.balance_on(a.id, b.today),
))
.font(theme::semibold(13.0)),
);
},
);
});
}
}
for m in &c.members {
let cap = b.ledger.chart().find_by_code(&m.capital_account);
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
widgets::pill(
ui,
&format!("{}%", (m.share * Decimal::from(100)).normalize()),
p.accent,
p.accent_soft,
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::dollars(bal))
.font(theme::semibold(13.5)),
);
},
);
});
if let Some(a) = cap {
ui.label(
RichText::new(format!("Capital account {} {}", a.code, a.name))
.font(theme::regular(12.0))
.color(p.muted),
);
}
ui.add_space(6.0);
}
},
);
ui.add_space(12.0);
widgets::titled_card(
ui,
"Periods",
|_| {},
|ui| {
let periods = b.ledger.list_periods();
if periods.is_empty() {
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
}
for per in periods {
ui.horizontal_wrapped(|ui| {
let (g, color) = match per.status {
PeriodStatus::Closed => (icon::LOCK, p.muted),
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
};
ui.label(RichText::new(g).color(color));
ui.label(RichText::new(&per.name).font(theme::semibold(13.0)));
ui.label(
RichText::new(format!(
"{} – {}",
fmt::date(per.start),
fmt::date(per.end)
))
.color(p.muted),
);
if let Some(e) = per.closing_entry
&& widgets::link(
ui,
RichText::new(format!("closing entry #{e}"))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(e));
}
});
}
},
);
}
});
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, shell);
});
}
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let assets: Vec<_> = b.ledger.registered_assets().collect();
if assets.is_empty() {
return;
}
let mut go = None;
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
// sheet; the schedule may run ahead of the books.
let acc: Decimal = b
.journal
.iter()
.filter(|l| {
l.line_type == "asset.depreciation_posted"
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
})
.filter_map(|l| {
l.payload
.get("amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Decimal>().ok())
})
.sum();
let bv = a.cost.amount - acc;
let scheduled = accumulated_depreciation(a, b.today);
let _ = book_value;
let dep = a.cost.amount - a.salvage_value.amount;
let frac: f32 = if dep.is_zero() {
1.0
} else {
(acc / dep).try_into().unwrap_or(0.0)
};
ui.horizontal_wrapped(|ui| {
if widgets::link(
ui,
RichText::new(b.asset_label(a.id)).font(theme::semibold(13.5)),
)
.clicked()
{
go = Some(a.id);
}
ui.label(
RichText::new(format!(
"bought {} · {} months · {}",
fmt::date(a.purchase_date),
a.useful_life_months,
fmt::words(&format!("{:?}", a.status)).to_lowercase()
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!("book value {}", fmt::dollars(bv)))
.font(theme::semibold(13.0)),
);
});
});
let behind = scheduled - acc;
let note = if behind > Decimal::ZERO {
format!(" · {} more scheduled through today", fmt::dollars(behind))
} else {
String::new()
};
progress(
ui,
frac,
&format!(
"{} of {} depreciated{note}",
fmt::dollars(acc),
fmt::dollars(dep)
),
);
ui.add_space(8.0);
}
},
);
if let Some(id) = go {
shell.go(Route::Asset(id));
}
if register {
shell.go(Route::Assets);
}
ui.add_space(16.0);
}
fn progress(ui: &mut Ui, frac: f32, label: &str) {
let p = pal(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
let mut done = r;
done.set_width(r.width() * frac.clamp(0.0, 1.0));
ui.painter()
.rect_filled(done, CornerRadius::same(4), p.accent);
ui.label(
RichText::new(label)
.font(theme::regular(11.5))
.color(p.faint),
);
}
fn schedules(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.schedules().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Amortization schedules",
|_| {},
|ui| {
for s in list {
let total = s.total.amount;
// A schedule an earlier version journaled may not compute
// (months past chrono's last day): said, never guessed.
let (remaining, due) = match (s.remaining(), s.due(b.today)) {
(Ok(r), Ok(d)) => (r, d),
(Err(e), _) | (_, Err(e)) => {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!("Its plan can't be computed: {e}."))
.font(theme::regular(12.0))
.color(p.negative),
);
ui.add_space(8.0);
continue;
}
};
let recognized = total.checked_sub(remaining);
let frac: f32 = match recognized {
_ if total.is_zero() => 1.0,
Some(r) => r
.checked_div(total)
.and_then(|x| x.try_into().ok())
.unwrap_or(0.0),
None => 0.0,
};
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {} months from {}",
fmt::words(&format!("{:?}", s.kind)),
s.months,
fmt::month(s.start)
))
.font(theme::regular(12.0))
.color(p.muted),
);
if !due.is_empty() {
widgets::pill(ui, &format!("{} due", due.len()), p.warning, p.warning_soft);
}
});
progress(
ui,
frac,
&format!(
"{} of {} recognized · {} left",
fmt::dollars_or(recognized),
fmt::dollars(total),
fmt::dollars(remaining)
),
);
ui.add_space(8.0);
}
},
);
ui.add_space(16.0);
}
fn recurring(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.recurring_entries().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Recurring entries",
|_| {},
|ui| {
for r in list {
let amount = r
.lines
.iter()
.filter(|(_, s, _)| *s == ledger_core::Side::Debit)
.try_fold(Decimal::ZERO, |t, (_, _, m)| t.checked_add(m.amount));
let next = (0..)
.map_while(|k| {
r.frequency
.nth(r.start, k)
.filter(|d| r.end.is_none_or(|e| *d <= e))
})
.find(|d| !r.generated.contains(d));
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {}",
fmt::words(&format!("{:?}", r.frequency)),
fmt::dollars_or(amount)
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let text = match next {
Some(d) if d <= b.today => format!("due {}", fmt::date(d)),
Some(d) => format!("next {}", fmt::date(d)),
None => "ended".into(),
};
ui.label(RichText::new(text).font(theme::regular(12.5)).color(
if next.is_some_and(|d| d <= b.today) {
p.warning
} else {
p.muted
},
));
});
});
ui.label(
RichText::new(format!(
"{} generated so far · drafts only; each one is posted with its receipt",
r.generated.len()
))
.font(theme::regular(11.5))
.color(p.faint),
);
ui.add_space(6.0);
}
},
);
ui.add_space(16.0);
}
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).ok();
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Vendors",
|_| {},
|ui| {
let vendors = b.ledger.list_vendors();
if vendors.is_empty() {
ui.label(RichText::new("No vendors yet.").color(p.muted));
}
for v in vendors {
let paid = r1099
.iter()
.flat_map(|r| &r.vendors)
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
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 {
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
} else {
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
}
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !paid.is_zero() {
ui.label(
RichText::new(format!(
"{} paid in {year}",
fmt::dollars(paid)
))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
_ => {
widgets::titled_card(
ui,
"Customers",
|_| {},
|ui| {
let customers = b.ledger.list_customers();
if customers.is_empty() {
ui.label(RichText::new("No customers yet.").color(p.muted));
}
for c in customers {
// None: a balance, or their sum, can't be computed.
let owed = (1..b.ledger.next_invoice_id())
.filter_map(|i| b.ledger.get_invoice(i))
.filter(|i| i.customer_id == c.id)
.try_fold(Decimal::ZERO, |t, i| t.checked_add(i.balance().ok()?));
ui.horizontal_wrapped(|ui| {
name(ui, &c.name, b.ledger.party_of_customer(c.id));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !owed.is_some_and(|o| o.is_zero()) {
ui.label(
RichText::new(format!(
"owes {}",
fmt::dollars_or(owed)
))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
});
if let Some(r) = go {
shell.go(r);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{Build, beside, cents, d, find, has, render_all};
use ledger_core::arap::DocumentLine;
use ledger_core::{AccountType, Currency, Frequency, Money, NewRecurring, NewSchedule, Side};
/// The company page: each amortization schedule's recognized share as
/// a bar (three of twelve months posted: a quarter), each recurring
/// entry, and each customer with what they owe, theirs alone.
#[test]
fn the_company_page_says_what_each_record_holds() {
let mut b = Build::new("company");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
let prepaid =
b.s.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
let insurance =
b.s.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
b.post(d(2026, 1, 2), prepaid, cash, 120_000);
let usd = |c: i64| Money::new(cents(c), Currency::USD);
b.s.register_schedule(NewSchedule {
description: "Insurance plan".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd(120_000),
start: d(2026, 1, 1),
months: 12,
balance_account: prepaid,
pl_account: insurance,
})
.unwrap();
b.s.run_schedules(d(2026, 3, 31)).unwrap();
b.s.register_recurring(NewRecurring {
description: "Office rent".into(),
lines: vec![
(rent, Side::Debit, usd(50_000)),
(cash, Side::Credit, usd(50_000)),
],
frequency: Frequency::Monthly,
start: d(2026, 1, 20),
end: None,
})
.unwrap();
let line = |c: i64| {
vec![DocumentLine {
description: "work".into(),
amount: cents(c),
}]
};
let acme = b.s.add_customer("Acme").unwrap();
let beta = b.s.add_customer("Beta").unwrap();
b.s.add_customer("Gamma").unwrap();
b.s.create_invoice(acme, d(2026, 9, 1), d(2026, 10, 1), line(120_000))
.unwrap();
b.s.create_invoice(beta, d(2026, 9, 1), d(2026, 10, 1), line(20_000))
.unwrap();
let (_dir, books) = b.books();
let (texts, rects, p) = render_all(books, Route::Company, |_| {});
find(&texts, "Amortization schedules");
let label = find(
&texts,
"$300.00 of $1,200.00 recognized \u{b7} $900.00 left",
);
// The bar just above it: the track, and the part done.
let near = |fill: egui::Color32| {
rects
.iter()
.filter(|(r, f)| {
*f == fill
&& r.height() <= 8.5
&& (label.pos.y - r.max.y) >= 0.0
&& (label.pos.y - r.max.y) < 12.0
})
.map(|(r, _)| r.width())
.fold(0.0f32, f32::max)
};
let (track, done) = (near(p.hover), near(p.accent));
assert!(track > 100.0, "track {track}");
assert!((done / track - 0.25).abs() < 0.01, "{done} of {track}");
find(&texts, "Recurring entries");
find(&texts, "Office rent");
find(&texts, "Vendors");
find(&texts, "Customers");
assert_eq!(beside(&texts, "Acme").text, "owes $1,200.00");
assert_eq!(beside(&texts, "Beta").text, "owes $200.00");
assert!(!has(&texts, "owes $0.00"));
}
}
+589
View File
@@ -0,0 +1,589 @@
//! 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;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::receipts::State as Render;
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, Subject};
#[derive(Default)]
pub struct State {
pub category: Option<String>,
pub show_withdrawn: bool,
pub query: String,
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
match selected.and_then(|id| b.attachments.iter().find(|a| a.id == id)) {
Some(a) => super::split(
ui,
"documents",
(ui.available_width() * 0.5).max(460.0),
shell,
|ui, shell| grid(ui, b, Some(a.id), shell),
|ui, shell| detail(ui, b, a, shell),
),
None => grid(ui, b, None, shell),
}
}
fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
page(ui, "documents", |ui| {
let current = b
.attachments
.iter()
.filter(|a| a.withdrawn_at.is_none())
.count();
widgets::page_header(
ui,
"Documents",
&format!(
"{} on file: receipts that prove entries, and the company's records. Bytes are content-addressed and verified on every read.",
fmt::count(current, "document", "documents")
),
);
let mut cats: Vec<String> = b.attachments.iter().map(|a| a.category.clone()).collect();
cats.sort();
cats.dedup();
let st = &mut shell.state.documents;
ui.horizontal_wrapped(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
.desired_width(ui.available_width().min(260.0)),
);
if ui.selectable_label(st.category.is_none(), "All").clicked() {
st.category = None;
}
for c in &cats {
if ui
.selectable_label(st.category.as_deref() == Some(c.as_str()), fmt::label(c))
.clicked()
{
st.category = Some(c.clone());
}
}
ui.checkbox(&mut st.show_withdrawn, "Show withdrawn");
});
ui.add_space(12.0);
let q = st.query.to_lowercase();
let docs: Vec<&Attachment> = b
.attachments
.iter()
.rev()
.filter(|a| st.show_withdrawn || a.withdrawn_at.is_none())
.filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c))
.filter(|a| {
q.is_empty()
|| a.file_name.to_lowercase().contains(&q)
|| a.description
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&q)
})
.collect();
if docs.is_empty() {
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
return;
}
// As many 200-point tiles as fit, stretched to fill the row.
let per_row = widgets::fit_columns(ui.available_width(), 200.0, 16.0, usize::MAX);
let tile_w =
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
let mut go = None;
for row in docs.chunks(per_row) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
for a in row {
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
go = Some(a.id);
}
}
});
ui.add_space(16.0);
}
if let Some(id) = go {
shell.go(Route::Document(id));
}
});
}
fn tile(
ui: &mut Ui,
b: &Books,
a: &Attachment,
w: f32,
on: bool,
shell: &mut Shell<'_>,
) -> egui::Response {
let p = pal(ui.ctx());
let h = 250.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click());
let fill = if on { p.selected } else { p.surface };
ui.painter().rect(
rect,
CornerRadius::same(10),
fill,
Stroke::new(1.0, if resp.hovered() { p.accent } else { p.border }),
egui::StrokeKind::Inside,
);
let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0));
ui.painter()
.rect_filled(thumb, CornerRadius::same(6), p.hover);
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
Render::Ready(pages) => {
if let Some(tex) = pages.first() {
// Top of the first page, filling the width.
let size = tex.size_vec2();
let scale = thumb.width() / size.x;
let visible_h = (thumb.height() / scale).min(size.y);
let uv = egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, visible_h / size.y),
);
let img_rect =
egui::Rect::from_min_size(thumb.min, vec2(thumb.width(), visible_h * scale));
ui.painter()
.image(tex.id(), img_rect, uv, egui::Color32::WHITE);
}
}
Render::Loading => {
ui.painter().text(
thumb.center(),
egui::Align2::CENTER_CENTER,
"…",
theme::regular(16.0),
p.faint,
);
}
Render::Failed(_) => {
ui.painter().text(
thumb.center(),
egui::Align2::CENTER_CENTER,
icon::FILE,
egui::FontId::proportional(28.0),
p.faint,
);
}
}
// Title: what it proves, else what it is.
let title = a
.entry_id
.and_then(|e| b.entry(e))
.map(|e| e.description.clone())
.or_else(|| a.description.clone())
.unwrap_or_else(|| a.file_name.clone());
let max_chars = ((w - 20.0) / 7.2) as usize;
let title = if title.chars().count() > max_chars {
title.chars().take(max_chars - 1).collect::<String>() + "…"
} else {
title
};
ui.painter().text(
egui::pos2(rect.left() + 10.0, thumb.bottom() + 18.0),
egui::Align2::LEFT_CENTER,
title,
theme::semibold(12.5),
p.text,
);
let sub = match (a.entry_id, &a.withdrawn_at) {
(_, Some(_)) => format!("Withdrawn · {}", a.file_name),
(Some(e), None) => match b.entry(e) {
Some(x) => format!(
"#{e} · {} · {}",
fmt::date_short(x.date),
fmt::dollars_or(x.amount)
),
None => format!("#{e}"),
},
(None, None) => format!("{} · {}", fmt::label(&a.category), a.file_name),
};
let sub = if sub.chars().count() > max_chars + 4 {
sub.chars().take(max_chars + 3).collect::<String>() + "…"
} else {
sub
};
ui.painter().text(
egui::pos2(rect.left() + 10.0, thumb.bottom() + 40.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(11.5),
if a.withdrawn_at.is_some() {
p.warning
} else {
p.muted
},
);
resp
}
fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
egui::ScrollArea::vertical()
.id_salt(("doc", a.id))
.auto_shrink(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("Document {}", a.id))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
.clicked()
{
shell.go(Route::Documents);
}
});
});
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)
.spacing(vec2(20.0, 6.0))
.show(ui, |ui| {
let k = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
};
k(ui, "Category");
ui.label(fmt::label(&a.category));
ui.end_row();
k(ui, "Type");
ui.label(format!("{} · {}", a.mime_type, fmt::bytes(a.size_bytes)));
ui.end_row();
k(ui, "Filed");
ui.label(fmt::timestamp(&a.created_at));
ui.end_row();
k(ui, "SHA-256");
widgets::copyable(
ui,
RichText::new(&a.blob_sha).monospace().size(11.5),
&a.blob_sha,
);
ui.end_row();
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);
ui.label(RichText::new(d).color(p.muted));
}
if a.withdrawn_at.is_some() {
ui.add_space(6.0);
widgets::pill(
ui,
&format!("Withdrawn: {}", a.withdrawn_reason.as_deref().unwrap_or("")),
p.warning,
p.warning_soft,
);
}
});
}
/// 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_or(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),
);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{Build, d, find, render};
/// A document's page lists what it is linked to: an entry with its date
/// and amount.
#[test]
fn a_document_lists_the_entry_it_is_linked_to() {
let mut b = Build::new("document-links");
let (cash, capital) = (b.cash, b.capital);
let e = b.post(d(2026, 1, 2), cash, capital, 100_000);
let (att, _, _) =
b.s.file_document("other", "note.pdf", "application/pdf", b"%PDF note", None)
.unwrap();
b.s.link_attachment(att, ledger_store::Subject::Entry(e))
.unwrap();
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Document(att), |_| {});
find(&texts, "LINKED TO");
find(&texts, "Jan 2, 2026 \u{b7} $1,000.00");
}
}
+578
View File
@@ -0,0 +1,578 @@
//! One entry, completely: what it is, every line, the receipt itself, and
//! every journal event that touched it.
use crate::app::Shell;
use crate::books::{Books, EntryInfo};
use crate::fmt;
use crate::nav::Route;
use crate::receipts::State as Render;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{EntryStatus, Side};
use ledger_store::Attachment;
use rust_decimal::Decimal;
pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(entry) = b.ledger.get_entry(e.id) else {
return;
};
egui::ScrollArea::vertical()
.id_salt(("entry", e.id))
.auto_shrink(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
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
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
.on_hover_text("Close")
.clicked()
{
shell.go(Route::Transactions);
}
});
});
ui.label(RichText::new(&e.description).font(theme::bold(21.0)));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
match e.status {
EntryStatus::Posted => widgets::pill(ui, "Posted", p.positive, p.positive_soft),
EntryStatus::Draft => {
widgets::pill(ui, "Draft — not in any report", p.warning, p.warning_soft)
}
};
widgets::pill(ui, e.origin, p.muted, p.hover);
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 {
widgets::pill(ui, "Missing receipt", p.negative, p.negative_soft);
}
});
if let Some(o) = e.reversal_of {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!("{} Reverses #{o}", icon::ARROW_COUNTER_CLOCKWISE))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(o));
}
}
if let Some(r) = e.reversed_by {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!(
"{} Reversed by #{r}",
icon::ARROW_COUNTER_CLOCKWISE
))
.color(p.accent),
)
.clicked()
{
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.
let created = e.events.first().map(|&i| fmt::timestamp(&b.journal[i].ts));
// Posted by entry.posted, or in one step by whatever created it
// (a schedule, a close, a bill).
let posted = e
.events
.iter()
.find(|&&i| b.journal[i].line_type == "entry.posted")
.or_else(|| e.events.first())
.map(|&i| fmt::timestamp(&b.journal[i].ts));
egui::Grid::new(("facts", e.id))
.num_columns(2)
.spacing(vec2(24.0, 6.0))
.show(ui, |ui| {
let k = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
};
k(ui, "Date");
ui.label(fmt::date(e.date));
ui.end_row();
k(ui, "Amount");
widgets::copyable(
ui,
RichText::new(fmt::dollars_or(e.amount)).font(theme::semibold(14.0)),
&fmt::plain(&fmt::dollars_or(e.amount)).unwrap_or_default(),
);
ui.end_row();
if let Some(c) = &created {
k(ui, "Recorded");
ui.add(egui::Label::new(c).wrap());
ui.end_row();
}
if e.status == EntryStatus::Posted
&& let Some(t) = &posted
{
k(ui, "Posted");
ui.add(egui::Label::new(t).wrap());
ui.end_row();
}
});
ui.add_space(16.0);
// Lines.
section(ui, "Lines");
let total_w = ui.available_width();
let amt_w = ((total_w - 26.0) * 0.25).clamp(64.0, 104.0);
let class_names: std::collections::HashMap<u32, String> = b
.ledger
.list_classes()
.into_iter()
.map(|c| (c.id, c.name.clone()))
.collect();
egui::Frame::new()
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(10))
.inner_margin(Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(total_w - 26.0);
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
"Account",
theme::regular(12.0),
p.muted,
);
widgets::amount_cell(ui, "Debit", amt_w, p.muted, false, false);
widgets::amount_cell(ui, "Credit", amt_w, p.muted, false, false);
});
// None: past exact arithmetic (a draft line an earlier
// version journaled, of any size: rules.md B-28).
let (mut dr, mut cr) = (Some(Decimal::ZERO), Some(Decimal::ZERO));
for line in entry.lines() {
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
ui.allocate_ui(vec2(w, 22.0), |ui| {
ui.set_width(w);
ui.horizontal(|ui| {
let r = widgets::link_truncated(
ui,
RichText::new(b.account_label(line.account))
.font(theme::regular(13.5)),
);
if r.clicked() {
shell.go(Route::Account(line.account));
}
if let Some(c) = line.class_id.and_then(|c| class_names.get(&c))
{
ui.label(
RichText::new(format!("{} {c}", icon::TAG))
.font(theme::regular(11.5))
.color(p.muted),
);
}
if let (Some(cur), Some(fa)) =
(&line.foreign_currency, line.foreign_amount)
{
ui.label(
RichText::new(format!(
"{} {}",
fmt::money(fa),
cur.code()
))
.font(theme::regular(11.5))
.color(p.muted),
);
}
});
});
let a = fmt::money(line.amount.amount);
let (d, c) = match line.side {
Side::Debit => {
dr = dr.and_then(|t| t.checked_add(line.amount.amount));
(a, String::new())
}
Side::Credit => {
cr = cr.and_then(|t| t.checked_add(line.amount.amount));
(String::new(), a)
}
};
widgets::amount_cell(ui, &d, amt_w, p.text, false, false);
widgets::amount_cell(ui, &c, amt_w, p.text, false, false);
});
}
widgets::rule(ui);
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
let totalled = dr.is_some() && cr.is_some();
let balanced = totalled && dr == cr;
let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), egui::Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
if balanced {
format!("{} Balanced", icon::CHECK)
} else if totalled {
"Out of balance".into()
} else {
"Too large to total".into()
},
theme::regular(12.0),
if balanced { p.positive } else { p.negative },
);
widgets::amount_cell(ui, &fmt::money_or(dr), amt_w, p.text, true, false);
widgets::amount_cell(ui, &fmt::money_or(cr), amt_w, p.text, true, false);
});
});
ui.add_space(18.0);
// 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)
|| (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 and documents"
},
);
if all_for_entry.is_empty() {
let msg = if e.missing_receipt {
format!("None attached, and {}.", b.receipt_rule())
} else {
"None attached.".to_string()
};
ui.label(RichText::new(msg).color(if e.missing_receipt {
p.negative
} else {
p.muted
}));
}
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);
}
ui.add_space(12.0);
// History.
section(ui, "History");
for &i in &e.events {
let line = &b.journal[i];
let ev = super::journal::describe_any(b, line);
ui.horizontal_top(|ui| {
ui.label(RichText::new(ev.icon).color(p.muted));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let r = ui.add(
egui::Label::new(RichText::new(&ev.text).font(theme::regular(12.5)))
.wrap()
.sense(egui::Sense::click()),
);
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
ui.label(
RichText::new(format!(
"event {} · {}",
line.seq,
fmt::timestamp(&line.ts)
))
.font(theme::regular(11.0))
.color(p.faint),
);
});
});
ui.add_space(4.0);
}
});
}
pub fn section(ui: &mut Ui, title: &str) {
let p = pal(ui.ctx());
ui.label(
RichText::new(title.to_uppercase())
.font(theme::semibold(11.5))
.color(p.faint),
);
ui.add_space(6.0);
}
/// A receipt or document: its name and facts, then the file itself.
pub fn attachment_view(
ui: &mut Ui,
b: &Books,
a: &Attachment,
shell: &mut Shell<'_>,
link_to_doc: bool,
) {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.label(
RichText::new(if a.mime_type == "application/pdf" {
icon::FILE_PDF
} else {
icon::FILE_IMAGE
})
.size(18.0)
.color(p.muted),
);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
let name = RichText::new(&a.file_name).font(theme::semibold(13.0));
if link_to_doc {
if widgets::link(ui, name).clicked() {
shell.go(Route::Document(a.id));
}
} else {
ui.label(name);
}
ui.label(
RichText::new(format!(
"{} · {} · filed {}",
fmt::label(&a.category),
fmt::bytes(a.size_bytes),
fmt::timestamp(&a.created_at)
))
.font(theme::regular(11.5))
.color(p.muted),
);
});
});
if let Some(when) = &a.withdrawn_at {
widgets::pill(
ui,
&format!(
"Withdrawn {}: {}",
fmt::timestamp(when),
a.withdrawn_reason.as_deref().unwrap_or("")
),
p.warning,
p.warning_soft,
);
}
if let Some(d) = &a.description {
ui.label(RichText::new(d).font(theme::regular(12.5)).color(p.muted));
}
ui.add_space(6.0);
render(ui, b, a, shell, ui.available_width());
}
/// The file itself, pages stacked, scaled to `width`.
pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, width: f32) {
let p = pal(ui.ctx());
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
Render::Loading => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("Rendering…").color(p.muted));
});
}
Render::Failed(err) => {
ui.label(
RichText::new(format!("{} Can't show this file: {err}", icon::WARNING))
.color(p.negative),
);
}
Render::Ready(pages) => {
for (i, tex) in pages.iter().enumerate() {
let size = tex.size_vec2();
let scale = (width / size.x).min(1.0);
let img = egui::Image::new(tex)
.fit_to_exact_size(size * scale)
.corner_radius(CornerRadius::same(4));
egui::Frame::new()
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(6))
.show(ui, |ui| {
ui.add(img);
});
if pages.len() > 1 {
ui.label(
RichText::new(format!("Page {} of {}", i + 1, pages.len()))
.font(theme::regular(11.0))
.color(p.faint),
);
}
ui.add_space(6.0);
}
}
}
}
/// 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(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{Build, d, find, old_books, render};
/// An entry's totals line: "Balanced" when its debits equal its
/// credits, "Out of balance" when they don't, "Too large to total"
/// when a side is past exact arithmetic (a draft an earlier version
/// journaled); its totals right-aligned under the lines' columns.
#[test]
fn an_entrys_totals_say_whether_it_balances() {
let mut b = Build::new("entry-totals");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
let _ = rent;
let posted = b.post(d(2026, 1, 2), cash, capital, 100_000);
let usd = |c: i64| ledger_core::Money::new(Decimal::new(c, 2), ledger_core::Currency::USD);
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Entry(posted), |_| {});
assert_eq!(
find(&texts, &format!("{} Balanced", icon::CHECK)).color,
p.positive
);
// Each column's amounts end at one edge: the lines' and the total's.
let mut edges: Vec<i32> = texts
.iter()
.filter(|t| t.text == fmt::money(Decimal::new(100_000, 2)))
.map(|t| (t.pos.x + t.width).round() as i32)
.collect();
assert_eq!(edges.len(), 4, "two lines and two totals");
edges.sort_unstable();
edges.dedup();
assert_eq!(edges.len(), 2, "two columns: {edges:?}");
let mut b = Build::new("entry-unbalanced");
let (cash, rent) = (b.cash, b.rent);
let draft = b.s.create_entry(d(2026, 3, 1), "half").unwrap();
b.s.add_line(draft, rent, Side::Debit, usd(1_000)).unwrap();
b.s.add_line(draft, cash, Side::Credit, usd(500)).unwrap();
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Entry(draft), |_| {});
assert_eq!(find(&texts, "Out of balance").color, p.negative);
// Its debits past exact arithmetic, its credits $1.00.
let (_dir, books) = old_books("x_draft_max_lines");
let (texts, p) = render(books, Route::Entry(1), |_| {});
assert_eq!(find(&texts, "Too large to total").color, p.negative);
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 384 files, more files were not shown because too many files have changed in this diff. Show more