Party money counts cash paying down a liability (reimbursements, card payments)
check / check (push) Waiting to run
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:
1 parent
ae927c3f73
commit
df3fc3f4e4
4 files changed
+233
-56
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user