//! Company settings from the books dir's `wanderbooks.toml`. //! //! `wanderbooks init` writes that file next to `books-data/`; onboarding //! fills in `[company]`. The server reads the company name (report headers), //! the fiscal year start (fiscal-year-to-date reports), the members of a //! multi-member LLC (`[[company.members]]`: name, share, capital account — //! for the statement of members' equity), `[books] accounting_basis` //! (report footers), and `[books] receipt_threshold` (the receipt policy, //! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before //! the next start, so the two can't disagree. pub mod receipt_policy; pub use receipt_policy::ReceiptPolicy; use chrono::{Datelike, NaiveDate}; use rust_decimal::Decimal; use serde::Deserialize; 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, } 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 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 { /// `[company]`. pub company: Company, /// `[books] receipt_threshold`, validated as a decimal string. pub receipt_threshold: Option, /// `[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). pub cash_accounts: Vec, /// `[books] financing_accounts`: liability account codes the cash flow /// statement treats as financing (loans, and what owners advanced: /// ASC 230-10-45-14(2)). Empty: callers decide. pub financing_accounts: Vec, /// `[books] investing_accounts`: asset account codes the cash flow /// statement treats as investing (loans the company makes, investments /// it buys: ASC 230-10-20). Registered fixed assets need not be listed. pub investing_accounts: Vec, } #[derive(Deserialize)] struct BooksToml { #[serde(default)] company: CompanyToml, #[serde(default)] books: BooksSection, } #[derive(Default, Deserialize)] struct CompanyToml { name: Option, fiscal_year_start: Option, tax_form: Option, tax_classification: Option, #[serde(default)] members: Vec, } #[derive(Deserialize)] struct MemberToml { name: String, share: String, capital_account: Option, distribution_account: Option, } #[derive(Default, Deserialize)] struct BooksSection { receipt_threshold: Option, accounting_basis: Option, close_into: Option, #[serde(default)] close_sweep: Vec, #[serde(default)] cash_accounts: Vec, #[serde(default)] financing_accounts: Vec, #[serde(default)] investing_accounts: Vec, } /// 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(); let bad = || format!("member {name}: share {s:?} must be like \"50%\" or \"0.5\""); let share = match s.strip_suffix('%') { Some(pct) => pct.trim().parse::().map_err(|_| bad())? / Decimal::from(100), None => s.parse::().map_err(|_| bad())?, }; if share <= Decimal::ZERO || share > Decimal::ONE { return Err(bad()); } Ok(share) } /// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the /// last day of a month: IRS Pub. 538, docs/spec/sources/ /// irs-periods-and-methods.md). fn parse_month_day(s: &str) -> Result<(u32, u32), String> { let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\""); let (m, d) = s.split_once('-').ok_or_else(bad)?; let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?); if !(1..=12).contains(&m) { return Err(bad()); } if d != 1 { return Err(format!( "fiscal_year_start {s:?}: a fiscal year starts on the first of a month (it ends on the last day of one: IRS Pub. 538)" )); } Ok((m, d)) } /// Account codes as written, trimmed; a blank one is refused. fn codes(key: &str, list: Vec) -> Result, String> { list.into_iter() .map(|c| { let c = c.trim().to_string(); if c.is_empty() { Err(format!("{key}: an account code is blank")) } else { Ok(c) } }) .collect() } /// A decimal string as the books take one: exactly `-?digits[.digits]`. /// The decimal parser alone also takes "1_000", "+5", ".5", and /// exponents ("1e3"); a setting or tool argument never means those. pub fn decimal(s: &str) -> Option { let digits = s.strip_prefix('-').unwrap_or(s); let (whole, frac) = match digits.split_once('.') { Some((w, f)) => (w, Some(f)), None => (digits, None), }; let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()); if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) { return None; } s.parse().ok() } /// The receipt policy in force: `config_json`'s `receipt_policy` when the /// file sets one, else `[books] receipt_threshold`, else the default /// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and /// the viewer both call this, so they enforce and show one rule. pub fn effective_receipt_policy( config_json: &Path, settings: &BooksSettings, ) -> Result { #[derive(Deserialize)] struct ConfigJson { #[serde(default)] receipt_policy: Option, } let from_json = if config_json.exists() { let raw = std::fs::read_to_string(config_json) .map_err(|e| format!("{}: {e}", config_json.display()))?; serde_json::from_str::(&raw) .map_err(|e| format!("bad config {}: {e}", config_json.display()))? .receipt_policy } else { None }; let policy = match (from_json, &settings.receipt_threshold) { (Some(p), _) => p, (None, Some(t)) => ReceiptPolicy { require_receipts_above: t.clone(), ..ReceiptPolicy::default() }, (None, None) => ReceiptPolicy::default(), }; policy.validate()?; Ok(policy) } /// Parse `wanderbooks.toml` text. /// /// ```text /// GOAL: read the company's settings, refusing any the books can't honor /// rather than guessing. /// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md: /// a fiscal year ends on the last day of a month); Treas. Reg. /// §301.7701-3 and the Schedule C instructions /// (docs/spec/sources/irs-entity-classification.md); IRC §704(a) /// (shares from the agreement: docs/spec/sources/irs-partnerships.md). /// INVARIANTS: /// 1. Every setting is optional; an empty file is a calendar-year, /// accrual-basis "Unnamed company" with no members. /// 2. fiscal_year_start MUST be the first of a month ("MM-01"). /// 3. Members: each MUST have a name and a capital account, names and /// capital accounts MUST be distinct (and no member's distributions /// account another's capital), and shares (each in (0, 1], "50%" or /// "0.5") MUST sum to exactly 1. /// 4. tax_form wins over tax_classification (which is then not read); /// a classification is read by whole words, ignoring case, and MUST /// name exactly one return (source table). tax_form, basis, and /// classification values ignore case. /// 5. receipt_threshold MUST be a decimal at least zero. /// 6. Account codes (cash, financing, investing, close_into, /// close_sweep, members' capital and distributions) are trimmed; a /// blank one is refused (a blank distributions account means none). close_into is /// refused with members (a close goes to their capital accounts). /// 7. profile holds every simple [company] value in file order: /// strings as written, numbers, booleans, and dates in TOML's /// spelling; tables and arrays (members) are left out. /// 8. NEVER accept a file that would be read differently than written: /// an unknown basis, tax form, or classification is refused. /// ``` pub fn parse(text: &str) -> Result { let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?; let mut company = Company::default(); if let Some(name) = raw.company.name.filter(|n| !n.trim().is_empty()) { company.name = name.trim().to_string(); } if let Some(fy) = raw.company.fiscal_year_start { company.fiscal_year_start = parse_month_day(fy.trim())?; } for m in raw.company.members { let share = parse_share(&m.name, &m.share)?; let capital_account = m .capital_account .filter(|c| !c.trim().is_empty()) .ok_or_else(|| format!("member {}: capital_account is required", m.name))?; company.members.push(Member { name: m.name.trim().to_string(), share, capital_account: capital_account.trim().to_string(), distribution_account: m .distribution_account .map(|d| d.trim().to_string()) .filter(|d| !d.is_empty()), }); } for (i, m) in company.members.iter().enumerate() { if m.name.is_empty() { return Err(format!("member {}: name is blank", i + 1)); } for other in &company.members[..i] { if other.name == m.name { return Err(format!("member {} is listed twice", m.name)); } if other.capital_account == m.capital_account { return Err(format!( "members {} and {} share capital account {}", other.name, m.name, m.capital_account )); } } for other in &company.members { if other.distribution_account.as_deref() == Some(m.capital_account.as_str()) { return Err(format!( "{}'s distributions account {} is {}'s capital account", other.name, m.capital_account, m.name )); } } } if !company.members.is_empty() { let total: Decimal = company.members.iter().map(|m| m.share).sum(); if total != Decimal::ONE { return Err(format!("member shares sum to {total}, not 100%")); } } company.tax_form = match (&raw.company.tax_form, &raw.company.tax_classification) { (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(|| { format!( "tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)" ) })?), (None, None) => None, }; if !company.members.is_empty() && raw.books.close_into.is_some() { return Err( "close_into is for single-owner businesses; with [[company.members]] a close goes to each member's capital account" .to_string(), ); } company.close_into = match raw.books.close_into { Some(c) => Some(codes("close_into", vec![c])?.remove(0)), None => None, }; company.close_sweep = codes("close_sweep", raw.books.close_sweep)?; if let Some(basis) = raw.books.accounting_basis { company.basis = match basis.trim().to_lowercase().as_str() { "accrual" => "Accrual".to_string(), "cash" => "Cash".to_string(), other => { return Err(format!( "accounting_basis {other:?}: expected accrual or cash" )); } }; } let receipt_threshold = match raw.books.receipt_threshold { Some(t) => { let v = decimal(t.trim()) .ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?; if v.is_sign_negative() && !v.is_zero() { return Err(format!("receipt_threshold {t:?} is below zero")); } Some(t.trim().to_string()) } None => None, }; let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?; if let Some(toml::Value::Table(c)) = table.get("company") { company.profile = c .iter() .filter_map(|(k, v)| { let s = match v { toml::Value::String(s) => s.clone(), toml::Value::Integer(i) => i.to_string(), toml::Value::Float(f) => f.to_string(), toml::Value::Boolean(b) => b.to_string(), toml::Value::Datetime(d) => d.to_string(), _ => return None, }; Some((k.clone(), s)) }) .collect(); } Ok(BooksSettings { company, receipt_threshold, 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)?, }) } /// Load `wanderbooks.toml` from a path. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; parse(&text).map_err(|e| format!("{}: {e}", path.display())) } #[cfg(test)] mod tests { use super::*; fn d(y: i32, m: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, day).unwrap() } #[test] fn calendar_year_by_default() { let c = parse("").unwrap().company; assert_eq!(c.fiscal_year_start, (1, 1)); assert_eq!( c.fiscal_year_containing(d(2026, 10, 11)), (d(2026, 1, 1), d(2026, 12, 31)) ); assert_eq!( c.fiscal_year_containing(d(2026, 1, 1)), (d(2026, 1, 1), d(2026, 12, 31)) ); } #[test] fn a_july_fiscal_year_spans_two_calendar_years() { let c = parse("[company]\nfiscal_year_start = \"07-01\"\n") .unwrap() .company; assert_eq!( c.fiscal_year_containing(d(2026, 3, 15)), (d(2025, 7, 1), d(2026, 6, 30)) ); assert_eq!( c.fiscal_year_containing(d(2026, 7, 1)), (d(2026, 7, 1), d(2027, 6, 30)) ); assert_eq!( c.fiscal_year_containing(d(2026, 6, 30)), (d(2025, 7, 1), d(2026, 6, 30)) ); } #[test] fn reads_the_scaffolded_file() { let s = parse( "[company]\nname = \"Fernhill Labs LLC\"\nentity_type = \"x\"\nfiscal_year_start = \"01-01\"\n\n[books]\ndata_dir = \"books-data\"\nreceipt_threshold = \"25.00\"\n", ) .unwrap(); assert_eq!(s.company.name, "Fernhill Labs LLC"); assert_eq!(s.receipt_threshold.as_deref(), Some("25.00")); // An empty name (fresh scaffold) keeps the placeholder. assert_eq!( parse("[company]\nname = \"\"\n").unwrap().company.name, "Unnamed company" ); } #[test] fn keeps_the_whole_profile_in_order() { let s = parse( "[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n", ) .unwrap(); assert_eq!( s.company.profile, [ ("name".to_string(), "Acme LLC".to_string()), ("ein".to_string(), "12-3456789".to_string()), ("formed".to_string(), "2026-10-04".to_string()), ] ); assert_eq!(s.cash_accounts, ["1000", "1010"]); assert_eq!(s.financing_accounts, ["2100"]); assert_eq!(s.investing_accounts, ["1400"]); } #[test] fn reads_members_and_basis() { let s = parse( "[company]\n[[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"Morgan Ellis\"\nshare = \"0.5\"\ncapital_account = \"3001\"\n[books]\naccounting_basis = \"cash\"\n", ) .unwrap(); let half = Decimal::new(5, 1); assert_eq!( s.company.members, vec![ Member { name: "Tobin Harlow".into(), share: half, capital_account: "3000".into(), distribution_account: None, }, Member { name: "Morgan Ellis".into(), share: half, capital_account: "3001".into(), distribution_account: None, }, ] ); assert_eq!(s.company.basis, "Cash"); assert_eq!(parse("").unwrap().company.basis, "Accrual"); // Shares must add up; every member needs a capital account. let two = |a: &str, b: &str| { format!( "[company]\n[[company.members]]\nname = \"A\"\nshare = \"{a}\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"{b}\"\ncapital_account = \"3001\"\n" ) }; assert!(parse(&two("50%", "40%")).is_err()); assert!(parse(&two("60%", "40%")).is_ok()); assert!(parse("[company]\n[[company.members]]\nname = \"A\"\nshare = \"100%\"\n").is_err()); assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err()); } #[test] fn tax_form_from_setting_or_classification() { use ledger_core::TaxForm; let form = |toml: &str| parse(toml).unwrap().company.tax_form; assert_eq!( form("[company]\ntax_form = \"1120s\"\n"), Some(TaxForm::Form1120S) ); assert_eq!( form("[company]\ntax_classification = \"partnership\"\n"), Some(TaxForm::Form1065) ); assert_eq!( form("[company]\ntax_classification = \"S corporation\"\n"), Some(TaxForm::Form1120S) ); assert_eq!( form("[company]\ntax_classification = \"disregarded entity\"\n"), Some(TaxForm::ScheduleC) ); assert_eq!( form("[company]\ntax_classification = \"partnership\"\ntax_form = \"1120\"\n"), Some(TaxForm::Form1120) ); assert_eq!(form(""), None); assert!(parse("[company]\ntax_form = \"990\"\n").is_err()); } #[test] fn rejects_bad_settings() { assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err()); assert!(parse("[company]\nfiscal_year_start = \"13-01\"\n").is_err()); assert!(parse("[company]\nfiscal_year_start = \"Jan 1\"\n").is_err()); assert!(parse("[books]\nreceipt_threshold = \"25 dollars\"\n").is_err()); } }