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
220 lines
7.9 KiB
Rust
220 lines
7.9 KiB
Rust
//! 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))]
|
|
);
|
|
}
|