From b292a505b1c8d737310a309b00687639044f7956 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sat, 17 Oct 2026 10:42:13 -0600 Subject: [PATCH] P1: the books keep how they are kept Settings, conventions (posting templates, approval rules, policies, close steps, calendar, mappings, notes), decisions, open items and entry approvals are journal events (journal format 2). Books that keep their settings never read wanderbooks.toml or config.json; the server and the viewer take the vault key from --vault-key-file instead. books.import_settings moves a settings file in once, exactly as the server reads it. Approval rules, when enforced, gate every posting path. Replay refuses what it can't read as newer, never as corrupt. Lean: a record changes no figure. Mutation on the whole diff: every survivor killed by a test but two equivalent (docs/testing.md). The Company page says a late open item is overdue in words, not colour alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- .github/workflows/scheduled.yml | 2 +- Cargo.lock | 3 + README.md | 2 +- crates/ledger-cli/Cargo.toml | 1 + crates/ledger-cli/src/main.rs | 12 + crates/ledger-cli/tests/init.rs | 38 + crates/ledger-config/src/codec.rs | 766 +++++++ crates/ledger-config/src/lib.rs | 287 +-- .../tests/codec_props.proptest-regressions | 7 + crates/ledger-config/tests/codec_props.rs | 122 ++ .../ledger-config/tests/effective_policy.rs | 41 + crates/ledger-core/Cargo.toml | 3 + crates/ledger-core/src/assets.rs | 2 +- crates/ledger-core/src/conventions.rs | 1019 ++++++++++ crates/ledger-core/src/error.rs | 38 +- crates/ledger-core/src/ledger.rs | 555 ++++- crates/ledger-core/src/lib.rs | 2 + crates/ledger-core/src/settings.rs | 1781 +++++++++++++++++ crates/ledger-core/src/shares.rs | 2 +- crates/ledger-core/tests/conventions_units.rs | 359 ++++ crates/ledger-core/tests/settings_units.rs | 425 ++++ crates/ledger-core/tests/spec_ledger.rs | 2 +- crates/ledger-gui/src/books.rs | 32 +- crates/ledger-gui/src/data.rs | 38 +- crates/ledger-gui/src/lib.rs | 15 + crates/ledger-gui/src/main.rs | 9 +- crates/ledger-gui/src/pages/cash.rs | 2 +- crates/ledger-gui/src/pages/company.rs | 122 +- crates/ledger-gui/src/pages/journal.rs | 69 + crates/ledger-gui/tests/kept.rs | 271 +++ crates/ledger-gui/tests/newer_journal.rs | 4 +- crates/ledger-gui/tests/old_books.rs | 72 +- crates/ledger-gui/tests/vault_key_flag.rs | 76 + crates/ledger-mcp/handbook/contract.md | 3 +- .../handbook/settings-and-conventions.md | 41 +- crates/ledger-mcp/src/handbook.rs | 84 +- crates/ledger-mcp/src/main.rs | 53 +- crates/ledger-mcp/src/server.rs | 441 +++- crates/ledger-mcp/src/settings.rs | 232 ++- crates/ledger-mcp/src/tools/kept.rs | 1540 ++++++++++++++ crates/ledger-mcp/src/types.rs | 32 +- crates/ledger-mcp/tests/mcp_stdio.rs | 2 +- crates/ledger-mcp/tests/spec_kept.rs | 670 +++++++ crates/ledger-mcp/tests/spec_mcp.rs | 81 + crates/ledger-mcp/tests/spec_vault.rs | 27 + crates/ledger-mcp/tests/startup.rs | 2 +- crates/ledger-store/src/format.rs | 37 +- crates/ledger-store/src/journal.rs | 6 + crates/ledger-store/src/lib.rs | 1 + crates/ledger-store/src/meta.rs | 380 ++++ crates/ledger-store/src/schema.rs | 59 +- crates/ledger-store/src/store.rs | 496 ++++- ...f1a153ceaf11dc060ab44b120a94406a08207babc2 | 1 + .../old-books/p1_record/expected.json | 81 + .../old-books/p1_record/journal.jsonl | 25 + .../fixtures/old-books/p1_record/record.json | 289 +++ crates/ledger-store/tests/format.rs | 46 +- crates/ledger-store/tests/settings.rs | 1092 ++++++++++ docs/architecture.md | 17 + docs/mcp-interface.md | 202 +- docs/spec/rules.md | 75 + docs/testing.md | 75 + docs/verification.md | 16 +- fuzz/Cargo.lock | 110 +- fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/fuzz_settings_replay.rs | 226 +++ fuzz/fuzz_targets/fuzz_store_replay.rs | 7 +- plan/STATUS.md | 18 +- plan/served.md | 12 + verify/lean/Audit.lean | 2 + verify/lean/Wanderbooks/Ledger.lean | 43 +- 71 files changed, 12249 insertions(+), 461 deletions(-) create mode 100644 crates/ledger-config/src/codec.rs create mode 100644 crates/ledger-config/tests/codec_props.proptest-regressions create mode 100644 crates/ledger-config/tests/codec_props.rs create mode 100644 crates/ledger-config/tests/effective_policy.rs create mode 100644 crates/ledger-core/src/conventions.rs create mode 100644 crates/ledger-core/src/settings.rs create mode 100644 crates/ledger-core/tests/conventions_units.rs create mode 100644 crates/ledger-core/tests/settings_units.rs create mode 100644 crates/ledger-gui/tests/kept.rs create mode 100644 crates/ledger-gui/tests/vault_key_flag.rs create mode 100644 crates/ledger-mcp/src/tools/kept.rs create mode 100644 crates/ledger-mcp/tests/spec_kept.rs create mode 100644 crates/ledger-store/src/meta.rs create mode 100644 crates/ledger-store/tests/fixtures/old-books/p1_record/blobs/c5/c2/c5c2e8be6ad0825a56ded1f1a153ceaf11dc060ab44b120a94406a08207babc2 create mode 100644 crates/ledger-store/tests/fixtures/old-books/p1_record/expected.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/p1_record/journal.jsonl create mode 100644 crates/ledger-store/tests/fixtures/old-books/p1_record/record.json create mode 100644 crates/ledger-store/tests/settings.rs create mode 100644 fuzz/fuzz_targets/fuzz_settings_replay.rs diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index 4a59d0c..65b3483 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_import_csv, fuzz_config_parse] + target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse] steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v2 diff --git a/Cargo.lock b/Cargo.lock index 125b897..5b7aa79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3348,6 +3348,7 @@ dependencies = [ "defmt", "jiff-core", "jiff-static", + "jiff-tzdb", "jiff-tzdb-platform", "js-sys", "log", @@ -3651,6 +3652,7 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" name = "ledger-cli" version = "0.1.0" dependencies = [ + "chrono", "ledger-config", "ledger-core", "serde_json", @@ -3678,6 +3680,7 @@ version = "0.1.0" dependencies = [ "chrono", "criterion", + "jiff", "proptest", "rust_decimal", "serde", diff --git a/README.md b/README.md index 0b9b8a3..a52fc9c 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ just # lists the recipes: check, build, prove, demo, ... ``` crates/ledger-core/ # engine: money, accounts, journal, ledger, reports crates/ledger-store/ # journal + SQLite projection + blob store -crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy +crates/ledger-mcp/ # MCP server: 108 tools, resources, handbook, receipt policy crates/ledger-import/ # QBO CSV import, IIF/XLSX export crates/ledger-config/ # wanderbooks.toml company settings crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index 11cd89b..158921b 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -11,6 +11,7 @@ name = "wanderbooks" path = "src/main.rs" [dev-dependencies] +chrono = "0.4" ledger-config = { path = "../ledger-config" } ledger-core = { path = "../ledger-core" } serde_json = "1" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index bc3702c..066b9aa 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -262,6 +262,9 @@ fiscal_year_start = "01-01" # "partnership", "S corporation", "C corporation", or "disregarded entity" # (Treas. Reg. §301.7701-3). tax_form wins when both are set. # tax_classification = "partnership" +# The company's time zone (an IANA name): what "today" means for the +# books. Unset: the computer's. +# timezone = "America/Denver" # Identity facts the CPA needs, each from a primary document (EIN letter, # certificate of organization). Any simple key = value line here shows in # the viewer's profile, labelled from its key. @@ -304,6 +307,10 @@ accounting_basis = "accrual" # means every entry (IRS Pub. 583: keep the documents that support your # books). A receipt_policy in books-data/config.json overrides it. receipt_threshold = "25.00" +# Accounts whose entries always need a receipt, whatever the amount; and +# accounts exempt from the rule (an entry whose every line is in one). +# receipt_required_accounts = ["6200"] +# receipt_exempt_accounts = ["6999"] # Account codes that hold cash (bank, savings, cash on hand), for the cash # flow statement and dashboards. Unset: cash accounts are found by name. # cash_accounts = ["1000", "1010"] @@ -332,6 +339,11 @@ receipt_threshold = "25.00" # (a transfer between your own accounts); "off" never mentions it. # parties = "suggest" +# Approval rules (conventions naming who approves which entries) block a +# post until approved only under "enforce"; "off" (the default) records +# them and blocks nothing. +# approval_rules = "off" + # Which accounts invoices, bills, payroll, and closes post to (receivables, # payables, revenue, payroll, retained earnings) is not set here: those # roles are part of the books, journaled with books.assign_role and listed diff --git a/crates/ledger-cli/tests/init.rs b/crates/ledger-cli/tests/init.rs index 2327832..fab60f9 100644 --- a/crates/ledger-cli/tests/init.rs +++ b/crates/ledger-cli/tests/init.rs @@ -418,3 +418,41 @@ fn the_toml_shows_every_setting_the_config_reads() { assert!(has(sect, key), "[{sect}] {key} is in the template"); } } + +/// Moving the scaffolded settings file, and every example in it, into +/// books: each imports, reads as the file does (the import refuses +/// otherwise), and exporting it back and importing the export changes +/// nothing (plan/served.md P1: toml_round_trip_zero_events). +#[test] +fn the_toml_and_its_examples_import_and_round_trip() { + use ledger_core::settings::{Settings, Source}; + let tmp = tempfile::tempdir().unwrap(); + let (_, text) = scaffolded_toml(tmp.path()); + let single_owner = |ex: &str| ex.starts_with("close_into "); + let source = Source { + decided_by: "onboarding".into(), + decided_on: chrono_date(), + citation: None, + note: None, + }; + for file in [ + text.clone(), + uncomment(&text, |ex| !single_owner(ex)), + uncomment(&text, single_owner), + ] { + let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD") + .unwrap_or_else(|e| panic!("imports: {e}\n{file}")); + assert!(import.unknown.is_empty(), "{:?}", import.unknown); + let mut books = Settings::default(); + let changes = books.check_changes(&import.changes, &source).unwrap(); + books.apply(&changes, &source, 1).unwrap(); + let export = ledger_config::codec::export(&books, "USD"); + let again = ledger_config::codec::import(&export, None, &books, "USD") + .unwrap_or_else(|e| panic!("the export imports: {e}\n{export}")); + assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes); + } +} + +fn chrono_date() -> chrono::NaiveDate { + chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap() +} diff --git a/crates/ledger-config/src/codec.rs b/crates/ledger-config/src/codec.rs new file mode 100644 index 0000000..7dc8b4f --- /dev/null +++ b/crates/ledger-config/src/codec.rs @@ -0,0 +1,766 @@ +//! Settings files as text in and out of the books (plan/served.md 5.4). +//! +//! Books keep their settings in the journal (`ledger_core::settings`). +//! A `wanderbooks.toml` is text to import from and export to: the server +//! never reads one once the books hold their settings. +//! +//! ```text +//! GOAL: move a company's settings file into its books without changing +//! how a single setting reads, and write them back out as a file. +//! SOURCE: plan/served.md I-3, 5.4 (toml_round_trip_zero_events); the +//! settings file's own rules (crate::parse). +//! INVARIANTS: +//! 1. An import is exactly the changes that make the books' settings +//! what the file (and config.json's receipt policy, if given) says: +//! keys the file sets and the books don't, or set differently, are +//! set; keys the books set and the file doesn't are cleared. Nothing +//! else. +//! 2. NEVER an import that reads differently than the file: the +//! settings after it give the same company, receipt policy, cash +//! flow roles, asset tag prefix, party policy and time zone that +//! the file gives the server today (crate::parse, +//! crate::effective_receipt_policy_text), or the import is refused. +//! 3. Nothing in the file is dropped silently: what isn't a books +//! setting is a note (where it goes instead) or unknown (not +//! imported; the caller shows it before anything is journaled). +//! 4. Round trip: importing an export changes nothing (identity values +//! are exported masked, and a masked value imports as "unchanged"). +//! ``` + +use crate::{BooksSettings, ReceiptPolicy}; +use ledger_core::settings::{self, Change, KEYS, Kind, Sensitivity, Settings, Source}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +/// What importing a settings file would do. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Import { + /// The changes that make the books' settings what the file says + /// (INVARIANT 1); empty when they already are. + pub changes: Vec, + /// What the file says that isn't a books setting, and where it goes + /// instead (INVARIANT 3). + pub notes: Vec, + /// Entries the books don't know: not imported (INVARIANT 3). + pub unknown: Vec, + /// `[vault] key_file`, if the file sets one: the server's to use + /// (`--vault-key-file`), not a books setting. + pub vault_key_file: Option, + /// The file's comments, in order: a settings file's notes (where a + /// value came from, what it waits on) are not settings, so they are + /// listed for the caller to keep elsewhere (a convention, a decision, a + /// setting's note) rather than dropped unseen (INVARIANT 3). + pub comments: Vec, +} + +/// The comments of TOML text: what follows each `#` outside a string, in +/// order, trimmed, blank ones left out. +pub fn comments(text: &str) -> Vec { + let mut out = Vec::new(); + for line in text.lines() { + let mut quote: Option = None; + let mut escaped = false; + for (i, c) in line.char_indices() { + match quote { + Some(q) => { + if escaped { + escaped = false; + } else if c == '\\' && q == '"' { + escaped = true; + } else if c == q { + quote = None; + } + } + None if c == '"' || c == '\'' => quote = Some(c), + None if c == '#' => { + let rest = line[i + 1..].trim(); + if !rest.is_empty() { + out.push(rest.to_string()); + } + break; + } + None => {} + } + } + } + out +} + +/// A TOML value as the JSON a setting takes: strings stay strings; a +/// number, boolean or date in a text setting reads as its TOML spelling; +/// arrays and tables convert element by element. +fn json_of(v: &toml::Value) -> Value { + match v { + toml::Value::String(s) => json!(s), + toml::Value::Integer(i) => json!(i.to_string()), + toml::Value::Float(f) => json!(f.to_string()), + toml::Value::Boolean(b) => json!(b.to_string()), + toml::Value::Datetime(d) => json!(d.to_string()), + toml::Value::Array(a) => Value::Array(a.iter().map(json_of).collect()), + toml::Value::Table(t) => { + Value::Object(t.iter().map(|(k, v)| (k.clone(), json_of(v))).collect()) + } + } +} + +fn key_at(table: &str, name: &str) -> Option<&'static settings::Key> { + KEYS.iter().find(|k| k.toml == (table, name)) +} + +/// What importing `text` (a `wanderbooks.toml`) and, if given, +/// `config_json` (its `receipt_policy` wins over the file's receipt +/// settings, as it does for the server) would change in books holding +/// `current`, kept in `base_currency`. +pub fn import( + text: &str, + config_json: Option<&str>, + current: &Settings, + base_currency: &str, +) -> Result { + // The file as the server reads it today: what the import must match. + let legacy = crate::parse(text)?; + let legacy_policy = crate::effective_receipt_policy_text(config_json, &legacy)?; + let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?; + let mut out = Import { + comments: comments(text), + ..Import::default() + }; + let mut wanted: BTreeMap<&'static str, Value> = BTreeMap::new(); + for (section, body) in &table { + let toml::Value::Table(body) = body else { + out.unknown.push(format!("{section} (outside any table)")); + continue; + }; + for (name, v) in body { + match (section.as_str(), name.as_str()) { + ("company", "base_currency") => { + let c = v.as_str().unwrap_or_default().trim().to_uppercase(); + if c != base_currency { + return Err(format!( + "[company] base_currency {c:?}: these books are kept in {base_currency}, fixed when they were created" + )); + } + out.notes.push(format!( + "[company] base_currency: the books keep it themselves ({base_currency}, fixed when they were created)" + )); + } + ("books", "data_dir") => out.notes.push( + "[books] data_dir: where the books live is the server's to know, not a setting; dropped".into(), + ), + ("vault", "key_file") => { + out.vault_key_file = v.as_str().map(str::to_string); + out.notes.push( + "[vault] key_file: the server's to know (start it with --vault-key-file), not a books setting; dropped".into(), + ); + } + ("viewer", n) => out.notes.push(format!( + "[viewer] {n}: the agent's preference, not a books setting; dropped (record it as a convention if it matters)" + )), + (s, n) => match key_at(s, n) { + Some(k) => { + wanted.insert(k.name, json_of(v)); + } + None => out.unknown.push(format!("[{s}] {n}")), + }, + } + } + } + if let Some(raw) = config_json { + let j: Value = serde_json::from_str(raw).map_err(|e| format!("bad config: {e}"))?; + let obj = j.as_object().ok_or("bad config: not a JSON object")?; + for (k, v) in obj { + match k.as_str() { + "receipt_policy" => { + // Its policy replaces the file's, whole (crate:: + // effective_receipt_policy_text). + let p: ReceiptPolicy = serde_json::from_value(v.clone()) + .map_err(|e| format!("bad config: {e}"))?; + wanted.insert("policy.evidence.threshold", json!(p.require_receipts_above)); + wanted.insert( + "policy.evidence.required_accounts", + json!(p.receipt_required_accounts), + ); + wanted.insert( + "policy.evidence.exempt_accounts", + json!(p.receipt_exempt_accounts), + ); + } + "base_currency" => { + let c = v.as_str().unwrap_or_default().trim().to_uppercase(); + if c != base_currency { + return Err(format!( + "config.json base_currency {c:?}: these books are kept in {base_currency}" + )); + } + } + other => out.unknown.push(format!("config.json {other}")), + } + } + } + // Canonical values, and the diff against what the books hold. An + // identity value exported masked ("**-***5893") never imports: it is + // what the books hold already when its last four match, else a note, + // and either way the books' own value stays (INVARIANT 4). + let mut canon: BTreeMap<&'static str, Value> = BTreeMap::new(); + let mut keep: Vec<&'static str> = Vec::new(); + for (name, v) in &wanted { + let k = settings::key(name).expect("taken from KEYS"); + let masked = + k.sensitivity == Sensitivity::Identity && v.as_str().is_some_and(|s| s.contains('*')); + if masked { + let same = current.get(name).is_some_and(|r| { + v.as_str() == Some(settings::display(k, &r.value, false).as_str()) + }); + if !same { + out.notes.push(format!( + "[{}] {}: masked in this file, so not imported; set it with books.set_setting", + k.toml.0, k.toml.1 + )); + } + keep.push(k.name); + continue; + } + let value = + settings::check(k, v).map_err(|e| format!("[{}] {}: {e}", k.toml.0, k.toml.1))?; + // An empty list is the same as no list: not a change. + if value.as_array().is_some_and(Vec::is_empty) && k.kind != Kind::Members { + continue; + } + canon.insert(k.name, value); + } + for k in KEYS { + let have = current.get(k.name).map(|r| &r.value); + match (canon.get(k.name), have) { + (Some(want), Some(have)) if want == have => {} + (Some(want), _) => out.changes.push(Change { + key: k.name.into(), + value: Some(want.clone()), + }), + (None, Some(_)) if keep.contains(&k.name) => {} + (None, Some(_)) => out.changes.push(Change { + key: k.name.into(), + value: None, + }), + (None, None) => {} + } + } + // INVARIANT 2: the books, after the import, read as the file does. + let mut next = current.clone(); + let probe = Source { + decided_by: "import".into(), + decided_on: chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("a date"), + citation: None, + note: None, + }; + next.apply(&out.changes, &probe, u64::MAX)?; + next.check_whole()?; + let ours = books_settings(&next, base_currency); + let our_policy = receipt_policy(&next); + let differs = differences(&legacy, &legacy_policy, &ours, &our_policy); + if !differs.is_empty() { + return Err(format!( + "importing would read these settings differently than the file does: {}", + differs.join(", ") + )); + } + Ok(out) +} + +/// Where two readings of the settings differ, by field name. +fn differences( + a: &BooksSettings, + ap: &ReceiptPolicy, + b: &BooksSettings, + bp: &ReceiptPolicy, +) -> Vec<&'static str> { + let (ca, cb) = (&a.company, &b.company); + let threshold = |p: &ReceiptPolicy| crate::decimal(p.require_receipts_above.trim()); + [ + ("company name", ca.name == cb.name), + ( + "fiscal year start", + ca.fiscal_year_start == cb.fiscal_year_start, + ), + ("members", ca.members == cb.members), + ("accounting basis", ca.basis == cb.basis), + ("tax form", ca.tax_form == cb.tax_form), + ("close_into", ca.close_into == cb.close_into), + ("close_sweep", ca.close_sweep == cb.close_sweep), + ("receipt threshold", threshold(ap) == threshold(bp)), + ( + "receipt required accounts", + ap.receipt_required_accounts == bp.receipt_required_accounts, + ), + ( + "receipt exempt accounts", + ap.receipt_exempt_accounts == bp.receipt_exempt_accounts, + ), + ("cash accounts", a.cash_accounts == b.cash_accounts), + ( + "financing accounts", + a.financing_accounts == b.financing_accounts, + ), + ( + "investing accounts", + a.investing_accounts == b.investing_accounts, + ), + ( + "asset tag prefix", + a.asset_tag_prefix() == b.asset_tag_prefix(), + ), + ("parties", a.parties == b.parties), + ("time zone", a.timezone == b.timezone), + ] + .into_iter() + .filter(|(_, same)| !same) + .map(|(n, _)| n) + .collect() +} + +/// The settings as the server and the viewer use them, from the books' +/// own (`ledger_core::settings`): what [`crate::parse`] gives for a +/// settings file. `[vault] key_file` is never a books setting. +pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings { + BooksSettings { + company: s.company(base_currency), + receipt_threshold: s.receipt_threshold().map(str::to_string), + receipt_required_accounts: s.receipt_required_accounts(), + receipt_exempt_accounts: s.receipt_exempt_accounts(), + cash_accounts: s.cash_accounts(), + financing_accounts: s.financing_accounts(), + investing_accounts: s.investing_accounts(), + asset_tag_prefix: s.asset_tag_prefix().map(str::to_string), + parties: s.parties(), + vault_key_file: None, + timezone: s.timezone().map(str::to_string), + } +} + +/// The receipt policy the books' own settings set. +pub fn receipt_policy(s: &Settings) -> ReceiptPolicy { + ReceiptPolicy { + require_receipts_above: s + .receipt_threshold() + .unwrap_or(settings::DEFAULT_THRESHOLD) + .to_string(), + receipt_required_accounts: s.receipt_required_accounts(), + receipt_exempt_accounts: s.receipt_exempt_accounts(), + } +} + +/// One TOML line `name = value`, the value spelled as TOML. +fn toml_line(name: &str, v: &Value) -> String { + let tv = match v { + Value::String(s) => toml::Value::String(s.clone()), + Value::Array(a) => toml::Value::Array( + a.iter() + .map(|x| toml::Value::String(x.as_str().unwrap_or_default().to_string())) + .collect(), + ), + other => toml::Value::String(other.to_string()), + }; + let mut t = toml::Table::new(); + t.insert(name.to_string(), tv); + toml::to_string(&t) + .unwrap_or_default() + .trim_end() + .to_string() +} + +fn provenance(out: &mut String, r: &settings::Recorded) { + let s = &r.source; + let _ = writeln!( + out, + "# {}, {} (journal line {})", + s.decided_by, s.decided_on, r.seq + ); + for extra in [&s.citation, &s.note].into_iter().flatten() { + for line in extra.lines() { + let _ = writeln!(out, "# {line}"); + } + } +} + +/// The books' settings as a settings file (`wanderbooks.toml` text): +/// every setting set, under its table, with who decided it and when as a +/// comment; identity values masked (INVARIANT 4). +pub fn export(s: &Settings, base_currency: &str) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "# wanderbooks settings, exported from the books{}.", + s.last_seq() + .map(|q| format!(" (through journal line {q})")) + .unwrap_or_default() + ); + out.push_str( + "# The books keep their own settings: this file is a copy, and editing it\n# changes nothing until it is imported (books.import_settings).\n", + ); + for table in ["company", "books"] { + let _ = write!(out, "\n[{table}]\n"); + if table == "company" { + let _ = writeln!( + out, + "# Fixed when the books were created.\nbase_currency = {:?}", + base_currency + ); + } + let mut members = None; + for (k, r) in s.iter().filter(|(k, _)| k.toml.0 == table) { + if k.kind == Kind::Members { + members = Some((k, r)); + continue; + } + provenance(&mut out, r); + let shown = match k.sensitivity { + Sensitivity::Identity => Value::String(settings::display(k, &r.value, false)), + Sensitivity::Public => r.value.clone(), + }; + let _ = writeln!(out, "{}", toml_line(k.toml.1, &shown)); + } + if let Some((_, r)) = members { + let _ = writeln!(out); + provenance(&mut out, r); + for m in r.value.as_array().into_iter().flatten() { + out.push_str("[[company.members]]\n"); + for f in [ + "name", + "share", + "capital_account", + "distribution_account", + "files_jointly_with", + ] { + if let Some(v) = m.get(f) { + let _ = writeln!(out, "{}", toml_line(f, v)); + } + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const WANDERLAND: &str = r#" +[company] +name = "Wanderland Labs LLC" +entity_type = "LLC, multi-member, member-managed" +tax_classification = "partnership" +base_currency = "USD" +fiscal_year_start = "01-01" +formation_state = "UT" +formed = "2026-10-04" +state_entity_number = "14745117-0160" +ein = "43-2215893" +irs_name_control = "WALN" +duns = "149976972" +address = "15281 S Tall Woods Dr, Draper, UT 84020" +registered_agent = "Stephen Waits" +partnership_representative = "Stephen Waits" +cpa = "Krista Baker" + +[[company.members]] +name = "Stephen Waits" +share = "50%" +capital_account = "3000" +files_jointly_with = "Katherine Pendergast" + +[[company.members]] +name = "Katherine Pendergast" +share = "50%" +capital_account = "3001" +files_jointly_with = "Stephen Waits" + +[books] +data_dir = "books-data" +accounting_basis = "accrual" +receipt_threshold = "0.00" +cash_accounts = ["1000"] +financing_accounts = ["2100"] +parties = "require" + +[viewer] +open_at_start = "ask" +"#; + + fn src() -> Source { + Source { + decided_by: "Owner".into(), + decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(), + citation: None, + note: Some("imported".into()), + } + } + + fn imported(text: &str) -> Settings { + let i = import(text, None, &Settings::default(), "USD").unwrap(); + let mut s = Settings::default(); + let changes = s.check_changes(&i.changes, &src()).unwrap(); + s.apply(&changes, &src(), 1).unwrap(); + s + } + + /// INVARIANTS 1-3 on a real company's file. + #[test] + fn a_real_file_imports_whole() { + let i = import(WANDERLAND, None, &Settings::default(), "USD").unwrap(); + assert!(i.unknown.is_empty(), "{:?}", i.unknown); + assert_eq!(i.notes.len(), 3, "{:?}", i.notes); + // 14 company facts, the members, and 5 [books] settings. + assert_eq!(i.changes.len(), 20); + let s = imported(WANDERLAND); + assert_eq!(s.get("company.ein").unwrap().value, json!("43-2215893")); + assert_eq!(s.parties(), crate::PartyPolicy::Require); + // Already imported: nothing to change. + let again = import(WANDERLAND, None, &s, "USD").unwrap(); + assert!(again.changes.is_empty(), "{:?}", again.changes); + } + + /// INVARIANT 4: an export imports as no change at all. + #[test] + fn export_round_trips() { + let s = imported(WANDERLAND); + let text = export(&s, "USD"); + assert!(text.contains("ein = \"**-***5893\""), "{text}"); + assert!(!text.contains("2215893"), "{text}"); + let back = import(&text, None, &s, "USD").unwrap(); + assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes); + assert!(back.unknown.is_empty(), "{:?}", back.unknown); + // And the export reads, as a settings file, as the books do. + assert_eq!( + crate::parse(&text).unwrap().company.name, + "Wanderland Labs LLC" + ); + } + + /// INVARIANT 1: keys the file leaves out are cleared. + #[test] + fn what_the_file_leaves_out_is_cleared() { + let s = imported(WANDERLAND); + let i = import( + "[company]\nname = \"Wanderland Labs LLC\"\n", + None, + &s, + "USD", + ) + .unwrap(); + assert!( + i.changes + .iter() + .any(|c| c.key == "company.members" && c.value.is_none()) + ); + assert!(!i.changes.iter().any(|c| c.key == "company.name")); + } + + /// INVARIANT 3, and the currency is the books'. + #[test] + fn unknown_and_currency() { + let i = import( + "[company]\nwebsite = \"x\"\n[extra]\na = 1\n", + None, + &Settings::default(), + "USD", + ) + .unwrap(); + assert_eq!(i.unknown, ["[company] website", "[extra] a"]); + assert!( + import( + "[company]\nbase_currency = \"EUR\"\n", + None, + &Settings::default(), + "USD" + ) + .is_err() + ); + } + + /// config.json's receipt policy wins, whole, as it does for the server. + #[test] + fn config_json_policy_wins() { + let j = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#; + let i = import( + "[books]\nreceipt_threshold = \"0.00\"\n", + Some(j), + &Settings::default(), + "USD", + ) + .unwrap(); + let mut s = Settings::default(); + s.apply(&i.changes, &src(), 1).unwrap(); + let p = receipt_policy(&s); + assert_eq!(p.require_receipts_above, "10.00"); + assert_eq!(p.receipt_exempt_accounts, ["6999"]); + } + + /// INVARIANT 2: a value the books would read differently is refused + /// (here: a time zone the file names that isn't one). + #[test] + fn never_reads_differently() { + assert!( + import( + "[company]\ntimezone = \"Nowhere/Land\"\n", + None, + &Settings::default(), + "USD" + ) + .is_err() + ); + assert!( + import( + "[company]\nfiscal_year_start = \"07-01\"\n", + None, + &Settings::default(), + "USD" + ) + .is_ok() + ); + } + + /// A comment is what follows a `#` outside a string: a `#` inside a + /// basic string (even after an escaped quote) or a literal string + /// (where a backslash escapes nothing) is text; blank comments are + /// left out. + #[test] + fn comments_are_what_follows_a_hash_outside_strings() { + let text = "# top\n\ + name = \"a # not\" # one\n\ + esc = \"q \\\" # still\" # two\n\ + lit = 'c:\\' # three\n\ + #\n\ + x = 1 # \n\ + y = 2#five\n"; + assert_eq!(comments(text), ["top", "one", "two", "three", "five"]); + assert!(comments("a = 'x' \"y\"\n").is_empty()); + } + + /// The import lists the file's comments (INVARIANT 3). + #[test] + fn an_import_lists_the_comments() { + let i = import( + "# Kept settings\n[company]\nname = \"X LLC\" # from the certificate\n", + None, + &Settings::default(), + "USD", + ) + .unwrap(); + assert_eq!(i.comments, ["Kept settings", "from the certificate"]); + } + + /// config.json's base_currency must be the books'. + #[test] + fn config_json_currency_is_the_books() { + let ok = import( + "", + Some(r#"{"base_currency": " usd "}"#), + &Settings::default(), + "USD", + ) + .unwrap(); + assert!(ok.changes.is_empty() && ok.unknown.is_empty(), "{ok:?}"); + let err = import( + "", + Some(r#"{"base_currency": "EUR"}"#), + &Settings::default(), + "USD", + ) + .unwrap_err(); + assert!(err.contains("config.json base_currency \"EUR\""), "{err}"); + } + + /// INVARIANT 4: a masked identity value never imports. It keeps the + /// books' own value either way, with a note only when its last four + /// differ from what the books hold. + #[test] + fn a_masked_identity_keeps_the_books_value() { + let s = imported(WANDERLAND); + let masked = + |ein: &str| WANDERLAND.replace("ein = \"43-2215893\"", &format!("ein = \"{ein}\"")); + let note = + "[company] ein: masked in this file, so not imported; set it with books.set_setting"; + let same = import(&masked("**-***5893"), None, &s, "USD").unwrap(); + assert!(same.changes.is_empty(), "{:?}", same.changes); + assert!(!same.notes.iter().any(|n| n == note), "{:?}", same.notes); + let other = import(&masked("**-***0000"), None, &s, "USD").unwrap(); + assert!(other.changes.is_empty(), "{:?}", other.changes); + assert!(other.notes.iter().any(|n| n == note), "{:?}", other.notes); + // Into books that hold none: still not imported, and noted. + let empty = import( + "[company]\nein = \"**-***5893\"\n", + None, + &Settings::default(), + "USD", + ) + .unwrap(); + assert!(empty.changes.is_empty(), "{:?}", empty.changes); + assert_eq!(empty.notes, [note]); + } + + /// An empty list is no list: not a change. + #[test] + fn an_empty_list_is_no_change() { + let i = import( + "[books]\ncash_accounts = []\nfinancing_accounts = []\n", + None, + &Settings::default(), + "USD", + ) + .unwrap(); + assert!(i.changes.is_empty(), "{:?}", i.changes); + } + + /// A value the file changes is a change, exactly that one. + #[test] + fn a_changed_value_is_one_change() { + let s = imported(WANDERLAND); + let text = WANDERLAND.replace("\"Wanderland Labs LLC\"", "\"Other Labs LLC\""); + let i = import(&text, None, &s, "USD").unwrap(); + assert_eq!( + i.changes, + [Change { + key: "company.name".into(), + value: Some(json!("Other Labs LLC")), + }] + ); + } + + /// INVARIANT 2's check names each field read differently. + #[test] + fn differences_name_each_field() { + let a = BooksSettings::default(); + let p = ReceiptPolicy::default(); + assert!(differences(&a, &p, &a, &p).is_empty()); + let mut b = a.clone(); + b.company.name = "Other".into(); + b.timezone = Some("America/Denver".into()); + let mut q = p.clone(); + q.require_receipts_above = format!("{}0", p.require_receipts_above); + // The default asset tag prefix is made from the company's name. + let named = ["company name", "asset tag prefix", "time zone"]; + assert_eq!(differences(&a, &p, &b, &q), named); + q.require_receipts_above = "1.00".into(); + assert_eq!( + differences(&a, &p, &b, &q), + [ + "company name", + "receipt threshold", + "asset tag prefix", + "time zone" + ] + ); + } + + /// An export says, for each setting, who decided it, when, and its + /// note. + #[test] + fn an_export_keeps_each_settings_source() { + let text = export(&imported(WANDERLAND), "USD"); + assert!( + text.contains("\n# Owner, 2026-10-17 (journal line 1)\n# imported\n"), + "{text}" + ); + } +} diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index fa19790..62dadfc 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -9,128 +9,15 @@ //! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before //! the next start, so the two can't disagree. +pub mod codec; pub mod receipt_policy; +pub use ledger_core::settings::{Company, Member, PartyPolicy, decimal}; pub use receipt_policy::ReceiptPolicy; -use chrono::{Datelike, NaiveDate}; use rust_decimal::Decimal; use serde::Deserialize; use std::path::Path; -/// The company the books belong to. -#[derive(Clone, Debug, PartialEq)] -pub struct Company { - /// Legal name, for report headers. - pub name: String, - /// Month and day the fiscal year starts. Default January 1. - pub fiscal_year_start: (u32, u32), - /// Members of a multi-member LLC; empty for other entities. - pub members: Vec, - /// "Accrual" or "Cash", for report footers. Default "Accrual". - pub basis: String, - /// The income tax return the business files, if known: from - /// `tax_form`, else from `tax_classification`. - pub tax_form: Option, - /// `[books] close_into`: the equity account a period close puts net - /// income in (a sole proprietor's capital, say). Unset: Retained - /// Earnings, or members' capital accounts when there are members. - pub close_into: Option, - /// `[books] close_sweep`: equity accounts (draws, dividends, - /// distributions) a close zeroes into `close_into`. - pub close_sweep: Vec, - /// Every simple `[company]` setting (name, EIN, formation date, ...), - /// in file order, as written: the full profile for display. - pub profile: Vec<(String, String)>, -} - -/// One member of a multi-member LLC. -#[derive(Clone, Debug, PartialEq)] -pub struct Member { - /// Name, as it appears on reports. - pub name: String, - /// Share of income, e.g. 0.50. - pub share: Decimal, - /// Code of their capital account in the chart. - pub capital_account: String, - /// Code of their distributions account, if distributions are recorded - /// separately from capital (zeroed into capital at close). - pub distribution_account: Option, - /// `files_jointly_with`: the other member this member files a joint - /// individual return with (spouses). Each still gets their own K-1 - /// (a partnership reports each partner separately, IRS Instructions - /// for Form 1065, Schedule K-1); both K-1s go on the one Form 1040 - /// (Schedule E, Part II). Informational: the CPA package says so. - pub files_jointly_with: Option, -} - -impl Default for Company { - fn default() -> Self { - Self { - name: "Unnamed company".to_string(), - fiscal_year_start: (1, 1), - members: Vec::new(), - basis: "Accrual".to_string(), - tax_form: None, - close_into: None, - close_sweep: Vec::new(), - profile: Vec::new(), - } - } -} - -impl Company { - /// The members as party shares see them ([`ledger_core::PartyMember`]): - /// each member whose name (ignoring case and surrounding space) is a - /// party with the member role, and whose capital account is in the - /// chart. The party not archived of that name when there is one, else - /// the most recent archived one: archiving a member ("no longer dealt - /// with; history stays") never takes their history away. Members - /// missing either are left out, so their shares can't be derived (and - /// the viewer shows no capital statement for them). - pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec { - let is_member = |p: &&ledger_core::Party| p.roles.contains(&ledger_core::Role::Member); - self.members - .iter() - .filter_map(|m| { - let key = ledger_core::parties::name_key(&m.name); - let party = match ledger.party_by_name(&m.name) { - Some(p) => Some(p).filter(is_member)?, - None => ledger - .parties() - .filter(|p| p.archived && ledger_core::parties::name_key(&p.name) == key) - .filter(is_member) - .max_by_key(|p| p.id)?, - }; - Some(ledger_core::PartyMember { - party: party.id, - share: m.share, - capital: ledger.chart().find_by_code(&m.capital_account)?.id, - distributions: m - .distribution_account - .as_deref() - .and_then(|c| ledger.chart().find_by_code(c)) - .map(|a| a.id), - }) - }) - .collect() - } - - /// The fiscal year containing `d`: `(first day, last day)`, inclusive. - pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) { - let (m, day) = self.fiscal_year_start; - let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day"); - let start = if on(d.year()) <= d { - on(d.year()) - } else { - on(d.year() - 1) - }; - let end = on(start.year() + 1) - .pred_opt() - .expect("not the minimum date"); - (start, end) - } -} - /// What the server takes from `wanderbooks.toml`. #[derive(Clone, Debug, Default, PartialEq)] pub struct BooksSettings { @@ -138,6 +25,12 @@ pub struct BooksSettings { pub company: Company, /// `[books] receipt_threshold`, validated as a decimal string. pub receipt_threshold: Option, + /// `[books] receipt_required_accounts`: account codes whose entries + /// always need a receipt (unless `config.json` sets a policy). + pub receipt_required_accounts: Vec, + /// `[books] receipt_exempt_accounts`: account codes exempt from the + /// receipt rule (unless `config.json` sets a policy). + pub receipt_exempt_accounts: Vec, /// `[books] cash_accounts`: account codes that hold cash (bank, /// savings, cash on hand), for cash reports and dashboards. Empty: /// callers decide (e.g. by account name). @@ -160,6 +53,9 @@ pub struct BooksSettings { /// `[vault] key_file`: the TIN vault's key file. Unset: the vault's /// default (`~/.config/wanderbooks/vault.key`). pub vault_key_file: Option, + /// `[company] timezone`: the company's time zone (an IANA name), what + /// "today" means for the books. Unset: the computer's. + pub timezone: Option, } impl BooksSettings { @@ -231,6 +127,7 @@ struct CompanyToml { fiscal_year_start: Option, tax_form: Option, tax_classification: Option, + timezone: Option, #[serde(default)] members: Vec, } @@ -247,6 +144,10 @@ struct MemberToml { #[derive(Default, Deserialize)] struct BooksSection { receipt_threshold: Option, + #[serde(default)] + receipt_required_accounts: Vec, + #[serde(default)] + receipt_exempt_accounts: Vec, accounting_basis: Option, close_into: Option, #[serde(default)] @@ -261,47 +162,6 @@ struct BooksSection { parties: Option, } -/// 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 { - 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, - } -} - /// Parse a share: "50%" or "0.5". fn parse_share(name: &str, s: &str) -> Result { let s = s.trim(); @@ -348,22 +208,6 @@ fn codes(key: &str, list: Vec) -> Result, String> { .collect() } -/// A decimal string as the books take one: exactly `-?digits[.digits]`. -/// The decimal parser alone also takes "1_000", "+5", ".5", and -/// exponents ("1e3"); a setting or tool argument never means those. -pub fn decimal(s: &str) -> Option { - let digits = s.strip_prefix('-').unwrap_or(s); - let (whole, frac) = match digits.split_once('.') { - Some((w, f)) => (w, Some(f)), - None => (digits, None), - }; - let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()); - if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) { - return None; - } - s.parse().ok() -} - /// The receipt policy in force: `config_json`'s `receipt_policy` when the /// file sets one, else `[books] receipt_threshold`, else the default /// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and @@ -371,28 +215,52 @@ pub fn decimal(s: &str) -> Option { pub fn effective_receipt_policy( config_json: &Path, settings: &BooksSettings, +) -> Result { + let raw = if config_json.exists() { + Some( + std::fs::read_to_string(config_json) + .map_err(|e| format!("{}: {e}", config_json.display()))?, + ) + } else { + None + }; + effective_receipt_policy_text(raw.as_deref(), settings).map_err(|e| { + e.replace( + "bad config", + &format!("bad config {}", config_json.display()), + ) + }) +} + +/// [`effective_receipt_policy`] from `config.json`'s text (`None`: no +/// such file). +pub fn effective_receipt_policy_text( + config_json: Option<&str>, + settings: &BooksSettings, ) -> Result { #[derive(Deserialize)] struct ConfigJson { #[serde(default)] receipt_policy: Option, } - let from_json = if config_json.exists() { - let raw = std::fs::read_to_string(config_json) - .map_err(|e| format!("{}: {e}", config_json.display()))?; - serde_json::from_str::(&raw) - .map_err(|e| format!("bad config {}: {e}", config_json.display()))? - .receipt_policy - } else { - None + let from_json = match config_json { + Some(raw) => { + serde_json::from_str::(raw) + .map_err(|e| format!("bad config: {e}"))? + .receipt_policy + } + None => None, }; - let policy = match (from_json, &settings.receipt_threshold) { - (Some(p), _) => p, - (None, Some(t)) => ReceiptPolicy { - require_receipts_above: t.clone(), - ..ReceiptPolicy::default() + let policy = match from_json { + Some(p) => p, + None => ReceiptPolicy { + require_receipts_above: settings + .receipt_threshold + .clone() + .unwrap_or_else(|| ledger_core::settings::DEFAULT_THRESHOLD.to_string()), + receipt_required_accounts: settings.receipt_required_accounts.clone(), + receipt_exempt_accounts: settings.receipt_exempt_accounts.clone(), }, - (None, None) => ReceiptPolicy::default(), }; policy.validate()?; Ok(policy) @@ -521,7 +389,7 @@ pub fn parse(text: &str) -> Result { (Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| { format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120") })?), - (None, Some(c)) => Some(form_for_classification(c).ok_or_else(|| { + (None, Some(c)) => Some(ledger_core::settings::form_for_classification(c).ok_or_else(|| { format!( "tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)" ) @@ -578,9 +446,26 @@ pub fn parse(text: &str) -> Result { }) .collect(); } + let timezone = match raw.company.timezone { + Some(z) => { + let key = ledger_core::settings::key("company.timezone").expect("a known key"); + let v = ledger_core::settings::check(key, &toml_json(&z))?; + v.as_str().map(str::to_string) + } + None => None, + }; Ok(BooksSettings { company, receipt_threshold, + receipt_required_accounts: codes( + "receipt_required_accounts", + raw.books.receipt_required_accounts, + )?, + receipt_exempt_accounts: codes( + "receipt_exempt_accounts", + raw.books.receipt_exempt_accounts, + )?, + timezone, cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?, financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?, investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?, @@ -622,31 +507,8 @@ pub fn parse(text: &str) -> Result { }) } -/// `[books] parties`: whether entries name their counterparty -/// (docs/parties-and-attachments.md "Counterparties"; DECISION, Stephen, -/// 2026-10-15: the default suggests, books may require or turn it off). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum PartyPolicy { - /// Never mention counterparties. - Off, - /// A draft or preview without a party names the likely one; nothing - /// is refused. - #[default] - Suggest, - /// Posting is refused until the entry names a party or is marked as - /// having none. - Require, -} - -impl PartyPolicy { - /// Its wire name. - pub fn key(self) -> &'static str { - match self { - PartyPolicy::Off => "off", - PartyPolicy::Suggest => "suggest", - PartyPolicy::Require => "require", - } - } +fn toml_json(s: &str) -> serde_json::Value { + serde_json::Value::String(s.to_string()) } /// Load `wanderbooks.toml` from a path. @@ -658,6 +520,7 @@ pub fn load(path: &Path) -> Result { #[cfg(test)] mod tests { use super::*; + use chrono::NaiveDate; /// files_jointly_with: names another member, on both sides. #[test] diff --git a/crates/ledger-config/tests/codec_props.proptest-regressions b/crates/ledger-config/tests/codec_props.proptest-regressions new file mode 100644 index 0000000..8e32c2d --- /dev/null +++ b/crates/ledger-config/tests/codec_props.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 71eaf212cfff55fb9489fddf65627997660881f193be6bcbb21bc2a2add6a420 # shrinks to picks = [(44, false)] diff --git a/crates/ledger-config/tests/codec_props.rs b/crates/ledger-config/tests/codec_props.rs new file mode 100644 index 0000000..f9201c5 --- /dev/null +++ b/crates/ledger-config/tests/codec_props.rs @@ -0,0 +1,122 @@ +//! The settings file codec over settings built at random from valid and +//! refused changes (plan/served.md P1: toml_round_trip_zero_events). +//! +//! ```text +//! INVARIANTS (crate::codec): +//! 1. An export imports into the same books as no change at all. +//! 2. An export imports into empty books as exactly those settings, +//! identity values aside (exported masked: they stay unset). +//! 3. The export reads, as a settings file, as the books do. +//! ``` + +use chrono::NaiveDate; +use ledger_config::codec; +use ledger_core::settings::{Change, Settings, Source}; +use proptest::prelude::*; +use serde_json::{Value, json}; + +fn src() -> Source { + Source { + decided_by: "Pat Rivera".into(), + decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(), + citation: Some("Operating agreement §4.1\nsecond line".into()), + note: None, + } +} + +fn candidates() -> Vec<(&'static str, Value)> { + vec![ + ("company.name", json!("Acme \"Widgets\" LLC")), + ("company.name", json!("Ünïcödé Holdings")), + ("company.entity_type", json!("LLC, multi-member")), + ("company.tax_classification", json!("S corporation")), + ("company.tax_classification", json!("partnership")), + ("company.tax_form", json!("1065")), + ("company.fiscal_year_start", json!("07-01")), + ("company.timezone", json!("America/Denver")), + ("company.formation_state", json!("UT")), + ("company.formed", json!("2026-10-04")), + ("company.state_entity_number", json!("14745117-0160")), + ("company.ein", json!("43-2215893")), + ("company.irs_name_control", json!("waln")), + ("company.duns", json!("149-976-972")), + ("company.address", json!("1 Main St\nSuite 2, Draper, UT")), + ("company.registered_agent", json!("Pat Rivera")), + ("company.partnership_representative", json!("Pat Rivera")), + ("company.cpa", json!("Krista Baker")), + ( + "company.members", + json!([{"name": "A", "share": "50%", "capital_account": "3000", + "distribution_account": "3100", "files_jointly_with": "B"}, + {"name": "B", "share": "0.5", "capital_account": "3001", + "files_jointly_with": "A"}]), + ), + ( + "company.members", + json!([{"name": "Solo", "share": "100%", "capital_account": "3000"}]), + ), + ("books.accounting_basis", json!("cash")), + ("books.cash_accounts", json!(["1000", "1010"])), + ("books.financing_accounts", json!(["2100"])), + ("books.investing_accounts", json!(["1400"])), + ("books.close_into", json!("3000")), + ("books.close_sweep", json!(["3100"])), + ("books.asset_tag_prefix", json!("WL")), + ("policy.evidence.threshold", json!("0.00")), + ("policy.evidence.threshold", json!("25.5")), + ("policy.evidence.required_accounts", json!(["6200"])), + ("policy.evidence.exempt_accounts", json!(["6999", "6998"])), + ("policy.parties", json!("require")), + ("policy.approval_rules", json!("enforce")), + ] +} + +fn build(picks: &[(usize, bool)]) -> Settings { + let pool = candidates(); + let mut s = Settings::default(); + for (seq, (i, clear)) in picks.iter().enumerate() { + let (k, v) = &pool[i % pool.len()]; + let change = Change { + key: (*k).into(), + value: (!clear).then(|| v.clone()), + }; + // Refused changes (settings that wouldn't hold together) are + // skipped, as the books skip them. + if let Ok(made) = s.check_changes(&[change], &src()) { + s.apply(&made, &src(), seq as u64 + 1).unwrap(); + } + } + s +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn exports_round_trip(picks in proptest::collection::vec((0usize..64, prop::bool::weighted(0.15)), 0..30)) { + let books = build(&picks); + let text = codec::export(&books, "USD"); + // INVARIANT 3: the export reads as a settings file. + let file = ledger_config::parse(&text) + .unwrap_or_else(|e| panic!("the export reads as a settings file: {e}\n{text}")); + prop_assert_eq!(&file.company.name, &books.company("USD").name); + // INVARIANT 1. + let same = codec::import(&text, None, &books, "USD") + .unwrap_or_else(|e| panic!("{e}\n{text}")); + prop_assert!(same.changes.is_empty(), "{:?}\n{}", same.changes, text); + prop_assert!(same.unknown.is_empty(), "{:?}", same.unknown); + // INVARIANT 2. + let fresh = codec::import(&text, None, &Settings::default(), "USD") + .unwrap_or_else(|e| panic!("{e}\n{text}")); + let mut other = Settings::default(); + other.apply(&fresh.changes, &src(), 1).unwrap(); + for (k, r) in books.iter() { + if k.name == "company.ein" { + prop_assert!(other.get(k.name).is_none()); + } else { + prop_assert_eq!(other.get(k.name).map(|o| &o.value), Some(&r.value), "{}", k.name); + } + } + prop_assert_eq!(other.iter().count() + usize::from(books.get("company.ein").is_some()), books.iter().count()); + } +} diff --git a/crates/ledger-config/tests/effective_policy.rs b/crates/ledger-config/tests/effective_policy.rs new file mode 100644 index 0000000..d087a58 --- /dev/null +++ b/crates/ledger-config/tests/effective_policy.rs @@ -0,0 +1,41 @@ +//! The receipt policy in force when the settings come from files: +//! config.json's when it sets one, else the settings file's threshold. + +use ledger_config::{BooksSettings, effective_receipt_policy}; + +fn dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn config_json_wins_else_the_settings_file() { + let d = dir("files"); + let json = d.join("config.json"); + let settings = BooksSettings { + receipt_threshold: Some("0.00".into()), + ..BooksSettings::default() + }; + // No config.json: the settings file's threshold. + let p = effective_receipt_policy(&json, &settings).unwrap(); + assert_eq!(p.require_receipts_above, "0.00"); + // config.json's policy, whole. + std::fs::write( + &json, + r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#, + ) + .unwrap(); + let p = effective_receipt_policy(&json, &settings).unwrap(); + assert_eq!(p.require_receipts_above, "10.00"); + assert_eq!(p.receipt_exempt_accounts, ["6999"]); + // A broken one is refused, by name. + std::fs::write(&json, "{ not json").unwrap(); + let err = effective_receipt_policy(&json, &settings).unwrap_err(); + assert!( + err.starts_with(&format!("bad config {}", json.display())), + "{err}" + ); + std::fs::remove_dir_all(&d).unwrap(); +} diff --git a/crates/ledger-core/Cargo.toml b/crates/ledger-core/Cargo.toml index 2b70ea3..71cc9ba 100644 --- a/crates/ledger-core/Cargo.toml +++ b/crates/ledger-core/Cargo.toml @@ -12,6 +12,9 @@ thiserror = "2" chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# company.timezone: IANA names, with the zone database built in so every +# computer reads a name the same way. +jiff = { version = "0.2.35", default-features = false, features = ["std", "tz-system", "tzdb-bundle-always"] } [lints] workspace = true diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs index ad9771c..56f0455 100644 --- a/crates/ledger-core/src/assets.rs +++ b/crates/ledger-core/src/assets.rs @@ -163,7 +163,7 @@ impl TaxBasis { self.check_journaled()?; match self.contributor_basis { Some(b) if b > crate::kernel::MAX_LINE_AMOUNT => { - Err("a basis is at most 1000000000000000".into()) + Err("a basis is at most one quadrillion".into()) } _ => Ok(()), } diff --git a/crates/ledger-core/src/conventions.rs b/crates/ledger-core/src/conventions.rs new file mode 100644 index 0000000..f4df6a8 --- /dev/null +++ b/crates/ledger-core/src/conventions.rs @@ -0,0 +1,1019 @@ +//! A company's conventions, decisions and open items, journaled in the +//! books (plan/served.md 5.2: `convention.*`, `decision.recorded`, +//! `open_item.*`, and `entry.approved` for approval rules). +//! +//! Conventions are how this company keeps its books: posting templates +//! ("an expense a member paid personally: Dr expense / Cr Due to +//! Members"), policies, close steps, calendar items, notes. Decisions are +//! the choices the owners made, with who made them and when. Open items +//! are what is waiting on someone. All are company data that any agent +//! reads through the server, not prose in a file beside the books. +//! +//! ```text +//! GOAL: keep the company's way of working, its decisions and its loose +//! ends in the books, sourced, so every agent everywhere sees them. +//! SOURCE: plan/served.md I-3, 4.2 (books.start lists conventions), 5.2; +//! DECISION (Stephen, 2026-10-16): light governance, approval +//! rules exist for companies that want them and are off by +//! default. +//! INVARIANTS: +//! 1. A convention's id is a slug ([a-z0-9-], 1 to 64, starting with a +//! letter or digit), never reused: setting a live id revises it (its +//! kind can't change), a retired id is refused. +//! 2. A posting template has a debit and a credit line, each side's +//! shares summing to exactly 1, each on an account in the chart that +//! takes postings or on a slot ("{expense}") that names one when a +//! draft is made from it; drafting from it makes lines on accounts in +//! the chart that take postings, summing on each side to the amount +//! exactly, each a whole number of minor units, none zero. +//! 3. An approval rule names its approver and limits itself by accounts +//! that take postings, an amount, or both; under +//! `policy.approval_rules = enforce` an entry a rule covers posts only +//! once that approver approved it as it stands (its fingerprint: a +//! line added, a class or a party changed after an approval voids +//! it), and an +//! entry the server would post at once (an invoice, a bill, a +//! payment, payroll, a remittance, a disposal) that a rule covers is +//! refused: draft it instead. Reversals, closes, depreciation and +//! amortization follow from entries already posted and are exempt. +//! Under `off` (the default) rules never block anything. +//! 4. Decision and open-item ids are allocated in order and journaled; +//! a decision supersedes at most one live decision; an item resolves +//! once. +//! 5. Replay takes what was journaled (rules.md B-28): text limits and +//! chart checks are for new commands only. +//! 6. No text holds a full TIN (settings INVARIANT 4). +//! ``` + +use crate::settings::{MAX_LONG_TEXT, MAX_TEXT, Source, line_text, long_text}; +use crate::{EntryId, Side}; +use chrono::NaiveDate; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Longest convention or decision body. +pub const MAX_BODY: usize = 20_000; + +/// What a convention is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConventionKind { + /// How a kind of transaction is posted; drafts come from it + /// (`books.draft_from_template`). + PostingTemplate, + /// Who must approve which entries (enforced only under + /// `policy.approval_rules = enforce`). + ApprovalRule, + /// A rule of practice (numbering, file formats, capitalization). + Policy, + /// A step of the month-end close. + CloseStep, + /// Something due on a date every year (annual report, returns). + Calendar, + /// A mapping between these books and another system's names. + Mapping, + /// Anything else worth keeping. + Note, +} + +impl ConventionKind { + /// Every kind, in order. + pub const ALL: [ConventionKind; 7] = [ + ConventionKind::PostingTemplate, + ConventionKind::ApprovalRule, + ConventionKind::Policy, + ConventionKind::CloseStep, + ConventionKind::Calendar, + ConventionKind::Mapping, + ConventionKind::Note, + ]; + + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + ConventionKind::PostingTemplate => "posting_template", + ConventionKind::ApprovalRule => "approval_rule", + ConventionKind::Policy => "policy", + ConventionKind::CloseStep => "close_step", + ConventionKind::Calendar => "calendar", + ConventionKind::Mapping => "mapping", + ConventionKind::Note => "note", + } + } + + /// From its wire name. + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.key() == s) + } +} + +/// One line of a posting template. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TemplateLine { + /// The account's code, or a slot, `{name}` (lowercase letters, + /// digits and `_`), that a draft names: `{expense}`. + pub account: String, + /// Debit or credit (`"debit"`, `"credit"`). + #[serde(with = "side_word")] + pub side: Side, + /// Its share of the amount, as a decimal string ("1", "0.5"). + pub share: String, +} + +/// A side as a word, `"debit"` or `"credit"`. +mod side_word { + use crate::Side; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(side: &Side, s: S) -> Result { + s.serialize_str(match side { + Side::Debit => "debit", + Side::Credit => "credit", + }) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + match String::deserialize(d)?.to_lowercase().as_str() { + "debit" => Ok(Side::Debit), + "credit" => Ok(Side::Credit), + other => Err(serde::de::Error::custom(format!( + "side {other:?}: debit or credit" + ))), + } + } +} + +/// A posting template's structure. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Template { + /// The entry description drafts take unless given one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The lines. + pub lines: Vec, +} + +/// An approval rule's structure. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalRule { + /// Who approves: a name. + pub approver: String, + /// The rule covers entries with a line in one of these accounts + /// (codes); empty: any account. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub accounts: Vec, + /// The rule covers entries whose debits are strictly greater than + /// this; unset: any amount. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount_over: Option, +} + +/// A convention being set: what `books.set_convention` takes. +#[derive(Clone, Debug, PartialEq)] +pub struct NewConvention { + /// Its id (a slug). + pub id: String, + /// What it is. + pub kind: ConventionKind, + /// A short title. + pub title: String, + /// The convention in words (Markdown). + pub body_md: String, + /// Its structure: a [`Template`] for a posting template, an + /// [`ApprovalRule`] for an approval rule; none for other kinds. + pub structured: Option, + /// Who decided it. + pub source: Source, +} + +/// Why and when something was retired or resolved. +#[derive(Clone, Debug, PartialEq)] +pub struct Closing { + /// Why (a convention's retirement) or how (an item's resolution). + pub text: String, + /// Who decided. + pub source: Source, + /// The journal line. + pub seq: u64, +} + +/// A convention as the journal holds it. +#[derive(Clone, Debug, PartialEq)] +pub struct Convention { + /// Its id. + pub id: String, + /// What it is. + pub kind: ConventionKind, + /// A short title. + pub title: String, + /// The convention in words. + pub body_md: String, + /// Its structure, if any. + pub structured: Option, + /// Who decided this revision. + pub source: Source, + /// The journal line of this revision. + pub seq: u64, + /// 1 for the first version, then 2, 3, ... + pub revision: u32, + /// Set once retired. + pub retired: Option, +} + +impl Convention { + /// Its template, when it is a posting template. + pub fn template(&self) -> Option