P3: odd cents split the same running way everywhere (defect 17, D-38)
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:
swaitsandClaude Opus 5.5 committed 2026-10-20 21:35:00 -06:00
1 parent 2390dc6553
commit a7f3cc09a9
44 files changed
+6636 -195

No files matched your search

+48 -3
View File
@@ -26,8 +26,21 @@ use serde_json::{Value, json};
/// The newest journal format this version reads and writes.
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
/// Optional journal features this version reads (none yet).
pub const KNOWN_FEATURES: &[&str] = &[];
/// Optional journal features this version reads. Each is a field or value
/// on an event type an older version knows, which that version would
/// misread silently; a writer declares the feature (`journal.format_set`)
/// before its first such line, so the older version stops there instead
/// (rules.md B-27). Books that never use one stay readable by versions
/// before it.
pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE];
/// Feature: `period.closed` closes to members with `"rounding":
/// "cumulative_since"` and `since_seq`, the count of members closes
/// starting at the first since the members or shares last changed (P3
/// defect 17, rules.md B-13). An older version would call the rounding
/// unknown, or (before the format gate) replay the close under the run
/// rule, which can split it differently.
pub const MEMBERS_CUMULATIVE: &str = "members_cumulative";
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -188,6 +201,12 @@ impl JournalFormat {
/// Read a `journal.format_set` line, refusing a format or feature this
/// version doesn't read.
pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError> {
declared_knowing(line, KNOWN_FEATURES)
}
/// [`declared`] by a version that knows the features `known`: what an
/// older version, which knew fewer, does with the same line.
fn declared_knowing(line: &JournalLine, known: &[&str]) -> Result<JournalFormat, StoreError> {
let p = &line.payload;
let format = p
.get("format")
@@ -216,7 +235,7 @@ pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError>
let unknown: Vec<&str> = features
.iter()
.map(String::as_str)
.filter(|f| !KNOWN_FEATURES.contains(f))
.filter(|f| !known.contains(f))
.collect();
if !unknown.is_empty() {
return Err(newer(
@@ -485,4 +504,30 @@ mod tests {
));
}
}
/// P3 defect 17's feature (members_cumulative): this version reads it;
/// a version before it (KNOWN_FEATURES empty, as through P2) stops at
/// its declaration with NewerJournal, never replaying a close counted
/// from its regime's start under the run rule.
#[test]
fn an_older_reader_stops_at_the_members_cumulative_feature() {
assert!(KNOWN_FEATURES.contains(&MEMBERS_CUMULATIVE));
let l = line(
FORMAT_SET,
json!({"format": 2, "features": [MEMBERS_CUMULATIVE]}),
);
assert_eq!(
declared(&l).unwrap(),
JournalFormat {
format: 2,
features: vec![MEMBERS_CUMULATIVE.to_string()]
}
);
match declared_knowing(&l, &[]) {
Err(StoreError::NewerJournal { need, .. }) => {
assert_eq!(need, "journal features members_cumulative")
}
other => panic!("expected NewerJournal, got {other:?}"),
}
}
}
+80 -12
View File
@@ -583,6 +583,29 @@ impl Store {
Ok(())
}
/// Declare a journal feature (crate::format::KNOWN_FEATURES) before the
/// first event that needs it: a field or value on a known event type
/// that an older version would otherwise misread silently. Declared
/// with the format in force (at least 2) and every feature already in
/// force; nothing is journaled when it is already declared. Books that
/// never use the feature stay readable by older versions.
///
/// ```text
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
/// instead of misreading a line it thinks it knows.
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
/// ```
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
if self.format.features.iter().any(|f| f == feature) {
return Ok(());
}
let format = self.format.format.max(crate::meta::FORMAT);
let mut features = self.format.features.clone();
features.push(feature.to_string());
let features: Vec<&str> = features.iter().map(String::as_str).collect();
self.declare_journal_format(format, &features)
}
/// Change settings (`settings.set`): all or nothing, each value
/// canonical, the result whole (ledger_core::settings). Returns the
/// changes made; none (journaling nothing) when every change is the
@@ -1798,6 +1821,11 @@ impl Store {
// ensure then no-ops and replay stays deterministic.
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
}
if matches!(to, CloseTo::Members(_) | CloseTo::MembersCumulative { .. }) {
// A close counted from its regime's start: an older version
// would replay it under the run rule (format gate, B-27).
self.ensure_feature(crate::format::MEMBERS_CUMULATIVE)?;
}
let mut next = self.ledger.clone();
let closing_entry = next.close_period_to(id, to)?;
let entry_detail = match closing_entry {
@@ -1818,9 +1846,8 @@ impl Store {
CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
CloseTo::Members(members)
| CloseTo::MembersPerClose(members)
| CloseTo::MembersLastAbsorbs(members) => {
_ => {
let members = to.members().expect("every other target is members");
let mut v = json!({
"members": members
.iter()
@@ -1835,7 +1862,18 @@ impl Store {
// journaled before 2026-10-13 carry none (the last member
// absorbed it).
match to {
CloseTo::Members(_) => v["rounding"] = json!("cumulative"),
CloseTo::Members(_) | CloseTo::MembersCumulative { .. } => {
let rule = next
.get_period(id)
.and_then(|p| p.members_closed.as_ref())
.map(|m| m.rule);
let Some(ledger_core::MemberRule::Cumulative { since_seq }) = rule else {
unreachable!("a members close records its cumulative rule")
};
v["rounding"] = json!("cumulative_since");
v["since_seq"] = json!(since_seq);
}
CloseTo::MembersRun(_) => v["rounding"] = json!("cumulative"),
CloseTo::MembersPerClose(_) => v["rounding"] = json!("largest_remainder"),
_ => {}
}
@@ -4739,7 +4777,7 @@ pub(crate) fn replay_line_into_ledger(
// Deterministic: the close re-derives the closing entry from
// the replayed state and the journaled target (absent: Retained
// Earnings), so it must match the journal's id and every line.
let to = close_to_from_journal(p.get("close_to"))?;
let to = close_to_from_journal(line, p.get("close_to"))?;
let got = ledger.close_period_to(id, &to)?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
@@ -5021,7 +5059,10 @@ fn tax_basis_from_journal(p: &Value) -> Result<ledger_core::TaxBasis, StoreError
})
}
fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
/// A journaled close's target. A rounding rule only a newer version
/// writes (an unknown name, or a value that isn't a name) is newer, never
/// corrupt (rules.md B-27).
fn close_to_from_journal(line: &JournalLine, v: Option<&Value>) -> Result<CloseTo, StoreError> {
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
v.as_array()
@@ -5062,12 +5103,39 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
.collect::<Result<_, StoreError>>()?;
// The rounding rule the close was made under: absent before
// 2026-10-13, when the last member absorbed it; "largest_remainder"
// per close, then "cumulative" over a run of closes.
match v.get("rounding").and_then(|r| r.as_str()) {
None => Ok(CloseTo::MembersLastAbsorbs(members)),
Some("largest_remainder") => Ok(CloseTo::MembersPerClose(members)),
Some("cumulative") => Ok(CloseTo::Members(members)),
Some(_) => Err(corrupt("unknown rounding")),
// per close, then "cumulative" over a run of closes, then (P3)
// "cumulative_since" the close numbered since_seq.
let rounding = match v.get("rounding") {
None | Some(Value::Null) => None,
Some(Value::String(r)) => Some(r.as_str()),
Some(other) => {
return Err(crate::format::newer_content(
line,
format!("a members close's rounding {other}"),
));
}
};
let since_seq = v.get("since_seq");
let older = |to: CloseTo| match since_seq {
// Only "cumulative_since" counts from a close.
Some(_) => Err(corrupt("since_seq beside another rounding")),
None => Ok(to),
};
match rounding {
None => older(CloseTo::MembersLastAbsorbs(members)),
Some("largest_remainder") => older(CloseTo::MembersPerClose(members)),
Some("cumulative") => older(CloseTo::MembersRun(members)),
Some("cumulative_since") => Ok(CloseTo::MembersCumulative {
members,
since_seq: since_seq
.and_then(Value::as_u64)
.filter(|&s| s >= 1)
.ok_or_else(|| corrupt("since_seq"))?,
}),
Some(other) => Err(crate::format::newer_content(
line,
format!("a members close's rounding {other:?}"),
)),
}
}
@@ -9,6 +9,9 @@
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
# cargo run --bin expect -- OUT (expected.json beside each journal)
# and copy each journal.jsonl, blobs/ and expected.json into old-books/.
# x_member_closes was written later, the same way, by main 00226a4c
# (2026-10-20, before P3's odd cent): `cargo run --bin members -- OUT`
# (members closes under every rounding rule main wrote; P3 review R-2).
[package]
name = "writer"
version = "0.1.0"
@@ -22,3 +25,5 @@ rust_decimal = "=1.43.0"
serde_json = "1"
[workspace]
# main's crates (wb-main) keep their own workspace.
exclude = ["wb-main"]
@@ -0,0 +1,83 @@
//! x_member_closes: members closes under each rounding rule main wrote
//! (P3 review R-2), through MAIN's Store API (main 00226a4c, 2026-10-20).
//! Two members, A and B, 50/50; A has a distributions account. Eleven
//! months, each closed in order:
//! Jan-Mar: untagged (the last member absorbs): +0.01, -0.03, +0.05;
//! A's 1.00 distribution in February is swept into A's capital.
//! Apr-Jun: "largest_remainder" (each close on its own): +0.07, -0.01, +0.03.
//! Jul-Nov: "cumulative" (the run rule), broken by a close to retained
//! earnings in September: +0.01, +0.01, (RE +0.01), +0.03, -0.05.
//! Hand-computed: A's earnings 0.07, B's 0.05 (of 0.12 closed to them;
//! exact 0.06 each); retained earnings 0.01.
use chrono::NaiveDate;
use ledger_core::*;
use ledger_store::Store;
use rust_decimal::Decimal;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(s.parse::<Decimal>().unwrap(), Currency::USD)
}
fn main() {
let dir = std::path::PathBuf::from(std::env::args().nth(1).unwrap()).join("x_member_closes");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Store::create(&dir, Currency::USD).unwrap();
let cash = s.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap();
let cap_a = s.add_account("3000", "Capital A", AccountType::Equity, None, false).unwrap();
let cap_b = s.add_account("3001", "Capital B", AccountType::Equity, None, false).unwrap();
let dist_a = s.add_account("3100", "Distributions A", AccountType::Equity, None, false).unwrap();
let rev = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap();
let exp = s.add_account("6000", "Fees", AccountType::Expense, None, false).unwrap();
let post = |s: &mut Store, date, dr, cr, amount: &str| {
let e = s.create_entry(date, "x").unwrap();
s.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
s.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
s.post_entry(e).unwrap();
};
post(&mut s, d(1, 2), cash, cap_a, "100.00");
post(&mut s, d(1, 2), cash, cap_b, "100.00");
post(&mut s, d(2, 20), dist_a, cash, "1.00");
let members = vec![
MemberClose { capital: cap_a, share: "0.5".parse().unwrap(), distributions: Some(dist_a) },
MemberClose { capital: cap_b, share: "0.5".parse().unwrap(), distributions: None },
];
let months: [(u32, &str, &str); 11] = [
(1, "0.01", "last"),
(2, "-0.03", "last"),
(3, "0.05", "last"),
(4, "0.07", "per"),
(5, "-0.01", "per"),
(6, "0.03", "per"),
(7, "0.01", "run"),
(8, "0.01", "run"),
(9, "0.01", "re"),
(10, "0.03", "run"),
(11, "-0.05", "run"),
];
for (m, net, rule) in months {
match net.strip_prefix('-') {
Some(loss) => post(&mut s, d(m, 10), exp, cash, loss),
None => post(&mut s, d(m, 10), cash, rev, net),
}
let end = NaiveDate::from_ymd_opt(2026, m + 1, 1).unwrap().pred_opt().unwrap();
let p = s.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap();
let to = match rule {
"last" => CloseTo::MembersLastAbsorbs(members.clone()),
"per" => CloseTo::MembersPerClose(members.clone()),
"run" => CloseTo::Members(members.clone()),
_ => CloseTo::RetainedEarnings { sweep: vec![] },
};
s.close_period_to(p, &to).unwrap();
}
let bal = |id| s.ledger().account_balance(id).unwrap();
println!(
"WROTE x_member_closes: A {} B {} dist {}",
bal(cap_a),
bal(cap_b),
bal(dist_a)
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,86 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-20T22:36:22Z","type":"store.created"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Cash","parent":null,"placeholder":false},"prev":"d48b7756f8b6c3d65159fd1483103d880bdf5526a6a1da756a618df657d96de3","seq":2,"ts":"2026-10-20T22:36:22Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital A","parent":null,"placeholder":false},"prev":"656d6610bd33f69658f1e1e05bff473f5a44af70cebcdac8954cae37a394f1aa","seq":3,"ts":"2026-10-20T22:36:22Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3001","currency":"USD","id":3,"name":"Capital B","parent":null,"placeholder":false},"prev":"ae10bfa37de84be844b468a30c68fe92fd8de23a686c248ad76ee375990dd0a8","seq":4,"ts":"2026-10-20T22:36:22Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3100","currency":"USD","id":4,"name":"Distributions A","parent":null,"placeholder":false},"prev":"232eaad799ea56d043213121ea8de0bf46b3ba8e9bb3b8f70dd338edfd55a944","seq":5,"ts":"2026-10-20T22:36:22Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":5,"name":"Sales","parent":null,"placeholder":false},"prev":"7114ae64eff24884344a6c9a541b2c7cfbfa648b23f1bbc69f199fa6b866bc91","seq":6,"ts":"2026-10-20T22:36:22Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":6,"name":"Fees","parent":null,"placeholder":false},"prev":"8c2bc4978cfd9035130a77332a3e3e943bef20dfc14b313fb67be9b1f1d2d6c4","seq":7,"ts":"2026-10-20T22:36:22Z","type":"account.added"}
{"payload":{"date":"2026-01-02","description":"x","id":1},"prev":"9e429057f4dfe113f5b437bd08ad6964dd60176f06bd4b374eae81647dfbc5ec","seq":8,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":1,"amount":"100.00","currency":"USD","id":1,"side":"debit"},"prev":"601be139abb99f295b38b8d92b369301f03dccd55b3a4da6c79b3197b4c6bd28","seq":9,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"100.00","currency":"USD","id":1,"side":"credit"},"prev":"c09df370b486aba0fe0b22c826fe8d0b07bf39c4c42ff08b7d2d48c04b893b48","seq":10,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-02","description":"x","id":1,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"debit"},{"account":2,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d6ddbcc8056897e9623aa7a0acd9a95e6b89d0fe8c976a114b5a54eb76bab8b3","seq":11,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"date":"2026-01-02","description":"x","id":2},"prev":"a208245587b17e1774d74b44d025ed8cea044495ccaa51822204558cc396b19c","seq":12,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":1,"amount":"100.00","currency":"USD","id":2,"side":"debit"},"prev":"be56d3de30706dc7498a6f8524bf52408813108dd29b56770918c5c46993acd0","seq":13,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"100.00","currency":"USD","id":2,"side":"credit"},"prev":"0c9847420f6d96e94f120c05da81e09c20a0e0068aca19ae67788515cd5cd404","seq":14,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-02","description":"x","id":2,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"debit"},{"account":3,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"3b7c5513b31ea4322b1bb89bc72db1adef5a76fec0601ea633171cb0a5e4864d","seq":15,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"date":"2026-02-20","description":"x","id":3},"prev":"77c977e18ab290db770da59a850ba0d250b8ba8dbcbdcde9602621d932a3e36c","seq":16,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":4,"amount":"1.00","currency":"USD","id":3,"side":"debit"},"prev":"a7a09a5cb7b392553451b7c3b4c4daf558af8dfb900be27a3dd1616c7f162e27","seq":17,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"1.00","currency":"USD","id":3,"side":"credit"},"prev":"c4ef34b79227afb83fe3ba174d2532270dfd93bb200bb0ef1224bd88e1400dc5","seq":18,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-20","description":"x","id":3,"lines":[{"account":4,"amount":"1.00","currency":"USD","side":"debit"},{"account":1,"amount":"1.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"436b4b244cb9936169ec3f9a126a49fbed1f4b6345eb1ebc94f5f0c32a109e7d","seq":19,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"date":"2026-01-10","description":"x","id":4},"prev":"7c9678ee33efabfe40c11ca97d09f3c85e127b6f927747947b1678ce9397df5c","seq":20,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.01","currency":"USD","id":4,"side":"debit"},"prev":"06f89be795ac91344f2449c6703c25e0285ab0044dbacaed3659c494b599fcfe","seq":21,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.01","currency":"USD","id":4,"side":"credit"},"prev":"267ac19f0fee58f5b7fa6da19de3e21f623814a3b9373d98cba8a875b2514495","seq":22,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-10","description":"x","id":4,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2d8c53df64faecd0bf0834edac009ad0e315c6c3458ca1b5991d636fad153809","seq":23,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"82247efbe44c98efb1276c5678b97121d1fe8b16071c725ea460d5f282fc044a","seq":24,"ts":"2026-10-20T22:36:22Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}]},"closing_entry":5,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":5,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":2,"amount":"0.01","currency":"USD","side":"credit"}]},"id":1},"prev":"6280e3a7a5e5581346d562f82501c28e5d56da4f3a46e47da9194c58c0904a2e","seq":25,"ts":"2026-10-20T22:36:22Z","type":"period.closed"}
{"payload":{"date":"2026-02-10","description":"x","id":6},"prev":"fd348f69afd6c2810cd8ee309a0ffbe907a4a1b2ebf1054c509bcbaabdc4bf17","seq":26,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":6,"amount":"0.03","currency":"USD","id":6,"side":"debit"},"prev":"c241f51dcaebac1313c025be952019ccd08ab604a87c46698a6eb3a1c09f2e5b","seq":27,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"0.03","currency":"USD","id":6,"side":"credit"},"prev":"116374477240159a9cc2b8a7944f228bab54eb1c952031b7d4c87cd994a90f5f","seq":28,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-10","description":"x","id":6,"lines":[{"account":6,"amount":"0.03","currency":"USD","side":"debit"},{"account":1,"amount":"0.03","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"aa7d8169faab35408e4465a8b24363730b24fc9ccffce8cd7ed3a3c78bd23eb3","seq":29,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"end":"2026-02-28","id":2,"name":"2026-02","start":"2026-02-01"},"prev":"d84c26dd5d2a2b6c3b448a1ccd9394768a9ecac2827a9bb7e420db2e82c3699e","seq":30,"ts":"2026-10-20T22:36:22Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}]},"closing_entry":7,"entry":{"date":"2026-02-28","description":"Close period 2026-02","id":7,"lines":[{"account":6,"amount":"0.03","currency":"USD","side":"credit"},{"account":2,"amount":"0.02","currency":"USD","side":"debit"},{"account":3,"amount":"0.01","currency":"USD","side":"debit"},{"account":4,"amount":"1.00","currency":"USD","side":"credit"},{"account":2,"amount":"1.00","currency":"USD","side":"debit"}]},"id":2},"prev":"f1e4617e0238ddcc92bb6e38d67323e97cbed40cbb59995205c143f463ce3866","seq":31,"ts":"2026-10-20T22:36:22Z","type":"period.closed"}
{"payload":{"date":"2026-03-10","description":"x","id":8},"prev":"43be78882bc9bef04dacdbb2428cd96276390b853bbc7cb4ff782069c6663db3","seq":32,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.05","currency":"USD","id":8,"side":"debit"},"prev":"972871a9cf053a3fdfad69b14b8194f94dc83b27d69fd7850b9a1c1829cc0b98","seq":33,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.05","currency":"USD","id":8,"side":"credit"},"prev":"900444859549026f0e81bc4d207e6cbd4a64c05ccd66c4021b76b9042e73623d","seq":34,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-10","description":"x","id":8,"lines":[{"account":1,"amount":"0.05","currency":"USD","side":"debit"},{"account":5,"amount":"0.05","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"67886299023903711f639c2f2beccfc09113aebcd25743c1f491cadb3675de6a","seq":35,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"end":"2026-03-31","id":3,"name":"2026-03","start":"2026-03-01"},"prev":"8e70a44717e1593afe8f9979d1bf2c5e6a663dec66eb0d2511458582c5757e20","seq":36,"ts":"2026-10-20T22:36:22Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}]},"closing_entry":9,"entry":{"date":"2026-03-31","description":"Close period 2026-03","id":9,"lines":[{"account":5,"amount":"0.05","currency":"USD","side":"debit"},{"account":2,"amount":"0.03","currency":"USD","side":"credit"},{"account":3,"amount":"0.02","currency":"USD","side":"credit"}]},"id":3},"prev":"7402d428c3cf959ed87d815be5b6f8832e92f4e839f7d016af6c2adfbfed9fdd","seq":37,"ts":"2026-10-20T22:36:22Z","type":"period.closed"}
{"payload":{"date":"2026-04-10","description":"x","id":10},"prev":"cf6379a5c59b3ea659f37e133b75851c2f64501ff691f9b598c46174d530ecf3","seq":38,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.07","currency":"USD","id":10,"side":"debit"},"prev":"b9177ac8026f3153a671e52760d2d84c4c8c6f3a51fa2d87ceed4e1b365ab991","seq":39,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.07","currency":"USD","id":10,"side":"credit"},"prev":"97fe44dc61a0daa1e314684d72f3d113fbe2f3f72a4af2313caed05e78b68833","seq":40,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-04-10","description":"x","id":10,"lines":[{"account":1,"amount":"0.07","currency":"USD","side":"debit"},{"account":5,"amount":"0.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c35ccd51b0626b522185bf76842c88992e3cd7f79ba1089d0473374a60cf61a2","seq":41,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"end":"2026-04-30","id":4,"name":"2026-04","start":"2026-04-01"},"prev":"b99b2a67c5ba001e8ce7745dd14d7f70e423c319c04486182254e1065793328c","seq":42,"ts":"2026-10-20T22:36:22Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"largest_remainder"},"closing_entry":11,"entry":{"date":"2026-04-30","description":"Close period 2026-04","id":11,"lines":[{"account":5,"amount":"0.07","currency":"USD","side":"debit"},{"account":2,"amount":"0.04","currency":"USD","side":"credit"},{"account":3,"amount":"0.03","currency":"USD","side":"credit"}]},"id":4},"prev":"5849509840c3033bff1ccf60e96a20dec9c7adf0e34ffd0862523fa6acea2e82","seq":43,"ts":"2026-10-20T22:36:22Z","type":"period.closed"}
{"payload":{"date":"2026-05-10","description":"x","id":12},"prev":"c31ca5fa010b15f00c157f464b7c4cede3dcdef9007271fab7ce0c2e7017ef08","seq":44,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":6,"amount":"0.01","currency":"USD","id":12,"side":"debit"},"prev":"176c4f829ed8a51c1000fca24f6ada374eddae5fcd0ba4e795880248de4add88","seq":45,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"0.01","currency":"USD","id":12,"side":"credit"},"prev":"59ef20ce6f87a178e59a6fd776df92469cf1b7d2600df44efc455f895f7dc9a8","seq":46,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-05-10","description":"x","id":12,"lines":[{"account":6,"amount":"0.01","currency":"USD","side":"debit"},{"account":1,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"04b41e6f7ac0f0183596f59c3b1d59f6686ce8119614d16b4cc399d571c30f34","seq":47,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"end":"2026-05-31","id":5,"name":"2026-05","start":"2026-05-01"},"prev":"2d9afc259b1dba167ca3fe21d57fbc1a3d82188d351900709440dce4c68e1173","seq":48,"ts":"2026-10-20T22:36:22Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"largest_remainder"},"closing_entry":13,"entry":{"date":"2026-05-31","description":"Close period 2026-05","id":13,"lines":[{"account":6,"amount":"0.01","currency":"USD","side":"credit"},{"account":2,"amount":"0.01","currency":"USD","side":"debit"}]},"id":5},"prev":"c7cdb2f75922c5b362c4002bdf09fb34df19359414221e426a52df6790b3f5c5","seq":49,"ts":"2026-10-20T22:36:22Z","type":"period.closed"}
{"payload":{"date":"2026-06-10","description":"x","id":14},"prev":"3d19bf47eb75ec57e51442a4e7aa888f45ae1b8b4ced9f343129181cad356837","seq":50,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.03","currency":"USD","id":14,"side":"debit"},"prev":"0c15f70e6c6e7fd0feb83aa7b21eb3b1a39148ec13e29d2e4e44c873ad383c9f","seq":51,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.03","currency":"USD","id":14,"side":"credit"},"prev":"7e822401f33a9977406626fa1ab01e1d113233dd61aaca9da71ba300b6b05be6","seq":52,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"}
{"payload":{"date":"2026-06-10","description":"x","id":14,"lines":[{"account":1,"amount":"0.03","currency":"USD","side":"debit"},{"account":5,"amount":"0.03","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5a7691df36edec88888faa6230cf118dbd0aed36a323049ae3ce613d12589997","seq":53,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"}
{"payload":{"end":"2026-06-30","id":6,"name":"2026-06","start":"2026-06-01"},"prev":"2a8639154a4c9e9209666bf3e79770fe3aef8d65c544c36a63199d70094054ae","seq":54,"ts":"2026-10-20T22:36:22Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"largest_remainder"},"closing_entry":15,"entry":{"date":"2026-06-30","description":"Close period 2026-06","id":15,"lines":[{"account":5,"amount":"0.03","currency":"USD","side":"debit"},{"account":2,"amount":"0.02","currency":"USD","side":"credit"},{"account":3,"amount":"0.01","currency":"USD","side":"credit"}]},"id":6},"prev":"f07f0f33e40c04aa0d5d01bd6fb5b657544697f74cb666c18e1a477d402c3401","seq":55,"ts":"2026-10-20T22:36:22Z","type":"period.closed"}
{"payload":{"date":"2026-07-10","description":"x","id":16},"prev":"151e2397daf4eaa0f2e80e9efdf2477a2efc7a20741c887db00bbdae850756a1","seq":56,"ts":"2026-10-20T22:36:22Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.01","currency":"USD","id":16,"side":"debit"},"prev":"b547976858511f9ce393616f51b5756dc209f57092e48a989eed7e1f56864e43","seq":57,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.01","currency":"USD","id":16,"side":"credit"},"prev":"d5193903dafb864592ee04f832e2e3e39393a6b7a1a68133b6ed64d7a61deeb5","seq":58,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"date":"2026-07-10","description":"x","id":16,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"facf5e345f9e12f532640dcd26c103d0b445a71d3df2e73a8b9fc2447d586a8a","seq":59,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"}
{"payload":{"end":"2026-07-31","id":7,"name":"2026-07","start":"2026-07-01"},"prev":"19a977322c7656907e0c1963ea3509d777466b3cac40602158cfc9d35e2d5be3","seq":60,"ts":"2026-10-20T22:36:23Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":17,"entry":{"date":"2026-07-31","description":"Close period 2026-07","id":17,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":2,"amount":"0.01","currency":"USD","side":"credit"}]},"id":7},"prev":"9ae96db6a36a943e27213f8f186947b720ff57aee51b3f9f1ce8a12b069500d7","seq":61,"ts":"2026-10-20T22:36:23Z","type":"period.closed"}
{"payload":{"date":"2026-08-10","description":"x","id":18},"prev":"cf6b81c159292b4d2b4cee2716e2c184e15041639b929944146694fedada6078","seq":62,"ts":"2026-10-20T22:36:23Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.01","currency":"USD","id":18,"side":"debit"},"prev":"a69ec51e484e61b444b097886984d493619ec0e88f90d04d674b6f716328a11a","seq":63,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.01","currency":"USD","id":18,"side":"credit"},"prev":"ff5d291ffe60a1519fb50e030ead50faf0adca786b98dae951a2fc30f718fc9b","seq":64,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"date":"2026-08-10","description":"x","id":18,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b5a12d4c2f0007b790bc0bf977151e23865498a935f92dbd9f5c992cb8a4c71e","seq":65,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"}
{"payload":{"end":"2026-08-31","id":8,"name":"2026-08","start":"2026-08-01"},"prev":"1aef626e3b26cce874c5b474990be8c333c5d1eb2327be739f59163bc7103c49","seq":66,"ts":"2026-10-20T22:36:23Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":19,"entry":{"date":"2026-08-31","description":"Close period 2026-08","id":19,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":3,"amount":"0.01","currency":"USD","side":"credit"}]},"id":8},"prev":"3aebd760694ae27241a4241923035c08b4b1a29dc61726a8203c3f05f4922cb5","seq":67,"ts":"2026-10-20T22:36:23Z","type":"period.closed"}
{"payload":{"date":"2026-09-10","description":"x","id":20},"prev":"1f848b707680af75484084b4540dfe71c168c9cb54db761ea429d613d5318267","seq":68,"ts":"2026-10-20T22:36:23Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.01","currency":"USD","id":20,"side":"debit"},"prev":"f9bbb823ec0e036d7246ab5d05dfb3617f7ad0b9988e622f48e4d4afd4b8e4a0","seq":69,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.01","currency":"USD","id":20,"side":"credit"},"prev":"4009cf504d97e2bf46f0ad1edaeb5e7e5484d3a8c26580b753c09abbb6744802","seq":70,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"date":"2026-09-10","description":"x","id":20,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5892d0131f14095e5d2426d0303d362687f4793b34e37993d50e6814da0ece9c","seq":71,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"}
{"payload":{"end":"2026-09-30","id":9,"name":"2026-09","start":"2026-09-01"},"prev":"03649f77b722b97c214637744a5b458334998548196ee392019a6e4c5597d4ef","seq":72,"ts":"2026-10-20T22:36:23Z","type":"period.created"}
{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"52e2aa39b666fa0ae081d1135a8d84b34d87530dc56fc7816f98f9d52baafacb","seq":73,"ts":"2026-10-20T22:36:23Z","type":"account.added"}
{"payload":{"close_to":"retained_earnings","closing_entry":21,"entry":{"date":"2026-09-30","description":"Close period 2026-09","id":21,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":7,"amount":"0.01","currency":"USD","side":"credit"}]},"id":9},"prev":"20b0e0ea81e4449eecfd3db0847718c97d90a71a4e4ca012c45662619ee95580","seq":74,"ts":"2026-10-20T22:36:23Z","type":"period.closed"}
{"payload":{"date":"2026-10-10","description":"x","id":22},"prev":"2c538d1e71039b6110c347cebde881ce0e4e0b5f5e63d7ec62a6b389ee19c1c7","seq":75,"ts":"2026-10-20T22:36:23Z","type":"entry.created"}
{"payload":{"account":1,"amount":"0.03","currency":"USD","id":22,"side":"debit"},"prev":"ba25543d5aada70e19f43591d6ee97ce40ac174e580d13746a114912b17877b7","seq":76,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"account":5,"amount":"0.03","currency":"USD","id":22,"side":"credit"},"prev":"c4197697cfc61e1e8569ca363cd9d689f582233f53c6365c2cd69bbf0444d6be","seq":77,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"date":"2026-10-10","description":"x","id":22,"lines":[{"account":1,"amount":"0.03","currency":"USD","side":"debit"},{"account":5,"amount":"0.03","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"135a8dd9eef94a66eefa5d03065fb9738595773ed7fa3e78275d20c1e8e325f2","seq":78,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"}
{"payload":{"end":"2026-10-31","id":10,"name":"2026-10","start":"2026-10-01"},"prev":"9b571865ef045c315429db710a9fb981b5ced04e49e458a42494c906ae471fa1","seq":79,"ts":"2026-10-20T22:36:23Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":23,"entry":{"date":"2026-10-31","description":"Close period 2026-10","id":23,"lines":[{"account":5,"amount":"0.03","currency":"USD","side":"debit"},{"account":2,"amount":"0.02","currency":"USD","side":"credit"},{"account":3,"amount":"0.01","currency":"USD","side":"credit"}]},"id":10},"prev":"067522b0fb85b0165a90d9fdaf0e600507ef8c1cd2d90df4369c8e7f755ffe0f","seq":80,"ts":"2026-10-20T22:36:23Z","type":"period.closed"}
{"payload":{"date":"2026-11-10","description":"x","id":24},"prev":"4cad60fbb38a6fe5324adb8a9f9fcadc2e4ce6baa5e9b714138b2e74d77bc9aa","seq":81,"ts":"2026-10-20T22:36:23Z","type":"entry.created"}
{"payload":{"account":6,"amount":"0.05","currency":"USD","id":24,"side":"debit"},"prev":"bba5273b15c5dbd1fcf0f9b72e003e357bc782bb7984fb8db277c18997e039c6","seq":82,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"0.05","currency":"USD","id":24,"side":"credit"},"prev":"3117a5c4f737f9b73e88cb92e316e299fac02c4ddd1f61fe190bfcc23ebcb690","seq":83,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"}
{"payload":{"date":"2026-11-10","description":"x","id":24,"lines":[{"account":6,"amount":"0.05","currency":"USD","side":"debit"},{"account":1,"amount":"0.05","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7e26fd3e79116b352465755e53411d5e302a0abd89a680a4c1e49d9709094a65","seq":84,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"}
{"payload":{"end":"2026-11-30","id":11,"name":"2026-11","start":"2026-11-01"},"prev":"2eafcaaa0ae4aea57eb8e700ef940d9318a9a8601175dcafa5356300e6650775","seq":85,"ts":"2026-10-20T22:36:23Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":25,"entry":{"date":"2026-11-30","description":"Close period 2026-11","id":25,"lines":[{"account":6,"amount":"0.05","currency":"USD","side":"credit"},{"account":2,"amount":"0.03","currency":"USD","side":"debit"},{"account":3,"amount":"0.02","currency":"USD","side":"debit"}]},"id":11},"prev":"392fc8390543e580898401ba489ef0c05fe78646612581c9357145e80a2c2a3d","seq":86,"ts":"2026-10-20T22:36:23Z","type":"period.closed"}
+525
View File
@@ -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);
}
+71 -18
View File
@@ -2087,16 +2087,64 @@ fn store_add(store: &mut Store, code: &str, t: AccountType) -> ledger_core::Acco
}
/// Contract (rules.md B-13): each members close journals its rounding rule
/// and replays under it: "cumulative" for new closes, "largest_remainder"
/// for the per-close rule of earlier on 2026-10-13. Three one-cent months,
/// 50/50: cumulative ends A 0.02 / B 0.01, per close A 0.03 / B 0.00.
/// and replays under it: "cumulative_since" with the close its count starts
/// at for new closes (P3), "cumulative" for the run rule of 2026-10-13 to
/// P3, "largest_remainder" for the per-close rule of earlier on 2026-10-13.
/// Three one-cent months, 50/50: both cumulative rules end A 0.02 / B
/// 0.01, per close A 0.03 / B 0.00. With February closed to retained
/// earnings instead, the two cumulative rules differ: cumulative_since
/// skips it (the running 0.02 splits A 0.01 / B 0.01), the run rule
/// restarts at March (A gets both cents: A 0.02 / B 0.00).
#[test]
fn members_closes_replay_under_the_rule_they_were_journaled_with() {
for (cumulative, rule, a, b) in [
(true, "\"rounding\":\"cumulative\"", "0.02", "0.01"),
(false, "\"rounding\":\"largest_remainder\"", "0.03", "0.00"),
use ledger_core::{CloseTo, MemberClose};
type Rule = fn(Vec<MemberClose>) -> CloseTo;
let since: Rule = CloseTo::Members;
let run: Rule = CloseTo::MembersRun;
let per: Rule = CloseTo::MembersPerClose;
for (name, to, re_february, rule, a, b) in [
(
"since",
since,
false,
"\"rounding\":\"cumulative_since\"",
"0.02",
"0.01",
),
(
"run",
run,
false,
"\"rounding\":\"cumulative\"",
"0.02",
"0.01",
),
(
"per",
per,
false,
"\"rounding\":\"largest_remainder\"",
"0.03",
"0.00",
),
(
"since-re",
since,
true,
"\"rounding\":\"cumulative_since\"",
"0.01",
"0.01",
),
(
"run-re",
run,
true,
"\"rounding\":\"cumulative\"",
"0.02",
"0.00",
),
] {
let dir = test_dir(&format!("close-rule-{cumulative}"));
let dir = test_dir(&format!("close-rule-{name}"));
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
@@ -2112,19 +2160,14 @@ fn members_closes_replay_under_the_rule_they_were_journaled_with() {
let rev = store
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let members: Vec<ledger_core::MemberClose> = caps
let members: Vec<MemberClose> = caps
.iter()
.map(|&capital| ledger_core::MemberClose {
.map(|&capital| MemberClose {
capital,
share: Decimal::new(5, 1),
distributions: None,
})
.collect();
let to = if cumulative {
ledger_core::CloseTo::Members(members)
} else {
ledger_core::CloseTo::MembersPerClose(members)
};
let cent = Money::new(Decimal::new(1, 2), Currency::USD);
for m in 1..=3u32 {
let day = |d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
@@ -2139,16 +2182,26 @@ fn members_closes_replay_under_the_rule_they_were_journaled_with() {
let p = store
.create_period(&format!("2026-{m:02}"), day(1), end)
.unwrap();
store.close_period_to(p, &to).unwrap();
if re_february && m == 2 {
store.close_period(p).unwrap();
} else {
store.close_period_to(p, &to(members.clone())).unwrap();
}
}
let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
assert_eq!(journal.matches(rule).count(), 3, "{rule}");
let closes = if re_february { 2 } else { 3 };
assert_eq!(journal.matches(rule).count(), closes, "{name}");
if rule.contains("cumulative_since") {
// Every count starts at the first close: February's close to
// retained earnings is skipped, not a restart.
assert_eq!(journal.matches("\"since_seq\":1").count(), closes, "{name}");
}
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
let bal = |id| store.ledger().account_balance(id).unwrap();
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{rule}");
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{rule}");
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{name}");
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{name}");
}
}