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:
52 files changed
+7535
-560
No files matched your search
+142
-98
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user