P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Members' shares of earnings are split cumulatively by largest remainder in the closes, the members' equity statement, the K-1 worksheet and the viewer, so a member's capital on a given day never moves by a cent when its month is later closed. Old closes replay as they were; the first P3 close carries any true-up. Feature members_cumulative. Kani and Lean proofs of the bounded drift; bookspec examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
44 files changed
+6636
-195
No files matched your search
@@ -405,11 +405,13 @@ pub struct CapitalLine {
|
||||
/// is empty, the liability accounts whose name says "due to" or
|
||||
/// "owed to" (DECISION: a name is a guess; the setting is the books'
|
||||
/// word). owed_to_member: this member's part of it by the members'
|
||||
/// shares (ledger_core::periods::allocate over every member, so the
|
||||
/// parts sum to the balance).
|
||||
/// shares, to the base currency's minor unit (ledger_core::periods::
|
||||
/// allocate_cumulative over every member with nothing given, ties to
|
||||
/// the member listed first, so the parts sum to the balance).
|
||||
/// 6. None when the party isn't a member, a [[company.members]]
|
||||
/// account isn't in the chart, or k1_detail refuses (shares not
|
||||
/// summing to 1, accounts not distinct equity accounts).
|
||||
/// summing to 1, accounts not distinct equity accounts), or the
|
||||
/// split of what is owed can't be computed exactly (TooLargeToCompute).
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CapitalStatement {
|
||||
@@ -1746,7 +1748,13 @@ impl Books {
|
||||
.map(|&a| self.balance_on(a, span.1))
|
||||
.sum();
|
||||
let shares: Vec<Decimal> = members.iter().map(|x| x.share).collect();
|
||||
let owed_to_member = ledger_core::periods::allocate(due_to_members, &shares)[i];
|
||||
let owed_to_member = ledger_core::periods::allocate_cumulative(
|
||||
due_to_members,
|
||||
&shares,
|
||||
&vec![Decimal::ZERO; shares.len()],
|
||||
l.base_currency().allocation_places(),
|
||||
)
|
||||
.ok()?[i];
|
||||
Some(CapitalStatement {
|
||||
party,
|
||||
capital: m.capital,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! The odd cent in the viewer (P3 defect 17, D-38): a member's page shows
|
||||
//! their capital the way the closes split earnings, so the figure for a
|
||||
//! day never moves by a cent when its month is later closed.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, CloseTo, Currency, MemberClose, Money, Role, Side};
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::{Source, data};
|
||||
use ledger_store::Store;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("ledger-gui-odd-cent-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TempDir(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
d(10, 15)
|
||||
}
|
||||
|
||||
fn load(dir: &Path) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.to_path_buf(),
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Each member's closing capital on their page for the spans from Jan 1
|
||||
/// to every day through `last`.
|
||||
fn every_day(b: &Books, members: &[ledger_core::PartyId], last: NaiveDate) -> Vec<Vec<Decimal>> {
|
||||
let mut out = Vec::new();
|
||||
let mut day = d(1, 1);
|
||||
while day <= last {
|
||||
out.push(
|
||||
members
|
||||
.iter()
|
||||
.map(|&m| b.capital_statement(m, (d(1, 1), day)).unwrap().closing)
|
||||
.collect(),
|
||||
);
|
||||
day = day.succ_opt().unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// D-38: two members, Ann and Bo, at 50% each; January to June each earn
|
||||
/// a cent, each month closed in turn. The cumulative split, by hand: the
|
||||
/// running 0.01, 0.02, …, 0.06 splits A 0.01 / B 0.00, 0.01 / 0.01, 0.02
|
||||
/// / 0.01, 0.02 / 0.02, 0.03 / 0.02, 0.03 / 0.03 (each odd cent's tie to
|
||||
/// Ann, listed first). The viewer shows each member's capital for every
|
||||
/// day of the year so far the same before and after each month closes
|
||||
/// (the split of each month's cent alone gave Ann every cent until its
|
||||
/// close moved it), and ends at 0.03 each.
|
||||
#[test]
|
||||
fn a_members_page_shows_the_same_capital_before_and_after_a_close() {
|
||||
let dir = TempDir::new("pages");
|
||||
let books = dir.0.join("books-data");
|
||||
let mut s = Store::create(&books, Currency::USD).unwrap();
|
||||
let mut add = |code: &str, name: &str, t| s.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = add("1000", "Cash", AccountType::Asset);
|
||||
let caps = [
|
||||
add("3000", "Capital Ann", AccountType::Equity),
|
||||
add("3001", "Capital Bo", AccountType::Equity),
|
||||
];
|
||||
let sales = add("4000", "Sales", AccountType::Revenue);
|
||||
let members = [
|
||||
s.add_party("Ann", &[Role::Member]).unwrap(),
|
||||
s.add_party("Bo", &[Role::Member]).unwrap(),
|
||||
];
|
||||
let import = ledger_config::codec::import(
|
||||
"[books]\ncash_accounts = [\"1000\"]\n[company]\nname = \"Example Partners LLC\"\n\
|
||||
[[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
|
||||
None,
|
||||
s.ledger().settings(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
s.adopt_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the test".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let to = CloseTo::Members(
|
||||
caps.iter()
|
||||
.map(|&capital| MemberClose {
|
||||
capital,
|
||||
share: Decimal::new(5, 1),
|
||||
distributions: None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let cent = Money::new(Decimal::new(1, 2), Currency::USD);
|
||||
drop(s);
|
||||
for m in 1..=6 {
|
||||
let mut s = Store::open(&books).unwrap();
|
||||
let e = s.create_entry(d(m, 10), "a cent").unwrap();
|
||||
s.add_line(e, cash, Side::Debit, cent).unwrap();
|
||||
s.add_line(e, sales, Side::Credit, cent).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
drop(s);
|
||||
let end = d(m + 1, 1).pred_opt().unwrap();
|
||||
let before = every_day(&load(&books), &members, end);
|
||||
let mut s = Store::open(&books).unwrap();
|
||||
let p = s
|
||||
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
|
||||
.unwrap();
|
||||
s.close_period_to(p, &to).unwrap();
|
||||
drop(s);
|
||||
let after = every_day(&load(&books), &members, end);
|
||||
assert_eq!(after, before, "month {m}");
|
||||
}
|
||||
let b = load(&books);
|
||||
let closing: Vec<Decimal> = members
|
||||
.iter()
|
||||
.map(|&m| b.capital_statement(m, (d(1, 1), d(6, 30))).unwrap().closing)
|
||||
.collect();
|
||||
assert_eq!(closing, [Decimal::new(3, 2), Decimal::new(3, 2)]);
|
||||
}
|
||||
|
||||
/// A member's page in a yen book splits in whole yen (B-3, B-13), as the
|
||||
/// closes and the statement do. Ann and Bo at 50% each. "Owed to them"
|
||||
/// (CapitalStatement INVARIANT 5): 101 yen due to the members, 50.5 each
|
||||
/// exactly, the tied yen to Ann, listed first: Ann 51, Bo 50. Property
|
||||
/// they contributed together (K-1 INVARIANT 4): Dr Equipment 101, Dr Cash
|
||||
/// 1 / Cr Ann's capital 51, Cr Bo's 51, so 50.5 each of property, the yen
|
||||
/// to Ann: Ann 51 property, Bo 50 property and 1 cash. Split to cents, both
|
||||
/// were 50.50 each: half a yen, which no yen amount holds.
|
||||
#[test]
|
||||
fn a_yen_members_page_splits_in_whole_yen() {
|
||||
let dir = TempDir::new("yen");
|
||||
let books = dir.0.join("books-data");
|
||||
let mut s = Store::create(&books, Currency::JPY).unwrap();
|
||||
let mut add = |code: &str, name: &str, t| s.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = add("1000", "Cash", AccountType::Asset);
|
||||
let equipment = add("1500", "Equipment", AccountType::Asset);
|
||||
let due = add("2100", "Due to members", AccountType::Liability);
|
||||
let caps = [
|
||||
add("3000", "Capital Ann", AccountType::Equity),
|
||||
add("3001", "Capital Bo", AccountType::Equity),
|
||||
];
|
||||
let fees = add("6000", "Fees", AccountType::Expense);
|
||||
let members = [
|
||||
s.add_party("Ann", &[Role::Member]).unwrap(),
|
||||
s.add_party("Bo", &[Role::Member]).unwrap(),
|
||||
];
|
||||
let import = ledger_config::codec::import(
|
||||
"[books]\ncash_accounts = [\"1000\"]\n[company]\nname = \"Example Partners LLC\"\n\
|
||||
[[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
|
||||
None,
|
||||
s.ledger().settings(),
|
||||
"JPY",
|
||||
)
|
||||
.unwrap();
|
||||
s.adopt_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the test".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let yen = |n: i64| Money::new(Decimal::from(n), Currency::JPY);
|
||||
let e = s.create_entry(d(3, 1), "contributed together").unwrap();
|
||||
s.add_line(e, equipment, Side::Debit, yen(101)).unwrap();
|
||||
s.add_line(e, cash, Side::Debit, yen(1)).unwrap();
|
||||
s.add_line(e, caps[0], Side::Credit, yen(51)).unwrap();
|
||||
s.add_line(e, caps[1], Side::Credit, yen(51)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let e = s.create_entry(d(4, 1), "paid for the company").unwrap();
|
||||
s.add_line(e, fees, Side::Debit, yen(101)).unwrap();
|
||||
s.add_line(e, due, Side::Credit, yen(101)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
drop(s);
|
||||
let b = load(&books);
|
||||
let page: Vec<_> = members
|
||||
.iter()
|
||||
.map(|&m| {
|
||||
let st = b.capital_statement(m, (d(1, 1), today())).unwrap();
|
||||
(
|
||||
st.due_to_members,
|
||||
st.owed_to_member,
|
||||
st.contributions_property,
|
||||
st.contributions_cash,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let y = |n: i64| Decimal::from(n);
|
||||
assert_eq!(
|
||||
page,
|
||||
[(y(101), y(51), y(51), y(0)), (y(101), y(50), y(50), y(1))]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user