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 {