config, mcp, gui: specs from sources, spec-blind suites, and the bugs they found
check / check (push) Canceled after 0s

ledger-config: parse gets a sourced spec. A fiscal year starts on the
first of a month (IRS Pub. 538); tax_classification is read by whole
words (Treas. Reg. 301.7701-3, Schedule C instructions; "business corp"
no longer reads as an S corp, "corporation or S corporation" is refused);
duplicate or blank members and shared capital accounts are refused; a
negative or non-grammatical receipt threshold is refused; codes trimmed.
tests/spec_config.rs (65 spec-blind tests, 14 properties), fuzz target
fuzz_config_parse (weekly). Mutation: 55 of 55 viable caught (from 25/38).

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-14 10:13:05 -06:00
1 parent 5b8910a1cd
commit 3f20df9700
42 files changed
+4636 -363

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: jdx/mise-action@v2 - uses: jdx/mise-action@v2
Generated
+240 -3
View File
@@ -571,7 +571,7 @@ checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561"
dependencies = [ dependencies = [
"paste", "paste",
"roman-numerals-rs", "roman-numerals-rs",
"strum", "strum 0.27.2",
"unic-langid", "unic-langid",
"unicode-normalization", "unicode-normalization",
"unscanny", "unscanny",
@@ -700,6 +700,12 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]] [[package]]
name = "borsh" name = "borsh"
version = "1.8.1" version = "1.8.1"
@@ -757,6 +763,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.2" version = "1.25.2"
@@ -1373,6 +1385,12 @@ dependencies = [
"syn 3.0.7", "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]] [[package]]
name = "data-url" name = "data-url"
version = "0.3.2" version = "0.3.2"
@@ -1742,6 +1760,15 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" 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]] [[package]]
name = "emath" name = "emath"
version = "0.36.2" version = "0.36.2"
@@ -1937,6 +1964,17 @@ dependencies = [
"regex-syntax", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -2002,6 +2040,17 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -2092,6 +2141,16 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "fsevent-sys" name = "fsevent-sys"
version = "4.1.0" version = "4.1.0"
@@ -2249,9 +2308,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -2496,6 +2557,8 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [ dependencies = [
"allocator-api2",
"equivalent",
"foldhash", "foldhash",
] ]
@@ -3340,6 +3403,59 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "kamadak-exif" name = "kamadak-exif"
version = "0.6.1" version = "0.6.1"
@@ -3476,8 +3592,11 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"ledger-core", "ledger-core",
"proptest",
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json",
"tempfile",
"toml 1.1.8+spec-1.1.0", "toml 1.1.8+spec-1.1.0",
] ]
@@ -3543,11 +3662,13 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"jsonschema",
"ledger-config", "ledger-config",
"ledger-core", "ledger-core",
"ledger-import", "ledger-import",
"ledger-store", "ledger-store",
"mime_guess", "mime_guess",
"proptest",
"rmcp", "rmcp",
"rust_decimal", "rust_decimal",
"schemars", "schemars",
@@ -3732,6 +3853,12 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -3921,6 +4048,20 @@ dependencies = [
"bitflags 2.13.2", "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]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.8" version = "0.4.8"
@@ -3931,6 +4072,21 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -3946,6 +4102,27 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -4354,6 +4531,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]] [[package]]
name = "page_size" name = "page_size"
version = "0.6.0" version = "0.6.0"
@@ -5122,6 +5305,23 @@ dependencies = [
"syn 3.0.7", "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]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@@ -5866,7 +6066,16 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [ 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]] [[package]]
@@ -5881,6 +6090,18 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "subsetter" name = "subsetter"
version = "0.2.6" version = "0.2.6"
@@ -5949,7 +6170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
dependencies = [ dependencies = [
"bincode", "bincode",
"fancy-regex", "fancy-regex 0.16.2",
"flate2", "flate2",
"fnv", "fnv",
"once_cell", "once_cell",
@@ -6929,6 +7150,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -7044,6 +7275,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]] [[package]]
name = "wait-timeout" name = "wait-timeout"
version = "0.2.1" version = "0.2.1"
+5
View File
@@ -11,7 +11,12 @@ ledger-core = { path = "../ledger-core" }
chrono = "0.4" chrono = "0.4"
rust_decimal = "1" rust_decimal = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = { version = "1", features = ["preserve_order"] } toml = { version = "1", features = ["preserve_order"] }
[dev-dependencies]
proptest = "1"
tempfile = "3"
[lints] [lints]
workspace = true workspace = true
+189 -28
View File
@@ -9,6 +9,9 @@
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before //! 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. //! the next start, so the two can't disagree.
pub mod receipt_policy;
pub use receipt_policy::ReceiptPolicy;
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::Deserialize; use serde::Deserialize;
@@ -148,21 +151,44 @@ struct BooksSection {
investing_accounts: Vec<String>, investing_accounts: Vec<String>,
} }
/// The return a federal tax classification files: "partnership" → 1065, /// The return a federal tax classification files (docs/spec/sources/
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship" /// irs-entity-classification.md), read by whole words, ignoring case:
/// or "disregarded entity" → Schedule C. Unrecognized: unknown. /// "partnership" → 1065; "S corporation" (S corp, S-corp, scorp) →
/// 1120-S; "C corporation", or "corporation", "corp", or "association"
/// alone → 1120; "disregarded", "sole proprietor(ship)", or
/// "single-member" → Schedule C. Anything else, or words naming two
/// returns ("corporation or S corporation"), names none.
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> { fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
let c = c.to_lowercase(); use ledger_core::TaxForm;
if c.contains("partnership") { let lower = c.to_lowercase();
Some(ledger_core::TaxForm::Form1065) let words: Vec<&str> = lower
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") { .split(|ch: char| !ch.is_alphanumeric())
Some(ledger_core::TaxForm::Form1120S) .filter(|w| !w.is_empty())
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") { .collect();
Some(ledger_core::TaxForm::Form1120) let has = |w: &str| words.contains(&w);
} else if c.contains("sole") || c.contains("disregarded") { let mut found = Vec::new();
Some(ledger_core::TaxForm::ScheduleC) if has("partnership") {
} else { found.push(TaxForm::Form1065);
None }
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<Decimal, String> {
Ok(share) Ok(share)
} }
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a /// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the
/// day every year has. /// 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> { 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 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) = s.split_once('-').ok_or_else(bad)?;
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| 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. if !(1..=12).contains(&m) {
NaiveDate::from_ymd_opt(2001, m, d) return Err(bad());
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?; }
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)) Ok((m, d))
} }
/// Account codes as written, trimmed; a blank one is refused.
fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
list.into_iter()
.map(|c| {
let c = c.trim().to_string();
if c.is_empty() {
Err(format!("{key}: an account code is blank"))
} else {
Ok(c)
}
})
.collect()
}
/// A decimal string as the books take one: exactly `-?digits[.digits]`.
/// The decimal parser alone also takes "1_000", "+5", ".5", and
/// exponents ("1e3"); a setting or tool argument never means those.
pub fn decimal(s: &str) -> Option<Decimal> {
let digits = s.strip_prefix('-').unwrap_or(s);
let (whole, frac) = match digits.split_once('.') {
Some((w, f)) => (w, Some(f)),
None => (digits, None),
};
let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) {
return None;
}
s.parse().ok()
}
/// The receipt policy in force: `config_json`'s `receipt_policy` when the
/// file sets one, else `[books] receipt_threshold`, else the default
/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and
/// the viewer both call this, so they enforce and show one rule.
pub fn effective_receipt_policy(
config_json: &Path,
settings: &BooksSettings,
) -> Result<ReceiptPolicy, String> {
#[derive(Deserialize)]
struct ConfigJson {
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
}
let from_json = if config_json.exists() {
let raw = std::fs::read_to_string(config_json)
.map_err(|e| format!("{}: {e}", config_json.display()))?;
serde_json::from_str::<ConfigJson>(&raw)
.map_err(|e| format!("bad config {}: {e}", config_json.display()))?
.receipt_policy
} else {
None
};
let policy = match (from_json, &settings.receipt_threshold) {
(Some(p), _) => p,
(None, Some(t)) => ReceiptPolicy {
require_receipts_above: t.clone(),
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
policy.validate()?;
Ok(policy)
}
/// Parse `wanderbooks.toml` text. /// Parse `wanderbooks.toml` text.
///
/// ```text
/// GOAL: read the company's settings, refusing any the books can't honor
/// rather than guessing.
/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md:
/// a fiscal year ends on the last day of a month); Treas. Reg.
/// §301.7701-3 and the Schedule C instructions
/// (docs/spec/sources/irs-entity-classification.md); IRC §704(a)
/// (shares from the agreement: docs/spec/sources/irs-partnerships.md).
/// INVARIANTS:
/// 1. Every setting is optional; an empty file is a calendar-year,
/// accrual-basis "Unnamed company" with no members.
/// 2. fiscal_year_start MUST be the first of a month ("MM-01").
/// 3. Members: each MUST have a name and a capital account, names and
/// capital accounts MUST be distinct (and no member's distributions
/// account another's capital), and shares (each in (0, 1], "50%" or
/// "0.5") MUST sum to exactly 1.
/// 4. tax_form wins over tax_classification (which is then not read);
/// a classification is read by whole words, ignoring case, and MUST
/// name exactly one return (source table). tax_form, basis, and
/// classification values ignore case.
/// 5. receipt_threshold MUST be a decimal at least zero.
/// 6. Account codes (cash, financing, investing, close_into,
/// close_sweep, members' capital and distributions) are trimmed; a
/// blank one is refused (a blank distributions account means none). close_into is
/// refused with members (a close goes to their capital accounts).
/// 7. profile holds every simple [company] value in file order:
/// strings as written, numbers, booleans, and dates in TOML's
/// spelling; tables and arrays (members) are left out.
/// 8. NEVER accept a file that would be read differently than written:
/// an unknown basis, tax form, or classification is refused.
/// ```
pub fn parse(text: &str) -> Result<BooksSettings, String> { pub fn parse(text: &str) -> Result<BooksSettings, String> {
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?; let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
let mut company = Company::default(); let mut company = Company::default();
@@ -218,6 +346,30 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
.filter(|d| !d.is_empty()), .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() { if !company.members.is_empty() {
let total: Decimal = company.members.iter().map(|m| m.share).sum(); let total: Decimal = company.members.iter().map(|m| m.share).sum();
if total != Decimal::ONE { if total != Decimal::ONE {
@@ -229,7 +381,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| { (Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120") 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, (None, None) => None,
}; };
if !company.members.is_empty() && raw.books.close_into.is_some() { if !company.members.is_empty() && raw.books.close_into.is_some() {
@@ -238,8 +394,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
.to_string(), .to_string(),
); );
} }
company.close_into = raw.books.close_into.map(|c| c.trim().to_string()); company.close_into = match raw.books.close_into {
company.close_sweep = raw.books.close_sweep; 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 { if let Some(basis) = raw.books.accounting_basis {
company.basis = match basis.trim().to_lowercase().as_str() { company.basis = match basis.trim().to_lowercase().as_str() {
"accrual" => "Accrual".to_string(), "accrual" => "Accrual".to_string(),
@@ -253,9 +412,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
} }
let receipt_threshold = match raw.books.receipt_threshold { let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => { Some(t) => {
t.trim() let v = decimal(t.trim())
.parse::<Decimal>() .ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?;
.map_err(|_| 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()) Some(t.trim().to_string())
} }
None => None, None => None,
@@ -280,9 +441,9 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
Ok(BooksSettings { Ok(BooksSettings {
company, company,
receipt_threshold, receipt_threshold,
cash_accounts: raw.books.cash_accounts, cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
financing_accounts: raw.books.financing_accounts, financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
investing_accounts: raw.books.investing_accounts, investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
}) })
} }
+224
View File
@@ -0,0 +1,224 @@
//! The receipt policy: server-side enforcement, not prose.
//!
//! Posting an entry whose debits are strictly greater than the configured
//! threshold requires at least one attached receipt, unless every line is in
//! an exempt account. Selected accounts can require receipts regardless of
//! amount. Reversals are exempt: the original entry carries the evidence.
//!
//! ```text
//! GOAL: no entry posts without the document that supports it.
//! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep
//! supporting documents"; they "support the entries in your
//! books"); DECISION (Stephen: the threshold, strictly greater
//! than; Wanderland's is 0.00, every entry, 2026-10-12).
//! INVARIANTS:
//! 1. An entry touching a required account always needs a receipt.
//! 2. Otherwise it needs one exactly when its debits are strictly
//! greater than the threshold and some line is outside the exempt
//! accounts; at threshold 0.00 every entry with debits needs one.
//! 3. Splitting does not dodge it: posted entries the same day with the
//! same description (whitespace and case aside) and the same debit
//! accounts count together (split_group), reversals aside.
//! 4. A threshold that isn't a decimal, or is below zero, is refused at
//! startup (validate).
//! ```
use ledger_core::{JournalEntry, Ledger, Side};
use rust_decimal::Decimal;
use serde::Deserialize;
/// Server-side receipt policy, loaded from config.
#[derive(Debug, Clone, Deserialize)]
pub struct ReceiptPolicy {
/// Receipts required when an entry's debits are strictly greater than
/// this amount. Decimal string, e.g. `"25.00"`.
#[serde(default = "default_threshold")]
pub require_receipts_above: String,
/// Account codes that always require a receipt, regardless of amount.
#[serde(default)]
pub receipt_required_accounts: Vec<String>,
/// Account codes exempt from the policy. The exemption applies only when
/// *every* line of the entry is in an exempt account.
#[serde(default)]
pub receipt_exempt_accounts: Vec<String>,
}
fn default_threshold() -> String {
"25.00".to_string()
}
impl Default for ReceiptPolicy {
fn default() -> Self {
Self {
require_receipts_above: default_threshold(),
receipt_required_accounts: Vec::new(),
receipt_exempt_accounts: Vec::new(),
}
}
}
impl ReceiptPolicy {
/// Refuse a policy the server can't honor: a threshold that isn't a
/// decimal, or is below zero. Checked at startup, so a typo can't
/// quietly become some other threshold.
pub fn validate(&self) -> Result<(), String> {
match crate::decimal(self.require_receipts_above.trim()) {
Some(t) if !t.is_sign_negative() || t.is_zero() => Ok(()),
_ => Err(format!(
"receipt threshold {:?} must be a decimal at least zero",
self.require_receipts_above
)),
}
}
/// The threshold: receipts are required strictly above it.
pub fn threshold(&self) -> Decimal {
// validate() runs at startup; an unvalidated policy (tests) that
// fails to parse requires a receipt on everything.
crate::decimal(self.require_receipts_above.trim()).unwrap_or(Decimal::ZERO)
}
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
entry
.lines()
.iter()
.map(|l| {
ledger
.chart()
.get(l.account)
.map(|a| a.code.clone())
.unwrap_or_default()
})
.collect()
}
/// Total debits of the entry (one side of the balanced entry).
pub fn entry_debits(entry: &JournalEntry) -> Decimal {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum()
}
/// True when posting this entry requires at least one receipt.
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
let codes = self.account_codes(ledger, entry);
let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect();
self.receipt_required_for(&refs, Self::entry_debits(entry))
}
/// True when an entry touching these account codes with this debit total
/// requires at least one receipt. The code-level check behind
/// [`Self::receipt_required`], for postings (like disposals) computed
/// without a draft entry in hand.
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
// Per-account override: any line in a required account → required.
if codes
.iter()
.any(|c| self.receipt_required_accounts.iter().any(|r| r == *c))
{
return true;
}
// Strictly greater than the threshold (Stephen: ">$25").
if debits <= self.threshold() {
return false;
}
// Exempt only when every line is in an exempt account.
!codes
.iter()
.all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c))
}
/// Human-readable policy summary for previews and errors.
pub fn describe(&self) -> String {
if self.threshold().is_zero() {
"receipts required on every entry".to_string()
} else {
format!(
"receipts required when entry debits are strictly greater than {}",
self.threshold()
)
}
}
/// The receipt rule, phrased for the agent's operating instructions.
pub fn rule(&self) -> String {
if self.threshold().is_zero() {
"every entry needs at least one receipt".to_string()
} else {
format!(
"any entry moving more than ${} in debits needs at least one receipt",
self.threshold().round_dp(2)
)
}
}
/// Normalize a description for split detection.
fn normalize_description(desc: &str) -> String {
desc.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
/// Debit account ids of an entry, sorted.
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
let mut ids: Vec<u64> = entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.account)
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
/// Detect a potential threshold split: posted entries on the same date
/// with the same normalized description and debit accounts, whose
/// combined debits with this entry exceed the threshold while this
/// entry alone does not. Returns the matching posted entry ids.
///
/// This is the anti-gaming rule: splitting a $100 purchase into four
/// $25 entries does not dodge the receipt policy. Same-day,
/// same-description, same-accounts is the signature; recurring spending
/// spread across days is not flagged.
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
let debits = Self::entry_debits(entry);
if debits > self.threshold() {
return Vec::new(); // Normal policy already applies.
}
let desc = Self::normalize_description(entry.description());
let accounts = Self::debit_accounts(entry);
let date = entry.date();
let mut group = Vec::new();
let mut total = debits;
for other in ledger.posted_entries() {
if other.id() == entry_id {
continue;
}
if other.date() != date {
continue;
}
if Self::normalize_description(other.description()) != desc {
continue;
}
if Self::debit_accounts(other) != accounts {
continue;
}
// Reversals are exempt from the policy; don't count them.
if other.reversal_of().is_some() {
continue;
}
group.push(other.id());
total += Self::entry_debits(other);
}
if !group.is_empty() && total > self.threshold() {
group
} else {
Vec::new()
}
}
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -180,7 +180,7 @@ mod tests {
assert_eq!((z.revenue, z.expenses), (Decimal::ZERO, Decimal::ZERO)); assert_eq!((z.revenue, z.expenses), (Decimal::ZERO, Decimal::ZERO));
// None counts every line: identical to the account-based P&L. // None counts every line: identical to the account-based P&L.
let all = crate::reports::profit_and_loss_filtered(&ledger, None).unwrap(); 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), (plain.revenue, plain.expenses));
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000))); 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[1].profit_and_loss.net_income(), d(40_000));
assert_eq!(rows[2].profit_and_loss.net_income(), d(15_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. // 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 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(); let exp: Decimal = rows.iter().map(|r| r.profit_and_loss.expenses).sum();
assert_eq!((rev, exp), (total.revenue, total.expenses)); assert_eq!((rev, exp), (total.revenue, total.expenses));
+1 -1
View File
@@ -12,7 +12,7 @@
//! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`, //! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`,
//! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections //! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections
//! are reversing entries. //! 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 //! pure functions of a ledger, which is what makes the semantic oracle
//! (`docs/oracle.md`) possible. //! (`docs/oracle.md`) possible.
+13 -8
View File
@@ -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 /// Revenue and expense totals: from [`unclosed_earnings`], what the books
/// date range, with a line per account, use [`profit_and_loss_for_period`]. /// 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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfitAndLoss { pub struct ProfitAndLoss {
/// Total revenue (credit-normal balances, reported positive). /// Total revenue (credit-normal balances, reported positive).
@@ -143,15 +144,19 @@ impl ProfitAndLoss {
} }
} }
/// Build the profit and loss statement from all posted entries. /// Earnings not yet closed: revenue and expenses over every posted entry,
pub fn profit_and_loss(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> { /// closing entries included, so a closed period's income (already moved
/// to equity) nets out. This is the balance sheet's current-earnings line,
/// not a profit and loss for any period (docs/testing.md #3; OpenStax,
/// Principles of Financial Accounting 5.1).
pub fn unclosed_earnings(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
profit_and_loss_filtered(ledger, None) profit_and_loss_filtered(ledger, None)
} }
/// Build the profit and loss statement, optionally filtered to one class. /// Build the profit and loss statement, optionally filtered to one class.
/// ///
/// With `None` every line counts and the result is identical to /// 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 /// class count. Unlike the account-at-a-time accumulation this works line
/// by line, because classes live on lines: each line contributes its signed /// 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 /// 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. /// sorted by class name; the unclassified row comes last.
/// ///
/// The rows partition the ledger: summing every row's revenue and expenses /// The rows partition the ledger: summing every row's revenue and expenses
/// reproduces [`profit_and_loss`]. /// reproduces [`unclosed_earnings`].
/// ///
/// ```text /// ```text
/// GOAL: a profit and loss per class (department, location, job), plus the /// GOAL: a profit and loss per class (department, location, job), plus the
@@ -313,7 +318,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
AccountType::Revenue | AccountType::Expense => {} AccountType::Revenue | AccountType::Expense => {}
} }
} }
let current_earnings = profit_and_loss(ledger)?.net_income(); let current_earnings = unclosed_earnings(ledger)?.net_income();
equity += current_earnings; equity += current_earnings;
Ok(BalanceSheet { Ok(BalanceSheet {
assets, assets,
@@ -1031,7 +1036,7 @@ pub fn members_equity(
/// ``` /// ```
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> { pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
let tb = trial_balance(ledger); let tb = trial_balance(ledger);
let pl = profit_and_loss(ledger)?; let pl = unclosed_earnings(ledger)?;
let bs = balance_sheet(ledger)?; let bs = balance_sheet(ledger)?;
Ok(serde_json::json!({ Ok(serde_json::json!({
"trial_balance": { "trial_balance": {
+2 -2
View File
@@ -10,7 +10,7 @@
//! semantic oracle (see `docs/oracle.md`). //! semantic oracle (see `docs/oracle.md`).
use chrono::NaiveDate; 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::{ use ledger_core::{
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger, AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
LedgerError, Money, Side, LedgerError, Money, Side,
@@ -565,7 +565,7 @@ fn accounting_equation_holds() {
#[test] #[test]
fn profit_and_loss_is_correct() { fn profit_and_loss_is_correct() {
let c = fixture_company(); 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.revenue, Decimal::from(4_000));
assert_eq!(pl.expenses, Decimal::from(800)); assert_eq!(pl.expenses, Decimal::from(800));
assert_eq!(pl.net_income(), Decimal::from(3_200)); assert_eq!(pl.net_income(), Decimal::from(3_200));
+3 -3
View File
@@ -10,8 +10,8 @@ use ledger_core::{
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value, AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
depreciation_schedule, depreciation_schedule,
reports::{ 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, trial_balance,
trial_balance, unclosed_earnings,
}, },
}; };
use proptest::prelude::*; 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)); 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_revenue + r2, whole.total_revenue);
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses); 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)); prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
} }
+4 -4
View File
@@ -11,7 +11,7 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use chrono::{Days, NaiveDate}; 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::{ use ledger_core::{
AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId, AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId,
CloseTo, Currency, EntryId, GeneralLedger, Ledger, MemberCapital, MemberClose, 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)), (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.revenue, c(15000));
assert_eq!(whole.expenses, c(5000)); assert_eq!(whole.expenses, c(5000));
} }
@@ -2134,7 +2134,7 @@ proptest! {
fn prop_pnl_by_class_rows_sum_to_whole(gens in arb_books()) { fn prop_pnl_by_class_rows_sum_to_whole(gens in arb_books()) {
let b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true); let b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
let rows = profit_and_loss_by_class(&b.l).unwrap(); 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::<Decimal>(), whole.revenue); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::<Decimal>(), whole.revenue);
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses);
let (wr, we) = model_pnl(&model(&gens, early(), d(2030, 1, 1), None)); 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::<Vec<_>>(), true); let mut b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
close_through(&mut b, Some(month), &re_close()); close_through(&mut b, Some(month), &re_close());
let rows = profit_and_loss_by_class(&b.l).unwrap(); 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::<Decimal>(), whole.revenue); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::<Decimal>(), whole.revenue);
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses); prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses);
} }
+2 -2
View File
@@ -15,7 +15,7 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use ledger_core::reports::{ 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::{ use ledger_core::{
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital, 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)) (dec(3_500), dec(920))
); );
// ... which is the life-of-ledger P&L, because nothing is outside it. // ... 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))); assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
// One class: only the tagged software line. // One class: only the tagged software line.
+26 -2
View File
@@ -12,6 +12,30 @@ use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report}; use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal; 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<Attention> { pub fn find(b: &Books) -> Vec<Attention> {
let mut out = Vec::new(); let mut out = Vec::new();
let today = b.today; let today = b.today;
@@ -49,9 +73,9 @@ pub fn find(b: &Books) -> Vec<Attention> {
.collect(); .collect();
out.push(Attention { out.push(Attention {
severity: Severity::Problem, 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!( detail: format!(
"{} of spending has no receipt attached ({}).", "{} in entries with no receipt attached ({}).",
fmt::dollars(total), fmt::dollars(total),
b.receipt_rule() b.receipt_rule()
), ),
+14 -21
View File
@@ -34,7 +34,7 @@ pub struct EntryInfo {
pub vendor: Option<String>, pub vendor: Option<String>,
pub reversal_of: Option<EntryId>, pub reversal_of: Option<EntryId>,
pub reversed_by: Option<EntryId>, pub reversed_by: Option<EntryId>,
/// 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, pub missing_receipt: bool,
/// Net effect on cash accounts: positive in, negative out, zero if the /// Net effect on cash accounts: positive in, negative out, zero if the
/// entry doesn't touch cash. /// entry doesn't touch cash.
@@ -162,11 +162,13 @@ impl Books {
let ledger = snap.ledger().clone(); let ledger = snap.ledger().clone();
let attachments = snap.attachments(None).map_err(|e| e.to_string())?; let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
let journal = snap.journal().to_vec(); let journal = snap.journal().to_vec();
let receipt_threshold = settings // The server's own rule (config.json, else wanderbooks.toml), so
.receipt_threshold // the viewer flags exactly what the server would refuse.
.as_deref() let policy = ledger_config::effective_receipt_policy(
.and_then(|t| t.parse().ok()) &snap.data_dir().join("config.json"),
.unwrap_or(Decimal::new(25, 0)); &settings,
)?;
let receipt_threshold = policy.threshold();
let cash_accounts = cash_accounts(&ledger, &settings); let cash_accounts = cash_accounts(&ledger, &settings);
let codes = |cs: &[String]| -> Vec<AccountId> { let codes = |cs: &[String]| -> Vec<AccountId> {
cs.iter() cs.iter()
@@ -238,23 +240,14 @@ impl Books {
.entry_vendor(id) .entry_vendor(id)
.and_then(|v| ledger.get_vendor(v)) .and_then(|v| ledger.get_vendor(v))
.map(|v| v.name.clone()); .map(|v| v.name.clone());
// Receipts prove spending: hand-made entries (and recurring // The receipt policy covers what is posted by hand (journal
// drafts) that debit an expense or buy an asset from outside. // entries and recurring drafts); invoices, payments, closes,
// Invoices, payments, transfers, draws, closes, and schedules // depreciation, and schedules carry their own evidence, and a
// carry their own evidence. // reversal leans on its original's.
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"))
})
});
let missing_receipt = matches!(origin, "Journal entry" | "Recurring") let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
&& spends && e.reversal_of().is_none()
&& rec.is_empty() && rec.is_empty()
&& amount > receipt_threshold; && policy.receipt_required(&ledger, e);
let cash_effect: Decimal = e let cash_effect: Decimal = e
.lines() .lines()
.iter() .iter()
+7
View File
@@ -15,6 +15,13 @@ fn f(d: Decimal) -> f64 {
d.try_into().unwrap_or(0.0) 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]. /// "Nice" axis ticks covering [lo, hi].
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> { fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
let span = (hi - lo).max(1e-9); let span = (hi - lo).max(1e-9);
+65 -1
View File
@@ -20,6 +20,23 @@ pub fn downloads_dir() -> PathBuf {
} }
/// `name` made safe as a file name on every platform. /// `name` made safe as a file name on every platform.
///
/// ```text
/// GOAL: a report title (company data) can never name a file outside the
/// downloads folder, or one a platform refuses.
/// SOURCE: docs/spec/sources/file-names.md (Microsoft's naming rules;
/// POSIX NAME_MAX).
/// INVARIANTS:
/// 1. The result is `<base>.<ext>`, `base` holding no reserved
/// character (< > : " / \ | ? *) and no control character: each is
/// replaced ('-' or a space).
/// 2. `base` is never empty, never "." or "..", never ends with a space
/// or a period, and is never a reserved device name (CON, NUL, COM1,
/// LPT¹, ... in any case, before its first period): such a base gets
/// a leading '_'.
/// 3. The whole name is at most 255 bytes of UTF-8, cut at a character
/// boundary.
/// ```
pub fn file_name(name: &str, ext: &str) -> String { pub fn file_name(name: &str, ext: &str) -> String {
let clean: String = name let clean: String = name
.chars() .chars()
@@ -29,7 +46,54 @@ pub fn file_name(name: &str, ext: &str) -> String {
c => c, c => c,
}) })
.collect(); .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 /// Write `bytes` to the downloads folder as `name.ext` (replacing a file
+49 -16
View File
@@ -1,36 +1,56 @@
//! Formatting money, dates, and counts. Amounts are exact decimals in and //! Formatting money, dates, and counts. Amounts are exact decimals in and
//! exact strings out: nothing here rounds except `compact`, which says so. //! 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 chrono::NaiveDate;
use rust_decimal::Decimal; 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 { pub fn money(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero(); let s = group(d.abs());
let s = group(d.abs().round_dp(2)); if is_negative(d) {
if neg { format!("\u{2212}{s}") } else { s } format!("\u{2212}{s}")
} else {
s
}
} }
/// Accounting style: negatives in parentheses, `(1,234.56)`. /// Accounting style: negatives in parentheses, `(1,234.56)`.
pub fn accounting(d: Decimal) -> String { pub fn accounting(d: Decimal) -> String {
if d.is_sign_negative() && !d.is_zero() { let s = group(d.abs());
format!("({})", group(d.abs().round_dp(2))) if is_negative(d) { format!("({s})") } else { s }
} else {
group(d.round_dp(2))
}
} }
/// With the currency sign: `$1,234.56`, `−$12.00`. /// With the currency sign: `$1,234.56`, `−$12.00`.
pub fn dollars(d: Decimal) -> String { pub fn dollars(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero(); let s = format!("${}", group(d.abs()));
let s = format!("${}", group(d.abs().round_dp(2))); if is_negative(d) {
if neg { format!("\u{2212}{s}") } else { s } format!("\u{2212}{s}")
} else {
s
}
} }
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`. /// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
pub fn compact(d: Decimal) -> String { pub fn compact(d: Decimal) -> String {
let f: f64 = d.try_into().unwrap_or(0.0); 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 { let s = if a >= 1_000_000.0 {
format!("${:.1}M", a / 1_000_000.0) format!("${:.1}M", a / 1_000_000.0)
} else if a >= 10_000.0 { } else if a >= 10_000.0 {
@@ -40,11 +60,22 @@ pub fn compact(d: Decimal) -> String {
} else { } else {
format!("${a:.0}") 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 { 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 (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
let mut out = String::new(); let mut out = String::new();
for (i, c) in int.chars().enumerate() { 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")), "0.00");
assert_eq!(money(dec("-0.00")), "0.00"); assert_eq!(money(dec("-0.00")), "0.00");
assert_eq!(accounting(dec("-207.68")), "(207.68)"); 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("1234")), "$1.2k");
assert_eq!(compact(dec("-25000")), "\u{2212}$25k"); assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
assert_eq!(compact(dec("950.4")), "$950"); assert_eq!(compact(dec("950.4")), "$950");
+11
View File
@@ -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. /// Back/forward history.
pub struct Nav { pub struct Nav {
back: Vec<Route>, back: Vec<Route>,
+12
View File
@@ -148,6 +148,18 @@ impl Palette {
/// Subsequence match: every query character in order, rewarding /// Subsequence match: every query character in order, rewarding
/// contiguous runs and word starts. None if no match. /// 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<i32> { fn fuzzy(hay: &str, query: &str) -> Option<i32> {
if query.is_empty() { if query.is_empty() {
return Some(0); return Some(0);
+5 -1
View File
@@ -26,9 +26,13 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
chrono = "0.4" chrono = "0.4"
thiserror = "2" 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" zip = "9.0.1"
toml = "1" toml = "1"
[lints] [lints]
workspace = true workspace = true
[dev-dependencies]
jsonschema = { version = "0.58", default-features = false }
proptest = "1"
+8 -14
View File
@@ -13,11 +13,12 @@ use ledger_config as company;
mod policy; mod policy;
mod server; mod server;
mod types; mod types;
mod wire;
use ledger_core::Currency; use ledger_core::Currency;
use ledger_store::Store; use ledger_store::Store;
use policy::ReceiptPolicy; use policy::ReceiptPolicy;
use rmcp::{ServiceExt, transport::stdio}; use rmcp::ServiceExt;
use serde::Deserialize; use serde::Deserialize;
use server::BooksServer; use server::BooksServer;
use std::path::PathBuf; use std::path::PathBuf;
@@ -28,10 +29,7 @@ struct Config {
/// ISO 4217 base currency for a new store. Default "USD". /// ISO 4217 base currency for a new store. Default "USD".
#[serde(default)] #[serde(default)]
base_currency: Option<String>, base_currency: Option<String>,
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold; // `receipt_policy` is read by ledger_config::effective_receipt_policy.
/// with neither, receipts are required above $25.00.
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
} }
fn default_data_dir() -> PathBuf { fn default_data_dir() -> PathBuf {
@@ -105,14 +103,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}, },
}; };
eprintln!("ledger-mcp: company {:?}", settings.company.name); eprintln!("ledger-mcp: company {:?}", settings.company.name);
let policy = match (config.receipt_policy, settings.receipt_threshold) { let policy = company::effective_receipt_policy(&config_path, &settings)?;
(Some(policy), _) => policy,
(None, Some(threshold)) => ReceiptPolicy {
require_receipts_above: threshold,
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
let roles = server::CashRoles { let roles = server::CashRoles {
cash: settings.cash_accounts, cash: settings.cash_accounts,
financing: settings.financing_accounts, financing: settings.financing_accounts,
@@ -152,8 +143,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_company(company) .with_company(company)
.with_cash_roles(roles); .with_cash_roles(roles);
rt.block_on(async { 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 _ = running.waiting().await;
let _ = writer.await;
Ok::<(), Box<dyn std::error::Error>>(()) Ok::<(), Box<dyn std::error::Error>>(())
}) })
} }
+3 -191
View File
@@ -1,192 +1,4 @@
//! The receipt policy: server-side enforcement, not prose. //! The receipt policy lives in `ledger-config` (shared with the viewer, so
//! //! both read one rule); re-exported here.
//! Posting an entry whose debits are strictly greater than the configured
//! threshold requires at least one attached receipt, unless every line is in
//! an exempt account. Selected accounts can require receipts regardless of
//! amount. Reversals are exempt: the original entry carries the evidence.
use ledger_core::{JournalEntry, Ledger, Side}; pub use ledger_config::ReceiptPolicy;
use rust_decimal::Decimal;
use serde::Deserialize;
/// Server-side receipt policy, loaded from config.
#[derive(Debug, Clone, Deserialize)]
pub struct ReceiptPolicy {
/// Receipts required when an entry's debits are strictly greater than
/// this amount. Decimal string, e.g. `"25.00"`.
#[serde(default = "default_threshold")]
pub require_receipts_above: String,
/// Account codes that always require a receipt, regardless of amount.
#[serde(default)]
pub receipt_required_accounts: Vec<String>,
/// Account codes exempt from the policy. The exemption applies only when
/// *every* line of the entry is in an exempt account.
#[serde(default)]
pub receipt_exempt_accounts: Vec<String>,
}
fn default_threshold() -> String {
"25.00".to_string()
}
impl Default for ReceiptPolicy {
fn default() -> Self {
Self {
require_receipts_above: default_threshold(),
receipt_required_accounts: Vec::new(),
receipt_exempt_accounts: Vec::new(),
}
}
}
impl ReceiptPolicy {
fn threshold(&self) -> Decimal {
self.require_receipts_above
.parse()
.unwrap_or(Decimal::from(25))
}
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
entry
.lines()
.iter()
.map(|l| {
ledger
.chart()
.get(l.account)
.map(|a| a.code.clone())
.unwrap_or_default()
})
.collect()
}
/// Total debits of the entry (one side of the balanced entry).
pub fn entry_debits(entry: &JournalEntry) -> Decimal {
entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum()
}
/// True when posting this entry requires at least one receipt.
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
let codes = self.account_codes(ledger, entry);
let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect();
self.receipt_required_for(&refs, Self::entry_debits(entry))
}
/// True when an entry touching these account codes with this debit total
/// requires at least one receipt. The code-level check behind
/// [`Self::receipt_required`], for postings (like disposals) computed
/// without a draft entry in hand.
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
// Per-account override: any line in a required account → required.
if codes
.iter()
.any(|c| self.receipt_required_accounts.iter().any(|r| r == *c))
{
return true;
}
// Strictly greater than the threshold (Stephen: ">$25").
if debits <= self.threshold() {
return false;
}
// Exempt only when every line is in an exempt account.
!codes
.iter()
.all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c))
}
/// Human-readable policy summary for previews and errors.
pub fn describe(&self) -> String {
if self.threshold().is_zero() {
"receipts required on every entry".to_string()
} else {
format!(
"receipts required when entry debits are strictly greater than {}",
self.threshold()
)
}
}
/// The receipt rule, phrased for the agent's operating instructions.
pub fn rule(&self) -> String {
if self.threshold().is_zero() {
"every entry needs at least one receipt".to_string()
} else {
format!(
"any entry moving more than ${} in debits needs at least one receipt",
self.threshold().round_dp(2)
)
}
}
/// Normalize a description for split detection.
fn normalize_description(desc: &str) -> String {
desc.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
/// Debit account ids of an entry, sorted.
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
let mut ids: Vec<u64> = entry
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.account)
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
/// Detect a potential threshold split: posted entries on the same date
/// with the same normalized description and debit accounts, whose
/// combined debits with this entry exceed the threshold while this
/// entry alone does not. Returns the matching posted entry ids.
///
/// This is the anti-gaming rule: splitting a $100 purchase into four
/// $25 entries does not dodge the receipt policy. Same-day,
/// same-description, same-accounts is the signature; recurring spending
/// spread across days is not flagged.
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
let debits = Self::entry_debits(entry);
if debits > self.threshold() {
return Vec::new(); // Normal policy already applies.
}
let desc = Self::normalize_description(entry.description());
let accounts = Self::debit_accounts(entry);
let date = entry.date();
let mut group = Vec::new();
let mut total = debits;
for other in ledger.posted_entries() {
if other.id() == entry_id {
continue;
}
if other.date() != date {
continue;
}
if Self::normalize_description(other.description()) != desc {
continue;
}
if Self::debit_accounts(other) != accounts {
continue;
}
// Reversals are exempt from the policy; don't count them.
if other.reversal_of().is_some() {
continue;
}
group.push(other.id());
total += Self::entry_debits(other);
}
if !group.is_empty() && total > self.threshold() {
group
} else {
Vec::new()
}
}
}
+102 -42
View File
@@ -1,5 +1,34 @@
//! The MCP server: narrow typed tools over the store, with the receipt //! The MCP server: narrow typed tools over the store, with the receipt
//! policy enforced server-side. //! 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::policy::ReceiptPolicy;
use crate::types::*; use crate::types::*;
@@ -330,9 +359,10 @@ fn parse_date(s: &str) -> Result<NaiveDate, String> {
.map_err(|_| format!("bad date {s:?}: expected YYYY-MM-DD")) .map_err(|_| format!("bad date {s:?}: expected YYYY-MM-DD"))
} }
/// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3).
fn parse_amount(s: &str) -> Result<Decimal, String> { fn parse_amount(s: &str) -> Result<Decimal, String> {
s.parse::<Decimal>() ledger_config::decimal(s)
.map_err(|_| format!("bad amount {s:?}: expected a decimal string like \"123.45\"")) .ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
} }
fn parse_side(s: &str) -> Result<Side, String> { fn parse_side(s: &str) -> Result<Side, String> {
@@ -378,28 +408,35 @@ fn parse_account_type(s: &str) -> Result<AccountType, String> {
} }
} }
/// 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<AccountId, String> { fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
if let Ok(id) = account.parse::<u64>() let chart = store.ledger().chart();
&& store.ledger().chart().get(id).is_some() let by_code = chart.find_by_code(account).map(|a| a.id);
{ let by_id = account
return Ok(id); .parse::<u64>()
.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<u64, String> { fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
if let Ok(id) = vendor.parse::<u64>() let by_id = vendor
&& store.ledger().get_vendor(id).is_some() .parse::<u64>()
{ .ok()
return Ok(id); .filter(|id| store.ledger().get_vendor(*id).is_some());
}
let matches: Vec<u64> = store let matches: Vec<u64> = store
.ledger() .ledger()
.list_vendors() .list_vendors()
@@ -407,6 +444,15 @@ fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
.filter(|v| v.name.eq_ignore_ascii_case(vendor.trim())) .filter(|v| v.name.eq_ignore_ascii_case(vendor.trim()))
.map(|v| v.id) .map(|v| v.id)
.collect(); .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() { match matches.as_slice() {
[id] => Ok(*id), [id] => Ok(*id),
[] => Err(format!( [] => Err(format!(
@@ -420,19 +466,23 @@ fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
form.map(|f| f.label().to_string()) 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<u32, String> { fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
if let Ok(id) = class.parse::<u32>() let by_id = class
&& store.ledger().get_class(id).is_some() .parse::<u32>()
{ .ok()
return Ok(id); .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", /// 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 mut store = self.lock()?;
let account = resolve_account(&store, &p.account)?; let account = resolve_account(&store, &p.account)?;
let side = parse_side(&p.side)?; 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? // Foreign-currency line?
if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() { if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() {
let fc = p let fc = p
@@ -949,8 +1004,7 @@ impl BooksServer {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
// Class tag, if requested. The line just added is the last one. // Class tag, if requested. The line just added is the last one.
if let Some(class_ref) = p.class { if let Some(class_id) = class_id {
let class_id = resolve_class(&store, &class_ref)?;
let line_index = store let line_index = store
.ledger() .ledger()
.get_entry(p.entry_id) .get_entry(p.entry_id)
@@ -3362,11 +3416,11 @@ impl BooksServer {
let total = r.total().to_string(); let total = r.total().to_string();
AgingRowOut { AgingRowOut {
name: r.name, name: r.name,
current: r.current.to_string(), current: money(r.current),
days_30: r.days_30.to_string(), days_30: money(r.days_30),
days_60: r.days_60.to_string(), days_60: money(r.days_60),
days_90: r.days_90.to_string(), days_90: money(r.days_90),
days_90_plus: r.days_90_plus.to_string(), days_90_plus: money(r.days_90_plus),
total, total,
} }
}) })
@@ -3389,11 +3443,11 @@ impl BooksServer {
let total = r.total().to_string(); let total = r.total().to_string();
AgingRowOut { AgingRowOut {
name: r.name, name: r.name,
current: r.current.to_string(), current: money(r.current),
days_30: r.days_30.to_string(), days_30: money(r.days_30),
days_60: r.days_60.to_string(), days_60: money(r.days_60),
days_90: r.days_90.to_string(), days_90: money(r.days_90),
days_90_plus: r.days_90_plus.to_string(), days_90_plus: money(r.days_90_plus),
total, total,
} }
}) })
@@ -3584,6 +3638,11 @@ impl BooksServer {
if end < start { if end < start {
return Err("end is before start".to_string()); 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. // Per-class P&L over the date range: accumulate line by line.
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Default)] #[derive(Default)]
@@ -3621,6 +3680,7 @@ impl BooksServer {
// Sort: named classes by name, unclassified last. // Sort: named classes by name, unclassified last.
let mut rows: Vec<(Option<String>, Acc)> = by_class let mut rows: Vec<(Option<String>, Acc)> = by_class
.into_iter() .into_iter()
.filter(|(cid, _)| only.is_none() || *cid == only)
.map(|(cid, acc)| { .map(|(cid, acc)| {
let name = cid let name = cid
.and_then(|id| ledger.get_class(id)) .and_then(|id| ledger.get_class(id))
+3 -3
View File
@@ -566,9 +566,9 @@ pub struct TrialBalanceLineOut {
pub code: String, pub code: String,
/// Account name. /// Account name.
pub name: String, pub name: String,
/// Debit balance as a decimal string, or "0". /// Debit balance, decimal string ("0.00" when none).
pub debit: String, pub debit: String,
/// Credit balance as a decimal string, or "0". /// Credit balance, decimal string ("0.00" when none).
pub credit: String, pub credit: String,
} }
@@ -1570,7 +1570,7 @@ pub struct DisposeAssetOut {
/// Gain (positive) or loss (negative), decimal string. /// Gain (positive) or loss (negative), decimal string.
pub gain_loss: String, pub gain_loss: String,
/// Depreciation auto-posted for the final partial period, decimal 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, pub final_depreciation: String,
/// The disposal entry's lines with resulting balances. /// The disposal entry's lines with resulting balances.
pub lines: Vec<PreviewLine>, pub lines: Vec<PreviewLine>,
+197
View File
@@ -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<R, W>(
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::<String>();
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);
}
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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 makes the ledger auditable and what will make the future oracle harness
trustworthy — the harness only needs to drive the command surface. 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 are free functions: pure, total, and deterministic. Same ledger in, same
report out, every time, on every platform. That determinism is the entire report out, every time, on every platform. That determinism is the entire
basis of `docs/oracle.md`. basis of `docs/oracle.md`.
@@ -31,7 +31,7 @@ crates/ledger-core/
account.rs # AccountType, Side, Account, ChartOfAccounts account.rs # AccountType, Side, Account, ChartOfAccounts
journal.rs # JournalLine, JournalEntry, validate() journal.rs # JournalLine, JournalEntry, validate()
ledger.rs # Ledger: the command surface, append-only log 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/ tests/
invariants.rs # accounting-law tests + randomized property test invariants.rs # accounting-law tests + randomized property test
golden/ # hand-verified expected report snapshots golden/ # hand-verified expected report snapshots
+10 -10
View File
@@ -85,14 +85,13 @@ below.
- `books.draft_entry { date: "2026-10-10", description: "..." }` - `books.draft_entry { date: "2026-10-10", description: "..." }`
→ `{ entry_id, status: "draft" }`. → `{ entry_id, status: "draft" }`.
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit", - `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
amount: "123.45" }` amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals class, bad amount) adds nothing.
after every line: the agent watches the entry balance in real time. - `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
- `books.post_entry { entry_id, confirmed }` → without confirmation: description, total, lines (with resulting balances), receipts,
`{ preview: { lines, resulting_balances, receipts: { attached: [...], receipt_required, receipt_missing, split_detected }`. Without
required_but_missing: bool } } }`; with confirmation: validates (including `confirmed: true`, `status` is `"preview"` and nothing changes; with it,
the receipt policy), posts, returns `{ entry, balances_changed: [{ code, the receipt policy is enforced and `status` is `"posted"`.
before, after }] }`.
- `books.reverse_entry { entry_id, date, description, confirmed }` → same - `books.reverse_entry { entry_id, date, description, confirmed }` → same
preview/confirm pattern as post. preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines. - `books.get_entry { entry_id }` → full entry with lines.
@@ -492,8 +491,9 @@ entries.
- `books.add_line` gains optional `class` (name or id): tags the new line. - `books.add_line` gains optional `class` (name or id): tags the new line.
Shared costs are split explicitly across lines at entry time. Shared costs are split explicitly across lines at entry time.
- `books.profit_and_loss` gains optional `class` filter. - `books.profit_and_loss` gains optional `class` filter.
- `books.profit_and_loss_by_class { start, end }` → per-class revenue, - `books.profit_and_loss_by_class { start, end, class? }` → per-class revenue,
expenses, net income, plus an unclassified row. Reversals carry their 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. class so divisional P&L nets correctly.
## Tools — CPA handoff ## Tools — CPA handoff
+34
View File
@@ -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, `<limits.h>`)
`{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.
@@ -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.
+36
View File
@@ -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. |
+47
View File
@@ -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.)
+1 -1
View File
@@ -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 | | 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 | | 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 | | 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 | | 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_...` | | 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_...` |
+47 -5
View File
@@ -1413,6 +1413,17 @@ dependencies = [
"smallvec", "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]] [[package]]
name = "ledger-core" name = "ledger-core"
version = "0.1.0" version = "0.1.0"
@@ -1430,6 +1441,7 @@ version = "0.0.0"
dependencies = [ dependencies = [
"arbitrary", "arbitrary",
"chrono", "chrono",
"ledger-config",
"ledger-core", "ledger-core",
"ledger-import", "ledger-import",
"ledger-store", "ledger-store",
@@ -2337,6 +2349,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "serde_yaml" name = "serde_yaml"
version = "0.9.34+deprecated" version = "0.9.34+deprecated"
@@ -2656,11 +2677,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime 0.6.11", "toml_datetime 0.6.11",
"toml_edit 0.22.27", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.11" version = "0.6.11"
@@ -2687,7 +2723,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime 0.6.11", "toml_datetime 0.6.11",
"toml_write", "toml_write",
"winnow 0.7.15", "winnow 0.7.15",
@@ -2720,6 +2756,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 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]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@@ -2807,7 +2849,7 @@ dependencies = [
"indexmap", "indexmap",
"rustc-hash", "rustc-hash",
"stacker", "stacker",
"toml", "toml 0.8.23",
"typst-library", "typst-library",
"typst-macros", "typst-macros",
"typst-syntax", "typst-syntax",
@@ -2923,7 +2965,7 @@ dependencies = [
"smallvec", "smallvec",
"syntect", "syntect",
"time", "time",
"toml", "toml 0.8.23",
"ttf-parser", "ttf-parser",
"two-face", "two-face",
"typed-arena", "typed-arena",
@@ -3038,7 +3080,7 @@ dependencies = [
"ecow", "ecow",
"rustc-hash", "rustc-hash",
"serde", "serde",
"toml", "toml 0.8.23",
"typst-timing", "typst-timing",
"typst-utils", "typst-utils",
"unicode-ident", "unicode-ident",
+10
View File
@@ -24,6 +24,9 @@ path = "../crates/ledger-store"
[dependencies.ledger-import] [dependencies.ledger-import]
path = "../crates/ledger-import" path = "../crates/ledger-import"
[dependencies.ledger-config]
path = "../crates/ledger-config"
[[bin]] [[bin]]
name = "fuzz_journal_line" name = "fuzz_journal_line"
path = "fuzz_targets/fuzz_journal_line.rs" path = "fuzz_targets/fuzz_journal_line.rs"
@@ -74,3 +77,10 @@ path = "fuzz_targets/fuzz_import_csv.rs"
test = false test = false
doc = false doc = false
bench = false bench = false
[[bin]]
name = "fuzz_config_parse"
path = "fuzz_targets/fuzz_config_parse.rs"
test = false
doc = false
bench = false
+33
View File
@@ -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:?}"
);
}
}
}
});
+17
View File
@@ -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"]
View File
Whitespace-only changes.
+25
View File
@@ -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"]
+6
View File
@@ -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", ""]
+28
View File
@@ -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"