P3: party totals by what moved the money, once (defects 14, 11, 24)

Each party's part of an entry is signed as its own account would show it,
with a direction and its cash and in-kind amounts; a reversal is its
original negated. Totals on a cash or all basis: for a member, all equals
the members' equity statement and cash the K-1's cash. Money is cash,
cards and the financing accounts, and a liability credit never pays its
own creditor (creditor_of), so accruals and declared distributions count
once. tin_last4 from the TIN or W-9 facts.

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-21 12:12:26 -06:00
1 parent 9ab003d0d1
commit c2ceabbae9
52 files changed
+7535 -560

No files matched your search

+8 -9
View File
@@ -172,17 +172,16 @@ fn the_last_months_of_a_span() {
fn a_partys_part_of_a_split_entry_is_its_fraction_of_what_was_bought() {
let f = fixture("what-for");
let e = f.books.ledger.get_entry(f.entry).unwrap();
let money = money_accounts(&f.books.ledger, &[f.cash]);
let money = money_accounts(&f.books.ledger, &[f.cash], &[]);
let flow = entry_flow(e, &money).unwrap();
let dec = |c: i64| Decimal::new(c, 2);
assert_eq!(
f.books.party_what_for(e, f.sam, &flow),
vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))]
);
assert_eq!(
f.books.party_what_for(e, f.riley, &flow),
vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))]
);
let sam = f.books.party_what_for(e, f.sam);
let riley = f.books.party_what_for(e, f.riley);
assert_eq!(sam, vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))]);
assert_eq!(riley, vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))]);
// Together, the entry's whole breakdown.
let total: Decimal = sam.iter().chain(&riley).map(|(_, v)| *v).sum();
assert_eq!(total, flow.net);
}
/// share_of_what_for doc: the whole what-for for the whole net; nothing
+267 -11
View File
@@ -8,7 +8,7 @@ use egui_kittest::Harness;
use ledger_core::arap::DocumentLine;
use ledger_core::{
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report,
TinFacts, TinType, W4Facts, W9Facts,
};
use ledger_gui::books::Books;
use ledger_gui::nav::{Route, Section};
@@ -258,6 +258,9 @@ fn fixture(tag: &str) -> Fixture {
let e = pay(&mut s, d(2026, 1, 20), "Old Co", cash, 12_300);
s.set_entry_party(e, Some(old)).unwrap();
s.update_party(old, PartyField::Archived(true)).unwrap();
// What the members advanced is money (rules.md B-35 item 5): Due to
// Shareholders is a financing account.
journal_settings(&mut s, "[books]\nfinancing_accounts = [\"2100\"]\n");
drop(s);
let oracle = Store::open(&data).unwrap().ledger().clone();
Fixture {
@@ -297,8 +300,20 @@ fn each_partys_totals_are_the_books_numbers() {
assert_eq!(sam.open_bills, bill.balance().ok());
assert_eq!(sam.open_bills, Some(cents(30_000)));
assert_eq!(sam.bills, vec![1]);
// The 1099 amount is ledger-core's worksheet line, exactly.
let r = vendor_1099_report(o, 2026, None).unwrap();
// The 1099 amount is ledger-core's worksheet line, exactly, over the
// viewer's payment accounts (every asset account, no cash accounts
// being set; the financing account).
let owed = o.chart().find_by_code("2100").unwrap().id;
let r = ledger_core::vendor_1099_report_with(
o,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![],
financing: vec![owed],
},
)
.unwrap();
let line = r
.vendors
.iter()
@@ -306,8 +321,9 @@ fn each_partys_totals_are_the_books_numbers() {
.unwrap();
assert_eq!(sam.form_1099.as_ref(), Some(line));
assert_eq!(sam.threshold_1099, Some(r.threshold));
// (Cash only: card and owner-paid aren't in it.)
assert_eq!(line.paid, cents(150_000));
// (Cash and what the owner paid for the company: the card isn't in
// it.)
assert_eq!(line.paid, cents(180_000));
// Every entry tagged, drafts included, newest first.
assert!(sam.entries.contains(&f.bill_entry));
assert!(sam.entries.contains(&f.reversal));
@@ -561,6 +577,57 @@ fn journal_text_for_party_events() {
}
}
/// party.creditor_set and the journal feature it declares read as words
/// in the journal, and the party's page lists what it is owed on.
#[test]
fn journal_text_for_a_creditor() {
use egui_kittest::kittest::Queryable;
let f = fixture("creditor");
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
let bank = s
.add_party("Harbor Bank", &[Role::FinancialInstitution])
.unwrap();
let card = s.ledger().chart().find_by_code("2050").unwrap().id;
s.set_party_creditor(bank, card, true).unwrap();
s.set_party_creditor(bank, card, false).unwrap();
s.set_party_creditor(bank, card, true).unwrap();
drop(s);
let b = load(&f.dir.0);
let texts: Vec<(String, Option<Route>)> = b
.journal
.iter()
.filter(|l| l.line_type == "party.creditor_set" || l.line_type == "journal.format_set")
.filter_map(|l| describe(&b, l))
.map(|e| (e.text, e.route))
.collect();
assert!(
texts.contains(&(
"Harbor Bank is now owed on Business Card".to_string(),
Some(Route::Party(bank))
)),
"{texts:#?}"
);
assert!(
texts.contains(&(
"Harbor Bank is no longer owed on Business Card".to_string(),
Some(Route::Party(bank))
)),
"{texts:#?}"
);
assert!(
texts
.iter()
.any(|(t, _)| t == "Journal format 2 declared, with party_creditors"),
"{texts:#?}"
);
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 1400.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(bank)));
h.run_steps(3);
h.get_by_label("Owed on");
h.get_by_label("Business Card");
}
#[test]
fn w9_text_never_shows_more_than_the_last_four() {
let f = fixture("w9text");
@@ -787,7 +854,7 @@ fn party_money_by_period_and_what_for() {
for period in b.party_periods(Some(p.id)) {
let span = b.party_span(period);
let fl = b.party_flow(p.id, span, true);
let a = ledger_core::parties::activity(o, p.id, span, &b.cash_accounts);
let a = ledger_core::parties::activity(o, p.id, span, &b.money);
assert_eq!((fl.paid, fl.received), (a.paid, a.received));
assert_eq!(
fl.what_for.iter().map(|x| x.1).sum::<Decimal>(),
@@ -824,7 +891,7 @@ fn party_money_by_period_and_what_for() {
let (line, _) = b.party_1099(f.sam, 2025).unwrap();
assert_eq!(line.paid, cents(50_000));
let (line, _) = b.party_1099(f.sam, 2026).unwrap();
assert_eq!(line.paid, cents(150_000));
assert_eq!(line.paid, cents(180_000));
}
#[test]
@@ -1156,9 +1223,19 @@ fn entries_list_every_party_with_its_resolved_share() {
vec![(m.tobin, Some(cents(5_000))), (m.mo, Some(cents(5_000)))]
);
assert_eq!(e.party.as_deref(), Some("Tobin Harlow & Morgan Ellis"));
// Property: net zero, shares of the total.
// Property (rules.md B-35): what moved each member's capital, signed
// as their own account shows it: received, in kind (before P3 it read
// +600.00, the sign of a payment).
let e = b.entry(m.property).unwrap();
assert_eq!(e.parties[1].resolved, Some(cents(60_000)));
assert_eq!(e.parties[1].resolved, Some(cents(-60_000)));
assert_eq!(
e.parties[1].direction,
Some(ledger_core::shares::Direction::Received)
);
assert_eq!(
(e.parties[1].cash, e.parties[1].in_kind),
(Some(Decimal::ZERO), Some(cents(-60_000)))
);
// The journal says who, for parties_set.
let line = b
.journal
@@ -1257,6 +1334,185 @@ fn a_members_page_is_their_capital_statement() {
h.get_by_label("Contributions of property (at fair market value)");
}
/// member_books with the laptop's contribution reversed and made again
/// (after the closed first half), so the pages have a reversal to say:
/// the books and the reversal's id.
fn reversed_laptop(tag: &str) -> (MemberBooks, EntryId) {
let m = member_books(tag);
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
let undo = s
.reverse_entry(m.property, d(2026, 7, 2), "Reversed")
.unwrap();
drop(s);
let again = post_more(
&m,
d(2026, 7, 3),
&[
("1500", Side::Debit, 120_000),
("3000", Side::Credit, 60_000),
("3001", Side::Credit, 60_000),
],
);
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
s.set_entry_parties(
again,
&[
ledger_core::PartyShare::whole(m.tobin),
ledger_core::PartyShare::whole(m.mo),
],
)
.unwrap();
drop(s);
(m, undo)
}
/// A harness showing `route` over the books in `dir`.
fn show(dir: &Path, route: Route) -> Harness<'static, App> {
let b = load(dir);
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 1400.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, route));
h.run_steps(3);
h
}
/// The viewer shows the tools' numbers (rules.md B-35): a member's party
/// flow is books.party's activity on both bases, labelled cash basis, with
/// the in-kind line under it; the entry page says each part in words, a
/// property contribution "received $600.00, in kind" and its reversal
/// "paid $600.00, in kind (reversal of #n)".
#[test]
fn the_party_page_shows_the_basis_and_in_kind() {
use egui_kittest::kittest::Queryable;
let (m, undo) = reversed_laptop("basis");
let b = load(&m.dir.0);
let span = b.party_span(ledger_gui::books::PartyPeriod::ThisYear);
for who in [m.tobin, m.mo] {
let flow = b.party_flow(who, span, true);
let server =
ledger_core::parties::activity_with(&b.ledger, who, span, &b.money, &b.members);
assert_eq!(
(flow.paid, flow.received),
server.totals(ledger_core::parties::Basis::Cash)
);
assert_eq!(
(
flow.paid + flow.paid_in_kind,
flow.received + flow.received_in_kind
),
server.totals(ledger_core::parties::Basis::All)
);
// Cash: half the reimbursement and their distribution; in kind:
// the laptop (600.00 − 600.00 + 600.00).
assert_eq!((flow.paid, flow.received), (cents(25_000), Decimal::ZERO));
assert_eq!(
(flow.paid_in_kind, flow.received_in_kind),
(Decimal::ZERO, cents(60_000))
);
assert_eq!(
flow.what_for.iter().map(|x| x.1).sum::<Decimal>(),
flow.net()
);
// What for is what the money paid for: the laptop, contributed in
// kind, reversed and contributed again, isn't in it (it read
// "Equipment 600.00" and "+400.00" of capital).
let equip = b.ledger.chart().find_by_code("1500").unwrap().id;
let due = b.ledger.chart().find_by_code("2100").unwrap().id;
let capital = b.member(who).unwrap().capital;
assert_eq!(
flow.what_for,
vec![(capital, cents(20_000)), (due, cents(5_000))],
"no {equip} line"
);
}
let flow = b.party_flow(m.tobin, span, true);
assert_eq!(
ledger_gui::pages::parties::in_kind_text(&flow).as_deref(),
Some("With property contributed or distributed: paid $250.00, received $600.00")
);
let h = show(&m.dir.0, Route::Party(m.tobin));
h.get_by_label("With property contributed or distributed: paid $250.00, received $600.00");
assert!(h.query_all_by_label_contains("cash basis").next().is_some());
let h = show(&m.dir.0, Route::Entry(m.property));
h.get_by_label_contains("Tobin Harlow · received $600.00, in kind (derived)");
let h = show(&m.dir.0, Route::Entry(undo));
h.get_by_label_contains(&format!(
"Morgan Ellis · paid $600.00, in kind (derived, reversal of #{})",
m.property
));
}
/// The viewer's what-for reads a reversal as parties::flow does, the
/// opposite of its original (rules.md B-35 item 3; design §1.4): a member
/// paying the card from their own funds (Dr card / Cr 2100), tagged with
/// the issuer, then reversed, is nothing on the issuer's page, paid or
/// what for. Its lines alone read as a second payment.
#[test]
fn a_reversed_member_card_payment_is_nothing_on_the_issuers_page() {
let m = member_books("card-reversed");
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
s.add_account("2050", "Business Card", AccountType::Liability, None, false)
.unwrap();
let issuer = s
.add_party("Harbor Bank", &[Role::FinancialInstitution])
.unwrap();
drop(s);
member_settings(
&m.dir.0,
"cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n",
);
let paid = post_more(
&m,
d(2026, 7, 10),
&[
("2050", Side::Debit, 10_000),
("2100", Side::Credit, 10_000),
],
);
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
s.set_entry_party(paid, Some(issuer)).unwrap();
drop(s);
let b = load(&m.dir.0);
let span = (d(2026, 1, 1), d(2026, 12, 31));
let flow = b.party_flow(issuer, span, true);
let card = b.ledger.chart().find_by_code("2050").unwrap().id;
assert_eq!((flow.paid, flow.received), (cents(10_000), Decimal::ZERO));
assert_eq!(flow.what_for, vec![(card, cents(10_000))]);
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
s.reverse_entry(paid, d(2026, 7, 11), "Reversed").unwrap();
drop(s);
let b = load(&m.dir.0);
let flow = b.party_flow(issuer, span, true);
assert_eq!((flow.paid, flow.received), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(flow.what_for, vec![], "the breakdown nets to nothing");
}
/// Screenshots of the party pages and the entry pages B-35 changed, for
/// eyes: set `SHOT_DIR`.
#[test]
fn party_basis_screenshots() {
let Ok(out) = std::env::var("SHOT_DIR") else {
return;
};
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
let (m, undo) = reversed_laptop("basis-shots");
for (name, route) in [
("party-member", Route::Party(m.tobin)),
("parties", Route::Parties),
("entry-property", Route::Entry(m.property)),
("entry-reversal", Route::Entry(undo)),
("entry-reimbursement", Route::Entry(m.reimburse)),
] {
let mut h = show(&m.dir.0, route);
h.run_steps(3);
h.render()
.unwrap()
.save(out.join(format!("{name}.png")))
.unwrap();
}
}
/// The viewer's statement is the K-1 worksheet's row, field for field,
/// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called
/// here with the members and cash accounts as the CPA package builds them.
@@ -1286,7 +1542,7 @@ fn the_capital_statement_is_the_k1_worksheet_row() {
}
});
let cash: Vec<_> = if fallback {
let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[])
let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[], &[])
.cash
.into_iter()
.collect();
@@ -1521,7 +1777,7 @@ fn party_money_is_the_servers_without_cash_accounts_and_after_today() {
&oracle,
apple,
(d(2026, 1, 1), d(2026, 12, 31)),
&[],
&ledger_core::parties::money_accounts(&oracle, &[], &[]),
&[],
);
assert_eq!(