//! The settings registry's small parts, value by value: each accessor, each //! check boundary, each tax classification, each masked or joined display. //! `settings.rs`'s own tests cover the invariants; these pin the exact //! outputs the server and viewer rely on (P1 mutation testing, //! 2026-10-17). use chrono::{Datelike, NaiveDate}; use ledger_core::settings::{ Change, Company, Member, PartyPolicy, Settings, Source, check, display, form_for_classification, holds_printed_tin, key, line_text, long_text, parse_month_day, parse_share, shape, today_in, }; use ledger_core::{AccountType, Currency, Ledger, PartyField, Role, TaxForm}; use rust_decimal::Decimal; use serde_json::{Value, json}; use std::str::FromStr; fn date(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).unwrap() } fn dec(s: &str) -> Decimal { Decimal::from_str(s).unwrap() } fn src() -> Source { Source { decided_by: "Owner".into(), decided_on: date(2026, 10, 17), citation: None, note: None, } } /// Settings holding `pairs`, checked then applied as one change. fn settings(pairs: &[(&str, Value)]) -> Settings { let changes: Vec = pairs .iter() .map(|(k, v)| Change { key: (*k).into(), value: Some(v.clone()), }) .collect(); let mut s = Settings::default(); let checked = s.check_changes(&changes, &src()).unwrap(); s.apply(&checked, &src(), 1).unwrap(); s } fn checked(name: &str, v: Value) -> Result { check(key(name).unwrap(), &v) } #[test] fn accessors_return_what_was_set() { let s = settings(&[ ("policy.evidence.threshold", json!("0.00")), ("policy.evidence.required_accounts", json!(["1000", "2050"])), ("policy.evidence.exempt_accounts", json!(["6900"])), ("books.cash_accounts", json!(["1000", "1010"])), ("books.financing_accounts", json!(["2500"])), ("books.investing_accounts", json!(["1500", "1510"])), ("books.asset_tag_prefix", json!("WL")), ("policy.parties", json!("off")), ("books.close_sweep", json!(["3100"])), ]); assert_eq!(s.receipt_threshold(), Some("0.00")); assert_eq!(s.receipt_required_accounts(), ["1000", "2050"]); assert_eq!(s.receipt_exempt_accounts(), ["6900"]); assert_eq!(s.cash_accounts(), ["1000", "1010"]); assert_eq!(s.financing_accounts(), ["2500"]); assert_eq!(s.investing_accounts(), ["1500", "1510"]); assert_eq!(s.asset_tag_prefix(), Some("WL")); assert_eq!(s.parties(), PartyPolicy::Off); assert_eq!(s.company("USD").close_sweep, ["3100"]); let s = settings(&[("policy.parties", json!("require"))]); assert_eq!(s.parties(), PartyPolicy::Require); // Unset: nothing, and the default policy. let s = Settings::default(); assert_eq!(s.receipt_threshold(), None); assert!(s.cash_accounts().is_empty()); assert_eq!(s.asset_tag_prefix(), None); assert_eq!(s.parties(), PartyPolicy::Suggest); } #[test] fn the_company_profile_is_its_company_keys_name_first() { let s = settings(&[ ("company.ein", json!("12-3456789")), ("company.name", json!("Acme LLC")), ("company.duns", json!("149976972")), ( "company.members", json!([ {"name": "Kat", "share": "50%", "capital_account": "3001"}, {"name": "Stephen", "share": "50%", "capital_account": "3000"}, ]), ), ("books.accounting_basis", json!("cash")), ("books.asset_tag_prefix", json!("WL")), ]); let c = s.company("USD"); let profile: Vec<(&str, &str)> = c .profile .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); assert_eq!( profile, [ ("name", "Acme LLC"), ("base_currency", "USD"), ("ein", "12-3456789"), ("duns", "149976972"), ] ); assert_eq!(c.name, "Acme LLC"); assert_eq!(c.basis, "Cash"); assert_eq!(c.members.len(), 2); } #[test] fn party_policy_spellings() { assert_eq!(PartyPolicy::Off.key(), "off"); assert_eq!(PartyPolicy::Suggest.key(), "suggest"); assert_eq!(PartyPolicy::Require.key(), "require"); assert_eq!(PartyPolicy::parse("off"), Some(PartyPolicy::Off)); assert_eq!(PartyPolicy::parse("suggest"), Some(PartyPolicy::Suggest)); assert_eq!(PartyPolicy::parse("require"), Some(PartyPolicy::Require)); assert_eq!(PartyPolicy::parse("required"), Some(PartyPolicy::Require)); assert_eq!(PartyPolicy::parse("Require"), None); assert_eq!(PartyPolicy::parse(""), None); } #[test] fn fiscal_year_bounds() { let c = Company { fiscal_year_start: (7, 1), ..Company::default() }; assert_eq!( c.fiscal_year_containing(date(2026, 3, 15)), (date(2025, 7, 1), date(2026, 6, 30)) ); assert_eq!( c.fiscal_year_containing(date(2026, 6, 30)), (date(2025, 7, 1), date(2026, 6, 30)) ); assert_eq!( c.fiscal_year_containing(date(2026, 7, 1)), (date(2026, 7, 1), date(2027, 6, 30)) ); let calendar = Company::default(); assert_eq!( calendar.fiscal_year_containing(date(2024, 2, 29)), (date(2024, 1, 1), date(2024, 12, 31)) ); } /// A member's party is the live one of their name, else the most recent /// archived one of their name: never another archived member's. #[test] fn party_members_find_each_members_own_party() { let mut l = Ledger::new(Currency::USD); let capital = l .add_account("3001", "Capital — Kat", AccountType::Equity, None, false) .unwrap(); l.add_account( "3000", "Capital — Stephen", AccountType::Equity, None, false, ) .unwrap(); let kat = l.add_party("Kat", &[Role::Member]).unwrap(); let other = l.add_party("Pat", &[Role::Member]).unwrap(); l.update_party(kat, PartyField::Archived(true)).unwrap(); l.update_party(other, PartyField::Archived(true)).unwrap(); let c = Company { members: vec![Member { name: "kat ".into(), share: Decimal::ONE, capital_account: "3001".into(), distribution_account: None, files_jointly_with: None, }], ..Company::default() }; let got = c.party_members(&l); assert_eq!(got.len(), 1); assert_eq!(got[0].party, kat); assert_eq!(got[0].capital, capital); assert_eq!(got[0].share, Decimal::ONE); assert_eq!(got[0].distributions, None); } #[test] fn printed_tin_joiners() { // Three spaces is not a joiner; a 3-6 split is no TIN's shape; two // dashes is not one joiner. assert!(!holds_printed_tin("123 45 6789")); assert!(!holds_printed_tin("123 456789")); assert!(!holds_printed_tin("123--45--6789")); // Two spaces, and a dash with a space each side, are. assert!(holds_printed_tin("123 45 6789")); assert!(holds_printed_tin("123 - 45 - 6789")); assert!(holds_printed_tin("12 3456789")); } #[test] fn text_length_boundaries() { assert_eq!(line_text("x", "aaaaa", 5).unwrap(), "aaaaa"); assert_eq!( line_text("x", "aaaaaa", 5).unwrap_err(), "x is longer than 5 characters" ); assert_eq!(long_text("x", "aaaaa", 5).unwrap(), "aaaaa"); assert_eq!( long_text("x", "aaaaaa", 5).unwrap_err(), "x is longer than 5 characters" ); assert_eq!(long_text("x", "a\nb\tc", 10).unwrap(), "a\nb\tc"); assert_eq!( long_text("x", "a\u{7}b", 10).unwrap_err(), "x holds a control character" ); } #[test] fn share_boundaries() { assert_eq!(parse_share("K", "1").unwrap(), Decimal::ONE); assert_eq!(parse_share("K", "100%").unwrap(), Decimal::ONE); assert_eq!(parse_share("K", "0.5").unwrap(), dec("0.5")); assert_eq!(parse_share("K", "50%").unwrap(), dec("0.5")); assert_eq!(parse_share("K", "0.0001").unwrap(), dec("0.0001")); for bad in ["0", "0%", "-0.5", "1.0001", "101%", "2"] { assert_eq!( parse_share("K", bad).unwrap_err(), format!("member K: share {bad:?} must be like \"50%\" or \"0.5\""), ); } } #[test] fn month_day_digits_only() { assert_eq!(parse_month_day("07-01").unwrap(), (7, 1)); for bad in ["+1-01", "01-+1", "-01", "01-"] { assert_eq!( parse_month_day(bad).unwrap_err(), format!("fiscal_year_start {bad:?}: expected \"MM-DD\", e.g. \"01-01\""), ); } } #[test] fn every_classification_names_its_return() { use TaxForm::*; for (c, want) in [ ("partnership", Some(Form1065)), ("General Partnership", Some(Form1065)), ("S corporation", Some(Form1120S)), ("S-corp", Some(Form1120S)), ("s corp", Some(Form1120S)), ("scorp", Some(Form1120S)), ("C corporation", Some(Form1120)), ("ccorp", Some(Form1120)), ("corporation", Some(Form1120)), ("corp", Some(Form1120)), ("association", Some(Form1120)), ("disregarded entity", Some(ScheduleC)), ("sole proprietor", Some(ScheduleC)), ("single-member LLC", Some(ScheduleC)), ("member single", None), ("corporation or S corporation", None), ("partnership or disregarded", None), ("LLC", None), ("", None), ] { assert_eq!(form_for_classification(c), want, "{c:?}"); } } #[test] fn codes_and_choices_keep_their_spelling() { assert_eq!( checked("books.close_into", json!(" 3000 ")), Ok(json!("3000")) ); assert_eq!( checked("books.cash_accounts", json!([" 1000", "1010 "])), Ok(json!(["1000", "1010"])) ); for (k, v) in [ ("books.accounting_basis", "accrual"), ("books.accounting_basis", "cash"), ("policy.parties", "off"), ("policy.parties", "suggest"), ("policy.parties", "require"), ("policy.approval_rules", "off"), ("policy.approval_rules", "enforce"), ] { assert_eq!(checked(k, json!(v)), Ok(json!(v)), "{k} {v}"); } } #[test] fn amount_bound_is_inclusive() { let max = ledger_core::kernel::MAX_LINE_AMOUNT.to_string(); assert_eq!( checked("policy.evidence.threshold", json!(max)), Ok(json!(max)) ); let over = format!("{max}.01"); assert_eq!( checked("policy.evidence.threshold", json!(over)), Err(format!( "policy.evidence.threshold {over:?} is out of range" )) ); } #[test] fn ein_shapes() { let ein = |s: &str| checked("company.ein", json!(s)); assert_eq!(ein("12-3456789"), Ok(json!("12-3456789"))); assert_eq!(ein("123456789"), Ok(json!("12-3456789"))); let refused = Err("company.ein: expected an EIN, NN-NNNNNNN".to_string()); for bad in [ "12345678", "12-34-567", "12345678x", "123-456789", "1-23456789", ] { assert_eq!(ein(bad), refused, "{bad:?}"); } } #[test] fn name_control_and_tag_prefix_bounds() { let nc = |s: &str| checked("company.irs_name_control", json!(s)); for ok in ["WAND", "A&B-", "A&", "A-B", "w"] { assert_eq!(nc(ok), Ok(json!(ok.to_uppercase())), "{ok:?}"); } assert_eq!( nc("WANDE"), Err("company.irs_name_control \"WANDE\": one to four letters, digits, & or -".into()) ); assert!(nc("W.A").is_err()); let tag = |s: &str| checked("books.asset_tag_prefix", json!(s)); assert_eq!(tag("WL"), Ok(json!("WL"))); assert_eq!(tag("ABCDEFGH"), Ok(json!("ABCDEFGH"))); assert_eq!( tag("ABCDEFGHI"), Err("books.asset_tag_prefix \"ABCDEFGHI\": 1 to 8 letters or digits".into()) ); assert!(tag("").is_err()); assert!(tag("W-L").is_err()); } /// Replay checks an amount and a fiscal year start by their shape. #[test] fn replay_shapes_amounts_and_month_starts() { let threshold = key("policy.evidence.threshold").unwrap(); assert!(shape(threshold, &json!("25.00")).is_ok()); assert_eq!( shape(threshold, &json!("abc")), Err("policy.evidence.threshold \"abc\" is not a decimal".into()) ); let fy = key("company.fiscal_year_start").unwrap(); assert!(shape(fy, &json!("07-15")).is_ok()); for bad in ["13-01", "02-30", "07"] { assert_eq!( shape(fy, &json!(bad)), Err(format!("fiscal_year_start {bad:?}: expected \"MM-DD\"")), ); } } #[test] fn today_is_the_companys_today() { let utc = chrono::Utc::now().date_naive(); let near = |d: NaiveDate| (d - utc).num_days().abs() <= 1; let s = settings(&[("company.timezone", json!("America/Denver"))]); assert!(near(s.today()), "{}", s.today()); assert!(near(today_in(None))); // UTC+14 and UTC-11 are 25 hours apart: never the same day. let east = today_in(Some("Pacific/Kiritimati")); let west = today_in(Some("Pacific/Pago_Pago")); assert!(near(east) && near(west)); assert!(east > west, "{east} {west}"); assert!(east.year() >= 2026); } #[test] fn display_joins_members_and_lists() { let members = key("company.members").unwrap(); let v = json!([ {"name": "Kat", "share": "0.5", "capital_account": "3001"}, {"name": "Stephen", "share": "0.5", "capital_account": "3000"}, ]); assert_eq!(display(members, &v, false), "Kat 0.5, Stephen 0.5"); let cash = key("books.cash_accounts").unwrap(); assert_eq!(display(cash, &json!(["1000", "1010"]), false), "1000, 1010"); let ein = key("company.ein").unwrap(); assert_eq!(display(ein, &json!("12-3456789"), false), "**-***6789"); assert_eq!(display(ein, &json!("12-3456789"), true), "12-3456789"); } /// A date is taken only as written YYYY-MM-DD, one spelling per day. #[test] fn dates_take_one_spelling() { let formed = |s: &str| checked("company.formed", json!(s)); assert_eq!(formed("2026-10-04"), Ok(json!("2026-10-04"))); assert_eq!(formed(" 2026-10-04 "), Ok(json!("2026-10-04"))); for bad in ["2026-1-4", "2026-10-4", "+2026-10-04"] { assert_eq!( formed(bad), Err(format!( "company.formed {bad:?}: expected a date, YYYY-MM-DD" )), ); } }