P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s
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
This commit is contained in:
44 files changed
+6636
-195
No files matched your search
@@ -0,0 +1,525 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in new issue
Block a user