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:
fail-fast: false
matrix:
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_import_csv]
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_import_csv, fuzz_config_parse]
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
Generated
+240 -3
View File
@@ -571,7 +571,7 @@ checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561"
dependencies = [
"paste",
"roman-numerals-rs",
"strum",
"strum 0.27.2",
"unic-langid",
"unicode-normalization",
"unscanny",
@@ -700,6 +700,12 @@ dependencies = [
"piper",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
version = "1.8.1"
@@ -757,6 +763,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -1373,6 +1385,12 @@ dependencies = [
"syn 3.0.7",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -1742,6 +1760,15 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "emath"
version = "0.36.2"
@@ -1937,6 +1964,17 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "fancy-regex"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -2002,6 +2040,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fluent-uri"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2092,6 +2141,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
dependencies = [
"num",
"num-bigint",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -2249,9 +2308,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -2496,6 +2557,8 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@@ -3340,6 +3403,59 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonschema"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
dependencies = [
"ahash",
"bytecount",
"data-encoding",
"email_address",
"fancy-regex 0.19.2",
"fraction",
"getrandom 0.3.4",
"itoa",
"jsonschema-regex",
"jsonschema-value",
"num-cmp",
"num-traits",
"percent-encoding",
"referencing",
"regex",
"serde",
"serde_json",
"strum 0.28.0",
"unicode-general-category",
"uuid-simd",
]
[[package]]
name = "jsonschema-regex"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
dependencies = [
"ahash",
"bytecount",
"fraction",
"getrandom 0.3.4",
"num-cmp",
"num-traits",
"serde_json",
"zmij",
]
[[package]]
name = "kamadak-exif"
version = "0.6.1"
@@ -3476,8 +3592,11 @@ version = "0.1.0"
dependencies = [
"chrono",
"ledger-core",
"proptest",
"rust_decimal",
"serde",
"serde_json",
"tempfile",
"toml 1.1.8+spec-1.1.0",
]
@@ -3543,11 +3662,13 @@ dependencies = [
"axum",
"base64 0.22.1",
"chrono",
"jsonschema",
"ledger-config",
"ledger-core",
"ledger-import",
"ledger-store",
"mime_guess",
"proptest",
"rmcp",
"rust_decimal",
"schemars",
@@ -3732,6 +3853,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mime"
version = "0.3.17"
@@ -3921,6 +4048,20 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -3931,6 +4072,21 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -3946,6 +4102,27 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4354,6 +4531,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "page_size"
version = "0.6.0"
@@ -5122,6 +5305,23 @@ dependencies = [
"syn 3.0.7",
]
[[package]]
name = "referencing"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
dependencies = [
"ahash",
"fluent-uri",
"getrandom 0.3.4",
"hashbrown 0.17.1",
"itoa",
"micromap",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -5866,7 +6066,16 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
"strum_macros 0.27.2",
]
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros 0.28.0",
]
[[package]]
@@ -5881,6 +6090,18 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -5949,7 +6170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
dependencies = [
"bincode",
"fancy-regex",
"fancy-regex 0.16.2",
"flate2",
"fnv",
"once_cell",
@@ -6929,6 +7150,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -7044,6 +7275,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "wait-timeout"
version = "0.2.1"
+5
View File
@@ -11,7 +11,12 @@ ledger-core = { path = "../ledger-core" }
chrono = "0.4"
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = { version = "1", features = ["preserve_order"] }
[dev-dependencies]
proptest = "1"
tempfile = "3"
[lints]
workspace = true
+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
//! the next start, so the two can't disagree.
pub mod receipt_policy;
pub use receipt_policy::ReceiptPolicy;
use chrono::{Datelike, NaiveDate};
use rust_decimal::Decimal;
use serde::Deserialize;
@@ -148,21 +151,44 @@ struct BooksSection {
investing_accounts: Vec<String>,
}
/// The return a federal tax classification files: "partnership" → 1065,
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship"
/// or "disregarded entity" → Schedule C. Unrecognized: unknown.
/// The return a federal tax classification files (docs/spec/sources/
/// irs-entity-classification.md), read by whole words, ignoring case:
/// "partnership" → 1065; "S corporation" (S corp, S-corp, scorp) →
/// 1120-S; "C corporation", or "corporation", "corp", or "association"
/// alone → 1120; "disregarded", "sole proprietor(ship)", or
/// "single-member" → Schedule C. Anything else, or words naming two
/// returns ("corporation or S corporation"), names none.
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
let c = c.to_lowercase();
if c.contains("partnership") {
Some(ledger_core::TaxForm::Form1065)
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") {
Some(ledger_core::TaxForm::Form1120S)
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") {
Some(ledger_core::TaxForm::Form1120)
} else if c.contains("sole") || c.contains("disregarded") {
Some(ledger_core::TaxForm::ScheduleC)
} else {
None
use ledger_core::TaxForm;
let lower = c.to_lowercase();
let words: Vec<&str> = lower
.split(|ch: char| !ch.is_alphanumeric())
.filter(|w| !w.is_empty())
.collect();
let has = |w: &str| words.contains(&w);
let mut found = Vec::new();
if has("partnership") {
found.push(TaxForm::Form1065);
}
for (i, w) in words.iter().enumerate() {
let prev = i.checked_sub(1).map(|p| words[p]);
let form = match *w {
"scorp" => Some(TaxForm::Form1120S),
"ccorp" | "association" => Some(TaxForm::Form1120),
"corp" | "corporation" if prev == Some("s") => Some(TaxForm::Form1120S),
"corp" | "corporation" => Some(TaxForm::Form1120),
_ => None,
};
found.extend(form);
}
if has("disregarded") || has("sole") || words.windows(2).any(|p| p == ["single", "member"]) {
found.push(TaxForm::ScheduleC);
}
found.sort();
found.dedup();
match found.as_slice() {
[one] => Some(*one),
_ => None,
}
}
@@ -180,19 +206,121 @@ fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
Ok(share)
}
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
/// day every year has.
/// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the
/// last day of a month: IRS Pub. 538, docs/spec/sources/
/// irs-periods-and-methods.md).
fn parse_month_day(s: &str) -> Result<(u32, u32), String> {
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\"");
let (m, d) = s.split_once('-').ok_or_else(bad)?;
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
// 2001 is not a leap year: a valid date there exists every year.
NaiveDate::from_ymd_opt(2001, m, d)
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?;
if !(1..=12).contains(&m) {
return Err(bad());
}
if d != 1 {
return Err(format!(
"fiscal_year_start {s:?}: a fiscal year starts on the first of a month (it ends on the last day of one: IRS Pub. 538)"
));
}
Ok((m, d))
}
/// Account codes as written, trimmed; a blank one is refused.
fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
list.into_iter()
.map(|c| {
let c = c.trim().to_string();
if c.is_empty() {
Err(format!("{key}: an account code is blank"))
} else {
Ok(c)
}
})
.collect()
}
/// A decimal string as the books take one: exactly `-?digits[.digits]`.
/// The decimal parser alone also takes "1_000", "+5", ".5", and
/// exponents ("1e3"); a setting or tool argument never means those.
pub fn decimal(s: &str) -> Option<Decimal> {
let digits = s.strip_prefix('-').unwrap_or(s);
let (whole, frac) = match digits.split_once('.') {
Some((w, f)) => (w, Some(f)),
None => (digits, None),
};
let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) {
return None;
}
s.parse().ok()
}
/// The receipt policy in force: `config_json`'s `receipt_policy` when the
/// file sets one, else `[books] receipt_threshold`, else the default
/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and
/// the viewer both call this, so they enforce and show one rule.
pub fn effective_receipt_policy(
config_json: &Path,
settings: &BooksSettings,
) -> Result<ReceiptPolicy, String> {
#[derive(Deserialize)]
struct ConfigJson {
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
}
let from_json = if config_json.exists() {
let raw = std::fs::read_to_string(config_json)
.map_err(|e| format!("{}: {e}", config_json.display()))?;
serde_json::from_str::<ConfigJson>(&raw)
.map_err(|e| format!("bad config {}: {e}", config_json.display()))?
.receipt_policy
} else {
None
};
let policy = match (from_json, &settings.receipt_threshold) {
(Some(p), _) => p,
(None, Some(t)) => ReceiptPolicy {
require_receipts_above: t.clone(),
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
policy.validate()?;
Ok(policy)
}
/// Parse `wanderbooks.toml` text.
///
/// ```text
/// GOAL: read the company's settings, refusing any the books can't honor
/// rather than guessing.
/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md:
/// a fiscal year ends on the last day of a month); Treas. Reg.
/// §301.7701-3 and the Schedule C instructions
/// (docs/spec/sources/irs-entity-classification.md); IRC §704(a)
/// (shares from the agreement: docs/spec/sources/irs-partnerships.md).
/// INVARIANTS:
/// 1. Every setting is optional; an empty file is a calendar-year,
/// accrual-basis "Unnamed company" with no members.
/// 2. fiscal_year_start MUST be the first of a month ("MM-01").
/// 3. Members: each MUST have a name and a capital account, names and
/// capital accounts MUST be distinct (and no member's distributions
/// account another's capital), and shares (each in (0, 1], "50%" or
/// "0.5") MUST sum to exactly 1.
/// 4. tax_form wins over tax_classification (which is then not read);
/// a classification is read by whole words, ignoring case, and MUST
/// name exactly one return (source table). tax_form, basis, and
/// classification values ignore case.
/// 5. receipt_threshold MUST be a decimal at least zero.
/// 6. Account codes (cash, financing, investing, close_into,
/// close_sweep, members' capital and distributions) are trimmed; a
/// blank one is refused (a blank distributions account means none). close_into is
/// refused with members (a close goes to their capital accounts).
/// 7. profile holds every simple [company] value in file order:
/// strings as written, numbers, booleans, and dates in TOML's
/// spelling; tables and arrays (members) are left out.
/// 8. NEVER accept a file that would be read differently than written:
/// an unknown basis, tax form, or classification is refused.
/// ```
pub fn parse(text: &str) -> Result<BooksSettings, String> {
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
let mut company = Company::default();
@@ -218,6 +346,30 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
.filter(|d| !d.is_empty()),
});
}
for (i, m) in company.members.iter().enumerate() {
if m.name.is_empty() {
return Err(format!("member {}: name is blank", i + 1));
}
for other in &company.members[..i] {
if other.name == m.name {
return Err(format!("member {} is listed twice", m.name));
}
if other.capital_account == m.capital_account {
return Err(format!(
"members {} and {} share capital account {}",
other.name, m.name, m.capital_account
));
}
}
for other in &company.members {
if other.distribution_account.as_deref() == Some(m.capital_account.as_str()) {
return Err(format!(
"{}'s distributions account {} is {}'s capital account",
other.name, m.capital_account, m.name
));
}
}
}
if !company.members.is_empty() {
let total: Decimal = company.members.iter().map(|m| m.share).sum();
if total != Decimal::ONE {
@@ -229,7 +381,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
})?),
(None, Some(c)) => form_for_classification(c),
(None, Some(c)) => Some(form_for_classification(c).ok_or_else(|| {
format!(
"tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)"
)
})?),
(None, None) => None,
};
if !company.members.is_empty() && raw.books.close_into.is_some() {
@@ -238,8 +394,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
.to_string(),
);
}
company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
company.close_sweep = raw.books.close_sweep;
company.close_into = match raw.books.close_into {
Some(c) => Some(codes("close_into", vec![c])?.remove(0)),
None => None,
};
company.close_sweep = codes("close_sweep", raw.books.close_sweep)?;
if let Some(basis) = raw.books.accounting_basis {
company.basis = match basis.trim().to_lowercase().as_str() {
"accrual" => "Accrual".to_string(),
@@ -253,9 +412,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
}
let receipt_threshold = match raw.books.receipt_threshold {
Some(t) => {
t.trim()
.parse::<Decimal>()
.map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?;
let v = decimal(t.trim())
.ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?;
if v.is_sign_negative() && !v.is_zero() {
return Err(format!("receipt_threshold {t:?} is below zero"));
}
Some(t.trim().to_string())
}
None => None,
@@ -280,9 +441,9 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
Ok(BooksSettings {
company,
receipt_threshold,
cash_accounts: raw.books.cash_accounts,
financing_accounts: raw.books.financing_accounts,
investing_accounts: raw.books.investing_accounts,
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
})
}
+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));
// None counts every line: identical to the account-based P&L.
let all = crate::reports::profit_and_loss_filtered(&ledger, None).unwrap();
let plain = crate::reports::profit_and_loss(&ledger).unwrap();
let plain = crate::reports::unclosed_earnings(&ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (plain.revenue, plain.expenses));
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
}
@@ -205,7 +205,7 @@ mod tests {
assert_eq!(rows[1].profit_and_loss.net_income(), d(40_000));
assert_eq!(rows[2].profit_and_loss.net_income(), d(15_000));
// The rows partition the ledger: they sum to the total P&L.
let total = crate::reports::profit_and_loss(&ledger).unwrap();
let total = crate::reports::unclosed_earnings(&ledger).unwrap();
let rev: Decimal = rows.iter().map(|r| r.profit_and_loss.revenue).sum();
let exp: Decimal = rows.iter().map(|r| r.profit_and_loss.expenses).sum();
assert_eq!((rev, exp), (total.revenue, total.expenses));
+1 -1
View File
@@ -12,7 +12,7 @@
//! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`,
//! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections
//! are reversing entries.
//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`profit_and_loss`](reports::profit_and_loss)) are
//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`unclosed_earnings`](reports::unclosed_earnings)) are
//! pure functions of a ledger, which is what makes the semantic oracle
//! (`docs/oracle.md`) possible.
+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
/// date range, with a line per account, use [`profit_and_loss_for_period`].
/// Revenue and expense totals: from [`unclosed_earnings`], what the books
/// hold now (closed periods already moved to equity); for a date range,
/// with a line per account, use [`profit_and_loss_for_period`].
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfitAndLoss {
/// Total revenue (credit-normal balances, reported positive).
@@ -143,15 +144,19 @@ impl ProfitAndLoss {
}
}
/// Build the profit and loss statement from all posted entries.
pub fn profit_and_loss(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
/// Earnings not yet closed: revenue and expenses over every posted entry,
/// closing entries included, so a closed period's income (already moved
/// to equity) nets out. This is the balance sheet's current-earnings line,
/// not a profit and loss for any period (docs/testing.md #3; OpenStax,
/// Principles of Financial Accounting 5.1).
pub fn unclosed_earnings(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
profit_and_loss_filtered(ledger, None)
}
/// Build the profit and loss statement, optionally filtered to one class.
///
/// With `None` every line counts and the result is identical to
/// [`profit_and_loss`]. With `Some(class)` only lines tagged with that
/// [`unclosed_earnings`]. With `Some(class)` only lines tagged with that
/// class count. Unlike the account-at-a-time accumulation this works line
/// by line, because classes live on lines: each line contributes its signed
/// amount (positive on the account's normal side) to its account type's
@@ -181,7 +186,7 @@ pub struct ClassProfitAndLoss {
/// sorted by class name; the unclassified row comes last.
///
/// The rows partition the ledger: summing every row's revenue and expenses
/// reproduces [`profit_and_loss`].
/// reproduces [`unclosed_earnings`].
///
/// ```text
/// GOAL: a profit and loss per class (department, location, job), plus the
@@ -313,7 +318,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
AccountType::Revenue | AccountType::Expense => {}
}
}
let current_earnings = profit_and_loss(ledger)?.net_income();
let current_earnings = unclosed_earnings(ledger)?.net_income();
equity += current_earnings;
Ok(BalanceSheet {
assets,
@@ -1031,7 +1036,7 @@ pub fn members_equity(
/// ```
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
let tb = trial_balance(ledger);
let pl = profit_and_loss(ledger)?;
let pl = unclosed_earnings(ledger)?;
let bs = balance_sheet(ledger)?;
Ok(serde_json::json!({
"trial_balance": {
+2 -2
View File
@@ -10,7 +10,7 @@
//! semantic oracle (see `docs/oracle.md`).
use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
use ledger_core::reports::{balance_sheet, snapshot, trial_balance, unclosed_earnings};
use ledger_core::{
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
LedgerError, Money, Side,
@@ -565,7 +565,7 @@ fn accounting_equation_holds() {
#[test]
fn profit_and_loss_is_correct() {
let c = fixture_company();
let pl = profit_and_loss(&c.ledger).unwrap();
let pl = unclosed_earnings(&c.ledger).unwrap();
assert_eq!(pl.revenue, Decimal::from(4_000));
assert_eq!(pl.expenses, Decimal::from(800));
assert_eq!(pl.net_income(), Decimal::from(3_200));
+3 -3
View File
@@ -10,8 +10,8 @@ use ledger_core::{
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
depreciation_schedule,
reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
trial_balance,
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
unclosed_earnings,
},
};
use proptest::prelude::*;
@@ -157,7 +157,7 @@ proptest! {
let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses));
prop_assert_eq!(first.total_revenue + r2, whole.total_revenue);
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
let all = profit_and_loss(&ledger).unwrap();
let all = unclosed_earnings(&ledger).unwrap();
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
}
+4 -4
View File
@@ -11,7 +11,7 @@
use std::collections::{BTreeMap, BTreeSet};
use chrono::{Days, NaiveDate};
use ledger_core::reports::{profit_and_loss, profit_and_loss_by_class, snapshot, trial_balance};
use ledger_core::reports::{profit_and_loss_by_class, snapshot, trial_balance, unclosed_earnings};
use ledger_core::{
AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId,
CloseTo, Currency, EntryId, GeneralLedger, Ledger, MemberCapital, MemberClose,
@@ -651,7 +651,7 @@ fn pnl_by_class_rows_sorted_by_name_unclassified_last() {
(None, c(0), c(3000)),
]
);
let whole = profit_and_loss(&co.l).unwrap();
let whole = unclosed_earnings(&co.l).unwrap();
assert_eq!(whole.revenue, c(15000));
assert_eq!(whole.expenses, c(5000));
}
@@ -2134,7 +2134,7 @@ proptest! {
fn prop_pnl_by_class_rows_sum_to_whole(gens in arb_books()) {
let b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
let rows = profit_and_loss_by_class(&b.l).unwrap();
let whole = profit_and_loss(&b.l).unwrap();
let whole = unclosed_earnings(&b.l).unwrap();
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::<Decimal>(), whole.revenue);
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));
@@ -2171,7 +2171,7 @@ proptest! {
let mut b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
close_through(&mut b, Some(month), &re_close());
let rows = profit_and_loss_by_class(&b.l).unwrap();
let whole = profit_and_loss(&b.l).unwrap();
let whole = unclosed_earnings(&b.l).unwrap();
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::<Decimal>(), whole.revenue);
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 ledger_core::reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, unclosed_earnings,
};
use ledger_core::{
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
@@ -127,7 +127,7 @@ fn profit_and_loss_is_scoped_to_the_period() {
(dec(3_500), dec(920))
);
// ... which is the life-of-ledger P&L, because nothing is outside it.
let all = profit_and_loss(&b.ledger).unwrap();
let all = unclosed_earnings(&b.ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
// One class: only the tagged software line.
+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 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> {
let mut out = Vec::new();
let today = b.today;
@@ -49,9 +73,9 @@ pub fn find(b: &Books) -> Vec<Attention> {
.collect();
out.push(Attention {
severity: Severity::Problem,
title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")),
title: format!("{} without receipts", fmt::count(missing.len(), "entry", "entries")),
detail: format!(
"{} of spending has no receipt attached ({}).",
"{} in entries with no receipt attached ({}).",
fmt::dollars(total),
b.receipt_rule()
),
+14 -21
View File
@@ -34,7 +34,7 @@ pub struct EntryInfo {
pub vendor: Option<String>,
pub reversal_of: 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,
/// Net effect on cash accounts: positive in, negative out, zero if the
/// entry doesn't touch cash.
@@ -162,11 +162,13 @@ impl Books {
let ledger = snap.ledger().clone();
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
let journal = snap.journal().to_vec();
let receipt_threshold = settings
.receipt_threshold
.as_deref()
.and_then(|t| t.parse().ok())
.unwrap_or(Decimal::new(25, 0));
// The server's own rule (config.json, else wanderbooks.toml), so
// the viewer flags exactly what the server would refuse.
let policy = ledger_config::effective_receipt_policy(
&snap.data_dir().join("config.json"),
&settings,
)?;
let receipt_threshold = policy.threshold();
let cash_accounts = cash_accounts(&ledger, &settings);
let codes = |cs: &[String]| -> Vec<AccountId> {
cs.iter()
@@ -238,23 +240,14 @@ impl Books {
.entry_vendor(id)
.and_then(|v| ledger.get_vendor(v))
.map(|v| v.name.clone());
// Receipts prove spending: hand-made entries (and recurring
// drafts) that debit an expense or buy an asset from outside.
// Invoices, payments, transfers, draws, closes, and schedules
// carry their own evidence.
let spends = e.lines().iter().any(|l| {
l.side == Side::Debit
&& ledger.chart().get(l.account).is_some_and(|a| {
a.account_type == AccountType::Expense
|| (a.account_type == AccountType::Asset
&& !cash_accounts.contains(&a.id)
&& !a.name.to_lowercase().contains("receivable"))
})
});
// The receipt policy covers what is posted by hand (journal
// entries and recurring drafts); invoices, payments, closes,
// depreciation, and schedules carry their own evidence, and a
// reversal leans on its original's.
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
&& spends
&& e.reversal_of().is_none()
&& rec.is_empty()
&& amount > receipt_threshold;
&& policy.receipt_required(&ledger, e);
let cash_effect: Decimal = e
.lines()
.iter()
+7
View File
@@ -15,6 +15,13 @@ fn f(d: Decimal) -> f64 {
d.try_into().unwrap_or(0.0)
}
/// ```text
/// INVARIANTS (finite lo <= hi, n >= 1):
/// 1. Evenly spaced by a step of 1, 2, 2.5, or 5 times a power of ten.
/// 2. The first tick is at or below lo and the last at or above hi, so
/// no data falls outside the gridlines.
/// 3. At most n + 2 ticks.
/// ```
/// "Nice" axis ticks covering [lo, hi].
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
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.
///
/// ```text
/// GOAL: a report title (company data) can never name a file outside the
/// downloads folder, or one a platform refuses.
/// SOURCE: docs/spec/sources/file-names.md (Microsoft's naming rules;
/// POSIX NAME_MAX).
/// INVARIANTS:
/// 1. The result is `<base>.<ext>`, `base` holding no reserved
/// character (< > : " / \ | ? *) and no control character: each is
/// replaced ('-' or a space).
/// 2. `base` is never empty, never "." or "..", never ends with a space
/// or a period, and is never a reserved device name (CON, NUL, COM1,
/// LPT¹, ... in any case, before its first period): such a base gets
/// a leading '_'.
/// 3. The whole name is at most 255 bytes of UTF-8, cut at a character
/// boundary.
/// ```
pub fn file_name(name: &str, ext: &str) -> String {
let clean: String = name
.chars()
@@ -29,7 +46,54 @@ pub fn file_name(name: &str, ext: &str) -> String {
c => c,
})
.collect();
format!("{}.{ext}", clean.trim())
let mut base = clean.trim().trim_end_matches(['.', ' ']).to_string();
if base.is_empty() {
base = "report".to_string();
}
let stem = base
.split('.')
.next()
.unwrap_or("")
.trim_end()
.to_uppercase();
let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ["COM", "LPT"].iter().any(|p| {
stem.strip_prefix(p).is_some_and(|n| {
matches!(
n,
"1" | "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "\u{b9}"
| "\u{b2}"
| "\u{b3}"
)
})
});
if reserved {
base.insert(0, '_');
}
let limit = 255usize.saturating_sub(ext.len() + 1);
if base.len() > limit {
let mut cut = limit;
while !base.is_char_boundary(cut) {
cut -= 1;
}
base.truncate(cut);
// A cut can leave a trailing space or period: trim again.
while base.ends_with(['.', ' ']) {
base.pop();
}
if base.is_empty() {
base = "report".to_string();
}
}
format!("{base}.{ext}")
}
/// Write `bytes` to the downloads folder as `name.ext` (replacing a file
+49 -16
View File
@@ -1,36 +1,56 @@
//! Formatting money, dates, and counts. Amounts are exact decimals in and
//! exact strings out: nothing here rounds except `compact`, which says so.
//!
//! ```text
//! GOAL: show every amount exactly as the books hold it.
//! SOURCE: DECISION (the MCP contract: "money is exact or it is wrong");
//! CONVENTION (US accounting display: thousands commas,
//! negatives in parentheses or with a minus sign).
//! INVARIANTS:
//! 1. money, accounting, and dollars NEVER round: every decimal place
//! the amount has (at least two) is shown, thousands grouped by
//! commas in threes, so the text reads back to the same value.
//! 2. Zero (and negative zero) shows without a sign; a negative shows
//! U+2212 (money, dollars) or parentheses (accounting), never both
//! and never "-0".
//! 3. compact rounds (chart axes, tight tiles): $950, $1.2k, $25k,
//! $3.4M; it never shows a sign on what rounds to zero.
//! ```
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Thousands separators, two decimals, a true minus sign: `−1,234.56`.
/// Thousands separators, every decimal (at least two), a true minus sign:
/// `−1,234.56`.
pub fn money(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero();
let s = group(d.abs().round_dp(2));
if neg { format!("\u{2212}{s}") } else { s }
let s = group(d.abs());
if is_negative(d) {
format!("\u{2212}{s}")
} else {
s
}
}
/// Accounting style: negatives in parentheses, `(1,234.56)`.
pub fn accounting(d: Decimal) -> String {
if d.is_sign_negative() && !d.is_zero() {
format!("({})", group(d.abs().round_dp(2)))
} else {
group(d.round_dp(2))
}
let s = group(d.abs());
if is_negative(d) { format!("({s})") } else { s }
}
/// With the currency sign: `$1,234.56`, `−$12.00`.
pub fn dollars(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero();
let s = format!("${}", group(d.abs().round_dp(2)));
if neg { format!("\u{2212}{s}") } else { s }
let s = format!("${}", group(d.abs()));
if is_negative(d) {
format!("\u{2212}{s}")
} else {
s
}
}
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
pub fn compact(d: Decimal) -> String {
let f: f64 = d.try_into().unwrap_or(0.0);
let (neg, a) = (f < 0.0, f.abs());
let a = f.abs();
let s = if a >= 1_000_000.0 {
format!("${:.1}M", a / 1_000_000.0)
} else if a >= 10_000.0 {
@@ -40,11 +60,22 @@ pub fn compact(d: Decimal) -> String {
} else {
format!("${a:.0}")
};
if neg { format!("\u{2212}{s}") } else { s }
// No sign on what rounds to zero ("$0", not "−$0").
if f < 0.0 && s != "$0" {
format!("\u{2212}{s}")
} else {
s
}
}
fn is_negative(d: Decimal) -> bool {
d.is_sign_negative() && !d.is_zero()
}
/// A non-negative amount, every decimal it has (at least two), grouped.
fn group(d: Decimal) -> String {
let s = format!("{:.2}", d);
let places = d.normalize().scale().max(2) as usize;
let s = format!("{:.*}", places, d);
let (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
let mut out = String::new();
for (i, c) in int.chars().enumerate() {
@@ -172,7 +203,9 @@ mod tests {
assert_eq!(money(dec("0")), "0.00");
assert_eq!(money(dec("-0.00")), "0.00");
assert_eq!(accounting(dec("-207.68")), "(207.68)");
assert_eq!(dollars(dec("999.999")), "$1,000.00");
assert_eq!(dollars(dec("999.999")), "$999.999");
assert_eq!(money(dec("0.125")), "0.125");
assert_eq!(compact(dec("-0.4")), "$0");
assert_eq!(compact(dec("1234")), "$1.2k");
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
assert_eq!(compact(dec("950.4")), "$950");
+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.
pub struct Nav {
back: Vec<Route>,
+12
View File
@@ -148,6 +148,18 @@ impl Palette {
/// Subsequence match: every query character in order, rewarding
/// contiguous runs and word starts. None if no match.
/// Fuzzy match score, higher is better.
///
/// ```text
/// INVARIANTS:
/// 1. Matches (Some) exactly when the query's non-space characters,
/// lower-cased, appear in order in the lower-cased hay; the empty
/// query matches everything with score 0.
/// 2. A match's score is at least the number of non-space query
/// characters; consecutive matches and matches at a word start score
/// more, so a word-start prefix scores at least as high as any
/// other match of the same query.
/// ```
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
if query.is_empty() {
return Some(0);
+5 -1
View File
@@ -26,9 +26,13 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync"] }
zip = "9.0.1"
toml = "1"
[lints]
workspace = true
[dev-dependencies]
jsonschema = { version = "0.58", default-features = false }
proptest = "1"
+8 -14
View File
@@ -13,11 +13,12 @@ use ledger_config as company;
mod policy;
mod server;
mod types;
mod wire;
use ledger_core::Currency;
use ledger_store::Store;
use policy::ReceiptPolicy;
use rmcp::{ServiceExt, transport::stdio};
use rmcp::ServiceExt;
use serde::Deserialize;
use server::BooksServer;
use std::path::PathBuf;
@@ -28,10 +29,7 @@ struct Config {
/// ISO 4217 base currency for a new store. Default "USD".
#[serde(default)]
base_currency: Option<String>,
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
/// with neither, receipts are required above $25.00.
#[serde(default)]
receipt_policy: Option<ReceiptPolicy>,
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
}
fn default_data_dir() -> PathBuf {
@@ -105,14 +103,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
},
};
eprintln!("ledger-mcp: company {:?}", settings.company.name);
let policy = match (config.receipt_policy, settings.receipt_threshold) {
(Some(policy), _) => policy,
(None, Some(threshold)) => ReceiptPolicy {
require_receipts_above: threshold,
..ReceiptPolicy::default()
},
(None, None) => ReceiptPolicy::default(),
};
let policy = company::effective_receipt_policy(&config_path, &settings)?;
let roles = server::CashRoles {
cash: settings.cash_accounts,
financing: settings.financing_accounts,
@@ -152,8 +143,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_company(company)
.with_cash_roles(roles);
rt.block_on(async {
let running = server.serve(stdio()).await?;
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
let running = server.serve((input, output)).await?;
let _ = running.waiting().await;
let _ = writer.await;
Ok::<(), Box<dyn std::error::Error>>(())
})
}
+3 -191
View File
@@ -1,192 +1,4 @@
//! The receipt policy: server-side enforcement, not prose.
//!
//! Posting an entry whose debits are strictly greater than the configured
//! threshold requires at least one attached receipt, unless every line is in
//! an exempt account. Selected accounts can require receipts regardless of
//! amount. Reversals are exempt: the original entry carries the evidence.
//! The receipt policy lives in `ledger-config` (shared with the viewer, so
//! both read one rule); re-exported here.
use ledger_core::{JournalEntry, Ledger, Side};
use rust_decimal::Decimal;
use serde::Deserialize;
/// Server-side receipt policy, loaded from config.
#[derive(Debug, Clone, Deserialize)]
pub struct ReceiptPolicy {
/// Receipts required when an entry's debits are strictly greater than
/// this amount. Decimal string, e.g. `"25.00"`.
#[serde(default = "default_threshold")]
pub require_receipts_above: String,
/// Account codes that always require a receipt, regardless of amount.
#[serde(default)]
pub receipt_required_accounts: Vec<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()
}
}
}
pub use ledger_config::ReceiptPolicy;
+102 -42
View File
@@ -1,5 +1,34 @@
//! The MCP server: narrow typed tools over the store, with the receipt
//! policy enforced server-side.
//!
//! ```text
//! GOAL: let an AI agent keep the books through tools that cannot leave
//! them wrong, and say exactly what happened.
//! SOURCE: docs/spec/sources/mcp-tools.md (MCP 2025-06-18 tools: structured
//! results conform to the declared output schema; tool errors are
//! results with isError; "validate all tool inputs");
//! docs/spec/sources/jsonrpc-2.0.md (one response per request, id
//! echoed, result xor error); DECISION (Stephen, the operating
//! contract in INSTRUCTIONS and docs/mcp-interface.md).
//! INVARIANTS:
//! 1. Every tool's structured result MUST conform to its declared output
//! schema, and the same JSON MUST be in a text block.
//! 2. A refused call (isError) MUST leave the books unchanged: the
//! journal byte for byte.
//! 3. Amounts in MUST be decimal strings, exactly `-?digits[.digits]`
//! (no exponent, sign "+", separators, or spaces); amounts out are
//! decimal strings, never JSON numbers, with at least two places and
//! never rounded.
//! 4. Posting and reversing MUST preview without `confirmed: true`, and a
//! preview MUST change nothing.
//! 5. An entry the receipt policy covers MUST NOT post, or keep posted,
//! without a receipt (crate::policy).
//! 6. A reference (account, vendor, class) given as a number that names
//! one thing by id and another by code or name is refused, never
//! guessed.
//! 7. The server MUST answer every request with exactly one response
//! whose id is the request's, and keep serving after any tool error.
//! ```
use crate::policy::ReceiptPolicy;
use crate::types::*;
@@ -330,9 +359,10 @@ fn parse_date(s: &str) -> Result<NaiveDate, String> {
.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> {
s.parse::<Decimal>()
.map_err(|_| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
ledger_config::decimal(s)
.ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
}
fn parse_side(s: &str) -> Result<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> {
if let Ok(id) = account.parse::<u64>()
&& store.ledger().chart().get(id).is_some()
{
return Ok(id);
let chart = store.ledger().chart();
let by_code = chart.find_by_code(account).map(|a| a.id);
let by_id = account
.parse::<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> {
if let Ok(id) = vendor.parse::<u64>()
&& store.ledger().get_vendor(id).is_some()
{
return Ok(id);
}
let by_id = vendor
.parse::<u64>()
.ok()
.filter(|id| store.ledger().get_vendor(*id).is_some());
let matches: Vec<u64> = store
.ledger()
.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()))
.map(|v| v.id)
.collect();
if let Some(id) = by_id {
return match matches.as_slice() {
[] => Ok(id),
[m] if *m == id => Ok(id),
_ => Err(format!(
"vendor {vendor:?} is ambiguous: it is vendor {id}'s id and another vendor's name; pass the id"
)),
};
}
match matches.as_slice() {
[id] => Ok(*id),
[] => Err(format!(
@@ -420,19 +466,23 @@ fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<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> {
if let Ok(id) = class.parse::<u32>()
&& store.ledger().get_class(id).is_some()
{
return Ok(id);
let by_id = class
.parse::<u32>()
.ok()
.filter(|id| store.ledger().get_class(*id).is_some());
let by_name = store.ledger().find_class_by_name(class).map(|c| c.id);
match (by_id, by_name) {
(Some(i), Some(n)) if i != n => Err(format!(
"class {class:?} is ambiguous: it is class {i}'s id and class {n}'s name; pass the id"
)),
(Some(i), _) | (None, Some(i)) => Ok(i),
(None, None) => Err(format!(
"unknown class {class:?}: use an id or name from books.list_classes"
)),
}
store
.ledger()
.find_class_by_name(class)
.map(|c| c.id)
.ok_or_else(|| {
format!("unknown class {class:?}: use an id or name from books.list_classes")
})
}
/// A money amount as a decimal string with at least two places: "0.00",
@@ -923,6 +973,11 @@ impl BooksServer {
let mut store = self.lock()?;
let account = resolve_account(&store, &p.account)?;
let side = parse_side(&p.side)?;
// Resolve the class before writing anything (INVARIANT 2).
let class_id = match &p.class {
Some(c) => Some(resolve_class(&store, c)?),
None => None,
};
// Foreign-currency line?
if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() {
let fc = p
@@ -949,8 +1004,7 @@ impl BooksServer {
.map_err(|e| e.to_string())?;
}
// Class tag, if requested. The line just added is the last one.
if let Some(class_ref) = p.class {
let class_id = resolve_class(&store, &class_ref)?;
if let Some(class_id) = class_id {
let line_index = store
.ledger()
.get_entry(p.entry_id)
@@ -3362,11 +3416,11 @@ impl BooksServer {
let total = r.total().to_string();
AgingRowOut {
name: r.name,
current: r.current.to_string(),
days_30: r.days_30.to_string(),
days_60: r.days_60.to_string(),
days_90: r.days_90.to_string(),
days_90_plus: r.days_90_plus.to_string(),
current: money(r.current),
days_30: money(r.days_30),
days_60: money(r.days_60),
days_90: money(r.days_90),
days_90_plus: money(r.days_90_plus),
total,
}
})
@@ -3389,11 +3443,11 @@ impl BooksServer {
let total = r.total().to_string();
AgingRowOut {
name: r.name,
current: r.current.to_string(),
days_30: r.days_30.to_string(),
days_60: r.days_60.to_string(),
days_90: r.days_90.to_string(),
days_90_plus: r.days_90_plus.to_string(),
current: money(r.current),
days_30: money(r.days_30),
days_60: money(r.days_60),
days_90: money(r.days_90),
days_90_plus: money(r.days_90_plus),
total,
}
})
@@ -3584,6 +3638,11 @@ impl BooksServer {
if end < start {
return Err("end is before start".to_string());
}
// One class, if asked (validated like every reference).
let only = match &p.class {
Some(c) => Some(resolve_class(&store, c)?),
None => None,
};
// Per-class P&L over the date range: accumulate line by line.
use std::collections::HashMap;
#[derive(Default)]
@@ -3621,6 +3680,7 @@ impl BooksServer {
// Sort: named classes by name, unclassified last.
let mut rows: Vec<(Option<String>, Acc)> = by_class
.into_iter()
.filter(|(cid, _)| only.is_none() || *cid == only)
.map(|(cid, acc)| {
let name = cid
.and_then(|id| ledger.get_class(id))
+3 -3
View File
@@ -566,9 +566,9 @@ pub struct TrialBalanceLineOut {
pub code: String,
/// Account name.
pub name: String,
/// Debit balance as a decimal string, or "0".
/// Debit balance, decimal string ("0.00" when none).
pub debit: String,
/// Credit balance as a decimal string, or "0".
/// Credit balance, decimal string ("0.00" when none).
pub credit: String,
}
@@ -1570,7 +1570,7 @@ pub struct DisposeAssetOut {
/// Gain (positive) or loss (negative), decimal string.
pub gain_loss: String,
/// Depreciation auto-posted for the final partial period, decimal string
/// ("0" when the period was already posted).
/// ("0.00" when the period was already posted).
pub final_depreciation: String,
/// The disposal entry's lines with resulting balances.
pub lines: Vec<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
trustworthy — the harness only needs to drive the command surface.
Reports (`trial_balance`, `balance_sheet`, `profit_and_loss`, `snapshot`)
Reports (`trial_balance`, `balance_sheet`, `unclosed_earnings`, `snapshot`)
are free functions: pure, total, and deterministic. Same ledger in, same
report out, every time, on every platform. That determinism is the entire
basis of `docs/oracle.md`.
@@ -31,7 +31,7 @@ crates/ledger-core/
account.rs # AccountType, Side, Account, ChartOfAccounts
journal.rs # JournalLine, JournalEntry, validate()
ledger.rs # Ledger: the command surface, append-only log
reports.rs # trial_balance, profit_and_loss, balance_sheet, snapshot
reports.rs # trial_balance, unclosed_earnings, balance_sheet, snapshot
tests/
invariants.rs # accounting-law tests + randomized property test
golden/ # hand-verified expected report snapshots
+10 -10
View File
@@ -85,14 +85,13 @@ below.
- `books.draft_entry { date: "2026-10-10", description: "..." }`
→ `{ entry_id, status: "draft" }`.
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
amount: "123.45" }`
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
after every line: the agent watches the entry balance in real time.
- `books.post_entry { entry_id, confirmed }` → without confirmation:
`{ preview: { lines, resulting_balances, receipts: { attached: [...],
required_but_missing: bool } } }`; with confirmation: validates (including
the receipt policy), posts, returns `{ entry, balances_changed: [{ code,
before, after }] }`.
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
class, bad amount) adds nothing.
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
description, total, lines (with resulting balances), receipts,
receipt_required, receipt_missing, split_detected }`. Without
`confirmed: true`, `status` is `"preview"` and nothing changes; with it,
the receipt policy is enforced and `status` is `"posted"`.
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines.
@@ -492,8 +491,9 @@ entries.
- `books.add_line` gains optional `class` (name or id): tags the new line.
Shared costs are split explicitly across lines at entry time.
- `books.profit_and_loss` gains optional `class` filter.
- `books.profit_and_loss_by_class { start, end }` → per-class revenue,
expenses, net income, plus an unclassified row. Reversals carry their
- `books.profit_and_loss_by_class { start, end, class? }` → per-class revenue,
expenses, net income, plus an unclassified row (only that class's row
when `class` is given; an unknown or ambiguous class is refused). Reversals carry their
class so divisional P&L nets correctly.
## Tools — CPA handoff
+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 |
| 2 | A balance sheet omits zero-balance accounts | CONFIRMED | Reg. S-X 210.4-03(a): "No caption should be shown ... as to which the items and conditions are not present." A comparative statement must still show a zero beside a non-zero prior column |
| 3 | The lifetime `profit_and_loss` counts closing entries | CONFIRMED | Used as earnings not yet closed on the balance sheet; closed income is already in capital (OpenStax Principles of Financial Accounting 5.1). Its name misleads: rename to unclosed earnings (pending) |
| 3 | The lifetime `profit_and_loss` counts closing entries | CONFIRMED | Used as earnings not yet closed on the balance sheet; closed income is already in capital (OpenStax Principles of Financial Accounting 5.1). Renamed `unclosed_earnings` (2026-10-14) |
| 4 | Closing a period with nothing to close still locks it | CONVENTION | A close is a lock (B-12); a dormant month must still lock and count for close-in-order |
| 5 | Draws and distributions are closed into capital at period end | CONFIRMED | OpenStax 5.1: dividends "are closed to the permanent account"; drawing accounts close to owner's capital. Monthly closes mean the year's K-1 Item L withdrawals sum across months |
| 6 | Reconciliation balances | CONFIRMED | Signed by the account's normal side (a card reads as owed), as statements show it; `reconciling_a_card_reads_its_normal_side_...` |
+47 -5
View File
@@ -1413,6 +1413,17 @@ dependencies = [
"smallvec",
]
[[package]]
name = "ledger-config"
version = "0.1.0"
dependencies = [
"chrono",
"ledger-core",
"rust_decimal",
"serde",
"toml 1.1.8+spec-1.1.0",
]
[[package]]
name = "ledger-core"
version = "0.1.0"
@@ -1430,6 +1441,7 @@ version = "0.0.0"
dependencies = [
"arbitrary",
"chrono",
"ledger-config",
"ledger-core",
"ledger-import",
"ledger-store",
@@ -2337,6 +2349,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -2656,11 +2677,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.1.2",
"toml_datetime 1.1.2+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -2687,7 +2723,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
@@ -2720,6 +2756,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -2807,7 +2849,7 @@ dependencies = [
"indexmap",
"rustc-hash",
"stacker",
"toml",
"toml 0.8.23",
"typst-library",
"typst-macros",
"typst-syntax",
@@ -2923,7 +2965,7 @@ dependencies = [
"smallvec",
"syntect",
"time",
"toml",
"toml 0.8.23",
"ttf-parser",
"two-face",
"typed-arena",
@@ -3038,7 +3080,7 @@ dependencies = [
"ecow",
"rustc-hash",
"serde",
"toml",
"toml 0.8.23",
"typst-timing",
"typst-utils",
"unicode-ident",
+10
View File
@@ -24,6 +24,9 @@ path = "../crates/ledger-store"
[dependencies.ledger-import]
path = "../crates/ledger-import"
[dependencies.ledger-config]
path = "../crates/ledger-config"
[[bin]]
name = "fuzz_journal_line"
path = "fuzz_targets/fuzz_journal_line.rs"
@@ -74,3 +77,10 @@ path = "fuzz_targets/fuzz_import_csv.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_config_parse"
path = "fuzz_targets/fuzz_config_parse.rs"
test = false
doc = false
bench = false
+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"