Files
wanderbooks/crates/ledger-store/tests/member_closes.rs
T
swaitsandClaude Opus 5.5 a7f3cc09a9
check / check (push) Canceled after 0s
P3: odd cents split the same running way everywhere (defect 17, D-38)
Members' shares of earnings are split cumulatively by largest remainder in
the closes, the members' equity statement, the K-1 worksheet and the
viewer, so a member's capital on a given day never moves by a cent when its
month is later closed. Old closes replay as they were; the first P3 close
carries any true-up. Feature members_cumulative. Kani and Lean proofs of
the bounded drift; bookspec examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 21:35:00 -06:00

526 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<MemberClose> {
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<usize> = 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<Value> = 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<MemberCapital> {
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<Decimal> {
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);
}