check / check (push) Canceled after 0s
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
526 lines
17 KiB
Rust
526 lines
17 KiB
Rust
//! 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);
|
||
}
|