//! Members closes in the journal (P3 defect 17, rules.md B-13, B-27): //! the cumulative rule's feature declaration, its `since_seq`, what a //! newer or damaged rounding tag does on replay, and the first close after //! the upgrade on books an earlier version wrote. Every expected figure is //! worked by hand in the comment beside it. use chrono::NaiveDate; use ledger_core::{ AccountId, AccountType, CloseTo, Currency, Ledger, MemberCapital, MemberClose, Money, Side, members_equity, }; use ledger_store::journal::read_and_verify_journal; use ledger_store::{JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError}; use rust_decimal::Decimal; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; /// A test's dir, removed when the test ends, pass or panic. struct TestDir(PathBuf); impl std::ops::Deref for TestDir { type Target = Path; fn deref(&self) -> &Path { &self.0 } } impl Drop for TestDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!( "wanderbooks-member-closes-{name}-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); TestDir(dir) } fn d(m: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, m, day).unwrap() } fn dec(s: &str) -> Decimal { s.parse().unwrap() } fn month_end(m: u32) -> NaiveDate { if m == 12 { d(12, 31) } else { d(m + 1, 1).pred_opt().unwrap() } } /// Two members at half each, capital 3000 and 3001; sales 4000. struct Books { store: Store, cash: AccountId, caps: [AccountId; 2], sales: AccountId, } fn books(dir: &Path) -> Books { let mut store = Store::create(dir, Currency::USD).unwrap(); let mut add = |code: &str, t| store.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); let caps = [ add("3000", AccountType::Equity), add("3001", AccountType::Equity), ]; let sales = add("4000", AccountType::Revenue); Books { store, cash, caps, sales, } } /// [`books`] opened again. fn reopen(dir: &Path) -> Books { let store = Store::open(dir).unwrap(); let id = |code: &str| store.ledger().chart().find_by_code(code).unwrap().id; let (cash, caps, sales) = (id("1000"), [id("3000"), id("3001")], id("4000")); Books { store, cash, caps, sales, } } fn half(caps: &[AccountId]) -> Vec { caps.iter() .map(|&capital| MemberClose { capital, share: dec("0.5"), distributions: None, }) .collect() } /// Earn `amount` on the 10th of month `m`, then close the month to `to`. fn earn_and_close(b: &mut Books, m: u32, amount: &str, to: &CloseTo) { let e = b.store.create_entry(d(m, 10), "sale").unwrap(); let money = Money::new(dec(amount), Currency::USD); b.store.add_line(e, b.cash, Side::Debit, money).unwrap(); b.store.add_line(e, b.sales, Side::Credit, money).unwrap(); b.store.post_entry(e).unwrap(); let p = b .store .create_period(&format!("2026-{m:02}"), d(m, 1), month_end(m)) .unwrap(); b.store.close_period_to(p, to).unwrap(); } /// The journal's lines as (type, payload). fn journal(dir: &Path) -> Vec<(String, Value)> { read_and_verify_journal(&dir.join(JOURNAL_FILE)) .unwrap() .into_iter() .map(|l| (l.line_type, l.payload)) .collect() } /// Copy `from`'s journal into `to` through `JournalWriter` (a valid /// chain), letting `edit` rewrite each payload. fn rewrite_journal(from: &Path, to: &Path, edit: impl Fn(&str, Value) -> Value) { let mut w = JournalWriter::open(&to.join(JOURNAL_FILE)).unwrap(); for (t, p) in journal(from) { w.append(&t, edit(&t, p)).unwrap(); } } /// Books with three members closes of a cent each (Jan to Mar, 50/50). fn three_cumulative_closes(dir: &Path) -> Books { let mut b = books(dir); let to = CloseTo::Members(half(&b.caps)); for m in 1..=3 { earn_and_close(&mut b, m, "0.01", &to); } b } /// Contract (rules.md B-27; P3 defect 17): the first members close counted /// from its regime's start declares the `members_cumulative` feature, on /// the line just before it and once only, so an older version stops at the /// declaration (it would replay the close under the run rule). Books that /// never close to the members never declare it. #[test] fn the_first_cumulative_close_declares_its_feature() { let dir = test_dir("declares"); let b = three_cumulative_closes(&dir); drop(b); let lines = journal(&dir); let declares = |p: &Value| { p["features"] .as_array() .is_some_and(|f| f.contains(&json!("members_cumulative"))) }; let declarations: Vec = lines .iter() .enumerate() .filter(|(_, (t, p))| t == "journal.format_set" && declares(p)) .map(|(i, _)| i) .collect(); let first_close = lines .iter() .position(|(t, p)| t == "period.closed" && p["close_to"]["rounding"] == "cumulative_since") .unwrap(); assert_eq!(declarations, [first_close - 1]); assert_eq!(lines[first_close - 1].1["format"], 2); assert_eq!( lines .iter() .filter(|(t, p)| t == "period.closed" && p["close_to"]["since_seq"] == 1) .count(), 3 ); // Reopened, a fourth close declares nothing again. let mut b = reopen(&dir); let to = CloseTo::Members(half(&b.caps)); earn_and_close(&mut b, 4, "0.01", &to); drop(b); let again = journal(&dir) .iter() .filter(|(t, p)| t == "journal.format_set" && declares(p)) .count(); assert_eq!(again, 1); // Books that close only to retained earnings never declare it. let dir = test_dir("declares-not"); let mut b = books(&dir); earn_and_close( &mut b, 1, "0.01", &CloseTo::RetainedEarnings { sweep: vec![] }, ); drop(b); assert!(!journal(&dir).iter().any(|(_, p)| declares(p))); } /// A change to a journaled close's target. type Edit = dyn Fn(&mut Value); /// Contract (rules.md B-27; P3 review R-9): a rounding rule only a newer /// version writes is newer, never corrupt: an unknown name, or a value /// that isn't a name. A `since_seq` beside another rule, or a /// "cumulative_since" without a usable one, is damage. #[test] fn an_unknown_rounding_is_newer_not_corrupt() { let src = test_dir("rounding-src"); drop(three_cumulative_closes(&src)); let edit_last_close = |name: &str, f: &Edit| -> TestDir { let dir = test_dir(&format!("rounding-{name}")); let closes = journal(&src) .iter() .filter(|(t, _)| t == "period.closed") .count(); let seen = std::cell::Cell::new(0); rewrite_journal(&src, &dir, |t, mut p| { if t == "period.closed" { seen.set(seen.get() + 1); if seen.get() == closes { f(&mut p["close_to"]); } } p }); dir }; let newer: [(&str, &Edit); 2] = [ ("unknown", &|c| c["rounding"] = json!("cumulative_by_day")), ("object", &|c| c["rounding"] = json!({"rule": "cumulative"})), ]; for (name, f) in newer { let dir = edit_last_close(name, f); let err = Store::open(&dir).err().expect(name); assert!( matches!(err, StoreError::NewerJournal { ref event_type, .. } if event_type == "period.closed"), "{name}: {err}" ); assert!( matches!( Snapshot::load(&dir).err(), Some(StoreError::NewerJournal { .. }) ), "{name}: the viewer" ); } let damaged: [(&str, &Edit); 4] = [ ("since-beside-run", &|c| c["rounding"] = json!("cumulative")), ("no-since", &|c| { c.as_object_mut().unwrap().remove("since_seq"); }), ("since-zero", &|c| c["since_seq"] = json!(0)), ("since-text", &|c| c["since_seq"] = json!("1")), ]; for (name, f) in damaged { let dir = edit_last_close(name, f); let err = Store::open(&dir).err().expect(name); assert!( matches!(err, StoreError::JournalCorrupt(_)), "{name}: {err}" ); } } /// Contract (B-13, B-8): a count that starts after the books' first close /// journals the close it starts at, and replays to it. January's cent /// closes at 50/50 (close 1, A's: the tie). February's at 60/40 starts a /// new count at close 2 (0.006 / 0.004: A's), and March's continues it /// (the running 0.02 is 0.012 / 0.008, so 0.01 / 0.01, less 0.01 / 0.00: /// B's). The journal says since_seq 1, 2, 2; the books reopen, verify, and /// stand at A 0.02, B 0.01. #[test] fn a_count_that_starts_later_journals_its_start() { let dir = test_dir("later-start"); let mut b = books(&dir); let sixty = CloseTo::Members( b.caps .iter() .zip(["0.6", "0.4"]) .map(|(&capital, share)| MemberClose { capital, share: dec(share), distributions: None, }) .collect(), ); let fifty = CloseTo::Members(half(&b.caps)); earn_and_close(&mut b, 1, "0.01", &fifty); earn_and_close(&mut b, 2, "0.01", &sixty); earn_and_close(&mut b, 3, "0.01", &sixty); drop(b); let since: Vec = journal(&dir) .into_iter() .filter(|(t, _)| t == "period.closed") .map(|(_, p)| p["close_to"]["since_seq"].clone()) .collect(); assert_eq!(since, [json!(1), json!(2), json!(2)]); let b = reopen(&dir); b.store.verify().unwrap(); let bal = |id| b.store.ledger().account_balance(id).unwrap(); assert_eq!((bal(b.caps[0]), bal(b.caps[1])), (dec("0.02"), dec("0.01"))); } /// Contract (B-8, B-13): replay re-derives where each count of members /// closes starts and refuses a close journaled with another start, so a /// wrong `since_seq` makes the books refuse to open, never show figures /// split some other way. #[test] fn a_close_journaled_with_another_start_refuses_to_open() { let src = test_dir("start-src"); drop(three_cumulative_closes(&src)); let dir = test_dir("start"); rewrite_journal(&src, &dir, |t, mut p| { if t == "period.closed" && p["id"] == 2 { p["close_to"]["since_seq"] = json!(2); } p }); for err in [ Store::open(&dir).err().expect("the writer"), Snapshot::load(&dir).err().expect("the viewer"), ] { assert!( err.to_string().contains("from close 2") && err.to_string().contains("from close 1"), "{err}" ); } } /// The members of x_member_closes as the settings would name them: A /// (capital 3000, distributions 3100) and B (3001), 50/50. fn fixture_members(l: &Ledger) -> Vec { let id = |code: &str| l.chart().find_by_code(code).unwrap().id; vec![ MemberCapital { name: "A".into(), capital_account: id("3000"), distribution_account: Some(id("3100")), share: dec("0.5"), }, MemberCapital { name: "B".into(), capital_account: id("3001"), distribution_account: None, share: dec("0.5"), }, ] } /// Each member's capital on the statement of members' equity as of `end`. fn capital_on(l: &Ledger, end: NaiveDate) -> Vec { members_equity(l, d(1, 1), end, &fixture_members(l)) .unwrap() .members .iter() .map(|m| m.ending) .collect() } /// Regression (P3 review R-2, D-10): books main wrote with members closes /// under every rounding rule it had (fixture x_member_closes: three /// untagged, three "largest_remainder", "cumulative" broken by a close to /// retained earnings) open to the figures main computed (A 99.07, B /// 100.05: earnings 0.07 / 0.05 of the 0.12 closed to them, exact 0.06 /// each), and the first close after the upgrade counts from the first /// close of all (September's close to retained earnings skipped) and /// carries the true-up. December earns 0.02: the running 0.14 splits /// 0.07 / 0.07, less 0.07 / 0.05 given, so A gets 0.00 and B 0.02, and /// both stand at their exact 0.07. /// /// The statement of members' equity (D-38, R-1): as of Nov 30, A's share /// of the 0.01 in retained earnings is what the next members close would /// give of it, allocate(0.13) − allocate(0.12) = 0.07 − 0.06 = 0.01 (B: /// 0.06 − 0.06 = 0), so A 99.08 and B 100.05, before and after December /// closes: the pending true-up never shows early. As of Dec 31, before the /// close, December's 0.02 and the 0.01 split allocate(0.15) − /// allocate(0.12) = 0.08 − 0.06 / 0.07 − 0.06: A 99.09, B 100.06; after it, /// allocate(0.15) − allocate(0.14) = 0.01 / 0.00 on capital 99.07 / 100.07: /// A 99.08, B 100.07. The figure moves by exactly the true-up the close /// posts (A −0.01, B +0.01), dated at it. #[test] fn the_first_close_after_the_upgrade_trues_up_the_old_rules() { let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/old-books/x_member_closes/journal.jsonl"); let dir = test_dir("upgrade"); std::fs::copy(&fixture, dir.join(JOURNAL_FILE)).unwrap(); let mut store = Store::open(&dir).unwrap(); let id = |s: &Store, code: &str| s.ledger().chart().find_by_code(code).unwrap().id; let (cash, a, b, sales) = ( id(&store, "1000"), id(&store, "3000"), id(&store, "3001"), id(&store, "4000"), ); let bal = |s: &Store, acct| s.ledger().account_balance(acct).unwrap(); assert_eq!( (bal(&store, a), bal(&store, b)), (dec("99.07"), dec("100.05")) ); let rules: Vec<_> = store .ledger() .list_periods() .iter() .map(|p| p.members_closed.as_ref().map(|m| m.rule)) .collect(); use ledger_core::MemberRule::{LastAbsorbs, PerClose, Run}; assert_eq!( rules, [ Some(LastAbsorbs), Some(LastAbsorbs), Some(LastAbsorbs), Some(PerClose), Some(PerClose), Some(PerClose), Some(Run), Some(Run), None, Some(Run), Some(Run), ] ); assert_eq!( capital_on(store.ledger(), d(11, 30)), [dec("99.08"), dec("100.05")] ); let e = store.create_entry(d(12, 10), "sale").unwrap(); let cents = Money::new(dec("0.02"), Currency::USD); store.add_line(e, cash, Side::Debit, cents).unwrap(); store.add_line(e, sales, Side::Credit, cents).unwrap(); store.post_entry(e).unwrap(); assert_eq!( capital_on(store.ledger(), d(12, 31)), [dec("99.09"), dec("100.06")] ); let dec_period = store.create_period("2026-12", d(12, 1), d(12, 31)).unwrap(); let members = vec![ MemberClose { capital: a, share: dec("0.5"), distributions: Some(id(&store, "3100")), }, MemberClose { capital: b, share: dec("0.5"), distributions: None, }, ]; let closing = store .close_period_to(dec_period, &CloseTo::Members(members)) .unwrap() .unwrap(); let lines: Vec<(String, Side, Decimal)> = store .ledger() .get_entry(closing) .unwrap() .lines() .iter() .map(|l| { let code = store.ledger().chart().get(l.account).unwrap().code.clone(); (code, l.side, l.amount.amount) }) .collect(); assert_eq!( lines, [ ("4000".to_string(), Side::Debit, dec("0.02")), ("3001".to_string(), Side::Credit, dec("0.02")), ] ); let figures = |s: &Ledger| { let (since, split) = s.member_close_split(dec_period).unwrap(); let rows: Vec<_> = split.iter().map(|m| (m.part, m.running, m.exact)).collect(); (since, rows) }; let expected = ( 1, vec![ (dec("0.00"), dec("0.07"), dec("0.07")), (dec("0.02"), dec("0.07"), dec("0.07")), ], ); assert_eq!(figures(store.ledger()), expected); assert_eq!( (bal(&store, a), bal(&store, b)), (dec("99.07"), dec("100.07")) ); assert_eq!( capital_on(store.ledger(), d(11, 30)), [dec("99.08"), dec("100.05")] ); assert_eq!( capital_on(store.ledger(), d(12, 31)), [dec("99.08"), dec("100.07")] ); // One declaration, just before the close. drop(store); let lines = journal(&dir); let n = lines.len(); assert_eq!(lines[n - 1].0, "period.closed"); assert_eq!(lines[n - 1].1["close_to"]["since_seq"], 1); assert_eq!(lines[n - 2].0, "journal.format_set"); assert_eq!(lines[n - 2].1["features"], json!(["members_cumulative"])); // Reopened, and in the viewer: the same. let store = Store::open(&dir).unwrap(); store.verify().unwrap(); assert_eq!(figures(store.ledger()), expected); assert_eq!( (bal(&store, a), bal(&store, b)), (dec("99.07"), dec("100.07")) ); let snap = Snapshot::load(&dir).unwrap(); assert_eq!(figures(snap.ledger()), expected); }