Files
wanderbooks/crates/ledger-core/tests/odd_cent.rs
T
swaitsandClaude Opus 5.5 8255ac2103
check / check (push) Canceled after 0s
Tests and docs: no figures from the first company's books
The odd-cent regression's net loss and its splits, the website's example
Apple charge and the GUI plan's example receipt now use made-up amounts.

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

1022 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The odd cent (P3 defect 17; rules.md B-13; D-10, D-38): members closes
//! split earnings cumulatively, each member's share of everything closed
//! to these members and shares so far less what earlier closes gave them,
//! and the statement of members' equity, the K-1 worksheet and the viewer
//! split earnings not yet closed the same running way.
//!
//! Every expected figure is worked by hand in the comment beside it, from
//! the rule: a close gives member i allocate(C + net)_i − given_i, where C
//! is what the count's earlier closes closed, given_i what they gave i,
//! and allocate splits by largest remainder to the currency's minor unit,
//! ties to the member listed first (DECISION, owner 2026-10-13).
use chrono::{Datelike, NaiveDate};
use ledger_core::{
AccountId, AccountType, CloseTo, Currency, EntryId, Ledger, LedgerError, MemberCapital,
MemberClose, MemberRule, Money, PeriodId, Side, members_equity,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn d(m: u32, dd: u32) -> NaiveDate {
day(2026, m, dd)
}
fn dec(s: &str) -> Decimal {
s.parse().unwrap()
}
fn decs(v: &[&str]) -> Vec<Decimal> {
v.iter().map(|s| dec(s)).collect()
}
fn month_end(y: i32, m: u32) -> NaiveDate {
if m == 12 {
day(y, 12, 31)
} else {
day(y, m + 1, 1).pred_opt().unwrap()
}
}
/// A partnership: cash 1000; member i's capital 300i and distributions
/// 310i; other equity 3500; sales 4000; fees 6000.
struct Co {
l: Ledger,
currency: Currency,
cash: AccountId,
caps: Vec<AccountId>,
dists: Vec<AccountId>,
other_equity: AccountId,
sales: AccountId,
fees: AccountId,
}
fn company(currency: Currency, members: usize) -> Co {
let mut l = Ledger::new(currency);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = (0..members)
.map(|i| add(&format!("300{i}"), AccountType::Equity))
.collect();
let dists = (0..members)
.map(|i| add(&format!("310{i}"), AccountType::Equity))
.collect();
let other_equity = add("3500", AccountType::Equity);
let sales = add("4000", AccountType::Revenue);
let fees = add("6000", AccountType::Expense);
Co {
l,
currency,
cash,
caps,
dists,
other_equity,
sales,
fees,
}
}
impl Co {
fn post(&mut self, date: NaiveDate, dr: AccountId, cr: AccountId, amount: Decimal) {
let e = self.l.create_entry(date, "x");
let m = Money::new(amount, self.currency);
self.l.add_line(e, dr, Side::Debit, m).unwrap();
self.l.add_line(e, cr, Side::Credit, m).unwrap();
self.l.post_entry(e).unwrap();
}
/// Earn `net` on `date`: a sale for a gain, a fee for a loss, nothing
/// for zero.
fn earn(&mut self, date: NaiveDate, net: Decimal) {
if net > Decimal::ZERO {
self.post(date, self.cash, self.sales, net);
} else if net < Decimal::ZERO {
self.post(date, self.fees, self.cash, -net);
}
}
/// Create month `m` of `y` as a period and close it to `to`.
fn close(
&mut self,
y: i32,
m: u32,
to: &CloseTo,
) -> (PeriodId, Result<Option<EntryId>, LedgerError>) {
let p = self
.l
.create_period(&format!("{y}-{m:02}"), day(y, m, 1), month_end(y, m))
.unwrap();
(p, self.l.close_period_to(p, to))
}
/// Earn `net` on the 10th of month `m` of 2026 and close the month.
fn month(&mut self, m: u32, net: &str, to: &CloseTo) -> PeriodId {
self.earn(d(m, 10), dec(net));
let (p, r) = self.close(2026, m, to);
r.unwrap();
p
}
/// The members in this order (indexes into `caps`), member i at
/// `shares[i]`, without distribution accounts.
fn members(&self, order: &[usize], shares: &[&str]) -> Vec<MemberClose> {
order
.iter()
.map(|&i| MemberClose {
capital: self.caps[i],
share: dec(shares[i]),
distributions: None,
})
.collect()
}
fn capitals(&self) -> Vec<Decimal> {
self.caps
.iter()
.map(|&c| self.l.account_balance(c).unwrap())
.collect()
}
/// The members as the statement of members' equity takes them, each
/// with their distributions account.
fn member_capitals(&self, shares: &[&str]) -> Vec<MemberCapital> {
shares
.iter()
.enumerate()
.map(|(i, s)| MemberCapital {
name: format!("M{i}"),
capital_account: self.caps[i],
distribution_account: Some(self.dists[i]),
share: dec(s),
})
.collect()
}
/// Each member's capital on the statement of members' equity as of
/// `as_of` (its ending, the statement from Jan 1, 2026).
fn statement(&self, shares: &[&str], as_of: NaiveDate) -> Vec<Decimal> {
self.statement_listing(&(0..shares.len()).collect::<Vec<_>>(), shares, as_of)
}
/// [`Co::statement`] listing the members in this order (indexes into
/// `caps`), as settings that list them so would.
fn statement_listing(
&self,
order: &[usize],
shares: &[&str],
as_of: NaiveDate,
) -> Vec<Decimal> {
let all = self.member_capitals(shares);
let members: Vec<MemberCapital> = order.iter().map(|&i| all[i].clone()).collect();
members_equity(&self.l, d(1, 1), as_of, &members)
.unwrap()
.members
.iter()
.map(|m| m.ending)
.collect()
}
fn lines(&self, id: EntryId) -> Vec<(String, Side, Decimal)> {
self.l
.get_entry(id)
.unwrap()
.lines()
.iter()
.map(|x| {
let code = self.l.chart().get(x.account).unwrap().code.clone();
(code, x.side, x.amount.amount)
})
.collect()
}
}
fn line(code: &str, side: Side, amount: &str) -> (String, Side, Decimal) {
(code.to_string(), side, dec(amount))
}
const HALF: [&str; 2] = ["0.5", "0.5"];
const THIRDS: [&str; 3] = ["0.3333", "0.3333", "0.3334"];
// ---------------------------------------------------------------------------
// The regression (audit F52, 2026-10-16, anonymized)
// ---------------------------------------------------------------------------
/// Regression (P3 defect 17; audit F52's −287.53, anonymized): members A
/// and B at half each. January's net loss of 287.53 closes to them: exact
/// −143.765 each, so A −143.77, B −143.76 (the tie to the member listed
/// first). February's 10.00 loss closes to retained earnings. March's
/// 287.53 loss closes to them again. The run rule (2026-10-13 to P3)
/// started over at March, handing A the tied cent a second time: A
/// −287.54, B −287.52 for an exact −287.53 each. Now February's close is
/// skipped and March continues January's count: the running −575.06 splits
/// −287.53 each, less January's −143.77 / −143.76, so March gives A
/// −143.76 and B −143.77. The March entry: Cr 6000 287.53, Dr 3000 143.76,
/// Dr 3001 143.77.
#[test]
fn the_audit_odd_cent_never_piles_up_across_closes() {
type Rule = fn(Vec<MemberClose>) -> CloseTo;
let (run, cumulative): (Rule, Rule) = (CloseTo::MembersRun, CloseTo::Members);
for (rule, a, b) in [
(run, "-287.54", "-287.52"),
(cumulative, "-287.53", "-287.53"),
] {
let mut co = company(Currency::USD, 2);
let to = rule(co.members(&[0, 1], &HALF));
let jan = co.month(1, "-287.53", &to);
let jan_entry = co.l.get_period(jan).unwrap().closing_entry.unwrap();
assert_eq!(
co.lines(jan_entry),
[
line("6000", Side::Credit, "287.53"),
line("3000", Side::Debit, "143.77"),
line("3001", Side::Debit, "143.76"),
]
);
co.month(2, "-10.00", &CloseTo::RetainedEarnings { sweep: vec![] });
let mar = co.month(3, "-287.53", &to);
assert_eq!(co.capitals(), decs(&[a, b]));
let retained = co.l.chart().find_by_code("3999").unwrap().id;
assert_eq!(co.l.account_balance(retained).unwrap(), dec("-10.00"));
if a == "-287.53" {
let mar_entry = co.l.get_period(mar).unwrap().closing_entry.unwrap();
assert_eq!(
co.lines(mar_entry),
[
line("6000", Side::Credit, "287.53"),
line("3000", Side::Debit, "143.76"),
line("3001", Side::Debit, "143.77"),
]
);
let (since, split) = co.l.member_close_split(mar).unwrap();
assert_eq!(since, 1, "the count starts at January's close");
let rows: Vec<_> = split.iter().map(|m| (m.part, m.running, m.exact)).collect();
assert_eq!(
rows,
[
(dec("-143.76"), dec("-287.53"), dec("-287.53")),
(dec("-143.77"), dec("-287.53"), dec("-287.53")),
]
);
}
}
}
// ---------------------------------------------------------------------------
// From inception (D-10): earlier rules trued up by the next close that posts
// ---------------------------------------------------------------------------
/// Three cents, 50/50, closed one at a time under the per-close rule:
/// each tie to A, so A 0.03, B 0.00.
fn three_per_close_cents(co: &mut Co) {
let legacy = CloseTo::MembersPerClose(co.members(&[0, 1], &HALF));
for m in 1..=3 {
co.month(m, "0.01", &legacy);
}
assert_eq!(co.capitals(), decs(&["0.03", "0.00"]));
}
/// D-10 (from inception; P3 brief R2): after three per-close cent months
/// (A 0.03, B 0.00), April's cent closes under the cumulative rule from
/// the first close: the running 0.04 splits 0.02 / 0.02, less 0.03 / 0.00
/// given, so A −0.01 and B +0.02. The entry: Dr 4000 0.01, Dr 3000 0.01,
/// Cr 3001 0.02. Both stand at their exact 0.02.
#[test]
fn members_closes_true_up_from_inception() {
let mut co = company(Currency::USD, 2);
three_per_close_cents(&mut co);
let apr = co.month(4, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF)));
let entry = co.l.get_period(apr).unwrap().closing_entry.unwrap();
assert_eq!(
co.lines(entry),
[
line("4000", Side::Debit, "0.01"),
line("3000", Side::Debit, "0.01"),
line("3001", Side::Credit, "0.02"),
]
);
assert_eq!(co.capitals(), decs(&["0.02", "0.02"]));
assert_eq!(co.l.member_close_split(apr).unwrap().0, 1);
}
/// INVARIANT 6 and rule 4 (a true-up rides only on a close that posts):
/// after the three per-close cents (A 0.03, B 0.00), April has no
/// activity, so its close posts nothing and credits no one; the count
/// still includes it (its net is zero). May's cent posts and carries the
/// true-up: the running 0.04 splits 0.02 / 0.02, so A −0.01, B +0.02.
#[test]
fn a_close_that_posts_nothing_carries_no_true_up() {
let mut co = company(Currency::USD, 2);
three_per_close_cents(&mut co);
let to = CloseTo::Members(co.members(&[0, 1], &HALF));
let (apr, posted) = co.close(2026, 4, &to);
assert_eq!(posted.unwrap(), None);
let record =
co.l.get_period(apr)
.unwrap()
.members_closed
.clone()
.unwrap();
assert_eq!(record.parts, decs(&["0", "0"]));
assert_eq!(record.rule, MemberRule::Cumulative { since_seq: 1 });
assert_eq!(co.capitals(), decs(&["0.03", "0.00"]));
co.month(5, "0.01", &to);
assert_eq!(co.capitals(), decs(&["0.02", "0.02"]));
}
/// P3 review R-3: a close whose only lines would be a distribution sweep
/// still posts, so it carries the pending true-up. After the three
/// per-close cents (A 0.03, B 0.00), A takes 5.00 through their
/// distributions account in April and nothing is earned: the running 0.03
/// splits 0.02 / 0.01, so A −0.01 and B +0.01, beside the sweep. The
/// entry: Dr 3000 0.01, Cr 3001 0.01, Cr 3100 5.00, Dr 3000 5.00.
#[test]
fn a_true_up_rides_on_a_distribution_sweep() {
let mut co = company(Currency::USD, 2);
three_per_close_cents(&mut co);
co.post(d(4, 20), co.dists[0], co.cash, dec("5.00"));
let mut members = co.members(&[0, 1], &HALF);
members[0].distributions = Some(co.dists[0]);
let (apr, posted) = co.close(2026, 4, &CloseTo::Members(members));
let entry = posted.unwrap().unwrap();
assert_eq!(
co.lines(entry),
[
line("3000", Side::Debit, "0.01"),
line("3001", Side::Credit, "0.01"),
line("3100", Side::Credit, "5.00"),
line("3000", Side::Debit, "5.00"),
]
);
assert_eq!(co.capitals(), decs(&["-4.98", "0.01"]));
let (_, split) = co.l.member_close_split(apr).unwrap();
let running: Vec<_> = split.iter().map(|m| m.running).collect();
assert_eq!(running, decs(&["0.02", "0.01"]));
}
// ---------------------------------------------------------------------------
// Where a count starts: a set of members and shares (B-13; §706(d))
// ---------------------------------------------------------------------------
/// A reorder of the members is not a change: [A, B] closes January's cent
/// (A 0.01); [B, A] closes February's and March's. February: the running
/// 0.02 splits 0.01 / 0.01, less A 0.01 / B 0.00, so B +0.01. March: 0.03
/// is 0.015 each, and the tied cent goes to the member the count's first
/// close listed first, A, whatever order March lists them in: 0.02 /
/// 0.01, less 0.01 / 0.01, so A +0.01. A 0.02, B 0.01; every close counts
/// from close 1. The statement of members' equity, listing them [A, B]
/// or [B, A], shows the same before and after March closes.
#[test]
fn reordering_members_keeps_counting_from_the_first_close() {
let mut co = company(Currency::USD, 2);
co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF)));
let reversed = CloseTo::Members(co.members(&[1, 0], &HALF));
let feb = co.month(2, "0.01", &reversed);
assert_eq!(co.capitals(), decs(&["0.01", "0.01"]));
co.earn(d(3, 10), dec("0.01"));
let listed_backwards = |co: &Co| {
let mut members = co.member_capitals(&HALF);
members.reverse();
let st = members_equity(&co.l, d(1, 1), d(3, 31), &members).unwrap();
st.members.iter().map(|m| m.ending).collect::<Vec<_>>()
};
assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.02", "0.01"]));
assert_eq!(listed_backwards(&co), decs(&["0.01", "0.02"]));
let (mar, r) = co.close(2026, 3, &reversed);
r.unwrap();
assert_eq!(co.capitals(), decs(&["0.02", "0.01"]));
assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.02", "0.01"]));
assert_eq!(listed_backwards(&co), decs(&["0.01", "0.02"]));
for p in [feb, mar] {
assert_eq!(co.l.member_close_split(p).unwrap().0, 1);
}
}
/// A change of shares starts a new count (26 U.S.C. §706(d)(1); Treas.
/// Reg. §1.706-4(a)(3)(vii)): what was closed at the old shares is never
/// split again at the new ones. January's cent at 50/50 goes to A (close
/// 1). February at 60/40 starts a count at close 2: 0.006 / 0.004, the
/// cent to A's larger remainder (re-splitting January's cent too would
/// have given B February's). March continues from close 2: 0.02 at 60/40
/// is 0.012 / 0.008, so 0.01 / 0.01, less 0.01 / 0.00: B +0.01. A return
/// to 50/50 in April is a new count again (from close 4), not January's:
/// April's cent to A. A 0.03, B 0.01.
#[test]
fn a_share_change_starts_a_new_count() {
let mut co = company(Currency::USD, 2);
let jan = co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF)));
let sixty = CloseTo::Members(co.members(&[0, 1], &["0.6", "0.4"]));
let feb = co.month(2, "0.01", &sixty);
assert_eq!(co.capitals(), decs(&["0.02", "0.00"]));
let mar = co.month(3, "0.01", &sixty);
assert_eq!(co.capitals(), decs(&["0.02", "0.01"]));
let apr = co.month(4, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF)));
assert_eq!(co.capitals(), decs(&["0.03", "0.01"]));
let since: Vec<u64> = [jan, feb, mar, apr]
.iter()
.map(|&p| co.l.member_close_split(p).unwrap().0)
.collect();
assert_eq!(since, [1, 2, 2, 4]);
}
/// P3 review R-8: a member joining mid-year starts a new count, so the
/// under-a-cent bound holds per set of members and shares, and a member's
/// year can be off by up to a cent per count. January to June, A and B at
/// 50/50 close 0.01, 0.02, 0.01, 0.01, 0.01, 0.01: the count's 0.07 ends
/// A 0.04 / B 0.03 (0.035 each, the tie to A). From July C joins, all at
/// 33.33% / 33.33% / 33.34%, closing 1.00 then 0.01 a month to December:
/// the count's 1.05 is exactly 0.349965 / 0.349965 / 0.35007; floors 0.34 /
/// 0.34 / 0.35 leave two cents for the largest remainders (A's and B's
/// 0.009965): 0.35 each. A 0.39, B 0.38, C 0.35.
#[test]
fn a_member_joining_mid_year_starts_a_new_count() {
let mut co = company(Currency::USD, 3);
let two = CloseTo::Members(co.members(&[0, 1], &HALF));
let three = CloseTo::Members(co.members(&[0, 1, 2], &THIRDS));
let mut periods = Vec::new();
for (m, net) in ["0.01", "0.02", "0.01", "0.01", "0.01", "0.01"]
.iter()
.enumerate()
{
periods.push(co.month(m as u32 + 1, net, &two));
}
for (m, net) in ["1.00", "0.01", "0.01", "0.01", "0.01", "0.01"]
.iter()
.enumerate()
{
periods.push(co.month(m as u32 + 7, net, &three));
}
assert_eq!(co.capitals(), decs(&["0.39", "0.38", "0.35"]));
let since: Vec<u64> = periods
.iter()
.map(|&p| co.l.member_close_split(p).unwrap().0)
.collect();
assert_eq!(since, [1, 1, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7]);
// Within each count, every running total is under a cent from exact.
for &p in &periods {
for m in co.l.member_close_split(p).unwrap().1 {
assert!((m.running - m.exact).abs() < dec("0.01"), "{m:?}");
}
}
// Over the year, A is under two cents (one per count) from its exact
// 0.035 + 0.349965 = 0.384965.
assert!((co.capitals()[0] - dec("0.384965")).abs() < dec("0.02"));
}
/// Scenario 6 (a journaled start must be the derived one): with January
/// closed to the members (close 1), February journaled as counting from
/// close 2 is refused and changes nothing; from close 0, refused as
/// malformed; from close 1, closed.
#[test]
fn a_journaled_start_other_than_the_derived_one_is_refused() {
let mut co = company(Currency::USD, 2);
co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF)));
co.earn(d(2, 10), dec("0.01"));
let feb = co.l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
let members = co.members(&[0, 1], &HALF);
let before = co.l.clone();
for since_seq in [2, 0] {
let to = CloseTo::MembersCumulative {
members: members.clone(),
since_seq,
};
assert!(
matches!(
co.l.preview_close_period_to(feb, &to),
Err(LedgerError::InvalidMembers(_))
),
"{since_seq}"
);
assert!(
matches!(
co.l.close_period_to(feb, &to),
Err(LedgerError::InvalidMembers(_))
),
"{since_seq}"
);
assert_eq!(co.l.list_periods(), before.list_periods());
assert_eq!(co.l.next_entry_id(), before.next_entry_id());
}
let to = CloseTo::MembersCumulative {
members,
since_seq: 1,
};
co.l.close_period_to(feb, &to).unwrap();
assert_eq!(co.capitals(), decs(&["0.01", "0.01"]));
}
// ---------------------------------------------------------------------------
// The minor unit (B-3): whole yen, thousandths of a dinar
// ---------------------------------------------------------------------------
/// P3 brief item 6: a yen book closes to its members in whole yen. 101
/// yen at 0.5 / 0.25 / 0.25 is exactly 50.5 / 25.25 / 25.25; floors 50 /
/// 25 / 25 leave one yen for the largest remainder (0.5): 51 / 25 / 25.
/// February's yen: the running 102 is 51 / 25.5 / 25.5, floors 51 / 25 /
/// 25, the tied yen to B: B +1. The run rule split to cents (50.50 …) and
/// the post refused it (FinerThanMinorUnit). The statement splits in whole
/// yen too, its "other" included (P3 review R-10.2): one yen put into
/// other equity in March is 0.5 / 0.25 / 0.25 exactly, so A's.
#[test]
fn a_yen_book_closes_to_members_in_whole_yen() {
let shares = ["0.5", "0.25", "0.25"];
let mut legacy = company(Currency::JPY, 3);
legacy.earn(d(1, 10), dec("101"));
let run = CloseTo::MembersRun(legacy.members(&[0, 1, 2], &shares));
assert!(matches!(
legacy.close(2026, 1, &run).1,
Err(LedgerError::FinerThanMinorUnit { .. })
));
let mut co = company(Currency::JPY, 3);
let to = CloseTo::Members(co.members(&[0, 1, 2], &shares));
co.earn(d(1, 10), dec("101"));
assert_eq!(co.statement(&shares, d(1, 31)), decs(&["51", "25", "25"]));
let (jan, r) = co.close(2026, 1, &to);
r.unwrap();
let entry = co.l.get_period(jan).unwrap().closing_entry.unwrap();
assert_eq!(
co.lines(entry),
[
line("4000", Side::Debit, "101"),
line("3000", Side::Credit, "51"),
line("3001", Side::Credit, "25"),
line("3002", Side::Credit, "25"),
]
);
co.earn(d(2, 10), dec("1"));
assert_eq!(co.statement(&shares, d(2, 28)), decs(&["51", "26", "25"]));
co.close(2026, 2, &to).1.unwrap();
assert_eq!(co.capitals(), decs(&["51", "26", "25"]));
assert_eq!(co.statement(&shares, d(2, 28)), decs(&["51", "26", "25"]));
// One yen into other equity: unexplained by flows or income, so
// "other", split in whole yen.
co.post(d(3, 5), co.cash, co.other_equity, dec("1"));
let st = members_equity(&co.l, d(3, 1), d(3, 31), &co.member_capitals(&shares)).unwrap();
let other: Vec<_> = st.members.iter().map(|m| m.other).collect();
assert_eq!(other, decs(&["1", "0", "0"]));
}
/// A dinar book splits to thousandths: 0.101 at 50/50 is 0.0505 each, the
/// tied thousandth to A (0.051 / 0.050); February's 0.001 makes the
/// running 0.102, 0.051 each: B +0.001.
#[test]
fn a_dinar_book_closes_to_members_in_thousandths() {
let kwd = Currency::new("KWD").unwrap();
let mut co = company(kwd, 2);
let to = CloseTo::Members(co.members(&[0, 1], &HALF));
co.month(1, "0.101", &to);
assert_eq!(co.capitals(), decs(&["0.051", "0.050"]));
co.month(2, "0.001", &to);
assert_eq!(co.capitals(), decs(&["0.051", "0.051"]));
}
/// A currency ISO 4217 gives no minor unit (gold, XAU) splits to the finest
/// amount a line may carry (kernel::MAX_LINE_SCALE places), so every amount
/// is a whole number of the unit it splits to and no member is ever one
/// from exact (B-13's precondition). 0.019 at 33.33% / 33.33% / 33.34% is
/// exactly 0.0063327 / 0.0063327 / 0.0063346: each member takes theirs.
/// Split to cents instead, the floors (0.00 each) left 0.019, a cent and
/// 0.009 of one, all of it to the largest remainder: C 0.019, A and B
/// nothing, C 1.27 cents from exact.
#[test]
fn a_gold_book_splits_to_the_finest_unit_a_line_carries() {
let xau = Currency::new("XAU").unwrap();
assert_eq!(xau.minor_units(), None);
assert_eq!(xau.allocation_places(), ledger_core::kernel::MAX_LINE_SCALE);
let mut co = company(xau, 3);
let to = CloseTo::Members(co.members(&[0, 1, 2], &THIRDS));
co.earn(d(1, 10), dec("0.019"));
let exact = decs(&["0.0063327", "0.0063327", "0.0063346"]);
assert_eq!(co.statement(&THIRDS, d(1, 31)), exact);
co.close(2026, 1, &to).1.unwrap();
assert_eq!(co.capitals(), exact);
assert_eq!(co.statement(&THIRDS, d(1, 31)), exact);
}
/// K-1 INVARIANT 4 at the minor unit: property contributed jointly in a
/// yen book is shared in whole yen. Dr Equipment 101, Dr Cash 1 / Cr A's
/// capital 51, Cr B's 51: the 101 of property is 50.5 each by their
/// credits; floors 50 / 50 leave a yen for the tie, the member listed
/// first: A 51 property and no cash, B 50 property and 1 cash. Split to
/// cents it was 50.50 each: half a yen of property, which no yen line can
/// hold.
#[test]
fn a_yen_books_k1_shares_property_in_whole_yen() {
let mut co = company(Currency::JPY, 2);
let equipment =
co.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let e = co.l.create_entry(d(3, 1), "contributed together");
let yen = |n: &str| Money::new(dec(n), Currency::JPY);
co.l.add_line(e, equipment, Side::Debit, yen("101"))
.unwrap();
co.l.add_line(e, co.cash, Side::Debit, yen("1")).unwrap();
co.l.add_line(e, co.caps[0], Side::Credit, yen("51"))
.unwrap();
co.l.add_line(e, co.caps[1], Side::Credit, yen("51"))
.unwrap();
co.l.post_entry(e).unwrap();
let members = co.member_capitals(&HALF);
let rows =
ledger_core::k1::k1_worksheet(&co.l, d(1, 1), d(12, 31), &members, &[co.cash]).unwrap();
let split: Vec<_> = rows
.iter()
.map(|r| (r.contributions_property, r.contributions_cash))
.collect();
assert_eq!(split, [(dec("51"), dec("0")), (dec("50"), dec("1"))]);
}
// ---------------------------------------------------------------------------
// The statement of members' equity (D-38; P3 review R-1)
// ---------------------------------------------------------------------------
/// Each member's whole statement column (beginning, contributions, income,
/// distributions, other, ending) for `start..=end`, the members listed in
/// `order` (indexes into `caps`), keyed back to that index.
fn columns(co: &Co, order: &[usize], start: NaiveDate, end: NaiveDate) -> Vec<[Decimal; 6]> {
let all = co.member_capitals(&HALF);
let listed: Vec<MemberCapital> = order.iter().map(|&i| all[i].clone()).collect();
let st = members_equity(&co.l, start, end, &listed).unwrap();
let mut out = vec![[Decimal::ZERO; 6]; order.len()];
for (&i, m) in order.iter().zip(&st.members) {
out[i] = [
m.beginning,
m.contributions,
m.net_income,
m.distributions,
m.other,
m.ending,
];
}
out
}
/// Regime::allocate's promise, on days before a count's first close too: a
/// reorder of the members in the settings moves no cent. January's cent,
/// earned on the 10th, closes to [A, B] (A's, the tie). As of Jan 15 no
/// close counts yet, but the cent will be closed into the count January's
/// close begins, so its order breaks the tie: A's whether the settings list
/// [A, B] or [B, A], as on Jan 31. In February a cent goes into other
/// equity (unexplained: "other"); its split follows the count's order too,
/// so neither a member's other nor their income moves with the listing.
#[test]
fn a_reorder_moves_no_cent_before_the_counts_first_close() {
let mut co = company(Currency::USD, 2);
co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF)));
co.post(d(2, 5), co.cash, co.other_equity, dec("0.01"));
for (start, end) in [
(d(1, 1), d(1, 15)),
(d(1, 1), d(1, 31)),
(d(2, 1), d(2, 28)),
] {
assert_eq!(
columns(&co, &[1, 0], start, end),
columns(&co, &[0, 1], start, end),
"{start} to {end}"
);
}
assert_eq!(co.statement(&HALF, d(1, 15)), decs(&["0.01", "0.00"]));
}
/// D-38 (P3 brief R3): a member's capital on a date doesn't change when
/// its period is closed. 50/50; January's cent closes to A. February
/// earns a cent, not yet closed: as of Feb 28 each member's share of it is
/// what February's close will give, allocate(0.02) − allocate(0.01) = 0.00
/// / 0.01, so A 0.01, B 0.01 (the split of February's cent alone gave A
/// 0.02 / B 0.00, and the close then moved it to 0.01 / 0.01). After the
/// close: the same.
#[test]
fn a_members_capital_on_a_date_does_not_change_when_its_period_closes() {
let mut co = company(Currency::USD, 2);
let to = CloseTo::Members(co.members(&[0, 1], &HALF));
co.month(1, "0.01", &to);
co.earn(d(2, 10), dec("0.01"));
assert_eq!(co.statement(&HALF, d(2, 28)), decs(&["0.01", "0.01"]));
co.close(2026, 2, &to).1.unwrap();
assert_eq!(co.capitals(), decs(&["0.01", "0.01"]));
assert_eq!(co.statement(&HALF, d(2, 28)), decs(&["0.01", "0.01"]));
}
/// P3 review R-1: a true-up of rounding by closes made under an earlier
/// rule shows on the statement only once a close posts it. After three
/// per-close cents (A 0.03, B 0.00, nothing undistributed), the statement
/// as of Mar 31 is the capital accounts: A 0.03, B 0.00. April's cent,
/// not yet closed: allocate(0.04) − allocate(0.03) = 0.02 − 0.02 / 0.02 −
/// 0.01, so A 0.03, B 0.01 as of Apr 30. April's close posts Dr 4000 0.01,
/// Dr 3000 0.01, Cr 3001 0.02: as of Apr 30, A 0.02, B 0.02 (moved by
/// the true-up, dated at the close); as of Mar 31, still A 0.03, B 0.00.
#[test]
fn a_pending_true_up_shows_only_once_posted() {
let mut co = company(Currency::USD, 2);
three_per_close_cents(&mut co);
assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.03", "0.00"]));
co.earn(d(4, 10), dec("0.01"));
assert_eq!(co.statement(&HALF, d(4, 30)), decs(&["0.03", "0.01"]));
co.close(2026, 4, &CloseTo::Members(co.members(&[0, 1], &HALF)))
.1
.unwrap();
assert_eq!(co.statement(&HALF, d(4, 30)), decs(&["0.02", "0.02"]));
assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.03", "0.00"]));
}
/// Three members at 33.33% / 33.33% / 33.34% (a third each can't be
/// written as decimal shares summing to 1; written to 28 places, the
/// product with an amount can't be computed exactly and the close is
/// refused, P3 review R-5), twelve months each closed to them: 100.00,
/// 0.01, 0.02, 33.33, −0.05, 0.10, 1.00, 0.07, 0.01, 12.34, −5.00, 0.02.
///
/// By hand: January 33.33 / 33.33 / 33.34 (exact). February's running
/// 100.01 is 33.333333 / 33.333333 / 33.343334; floors 33.33 / 33.33 /
/// 33.34 leave a cent for C's remainder (.003334): C +0.01. March's
/// 100.03 is 33.339999 / 33.339999 / 33.350002; floors 33.33 / 33.33 /
/// 33.35 leave two cents for A's and B's .009999: A +0.01, B +0.01, C
/// 0.00. December's running 141.85 is 47.278605 / 47.278605 / 47.29279;
/// floors 47.27 / 47.27 / 47.29 leave two cents for A and B: 47.28 /
/// 47.28 / 47.29.
///
/// D-38 on every day: the statement as of each day of the year, the
/// books closed month by month, equals the same statement for the same
/// entries never closed (everything still undistributed); and the K-1
/// worksheet ends each month where the statement does.
#[test]
fn three_members_at_a_third_each_over_twelve_months() {
let nets = [
"100.00", "0.01", "0.02", "33.33", "-0.05", "0.10", "1.00", "0.07", "0.01", "12.34",
"-5.00", "0.02",
];
let mut co = company(Currency::USD, 3);
let mut open = company(Currency::USD, 3);
let to = CloseTo::Members(co.members(&[0, 1, 2], &THIRDS));
let mut closed = Decimal::ZERO;
let mut parts = Vec::new();
for (i, net) in nets.iter().enumerate() {
let m = i as u32 + 1;
open.earn(d(m, 10), dec(net));
let before = co.capitals();
let p = co.month(m, net, &to);
closed += dec(net);
let after = co.capitals();
let part: Vec<Decimal> = after.iter().zip(&before).map(|(a, b)| a - b).collect();
assert_eq!(part.iter().sum::<Decimal>(), dec(net), "month {m}");
for (cap, share) in after.iter().zip(THIRDS) {
assert!((cap - closed * dec(share)).abs() < dec("0.01"), "month {m}");
}
assert_eq!(co.l.member_close_split(p).unwrap().0, 1);
parts.push(part);
}
assert_eq!(parts[0], decs(&["33.33", "33.33", "33.34"]));
assert_eq!(parts[1], decs(&["0.00", "0.00", "0.01"]));
assert_eq!(parts[2], decs(&["0.01", "0.01", "0.00"]));
assert_eq!(co.capitals(), decs(&["47.28", "47.28", "47.29"]));
let members = co.member_capitals(&THIRDS);
let mut date = d(1, 1);
while date <= d(12, 31) {
assert_eq!(
co.statement(&THIRDS, date),
open.statement(&THIRDS, date),
"{date}"
);
if date.succ_opt().unwrap().day() == 1 {
let rows =
ledger_core::k1::k1_worksheet(&co.l, d(1, 1), date, &members, &[co.cash]).unwrap();
let k1: Vec<Decimal> = rows.iter().map(|r| r.ending).collect();
assert_eq!(k1, co.statement(&THIRDS, date), "{date}");
}
date = date.succ_opt().unwrap();
}
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
/// How a close of the property below is made.
#[derive(Clone, Copy, Debug)]
enum Close {
/// The cumulative rule, the members in their order.
Members,
/// The cumulative rule, the members in reverse order.
Reversed,
/// To retained earnings.
Retained,
/// Not closed this month.
Open,
}
fn close_kind() -> impl Strategy<Value = Close> {
prop_oneof![
Just(Close::Members),
Just(Close::Reversed),
Just(Close::Retained),
Just(Close::Open),
]
}
/// A legacy close (replay only): 0 per close, 1 the last absorbs, 2 the
/// run rule.
fn legacy_to(kind: usize, members: Vec<MemberClose>) -> CloseTo {
match kind {
0 => CloseTo::MembersPerClose(members),
1 => CloseTo::MembersLastAbsorbs(members),
_ => CloseTo::MembersRun(members),
}
}
/// Month `i` from January 2026.
fn nth_month(i: usize) -> (i32, u32) {
(2026 + (i / 12) as i32, (i % 12) as u32 + 1)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
/// Oracle: rules.md B-13 and close INVARIANT 6, hand-stated: after
/// any history (closes under the legacy rules, then months closed to
/// the members in either order, to retained earnings, or left open),
/// every members close hands out exactly its net; once a cumulative
/// close has posted, each member's capital is under a cent from their
/// share of everything closed to the members; a close that posts
/// nothing changes no capital (P3 review R-3); and member_close_split
/// agrees with the balances. Fails on the run rule (the audit's
/// pattern: a close to retained earnings restarts it).
#[test]
fn members_capital_never_drifts_a_cent_from_their_share(
thirds in any::<bool>(),
legacy in prop::collection::vec((0usize..3, -500_000i64..500_000), 0..4),
months in prop::collection::vec((close_kind(), -500_000i64..500_000), 1..13),
) {
let shares: &[&str] = if thirds { &THIRDS } else { &HALF };
let n = shares.len();
let mut co = company(Currency::USD, n);
let order: Vec<usize> = (0..n).collect();
let reversed: Vec<usize> = (0..n).rev().collect();
// Everything closed to the members (any rule), and the close the
// count started at.
let mut closed = Decimal::ZERO;
let mut first: Option<u64> = None;
let mut month = 0;
for (kind, net) in legacy {
let (y, m) = nth_month(month);
month += 1;
co.earn(day(y, m, 10), Decimal::new(net, 2));
let to = legacy_to(kind, co.members(&order, shares));
let (p, r) = co.close(y, m, &to);
r.unwrap();
closed += Decimal::new(net, 2);
first = first.or(co.l.get_period(p).unwrap().close_seq);
}
let mut pending = Decimal::ZERO;
let mut posted = false;
for (kind, net) in months {
let (y, m) = nth_month(month);
month += 1;
co.earn(day(y, m, 10), Decimal::new(net, 2));
pending += Decimal::new(net, 2);
let to = match kind {
Close::Open => continue,
Close::Retained => {
co.close(y, m, &CloseTo::RetainedEarnings { sweep: vec![] }).1.unwrap();
pending = Decimal::ZERO;
continue;
}
Close::Members => CloseTo::Members(co.members(&order, shares)),
Close::Reversed => CloseTo::Members(co.members(&reversed, shares)),
};
let before = co.capitals();
let (p, r) = co.close(y, m, &to);
let entry = r.unwrap();
let period = co.l.get_period(p).unwrap().clone();
first = first.or(period.close_seq);
closed += pending;
let record = period.members_closed.unwrap();
prop_assert_eq!(record.net, pending);
pending = Decimal::ZERO;
prop_assert_eq!(record.parts.iter().sum::<Decimal>(), record.net);
prop_assert_eq!(record.rule, MemberRule::Cumulative { since_seq: first.unwrap() });
posted |= entry.is_some();
let after = co.capitals();
if entry.is_none() {
// Posting nothing, it credits no one: any true-up waits.
prop_assert_eq!(&after, &before);
}
if !posted {
continue;
}
for (cap, share) in after.iter().zip(shares) {
prop_assert!((cap - closed * dec(share)).abs() < dec("0.01"), "{} vs {}", cap, closed * dec(share));
}
let (since, split) = co.l.member_close_split(p).unwrap();
prop_assert_eq!(since, first.unwrap());
for s in split {
let i = co.caps.iter().position(|&c| c == s.capital).unwrap();
prop_assert_eq!(s.running, after[i]);
prop_assert_eq!(s.exact, closed * s.share);
}
}
prop_assert!(ledger_core::reports::trial_balance(&co.l).balanced());
}
/// Oracle: members' equity INVARIANTS 3a and 3c (D-38): for random
/// books with contributions, distributions, profits and losses, and
/// months closed to the members, to retained earnings or not at all,
/// with the settings listing the members in either order from month to
/// month (the close and the statement list them as the settings do),
/// each member's capital on the statement as of a close's period end
/// is the same before and after the close; and each member's ending on
/// one statement is their beginning on the next, whatever the split
/// date.
#[test]
fn each_members_capital_chains_and_holds_through_closes(
months in prop::collection::vec((0usize..3, 0i64..50_000, 0i64..20_000, 0i64..100_000, 0i64..80_000), 1..12),
kinds in prop::collection::vec(close_kind(), 12),
split in 1u32..330,
) {
let mut co = company(Currency::USD, 3);
let cents = |n: i64| Decimal::new(n, 2);
let order = [0, 1, 2];
let reversed = [2, 1, 0];
for (i, (who, contrib, dist, sales, costs)) in months.iter().enumerate() {
let m = i as u32 + 1;
let cap = co.caps[*who];
if *contrib > 0 {
co.post(d(m, 3), co.cash, cap, cents(*contrib));
}
co.earn(d(m, 10), cents(*sales));
co.earn(d(m, 15), -cents(*costs));
if *dist > 0 {
co.post(d(m, 20), co.dists[*who], co.cash, cents(*dist));
}
let with_dists = |co: &Co, order: &[usize]| -> Vec<MemberClose> {
let mut ms = co.members(order, &THIRDS);
for (m, &i) in ms.iter_mut().zip(order) {
m.distributions = Some(co.dists[i]);
}
ms
};
let listed: &[usize] = match kinds[i] {
Close::Reversed => &reversed,
_ => &order,
};
let to = match kinds[i] {
Close::Open => continue,
Close::Retained => CloseTo::RetainedEarnings { sweep: vec![] },
Close::Members | Close::Reversed => CloseTo::Members(with_dists(&co, listed)),
};
let end = month_end(2026, m);
let before = co.statement_listing(listed, &THIRDS, end);
co.close(2026, m, &to).1.unwrap();
prop_assert_eq!(co.statement_listing(listed, &THIRDS, end), before, "month {}", m);
}
let members = co.member_capitals(&THIRDS);
let mid = d(1, 1) + chrono::Days::new(u64::from(split));
let first = members_equity(&co.l, d(1, 1), mid, &members).unwrap();
let second = members_equity(&co.l, mid.succ_opt().unwrap(), d(12, 31), &members).unwrap();
let whole = members_equity(&co.l, d(1, 1), d(12, 31), &members).unwrap();
for i in 0..3 {
prop_assert_eq!(first.members[i].ending, second.members[i].beginning);
prop_assert_eq!(second.members[i].ending, whole.members[i].ending);
}
for st in [&first, &second, &whole] {
let incomes: Decimal = st.members.iter().map(|m| m.net_income).sum();
prop_assert_eq!(incomes, st.total.net_income);
}
}
/// Oracle: members_regime's doc (P3 review R-10.6): whatever order
/// nested periods (months, quarters, the year) are tried in, the
/// periods that close end strictly later, close after close, so "the
/// closes dated on or before a day" is always the first closes in
/// order.
#[test]
fn period_ends_rise_with_close_order(
attempts in Just((0usize..17).collect::<Vec<_>>()).prop_shuffle(),
) {
let mut co = company(Currency::USD, 2);
let mut periods = Vec::new();
for m in 1..=12 {
periods.push(co.l.create_period(&format!("m{m}"), d(m, 1), month_end(2026, m)).unwrap());
}
for q in 0..4u32 {
periods.push(co.l.create_period(&format!("q{q}"), d(3 * q + 1, 1), month_end(2026, 3 * q + 3)).unwrap());
}
periods.push(co.l.create_period("year", d(1, 1), d(12, 31)).unwrap());
for m in 1..=12 {
co.earn(d(m, 10), dec("0.01"));
}
let to = CloseTo::Members(co.members(&[0, 1], &HALF));
for i in attempts {
let _ = co.l.close_period_to(periods[i], &to);
}
let mut closed: Vec<_> = co.l.list_periods().into_iter().filter(|p| p.close_seq.is_some()).collect();
closed.sort_by_key(|p| p.close_seq);
for w in closed.windows(2) {
prop_assert!(w[0].end < w[1].end, "{} then {}", w[0].name, w[1].name);
}
}
}