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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
e60ecc5317
commit
c587593d78
71 files changed
+12249
-461
No files matched your search
@@ -0,0 +1,359 @@
|
||||
//! Conventions, approvals and templates in the engine, value by value:
|
||||
//! slots, summaries, template bounds, the approval fingerprint (journaled,
|
||||
//! so its meaning is fixed forever), which entries a rule covers, and
|
||||
//! when a convention is a revision at all.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every bound and every rule says exactly what it means; no more,
|
||||
//! no less.
|
||||
//! SOURCE: plan/served.md P1; P1 mutation run (2026-10-17).
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{self, Convention, ConventionKind, NewConvention};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{AccountType, Currency, EntryId, Ledger, LedgerError, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "A".into(),
|
||||
decided_on: d(2026, 10, 17),
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convention(id: &str, kind: ConventionKind, structured: Option<Value>) -> NewConvention {
|
||||
NewConvention {
|
||||
id: id.into(),
|
||||
kind,
|
||||
title: "Title".into(),
|
||||
body_md: "Body.".into(),
|
||||
structured,
|
||||
source: src(),
|
||||
}
|
||||
}
|
||||
|
||||
fn template(lines: Vec<Value>) -> NewConvention {
|
||||
convention(
|
||||
"t",
|
||||
ConventionKind::PostingTemplate,
|
||||
Some(json!({ "lines": lines })),
|
||||
)
|
||||
}
|
||||
|
||||
fn line(account: &str, side: &str, share: &str) -> Value {
|
||||
json!({"account": account, "side": side, "share": share})
|
||||
}
|
||||
|
||||
fn refusal(c: &NewConvention) -> String {
|
||||
conventions::check_new(c).expect_err("refused")
|
||||
}
|
||||
|
||||
/// A slot is a name in braces: lowercase letters, digits and `_`, 1 to 32
|
||||
/// of them.
|
||||
#[test]
|
||||
fn a_slot_is_a_short_lowercase_name_in_braces() {
|
||||
for (account, want) in [
|
||||
("{expense}", Some("expense")),
|
||||
("{a_1}", Some("a_1")),
|
||||
("{_}", Some("_")),
|
||||
("{7}", Some("7")),
|
||||
(
|
||||
"{abcdefghijklmnopqrstuvwxyz012345}",
|
||||
Some("abcdefghijklmnopqrstuvwxyz012345"),
|
||||
),
|
||||
("{abcdefghijklmnopqrstuvwxyz0123456}", None),
|
||||
("{}", None),
|
||||
("{Expense}", None),
|
||||
("{a-b}", None),
|
||||
("{a b}", None),
|
||||
("expense", None),
|
||||
("{expense", None),
|
||||
("6000", None),
|
||||
] {
|
||||
assert_eq!(conventions::slot(account), want, "{account}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A summary is the first line with text, trimmed, at most 160
|
||||
/// characters, with an ellipsis only when it was cut.
|
||||
#[test]
|
||||
fn a_summary_is_the_first_line_with_text() {
|
||||
let summary = |body: &str| {
|
||||
Convention {
|
||||
id: "c".into(),
|
||||
kind: ConventionKind::Note,
|
||||
title: "T".into(),
|
||||
body_md: body.into(),
|
||||
structured: None,
|
||||
source: src(),
|
||||
seq: 1,
|
||||
revision: 1,
|
||||
retired: None,
|
||||
}
|
||||
.summary()
|
||||
};
|
||||
assert_eq!(summary("\n \n First line \nsecond"), "First line");
|
||||
assert_eq!(summary(""), "");
|
||||
assert_eq!(summary("\n \n"), "");
|
||||
let exactly = "é".repeat(160);
|
||||
assert_eq!(summary(&exactly), exactly, "160 characters: whole");
|
||||
let over = "é".repeat(161);
|
||||
assert_eq!(summary(&over), format!("{exactly}…"), "161: cut");
|
||||
assert_eq!(summary(&format!(" {exactly} ")), exactly, "trimmed first");
|
||||
}
|
||||
|
||||
/// A posting template has 2 to 50 lines, each share in (0, 1], each
|
||||
/// side summing to exactly 1; a refusal names the line.
|
||||
#[test]
|
||||
fn template_bounds_are_exact() {
|
||||
// 50 lines: one debit, 49 credits summing to 1.
|
||||
let mut fifty = vec![line("6000", "debit", "1"), line("1000", "credit", "0.04")];
|
||||
fifty.extend((0..48).map(|_| line("1000", "credit", "0.02")));
|
||||
assert_eq!(fifty.len(), 50);
|
||||
conventions::check_new(&template(fifty)).expect("50 lines");
|
||||
let mut fifty_one = vec![line("6000", "debit", "1")];
|
||||
fifty_one.extend((0..50).map(|_| line("1000", "credit", "0.02")));
|
||||
assert!(refusal(&template(fifty_one)).contains("2 to 50 lines"));
|
||||
assert!(refusal(&template(vec![])).contains("2 to 50 lines"));
|
||||
// One line can't balance: refused by its count, not its shares.
|
||||
assert!(refusal(&template(vec![line("6000", "debit", "1")])).contains("2 to 50 lines"));
|
||||
conventions::check_new(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1"),
|
||||
]))
|
||||
.expect("2 lines");
|
||||
// A zero share is no share.
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("6000", "debit", "0"),
|
||||
line("1000", "credit", "1"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 2: share \"0\""), "{e}");
|
||||
// Over 1, or below zero, even when the side still sums to 1.
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1.5"),
|
||||
line("6000", "debit", "-0.5"),
|
||||
line("1000", "credit", "1"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 1: share \"1.5\""), "{e}");
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1.5"),
|
||||
line("1000", "credit", "-0.5"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 2: share \"1.5\""), "{e}");
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "0.5"),
|
||||
]));
|
||||
assert!(e.contains("sum to exactly 1"), "{e}");
|
||||
}
|
||||
|
||||
/// FNV-1a, 64 bits, written out here independently of the engine.
|
||||
fn fnv1a(text: &str) -> String {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in text.bytes() {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// The approval fingerprint is journaled with every approval and
|
||||
/// recomputed at post: its text and hash are fixed forever.
|
||||
#[test]
|
||||
fn the_fingerprint_is_fixed_forever() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let exp = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let e = l.create_entry(d(2026, 10, 2), "Laptop");
|
||||
let m = Money::new(dec("900.50"), Currency::USD);
|
||||
l.add_line(e, exp, Side::Debit, m).unwrap();
|
||||
l.add_line(e, bank, Side::Credit, m).unwrap();
|
||||
let entry = l.get_entry(e).unwrap();
|
||||
let text = format!(
|
||||
"2026-10-02\u{1f}Laptop\
|
||||
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
|
||||
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
|
||||
);
|
||||
let plain = conventions::fingerprint(entry, &[], None);
|
||||
assert_eq!(plain, fnv1a(&text));
|
||||
assert_eq!(fnv1a(""), "cbf29ce484222325", "FNV-1a's offset basis");
|
||||
assert_eq!(fnv1a("a"), "af63dc4c8601ec8c", "FNV-1a's published value");
|
||||
let none = conventions::fingerprint(entry, &[], Some("bank fee"));
|
||||
let with_none = format!(
|
||||
"2026-10-02\u{1f}Laptop\u{1d}none\u{1f}bank fee\
|
||||
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
|
||||
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
|
||||
);
|
||||
assert_eq!(none, fnv1a(&with_none));
|
||||
assert_ne!(none, plain);
|
||||
}
|
||||
|
||||
/// Books enforcing approval rules, with an expense and a bank account.
|
||||
fn enforcing() -> (Ledger, ledger_core::AccountId, ledger_core::AccountId) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let exp = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let on = [Change {
|
||||
key: "policy.approval_rules".into(),
|
||||
value: Some(json!("enforce")),
|
||||
}];
|
||||
let made = l.check_settings(&on, &src()).unwrap();
|
||||
l.apply_settings(&made, &src(), 1).unwrap();
|
||||
(l, exp, bank)
|
||||
}
|
||||
|
||||
fn set_convention(l: &mut Ledger, c: &NewConvention, seq: u64) {
|
||||
let c = l.check_convention(c).unwrap();
|
||||
l.apply_convention(c, seq).unwrap();
|
||||
}
|
||||
|
||||
fn draft(l: &mut Ledger, lines: &[(ledger_core::AccountId, Side, &str)]) -> EntryId {
|
||||
let e = l.create_entry(d(2026, 10, 2), "Spend");
|
||||
for (a, side, amount) in lines {
|
||||
l.add_line(e, *a, *side, Money::new(dec(amount), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// A rule's amount is "over": an entry whose debits are exactly it isn't
|
||||
/// covered; one a cent over is. It counts debits, not credits, even on a
|
||||
/// draft that doesn't balance yet.
|
||||
#[test]
|
||||
fn a_rule_covers_debits_strictly_over_its_amount() {
|
||||
let (mut l, exp, bank) = enforcing();
|
||||
set_convention(
|
||||
&mut l,
|
||||
&convention(
|
||||
"big",
|
||||
ConventionKind::ApprovalRule,
|
||||
Some(json!({"approver": "B", "amount_over": "500"})),
|
||||
),
|
||||
2,
|
||||
);
|
||||
let want = vec![("big".to_string(), "B".to_string())];
|
||||
let at = draft(
|
||||
&mut l,
|
||||
&[(exp, Side::Debit, "500"), (bank, Side::Credit, "500")],
|
||||
);
|
||||
assert!(l.approvals_missing(at).unwrap().is_empty(), "exactly 500");
|
||||
let over = draft(
|
||||
&mut l,
|
||||
&[(exp, Side::Debit, "500.01"), (bank, Side::Credit, "500.01")],
|
||||
);
|
||||
assert_eq!(l.approvals_missing(over).unwrap(), want);
|
||||
let debits = draft(&mut l, &[(exp, Side::Debit, "600")]);
|
||||
assert_eq!(l.approvals_missing(debits).unwrap(), want, "debits count");
|
||||
let credits = draft(&mut l, &[(bank, Side::Credit, "600")]);
|
||||
assert!(
|
||||
l.approvals_missing(credits).unwrap().is_empty(),
|
||||
"credits don't"
|
||||
);
|
||||
}
|
||||
|
||||
/// What check_settings returns is what will be journaled.
|
||||
#[test]
|
||||
fn check_settings_returns_the_changes_it_will_journal() {
|
||||
let l = Ledger::new(Currency::USD);
|
||||
let name = Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!(" Acme LLC ")),
|
||||
};
|
||||
let made = l.check_settings(&[name], &src()).unwrap();
|
||||
assert_eq!(
|
||||
made,
|
||||
[Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("Acme LLC")),
|
||||
}]
|
||||
);
|
||||
assert!(!l.settings().is_journaled(), "checking changes nothing");
|
||||
}
|
||||
|
||||
/// A convention set again unchanged is refused; a change to its title,
|
||||
/// its body or its structure alone is a revision.
|
||||
#[test]
|
||||
fn any_one_change_is_a_revision() {
|
||||
let (mut l, _, _) = enforcing();
|
||||
let base = convention(
|
||||
"big",
|
||||
ConventionKind::ApprovalRule,
|
||||
Some(json!({"approver": "B", "amount_over": "500"})),
|
||||
);
|
||||
set_convention(&mut l, &base, 2);
|
||||
let e = l.check_convention(&base).unwrap_err();
|
||||
assert!(
|
||||
matches!(&e, LedgerError::InvalidConvention(m) if m.contains("already says exactly that")),
|
||||
"{e}"
|
||||
);
|
||||
let title = NewConvention {
|
||||
title: "New title".into(),
|
||||
..base.clone()
|
||||
};
|
||||
let body = NewConvention {
|
||||
body_md: "New body.".into(),
|
||||
..base.clone()
|
||||
};
|
||||
let structured = NewConvention {
|
||||
structured: Some(json!({"approver": "B", "amount_over": "600"})),
|
||||
..base.clone()
|
||||
};
|
||||
for c in [title, body, structured] {
|
||||
assert!(l.check_convention(&c).is_ok(), "{c:?}");
|
||||
}
|
||||
let revised = NewConvention {
|
||||
title: "New title".into(),
|
||||
..base.clone()
|
||||
};
|
||||
set_convention(&mut l, &revised, 3);
|
||||
assert_eq!(l.conventions().convention("big").unwrap().revision, 2);
|
||||
}
|
||||
|
||||
/// A template drafts any amount up to a line's most, and refuses above
|
||||
/// it as out of range.
|
||||
#[test]
|
||||
fn a_template_drafts_up_to_a_lines_most() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
for (code, t) in [("6000", AccountType::Expense), ("1000", AccountType::Asset)] {
|
||||
l.add_account(code, code, t, None, false).unwrap();
|
||||
}
|
||||
set_convention(
|
||||
&mut l,
|
||||
&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1"),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
let max = ledger_core::kernel::MAX_LINE_AMOUNT;
|
||||
let (_, lines) = l.template_lines("t", max, &BTreeMap::new()).unwrap();
|
||||
assert!(lines.iter().all(|(_, _, m)| m.amount == max));
|
||||
assert!(matches!(
|
||||
l.template_lines("t", max + dec("0.01"), &BTreeMap::new()),
|
||||
Err(LedgerError::AmountOutOfRange)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! 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<Change> = 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<Value, String> {
|
||||
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!("FL")),
|
||||
("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("FL"));
|
||||
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": "Mo", "share": "50%", "capital_account": "3001"},
|
||||
{"name": "Tobin", "share": "50%", "capital_account": "3000"},
|
||||
]),
|
||||
),
|
||||
("books.accounting_basis", json!("cash")),
|
||||
("books.asset_tag_prefix", json!("FL")),
|
||||
]);
|
||||
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 — Mo", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
l.add_account(
|
||||
"3000",
|
||||
"Capital — Tobin",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mo = l.add_party("Mo", &[Role::Member]).unwrap();
|
||||
let other = l.add_party("Pat", &[Role::Member]).unwrap();
|
||||
l.update_party(mo, PartyField::Archived(true)).unwrap();
|
||||
l.update_party(other, PartyField::Archived(true)).unwrap();
|
||||
let c = Company {
|
||||
members: vec![Member {
|
||||
name: "mo ".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, mo);
|
||||
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("FL"), Ok(json!("FL")));
|
||||
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": "Mo", "share": "0.5", "capital_account": "3001"},
|
||||
{"name": "Tobin", "share": "0.5", "capital_account": "3000"},
|
||||
]);
|
||||
assert_eq!(display(members, &v, false), "Mo 0.5, Tobin 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"
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -772,7 +772,7 @@ fn a_negative_amount_line_never_posts() {
|
||||
assert_eq!(b.l.posted_entries().count(), 0);
|
||||
}
|
||||
|
||||
/// Oracle: AmountOutOfRange doc ("at most 1000000000000000, ten decimal
|
||||
/// Oracle: AmountOutOfRange doc ("at most one quadrillion, with ten decimal
|
||||
/// places"): exactly one quadrillion and exactly ten places post; one cent
|
||||
/// more, or an eleventh place, never does.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user