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

+142 -98
View File
@@ -67,13 +67,16 @@ pub struct EntryInfo {
/// One party an entry names, with its share.
///
/// ```text
/// SOURCE: ledger_core::shares (resolve), with Books::money_cash_accounts
/// and Books::members, as the server's books.get_entry resolves
/// them.
/// SOURCE: ledger_core::shares (resolve), with Books::money and
/// Books::members, as the server's books.get_entry resolves them.
/// INVARIANTS:
/// 1. resolved: the party's part from ledger_core::shares::resolve, None
/// when the entry's shares don't resolve; over an entry's parties the
/// resolved parts sum to what it moved exactly.
/// 1. resolved: the party's part from ledger_core::shares::resolve
/// (signed as a line on its own account: positive paid to it,
/// negative received from it), None when the entry's shares don't
/// resolve; over an entry's parties the resolved parts sum to the
/// target exactly.
/// 2. direction, cash, in_kind: the target's direction and the part's
/// money and property, None exactly when resolved is.
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct EntryParty {
@@ -81,6 +84,9 @@ pub struct EntryParty {
pub label: String,
pub share: ledger_core::Share,
pub resolved: Option<Decimal>,
pub direction: Option<ledger_core::shares::Direction>,
pub cash: Option<Decimal>,
pub in_kind: Option<Decimal>,
}
/// One fixed asset's trail through the journal and the entries.
@@ -133,13 +139,14 @@ impl AssetTrail {
/// year 1099s are counted in, and books.party's year: a posted entry,
/// invoice, or bill dated later this year counts).
/// Money: Books::activity (ledger_core::parties::activity_with with
/// Books::money_cash_accounts and Books::members: a party's share of
/// an entry naming several; the same numbers books.party reports).
/// Books::money and Books::members: a party's share of an entry naming
/// several; the same numbers books.party reports).
/// INVARIANTS:
/// 1. entries: every entry that is the party's, drafts included, in
/// Books::entries order (newest first).
/// 2. paid_ytd = activity's paid − received over the year, exactly.
/// Positive: paid to them, net; negative: received from them, net.
/// 2. paid_ytd = activity's paid − received over the year, exactly, on
/// basis cash (books.party's default). Positive: paid to them, net;
/// negative: received from them, net.
/// 3. invoiced_ytd = Σ Invoice::total() over its customer record's
/// invoices dated in the year; billed_ytd the same for its vendor
/// record's bills.
@@ -242,6 +249,11 @@ pub struct Books {
pub members: Vec<ledger_core::PartyMember>,
/// `[books] financing_accounts`, resolved (empty when not set).
pub financing_accounts: Vec<AccountId>,
/// The money accounts every party number reads
/// (ledger_core::parties::money_accounts over money_cash_accounts and
/// financing_accounts, with the cards), exactly as the server's
/// books.party and books.get_entry read them.
pub money: ledger_core::parties::MoneyAccounts,
/// The cash accounts every number the server also reports reads
/// money with: `[books] cash_accounts`, resolved; when that is empty,
/// core's fallback (ledger_core::parties::money_accounts: every asset
@@ -301,26 +313,26 @@ pub enum PartyPeriod {
/// ```text
/// GOAL: answer "how much did we spend with them, and how much did we
/// get back?" (owner, 2026-10-15) with the books' own numbers.
/// SOURCE: ledger_core::parties::activity_with (paid, received) and
/// ledger_core::parties::entry_flow (what for), with
/// Books::money_cash_accounts and Books::members, as PartyInfo
/// uses them.
/// SOURCE: ledger_core::parties::activity_with (paid, received, in kind)
/// and ledger_core::parties::flow (what for), with Books::money
/// and Books::members, as PartyInfo uses them; rules.md B-35.
/// INVARIANTS:
/// 1. paid, received: Books::activity(party, span)'s, exactly.
/// 1. paid, received: Books::activity(party, span)'s, exactly: basis
/// cash (money), books.party's default. paid_in_kind and
/// received_in_kind: its property given and taken in kind, so basis
/// all is paid + paid_in_kind and received + received_in_kind.
/// 2. net() = paid − received.
/// 3. what_for: over the party's posted entries dated in the span, the
/// sum by account of each entry_flow's what_for (a liability paid
/// down, like a reimbursement, appears as what the money paid for),
/// for an entry naming several parties this party's part of it: a
/// member's own capital and distributions lines when they come to
/// its resolved share exactly, else the what_for apportioned to the
/// share (share_of_what_for; nothing when the shares don't resolve),
/// the accounts whose sum is zero left out, largest magnitude first
/// (then by account id). Positive: what money paid for; negative:
/// where money received came from (or, on payables and
/// receivables, what is still owed).
/// 4. Σ what_for = net(), exactly: each entry_flow's what_for sums to
/// its net (a reversal reverses both).
/// sum by account of each entry's breakdown for the party
/// (Books::party_what_for: parties::flow's what_for, its share of it
/// for an entry naming several, made to sum to the party's cash part
/// of the entry), the accounts whose sum is zero left out, largest
/// magnitude first (then by account id). Positive: what money paid
/// for; negative: where money received came from (or, on payables
/// and receivables, what is still owed).
/// 4. Σ what_for = net(), exactly: each entry's breakdown sums to the
/// party's cash part of it, and activity sums those parts (a
/// reversal's are its original's negated).
/// 5. entries: how many of the party's entries are posted and dated
/// in the span.
/// ```
@@ -328,6 +340,8 @@ pub enum PartyPeriod {
pub struct PartyFlow {
pub paid: Decimal,
pub received: Decimal,
pub paid_in_kind: Decimal,
pub received_in_kind: Decimal,
pub what_for: Vec<(AccountId, Decimal)>,
pub entries: usize,
}
@@ -571,17 +585,22 @@ impl Books {
let money_cash_accounts = {
let configured = codes(&settings.cash_accounts);
if configured.is_empty() {
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[])
.cash
.into_iter()
.collect();
let mut all: Vec<AccountId> =
ledger_core::parties::money_accounts(&ledger, &[], &[])
.cash
.into_iter()
.collect();
all.sort_unstable();
all
} else {
configured
}
};
let share_money = ledger_core::parties::money_accounts(&ledger, &money_cash_accounts);
let share_money = ledger_core::parties::money_accounts(
&ledger,
&money_cash_accounts,
&financing_accounts,
);
let investing_accounts = codes(&settings.investing_accounts);
let card_accounts = ledger_store::reconcilable::card_accounts(&ledger);
@@ -675,7 +694,8 @@ impl Books {
// vendor tags, bills, invoices, their payments, reversals).
let party_id = ledger.entry_party(id);
let shares = ledger.entry_parties(id);
let resolved = ledger_core::shares::resolve(e, shares, &share_money, &members).ok();
let resolved =
ledger_core::shares::resolve(&ledger, id, shares, &share_money, &members).ok();
let parties: Vec<EntryParty> = shares
.iter()
.enumerate()
@@ -686,7 +706,10 @@ impl Books {
.map(|p| p.label().to_string())
.unwrap_or_else(|| format!("party {}", s.party)),
share: s.share,
resolved: resolved.as_ref().map(|r| r[i].1),
resolved: resolved.as_ref().map(|r| r.parts[i].amount),
direction: resolved.as_ref().map(|r| r.target.direction),
cash: resolved.as_ref().map(|r| r.parts[i].cash),
in_kind: resolved.as_ref().map(|r| r.parts[i].in_kind),
})
.collect();
let party = if parties.is_empty() {
@@ -766,6 +789,7 @@ impl Books {
cash_accounts,
members,
financing_accounts,
money: share_money,
money_cash_accounts,
investing_accounts,
card_accounts,
@@ -1505,17 +1529,14 @@ impl Books {
.collect();
let mut what_for = Vec::new();
if with_breakdown {
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
for e in &posted {
// A posted entry's flow computes (its lines are in a line's
// range); one that doesn't, activity counts as unresolved
// and counts nothing, so neither does what it was for.
let Ok(flow) = ledger_core::parties::entry_flow(e, &money) else {
// An entry activity counts as unresolved counts nothing, so
// neither does what it was for.
if a.unresolved.contains(&e.id()) {
continue;
};
let parts = self.party_what_for(e, party, &flow);
for (account, amount) in parts {
}
for (account, amount) in self.party_what_for(e, party) {
*by.entry(account).or_default() += amount;
}
}
@@ -1525,26 +1546,22 @@ impl Books {
PartyFlow {
paid: a.paid,
received: a.received,
paid_in_kind: a.paid_in_kind,
received_in_kind: a.received_in_kind,
what_for,
entries: posted.len(),
}
}
/// ledger_core::parties::activity_with over `span`, with
/// Books::money_cash_accounts and the members: the one source of a
/// party's money, the server's own (books.party, books.parties).
/// ledger_core::parties::activity_with over `span`, with Books::money
/// and the members: the one source of a party's money, the server's own
/// (books.party, books.parties).
pub fn activity(
&self,
party: PartyId,
span: (NaiveDate, NaiveDate),
) -> ledger_core::parties::Activity {
ledger_core::parties::activity_with(
&self.ledger,
party,
span,
&self.money_cash_accounts,
&self.members,
)
ledger_core::parties::activity_with(&self.ledger, party, span, &self.money, &self.members)
}
/// The books dir: the folder holding `books-data/` (and `vault/`).
@@ -1629,51 +1646,86 @@ impl Books {
}
/// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT
/// 3): its fraction of the entry's entry_flow what_for, summing to its
/// money share exactly (zero when the entry moves no money, like a
/// bill or a contribution of property).
/// 3): its share of the entry's money breakdown (ledger_core::parties::
/// flow's what_for: a reversal's is its original's negated), summing
/// to its cash part of the entry exactly (nothing counted when the
/// entry's shares don't resolve).
///
/// ```text
/// The fraction: 1 for an entry with one party; else its resolved
/// share over what the shares split (the net, or the entry's total
/// debits when the net is zero).
/// A member's own capital and distribution lines are theirs in full;
/// another member's are never theirs. Every other line is scaled by
/// the fraction (to the cent, toward zero), and the first scaled line
/// takes the leftover so the parts sum to the money share.
/// The fraction: the party's resolved part over the target (the sum of
/// every party's part): 1 for an entry with one party.
/// A member's own capital and distribution lines are theirs in full
/// when the entry names several; another member's are never theirs.
/// Every other line is scaled by the fraction (to the cent, toward
/// zero), and the first scaled line takes what is left over so the
/// breakdown sums to the party's cash part: a bill (no cash part)
/// sums to zero, and a member's entry that counts only what moved
/// their capital (a reimbursement with a distribution) leaves out the
/// money that wasn't theirs.
/// What moved in kind isn't what money paid for: when the party's part
/// has property in it (rules.md B-35), the property lines (asset
/// accounts neither cash nor receivables, the K-1 worksheet's test)
/// stay out, so a property contribution, and its reversal, show
/// nothing, and a distribution of money and equipment shows the
/// money alone.
/// ```
pub fn party_what_for(
&self,
e: &ledger_core::JournalEntry,
party: PartyId,
flow: &ledger_core::parties::EntryFlow,
) -> Vec<(AccountId, Decimal)> {
use rust_decimal::RoundingStrategy::ToZero;
let parties = self.ledger.entry_parties(e.id()).len();
if parties <= 1 {
return flow.what_for.clone();
}
let resolved = self.party_share(e, party);
// Posted lines are in a line's range, so these sums and products
// compute; one past exact arithmetic counts nothing, as an entry
// whose shares don't resolve.
let target = if flow.net.is_zero() {
match ledger_config::ReceiptPolicy::entry_debits(e) {
Ok(t) => t,
Err(_) => return Vec::new(),
}
} else {
flow.net
};
let Some(fraction) = resolved.checked_div(target) else {
let Some(info) = self
.entry_index
.get(&e.id())
.and_then(|&i| self.entries.get(i))
else {
return Vec::new();
};
let money_share = if flow.net.is_zero() {
Decimal::ZERO
let Some(me) = info.parties.iter().find(|p| p.id == party) else {
return Vec::new();
};
let (Some(resolved), Some(cash)) = (me.resolved, me.cash) else {
return Vec::new();
};
let in_kind = me.in_kind.is_some_and(|k| !k.is_zero());
let receivables = self.ledger.role_account(&ledger_core::RECEIVABLES);
let property = |a: AccountId| {
!self.money.cash.contains(&a)
&& Some(a) != receivables
&& self
.ledger
.chart()
.get(a)
.is_some_and(|x| x.account_type == AccountType::Asset)
};
let shares = self.ledger.entry_parties(e.id());
// A posted entry's flow computes (its lines are in a line's range);
// one past exact arithmetic counts nothing.
let Ok(flow) = ledger_core::parties::flow(&self.ledger, e.id(), shares, &self.money) else {
return Vec::new();
};
let Some(target) = info
.parties
.iter()
.try_fold(Decimal::ZERO, |t, p| t.checked_add(p.resolved?))
else {
return Vec::new();
};
let fraction = if info.parties.len() <= 1 {
Decimal::ONE
} else {
resolved
match resolved.checked_div(target) {
Some(f) => f,
None => return Vec::new(),
}
};
let several = info.parties.len() > 1;
let me = if several {
self.member(party).copied()
} else {
None
};
let me = self.member(party).copied();
let member_line = |a: AccountId| {
self.members
.iter()
@@ -1682,9 +1734,12 @@ impl Books {
let mut own: Vec<(AccountId, Decimal)> = Vec::new();
let mut scaled: Vec<(AccountId, Decimal)> = Vec::new();
for &(a, v) in &flow.what_for {
if in_kind && property(a) {
continue;
}
if me.is_some_and(|m| m.capital == a || m.distributions == Some(a)) {
own.push((a, v));
} else if !member_line(a) {
} else if !several || !member_line(a) {
let Some(part) = v.checked_mul(fraction) else {
return Vec::new();
};
@@ -1695,12 +1750,12 @@ impl Books {
.iter()
.chain(&scaled)
.try_fold(Decimal::ZERO, |t, (_, v)| t.checked_add(*v))
.and_then(|have| money_share.checked_sub(have))
.and_then(|have| cash.checked_sub(have))
else {
return Vec::new();
};
if !left.is_zero() {
match scaled.first_mut() {
match scaled.first_mut().or(own.first_mut()) {
Some(first) => first.1 += left,
None => own.push((e.lines()[0].account, left)),
}
@@ -1711,17 +1766,6 @@ impl Books {
.collect()
}
/// A party's resolved share of an entry's flow (its entry_flow net
/// for a sole party; zero when the shares don't resolve or it isn't
/// named).
pub fn party_share(&self, e: &ledger_core::JournalEntry, party: PartyId) -> Decimal {
self.entries
.get(self.entry_index.get(&e.id()).copied().unwrap_or(usize::MAX))
.and_then(|info| info.parties.iter().find(|p| p.id == party))
.and_then(|p| p.resolved)
.unwrap_or_default()
}
/// A member's capital statement over `span` (see
/// [`CapitalStatement`]); None when the party isn't a member.
pub fn capital_statement(
+63 -14
View File
@@ -94,8 +94,9 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
};
ui.add_space(4.0);
let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
if several {
text.push_str(&format!(" · {}", share_text(share)));
let what = share_text(share, several, e.reversal_of);
if !what.is_empty() {
text.push_str(&format!(" · {what}"));
}
if widgets::link(ui, RichText::new(text).color(p.accent))
.on_hover_text(format!(
@@ -511,23 +512,71 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid
}
}
/// One party's share of an entry, in words: "$50.00 (derived)", "$70.00",
/// "$33.34 (33.333%)", or the share alone when it doesn't resolve.
pub fn share_text(s: &crate::books::EntryParty) -> String {
let how = match s.share {
ledger_core::Share::Amount(_) => None,
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
ledger_core::Share::Derived => Some("derived".to_string()),
/// A party's part of an entry in words (rules.md B-35): which way it
/// went and how much ("paid $64.00", "received $740.00, in kind"), what of
/// it is still owed, the share it came from when the entry names several
/// ("(50%)", "(derived)"), and a reversal said as one ("paid $1,480.00
/// (reversal of #12)"), so a property contribution and its reversal never
/// read alike. A part that moved nothing with the party (a bill, a close)
/// says so. Empty for a sole party whose part doesn't resolve.
pub fn share_text(
s: &crate::books::EntryParty,
several: bool,
reversal_of: Option<ledger_core::EntryId>,
) -> String {
use ledger_core::shares::Direction;
let how = if several {
match s.share {
ledger_core::Share::Amount(_) => None,
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
ledger_core::Share::Derived => Some("derived".to_string()),
}
} else {
None
};
match (s.resolved, how) {
(Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())),
(Some(v), None) => fmt::dollars(v.abs()),
(None, Some(h)) => h,
(None, None) => match s.share {
let mut t = match (s.resolved, s.direction) {
(Some(v), Some(d)) => {
let amount = fmt::dollars(v.abs());
let mut t = match d {
Direction::Paid => format!("paid {amount}"),
Direction::Received => format!("received {amount}"),
Direction::Neither => format!("{amount}, no money moved"),
};
let cash = s.cash.unwrap_or_default();
let in_kind = s.in_kind.unwrap_or_default();
if !in_kind.is_zero() {
if in_kind == v {
t.push_str(", in kind");
} else {
t.push_str(&format!(", {} in kind", fmt::dollars(in_kind.abs())));
}
}
let owed = v - cash - in_kind;
if d != Direction::Neither && !owed.is_zero() {
t.push_str(&format!(", {} still owed", fmt::dollars(owed.abs())));
}
t
}
_ if several => match s.share {
ledger_core::Share::Amount(a) => fmt::dollars(a),
_ => String::new(),
},
_ => String::new(),
};
let mut notes: Vec<String> = how.into_iter().collect();
if s.resolved.is_some()
&& let Some(o) = reversal_of
{
notes.push(format!("reversal of #{o}"));
}
if !notes.is_empty() {
if t.is_empty() {
t = notes.join(", ");
} else {
t.push_str(&format!(" ({})", notes.join(", ")));
}
}
t
}
#[cfg(test)]
+15
View File
@@ -483,6 +483,21 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
};
ev(icon::USER_GEAR, text, pid.map(Route::Party))
}
"party.creditor_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
let account = p
.get("account_id")
.and_then(|v| v.as_u64())
.map(|a| b.account_name(a))
.unwrap_or_else(|| "an account".to_string());
let text = if p.get("on").and_then(|v| v.as_bool()).unwrap_or(true) {
format!("{name} is now owed on {account}")
} else {
format!("{name} is no longer owed on {account}")
};
ev(icon::USER_GEAR, text, pid.map(Route::Party))
}
"party.w9_set" => {
let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid);
+53 -6
View File
@@ -564,7 +564,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
ui.add(
egui::Label::new(
RichText::new(format!(
"{period_label}: Paid out is what each party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company; Received in is what came back in; Net is paid out less received (a plus means more came in). Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's."
"{period_label}, cash basis: Paid out is the money each party's posted entries paid it, from cash, a card, or a member or lender paying for the company; Received in is the money that came in from it; Net is paid out less received (a plus means more came in). Property contributed or distributed in kind is on each party's page. Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's."
))
.font(theme::regular(11.5))
.color(p.faint),
@@ -577,6 +577,21 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
});
}
/// The party's totals with property in kind (basis all, rules.md B-35
/// item 4), when it gave or took any in the period: "With property
/// contributed or distributed: paid $X, received $Y". None when nothing
/// moved in kind (the cash totals are then the whole story).
pub fn in_kind_text(flow: &crate::books::PartyFlow) -> Option<String> {
if flow.paid_in_kind.is_zero() && flow.received_in_kind.is_zero() {
return None;
}
Some(format!(
"With property contributed or distributed: paid {}, received {}",
fmt::dollars(flow.paid + flow.paid_in_kind),
fmt::dollars(flow.received + flow.received_in_kind)
))
}
/// A number tile on a party's page: label, amount, how it's computed,
/// and the line under it.
/// (label, value, how, sub); the value None when it can't be computed
@@ -673,14 +688,14 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
tiles.push((
"Paid out".into(),
Some(flow.paid),
format!("{label}, {span_words}: what this party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company (net of refunds and reversals of payments)."),
Some((label.clone(), p.muted)),
format!("{label}, {span_words}, cash basis: the money this party's posted entries paid it, from cash, a card, or a member or lender paying for the company (net of reversals of payments; a refund is received in). A bill counts once it is paid."),
Some((format!("{label} · cash basis"), p.muted)),
));
tiles.push((
"Received in".into(),
Some(flow.received),
format!("{label}, {span_words}: what this party's posted entries brought into cash and those accounts (net of reversals)."),
Some((label.clone(), p.muted)),
format!("{label}, {span_words}, cash basis: the money this party's posted entries brought in (net of reversals)."),
Some((format!("{label} · cash basis"), p.muted)),
));
let net = flow.net();
tiles.push((
@@ -771,6 +786,21 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string()));
}
});
// Basis all (rules.md B-35 item 4): property in kind, when any.
if let Some(text) = in_kind_text(&flow) {
ui.add_space(6.0);
ui.add(
egui::Label::new(
RichText::new(text)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
)
.on_hover_text(
"Basis all (books.party basis \"all\"): the money above plus property contributed or distributed in kind, as the statement of members' equity counts it.",
);
}
// A member's page is their capital account statement too.
if let Some(st) = b.capital_statement(id, span) {
@@ -939,6 +969,23 @@ fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option<Route>) {
}
}
ui.end_row();
if !party.creditor_of.is_empty() {
k(ui, "Owed on");
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
for &a in &party.creditor_of {
if widgets::link(ui, RichText::new(b.account_name(a)).color(p.accent))
.on_hover_text(
"What the company owes this party itself on (its card, its loan, what the members advanced): a charge or fee credited to it never counts as paying them.",
)
.clicked()
{
*go = Some(Route::Account(a));
}
}
});
ui.end_row();
}
if let Some(v) = party.vendor {
k(ui, "Vendor record");
if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent))
@@ -1229,7 +1276,7 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
.collect();
sub = format!(
"Their share, {} of {}, with {} · {sub}",
super::entry::share_text(mine),
super::entry::share_text(mine, true, e.reversal_of),
fmt::dollars_or(e.amount),
others.join(" & ")
);
+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!(