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