From df3fc3f4e4c40f2d779f3bd14e075b71ac992a7e Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 15 Oct 2026 16:07:23 -0600 Subject: [PATCH] Party money counts cash paying down a liability (reimbursements, card payments) ledger_core::parties::entry_flow: cash lines count; liability lines count except the part paired against cash in the same entry, which counts once as paid to the liability's creditor (or received from a lender). A reimbursement to the members no longer nets to zero. What-for breakdown comes from the same function and sums to the net exactly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/parties.rs | 171 ++++++++++++++++++----- crates/ledger-core/tests/spec_parties.rs | 75 ++++++++++ crates/ledger-gui/src/books.rs | 21 ++- crates/ledger-mcp/src/types.rs | 22 +-- 4 files changed, 233 insertions(+), 56 deletions(-) diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index b674f9f..77dd2c2 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -314,31 +314,146 @@ pub struct Activity { pub received: rust_decimal::Decimal, } -/// The money accounts [`activity`] counts: the cash accounts (every asset -/// account but receivables when `cash` is empty), and every liability -/// account but payables and the payroll withholdings (cards, and what -/// members paid for the company and are owed back). -pub fn money_accounts( - ledger: &crate::Ledger, - cash: &[crate::AccountId], -) -> std::collections::HashSet { +/// The accounts [`entry_flow`] reads money from: the cash accounts (every +/// asset account but receivables when `cash` is empty), and the +/// liabilities other than payables and the payroll withholdings (cards, +/// what members paid for the company and are owed back, loans). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MoneyAccounts { + /// Cash: bank, savings, cash on hand. + pub cash: std::collections::HashSet, + /// Liabilities that pay for the company or are paid down. + pub liabilities: std::collections::HashSet, +} + +/// The [`MoneyAccounts`] of `ledger`, with `cash` the books' cash accounts. +pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> MoneyAccounts { use crate::AccountType; let not_money: std::collections::HashSet = crate::PAYROLL[2..] .iter() .chain([&crate::PAYABLES, &crate::RECEIVABLES]) .filter_map(|c| ledger.role_account(c)) .collect(); - ledger - .chart() - .all() - .filter(|a| !not_money.contains(&a.id)) - .filter(|a| match a.account_type { - AccountType::Liability => true, - AccountType::Asset => cash.is_empty() || cash.contains(&a.id), - _ => false, - }) - .map(|a| a.id) - .collect() + let mut out = MoneyAccounts::default(); + for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) { + match a.account_type { + AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => { + out.cash.insert(a.id); + } + AccountType::Liability => { + out.liabilities.insert(a.id); + } + _ => {} + } + } + out +} + +/// What one entry moved with its counterparty ([`entry_flow`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EntryFlow { + /// Paid to the counterparty (positive) or received from it (negative). + pub net: rust_decimal::Decimal, + /// What for, by account (debits − credits): positive is what the money + /// paid for, negative where money received came from. Sums to `net`. + pub what_for: Vec<(crate::AccountId, rust_decimal::Decimal)>, +} + +/// The money one entry moved with its counterparty, and what for. +/// +/// ```text +/// GOAL: "how much did we pay them" right however it was paid: from the +/// bank, on a card, by a member for the company, or by paying down +/// what the company owes them (a reimbursement, a card payment). +/// SOURCE: DECISION (Stephen, 2026-10-15), after a reimbursement to the +/// members netted to zero under the earlier rule (cash and +/// liabilities alike as money). +/// DEFINITIONS (over the entry's lines, in `money`): +/// cash_cr, cash_dr: credits and debits on cash accounts; +/// liab_cr, liab_dr: credits and debits on money liabilities; +/// down = min(liab_dr, cash_cr): cash paying down a liability (its +/// creditor is paid; the liability is what for); +/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the +/// lender pays the company). +/// INVARIANTS: +/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up. +/// 2. what_for: every line not on a money account (debits − credits), +/// plus `down` on the debited liabilities and −`up` on the credited +/// ones (in line order), zero sums left out, accounts merged; its sum +/// is net exactly, because the entry balances. +/// SCENARIOS: +/// 1. Expense from the bank, on a card, or paid by a member for the +/// company (Dr expense / Cr 1000, 2050, or 2100): net is the amount. +/// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 / +/// Cr 1000): net is the amount, what for is the liability. +/// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received). +/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net. +/// 5. A transfer between the company's own cash accounts: zero. +/// 6. A member contributing property (Dr equipment / Cr capital): zero. +/// ``` +pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow { + use crate::Side; + use rust_decimal::Decimal; + let (mut cash_cr, mut cash_dr, mut liab_cr, mut liab_dr) = + (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); + for l in entry.lines() { + let a = l.amount.amount; + match ( + money.cash.contains(&l.account), + money.liabilities.contains(&l.account), + l.side, + ) { + (true, _, Side::Credit) => cash_cr += a, + (true, _, Side::Debit) => cash_dr += a, + (false, true, Side::Credit) => liab_cr += a, + (false, true, Side::Debit) => liab_dr += a, + _ => {} + } + } + let down = liab_dr.min(cash_cr); + let up = liab_cr.min(cash_dr); + let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up; + let mut by: std::collections::BTreeMap = Default::default(); + let (mut down_left, mut up_left) = (down, up); + let mut order: Vec = Vec::new(); + for l in entry.lines() { + let a = l.amount.amount; + let part = if money.cash.contains(&l.account) { + Decimal::ZERO + } else if money.liabilities.contains(&l.account) { + match l.side { + Side::Debit => { + let take = a.min(down_left); + down_left -= take; + take + } + Side::Credit => { + let take = a.min(up_left); + up_left -= take; + -take + } + } + } else { + match l.side { + Side::Debit => a, + Side::Credit => -a, + } + }; + if !part.is_zero() { + if !by.contains_key(&l.account) { + order.push(l.account); + } + *by.entry(l.account).or_default() += part; + } + } + EntryFlow { + net, + what_for: order + .into_iter() + .map(|a| (a, by[&a])) + .filter(|(_, v)| !v.is_zero()) + .collect(), + } } /// One party's entries, and the money paid to and received from it in @@ -353,9 +468,9 @@ pub fn money_accounts( /// 1. entries: every entry Ledger::entry_party names the party /// (hand tags, vendor tags, bills and their payments, invoices and /// their payments, and reversals of any of these carry it). -/// 2. Each posted entry dated in the span nets (credits − debits) on -/// money_accounts: positive is a payment, negative a receipt. A -/// reversal counts on its original's side, so it subtracts. +/// 2. Each posted entry dated in the span counts its entry_flow net: +/// positive is a payment, negative a receipt. A reversal counts on +/// its original's side, so it subtracts. /// 3. A card charge counts as paid (the company paid them, by card); a /// bill doesn't until it is paid. Not the 1099 amount: that is /// form1099::vendor_1099_report_with. @@ -366,22 +481,12 @@ pub fn activity( (start, end): (NaiveDate, NaiveDate), cash: &[crate::AccountId], ) -> Activity { - use crate::Side; use rust_decimal::Decimal; let entries: BTreeSet = (0..ledger.next_entry_id()) .filter(|&id| ledger.entry_party(id) == Some(party)) .collect(); let money = money_accounts(ledger, cash); - let net_out = |e: &crate::JournalEntry| -> Decimal { - e.lines() - .iter() - .filter(|l| money.contains(&l.account)) - .map(|l| match l.side { - Side::Credit => l.amount.amount, - Side::Debit => -l.amount.amount, - }) - .sum() - }; + let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).net; let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO); for &id in &entries { let Some(e) = ledger.get_entry(id) else { diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index df2a9e2..20af308 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -1135,3 +1135,78 @@ fn a_vault_record_hash_with_nine_digits_is_accepted() { }; assert_eq!(facts.check(), Ok(())); } + +/// Oracle: parties::entry_flow SCENARIOS 1–6, worked by hand; what_for +/// always sums to net. +#[test] +fn entry_flow_scenarios() { + use ledger_core::parties::{entry_flow, money_accounts}; + use ledger_core::{AccountType, Currency, Ledger, Money, Side}; + use rust_decimal::Decimal; + let mut l = Ledger::new(Currency::USD); + let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let bank = acct("1000", AccountType::Asset); + let savings = acct("1010", AccountType::Asset); + let equip = acct("1500", AccountType::Asset); + let card = acct("2050", AccountType::Liability); + let members = acct("2100", AccountType::Liability); + let loan = acct("2500", AccountType::Liability); + let capital = acct("3000", AccountType::Equity); + let exp = acct("6000", AccountType::Expense); + let money = money_accounts(&l, &[bank, savings]); + let d = chrono::NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(); + let mut case = |lines: &[(u64, Side, &str)]| { + let e = l.create_entry(d, "x"); + for (a, side, amt) in lines { + l.add_line( + e, + *a, + *side, + Money::new(amt.parse().unwrap(), Currency::USD), + ) + .unwrap(); + } + let f = entry_flow(l.get_entry(e).unwrap(), &money); + let sum: Decimal = f.what_for.iter().map(|(_, v)| *v).sum(); + assert_eq!(sum, f.net, "what_for sums to net: {f:?}"); + f + }; + use Side::{Credit as C, Debit as D}; + let dec = |s: &str| s.parse::().unwrap(); + // 1. Expense from the bank, a card, a member. + for src in [bank, card, members] { + let f = case(&[(exp, D, "10.00"), (src, C, "10.00")]); + assert_eq!(f.net, dec("10.00")); + assert_eq!(f.what_for, vec![(exp, dec("10.00"))]); + } + // 2. Reimbursing members; paying the card. + for liab in [members, card] { + let f = case(&[(liab, D, "387.64"), (bank, C, "387.64")]); + assert_eq!(f.net, dec("387.64")); + assert_eq!(f.what_for, vec![(liab, dec("387.64"))]); + } + // 3. A loan. + let f = case(&[(bank, D, "1000.00"), (loan, C, "1000.00")]); + assert_eq!(f.net, dec("-1000.00")); + // 4. Refunds to the bank and to the card. + assert_eq!( + case(&[(bank, D, "5.00"), (exp, C, "5.00")]).net, + dec("-5.00") + ); + assert_eq!( + case(&[(card, D, "5.00"), (exp, C, "5.00")]).net, + dec("-5.00") + ); + // 5. Own-account transfer. + let f = case(&[(savings, D, "50.00"), (bank, C, "50.00")]); + assert_eq!(f.net, Decimal::ZERO); + assert!(f.what_for.is_empty()); + // 6. Property contributed. + assert_eq!( + case(&[(equip, D, "750.00"), (capital, C, "750.00")]).net, + Decimal::ZERO + ); + // Split payment: part bank, part card. + let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]); + assert_eq!(f.net, dec("100.00")); +} diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 664e8b8..ae6427b 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -250,21 +250,21 @@ pub enum PartyPeriod { /// GOAL: answer "how much did we spend with them, and how much did we /// get back?" (Stephen, 2026-10-15) with the books' own numbers. /// SOURCE: ledger_core::parties::activity (paid, received) and -/// ledger_core::parties::money_accounts (the money set), with +/// ledger_core::parties::entry_flow (what for), with /// Books::cash_accounts, as PartyInfo uses them. /// INVARIANTS: /// 1. paid, received: activity(ledger, party, span, cash accounts)'s, /// exactly. /// 2. net() = paid − received. -/// 3. what_for: over the party's posted entries dated in the span, each -/// account not in money_accounts with its debits − credits, the -/// accounts whose sum is zero left out, largest magnitude first +/// 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), +/// 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: every entry balances, so its -/// non-money lines net to what its money lines moved (a reversal -/// reverses both). +/// 4. Σ what_for = net(), exactly: each entry_flow's what_for sums to +/// its net (a reversal reverses both). /// 5. entries: how many of the party's entries are posted and dated /// in the span. /// ``` @@ -1228,11 +1228,8 @@ impl Books { let money = ledger_core::parties::money_accounts(l, &self.cash_accounts); let mut by: BTreeMap = BTreeMap::new(); for e in &posted { - for line in e.lines().iter().filter(|x| !money.contains(&x.account)) { - *by.entry(line.account).or_default() += match line.side { - Side::Debit => line.amount.amount, - Side::Credit => -line.amount.amount, - }; + for (account, amount) in ledger_core::parties::entry_flow(e, &money).what_for { + *by.entry(account).or_default() += amount; } } what_for = by.into_iter().filter(|(_, v)| !v.is_zero()).collect(); diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 96388f4..ed2cf43 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -2853,17 +2853,17 @@ pub struct ListPartiesOut { /// the payments received on them, and reversals of any of these. Only /// posted entries dated in the calendar year count. /// -/// Money lines are the lines on the money accounts: the `[books] -/// cash_accounts` (every asset account but receivables when none are set) -/// and every liability account except the payables and payroll -/// withholding accounts (the control roles in `books.roles`: 2000 and -/// 2110-2150 by default). A liability counts because it is how the -/// company paid or was paid: a card, a member paying for it, a loan. Each -/// entry's net is its money lines' credits minus their debits: a positive -/// net was paid to the party, a negative one received from it; a reversal -/// counts against its original's side. An entry that only moves money -/// between money accounts (borrowing, a customer's deposit booked to a -/// liability) nets to zero. A bill is not paid until its payment is. +/// Each entry's money is `ledger_core::parties::entry_flow`: what left or +/// reached the `[books] cash_accounts` (every asset account but +/// receivables when none are set), plus what the company paid through a +/// liability (a card, a member paying for it, a loan; not payables or +/// payroll withholdings), with cash that pays a liability down counting +/// once, as paid to that liability's creditor (a reimbursement to the +/// members, a card payment to the issuer), and cash raised by a liability +/// counting as received from the lender. Positive is paid to the party, +/// negative received from it; a reversal counts against its original's +/// side. A transfer between the company's own cash accounts nets to zero. +/// A bill is not paid until its payment is. /// (`ledger_core::parties::activity` computes it; the viewer shows the /// same numbers.) #[derive(Debug, Clone, Serialize, JsonSchema)]