Party money counts cash paying down a liability (reimbursements, card payments)
check / check (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 16:07:23 -06:00
1 parent ae927c3f73
commit df3fc3f4e4
4 files changed
+233 -56

No files matched your search

+138 -33
View File
@@ -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<crate::AccountId> {
/// 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<crate::AccountId>,
/// Liabilities that pay for the company or are paid down.
pub liabilities: std::collections::HashSet<crate::AccountId>,
}
/// 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::AccountId> = 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<crate::AccountId, Decimal> = Default::default();
let (mut down_left, mut up_left) = (down, up);
let mut order: Vec<crate::AccountId> = 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<crate::EntryId> = (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 {
+75
View File
@@ -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::<Decimal>().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"));
}
+9 -12
View File
@@ -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<AccountId, Decimal> = 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();
+11 -11
View File
@@ -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)]