From 3f20df970030889014e98e22c05024e01505fc5c Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 14 Oct 2026 10:13:05 -0600 Subject: [PATCH] config, mcp, gui: specs from sources, spec-blind suites, and the bugs they found 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 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- .github/workflows/scheduled.yml | 2 +- Cargo.lock | 243 +- crates/ledger-config/Cargo.toml | 5 + crates/ledger-config/src/lib.rs | 217 +- crates/ledger-config/src/receipt_policy.rs | 224 ++ crates/ledger-config/tests/spec_config.rs | 1127 ++++++++++ crates/ledger-core/src/classes.rs | 4 +- crates/ledger-core/src/lib.rs | 2 +- crates/ledger-core/src/reports.rs | 21 +- crates/ledger-core/tests/invariants.rs | 4 +- crates/ledger-core/tests/properties.rs | 6 +- crates/ledger-core/tests/spec_reports.rs | 8 +- crates/ledger-core/tests/statements.rs | 4 +- crates/ledger-gui/src/attention.rs | 28 +- crates/ledger-gui/src/books.rs | 35 +- crates/ledger-gui/src/charts.rs | 7 + crates/ledger-gui/src/export.rs | 66 +- crates/ledger-gui/src/fmt.rs | 65 +- crates/ledger-gui/src/nav.rs | 11 + crates/ledger-gui/src/palette.rs | 12 + crates/ledger-mcp/Cargo.toml | 6 +- crates/ledger-mcp/src/main.rs | 22 +- crates/ledger-mcp/src/policy.rs | 194 +- crates/ledger-mcp/src/server.rs | 144 +- crates/ledger-mcp/src/types.rs | 6 +- crates/ledger-mcp/src/wire.rs | 197 ++ crates/ledger-mcp/tests/spec_mcp.rs | 1973 +++++++++++++++++ docs/architecture.md | 4 +- docs/mcp-interface.md | 20 +- docs/spec/sources/file-names.md | 34 + .../spec/sources/irs-entity-classification.md | 52 + docs/spec/sources/jsonrpc-2.0.md | 36 + docs/spec/sources/mcp-tools.md | 47 + docs/testing.md | 2 +- fuzz/Cargo.lock | 52 +- fuzz/Cargo.toml | 10 + fuzz/fuzz_targets/fuzz_config_parse.rs | 33 + fuzz/seeds/fuzz_config_parse/demo | 17 + fuzz/seeds/fuzz_config_parse/empty | 0 fuzz/seeds/fuzz_config_parse/members | 25 + fuzz/seeds/fuzz_config_parse/scorp | 6 + fuzz/seeds/fuzz_config_parse/wanderland | 28 + 42 files changed, 4636 insertions(+), 363 deletions(-) create mode 100644 crates/ledger-config/src/receipt_policy.rs create mode 100644 crates/ledger-config/tests/spec_config.rs create mode 100644 crates/ledger-mcp/src/wire.rs create mode 100644 crates/ledger-mcp/tests/spec_mcp.rs create mode 100644 docs/spec/sources/file-names.md create mode 100644 docs/spec/sources/irs-entity-classification.md create mode 100644 docs/spec/sources/jsonrpc-2.0.md create mode 100644 docs/spec/sources/mcp-tools.md create mode 100644 fuzz/fuzz_targets/fuzz_config_parse.rs create mode 100644 fuzz/seeds/fuzz_config_parse/demo create mode 100644 fuzz/seeds/fuzz_config_parse/empty create mode 100644 fuzz/seeds/fuzz_config_parse/members create mode 100644 fuzz/seeds/fuzz_config_parse/scorp create mode 100644 fuzz/seeds/fuzz_config_parse/wanderland diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index bc496f8..f9a48c9 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_import_csv] + target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_import_csv, fuzz_config_parse] steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v2 diff --git a/Cargo.lock b/Cargo.lock index 22e5d33..f03a0b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,7 +571,7 @@ checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561" dependencies = [ "paste", "roman-numerals-rs", - "strum", + "strum 0.27.2", "unic-langid", "unicode-normalization", "unscanny", @@ -700,6 +700,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" version = "1.8.1" @@ -757,6 +763,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.2" @@ -1373,6 +1385,12 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -1742,6 +1760,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "emath" version = "0.36.2" @@ -1937,6 +1964,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2002,6 +2040,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2092,6 +2141,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2249,9 +2308,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2496,6 +2557,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -3340,6 +3403,59 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.19.2", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "kamadak-exif" version = "0.6.1" @@ -3476,8 +3592,11 @@ version = "0.1.0" dependencies = [ "chrono", "ledger-core", + "proptest", "rust_decimal", "serde", + "serde_json", + "tempfile", "toml 1.1.8+spec-1.1.0", ] @@ -3543,11 +3662,13 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "jsonschema", "ledger-config", "ledger-core", "ledger-import", "ledger-store", "mime_guess", + "proptest", "rmcp", "rust_decimal", "schemars", @@ -3732,6 +3853,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -3921,6 +4048,20 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3931,6 +4072,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3946,6 +4102,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4354,6 +4531,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "page_size" version = "0.6.0" @@ -5122,6 +5305,23 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "referencing" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -5866,7 +6066,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -5881,6 +6090,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -5949,7 +6170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", + "fancy-regex 0.16.2", "flate2", "fnv", "once_cell", @@ -6929,6 +7150,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -7044,6 +7275,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/crates/ledger-config/Cargo.toml b/crates/ledger-config/Cargo.toml index c459de6..067908b 100644 --- a/crates/ledger-config/Cargo.toml +++ b/crates/ledger-config/Cargo.toml @@ -11,7 +11,12 @@ ledger-core = { path = "../ledger-core" } chrono = "0.4" rust_decimal = "1" serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = { version = "1", features = ["preserve_order"] } +[dev-dependencies] +proptest = "1" +tempfile = "3" + [lints] workspace = true diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index be83ee5..40e5f52 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -9,6 +9,9 @@ //! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before //! the next start, so the two can't disagree. +pub mod receipt_policy; +pub use receipt_policy::ReceiptPolicy; + use chrono::{Datelike, NaiveDate}; use rust_decimal::Decimal; use serde::Deserialize; @@ -148,21 +151,44 @@ struct BooksSection { investing_accounts: Vec, } -/// The return a federal tax classification files: "partnership" → 1065, -/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship" -/// or "disregarded entity" → Schedule C. Unrecognized: unknown. +/// The return a federal tax classification files (docs/spec/sources/ +/// irs-entity-classification.md), read by whole words, ignoring case: +/// "partnership" → 1065; "S corporation" (S corp, S-corp, scorp) → +/// 1120-S; "C corporation", or "corporation", "corp", or "association" +/// alone → 1120; "disregarded", "sole proprietor(ship)", or +/// "single-member" → Schedule C. Anything else, or words naming two +/// returns ("corporation or S corporation"), names none. fn form_for_classification(c: &str) -> Option { - let c = c.to_lowercase(); - if c.contains("partnership") { - Some(ledger_core::TaxForm::Form1065) - } else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") { - Some(ledger_core::TaxForm::Form1120S) - } else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") { - Some(ledger_core::TaxForm::Form1120) - } else if c.contains("sole") || c.contains("disregarded") { - Some(ledger_core::TaxForm::ScheduleC) - } else { - None + use ledger_core::TaxForm; + let lower = c.to_lowercase(); + let words: Vec<&str> = lower + .split(|ch: char| !ch.is_alphanumeric()) + .filter(|w| !w.is_empty()) + .collect(); + let has = |w: &str| words.contains(&w); + let mut found = Vec::new(); + if has("partnership") { + found.push(TaxForm::Form1065); + } + for (i, w) in words.iter().enumerate() { + let prev = i.checked_sub(1).map(|p| words[p]); + let form = match *w { + "scorp" => Some(TaxForm::Form1120S), + "ccorp" | "association" => Some(TaxForm::Form1120), + "corp" | "corporation" if prev == Some("s") => Some(TaxForm::Form1120S), + "corp" | "corporation" => Some(TaxForm::Form1120), + _ => None, + }; + found.extend(form); + } + if has("disregarded") || has("sole") || words.windows(2).any(|p| p == ["single", "member"]) { + found.push(TaxForm::ScheduleC); + } + found.sort(); + found.dedup(); + match found.as_slice() { + [one] => Some(*one), + _ => None, } } @@ -180,19 +206,121 @@ fn parse_share(name: &str, s: &str) -> Result { Ok(share) } -/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a -/// day every year has. +/// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the +/// last day of a month: IRS Pub. 538, docs/spec/sources/ +/// irs-periods-and-methods.md). fn parse_month_day(s: &str) -> Result<(u32, u32), String> { let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\""); let (m, d) = s.split_once('-').ok_or_else(bad)?; let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?); - // 2001 is not a leap year: a valid date there exists every year. - NaiveDate::from_ymd_opt(2001, m, d) - .ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?; + if !(1..=12).contains(&m) { + return Err(bad()); + } + if d != 1 { + return Err(format!( + "fiscal_year_start {s:?}: a fiscal year starts on the first of a month (it ends on the last day of one: IRS Pub. 538)" + )); + } Ok((m, d)) } +/// Account codes as written, trimmed; a blank one is refused. +fn codes(key: &str, list: Vec) -> Result, String> { + list.into_iter() + .map(|c| { + let c = c.trim().to_string(); + if c.is_empty() { + Err(format!("{key}: an account code is blank")) + } else { + Ok(c) + } + }) + .collect() +} + +/// A decimal string as the books take one: exactly `-?digits[.digits]`. +/// The decimal parser alone also takes "1_000", "+5", ".5", and +/// exponents ("1e3"); a setting or tool argument never means those. +pub fn decimal(s: &str) -> Option { + let digits = s.strip_prefix('-').unwrap_or(s); + let (whole, frac) = match digits.split_once('.') { + Some((w, f)) => (w, Some(f)), + None => (digits, None), + }; + let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()); + if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) { + return None; + } + s.parse().ok() +} + +/// The receipt policy in force: `config_json`'s `receipt_policy` when the +/// file sets one, else `[books] receipt_threshold`, else the default +/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and +/// the viewer both call this, so they enforce and show one rule. +pub fn effective_receipt_policy( + config_json: &Path, + settings: &BooksSettings, +) -> Result { + #[derive(Deserialize)] + struct ConfigJson { + #[serde(default)] + receipt_policy: Option, + } + let from_json = if config_json.exists() { + let raw = std::fs::read_to_string(config_json) + .map_err(|e| format!("{}: {e}", config_json.display()))?; + serde_json::from_str::(&raw) + .map_err(|e| format!("bad config {}: {e}", config_json.display()))? + .receipt_policy + } else { + None + }; + let policy = match (from_json, &settings.receipt_threshold) { + (Some(p), _) => p, + (None, Some(t)) => ReceiptPolicy { + require_receipts_above: t.clone(), + ..ReceiptPolicy::default() + }, + (None, None) => ReceiptPolicy::default(), + }; + policy.validate()?; + Ok(policy) +} + /// Parse `wanderbooks.toml` text. +/// +/// ```text +/// GOAL: read the company's settings, refusing any the books can't honor +/// rather than guessing. +/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md: +/// a fiscal year ends on the last day of a month); Treas. Reg. +/// §301.7701-3 and the Schedule C instructions +/// (docs/spec/sources/irs-entity-classification.md); IRC §704(a) +/// (shares from the agreement: docs/spec/sources/irs-partnerships.md). +/// INVARIANTS: +/// 1. Every setting is optional; an empty file is a calendar-year, +/// accrual-basis "Unnamed company" with no members. +/// 2. fiscal_year_start MUST be the first of a month ("MM-01"). +/// 3. Members: each MUST have a name and a capital account, names and +/// capital accounts MUST be distinct (and no member's distributions +/// account another's capital), and shares (each in (0, 1], "50%" or +/// "0.5") MUST sum to exactly 1. +/// 4. tax_form wins over tax_classification (which is then not read); +/// a classification is read by whole words, ignoring case, and MUST +/// name exactly one return (source table). tax_form, basis, and +/// classification values ignore case. +/// 5. receipt_threshold MUST be a decimal at least zero. +/// 6. Account codes (cash, financing, investing, close_into, +/// close_sweep, members' capital and distributions) are trimmed; a +/// blank one is refused (a blank distributions account means none). close_into is +/// refused with members (a close goes to their capital accounts). +/// 7. profile holds every simple [company] value in file order: +/// strings as written, numbers, booleans, and dates in TOML's +/// spelling; tables and arrays (members) are left out. +/// 8. NEVER accept a file that would be read differently than written: +/// an unknown basis, tax form, or classification is refused. +/// ``` pub fn parse(text: &str) -> Result { let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?; let mut company = Company::default(); @@ -218,6 +346,30 @@ pub fn parse(text: &str) -> Result { .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 + )); + } + } + } if !company.members.is_empty() { let total: Decimal = company.members.iter().map(|m| m.share).sum(); if total != Decimal::ONE { @@ -229,7 +381,11 @@ pub fn parse(text: &str) -> Result { (Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| { format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120") })?), - (None, Some(c)) => form_for_classification(c), + (None, Some(c)) => Some(form_for_classification(c).ok_or_else(|| { + format!( + "tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)" + ) + })?), (None, None) => None, }; if !company.members.is_empty() && raw.books.close_into.is_some() { @@ -238,8 +394,11 @@ pub fn parse(text: &str) -> Result { .to_string(), ); } - company.close_into = raw.books.close_into.map(|c| c.trim().to_string()); - company.close_sweep = raw.books.close_sweep; + company.close_into = match raw.books.close_into { + Some(c) => Some(codes("close_into", vec![c])?.remove(0)), + None => None, + }; + company.close_sweep = codes("close_sweep", raw.books.close_sweep)?; if let Some(basis) = raw.books.accounting_basis { company.basis = match basis.trim().to_lowercase().as_str() { "accrual" => "Accrual".to_string(), @@ -253,9 +412,11 @@ pub fn parse(text: &str) -> Result { } let receipt_threshold = match raw.books.receipt_threshold { Some(t) => { - t.trim() - .parse::() - .map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?; + let v = decimal(t.trim()) + .ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?; + if v.is_sign_negative() && !v.is_zero() { + return Err(format!("receipt_threshold {t:?} is below zero")); + } Some(t.trim().to_string()) } None => None, @@ -280,9 +441,9 @@ pub fn parse(text: &str) -> Result { Ok(BooksSettings { company, receipt_threshold, - cash_accounts: raw.books.cash_accounts, - financing_accounts: raw.books.financing_accounts, - investing_accounts: raw.books.investing_accounts, + cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?, + financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?, + investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?, }) } diff --git a/crates/ledger-config/src/receipt_policy.rs b/crates/ledger-config/src/receipt_policy.rs new file mode 100644 index 0000000..8f676a7 --- /dev/null +++ b/crates/ledger-config/src/receipt_policy.rs @@ -0,0 +1,224 @@ +//! The receipt policy: server-side enforcement, not prose. +//! +//! Posting an entry whose debits are strictly greater than the configured +//! threshold requires at least one attached receipt, unless every line is in +//! an exempt account. Selected accounts can require receipts regardless of +//! amount. Reversals are exempt: the original entry carries the evidence. +//! +//! ```text +//! GOAL: no entry posts without the document that supports it. +//! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep +//! supporting documents"; they "support the entries in your +//! books"); DECISION (Stephen: the threshold, strictly greater +//! than; Wanderland's is 0.00, every entry, 2026-10-12). +//! INVARIANTS: +//! 1. An entry touching a required account always needs a receipt. +//! 2. Otherwise it needs one exactly when its debits are strictly +//! greater than the threshold and some line is outside the exempt +//! accounts; at threshold 0.00 every entry with debits needs one. +//! 3. Splitting does not dodge it: posted entries the same day with the +//! same description (whitespace and case aside) and the same debit +//! accounts count together (split_group), reversals aside. +//! 4. A threshold that isn't a decimal, or is below zero, is refused at +//! startup (validate). +//! ``` + +use ledger_core::{JournalEntry, Ledger, Side}; +use rust_decimal::Decimal; +use serde::Deserialize; + +/// Server-side receipt policy, loaded from config. +#[derive(Debug, Clone, Deserialize)] +pub struct ReceiptPolicy { + /// Receipts required when an entry's debits are strictly greater than + /// this amount. Decimal string, e.g. `"25.00"`. + #[serde(default = "default_threshold")] + pub require_receipts_above: String, + /// Account codes that always require a receipt, regardless of amount. + #[serde(default)] + pub receipt_required_accounts: Vec, + /// 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, +} + +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 { + entry + .lines() + .iter() + .map(|l| { + ledger + .chart() + .get(l.account) + .map(|a| a.code.clone()) + .unwrap_or_default() + }) + .collect() + } + + /// Total debits of the entry (one side of the balanced entry). + pub fn entry_debits(entry: &JournalEntry) -> Decimal { + entry + .lines() + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount.amount) + .sum() + } + + /// True when posting this entry requires at least one receipt. + pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool { + let codes = self.account_codes(ledger, entry); + let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect(); + self.receipt_required_for(&refs, Self::entry_debits(entry)) + } + + /// True when an entry touching these account codes with this debit total + /// requires at least one receipt. The code-level check behind + /// [`Self::receipt_required`], for postings (like disposals) computed + /// without a draft entry in hand. + pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool { + // Per-account override: any line in a required account → required. + if codes + .iter() + .any(|c| self.receipt_required_accounts.iter().any(|r| r == *c)) + { + return true; + } + // Strictly greater than the threshold (Stephen: ">$25"). + if debits <= self.threshold() { + return false; + } + // Exempt only when every line is in an exempt account. + !codes + .iter() + .all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c)) + } + + /// Human-readable policy summary for previews and errors. + pub fn describe(&self) -> String { + if self.threshold().is_zero() { + "receipts required on every entry".to_string() + } else { + format!( + "receipts required when entry debits are strictly greater than {}", + self.threshold() + ) + } + } + + /// The receipt rule, phrased for the agent's operating instructions. + pub fn rule(&self) -> String { + if self.threshold().is_zero() { + "every entry needs at least one receipt".to_string() + } else { + format!( + "any entry moving more than ${} in debits needs at least one receipt", + self.threshold().round_dp(2) + ) + } + } + + /// Normalize a description for split detection. + fn normalize_description(desc: &str) -> String { + desc.split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() + } + + /// Debit account ids of an entry, sorted. + fn debit_accounts(entry: &JournalEntry) -> Vec { + let mut ids: Vec = 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 { + let debits = Self::entry_debits(entry); + if debits > self.threshold() { + return Vec::new(); // Normal policy already applies. + } + let desc = Self::normalize_description(entry.description()); + let accounts = Self::debit_accounts(entry); + let date = entry.date(); + let mut group = Vec::new(); + let mut total = debits; + for other in ledger.posted_entries() { + if other.id() == entry_id { + continue; + } + if other.date() != date { + continue; + } + if Self::normalize_description(other.description()) != desc { + continue; + } + if Self::debit_accounts(other) != accounts { + continue; + } + // Reversals are exempt from the policy; don't count them. + if other.reversal_of().is_some() { + continue; + } + group.push(other.id()); + total += Self::entry_debits(other); + } + if !group.is_empty() && total > self.threshold() { + group + } else { + Vec::new() + } + } +} diff --git a/crates/ledger-config/tests/spec_config.rs b/crates/ledger-config/tests/spec_config.rs new file mode 100644 index 0000000..f43d31b --- /dev/null +++ b/crates/ledger-config/tests/spec_config.rs @@ -0,0 +1,1127 @@ +//! Spec-blind tests of `ledger_config`: every expected value here comes from +//! the doc comments and the GOAL/SOURCE/INVARIANTS block on `parse`, the +//! public types' docs, `ledger_core::TaxForm`'s docs, and the vendored IRS +//! sources (docs/spec/sources/irs-periods-and-methods.md, +//! irs-entity-classification.md, irs-partnerships.md). Where the spec is +//! silent, nothing is asserted. + +use chrono::{Datelike, Months, NaiveDate}; +use ledger_config::{BooksSettings, load, parse}; +use ledger_core::TaxForm; +use proptest::prelude::*; +use rust_decimal::Decimal; +use std::str::FromStr; + +fn ok(text: &str) -> BooksSettings { + match parse(text) { + Ok(s) => s, + Err(e) => panic!("expected Ok for:\n{text}\ngot Err: {e}"), + } +} + +fn refused(text: &str) { + assert!(parse(text).is_err(), "expected Err for:\n{text}"); +} + +fn dec(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() +} + +fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// `[company]` with one string setting. +fn company_kv(key: &str, value: &str) -> String { + format!("[company]\n{key} = {}\n", toml_str(value)) +} + +/// `[books]` with one string setting. +fn books_kv(key: &str, value: &str) -> String { + format!("[books]\n{key} = {}\n", toml_str(value)) +} + +/// A TOML basic string literal. +fn toml_str(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn toml_array(items: &[String]) -> String { + let parts: Vec = items.iter().map(|s| toml_str(s)).collect(); + format!("[{}]", parts.join(", ")) +} + +/// A members table: (name, share, capital account). +fn members_toml(members: &[(String, String, String)]) -> String { + let mut s = String::from("[company]\nname = \"Co\"\n"); + for (name, share, cap) in members { + s.push_str(&format!( + "\n[[company.members]]\nname = {}\nshare = {}\ncapital_account = {}\n", + toml_str(name), + toml_str(share), + toml_str(cap) + )); + } + s +} + +fn two_members(a_share: &str, b_share: &str) -> String { + members_toml(&[ + ("A".into(), a_share.into(), "3000".into()), + ("B".into(), b_share.into(), "3001".into()), + ]) +} + +fn classification(text: &str) -> Result, String> { + parse(&company_kv("tax_classification", text)).map(|s| s.company.tax_form) +} + +// --------------------------------------------------------------------------- +// INVARIANT 1: every setting optional; empty file defaults. +// --------------------------------------------------------------------------- + +/// INVARIANT 1: an empty file is a calendar-year, accrual-basis +/// "Unnamed company" with no members. +#[test] +fn inv1_empty_file_defaults() { + let s = ok(""); + assert_eq!(s.company.name, "Unnamed company"); + assert_eq!(s.company.fiscal_year_start, (1, 1)); + assert_eq!(s.company.basis, "Accrual"); + assert!(s.company.members.is_empty()); +} + +/// INVARIANT 1: with nothing set, the optional settings are unset (no tax +/// form known, no threshold, no close_into, no account roles). +#[test] +fn inv1_empty_file_leaves_options_unset() { + let s = ok(""); + assert_eq!(s.company.tax_form, None); + assert_eq!(s.company.close_into, None); + assert!(s.company.close_sweep.is_empty()); + assert_eq!(s.receipt_threshold, None); + assert!(s.cash_accounts.is_empty()); + assert!(s.financing_accounts.is_empty()); + assert!(s.investing_accounts.is_empty()); +} + +/// INVARIANT 1: empty `[company]` and `[books]` tables read as the defaults. +#[test] +fn inv1_empty_tables_are_defaults() { + assert_eq!(ok("[company]\n[books]\n"), ok("")); +} + +/// INVARIANT 1: setting only the name leaves the other defaults in place. +#[test] +fn inv1_name_only() { + let s = ok(&company_kv("name", "Acme LLC")); + assert_eq!(s.company.name, "Acme LLC"); + assert_eq!(s.company.fiscal_year_start, (1, 1)); + assert_eq!(s.company.basis, "Accrual"); +} + +/// Company doc: basis is "Accrual" or "Cash"; mcp-interface: the file says +/// "accrual" or "cash". +#[test] +fn basis_accrual_and_cash() { + assert_eq!( + ok(&books_kv("accounting_basis", "accrual")).company.basis, + "Accrual" + ); + assert_eq!( + ok(&books_kv("accounting_basis", "cash")).company.basis, + "Cash" + ); +} + +/// Company doc: `profile` is every simple `[company]` setting, in file +/// order, as written. +#[test] +fn profile_in_file_order_as_written() { + let s = ok("[company]\n\ + name = \"Acme LLC\"\n\ + ein = \"00-0000000\"\n\ + formed = \"2025-06-02\"\n\ + address = \"1 Main St\"\n"); + let want: Vec<(String, String)> = [ + ("name", "Acme LLC"), + ("ein", "00-0000000"), + ("formed", "2025-06-02"), + ("address", "1 Main St"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!(s.company.profile, want); +} + +// --------------------------------------------------------------------------- +// INVARIANT 2: fiscal_year_start is the first of a month. +// --------------------------------------------------------------------------- + +/// INVARIANT 2: every "MM-01" is accepted as (MM, 1). +#[test] +fn inv2_first_of_each_month_accepted() { + for m in 1..=12u32 { + let s = ok(&company_kv("fiscal_year_start", &format!("{m:02}-01"))); + assert_eq!(s.company.fiscal_year_start, (m, 1)); + } +} + +/// INVARIANT 2 (IRS Pub. 538: a fiscal year ends on the last day of a +/// month): any day other than the 1st is refused. +#[test] +fn inv2_not_first_of_month_refused() { + for v in ["01-02", "07-15", "12-31", "02-28", "06-30"] { + refused(&company_kv("fiscal_year_start", v)); + } +} + +/// INVARIANT 2: a month that doesn't exist is refused. +#[test] +fn inv2_bad_month_refused() { + for v in ["00-01", "13-01", "99-01"] { + refused(&company_kv("fiscal_year_start", v)); + } +} + +/// INVARIANT 2 / 7: text that isn't "MM-DD" is refused. +#[test] +fn inv2_garbage_refused() { + for v in ["", "January", "01/01", "2026-01-01", "xx-01"] { + refused(&company_kv("fiscal_year_start", v)); + } +} + +/// fiscal_year_containing doc + Pub. 538: a calendar year is Jan 1 to +/// Dec 31. +#[test] +fn fiscal_year_calendar() { + let c = ok("").company; + assert_eq!( + c.fiscal_year_containing(date(2026, 10, 14)), + (date(2026, 1, 1), date(2026, 12, 31)) + ); + assert_eq!( + c.fiscal_year_containing(date(2026, 1, 1)), + (date(2026, 1, 1), date(2026, 12, 31)) + ); + assert_eq!( + c.fiscal_year_containing(date(2026, 12, 31)), + (date(2026, 1, 1), date(2026, 12, 31)) + ); +} + +/// fiscal_year_containing doc + Pub. 538: a July start runs to June 30 +/// (last day of a month), and a date before July belongs to the year that +/// started the previous July. +#[test] +fn fiscal_year_july_start() { + let c = ok(&company_kv("fiscal_year_start", "07-01")).company; + assert_eq!( + c.fiscal_year_containing(date(2026, 3, 1)), + (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)) + ); +} + +/// A March start ends on the last day of February, Feb 29 in a leap year. +#[test] +fn fiscal_year_march_start_leap() { + let c = ok(&company_kv("fiscal_year_start", "03-01")).company; + assert_eq!( + c.fiscal_year_containing(date(2024, 2, 29)), + (date(2023, 3, 1), date(2024, 2, 29)) + ); + assert_eq!( + c.fiscal_year_containing(date(2025, 2, 1)), + (date(2024, 3, 1), date(2025, 2, 28)) + ); +} + +// --------------------------------------------------------------------------- +// INVARIANT 3: members. +// --------------------------------------------------------------------------- + +/// INVARIANT 3: two members, 50/50, read in order with their accounts. +#[test] +fn inv3_two_members_accepted() { + let s = ok(&two_members("50%", "50%")); + let m = &s.company.members; + assert_eq!(m.len(), 2); + assert_eq!(m[0].name, "A"); + assert_eq!(m[0].share, dec("0.5")); + assert_eq!(m[0].capital_account, "3000"); + assert_eq!(m[0].distribution_account, None); + assert_eq!(m[1].name, "B"); + assert_eq!(m[1].share, dec("0.5")); + assert_eq!(m[1].capital_account, "3001"); +} + +/// INVARIANT 3: shares as "50%" or "0.5" read the same. +#[test] +fn inv3_percent_and_decimal_agree() { + assert_eq!( + ok(&two_members("50%", "50%")), + ok(&two_members("0.5", "0.5")) + ); + assert_eq!( + ok(&two_members("25%", "0.75")).company.members[1].share, + dec("0.75") + ); + assert_eq!( + ok(&two_members("25%", "0.75")).company.members[0].share, + dec("0.25") + ); +} + +/// INVARIANT 3: shares that don't sum to exactly 1 are refused. +#[test] +fn inv3_shares_must_sum_to_one() { + refused(&two_members("50%", "49%")); + refused(&two_members("0.5", "0.51")); + refused(&two_members("0.5", "0.499999")); + refused(&two_members("60%", "60%")); +} + +/// INVARIANT 3: each share must be in (0, 1]: zero, negative, or above 1 +/// is refused even when the sum is 1. +#[test] +fn inv3_share_out_of_range_refused() { + refused(&two_members("0", "1")); + refused(&two_members("0%", "100%")); + refused(&two_members("1.5", "-0.5")); + refused(&two_members("150%", "-50%")); +} + +/// INVARIANT 3: a share of exactly 1 is in (0, 1]. +#[test] +fn inv3_single_member_full_share() { + let s = ok(&members_toml(&[( + "Solo".into(), + "100%".into(), + "3000".into(), + )])); + assert_eq!(s.company.members.len(), 1); + assert_eq!(s.company.members[0].share, Decimal::ONE); +} + +/// INVARIANT 3 / 7: a share that is neither "NN%" nor a decimal is refused. +#[test] +fn inv3_share_garbage_refused() { + refused(&two_members("half", "0.5")); + refused(&two_members("", "1")); +} + +/// INVARIANT 3: a member without a name is refused. +#[test] +fn inv3_member_needs_name() { + refused( + "[[company.members]]\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + ); +} + +/// INVARIANT 3: a member without a capital account is refused. +#[test] +fn inv3_member_needs_capital_account() { + refused( + "[[company.members]]\nname = \"A\"\nshare = \"50%\"\n\ + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + ); +} + +/// INVARIANT 3: member names must be distinct. +#[test] +fn inv3_duplicate_names_refused() { + refused(&members_toml(&[ + ("A".into(), "50%".into(), "3000".into()), + ("A".into(), "50%".into(), "3001".into()), + ])); +} + +/// INVARIANT 3: capital accounts must be distinct. +#[test] +fn inv3_duplicate_capital_accounts_refused() { + refused(&members_toml(&[ + ("A".into(), "50%".into(), "3000".into()), + ("B".into(), "50%".into(), "3000".into()), + ])); +} + +/// INVARIANT 3: no member's distributions account may be another's capital +/// account. +#[test] +fn inv3_distribution_into_other_capital_refused() { + refused( + "[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + distribution_account = \"3001\"\n\ + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + ); +} + +/// INVARIANT 3 + Member doc: a separate distributions account is read. +#[test] +fn inv3_distribution_account_read() { + let s = ok( + "[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + distribution_account = \"3100\"\n\ + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ + distribution_account = \"3101\"\n", + ); + assert_eq!( + s.company.members[0].distribution_account.as_deref(), + Some("3100") + ); + assert_eq!( + s.company.members[1].distribution_account.as_deref(), + Some("3101") + ); +} + +// --------------------------------------------------------------------------- +// INVARIANT 4: tax form and classification. +// --------------------------------------------------------------------------- + +/// INVARIANT 4 + TaxForm::parse doc: tax_form takes "schedule_c", "1065", +/// "1120s", "1120" and the labels. +#[test] +fn inv4_tax_form_values() { + let cases = [ + ("schedule_c", TaxForm::ScheduleC), + ("1065", TaxForm::Form1065), + ("1120s", TaxForm::Form1120S), + ("1120", TaxForm::Form1120), + ("Schedule C", TaxForm::ScheduleC), + ("Form 1065", TaxForm::Form1065), + ("Form 1120-S", TaxForm::Form1120S), + ("Form 1120", TaxForm::Form1120), + ]; + for (text, want) in cases { + let s = ok(&company_kv("tax_form", text)); + assert_eq!(s.company.tax_form, Some(want), "{text}"); + } +} + +/// INVARIANT 4: tax_form wins over tax_classification. +#[test] +fn inv4_tax_form_wins() { + let s = ok("[company]\ntax_form = \"1120s\"\ntax_classification = \"partnership\"\n"); + assert_eq!(s.company.tax_form, Some(TaxForm::Form1120S)); + let s = ok("[company]\ntax_classification = \"partnership\"\ntax_form = \"schedule_c\"\n"); + assert_eq!(s.company.tax_form, Some(TaxForm::ScheduleC)); +} + +/// INVARIANT 4 (source table): each row's phrasings map to its return. +#[test] +fn inv4_classification_table() { + for (text, want) in CLASSIFICATIONS { + assert_eq!(classification(text), Ok(Some(*want)), "{text}"); + } +} + +/// INVARIANT 4: read by whole words, so "business corporation" (which +/// contains the letters "s corporation") is a corporation, Form 1120. +#[test] +fn inv4_business_corporation_is_1120() { + assert_eq!( + classification("business corporation"), + Ok(Some(TaxForm::Form1120)) + ); +} + +/// INVARIANT 4 (whole words): "business corp" is not an S corporation. +#[test] +fn inv4_business_corp_not_s_corp() { + assert_ne!( + classification("business corp"), + Ok(Some(TaxForm::Form1120S)) + ); +} + +/// INVARIANT 4: "corp" alone is in the table's Form 1120 row (with +/// "corporation" and "association"), so "business corp" is Form 1120. +#[test] +fn inv4_bare_corp_is_1120() { + assert_eq!(classification("business corp"), Ok(Some(TaxForm::Form1120))); +} + +/// INVARIANT 4: "corporation" or "association" (Form 1120) beside an S +/// corporation phrase (Form 1120-S) names two returns: refused. +#[test] +fn inv4_corporation_beside_s_corporation_refused() { + for t in [ + "corporation or S corporation", + "S corporation or corporation", + "S corporation, formerly a corporation", + "association or S corp", + "S-corp association", + "scorp corporation", + ] { + assert!(classification(t).is_err(), "{t}"); + } +} + +/// INVARIANT 4 (whole words): "partnerships" is not the word +/// "partnership", and nothing else in the table matches, so it names no +/// return: refused. (A decision from the spec's "read by whole words".) +#[test] +fn inv4_partnerships_plural_refused() { + assert!(classification("partnerships").is_err()); +} + +/// INVARIANT 4: "multi-member LLC" contains no table word ("single-member" +/// is the table's word, and the source's default classification of a +/// multi-member LLC is not something the table reads). +#[test] +fn inv4_multi_member_refused() { + assert!(classification("multi-member LLC").is_err()); +} + +/// INVARIANT 4: two words for the same return still name exactly one. +#[test] +fn inv4_two_words_same_return() { + assert_eq!( + classification("disregarded entity, sole proprietorship"), + Ok(Some(TaxForm::ScheduleC)) + ); +} + +/// INVARIANT 4: a classification naming two returns is refused. +#[test] +fn inv4_two_returns_refused() { + for t in [ + "partnership or S corporation", + "partnership, corporation", + "single-member association", + "S corp or C corporation", + ] { + assert!(classification(t).is_err(), "{t}"); + } +} + +/// INVARIANT 8: an unknown classification is refused. +#[test] +fn inv8_unknown_classification_refused() { + for t in ["nonprofit", "trust", "LLC", "cooperative"] { + assert!(classification(t).is_err(), "{t}"); + } +} + +/// INVARIANT 8: an unknown tax form is refused, even with a valid +/// classification beside it. +#[test] +fn inv8_unknown_tax_form_refused() { + refused(&company_kv("tax_form", "990")); + refused(&company_kv("tax_form", "1040-ES")); + refused("[company]\ntax_form = \"990\"\ntax_classification = \"partnership\"\n"); +} + +/// INVARIANT 8: an unknown accounting basis is refused. +#[test] +fn inv8_unknown_basis_refused() { + for b in ["modified cash", "tax", "", "hybrid"] { + refused(&books_kv("accounting_basis", b)); + } +} + +// --------------------------------------------------------------------------- +// INVARIANT 5: receipt_threshold. +// --------------------------------------------------------------------------- + +/// INVARIANT 5: a decimal at least zero is accepted, zero included. +#[test] +fn inv5_threshold_accepted() { + for t in ["0.00", "0", "25.00", "75", "1000.5"] { + let s = ok(&books_kv("receipt_threshold", t)); + let got = s.receipt_threshold.expect("threshold set"); + assert_eq!(dec(&got), dec(t), "{t}"); + } +} + +/// INVARIANT 5: a negative threshold is refused. +#[test] +fn inv5_negative_refused() { + refused(&books_kv("receipt_threshold", "-0.01")); + refused(&books_kv("receipt_threshold", "-25")); +} + +/// INVARIANT 5 (and the MCP contract: decimal strings, never +/// locale-formatted): non-decimals are refused. +#[test] +fn inv5_not_decimal_refused() { + for t in ["", "abc", "$25", "1,000", "twenty", "25 dollars"] { + refused(&books_kv("receipt_threshold", t)); + } +} + +// --------------------------------------------------------------------------- +// INVARIANT 6: account codes. +// --------------------------------------------------------------------------- + +/// INVARIANT 6: cash, financing, and investing codes are trimmed. +#[test] +fn inv6_role_codes_trimmed() { + let s = ok("[books]\n\ + cash_accounts = [\" 1000 \", \"1010\"]\n\ + financing_accounts = [\"\\t2100\"]\n\ + investing_accounts = [\"1500 \"]\n"); + assert_eq!(s.cash_accounts, vec!["1000", "1010"]); + assert_eq!(s.financing_accounts, vec!["2100"]); + assert_eq!(s.investing_accounts, vec!["1500"]); +} + +/// INVARIANT 6: close_into and close_sweep are trimmed. +#[test] +fn inv6_close_codes_trimmed() { + let s = ok("[books]\nclose_into = \" 3000 \"\nclose_sweep = [\" 3100\", \"3200 \"]\n"); + assert_eq!(s.company.close_into.as_deref(), Some("3000")); + assert_eq!(s.company.close_sweep, vec!["3100", "3200"]); +} + +/// INVARIANT 6: a blank code is refused in every list and in close_into. +#[test] +fn inv6_blank_code_refused() { + for key in [ + "cash_accounts", + "financing_accounts", + "investing_accounts", + "close_sweep", + ] { + refused(&format!("[books]\n{key} = [\"\"]\n")); + refused(&format!("[books]\n{key} = [\"1000\", \" \"]\n")); + } + refused(&books_kv("close_into", "")); + refused(&books_kv("close_into", " ")); +} + +/// INVARIANT 6: close_into is refused with members. +#[test] +fn inv6_close_into_with_members_refused() { + let mut t = two_members("50%", "50%"); + t.push_str("\n[books]\nclose_into = \"3000\"\n"); + refused(&t); +} + +// --------------------------------------------------------------------------- +// Real files. +// --------------------------------------------------------------------------- + +/// A copy of the Wanderland Labs books dir's `wanderbooks.toml`. +const WANDERLAND: &str = r#"[company] +name = "Wanderland Labs LLC" +entity_type = "LLC, multi-member, member-managed" +tax_classification = "partnership" # Form 1065; a Schedule K-1 per member +base_currency = "USD" +fiscal_year_start = "01-01" # the year ends the day before: Dec 31 +formation_state = "UT" +formed = "2026-10-04" + +[[company.members]] +name = "Stephen Waits" +share = "50%" +capital_account = "3000" + +[[company.members]] +name = "Katherine Pendergast" +share = "50%" +capital_account = "3001" + +[books] +data_dir = "books-data" +accounting_basis = "accrual" +receipt_threshold = "0.00" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[viewer] +open_at_start = "ask" +"#; + +/// The demo's `wanderbooks.toml` (ledger-gui demo). +const DEMO: &str = r#"[company] +name = "Juniper Studio LLC" +entity_type = "LLC, single-member" +tax_classification = "disregarded entity" # Schedule C +fiscal_year_start = "01-01" +formation_state = "OR" +formed = "2025-06-02" +ein = "00-0000000" +address = "1200 NW Everett St, Portland, OR 97209" +owner = "Alex Rivera" + +[books] +accounting_basis = "accrual" +receipt_threshold = "25.00" +close_into = "3000" +close_sweep = ["3100"] +cash_accounts = ["1000", "1010"] +"#; + +/// Every invariant at once, on a real multi-member file. +#[test] +fn real_wanderland_file() { + let s = ok(WANDERLAND); + assert_eq!(s.company.name, "Wanderland Labs LLC"); + assert_eq!(s.company.fiscal_year_start, (1, 1)); + assert_eq!(s.company.basis, "Accrual"); + assert_eq!(s.company.tax_form, Some(TaxForm::Form1065)); + assert_eq!(s.company.members.len(), 2); + assert_eq!(s.company.members[0].name, "Stephen Waits"); + assert_eq!(s.company.members[1].capital_account, "3001"); + assert_eq!(dec(s.receipt_threshold.as_deref().unwrap()), Decimal::ZERO); + assert_eq!(s.cash_accounts, vec!["1000"]); + assert_eq!(s.financing_accounts, vec!["2100"]); + assert_eq!(s.company.close_into, None); + let keys: Vec<&str> = s.company.profile.iter().map(|(k, _)| k.as_str()).collect(); + for k in ["name", "entity_type", "formation_state", "formed"] { + assert!(keys.contains(&k), "{k} missing from {keys:?}"); + } + assert!( + s.company + .profile + .contains(&("formed".to_string(), "2026-10-04".to_string())) + ); +} + +/// Every invariant at once, on a real single-owner file. +#[test] +fn real_demo_file() { + let s = ok(DEMO); + assert_eq!(s.company.name, "Juniper Studio LLC"); + assert_eq!(s.company.tax_form, Some(TaxForm::ScheduleC)); + assert!(s.company.members.is_empty()); + assert_eq!(s.company.close_into.as_deref(), Some("3000")); + assert_eq!(s.company.close_sweep, vec!["3100"]); + assert_eq!(s.cash_accounts, vec!["1000", "1010"]); + assert_eq!(dec(s.receipt_threshold.as_deref().unwrap()), dec("25")); +} + +// --------------------------------------------------------------------------- +// load. +// --------------------------------------------------------------------------- + +/// `load` on a missing path is an error. +#[test] +fn load_missing_path_errors() { + let dir = tempfile::tempdir().unwrap(); + assert!(load(&dir.path().join("nope/wanderbooks.toml")).is_err()); +} + +/// `load` reads a file the same as `parse` reads its text, refusals +/// included. +#[test] +fn load_matches_parse() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("wanderbooks.toml"); + for text in [WANDERLAND, DEMO, ""] { + std::fs::write(&p, text).unwrap(); + assert_eq!(load(&p), parse(text)); + } + std::fs::write(&p, two_members("50%", "49%")).unwrap(); + assert!(load(&p).is_err()); +} + +// --------------------------------------------------------------------------- +// Properties. +// --------------------------------------------------------------------------- + +/// The table, every phrasing (source table and the doc on the reader). +const CLASSIFICATIONS: &[(&str, TaxForm)] = &[ + ("partnership", TaxForm::Form1065), + ("S corporation", TaxForm::Form1120S), + ("S corp", TaxForm::Form1120S), + ("S-corp", TaxForm::Form1120S), + ("scorp", TaxForm::Form1120S), + ("C corporation", TaxForm::Form1120), + ("corporation", TaxForm::Form1120), + ("association", TaxForm::Form1120), + ("disregarded entity", TaxForm::ScheduleC), + ("disregarded", TaxForm::ScheduleC), + ("sole proprietor", TaxForm::ScheduleC), + ("sole proprietorship", TaxForm::ScheduleC), + ("single-member", TaxForm::ScheduleC), +]; + +/// Filler words that name no return. +const FILLER: &[&str] = &[ + "LLC", + "taxed", + "as", + "a", + "an", + "we", + "are", + "elected", + "to", + "be", + "for", + "federal", + "tax", + "purposes", + "the", + "company", + "is", + "filed", + "(default)", + "Utah", + "member-managed", +]; + +fn filler() -> impl Strategy> { + prop::collection::vec(prop::sample::select(FILLER), 0..4) +} + +/// n positive basis-point shares summing to 10_000. +fn bps_shares() -> impl Strategy> { + (1usize..=10).prop_flat_map(|n| { + prop::sample::subsequence((1u32..10_000).collect::>(), n - 1).prop_map(|mut cuts| { + cuts.sort_unstable(); + let mut out = Vec::new(); + let mut prev = 0; + for c in cuts.into_iter().chain([10_000]) { + out.push(c - prev); + prev = c; + } + out + }) + }) +} + +/// n positive whole percents summing to 100. +fn pct_shares() -> impl Strategy> { + (1usize..=10).prop_flat_map(|n| { + prop::sample::subsequence((1u32..100).collect::>(), n - 1).prop_map(|mut cuts| { + cuts.sort_unstable(); + let mut out = Vec::new(); + let mut prev = 0; + for c in cuts.into_iter().chain([100]) { + out.push(c - prev); + prev = c; + } + out + }) + }) +} + +fn members_from(shares: &[String]) -> String { + let m: Vec<(String, String, String)> = shares + .iter() + .enumerate() + .map(|(i, s)| (format!("Member {i}"), s.clone(), format!("30{i:02}"))) + .collect(); + members_toml(&m) +} + +fn bps_str(b: u32) -> String { + (Decimal::from(b) / Decimal::from(10_000)).to_string() +} + +const KEYS_COMPANY: &[&str] = &[ + "name", + "fiscal_year_start", + "tax_form", + "tax_classification", + "ein", + "entity_type", +]; +const KEYS_BOOKS: &[&str] = &[ + "accounting_basis", + "receipt_threshold", + "cash_accounts", + "financing_accounts", + "investing_accounts", + "close_into", + "close_sweep", +]; +const KEYS_MEMBER: &[&str] = &["name", "share", "capital_account", "distribution_account"]; + +/// A TOML value of any shape, biased toward ones that are near-valid. +fn toml_value() -> impl Strategy { + let s = prop_oneof![ + any::(), + "[0-9]{0,2}-[0-9]{0,2}", + "-?[0-9]{0,4}(\\.[0-9]{0,3})?%?", + "[ \t]{0,2}[0-9]{0,5}[ \t]{0,2}", + prop::sample::select(vec![ + "accrual", + "cash", + "partnership", + "S corp", + "1065", + "schedule_c", + "50%", + "0.5", + "", + "01-01", + "02-29", + ]) + .prop_map(String::from), + ]; + prop_oneof![ + s.clone().prop_map(|v| toml_str(&v)), + prop::collection::vec(s, 0..4).prop_map(|v| toml_array(&v)), + any::().prop_map(|n| n.to_string()), + any::() + .prop_filter("finite", |f| f.is_finite()) + .prop_map(|f| format!("{f:?}")), + any::().prop_map(|b| b.to_string()), + Just("{ a = 1 }".to_string()), + ] +} + +fn toml_shaped() -> impl Strategy { + let entries = |keys: &'static [&'static str]| { + prop::collection::vec((prop::sample::select(keys), toml_value()), 0..6) + }; + ( + entries(KEYS_COMPANY), + prop::collection::vec(entries(KEYS_MEMBER), 0..4), + entries(KEYS_BOOKS), + ) + .prop_map(|(company, members, books)| { + let mut t = String::from("[company]\n"); + for (k, v) in company { + t.push_str(&format!("{k} = {v}\n")); + } + for m in members { + t.push_str("[[company.members]]\n"); + for (k, v) in m { + t.push_str(&format!("{k} = {v}\n")); + } + } + t.push_str("[books]\n"); + for (k, v) in books { + t.push_str(&format!("{k} = {v}\n")); + } + t + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + /// fiscal_year_containing doc + Pub. 538: for any first-of-month start + /// and any date, the year contains the date, starts on that month's + /// 1st, ends the day before the same month-day a year later, and is + /// 365 or 366 days long. + #[test] + fn prop_fiscal_year_contains_date( + month in 1u32..=12, + days in 0i64..(NaiveDate::from_ymd_opt(2200, 12, 31).unwrap() + - NaiveDate::from_ymd_opt(1900, 1, 1).unwrap()).num_days(), + ) { + let d = date(1900, 1, 1) + chrono::Duration::days(days); + let c = ok(&company_kv("fiscal_year_start", &format!("{month:02}-01"))).company; + let (start, end) = c.fiscal_year_containing(d); + prop_assert!(start <= d && d <= end, "{d}: {start}..{end}"); + prop_assert_eq!((start.month(), start.day()), (month, 1)); + let y = if d.month() >= month { d.year() } else { d.year() - 1 }; + prop_assert_eq!(start, date(y, month, 1)); + let next = start.checked_add_months(Months::new(12)).unwrap(); + prop_assert_eq!(end, next.pred_opt().unwrap()); + let len = (end - start).num_days() + 1; + prop_assert!(len == 365 || len == 366, "{len}"); + } + + /// Consecutive fiscal years tile: the day after one ends starts the + /// next, and the day before one starts ends the previous. + #[test] + fn prop_fiscal_years_tile( + month in 1u32..=12, + days in 0i64..(NaiveDate::from_ymd_opt(2200, 12, 31).unwrap() + - NaiveDate::from_ymd_opt(1900, 1, 1).unwrap()).num_days(), + ) { + let d = date(1900, 1, 1) + chrono::Duration::days(days); + let c = ok(&company_kv("fiscal_year_start", &format!("{month:02}-01"))).company; + let (start, end) = c.fiscal_year_containing(d); + let after = end.succ_opt().unwrap(); + let (s2, e2) = c.fiscal_year_containing(after); + prop_assert_eq!(s2, after); + prop_assert_eq!(c.fiscal_year_containing(e2).0, after); + let before = start.pred_opt().unwrap(); + prop_assert_eq!(c.fiscal_year_containing(before).1, before); + } + + /// INVARIANT 2: any day but the 1st, of any month, is refused. + #[test] + fn prop_fiscal_start_not_first_refused(month in 1u32..=12, day in 2u32..=31) { + refused(&company_kv("fiscal_year_start", &format!("{month:02}-{day:02}"))); + } + + /// INVARIANT 3: 1..=10 members whose decimal shares sum to 1 are + /// accepted, with the shares as written. + #[test] + fn prop_shares_summing_to_one_accepted(bps in bps_shares()) { + let shares: Vec = bps.iter().map(|b| bps_str(*b)).collect(); + let s = ok(&members_from(&shares)); + prop_assert_eq!(s.company.members.len(), bps.len()); + for (m, b) in s.company.members.iter().zip(&bps) { + prop_assert_eq!(m.share, Decimal::from(*b) / Decimal::from(10_000)); + } + let total: Decimal = s.company.members.iter().map(|m| m.share).sum(); + prop_assert_eq!(total, Decimal::ONE); + } + + /// INVARIANT 3: perturb one share by ±0.01 and the sum is no longer 1: + /// refused. + #[test] + fn prop_perturbed_share_refused(bps in bps_shares(), pick in any::(), up in any::()) { + let i = pick.index(bps.len()); + let mut shares: Vec = + bps.iter().map(|b| Decimal::from(*b) / Decimal::from(10_000)).collect(); + let delta = dec("0.01"); + shares[i] = if up { shares[i] + delta } else { shares[i] - delta }; + let text: Vec = shares.iter().map(|d| d.to_string()).collect(); + refused(&members_from(&text)); + } + + /// INVARIANT 3: shares written as "NN%" read the same as decimals. + #[test] + fn prop_percent_equals_decimal(pcts in pct_shares()) { + let as_pct: Vec = pcts.iter().map(|p| format!("{p}%")).collect(); + let as_dec: Vec = + pcts.iter().map(|p| (Decimal::from(*p) / Decimal::from(100)).to_string()).collect(); + let a = ok(&members_from(&as_pct)); + let b = ok(&members_from(&as_dec)); + prop_assert_eq!(a.company.members, b.company.members); + } + + /// INVARIANT 6: whitespace padding around codes is trimmed. + #[test] + fn prop_codes_trimmed( + codes in prop::collection::vec(("[ \t]{0,3}", "[0-9A-Za-z]{1,6}", "[ \t]{0,3}"), 1..5), + into in ("[ \t]{0,3}", "[0-9]{1,6}", "[ \t]{0,3}"), + ) { + let padded: Vec = codes.iter().map(|(a, c, b)| format!("{a}{c}{b}")).collect(); + let bare: Vec = codes.iter().map(|(_, c, _)| c.clone()).collect(); + let arr = toml_array(&padded); + let text = format!( + "[books]\ncash_accounts = {arr}\nfinancing_accounts = {arr}\n\ + investing_accounts = {arr}\nclose_sweep = {arr}\nclose_into = {}\n", + toml_str(&format!("{}{}{}", into.0, into.1, into.2)) + ); + let s = ok(&text); + prop_assert_eq!(&s.cash_accounts, &bare); + prop_assert_eq!(&s.financing_accounts, &bare); + prop_assert_eq!(&s.investing_accounts, &bare); + prop_assert_eq!(&s.company.close_sweep, &bare); + prop_assert_eq!(s.company.close_into.as_deref(), Some(into.1.as_str())); + } + + /// INVARIANT 6: a blank code anywhere in a list is refused. + #[test] + fn prop_blank_code_refused( + key in prop::sample::select(vec!["cash_accounts", "financing_accounts", "investing_accounts", "close_sweep"]), + good in prop::collection::vec("[0-9]{1,6}", 0..4), + blank in "[ \t]{0,4}", + at in any::(), + ) { + let mut items = good.clone(); + items.insert(at.index(good.len() + 1), blank); + refused(&format!("[books]\n{key} = {}\n", toml_array(&items))); + } + + /// INVARIANT 4: a table phrase among words that name no return maps + /// per the source table. + #[test] + fn prop_classification_in_context( + row in prop::sample::select(CLASSIFICATIONS), + before in filler(), + after in filler(), + ) { + let text = before.iter().chain([&row.0]).chain(after.iter()).copied().collect::>().join(" "); + prop_assert_eq!(classification(&text), Ok(Some(row.1)), "{}", text); + } + + /// INVARIANT 4: a phrase naming two different returns is refused. + #[test] + fn prop_classification_two_returns_refused( + a in prop::sample::select(CLASSIFICATIONS), + b in prop::sample::select(CLASSIFICATIONS), + mid in filler(), + ) { + prop_assume!(a.1 != b.1); + let text = [a.0].iter().chain(mid.iter()).chain([&b.0]).copied().collect::>().join(" "); + prop_assert!(classification(&text).is_err(), "{}", text); + } + + /// INVARIANT 4: tax_form wins over any classification beside it. + #[test] + fn prop_tax_form_wins( + form in prop::sample::select(TaxForm::ALL.to_vec()), + row in prop::sample::select(CLASSIFICATIONS), + ) { + let text = format!( + "[company]\ntax_form = {}\ntax_classification = {}\n", + toml_str(form.label()), + toml_str(row.0) + ); + prop_assert_eq!(ok(&text).company.tax_form, Some(form)); + } + + /// INVARIANT 5: any non-negative decimal is accepted, any negative one + /// refused. + #[test] + fn prop_threshold_sign(units in 0i64..1_000_000_000, scale in 0u32..=4) { + let d = Decimal::new(units, scale); + let s = ok(&books_kv("receipt_threshold", &d.to_string())); + prop_assert_eq!(dec(s.receipt_threshold.as_deref().unwrap()), d); + if units > 0 { + refused(&books_kv("receipt_threshold", &(-d).to_string())); + } + } + + /// Every refusal is an Err, never a panic: any text at all. + #[test] + fn prop_any_string_never_panics(text in any::()) { + let _ = parse(&text); + } + + /// Every refusal is an Err, never a panic: TOML-shaped text with the + /// real keys and arbitrary values. + #[test] + fn prop_toml_shaped_never_panics(text in toml_shaped()) { + let _ = parse(&text); + } +} + +/// INVARIANT 7: the profile keeps numbers, booleans, and dates in TOML's +/// spelling, in file order, and leaves out tables and arrays. +#[test] +fn inv7_profile_keeps_simple_values_of_every_type() { + let s = ledger_config::parse( + "[company]\nname = \"Acme\"\nemployees = 3\nrate = 2.5\nactive = true\nformed = 2026-10-04\ncodes = [\"a\"]\n[company.address]\ncity = \"Ogden\"\n", + ) + .unwrap(); + assert_eq!( + s.company.profile, + [ + ("name".to_string(), "Acme".to_string()), + ("employees".to_string(), "3".to_string()), + ("rate".to_string(), "2.5".to_string()), + ("active".to_string(), "true".to_string()), + ("formed".to_string(), "2026-10-04".to_string()), + ] + ); +} diff --git a/crates/ledger-core/src/classes.rs b/crates/ledger-core/src/classes.rs index 1dd387e..57c14f5 100644 --- a/crates/ledger-core/src/classes.rs +++ b/crates/ledger-core/src/classes.rs @@ -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)); diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 0ecb04e..3f109cb 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -12,7 +12,7 @@ //! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`, //! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections //! are reversing entries. -//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`profit_and_loss`](reports::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. diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index a7fce67..0a519c0 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -126,8 +126,9 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool) } } -/// Profit and loss: revenue and expenses for the life of the ledger. For a -/// date range, with a line per account, use [`profit_and_loss_for_period`]. +/// Revenue and expense totals: from [`unclosed_earnings`], what the books +/// hold now (closed periods already moved to equity); for a date range, +/// with a line per account, use [`profit_and_loss_for_period`]. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ProfitAndLoss { /// Total revenue (credit-normal balances, reported positive). @@ -143,15 +144,19 @@ impl ProfitAndLoss { } } -/// Build the profit and loss statement from all posted entries. -pub fn profit_and_loss(ledger: &Ledger) -> Result { +/// Earnings not yet closed: revenue and expenses over every posted entry, +/// closing entries included, so a closed period's income (already moved +/// to equity) nets out. This is the balance sheet's current-earnings line, +/// not a profit and loss for any period (docs/testing.md #3; OpenStax, +/// Principles of Financial Accounting 5.1). +pub fn unclosed_earnings(ledger: &Ledger) -> Result { profit_and_loss_filtered(ledger, None) } /// Build the profit and loss statement, optionally filtered to one class. /// /// With `None` every line counts and the result is identical to -/// [`profit_and_loss`]. With `Some(class)` only lines tagged with that +/// [`unclosed_earnings`]. With `Some(class)` only lines tagged with that /// class count. Unlike the account-at-a-time accumulation this works line /// by line, because classes live on lines: each line contributes its signed /// amount (positive on the account's normal side) to its account type's @@ -181,7 +186,7 @@ pub struct ClassProfitAndLoss { /// sorted by class name; the unclassified row comes last. /// /// The rows partition the ledger: summing every row's revenue and expenses -/// reproduces [`profit_and_loss`]. +/// reproduces [`unclosed_earnings`]. /// /// ```text /// GOAL: a profit and loss per class (department, location, job), plus the @@ -313,7 +318,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result { AccountType::Revenue | AccountType::Expense => {} } } - let current_earnings = profit_and_loss(ledger)?.net_income(); + let current_earnings = unclosed_earnings(ledger)?.net_income(); equity += current_earnings; Ok(BalanceSheet { assets, @@ -1031,7 +1036,7 @@ pub fn members_equity( /// ``` pub fn snapshot(ledger: &Ledger) -> Result { let tb = trial_balance(ledger); - let pl = profit_and_loss(ledger)?; + let pl = unclosed_earnings(ledger)?; let bs = balance_sheet(ledger)?; Ok(serde_json::json!({ "trial_balance": { diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index 66a72f2..fb8a306 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -10,7 +10,7 @@ //! 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, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger, LedgerError, Money, Side, @@ -565,7 +565,7 @@ fn accounting_equation_holds() { #[test] fn profit_and_loss_is_correct() { let c = fixture_company(); - let pl = profit_and_loss(&c.ledger).unwrap(); + let pl = unclosed_earnings(&c.ledger).unwrap(); assert_eq!(pl.revenue, Decimal::from(4_000)); assert_eq!(pl.expenses, Decimal::from(800)); assert_eq!(pl.net_income(), Decimal::from(3_200)); diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index 483712e..7dde157 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -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)); } diff --git a/crates/ledger-core/tests/spec_reports.rs b/crates/ledger-core/tests/spec_reports.rs index 8441b80..2feb071 100644 --- a/crates/ledger-core/tests/spec_reports.rs +++ b/crates/ledger-core/tests/spec_reports.rs @@ -11,7 +11,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{Days, NaiveDate}; -use ledger_core::reports::{profit_and_loss, profit_and_loss_by_class, snapshot, trial_balance}; +use ledger_core::reports::{profit_and_loss_by_class, snapshot, trial_balance, unclosed_earnings}; use ledger_core::{ AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId, CloseTo, Currency, EntryId, GeneralLedger, Ledger, MemberCapital, MemberClose, @@ -651,7 +651,7 @@ fn pnl_by_class_rows_sorted_by_name_unclassified_last() { (None, c(0), c(3000)), ] ); - let whole = profit_and_loss(&co.l).unwrap(); + let whole = unclosed_earnings(&co.l).unwrap(); assert_eq!(whole.revenue, c(15000)); assert_eq!(whole.expenses, c(5000)); } @@ -2134,7 +2134,7 @@ proptest! { fn prop_pnl_by_class_rows_sum_to_whole(gens in arb_books()) { let b = build_ordered(&gens, 1, &(0..N).collect::>(), true); let rows = profit_and_loss_by_class(&b.l).unwrap(); - let whole = profit_and_loss(&b.l).unwrap(); + let whole = unclosed_earnings(&b.l).unwrap(); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::(), whole.revenue); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::(), whole.expenses); let (wr, we) = model_pnl(&model(&gens, early(), d(2030, 1, 1), None)); @@ -2171,7 +2171,7 @@ proptest! { let mut b = build_ordered(&gens, 1, &(0..N).collect::>(), true); close_through(&mut b, Some(month), &re_close()); let rows = profit_and_loss_by_class(&b.l).unwrap(); - let whole = profit_and_loss(&b.l).unwrap(); + let whole = unclosed_earnings(&b.l).unwrap(); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::(), whole.revenue); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::(), whole.expenses); } diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index ab7a0a0..b5f282b 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -15,7 +15,7 @@ use chrono::NaiveDate; use ledger_core::reports::{ - balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period, + balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, unclosed_earnings, }; use ledger_core::{ AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital, @@ -127,7 +127,7 @@ fn profit_and_loss_is_scoped_to_the_period() { (dec(3_500), dec(920)) ); // ... which is the life-of-ledger P&L, because nothing is outside it. - let all = profit_and_loss(&b.ledger).unwrap(); + let all = unclosed_earnings(&b.ledger).unwrap(); assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920))); // One class: only the tagged software line. diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index 560b0b6..6a4f687 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -12,6 +12,30 @@ use ledger_core::reports::profit_and_loss_for_period; use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report}; use rust_decimal::Decimal; +/// Everything the books need, as items to show. +/// +/// ```text +/// GOAL: tell the humans what's unfinished or wrong, and hand them the +/// prompt that gets the agent to fix it; never change the books. +/// SOURCE: DECISION (Stephen: the viewer is read-only; its "Needs +/// attention" list offers prompts, AGENTS.md); the conditions +/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s). +/// INVARIANTS: +/// 1. Pure: reads `b`, writes nothing. +/// 2. One item per: draft entry (Todo); posted entry missing a receipt +/// (Problem; four or more are one combined item); recurring entry or +/// schedule due through today (Todo); period ended and still open +/// (Todo); fiscal-year account with activity and no tax category +/// (Info); legal rule known only through a past year (Todo); 1099 +/// vendor without a W-9 (Problem); overdue bills, and overdue +/// invoices (Problem, one each, with the total); money owed to +/// owners (Info, per account with a balance); the database differing +/// from the journal or unreadable, and damaged or missing stored +/// files (Problem). +/// 3. Nothing else: books with none of those yield no items. +/// 4. Every item's prompt starts "In wanderbooks," or names +/// wanderbooks, and its amounts are the books' exact amounts. +/// ``` pub fn find(b: &Books) -> Vec { let mut out = Vec::new(); let today = b.today; @@ -49,9 +73,9 @@ pub fn find(b: &Books) -> Vec { .collect(); out.push(Attention { severity: Severity::Problem, - title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")), + title: format!("{} without receipts", fmt::count(missing.len(), "entry", "entries")), detail: format!( - "{} of spending has no receipt attached ({}).", + "{} in entries with no receipt attached ({}).", fmt::dollars(total), b.receipt_rule() ), diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 045a3ae..5b224e7 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -34,7 +34,7 @@ pub struct EntryInfo { pub vendor: Option, pub reversal_of: Option, pub reversed_by: Option, - /// Spending over the receipt threshold with no receipt. + /// Covered by the receipt policy (the server's rule) and has no receipt. pub missing_receipt: bool, /// Net effect on cash accounts: positive in, negative out, zero if the /// entry doesn't touch cash. @@ -162,11 +162,13 @@ impl Books { let ledger = snap.ledger().clone(); let attachments = snap.attachments(None).map_err(|e| e.to_string())?; let journal = snap.journal().to_vec(); - let receipt_threshold = settings - .receipt_threshold - .as_deref() - .and_then(|t| t.parse().ok()) - .unwrap_or(Decimal::new(25, 0)); + // The server's own rule (config.json, else wanderbooks.toml), so + // the viewer flags exactly what the server would refuse. + let policy = ledger_config::effective_receipt_policy( + &snap.data_dir().join("config.json"), + &settings, + )?; + let receipt_threshold = policy.threshold(); let cash_accounts = cash_accounts(&ledger, &settings); let codes = |cs: &[String]| -> Vec { cs.iter() @@ -238,23 +240,14 @@ impl Books { .entry_vendor(id) .and_then(|v| ledger.get_vendor(v)) .map(|v| v.name.clone()); - // Receipts prove spending: hand-made entries (and recurring - // drafts) that debit an expense or buy an asset from outside. - // Invoices, payments, transfers, draws, closes, and schedules - // carry their own evidence. - let spends = e.lines().iter().any(|l| { - l.side == Side::Debit - && ledger.chart().get(l.account).is_some_and(|a| { - a.account_type == AccountType::Expense - || (a.account_type == AccountType::Asset - && !cash_accounts.contains(&a.id) - && !a.name.to_lowercase().contains("receivable")) - }) - }); + // The receipt policy covers what is posted by hand (journal + // entries and recurring drafts); invoices, payments, closes, + // depreciation, and schedules carry their own evidence, and a + // reversal leans on its original's. let missing_receipt = matches!(origin, "Journal entry" | "Recurring") - && spends + && e.reversal_of().is_none() && rec.is_empty() - && amount > receipt_threshold; + && policy.receipt_required(&ledger, e); let cash_effect: Decimal = e .lines() .iter() diff --git a/crates/ledger-gui/src/charts.rs b/crates/ledger-gui/src/charts.rs index 2b8e31c..2662aa0 100644 --- a/crates/ledger-gui/src/charts.rs +++ b/crates/ledger-gui/src/charts.rs @@ -15,6 +15,13 @@ 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. +/// 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. +/// ``` /// "Nice" axis ticks covering [lo, hi]. fn ticks(lo: f64, hi: f64, n: usize) -> Vec { let span = (hi - lo).max(1e-9); diff --git a/crates/ledger-gui/src/export.rs b/crates/ledger-gui/src/export.rs index 82eb790..5fa3fd7 100644 --- a/crates/ledger-gui/src/export.rs +++ b/crates/ledger-gui/src/export.rs @@ -20,6 +20,23 @@ pub fn downloads_dir() -> PathBuf { } /// `name` made safe as a file name on every platform. +/// +/// ```text +/// GOAL: a report title (company data) can never name a file outside the +/// downloads folder, or one a platform refuses. +/// SOURCE: docs/spec/sources/file-names.md (Microsoft's naming rules; +/// POSIX NAME_MAX). +/// INVARIANTS: +/// 1. The result is `.`, `base` holding no reserved +/// character (< > : " / \ | ? *) and no control character: each is +/// replaced ('-' or a space). +/// 2. `base` is never empty, never "." or "..", never ends with a space +/// or a period, and is never a reserved device name (CON, NUL, COM1, +/// LPT¹, ... in any case, before its first period): such a base gets +/// a leading '_'. +/// 3. The whole name is at most 255 bytes of UTF-8, cut at a character +/// boundary. +/// ``` pub fn file_name(name: &str, ext: &str) -> String { let clean: String = name .chars() @@ -29,7 +46,54 @@ pub fn file_name(name: &str, ext: &str) -> String { c => c, }) .collect(); - format!("{}.{ext}", clean.trim()) + let mut base = clean.trim().trim_end_matches(['.', ' ']).to_string(); + if base.is_empty() { + base = "report".to_string(); + } + let stem = base + .split('.') + .next() + .unwrap_or("") + .trim_end() + .to_uppercase(); + let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ["COM", "LPT"].iter().any(|p| { + stem.strip_prefix(p).is_some_and(|n| { + matches!( + n, + "1" | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + | "\u{b9}" + | "\u{b2}" + | "\u{b3}" + ) + }) + }); + if reserved { + base.insert(0, '_'); + } + let limit = 255usize.saturating_sub(ext.len() + 1); + if base.len() > limit { + let mut cut = limit; + while !base.is_char_boundary(cut) { + cut -= 1; + } + base.truncate(cut); + // A cut can leave a trailing space or period: trim again. + while base.ends_with(['.', ' ']) { + base.pop(); + } + if base.is_empty() { + base = "report".to_string(); + } + } + format!("{base}.{ext}") } /// Write `bytes` to the downloads folder as `name.ext` (replacing a file diff --git a/crates/ledger-gui/src/fmt.rs b/crates/ledger-gui/src/fmt.rs index 1116ba5..b50eae9 100644 --- a/crates/ledger-gui/src/fmt.rs +++ b/crates/ledger-gui/src/fmt.rs @@ -1,36 +1,56 @@ //! Formatting money, dates, and counts. Amounts are exact decimals in and //! exact strings out: nothing here rounds except `compact`, which says so. +//! +//! ```text +//! GOAL: show every amount exactly as the books hold it. +//! SOURCE: DECISION (the MCP contract: "money is exact or it is wrong"); +//! CONVENTION (US accounting display: thousands commas, +//! negatives in parentheses or with a minus sign). +//! INVARIANTS: +//! 1. money, accounting, and dollars NEVER round: every decimal place +//! the amount has (at least two) is shown, thousands grouped by +//! commas in threes, so the text reads back to the same value. +//! 2. Zero (and negative zero) shows without a sign; a negative shows +//! U+2212 (money, dollars) or parentheses (accounting), never both +//! and never "-0". +//! 3. compact rounds (chart axes, tight tiles): $950, $1.2k, $25k, +//! $3.4M; it never shows a sign on what rounds to zero. +//! ``` use chrono::NaiveDate; use rust_decimal::Decimal; -/// Thousands separators, two decimals, a true minus sign: `−1,234.56`. +/// Thousands separators, every decimal (at least two), a true minus sign: +/// `−1,234.56`. pub fn money(d: Decimal) -> String { - let neg = d.is_sign_negative() && !d.is_zero(); - let s = group(d.abs().round_dp(2)); - if neg { format!("\u{2212}{s}") } else { s } + let s = group(d.abs()); + if is_negative(d) { + format!("\u{2212}{s}") + } else { + s + } } /// Accounting style: negatives in parentheses, `(1,234.56)`. pub fn accounting(d: Decimal) -> String { - if d.is_sign_negative() && !d.is_zero() { - format!("({})", group(d.abs().round_dp(2))) - } else { - group(d.round_dp(2)) - } + let s = group(d.abs()); + if is_negative(d) { format!("({s})") } else { s } } /// With the currency sign: `$1,234.56`, `−$12.00`. pub fn dollars(d: Decimal) -> String { - let neg = d.is_sign_negative() && !d.is_zero(); - let s = format!("${}", group(d.abs().round_dp(2))); - if neg { format!("\u{2212}{s}") } else { s } + let s = format!("${}", group(d.abs())); + if is_negative(d) { + format!("\u{2212}{s}") + } else { + s + } } /// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`. pub fn compact(d: Decimal) -> String { let f: f64 = d.try_into().unwrap_or(0.0); - let (neg, a) = (f < 0.0, f.abs()); + let a = f.abs(); let s = if a >= 1_000_000.0 { format!("${:.1}M", a / 1_000_000.0) } else if a >= 10_000.0 { @@ -40,11 +60,22 @@ pub fn compact(d: Decimal) -> String { } else { format!("${a:.0}") }; - if neg { format!("\u{2212}{s}") } else { s } + // No sign on what rounds to zero ("$0", not "−$0"). + if f < 0.0 && s != "$0" { + format!("\u{2212}{s}") + } else { + s + } } +fn is_negative(d: Decimal) -> bool { + d.is_sign_negative() && !d.is_zero() +} + +/// A non-negative amount, every decimal it has (at least two), grouped. fn group(d: Decimal) -> String { - let s = format!("{:.2}", d); + let places = d.normalize().scale().max(2) as usize; + let s = format!("{:.*}", places, d); let (int, frac) = s.split_once('.').unwrap_or((&s, "00")); let mut out = String::new(); for (i, c) in int.chars().enumerate() { @@ -172,7 +203,9 @@ mod tests { assert_eq!(money(dec("0")), "0.00"); assert_eq!(money(dec("-0.00")), "0.00"); assert_eq!(accounting(dec("-207.68")), "(207.68)"); - assert_eq!(dollars(dec("999.999")), "$1,000.00"); + assert_eq!(dollars(dec("999.999")), "$999.999"); + assert_eq!(money(dec("0.125")), "0.125"); + assert_eq!(compact(dec("-0.4")), "$0"); assert_eq!(compact(dec("1234")), "$1.2k"); assert_eq!(compact(dec("-25000")), "\u{2212}$25k"); assert_eq!(compact(dec("950.4")), "$950"); diff --git a/crates/ledger-gui/src/nav.rs b/crates/ledger-gui/src/nav.rs index 02b3d34..996567f 100644 --- a/crates/ledger-gui/src/nav.rs +++ b/crates/ledger-gui/src/nav.rs @@ -150,6 +150,17 @@ impl Section { } } +/// ```text +/// GOAL: back and forward behave like a web browser's. +/// SOURCE: CONVENTION (browser session history: going somewhere new +/// drops the forward list). +/// INVARIANTS: +/// 1. go(r) to a different route makes it current, pushes the old one +/// on back, and clears forward; go to the current route does nothing. +/// 2. back() then forward() returns to the same route (and vice versa); +/// either does nothing when its list is empty. +/// 3. can_back/can_forward say whether back/forward would move. +/// ``` /// Back/forward history. pub struct Nav { back: Vec, diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index 0d150f9..8c71adf 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -148,6 +148,18 @@ impl Palette { /// Subsequence match: every query character in order, rewarding /// contiguous runs and word starts. None if no match. +/// Fuzzy match score, higher is better. +/// +/// ```text +/// INVARIANTS: +/// 1. Matches (Some) exactly when the query's non-space characters, +/// lower-cased, appear in order in the lower-cased hay; the empty +/// query matches everything with score 0. +/// 2. A match's score is at least the number of non-space query +/// characters; consecutive matches and matches at a word start score +/// more, so a word-start prefix scores at least as high as any +/// other match of the same query. +/// ``` fn fuzzy(hay: &str, query: &str) -> Option { if query.is_empty() { return Some(0); diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 352e2ab..c017f62 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -26,9 +26,13 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = "0.4" thiserror = "2" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync"] } zip = "9.0.1" toml = "1" [lints] workspace = true + +[dev-dependencies] +jsonschema = { version = "0.58", default-features = false } +proptest = "1" diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs index 544e262..5287dd6 100644 --- a/crates/ledger-mcp/src/main.rs +++ b/crates/ledger-mcp/src/main.rs @@ -13,11 +13,12 @@ use ledger_config as company; mod policy; mod server; mod types; +mod wire; use ledger_core::Currency; use ledger_store::Store; use policy::ReceiptPolicy; -use rmcp::{ServiceExt, transport::stdio}; +use rmcp::ServiceExt; use serde::Deserialize; use server::BooksServer; use std::path::PathBuf; @@ -28,10 +29,7 @@ struct Config { /// ISO 4217 base currency for a new store. Default "USD". #[serde(default)] base_currency: Option, - /// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold; - /// with neither, receipts are required above $25.00. - #[serde(default)] - receipt_policy: Option, + // `receipt_policy` is read by ledger_config::effective_receipt_policy. } fn default_data_dir() -> PathBuf { @@ -105,14 +103,7 @@ fn main() -> Result<(), Box> { }, }; eprintln!("ledger-mcp: company {:?}", settings.company.name); - let policy = match (config.receipt_policy, settings.receipt_threshold) { - (Some(policy), _) => policy, - (None, Some(threshold)) => ReceiptPolicy { - require_receipts_above: threshold, - ..ReceiptPolicy::default() - }, - (None, None) => ReceiptPolicy::default(), - }; + let policy = company::effective_receipt_policy(&config_path, &settings)?; let roles = server::CashRoles { cash: settings.cash_accounts, financing: settings.financing_accounts, @@ -152,8 +143,11 @@ fn main() -> Result<(), Box> { .with_company(company) .with_cash_roles(roles); rt.block_on(async { - let running = server.serve(stdio()).await?; + // The wire answers what JSON-RPC requires that rmcp drops (wire.rs). + let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout()); + let running = server.serve((input, output)).await?; let _ = running.waiting().await; + let _ = writer.await; Ok::<(), Box>(()) }) } diff --git a/crates/ledger-mcp/src/policy.rs b/crates/ledger-mcp/src/policy.rs index 6c306be..c365165 100644 --- a/crates/ledger-mcp/src/policy.rs +++ b/crates/ledger-mcp/src/policy.rs @@ -1,192 +1,4 @@ -//! 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. +//! The receipt policy lives in `ledger-config` (shared with the viewer, so +//! both read one rule); re-exported here. -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, - /// 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, -} - -fn default_threshold() -> String { - "25.00".to_string() -} - -impl Default for ReceiptPolicy { - fn default() -> Self { - Self { - require_receipts_above: default_threshold(), - receipt_required_accounts: Vec::new(), - receipt_exempt_accounts: Vec::new(), - } - } -} - -impl ReceiptPolicy { - fn threshold(&self) -> Decimal { - self.require_receipts_above - .parse() - .unwrap_or(Decimal::from(25)) - } - - fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec { - entry - .lines() - .iter() - .map(|l| { - ledger - .chart() - .get(l.account) - .map(|a| a.code.clone()) - .unwrap_or_default() - }) - .collect() - } - - /// Total debits of the entry (one side of the balanced entry). - pub fn entry_debits(entry: &JournalEntry) -> Decimal { - entry - .lines() - .iter() - .filter(|l| l.side == Side::Debit) - .map(|l| l.amount.amount) - .sum() - } - - /// True when posting this entry requires at least one receipt. - pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool { - let codes = self.account_codes(ledger, entry); - let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect(); - self.receipt_required_for(&refs, Self::entry_debits(entry)) - } - - /// True when an entry touching these account codes with this debit total - /// requires at least one receipt. The code-level check behind - /// [`Self::receipt_required`], for postings (like disposals) computed - /// without a draft entry in hand. - pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool { - // Per-account override: any line in a required account → required. - if codes - .iter() - .any(|c| self.receipt_required_accounts.iter().any(|r| r == *c)) - { - return true; - } - // Strictly greater than the threshold (Stephen: ">$25"). - if debits <= self.threshold() { - return false; - } - // Exempt only when every line is in an exempt account. - !codes - .iter() - .all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c)) - } - - /// Human-readable policy summary for previews and errors. - pub fn describe(&self) -> String { - if self.threshold().is_zero() { - "receipts required on every entry".to_string() - } else { - format!( - "receipts required when entry debits are strictly greater than {}", - self.threshold() - ) - } - } - - /// The receipt rule, phrased for the agent's operating instructions. - pub fn rule(&self) -> String { - if self.threshold().is_zero() { - "every entry needs at least one receipt".to_string() - } else { - format!( - "any entry moving more than ${} in debits needs at least one receipt", - self.threshold().round_dp(2) - ) - } - } - - /// Normalize a description for split detection. - fn normalize_description(desc: &str) -> String { - desc.split_whitespace() - .collect::>() - .join(" ") - .to_lowercase() - } - - /// Debit account ids of an entry, sorted. - fn debit_accounts(entry: &JournalEntry) -> Vec { - let mut ids: Vec = 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 { - let debits = Self::entry_debits(entry); - if debits > self.threshold() { - return Vec::new(); // Normal policy already applies. - } - let desc = Self::normalize_description(entry.description()); - let accounts = Self::debit_accounts(entry); - let date = entry.date(); - let mut group = Vec::new(); - let mut total = debits; - for other in ledger.posted_entries() { - if other.id() == entry_id { - continue; - } - if other.date() != date { - continue; - } - if Self::normalize_description(other.description()) != desc { - continue; - } - if Self::debit_accounts(other) != accounts { - continue; - } - // Reversals are exempt from the policy; don't count them. - if other.reversal_of().is_some() { - continue; - } - group.push(other.id()); - total += Self::entry_debits(other); - } - if !group.is_empty() && total > self.threshold() { - group - } else { - Vec::new() - } - } -} +pub use ledger_config::ReceiptPolicy; diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 56873ab..dbb5ce1 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1,5 +1,34 @@ //! The MCP server: narrow typed tools over the store, with the receipt //! policy enforced server-side. +//! +//! ```text +//! GOAL: let an AI agent keep the books through tools that cannot leave +//! them wrong, and say exactly what happened. +//! SOURCE: docs/spec/sources/mcp-tools.md (MCP 2025-06-18 tools: structured +//! results conform to the declared output schema; tool errors are +//! results with isError; "validate all tool inputs"); +//! docs/spec/sources/jsonrpc-2.0.md (one response per request, id +//! echoed, result xor error); DECISION (Stephen, the operating +//! contract in INSTRUCTIONS and docs/mcp-interface.md). +//! INVARIANTS: +//! 1. Every tool's structured result MUST conform to its declared output +//! schema, and the same JSON MUST be in a text block. +//! 2. A refused call (isError) MUST leave the books unchanged: the +//! journal byte for byte. +//! 3. Amounts in MUST be decimal strings, exactly `-?digits[.digits]` +//! (no exponent, sign "+", separators, or spaces); amounts out are +//! decimal strings, never JSON numbers, with at least two places and +//! never rounded. +//! 4. Posting and reversing MUST preview without `confirmed: true`, and a +//! preview MUST change nothing. +//! 5. An entry the receipt policy covers MUST NOT post, or keep posted, +//! without a receipt (crate::policy). +//! 6. A reference (account, vendor, class) given as a number that names +//! one thing by id and another by code or name is refused, never +//! guessed. +//! 7. The server MUST answer every request with exactly one response +//! whose id is the request's, and keep serving after any tool error. +//! ``` use crate::policy::ReceiptPolicy; use crate::types::*; @@ -330,9 +359,10 @@ fn parse_date(s: &str) -> Result { .map_err(|_| format!("bad date {s:?}: expected YYYY-MM-DD")) } +/// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3). fn parse_amount(s: &str) -> Result { - s.parse::() - .map_err(|_| format!("bad amount {s:?}: expected a decimal string like \"123.45\"")) + ledger_config::decimal(s) + .ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\"")) } fn parse_side(s: &str) -> Result { @@ -378,28 +408,35 @@ fn parse_account_type(s: &str) -> Result { } } -/// Resolve an account reference: a numeric id, or an account code. +/// Resolve an account reference: an account code, or a numeric id. A +/// number that is one account's code and another's id is refused +/// (INVARIANT 6). fn resolve_account(store: &Store, account: &str) -> Result { - if let Ok(id) = account.parse::() - && store.ledger().chart().get(id).is_some() - { - return Ok(id); + let chart = store.ledger().chart(); + let by_code = chart.find_by_code(account).map(|a| a.id); + let by_id = account + .parse::() + .ok() + .filter(|id| chart.get(*id).is_some()); + match (by_code, by_id) { + (Some(c), Some(i)) if c != i => Err(format!( + "account {account:?} is ambiguous: it is the code of account {c} and the id of account {i}; pass the code of the one you mean" + )), + (Some(c), _) => Ok(c), + (None, Some(i)) => Ok(i), + (None, None) => Err(format!( + "unknown account {account:?}: use an id or code from books.chart" + )), } - store - .ledger() - .chart() - .find_by_code(account) - .map(|a| a.id) - .ok_or_else(|| format!("unknown account {account:?}: use an id or code from books.chart")) } -/// Resolve a class by id or name (case-insensitive). +/// Resolve a vendor by id or name (case-insensitive); a number that is +/// one vendor's id and another's name is refused (INVARIANT 6). fn resolve_vendor(store: &Store, vendor: &str) -> Result { - if let Ok(id) = vendor.parse::() - && store.ledger().get_vendor(id).is_some() - { - return Ok(id); - } + let by_id = vendor + .parse::() + .ok() + .filter(|id| store.ledger().get_vendor(*id).is_some()); let matches: Vec = store .ledger() .list_vendors() @@ -407,6 +444,15 @@ fn resolve_vendor(store: &Store, vendor: &str) -> Result { .filter(|v| v.name.eq_ignore_ascii_case(vendor.trim())) .map(|v| v.id) .collect(); + if let Some(id) = by_id { + return match matches.as_slice() { + [] => Ok(id), + [m] if *m == id => Ok(id), + _ => Err(format!( + "vendor {vendor:?} is ambiguous: it is vendor {id}'s id and another vendor's name; pass the id" + )), + }; + } match matches.as_slice() { [id] => Ok(*id), [] => Err(format!( @@ -420,19 +466,23 @@ fn form_1099_label(form: Option) -> Option { form.map(|f| f.label().to_string()) } +/// Resolve a class by id or name; a number that is one class's id and +/// another's name is refused (INVARIANT 6). fn resolve_class(store: &Store, class: &str) -> Result { - if let Ok(id) = class.parse::() - && store.ledger().get_class(id).is_some() - { - return Ok(id); + let by_id = class + .parse::() + .ok() + .filter(|id| store.ledger().get_class(*id).is_some()); + let by_name = store.ledger().find_class_by_name(class).map(|c| c.id); + match (by_id, by_name) { + (Some(i), Some(n)) if i != n => Err(format!( + "class {class:?} is ambiguous: it is class {i}'s id and class {n}'s name; pass the id" + )), + (Some(i), _) | (None, Some(i)) => Ok(i), + (None, None) => Err(format!( + "unknown class {class:?}: use an id or name from books.list_classes" + )), } - store - .ledger() - .find_class_by_name(class) - .map(|c| c.id) - .ok_or_else(|| { - format!("unknown class {class:?}: use an id or name from books.list_classes") - }) } /// A money amount as a decimal string with at least two places: "0.00", @@ -923,6 +973,11 @@ impl BooksServer { let mut store = self.lock()?; let account = resolve_account(&store, &p.account)?; let side = parse_side(&p.side)?; + // Resolve the class before writing anything (INVARIANT 2). + let class_id = match &p.class { + Some(c) => Some(resolve_class(&store, c)?), + None => None, + }; // Foreign-currency line? if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() { let fc = p @@ -949,8 +1004,7 @@ impl BooksServer { .map_err(|e| e.to_string())?; } // Class tag, if requested. The line just added is the last one. - if let Some(class_ref) = p.class { - let class_id = resolve_class(&store, &class_ref)?; + if let Some(class_id) = class_id { let line_index = store .ledger() .get_entry(p.entry_id) @@ -3362,11 +3416,11 @@ impl BooksServer { let total = r.total().to_string(); AgingRowOut { name: r.name, - current: r.current.to_string(), - days_30: r.days_30.to_string(), - days_60: r.days_60.to_string(), - days_90: r.days_90.to_string(), - days_90_plus: r.days_90_plus.to_string(), + current: money(r.current), + days_30: money(r.days_30), + days_60: money(r.days_60), + days_90: money(r.days_90), + days_90_plus: money(r.days_90_plus), total, } }) @@ -3389,11 +3443,11 @@ impl BooksServer { let total = r.total().to_string(); AgingRowOut { name: r.name, - current: r.current.to_string(), - days_30: r.days_30.to_string(), - days_60: r.days_60.to_string(), - days_90: r.days_90.to_string(), - days_90_plus: r.days_90_plus.to_string(), + current: money(r.current), + days_30: money(r.days_30), + days_60: money(r.days_60), + days_90: money(r.days_90), + days_90_plus: money(r.days_90_plus), total, } }) @@ -3584,6 +3638,11 @@ impl BooksServer { if end < start { return Err("end is before start".to_string()); } + // One class, if asked (validated like every reference). + let only = match &p.class { + Some(c) => Some(resolve_class(&store, c)?), + None => None, + }; // Per-class P&L over the date range: accumulate line by line. use std::collections::HashMap; #[derive(Default)] @@ -3621,6 +3680,7 @@ impl BooksServer { // Sort: named classes by name, unclassified last. let mut rows: Vec<(Option, Acc)> = by_class .into_iter() + .filter(|(cid, _)| only.is_none() || *cid == only) .map(|(cid, acc)| { let name = cid .and_then(|id| ledger.get_class(id)) diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 44046a5..56011ce 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -566,9 +566,9 @@ pub struct TrialBalanceLineOut { pub code: String, /// Account name. pub name: String, - /// Debit balance as a decimal string, or "0". + /// Debit balance, decimal string ("0.00" when none). pub debit: String, - /// Credit balance as a decimal string, or "0". + /// Credit balance, decimal string ("0.00" when none). pub credit: String, } @@ -1570,7 +1570,7 @@ pub struct DisposeAssetOut { /// Gain (positive) or loss (negative), decimal string. pub gain_loss: String, /// Depreciation auto-posted for the final partial period, decimal string - /// ("0" when the period was already posted). + /// ("0.00" when the period was already posted). pub final_depreciation: String, /// The disposal entry's lines with resulting balances. pub lines: Vec, diff --git a/crates/ledger-mcp/src/wire.rs b/crates/ledger-mcp/src/wire.rs new file mode 100644 index 0000000..cb4062c --- /dev/null +++ b/crates/ledger-mcp/src/wire.rs @@ -0,0 +1,197 @@ +//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the +//! transport doesn't do. +//! +//! ```text +//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says +//! it gets, even one rmcp would drop. +//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request, +//! id echoed, or null when it can't be detected; 5.1: -32700 Parse +//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches. +//! INVARIANTS: +//! 1. A line that isn't JSON gets {"code": -32700} with id null. +//! 2. JSON that isn't a request, notification, or response object (an +//! array, a scalar, an object without "jsonrpc": "2.0" and a string +//! "method" or a result/error) gets {"code": -32600}, with the +//! line's id when it has a valid one (string, number, null), else +//! null. +//! 3. Everything else goes to rmcp unchanged; a blank line is ignored. +//! 4. Output lines are written whole: replies made here never split a +//! line rmcp writes. +//! ``` + +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc; + +/// What to do with one line from the client. +#[derive(Debug, PartialEq)] +pub enum Line { + /// Hand it to rmcp. + Forward, + /// Ignore it (blank). + Skip, + /// Answer it here with this JSON-RPC response. + Reply(Value), +} + +/// Classify one line (INVARIANTS 1-3). +pub fn classify(line: &str) -> Line { + if line.trim().is_empty() { + return Line::Skip; + } + let v: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")), + }; + let Some(obj) = v.as_object() else { + return Line::Reply(error(Value::Null, -32600, "Invalid Request")); + }; + let id = match obj.get("id") { + Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(), + _ => Value::Null, + }; + let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0"); + let id_ok = match obj.get("id") { + None => true, + Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null), + }; + let request = obj.get("method").is_some_and(Value::is_string); + let response = + obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error")); + if version_ok && id_ok && (request || response) { + Line::Forward + } else { + Line::Reply(error(id, -32600, "Invalid Request")) + } +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +/// Run the wire between the real `input`/`output` and rmcp's ends: +/// returns (what rmcp reads, what rmcp writes) and the task that must be +/// awaited after rmcp finishes, so the last lines are flushed. +pub fn spawn( + input: R, + mut output: W, +) -> ( + tokio::io::DuplexStream, + tokio::io::DuplexStream, + tokio::task::JoinHandle<()>, +) +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + const BUF: usize = 1 << 20; + let (rmcp_in, mut feed) = tokio::io::duplex(BUF); + let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let replies = tx.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(input).lines(); + while let Ok(Some(line)) = lines.next_line().await { + match classify(&line) { + Line::Skip => {} + Line::Forward => { + if feed + .write_all(format!("{line}\n").as_bytes()) + .await + .is_err() + { + break; + } + } + Line::Reply(v) => { + let _ = replies.send(v.to_string()); + } + } + } + // Dropping `feed` ends rmcp's input. + }); + tokio::spawn(async move { + let mut lines = BufReader::new(from_rmcp).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if tx.send(line).is_err() { + break; + } + } + }); + let writer = tokio::spawn(async move { + while let Some(line) = rx.recv().await { + if output + .write_all(format!("{line}\n").as_bytes()) + .await + .is_err() + || output.flush().await.is_err() + { + break; + } + } + }); + (rmcp_in, rmcp_out, writer) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn code(l: Line) -> (i64, Value) { + match l { + Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()), + other => panic!("{other:?}"), + } + } + + #[test] + fn classifies_by_the_jsonrpc_spec() { + assert_eq!( + code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)), + (-32700, Value::Null) + ); + assert_eq!(code(classify("not json")), (-32700, Value::Null)); + assert_eq!(code(classify("[]")), (-32600, Value::Null)); + assert_eq!(code(classify("[1]")), (-32600, Value::Null)); + assert_eq!(code(classify("5")), (-32600, Value::Null)); + assert_eq!( + code(classify(r#"{"jsonrpc":"2.0","id":98}"#)), + (-32600, json!(98)) + ); + assert_eq!( + code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)), + (-32600, json!("a")) + ); + assert_eq!( + code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)), + (-32600, Value::Null) + ); + assert_eq!( + code(classify(r#"{"jsonrpc":"2.0","method":5}"#)), + (-32600, Value::Null) + ); + assert_eq!( + classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#), + Line::Forward + ); + assert_eq!( + classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), + Line::Forward + ); + assert_eq!( + classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#), + Line::Forward + ); + assert_eq!( + classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#), + Line::Forward + ); + assert_eq!( + code(classify( + r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"# + )), + (-32600, json!(7)) + ); + assert_eq!(classify(" "), Line::Skip); + } +} diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs new file mode 100644 index 0000000..a9f9a48 --- /dev/null +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -0,0 +1,1973 @@ +//! Spec-blind tests for `ledger-mcp`, driven over stdio against the real +//! binary. +//! +//! Expected behavior comes only from the GOAL/SOURCE/INVARIANTS blocks of +//! `src/server.rs` and `src/policy.rs`, the server's `INSTRUCTIONS`, +//! `docs/mcp-interface.md`, `docs/spec/sources/{mcp-tools,jsonrpc-2.0, +//! irs-records}.md`, and the server's own `tools/list` (the declared +//! interface: names, input and output schemas). Where those are silent the +//! tests don't assert. +//! +//! Every tool call goes through [`Mcp::tool`], which checks, whatever the +//! test: one response with the request's id, result xor error (JSON-RPC +//! 2.0 §5); a refused call (isError or a JSON-RPC error) leaves +//! `journal.jsonl` byte-identical (server invariant 2); a success has +//! `structuredContent` that validates against the tool's `outputSchema` +//! and the same JSON in a text block (invariant 1); and no money key holds +//! a JSON number or a string outside `^-?\d+\.\d{2,}$` (invariant 3). + +use proptest::prelude::*; +use serde_json::{Value, json}; +use std::collections::{BTreeSet, HashMap}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::time::Duration; + +/// How long any single response may take before the server counts as hung. +const TIMEOUT: Duration = Duration::from_secs(30); + +/// Keys whose values are money. Derived from the `outputSchema`s: every +/// string property described as an amount or "decimal string", plus the +/// payout totals (`gross`, `fees`, `adjustments`, `deposit`), the +/// reconciliation `difference`, and the doc's `debits`/`credits`/ +/// `before`/`after`. Arrays and objects under these keys (e.g. `assets` +/// in `list_assets`) are walked, not checked. +const MONEY_KEYS: &[&str] = &[ + "accumulated", + "adjustments", + "after", + "amount", + "assets", + "balance", + "balance_after", + "balance_before", + "before", + "book_balance", + "book_value", + "cash_beginning", + "cash_ending", + "cash_from_financing", + "cash_from_investing", + "cash_from_operations", + "cleared_balance", + "cost", + "credit", + "credits", + "current", + "current_earnings", + "days_30", + "days_60", + "days_90", + "days_90_plus", + "debit", + "debits", + "deposit", + "depreciation", + "difference", + "equity", + "expenses", + "fees", + "final_depreciation", + "gain_loss", + "gross", + "liabilities", + "movement", + "net_change_in_cash", + "net_income", + "paid", + "proceeds", + "remaining", + "revenue", + "salvage_value", + "statement_balance", + "threshold", + "total", + "total_credits", + "total_debits", + "total_gross", + "total_net", +]; + +// ---------------------------------------------------------------- harness + +/// What a `tools/call` came back as. +#[derive(Debug, Clone)] +enum Out { + /// Success: the `structuredContent`. + Ok(Value), + /// `isError: true`: the first text block. + ToolErr(String), + /// A JSON-RPC error object. + RpcErr(Value), +} + +impl Out { + fn ok(self, what: &str) -> Value { + match self { + Out::Ok(v) => v, + other => panic!("{what}: expected success, got {other:?}"), + } + } + fn refused(&self) -> bool { + !matches!(self, Out::Ok(_)) + } + fn err_text(&self) -> String { + match self { + Out::Ok(v) => panic!("expected a refusal, got {v}"), + Out::ToolErr(t) => t.clone(), + Out::RpcErr(e) => e.to_string(), + } + } +} + +struct Mcp { + stdin: ChildStdin, + rx: Receiver, + child: Child, + next_id: u64, + data: PathBuf, + schemas: HashMap, + validators: HashMap, + /// Tools that returned a success at least once. + exercised: BTreeSet, + /// Invariant 3 (amounts out) violations: `tool: path = value`. + money_violations: Vec, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DIR_SEQ: AtomicU64 = AtomicU64::new(0); + +/// A fresh, empty per-test directory under the system temp dir. +fn test_dir(name: &str) -> PathBuf { + let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("ledger-mcp-spec-{}-{name}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Write a `wanderbooks.toml` with the given receipt threshold and extra +/// TOML (appended under `[books]` unless it opens its own table). +fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf { + let path = dir.join("wanderbooks.toml"); + std::fs::write( + &path, + format!( + "[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{extra}" + ), + ) + .unwrap(); + path +} + +fn spawn_child(dir: &Path, toml: &Path) -> Child { + let run = dir.join("run"); + std::fs::create_dir_all(&run).unwrap(); + Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) + .arg("--data-dir") + .arg(dir.join("books-data")) + .arg("--books-toml") + .arg(toml) + // books.show writes a viewer request here; keep it out of the + // user's real runtime dir. + .env("XDG_RUNTIME_DIR", &run) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ledger-mcp") +} + +impl Mcp { + /// Spawn on `dir` with `toml`, initialize, and load `tools/list`. + fn start(dir: &Path, toml: &Path) -> Self { + let mut child = spawn_child(dir, toml); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in stdout.lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + let mut mcp = Self { + stdin, + rx, + child, + next_id: 1, + data: dir.join("books-data"), + schemas: HashMap::new(), + validators: HashMap::new(), + exercised: BTreeSet::new(), + money_violations: Vec::new(), + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "spec-test", "version": "0.1.0"}, + }), + ); + assert!(init.get("result").is_some(), "initialize failed: {init}"); + mcp.send_line( + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(), + ); + let list = mcp.rpc("tools/list", json!({})); + for tool in list["result"]["tools"].as_array().expect("tools") { + let name = tool["name"].as_str().unwrap().to_string(); + let schema = tool["outputSchema"].clone(); + assert!(schema.is_object(), "{name} declares no outputSchema"); + let validator = jsonschema::validator_for(&schema) + .unwrap_or_else(|e| panic!("{name}'s outputSchema is not a valid schema: {e}")); + mcp.validators.insert(name.clone(), validator); + mcp.schemas.insert(name, tool.clone()); + } + mcp + } + + fn journal(&self) -> Vec { + std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() + } + + fn send_line(&mut self, line: &str) { + writeln!(self.stdin, "{line}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// The next JSON-RPC response (messages without an id, i.e. server + /// notifications, are skipped). `None` on timeout or EOF. + fn recv(&mut self, timeout: Duration) -> Option { + loop { + match self.rx.recv_timeout(timeout) { + Ok(line) => { + let v: Value = serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); + if v.get("id").is_none() && v.get("method").is_some() { + continue; + } + return Some(v); + } + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, + } + } + } + + /// Send a request and return its response, checking JSON-RPC 2.0 §5: + /// one response, same id, `jsonrpc: "2.0"`, result xor error. + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + self.send_line(&msg.to_string()); + let resp = self + .recv(TIMEOUT) + .unwrap_or_else(|| panic!("no response to {method} (id {id}): server hung or died")); + check_response_shape(&resp); + assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}"); + resp + } + + /// Call a tool and check invariants 1, 2, 3 and 7 on the outcome. + fn tool(&mut self, name: &str, args: Value) -> Out { + let before = self.journal(); + let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let out = if let Some(err) = resp.get("error") { + Out::RpcErr(err.clone()) + } else { + let result = &resp["result"]; + if result.get("isError") == Some(&json!(true)) { + let text = result["content"] + .as_array() + .and_then(|c| c.iter().find_map(|b| b["text"].as_str())) + .unwrap_or_default() + .to_string(); + Out::ToolErr(text) + } else { + Out::Ok(self.check_success(name, result)) + } + }; + if out.refused() { + assert!( + self.journal() == before, + "invariant 2: refused {name} {args} changed the journal ({out:?})" + ); + } + out + } + + /// Invariant 1 (schema + text block) and invariant 3 (amounts out). + fn check_success(&mut self, name: &str, result: &Value) -> Value { + let sc = result + .get("structuredContent") + .unwrap_or_else(|| panic!("{name}: success without structuredContent: {result}")) + .clone(); + let validator = self + .validators + .get(name) + .unwrap_or_else(|| panic!("{name} is not in tools/list")); + let errors: Vec = validator + .iter_errors(&sc) + .map(|e| format!("{} at {}", e, e.instance_path())) + .collect(); + assert!( + errors.is_empty(), + "invariant 1: {name} result violates its outputSchema: {errors:?}\n{sc}" + ); + let same_text = result["content"].as_array().is_some_and(|blocks| { + blocks.iter().any(|b| { + b["type"] == "text" + && b["text"] + .as_str() + .and_then(|t| serde_json::from_str::(t).ok()) + .is_some_and(|v| v == sc) + }) + }); + assert!( + same_text, + "invariant 1: {name} has no text block holding its structuredContent" + ); + let mut bad = Vec::new(); + money_walk(&sc, "$", None, &mut bad); + for b in bad { + self.money_violations.push(format!("{name}: {b}")); + } + self.exercised.insert(name.to_string()); + sc + } + + /// `tool`, expecting success. + fn ok(&mut self, name: &str, args: Value) -> Value { + let a = args.to_string(); + self.tool(name, args).ok(&format!("{name} {a}")) + } + + /// `tool`, expecting a refusal (isError or JSON-RPC error). + fn refuse(&mut self, name: &str, args: Value) -> String { + let a = args.to_string(); + let out = self.tool(name, args); + assert!(out.refused(), "{name} {a} should be refused, got {out:?}"); + out.err_text() + } + + /// Add accounts `(code, name, type)`; returns their ids. + fn accounts(&mut self, accts: &[(&str, &str, &str)]) -> Vec { + accts + .iter() + .map(|(code, name, ty)| { + self.ok( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + )["account_id"] + .as_u64() + .unwrap() + }) + .collect() + } + + /// Draft an entry with `(account, side, amount)` lines; returns its id. + fn draft(&mut self, date: &str, desc: &str, lines: &[(&str, &str, &str)]) -> u64 { + let id = self.ok( + "books.draft_entry", + json!({"date": date, "description": desc}), + )["entry_id"] + .as_u64() + .unwrap(); + for (account, side, amount) in lines { + self.ok( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ); + } + id + } + + fn attach(&mut self, entry: Option, bytes: &[u8]) -> u64 { + let mut args = json!({ + "file_name": "receipt.txt", + "mime_type": "text/plain", + "content_base64": b64(bytes), + }); + if let Some(e) = entry { + args["entry_id"] = json!(e); + } + self.ok("books.attach_receipt", args)["attachment_id"] + .as_u64() + .unwrap() + } + + fn post(&mut self, entry: u64) -> Out { + self.tool( + "books.post_entry", + json!({"entry_id": entry, "confirmed": true}), + ) + } + + fn entry_status(&mut self, entry: u64) -> String { + self.ok("books.get_entry", json!({"entry_id": entry}))["status"] + .as_str() + .unwrap() + .to_string() + } +} + +fn check_response_shape(resp: &Value) { + assert_eq!( + resp["jsonrpc"], "2.0", + "not a JSON-RPC 2.0 response: {resp}" + ); + let has_result = resp.get("result").is_some(); + let has_error = resp.get("error").is_some(); + assert!( + has_result ^ has_error, + "response must have exactly one of result/error: {resp}" + ); + assert!(resp.get("id").is_some(), "response without id: {resp}"); + if has_error { + assert!( + resp["error"]["code"].is_i64(), + "error code must be an integer: {resp}" + ); + } +} + +/// Invariant 3, amounts out: under a money key, no JSON number, and every +/// string matches `^-?\d+\.\d{2,}$`. +fn money_walk(v: &Value, path: &str, key: Option<&str>, bad: &mut Vec) { + let money = key.is_some_and(|k| MONEY_KEYS.contains(&k)); + match v { + Value::Number(_) if money => bad.push(format!("{path} = {v} (JSON number)")), + Value::String(s) if money && !is_money_out(s) => bad.push(format!("{path} = {s:?}")), + Value::Object(m) => { + for (k, child) in m { + money_walk(child, &format!("{path}.{k}"), Some(k), bad); + } + } + Value::Array(a) => { + for (i, child) in a.iter().enumerate() { + money_walk(child, &format!("{path}[{i}]"), key, bad); + } + } + _ => {} + } +} + +/// `^-?\d+\.\d{2,}$` +fn is_money_out(s: &str) -> bool { + let s = s.strip_prefix('-').unwrap_or(s); + let Some((int, frac)) = s.split_once('.') else { + return false; + }; + !int.is_empty() + && int.bytes().all(|b| b.is_ascii_digit()) + && frac.len() >= 2 + && frac.bytes().all(|b| b.is_ascii_digit()) +} + +/// The amount-in grammar, server invariant 3: `-?digits[.digits]`. +fn is_amount_in(s: &str) -> bool { + let s = s.strip_prefix('-').unwrap_or(s); + let (int, frac) = match s.split_once('.') { + Some((i, f)) => (i, Some(f)), + None => (s, None), + }; + !int.is_empty() + && int.bytes().all(|b| b.is_ascii_digit()) + && frac.is_none_or(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit())) +} + +/// Canonical form of a grammatical decimal: no leading zeros, no trailing +/// fractional zeros. Compares two decimal strings by value. +fn canon(s: &str) -> String { + let (neg, s) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s), + }; + let (int, frac) = s.split_once('.').unwrap_or((s, "")); + let int = int.trim_start_matches('0'); + let frac = frac.trim_end_matches('0'); + let int = if int.is_empty() { "0" } else { int }; + let body = if frac.is_empty() { + int.to_string() + } else { + format!("{int}.{frac}") + }; + if neg && body != "0" { + format!("-{body}") + } else { + body + } +} + +fn b64(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let n = (u32::from(chunk[0]) << 16) + | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) + | u32::from(*chunk.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= chunk.len() { + out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +/// A chart used by several tests. +const BASE_CHART: &[(&str, &str, &str)] = &[ + ("1000", "Bank", "asset"), + ("1200", "Accounts Receivable", "asset"), + ("2000", "Accounts Payable", "liability"), + ("2100", "Due to Shareholders", "liability"), + ("3000", "Capital A", "equity"), + ("3001", "Capital B", "equity"), + ("4000", "Revenue", "revenue"), + ("6000", "Rent", "expense"), + ("6999", "General Expenses", "expense"), +]; + +// ------------------------------------------------- invariant 1: schemas + +/// Every tool, read-only and mutating, on a scripted happy path; every +/// success validated by `Mcp::tool`. Returns the server for inspection. +fn happy_path(name: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml( + &dir, + "1000000.00", + "financing_accounts = [\"2100\"]\n\ + [[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + ); + let mut m = Mcp::start(&dir, &toml); + + // Empty books: every read-only tool. + m.ok("books.chart", json!({})); + m.ok( + "books.chart_template", + json!({"entity_type": "partnership", "owners": ["A", "B"]}), + ); + let cats = m.ok("books.tax_categories", json!({})); + m.ok("books.trial_balance", json!({})); + m.ok("books.balance_sheet", json!({})); + m.ok("books.profit_and_loss", json!({})); + m.ok("books.list_assets", json!({})); + m.ok("books.list_classes", json!({})); + m.ok("books.list_payroll_runs", json!({})); + m.ok("books.list_periods", json!({})); + m.ok("books.list_receipts", json!({})); + m.ok("books.list_recurring", json!({})); + m.ok("books.list_schedules", json!({})); + m.ok("books.list_vendors", json!({})); + m.ok("books.digest", json!({"since": "2026-01-01"})); + + // Chart. + m.accounts(BASE_CHART); + m.accounts(&[ + ("1300", "Prepaid Expenses", "asset"), + ("1500", "Equipment", "asset"), + ("1510", "Accumulated Depreciation", "asset"), + ("2400", "Deferred Revenue", "liability"), + ("3999", "Retained Earnings", "equity"), + ("6010", "Platform Fees", "expense"), + ("6500", "Depreciation Expense", "expense"), + ]); + m.ok( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n"}), + ); + m.ok( + "books.renumber_account", + json!({"account": "7000", "code": "7100"}), + ); + let deduction = cats["categories"] + .as_array() + .unwrap() + .iter() + .find(|c| c["kind"] == "deduction") + .expect("a deduction category")["key"] + .clone(); + m.ok( + "books.set_tax_category", + json!({"account": "6000", "category": deduction}), + ); + + // Classes, parties, 1099. + m.ok("books.add_class", json!({"name": "Rondo"})); + m.ok("books.list_classes", json!({})); + let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"] + .as_u64() + .unwrap(); + let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"] + .as_u64() + .unwrap(); + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true}), + ); + m.ok("books.list_vendors", json!({})); + + // Owner contribution: draft, lines, receipt, preview, post. + let e1 = m.draft( + "2026-01-02", + "Owner contribution", + &[ + ("1000", "debit", "10000.00"), + ("3000", "credit", "5000.00"), + ("3001", "credit", "5000.00"), + ], + ); + m.attach(Some(e1), b"contribution receipt"); + m.ok("books.post_entry", json!({"entry_id": e1})); + m.ok( + "books.post_entry", + json!({"entry_id": e1, "confirmed": true, "expected_total": "10000.00"}), + ); + m.ok("books.get_entry", json!({"entry_id": e1})); + + // Rent: vendor, class, unlinked receipt then link, clearing. + let e2 = m.ok( + "books.draft_entry", + json!({"date": "2026-01-15", "description": "January rent", "vendor": "Landlord"}), + )["entry_id"] + .as_u64() + .unwrap(); + m.ok( + "books.add_line", + json!({"entry_id": e2, "account": "6000", "side": "debit", "amount": "100.00", "class": "Rondo"}), + ); + m.ok( + "books.add_line", + json!({"entry_id": e2, "account": "1000", "side": "credit", "amount": "100.00"}), + ); + let r_old = m.attach(None, b"rent receipt v1"); + m.ok( + "books.link_receipt", + json!({"attachment_id": r_old, "entry_id": e2}), + ); + m.post(e2).ok("post rent"); + m.ok( + "books.mark_cleared", + json!({"entry_id": e2, "line_index": 1, "cleared": true}), + ); + m.ok( + "books.set_entry_vendor", + json!({"entry_id": e2, "vendor": vendor.to_string()}), + ); + // Replace the receipt: attach new, unlink old, retire it. + m.attach(Some(e2), b"rent receipt v2"); + m.ok( + "books.unlink_receipt", + json!({"attachment_id": r_old, "reason": "replaced by v2"}), + ); + m.ok( + "books.withdraw_attachment", + json!({"attachment_id": r_old, "reason": "superseded"}), + ); + m.ok("books.list_receipts", json!({"include_withdrawn": true})); + m.ok("books.list_receipts", json!({"entry_id": e2})); + m.ok("books.get_receipt", json!({"attachment_id": r_old})); + let doc = m.ok( + "books.file_document", + json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain", + "content_base64": b64(b"operating agreement"), "description": "OA"}), + )["attachment_id"] + .as_u64() + .unwrap(); + let local = dir.join("local-receipt.txt"); + std::fs::write(&local, b"a local receipt").unwrap(); + m.ok( + "books.import_local_file", + json!({"path": local.to_str().unwrap(), "category": "receipt"}), + ); + + // Reversal: preview, then confirmed. + let e3 = m.draft( + "2026-01-20", + "Mistake", + &[("6999", "debit", "50.00"), ("1000", "credit", "50.00")], + ); + m.attach(Some(e3), b"mistake receipt"); + m.post(e3).ok("post mistake"); + let rev = json!({"entry_id": e3, "date": "2026-01-21", "description": "Reverse mistake"}); + m.ok("books.reverse_entry", rev.clone()); + let mut rev_c = rev; + rev_c["confirmed"] = json!(true); + m.ok("books.reverse_entry", rev_c); + + // FX. + m.ok( + "books.set_fx_rate", + json!({"currency": "EUR", "date": "2026-01-01", "rate": "1.10"}), + ); + let fx = m.ok( + "books.draft_entry", + json!({"date": "2026-04-22", "description": "EUR draft"}), + )["entry_id"] + .as_u64() + .unwrap(); + m.ok( + "books.add_line", + json!({"entry_id": fx, "account": "6999", "side": "debit", "amount": "11.00", + "foreign_currency": "EUR", "foreign_amount": "10.00", "fx_rate": "1.10"}), + ); + + // A/R and A/P. + let inv = m.ok( + "books.create_invoice", + json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + "lines": [{"description": "Consulting", "amount": "500.00"}]}), + )["id"] + .as_u64() + .unwrap(); + m.ok( + "books.receive_payment", + json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), + ); + let bill = m.ok( + "books.create_bill", + json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28", + "lines": [{"description": "Office rent", "amount": "300.00"}]}), + )["id"] + .as_u64() + .unwrap(); + m.ok( + "books.pay_bill", + json!({"document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}), + ); + m.ok("books.ar_aging", json!({"as_of": "2026-06-30"})); + m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); + + // Fixed assets. + let asset = m.ok( + "books.register_asset", + json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "3000.00", + "salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500", + "accum_depr_account": "1510", "expense_account": "6500"}), + )["asset_id"] + .as_u64() + .unwrap(); + m.ok("books.asset_schedule", json!({"asset_id": asset})); + m.ok("books.list_assets", json!({})); + m.ok("books.run_depreciation", json!({"period": "2026-01"})); + let dispose = json!({"asset_id": asset, "date": "2026-06-15", "proceeds": "1000.00", + "proceeds_account": "1000", "gain_loss_account": "7100"}); + m.ok("books.dispose_asset", dispose.clone()); + let mut dispose_c = dispose; + dispose_c["confirmed"] = json!(true); + dispose_c["receipt_attachment_ids"] = json!([doc]); + m.ok("books.dispose_asset", dispose_c); + + // Schedules and recurring. + m.ok( + "books.create_schedule", + json!({"kind": "prepaid_expense", "description": "Insurance", "total": "1200.00", + "start": "2026-01-01", "months": 12, "balance_account": "1300", "pl_account": "6999"}), + ); + m.ok("books.list_schedules", json!({})); + m.ok("books.run_schedules", json!({"through": "2026-03-31"})); + m.ok( + "books.run_schedules", + json!({"through": "2026-03-31", "confirmed": true}), + ); + m.ok( + "books.create_recurring", + json!({"description": "Hosting", "frequency": "monthly", "start": "2026-04-30", + "end": "2026-06-30", "lines": [ + {"account": "6999", "side": "debit", "amount": "10.00"}, + {"account": "1000", "side": "credit", "amount": "10.00"}]}), + ); + m.ok("books.list_recurring", json!({})); + m.ok("books.run_recurring", json!({"through": "2026-06-30"})); + + // Payouts. + m.ok( + "books.record_payout", + json!({"date": "2026-02-28", "description": "App Store payout", "deposit_account": "1000", + "deposit": "85.00", "revenue": [{"account": "4000", "amount": "100.00"}], + "fees": [{"account": "6010", "amount": "15.00"}]}), + ); + + // Payroll. + m.ok( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [{ + "employee_name": "Pat", "gross_pay": "1000.00", "federal_withholding": "100.00", + "state_withholding": "50.00", "ss_employee": "62.00", "medicare_employee": "14.50", + "ss_employer": "62.00", "medicare_employer": "14.50", "other_deductions": "0.00"}]}), + ); + m.ok("books.list_payroll_runs", json!({})); + m.ok( + "books.remit_payroll_taxes", + json!({"date": "2026-02-20", "cash_account": "1000"}), + ); + + // Bank import and reconciliation. + m.ok( + "books.import_bank_csv", + json!({"csv_text": "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n", + "bank_account_code": "1000", "default_offset_code": "6999"}), + ); + m.ok( + "books.reconcile", + json!({"account": "1000", "statement_date": "2026-12-31", "statement_balance": "100.00"}), + ); + + // Periods. + let p = m.ok( + "books.create_period", + json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}), + )["period_id"] + .as_u64() + .unwrap(); + m.ok("books.list_periods", json!({})); + m.ok("books.close_period", json!({"period_id": p})); + m.ok( + "books.close_period", + json!({"period_id": p, "confirmed": true}), + ); + + // Reports over the populated books. + m.ok("books.chart", json!({})); + m.ok("books.trial_balance", json!({"as_of": "2026-12-31"})); + m.ok("books.balance_sheet", json!({"as_of": "2026-12-31"})); + m.ok( + "books.profit_and_loss", + json!({"start": "2026-01-01", "end": "2026-12-31"}), + ); + m.ok("books.profit_and_loss", json!({"class": "Rondo"})); + m.ok( + "books.profit_and_loss_by_class", + json!({"start": "2026-01-01", "end": "2026-12-31"}), + ); + m.ok( + "books.cash_flow", + json!({"start": "2026-01-01", "end": "2026-12-31"}), + ); + m.ok("books.digest", json!({"since": "2026-01-01"})); + m.ok("books.search_entries", json!({"text": "rent", "limit": 10})); + m.ok("books.vendor_1099", json!({"year": 2026})); + m.ok("books.verify", json!({})); + m.ok("books.show", json!({"entry_id": e1})); + m.ok( + "books.show", + json!({"report": "balance_sheet", "end": "2026-12-31"}), + ); + for (format, report) in [ + ("csv", "trial_balance"), + ("json", "profit_and_loss"), + ("xlsx", "balance_sheet"), + ("pdf", "profit_and_loss"), + ("csv", "cash_flow"), + ("iif", "journal"), + ("csv", "general_ledger"), + ("json", "members_equity"), + ("csv", "tax_summary"), + ] { + m.ok( + "books.export", + json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}), + ); + } + m.ok( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "include_receipts": false}), + ); + + m +} + +/// Invariant 1 over every tool. Fails listing any tool never exercised +/// successfully. +#[test] +fn inv1_every_tool_result_matches_its_output_schema() { + let m = happy_path("inv1"); + let missing: Vec<&String> = m + .schemas + .keys() + .filter(|t| !m.exercised.contains(*t)) + .collect(); + assert!(missing.is_empty(), "tools never exercised: {missing:?}"); +} + +/// Invariant 3, amounts out, weak form: no money key ever holds a JSON +/// number, over every tool's results. +#[test] +fn inv3_amounts_out_are_never_json_numbers() { + let m = happy_path("inv3-out-numbers"); + let numbers: Vec<&String> = m + .money_violations + .iter() + .filter(|v| v.contains("JSON number")) + .collect(); + assert!(numbers.is_empty(), "{numbers:#?}"); +} + +/// Invariant 3, amounts out, full form: every amount is a decimal string +/// with at least two places. +#[test] +fn inv3_amounts_out_have_at_least_two_places() { + let m = happy_path("inv3-out-places"); + assert!( + m.money_violations.is_empty(), + "invariant 3 (amounts out): {:#?}", + m.money_violations + ); +} + +// ------------------------------------- invariants 2 and 7: state machine + +/// An id argument: usually one the books handed out (by index into what +/// this run has created), sometimes a raw number that may not exist. +#[derive(Debug, Clone, Copy)] +enum Pick { + Known(usize), + Raw(u64), +} + +impl Pick { + fn resolve(self, known: &[u64]) -> u64 { + match self { + Pick::Known(i) if !known.is_empty() => known[i % known.len()], + Pick::Known(i) => i as u64, + Pick::Raw(n) => n, + } + } +} + +fn pick() -> impl Strategy { + prop_oneof![3 => (0usize..16).prop_map(Pick::Known), 1 => (0u64..12).prop_map(Pick::Raw)] +} + +/// Ids handed out so far in a run. +#[derive(Default)] +struct Known { + entries: Vec, + attachments: Vec, + periods: Vec, + vendors: Vec, + bills: Vec, +} + +/// One step of the random walk. +#[derive(Debug, Clone)] +enum Op { + Draft { + date: String, + desc: String, + }, + /// Draft plus two lines (debit `account`, credit 1000), optionally + /// with a receipt: several calls, each checked. + Balanced { + date: String, + desc: String, + account: String, + amount: String, + receipt: bool, + }, + AddLine { + entry: Pick, + account: String, + side: String, + amount: String, + }, + Post { + entry: Pick, + confirmed: Option, + }, + Reverse { + entry: Pick, + date: String, + confirmed: Option, + }, + Attach { + entry: Option, + content: String, + }, + Link { + att: Pick, + entry: Pick, + }, + Unlink { + att: Pick, + }, + Withdraw { + att: Pick, + }, + CreatePeriod { + start: String, + end: String, + }, + ClosePeriod { + id: Pick, + confirmed: bool, + }, + AddAccount { + code: String, + ty: String, + }, + AddVendor { + name: String, + }, + CreateBill { + party: Pick, + amount: String, + }, + PayBill { + doc: Pick, + amount: String, + }, + Payout { + deposit: String, + }, + Renumber { + account: String, + code: String, + }, + Read(u8), + UnknownTool, + MissingArgs, + WrongTypes, +} + +fn date_strategy() -> impl Strategy { + prop_oneof![ + 8 => (1u32..=6, 1u32..=28).prop_map(|(m, d)| format!("2026-{m:02}-{d:02}")), + 1 => prop::sample::select(vec![ + "2026-02-30", "2026-13-01", "26-1-1", "", "2026/01/05", "yesterday", "2026-1-5", + ]) + .prop_map(String::from), + ] +} + +fn amount_strategy() -> impl Strategy { + prop_oneof![ + 6 => (1u64..=6000).prop_map(|c| format!("{}.{:02}", c / 100, c % 100)), + 2 => prop::sample::select(vec!["25.00", "25.01", "20.00", "5"]).prop_map(String::from), + 2 => prop::sample::select(vec![ + "1e3", "+5", ".5", "5.", "1,000", " 5", "abc", "", "-", "-5.00", "0", "0.001", "NaN", + ]) + .prop_map(String::from), + ] +} + +fn account_strategy() -> impl Strategy { + prop::sample::select(vec![ + "1000", "1000", "2000", "4000", "6000", "6000", "6999", "1", "3", "9999", "", "abc", + ]) + .prop_map(String::from) +} + +fn desc_strategy() -> impl Strategy { + prop::sample::select(vec!["Coffee", "coffee ", "Rent", "Supplies"]).prop_map(String::from) +} + +fn op_strategy() -> impl Strategy { + let confirmed = || prop::option::of(any::()); + prop_oneof![ + 2 => (date_strategy(), desc_strategy()).prop_map(|(date, desc)| Op::Draft { date, desc }), + 5 => (date_strategy(), desc_strategy(), + prop::sample::select(vec!["6000", "6999", "2000", "9999"]), + amount_strategy(), any::()) + .prop_map(|(date, desc, a, amount, receipt)| Op::Balanced { + date, desc, account: a.to_string(), amount, receipt, + }), + 5 => (pick(), account_strategy(), + prop::sample::select(vec!["debit", "credit", "debit", "credit", "Debit", "sideways"]), + amount_strategy()) + .prop_map(|(entry, account, s, amount)| Op::AddLine { entry, account, side: s.to_string(), amount }), + 6 => (pick(), confirmed()).prop_map(|(entry, confirmed)| Op::Post { entry, confirmed }), + 2 => (pick(), date_strategy(), confirmed()) + .prop_map(|(entry, date, confirmed)| Op::Reverse { entry, date, confirmed }), + 3 => (prop::option::of(pick()), prop_oneof![ + 4 => prop::collection::vec(any::(), 1..24).prop_map(|b| b64(&b)), + 1 => Just("!!not base64!!".to_string()), + ]) + .prop_map(|(entry, content)| Op::Attach { entry, content }), + 1 => (pick(), pick()).prop_map(|(att, entry)| Op::Link { att, entry }), + 1 => pick().prop_map(|att| Op::Unlink { att }), + 1 => pick().prop_map(|att| Op::Withdraw { att }), + 1 => (date_strategy(), date_strategy()).prop_map(|(start, end)| Op::CreatePeriod { start, end }), + 1 => (pick(), any::()).prop_map(|(id, confirmed)| Op::ClosePeriod { id, confirmed }), + 1 => (prop::sample::select(vec!["1000", "5000", "5001", "", "x"]), + prop::sample::select(vec!["asset", "expense", "revenue", "bogus"])) + .prop_map(|(c, t)| Op::AddAccount { code: c.to_string(), ty: t.to_string() }), + 1 => prop::sample::select(vec!["Acme", "Acme", "", "1"]).prop_map(|n| Op::AddVendor { name: n.to_string() }), + 1 => (pick(), amount_strategy()).prop_map(|(party, amount)| Op::CreateBill { party, amount }), + 1 => (pick(), amount_strategy()).prop_map(|(doc, amount)| Op::PayBill { doc, amount }), + 1 => amount_strategy().prop_map(|deposit| Op::Payout { deposit }), + 1 => (account_strategy(), prop::sample::select(vec!["8000", "1000", ""])) + .prop_map(|(account, c)| Op::Renumber { account, code: c.to_string() }), + 3 => (0u8..6).prop_map(Op::Read), + 1 => Just(Op::UnknownTool), + 1 => Just(Op::MissingArgs), + 1 => Just(Op::WrongTypes), + ] +} + +/// The id a success handed out under `key`, if any. +fn new_id(out: &Out, key: &str) -> Option { + match out { + Out::Ok(v) => v[key].as_u64(), + _ => None, + } +} + +fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) { + let opt = |v: Option| v.map_or(Value::Null, Value::Bool); + let (tool, args) = match op { + Op::Draft { date, desc } => ( + "books.draft_entry", + json!({"date": date, "description": desc}), + ), + Op::Balanced { + date, + desc, + account, + amount, + receipt, + } => { + let out = m.tool( + "books.draft_entry", + json!({"date": date, "description": desc}), + ); + let Some(e) = new_id(&out, "entry_id") else { + return; + }; + k.entries.push(e); + for (acct, side) in [(account.as_str(), "debit"), ("1000", "credit")] { + m.tool( + "books.add_line", + json!({"entry_id": e, "account": acct, "side": side, "amount": amount}), + ); + } + if *receipt { + let bytes = format!("receipt for entry {e}"); + let out = m.tool( + "books.attach_receipt", + json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain", + "content_base64": b64(bytes.as_bytes())}), + ); + k.attachments.extend(new_id(&out, "attachment_id")); + } + return; + } + Op::AddLine { + entry, + account, + side, + amount, + } => ( + "books.add_line", + json!({"entry_id": entry.resolve(&k.entries), "account": account, "side": side, + "amount": amount}), + ), + Op::Post { entry, confirmed } => ( + "books.post_entry", + json!({"entry_id": entry.resolve(&k.entries), "confirmed": opt(*confirmed)}), + ), + Op::Reverse { + entry, + date, + confirmed, + } => ( + "books.reverse_entry", + json!({"entry_id": entry.resolve(&k.entries), "date": date, "description": "undo", + "confirmed": opt(*confirmed)}), + ), + Op::Attach { entry, content } => ( + "books.attach_receipt", + json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin", + "mime_type": "application/octet-stream", "content_base64": content}), + ), + Op::Link { att, entry } => ( + "books.link_receipt", + json!({"attachment_id": att.resolve(&k.attachments), + "entry_id": entry.resolve(&k.entries)}), + ), + Op::Unlink { att } => ( + "books.unlink_receipt", + json!({"attachment_id": att.resolve(&k.attachments), "reason": "test"}), + ), + Op::Withdraw { att } => ( + "books.withdraw_attachment", + json!({"attachment_id": att.resolve(&k.attachments), "reason": "test"}), + ), + Op::CreatePeriod { start, end } => ( + "books.create_period", + json!({"name": "P", "start": start, "end": end}), + ), + Op::ClosePeriod { id, confirmed } => ( + "books.close_period", + json!({"period_id": id.resolve(&k.periods), "confirmed": confirmed}), + ), + Op::AddAccount { code, ty } => ( + "books.add_account", + json!({"code": code, "name": format!("Acct {code}"), "account_type": ty}), + ), + Op::AddVendor { name } => ("books.add_vendor", json!({"name": name})), + Op::CreateBill { party, amount } => ( + "books.create_bill", + json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01", + "due_date": "2026-03-31", "lines": [{"description": "thing", "amount": amount}]}), + ), + Op::PayBill { doc, amount } => ( + "books.pay_bill", + json!({"document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount, + "cash_account": "1000"}), + ), + Op::Payout { deposit } => ( + "books.record_payout", + json!({"date": "2026-03-31", "description": "payout", "deposit_account": "1000", + "deposit": deposit, "revenue": [{"account": "4000", "amount": "100.00"}], + "fees": [{"account": "6999", "amount": "15.00"}]}), + ), + Op::Renumber { account, code } => ( + "books.renumber_account", + json!({"account": account, "code": code}), + ), + Op::Read(i) => match i { + 0 => ("books.chart", json!({})), + 1 => ("books.trial_balance", json!({"as_of": "2026-12-31"})), + 2 => ( + "books.get_entry", + json!({"entry_id": Pick::Known(0).resolve(&k.entries)}), + ), + 3 => ("books.list_receipts", json!({})), + 4 => ("books.list_periods", json!({})), + _ => ("books.digest", json!({"since": "2026-01-01"})), + }, + Op::UnknownTool => ("books.no_such_tool", json!({})), + Op::MissingArgs => ("books.add_line", json!({"entry_id": 1})), + Op::WrongTypes => ( + "books.post_entry", + json!({"entry_id": "one", "confirmed": "yes"}), + ), + }; + // Mcp::tool checks invariants 1, 2, 3 (schema, text) and 7. + let out = m.tool(tool, args); + match tool { + "books.draft_entry" | "books.record_payout" => k.entries.extend(new_id(&out, "entry_id")), + "books.reverse_entry" => k.entries.extend(new_id(&out, "reversal_id")), + "books.attach_receipt" => k.attachments.extend(new_id(&out, "attachment_id")), + "books.create_period" => k.periods.extend(new_id(&out, "period_id")), + "books.add_vendor" => k.vendors.extend(new_id(&out, "id")), + "books.create_bill" => { + k.bills.extend(new_id(&out, "id")); + k.entries.extend(new_id(&out, "entry_id")); + } + _ => {} + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })] + + /// Invariants 2 and 7 under random call sequences: every call gets + /// exactly one response with its id, result xor error; any refusal + /// leaves the journal byte-identical; successes match their schemas; + /// and the server keeps answering afterwards. + #[test] + fn inv2_inv7_random_calls_keep_books_and_server_intact( + ops in prop::collection::vec(op_strategy(), 30..60) + ) { + let dir = test_dir("sm"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let mut known = Known::default(); + for op in &ops { + run_op(&mut m, &mut known, op); + } + // Still alive and consistent at the end. + m.ok("books.chart", json!({})); + let tb = m.ok("books.trial_balance", json!({"as_of": "2026-12-31"})); + prop_assert_eq!(&tb["balanced"], &json!(true)); + drop(m); + let _ = std::fs::remove_dir_all(&dir); + } +} + +// ---------------------------------------------- invariant 3: amounts in + +const REFUSED_AMOUNTS: &[&str] = &[ + "1e3", "1E3", "+5", ".5", "5.", "1_000", "1,000", " 5", "5 ", "", "-", "abc", "١٢٣", "5.0.0", + "0x10", "5..0", "--5", "5-", "Infinity", "NaN", "5", +]; + +/// Grammatical amounts with at most two places and a positive value: what +/// the core is expected to accept on a line. +const ACCEPTED_AMOUNTS: &[&str] = &["5", "5.00", "123.45", "5.5", "007.50", "1000000.00"]; + +fn amounts_server(name: &str) -> (PathBuf, Mcp, u64) { + let dir = test_dir(name); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let e = m.draft("2026-03-01", "amounts", &[]); + (dir, m, e) +} + +#[test] +fn inv3_add_line_amount_grammar_table() { + let (_dir, mut m, e) = amounts_server("inv3-table"); + for a in REFUSED_AMOUNTS { + m.refuse( + "books.add_line", + json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}), + ); + } + for a in ACCEPTED_AMOUNTS { + m.ok( + "books.add_line", + json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}), + ); + // Amounts out: the stored line keeps the value, never rounded. + let entry = m.ok("books.get_entry", json!({"entry_id": e})); + let last = entry["lines"].as_array().unwrap().last().unwrap().clone(); + let out = last["amount"].as_str().expect("amount is a string"); + assert_eq!(canon(out), canon(a), "line amount {out} for input {a}"); + } + assert!(m.money_violations.is_empty(), "{:#?}", m.money_violations); +} + +/// The amount grammar applies to every amount-taking tool, not just +/// add_line. +#[test] +fn inv3_other_tools_refuse_ungrammatical_amounts() { + let (_dir, mut m, e) = amounts_server("inv3-other"); + m.ok( + "books.add_line", + json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00"}), + ); + m.ok( + "books.add_line", + json!({"entry_id": e, "account": "1000", "side": "credit", "amount": "10.00"}), + ); + let vendor = m.ok("books.add_vendor", json!({"name": "V"}))["id"].clone(); + for bad in ["1e1", "+10.00", "10,00", " 10.00", "10.00 "] { + m.refuse( + "books.post_entry", + json!({"entry_id": e, "confirmed": true, "expected_total": bad}), + ); + m.refuse( + "books.create_bill", + json!({"party_id": vendor, "date": "2026-03-01", "due_date": "2026-03-31", + "lines": [{"description": "x", "amount": bad}]}), + ); + m.refuse( + "books.reconcile", + json!({"account": "1000", "statement_date": "2026-03-31", "statement_balance": bad}), + ); + m.refuse( + "books.record_payout", + json!({"date": "2026-03-31", "description": "p", "deposit_account": "1000", + "deposit": bad, "revenue": [{"account": "4000", "amount": "10.00"}]}), + ); + m.refuse( + "books.create_schedule", + json!({"kind": "prepaid_expense", "description": "s", "total": bad, + "start": "2026-01-01", "months": 12, "balance_account": "1200", + "pl_account": "6000"}), + ); + } +} + +fn amount_char() -> impl Strategy { + prop::sample::select(vec![ + '0', '1', '5', '9', '0', '1', '5', '9', '.', '.', '-', '+', 'e', 'E', '_', ',', ' ', '١', + 'x', + ]) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 16, failure_persistence: None, ..ProptestConfig::default() })] + + /// Random strings: ungrammatical ones are refused; grammatical ones + /// with at most two places and a positive value are accepted and come + /// back with the same value. Others (zero, negative, sub-cent) are the + /// core's business and aren't asserted. + #[test] + fn inv3_add_line_amount_grammar_random( + amounts in prop::collection::vec( + prop::collection::vec(amount_char(), 0..9).prop_map(|cs| cs.into_iter().collect::()), + 20..40, + ) + ) { + let (dir, mut m, e) = amounts_server("inv3-prop"); + for a in &amounts { + let out = m.tool( + "books.add_line", + json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}), + ); + if !is_amount_in(a) { + prop_assert!(out.refused(), "ungrammatical {:?} accepted", a); + continue; + } + let places = a.split_once('.').map_or(0, |(_, f)| f.len()); + let positive = !a.starts_with('-') && canon(a) != "0"; + if places <= 2 && positive && a.len() <= 7 { + prop_assert!(!out.refused(), "grammatical {:?} refused: {:?}", a, out); + let entry = m.ok("books.get_entry", json!({"entry_id": e})); + let last = entry["lines"].as_array().unwrap().last().unwrap().clone(); + prop_assert_eq!(canon(last["amount"].as_str().unwrap()), canon(a)); + } + } + prop_assert!(m.money_violations.is_empty(), "{:#?}", m.money_violations); + drop(m); + let _ = std::fs::remove_dir_all(&dir); + } +} + +// ----------------------------------------- invariant 4: preview changes nothing + +#[test] +fn inv4_post_and_reverse_preview_without_confirmed() { + let dir = test_dir("inv4"); + let toml = books_toml(&dir, "1000000.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let e = m.draft( + "2026-04-01", + "Rent", + &[("6000", "debit", "40.00"), ("1000", "credit", "40.00")], + ); + m.attach(Some(e), b"rent"); + + for args in [ + json!({"entry_id": e}), + json!({"entry_id": e, "confirmed": false}), + json!({"entry_id": e, "confirmed": null}), + ] { + let before = m.journal(); + let p = m.ok("books.post_entry", args.clone()); + assert_eq!(p["status"], "preview", "{args}: {p}"); + assert!(m.journal() == before, "post_entry preview {args} wrote"); + assert_eq!(m.entry_status(e), "draft"); + } + let before = m.journal(); + let posted = m.post(e).ok("confirmed post"); + assert_eq!(posted["status"], "posted"); + assert!(m.journal() != before, "confirmed post wrote nothing"); + assert_eq!(m.entry_status(e), "posted"); + + let rev = json!({"entry_id": e, "date": "2026-04-02", "description": "undo"}); + for extra in [json!({}), json!({"confirmed": false})] { + let mut args = rev.clone(); + args.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + let before = m.journal(); + let p = m.ok("books.reverse_entry", args.clone()); + assert_eq!(p["status"], "preview", "{args}: {p}"); + assert!(m.journal() == before, "reverse_entry preview {args} wrote"); + } + let mut args = rev; + args["confirmed"] = json!(true); + let before = m.journal(); + let r = m.ok("books.reverse_entry", args); + assert_eq!(r["status"], "posted", "{r}"); + assert!(m.journal() != before); + let tb = m.ok("books.trial_balance", json!({"as_of": "2026-12-31"})); + assert_eq!(tb["balanced"], true); +} + +// ------------------------------------------ invariant 5: receipt policy + +#[test] +fn inv5_threshold_is_strictly_greater_than() { + let dir = test_dir("inv5-25"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let at = m.draft( + "2026-05-01", + "At threshold", + &[("6000", "debit", "25.00"), ("1000", "credit", "25.00")], + ); + m.post(at).ok("25.00 posts without a receipt"); + let over = m.draft( + "2026-05-02", + "Over threshold", + &[("6000", "debit", "25.01"), ("1000", "credit", "25.01")], + ); + let preview = m.ok("books.post_entry", json!({"entry_id": over})); + assert_eq!(preview["receipt_missing"], true, "{preview}"); + let err = m.post(over).err_text(); + assert!(err.contains("receipt_required"), "{err}"); + assert_eq!(m.entry_status(over), "draft"); + m.attach(Some(over), b"receipt for 25.01"); + m.post(over).ok("25.01 posts with a receipt"); +} + +#[test] +fn inv5_zero_threshold_needs_a_receipt_on_every_entry() { + let dir = test_dir("inv5-0"); + let toml = books_toml(&dir, "0.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let e = m.draft( + "2026-05-01", + "A penny", + &[("6000", "debit", "0.01"), ("1000", "credit", "0.01")], + ); + let err = m.post(e).err_text(); + assert!(err.contains("receipt_required"), "{err}"); + m.attach(Some(e), b"penny receipt"); + m.post(e).ok("with a receipt"); + // Keep posted: the last receipt can't be unlinked from a covered entry. + let receipts = m.ok("books.list_receipts", json!({"entry_id": e})); + let att = receipts["receipts"][0]["attachment_id"].clone(); + let err = m.refuse( + "books.unlink_receipt", + json!({"attachment_id": att, "reason": "try"}), + ); + assert!(err.contains("receipt_required"), "{err}"); +} + +#[test] +fn inv5_splitting_does_not_dodge_the_threshold() { + let dir = test_dir("inv5-split"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let a = m.draft( + "2026-05-03", + "Office supplies", + &[("6999", "debit", "20.00"), ("1000", "credit", "20.00")], + ); + m.post(a).ok("first half posts"); + // Same day, same description (case and surrounding whitespace aside), + // same debit account: grouped, 40.00 > 25.00. + let b = m.draft( + "2026-05-03", + " OFFICE supplies ", + &[("6999", "debit", "20.00"), ("1000", "credit", "20.00")], + ); + let err = m.post(b).err_text(); + assert!(err.contains("receipt_required"), "{err}"); + // Not the signature: another day, or another debit account. + let c = m.draft( + "2026-05-04", + "Office supplies", + &[("6999", "debit", "20.00"), ("1000", "credit", "20.00")], + ); + m.post(c).ok("another day"); + let d = m.draft( + "2026-05-03", + "Office supplies", + &[("6000", "debit", "20.00"), ("1000", "credit", "20.00")], + ); + m.post(d).ok("another debit account"); + // A receipt clears it. + m.attach(Some(b), b"supplies receipt"); + m.post(b).ok("split with a receipt"); +} + +#[test] +fn inv5_reversals_are_exempt() { + let dir = test_dir("inv5-rev"); + let toml = books_toml(&dir, "0.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let e = m.draft( + "2026-05-01", + "Big", + &[("6000", "debit", "500.00"), ("1000", "credit", "500.00")], + ); + m.attach(Some(e), b"big receipt"); + m.post(e).ok("post"); + m.ok( + "books.reverse_entry", + json!({"entry_id": e, "date": "2026-05-02", "description": "undo", "confirmed": true}), + ); +} + +/// Policy invariant 4: a threshold that isn't a decimal, or is below +/// zero, is refused at startup. +#[test] +fn inv5_bad_threshold_refused_at_startup() { + for bad in ["-1", "abc", "-0.01", "", "1,000", "ten"] { + let dir = test_dir("inv5-startup"); + let toml = books_toml(&dir, bad, ""); + let mut child = spawn_child(&dir, &toml); + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "spec-test", "version": "0.1.0"}}}); + let _ = writeln!(stdin, "{init}"); + let _ = stdin.flush(); + let (tx, rx) = channel(); + std::thread::spawn(move || { + let mut line = String::new(); + let _ = BufReader::new(stdout).read_line(&mut line); + let _ = tx.send(line); + }); + let line = rx.recv_timeout(TIMEOUT).unwrap_or_default(); + let answered = serde_json::from_str::(&line) + .ok() + .is_some_and(|v| v.get("result").is_some()); + drop(stdin); + let status = wait_with_timeout(&mut child, TIMEOUT); + assert!( + !answered, + "threshold {bad:?}: server completed initialize: {line}" + ); + assert!( + status.is_some_and(|s| !s.success()), + "threshold {bad:?}: server did not exit nonzero ({status:?})" + ); + } +} + +fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Option { + let start = std::time::Instant::now(); + loop { + if let Ok(Some(s)) = child.try_wait() { + return Some(s); + } + if start.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +// --------------------------------------- invariant 6: ambiguous references + +#[test] +fn inv6_ambiguous_account_reference_refused() { + let dir = test_dir("inv6-acct"); + let toml = books_toml(&dir, "1000000.00", ""); + let mut m = Mcp::start(&dir, &toml); + // Account "2" first, then others; find one whose id is 2 and whose + // code is not "2". + let ids = m.accounts(&[ + ("2", "Two by code", "expense"), + ("1000", "Bank", "asset"), + ("6000", "Rent", "expense"), + ]); + assert!( + ids.contains(&2) && ids[0] != 2, + "need an account with id 2 that is not code \"2\": ids {ids:?}" + ); + let e = m.draft("2026-06-01", "ambiguity", &[]); + let err = m.refuse( + "books.add_line", + json!({"entry_id": e, "account": "2", "side": "debit", "amount": "10.00"}), + ); + assert!(!err.is_empty()); + m.refuse( + "books.reconcile", + json!({"account": "2", "statement_date": "2026-06-30", "statement_balance": "0.00"}), + ); + m.refuse( + "books.set_tax_category", + json!({"account": "2", "category": null}), + ); + m.refuse( + "books.renumber_account", + json!({"account": "2", "code": "6100"}), + ); + // Unique references work: a code nobody's id, an id nobody's code. + m.ok( + "books.add_line", + json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00"}), + ); + let unique_id = ids + .iter() + .copied() + .find(|&i| i != 2 && !["2", "1000", "6000"].contains(&i.to_string().as_str())) + .expect("an id that is no account's code"); + m.ok( + "books.add_line", + json!({"entry_id": e, "account": unique_id.to_string(), "side": "credit", "amount": "10.00"}), + ); + let entry = m.ok("books.get_entry", json!({"entry_id": e})); + assert_eq!(entry["lines"].as_array().unwrap().len(), 2, "{entry}"); +} + +#[test] +fn inv6_ambiguous_vendor_reference_refused() { + let dir = test_dir("inv6-vendor"); + let toml = books_toml(&dir, "1000000.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let acme = m.ok("books.add_vendor", json!({"name": "Acme"}))["id"] + .as_u64() + .unwrap(); + // A vendor literally named after Acme's id. + let named = m.ok("books.add_vendor", json!({"name": acme.to_string()}))["id"] + .as_u64() + .unwrap(); + assert_ne!(acme, named); + let amb = acme.to_string(); + m.refuse( + "books.set_vendor_1099", + json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false}), + ); + m.refuse( + "books.draft_entry", + json!({"date": "2026-06-01", "description": "x", "vendor": amb}), + ); + let e = m.draft("2026-06-01", "tag me", &[]); + m.refuse( + "books.set_entry_vendor", + json!({"entry_id": e, "vendor": amb}), + ); + // Unique: the name "Acme", and the second vendor's id (no vendor has + // that name). + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false}), + ); + let out = m.ok( + "books.set_entry_vendor", + json!({"entry_id": e, "vendor": named.to_string()}), + ); + assert!(out.get("vendor").is_some(), "{out}"); +} + +#[test] +fn inv6_ambiguous_class_reference_refused() { + let dir = test_dir("inv6-class"); + let toml = books_toml(&dir, "1000000.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let alpha = m.ok("books.add_class", json!({"name": "Alpha"}))["id"] + .as_u64() + .unwrap(); + let named = m.ok("books.add_class", json!({"name": alpha.to_string()}))["id"] + .as_u64() + .unwrap(); + assert_ne!(alpha, named); + let e = m.draft("2026-06-01", "classy", &[]); + // add_line with an ambiguous class is refused too, but writes the + // line first: see add_line_bad_class_leaves_journal_unchanged. + m.refuse("books.profit_and_loss", json!({"class": alpha.to_string()})); + + m.ok( + "books.add_line", + json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00", "class": "Alpha"}), + ); + m.ok( + "books.add_line", + json!({"entry_id": e, "account": "1000", "side": "credit", "amount": "10.00", + "class": named.to_string()}), + ); + m.ok("books.profit_and_loss", json!({"class": "Alpha"})); +} + +/// Invariant 2: a refused add_line leaves the journal unchanged, here when +/// the line's class doesn't resolve (unknown, or ambiguous per invariant +/// 6). +#[test] +fn add_line_bad_class_leaves_journal_unchanged() { + let dir = test_dir("bug-class"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + m.ok("books.add_class", json!({"name": "Alpha"})); + m.ok("books.add_class", json!({"name": "1"})); + let e = m.draft("2026-06-01", "classy", &[]); + for class in ["Nope", "99", "1"] { + // Mcp::tool asserts the journal is unchanged on refusal. + m.refuse( + "books.add_line", + json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00", + "class": class}), + ); + } + let entry = m.ok("books.get_entry", json!({"entry_id": e})); + assert_eq!(entry["lines"], json!([]), "{entry}"); +} + +/// Invariant 6 and "validate all tool inputs": profit_and_loss_by_class +/// declares a `class` filter; an unknown or ambiguous class must be +/// refused, not ignored. +#[test] +fn profit_and_loss_by_class_validates_class() { + let dir = test_dir("bug-pnl-class"); + let toml = books_toml(&dir, "1000000.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + m.ok("books.add_class", json!({"name": "Alpha"})); + m.ok("books.add_class", json!({"name": "1"})); + m.refuse("books.profit_and_loss_by_class", json!({"class": "Nope"})); + m.refuse("books.profit_and_loss_by_class", json!({"class": "1"})); +} + +/// Invariant 2 for other class-taking tools: a payout with an unknown +/// class is refused and creates no draft. +#[test] +fn payout_bad_class_leaves_journal_unchanged() { + let dir = test_dir("payout-class"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + m.refuse( + "books.record_payout", + json!({"date": "2026-03-31", "description": "p", "deposit_account": "1000", + "deposit": "85.00", "revenue": [{"account": "4000", "amount": "100.00", "class": "Nope"}], + "fees": [{"account": "6999", "amount": "15.00", "class": "Nope"}]}), + ); +} + +// --------------------------------------------- JSON-RPC 2.0 conformance + +/// mcp-tools.md: "Unknown tools" are protocol errors (JSON-RPC errors), +/// not isError results. +#[test] +fn jsonrpc_unknown_tool_is_a_protocol_error() { + let dir = test_dir("rpc-tool"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + let out = m.tool("books.no_such_tool", json!({})); + assert!( + matches!(out, Out::RpcErr(_)), + "unknown tool should be a JSON-RPC error, got {out:?}" + ); + m.ok("books.chart", json!({})); +} + +/// jsonrpc-2.0.md §5.1: unknown method → -32601. +#[test] +fn jsonrpc_unknown_method_is_32601() { + let dir = test_dir("rpc-method"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + let resp = m.rpc("books/does_not_exist", json!({})); + assert_eq!(resp["error"]["code"], -32601, "{resp}"); + m.ok("books.chart", json!({})); +} + +/// jsonrpc-2.0.md §5 and §5.1: invalid JSON → a response with code +/// -32700 and id null; the server keeps serving. +#[test] +fn jsonrpc_parse_error_is_32700_with_null_id() { + let dir = test_dir("rpc-parse"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.send_line("{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": "); + let resp = m.recv(Duration::from_secs(5)); + let resp = resp.expect("no response to a malformed JSON line"); + check_response_shape(&resp); + assert_eq!(resp["error"]["code"], -32700, "{resp}"); + assert_eq!(resp["id"], Value::Null, "{resp}"); +} + +/// jsonrpc-2.0.md §5: `id` is REQUIRED in every response; when it can't +/// be detected (Invalid Request) it MUST be null, else the request's. +#[test] +fn jsonrpc_invalid_request_response_has_id() { + let dir = test_dir("rpc-invalid"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + for (junk, id) in [ + ("[]", Value::Null), + ("{\"jsonrpc\": \"2.0\", \"id\": 98}", json!(98)), + ] { + m.send_line(junk); + let resp = m.recv(Duration::from_secs(5)).expect("no response"); + assert_eq!(resp["error"]["code"], -32600, "{resp}"); + assert!( + resp.get("id").is_some(), + "{junk}: response has no id: {resp}" + ); + // A detectable id should be echoed; Null when it isn't. + assert!(resp["id"] == id || resp["id"].is_null(), "{junk}: {resp}"); + } +} + +/// Whatever the server does with a malformed line, it keeps serving +/// (invariant 7). +#[test] +fn jsonrpc_server_survives_malformed_input() { + let dir = test_dir("rpc-survive"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + for junk in [ + "{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": ", + "not json at all", + "[]", + "{\"jsonrpc\": \"2.0\", \"id\": 98}", + ] { + m.send_line(junk); + // Drain anything it answered with, without requiring an answer + // (the missing id is jsonrpc_invalid_request_response_has_id). + while let Some(v) = m.recv(Duration::from_millis(300)) { + assert_eq!(v["jsonrpc"], "2.0", "{v}"); + assert!(v.get("result").is_some() ^ v.get("error").is_some(), "{v}"); + } + m.ok("books.chart", json!({})); + } +} + +/// Missing or mistyped arguments: mcp-tools.md lists "invalid arguments" +/// as protocol errors and "invalid input data" as tool errors, so either +/// is accepted; the books must not change and the server must survive. +#[test] +fn jsonrpc_invalid_arguments_refused_cleanly() { + let dir = test_dir("rpc-args"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + m.refuse("books.add_line", json!({"entry_id": 1})); + m.refuse("books.post_entry", json!({"entry_id": "one"})); + m.refuse( + "books.draft_entry", + json!({"date": 20260101, "description": "x"}), + ); + m.refuse("books.add_account", json!({})); + m.ok("books.chart", json!({})); +} + +#[test] +fn helpers_self_check() { + for ok in ["0.00", "-12.34", "1.234"] { + assert!(is_money_out(ok), "{ok}"); + } + for bad in ["0", "1.2", "1e3", "", "-", ".50", "1,000.00"] { + assert!(!is_money_out(bad), "{bad}"); + } + for ok in ["0", "5", "-5", "5.00", "007.5"] { + assert!(is_amount_in(ok), "{ok}"); + } + for bad in REFUSED_AMOUNTS { + assert!(!is_amount_in(bad), "{bad}"); + } + assert_eq!(canon("007.50"), "7.5"); + assert_eq!(canon("5.00"), "5"); + assert_eq!(canon("-0.00"), "0"); + assert_eq!(b64(b"abcd"), "YWJjZA=="); + let mut bad = Vec::new(); + money_walk( + &json!({"total": 5, "lines": [{"amount": "1.5"}], "id": 3}), + "$", + None, + &mut bad, + ); + assert_eq!(bad.len(), 2, "{bad:?}"); +} diff --git a/docs/architecture.md b/docs/architecture.md index e585495..a80d7ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ private, and constructors are `pub(crate)` where it matters. This is what makes the ledger auditable and what will make the future oracle harness trustworthy — the harness only needs to drive the command surface. -Reports (`trial_balance`, `balance_sheet`, `profit_and_loss`, `snapshot`) +Reports (`trial_balance`, `balance_sheet`, `unclosed_earnings`, `snapshot`) are free functions: pure, total, and deterministic. Same ledger in, same report out, every time, on every platform. That determinism is the entire basis of `docs/oracle.md`. @@ -31,7 +31,7 @@ crates/ledger-core/ account.rs # AccountType, Side, Account, ChartOfAccounts journal.rs # JournalLine, JournalEntry, validate() ledger.rs # Ledger: the command surface, append-only log - reports.rs # trial_balance, profit_and_loss, balance_sheet, snapshot + reports.rs # trial_balance, unclosed_earnings, balance_sheet, snapshot tests/ invariants.rs # accounting-law tests + randomized property test golden/ # hand-verified expected report snapshots diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index d71a498..c76a41c 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -85,14 +85,13 @@ below. - `books.draft_entry { date: "2026-10-10", description: "..." }` → `{ entry_id, status: "draft" }`. - `books.add_line { entry_id, account: "1000" | , side: "debit" | "credit", - amount: "123.45" }` - → `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals - after every line: the agent watches the entry balance in real time. -- `books.post_entry { entry_id, confirmed }` → without confirmation: - `{ preview: { lines, resulting_balances, receipts: { attached: [...], - required_but_missing: bool } } }`; with confirmation: validates (including - the receipt policy), posts, returns `{ entry, balances_changed: [{ code, - before, after }] }`. + amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or + class, bad amount) adds nothing. +- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date, + description, total, lines (with resulting balances), receipts, + receipt_required, receipt_missing, split_detected }`. Without + `confirmed: true`, `status` is `"preview"` and nothing changes; with it, + the receipt policy is enforced and `status` is `"posted"`. - `books.reverse_entry { entry_id, date, description, confirmed }` → same preview/confirm pattern as post. - `books.get_entry { entry_id }` → full entry with lines. @@ -492,8 +491,9 @@ entries. - `books.add_line` gains optional `class` (name or id): tags the new line. Shared costs are split explicitly across lines at entry time. - `books.profit_and_loss` gains optional `class` filter. -- `books.profit_and_loss_by_class { start, end }` → per-class revenue, - expenses, net income, plus an unclassified row. Reversals carry their +- `books.profit_and_loss_by_class { start, end, class? }` → per-class revenue, + expenses, net income, plus an unclassified row (only that class's row + when `class` is given; an unknown or ambiguous class is refused). Reversals carry their class so divisional P&L nets correctly. ## Tools — CPA handoff diff --git a/docs/spec/sources/file-names.md b/docs/spec/sources/file-names.md new file mode 100644 index 0000000..18cd72e --- /dev/null +++ b/docs/spec/sources/file-names.md @@ -0,0 +1,34 @@ +# File names the viewer writes + +Governs `ledger_gui::export::file_name`. Fetched 2026-10-14. + +## Microsoft, "Naming Files, Paths, and Namespaces" (learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file) + +> Use any character in the current code page for a name, including +> Unicode characters and characters in the extended character set +> (128–255), except for the following: The following reserved characters: +> < (less than) > (greater than) : (colon) " (double quote) / (forward +> slash) \ (backslash) | (vertical bar or pipe) ? (question mark) * +> (asterisk) Integer value zero, sometimes referred to as the ASCII NUL +> character. Characters whose integer representations are in the range +> from 1 through 31, except for alternate data streams where these +> characters are allowed. + +> Do not use the following reserved names for the name of a file: CON, +> PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9, +> COM¹, COM², COM³, LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9, +> LPT¹, LPT², and LPT³. Also avoid these names followed immediately by an +> extension; for example, NUL.txt and NUL.tar.gz are both equivalent to +> NUL. + +> Do not end a file or directory name with a space or a period. Although +> the underlying file system may support such names, the Windows shell and +> user interface does not. However, it is acceptable to specify a period +> as the first character of a name. + +## POSIX (IEEE Std 1003.1-2017, ``) + +`{NAME_MAX}`: "Maximum number of bytes in a filename (not including the +terminating null of a filename string)"; Linux's common file systems set +it to 255, and NTFS limits a name to 255 UTF-16 units. wanderbooks keeps a +name within 255 bytes of UTF-8, which fits both. diff --git a/docs/spec/sources/irs-entity-classification.md b/docs/spec/sources/irs-entity-classification.md new file mode 100644 index 0000000..c514030 --- /dev/null +++ b/docs/spec/sources/irs-entity-classification.md @@ -0,0 +1,52 @@ +# Entity classification: which return a business files + +Governs `ledger_config`'s reading of `[company] tax_classification`. +Fetched 2026-10-14. + +## Treas. Reg. §301.7701-3 (eCFR, via law.cornell.edu/cfr/text/26/301.7701-3) + +**(a) In general** + +> A business entity that is not classified as a corporation under +> § 301.7701-2(b) (1), (3), (4), (5), (6), (7), or (8) (an eligible +> entity) can elect its classification for federal tax purposes as +> provided in this section. An eligible entity with at least two members +> can elect to be classified as either an association (and thus a +> corporation under § 301.7701-2(b)(2)) or a partnership, and an eligible +> entity with a single owner can elect to be classified as an association +> or to be disregarded as an entity separate from its owner. + +**(b)(1) Domestic eligible entities** + +> Except as provided in paragraph (b)(3) of this section, unless the +> entity elects otherwise, a domestic eligible entity is— (i) A +> partnership if it has two or more members; or (ii) Disregarded as an +> entity separate from its owner if it has a single owner. + +## Instructions for Schedule C (Form 1040) (2025), "Single-member limited liability company (LLC)" + +> Generally, a single-member domestic LLC is not treated as a separate +> entity for federal income tax purposes. If you are the sole member of a +> domestic LLC, file Schedule C (or Schedule E or F, if applicable) unless +> you have elected to treat the domestic LLC as a corporation. + +## The returns (titles, from irs-tax-lines.md) + +- Form 1065, U.S. Return of Partnership Income: a partnership. +- Form 1120-S, U.S. Income Tax Return for an S Corporation: an S + corporation. +- Form 1120, U.S. Corporation Income Tax Return: a corporation (an + association included) that is not an S corporation. + +## Derived (wanderbooks) + +| classification says | return | +|---|---| +| partnership | Form 1065 | +| S corporation (S corp, S-corp) | Form 1120-S | +| C corporation; corporation, corp, or association alone | Form 1120 | +| disregarded entity, sole proprietor(ship), single-member | Schedule C | + +A phrase naming two returns ("corporation or S corporation") and anything +else names no return wanderbooks can be sure of, so it is +refused rather than guessed. diff --git a/docs/spec/sources/jsonrpc-2.0.md b/docs/spec/sources/jsonrpc-2.0.md new file mode 100644 index 0000000..95032ed --- /dev/null +++ b/docs/spec/sources/jsonrpc-2.0.md @@ -0,0 +1,36 @@ +# JSON-RPC 2.0 Specification (jsonrpc.org/specification) + +Governs `ledger-mcp`'s wire protocol (MCP is JSON-RPC 2.0). Fetched +2026-10-14. + +**5 Response object** + +> When a rpc call is made, the Server MUST reply with a Response, except +> for in the case of Notifications. [...] +> +> result: This member is REQUIRED on success. This member MUST NOT exist +> if there was an error invoking the method. [...] +> +> error: This member is REQUIRED on error. This member MUST NOT exist if +> there was no error triggered during invocation. [...] +> +> id: This member is REQUIRED. It MUST be the same as the value of the id +> member in the Request Object. If there was an error in detecting the id +> in the Request object (e.g. Parse error/Invalid Request), it MUST be +> Null. +> +> Either the result member or error member MUST be included, but both +> members MUST NOT be included. + +**5.1 Error object** + +> code: A Number that indicates the error type that occurred. This MUST be +> an integer. [...] +> +> | code | message | meaning | +> |---|---|---| +> | -32700 | Parse error | Invalid JSON was received by the server. | +> | -32600 | Invalid Request | The JSON sent is not a valid Request object. | +> | -32601 | Method not found | The method does not exist / is not available. | +> | -32602 | Invalid params | Invalid method parameter(s). | +> | -32603 | Internal error | Internal JSON-RPC error. | diff --git a/docs/spec/sources/mcp-tools.md b/docs/spec/sources/mcp-tools.md new file mode 100644 index 0000000..448dce3 --- /dev/null +++ b/docs/spec/sources/mcp-tools.md @@ -0,0 +1,47 @@ +# Model Context Protocol: tools (specification 2025-06-18) + +Governs `ledger-mcp`'s tool results and errors. Source: +https://github.com/modelcontextprotocol/modelcontextprotocol, +docs/specification/2025-06-18/server/tools.mdx (MIT). Fetched 2026-10-14. + +**Structured Content** + +> **Structured** content is returned as a JSON object in the +> `structuredContent` field of a result. +> +> For backwards compatibility, a tool that returns structured content +> SHOULD also return the serialized JSON in a TextContent block. + +**Output Schema** + +> Tools may also provide an output schema for validation of structured +> results. If an output schema is provided: +> +> - Servers **MUST** provide structured results that conform to this schema. +> - Clients **SHOULD** validate structured results against this schema. + +**Error Handling** + +> Tools use two error reporting mechanisms: +> +> 1. **Protocol Errors**: Standard JSON-RPC errors for issues like: +> - Unknown tools +> - Invalid arguments +> - Server errors +> +> 2. **Tool Execution Errors**: Reported in tool results with `isError: true`: +> - API failures +> - Invalid input data +> - Business logic errors + +**Security Considerations** + +> 1. Servers **MUST**: +> - Validate all tool inputs +> - Implement proper access controls +> - Rate limit tool invocations +> - Sanitize tool outputs + +(A local stdio server for one bookkeeper: access control is the file +system's, and rate limiting has no one to limit; wanderbooks takes +"validate all tool inputs" and "sanitize tool outputs" as binding.) diff --git a/docs/testing.md b/docs/testing.md index 0ccf195..1c9fd7e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -83,7 +83,7 @@ accounting question), CHANGE (a source says otherwise; work pending). |---|---|---|---| | 1 | A P&L whose start is after its end is refused | DESIGN | No standard governs a reversed range; refusing beats silently swapping a typo | | 2 | A balance sheet omits zero-balance accounts | CONFIRMED | Reg. S-X 210.4-03(a): "No caption should be shown ... as to which the items and conditions are not present." A comparative statement must still show a zero beside a non-zero prior column | -| 3 | The lifetime `profit_and_loss` counts closing entries | CONFIRMED | Used as earnings not yet closed on the balance sheet; closed income is already in capital (OpenStax Principles of Financial Accounting 5.1). Its name misleads: rename to unclosed earnings (pending) | +| 3 | The lifetime `profit_and_loss` counts closing entries | CONFIRMED | Used as earnings not yet closed on the balance sheet; closed income is already in capital (OpenStax Principles of Financial Accounting 5.1). Renamed `unclosed_earnings` (2026-10-14) | | 4 | Closing a period with nothing to close still locks it | CONVENTION | A close is a lock (B-12); a dormant month must still lock and count for close-in-order | | 5 | Draws and distributions are closed into capital at period end | CONFIRMED | OpenStax 5.1: dividends "are closed to the permanent account"; drawing accounts close to owner's capital. Monthly closes mean the year's K-1 Item L withdrawals sum across months | | 6 | Reconciliation balances | CONFIRMED | Signed by the account's normal side (a card reads as owed), as statements show it; `reconciling_a_card_reads_its_normal_side_...` | diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index e121c3d..ac45566 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1413,6 +1413,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "ledger-config" +version = "0.1.0" +dependencies = [ + "chrono", + "ledger-core", + "rust_decimal", + "serde", + "toml 1.1.8+spec-1.1.0", +] + [[package]] name = "ledger-core" version = "0.1.0" @@ -1430,6 +1441,7 @@ version = "0.0.0" dependencies = [ "arbitrary", "chrono", + "ledger-config", "ledger-core", "ledger-import", "ledger-store", @@ -2337,6 +2349,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2656,11 +2677,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -2687,7 +2723,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", @@ -2720,6 +2756,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -2807,7 +2849,7 @@ dependencies = [ "indexmap", "rustc-hash", "stacker", - "toml", + "toml 0.8.23", "typst-library", "typst-macros", "typst-syntax", @@ -2923,7 +2965,7 @@ dependencies = [ "smallvec", "syntect", "time", - "toml", + "toml 0.8.23", "ttf-parser", "two-face", "typed-arena", @@ -3038,7 +3080,7 @@ dependencies = [ "ecow", "rustc-hash", "serde", - "toml", + "toml 0.8.23", "typst-timing", "typst-utils", "unicode-ident", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index ae2631b..2844112 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,6 +24,9 @@ path = "../crates/ledger-store" [dependencies.ledger-import] path = "../crates/ledger-import" +[dependencies.ledger-config] +path = "../crates/ledger-config" + [[bin]] name = "fuzz_journal_line" path = "fuzz_targets/fuzz_journal_line.rs" @@ -74,3 +77,10 @@ path = "fuzz_targets/fuzz_import_csv.rs" test = false doc = false bench = false + +[[bin]] +name = "fuzz_config_parse" +path = "fuzz_targets/fuzz_config_parse.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_config_parse.rs b/fuzz/fuzz_targets/fuzz_config_parse.rs new file mode 100644 index 0000000..f11253f --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_config_parse.rs @@ -0,0 +1,33 @@ +// Fuzz target: `wanderbooks.toml` is written by people and agents, so its +// text is untrusted input. Parsing must return settings or an error, never +// panic or hang, and what it accepts must honor parse's invariants: the +// fiscal year starts on the first of a month, and members' shares (each in +// (0, 1]) sum to exactly 1. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rust_decimal::Decimal; + +fuzz_target!(|data: &[u8]| { + let Ok(text) = std::str::from_utf8(data) else { + return; + }; + if let Ok(s) = ledger_config::parse(text) { + let (m, d) = s.company.fiscal_year_start; + assert!( + (1..=12).contains(&m) && d == 1, + "{:?}", + s.company.fiscal_year_start + ); + if !s.company.members.is_empty() { + let total: Decimal = s.company.members.iter().map(|m| m.share).sum(); + assert_eq!(total, Decimal::ONE, "{:?}", s.company.members); + for mem in &s.company.members { + assert!( + mem.share > Decimal::ZERO && mem.share <= Decimal::ONE, + "{mem:?}" + ); + } + } + } +}); diff --git a/fuzz/seeds/fuzz_config_parse/demo b/fuzz/seeds/fuzz_config_parse/demo new file mode 100644 index 0000000..62167ba --- /dev/null +++ b/fuzz/seeds/fuzz_config_parse/demo @@ -0,0 +1,17 @@ +[company] +name = "Juniper Studio LLC" +entity_type = "LLC, single-member" +tax_classification = "disregarded entity" # Schedule C +fiscal_year_start = "01-01" +formation_state = "OR" +formed = "2025-06-02" +ein = "00-0000000" +address = "1200 NW Everett St, Portland, OR 97209" +owner = "Alex Rivera" + +[books] +accounting_basis = "accrual" +receipt_threshold = "25.00" +close_into = "3000" +close_sweep = ["3100"] +cash_accounts = ["1000", "1010"] diff --git a/fuzz/seeds/fuzz_config_parse/empty b/fuzz/seeds/fuzz_config_parse/empty new file mode 100644 index 0000000..e69de29 diff --git a/fuzz/seeds/fuzz_config_parse/members b/fuzz/seeds/fuzz_config_parse/members new file mode 100644 index 0000000..84c4965 --- /dev/null +++ b/fuzz/seeds/fuzz_config_parse/members @@ -0,0 +1,25 @@ +[company] +name = "Three Partners LLC" +tax_form = "1065" +fiscal_year_start = "07-01" + +[[company.members]] +name = "A" +share = "0.5" +capital_account = "3000" +distribution_account = "3100" + +[[company.members]] +name = "B" +share = "25%" +capital_account = "3001" + +[[company.members]] +name = "C" +share = " 0.25" +capital_account = " 3002 " + +[books] +accounting_basis = "cash" +receipt_threshold = "75" +investing_accounts = ["1500"] diff --git a/fuzz/seeds/fuzz_config_parse/scorp b/fuzz/seeds/fuzz_config_parse/scorp new file mode 100644 index 0000000..4f7b0d3 --- /dev/null +++ b/fuzz/seeds/fuzz_config_parse/scorp @@ -0,0 +1,6 @@ +[company] +tax_classification = "We are an S corp, formerly a sole proprietorship" +fiscal_year_start = "02-29" +[books] +close_into = " " +close_sweep = ["3100", ""] diff --git a/fuzz/seeds/fuzz_config_parse/wanderland b/fuzz/seeds/fuzz_config_parse/wanderland new file mode 100644 index 0000000..d016b18 --- /dev/null +++ b/fuzz/seeds/fuzz_config_parse/wanderland @@ -0,0 +1,28 @@ +[company] +name = "Wanderland Labs LLC" +entity_type = "LLC, multi-member, member-managed" +tax_classification = "partnership" # Form 1065; a Schedule K-1 per member +base_currency = "USD" +fiscal_year_start = "01-01" # the year ends the day before: Dec 31 +formation_state = "UT" +formed = "2026-10-04" + +[[company.members]] +name = "Stephen Waits" +share = "50%" +capital_account = "3000" + +[[company.members]] +name = "Katherine Pendergast" +share = "50%" +capital_account = "3001" + +[books] +data_dir = "books-data" +accounting_basis = "accrual" +receipt_threshold = "0.00" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[viewer] +open_at_start = "ask"