Files
wanderbooks/crates/ledger-core/tests/spec_cash_flow.rs
T
swaitsandClaude Opus 5.5 535874e0f3 fix(cash flow): a payment to a government is other operating, by its payee
The P3 cutover review's open point 2: a state's LLC filing fee, booked
to an organization-costs account (tax category organization_startup),
read as "Cash paid to suppliers and employees". B-14 rule 5 sent a
payment to class (g) only when every operating line's account was
taxes_licenses, so the account, not the payee, decided.

ASC 230-10-45-17, read in EY's guide that reproduces it (Statement of
cash flows, July 2026, p. 43; the PDF re-fetched and its SHA-256
recorded in docs/spec/sources/fasb-asc.md), lists apart "(b) Cash
payments to other suppliers and employees for other goods or services"
and "(c) Cash payments to governments for taxes, duties, fines, and
other fees or penalties". 45-25's classes name only income taxes (f)
among the latter, so the rest are (g), other operating cash payments.
A formation filing fee is rightly an organization cost (its account and
tax line), and still a payment to a government: the payee is what
45-17(c) is about.

So rule 5 now sends a payment's government part to (g): of an
operating payment the structure classes (d), what went to a party with
the government_agency or tax_agency role (the chain root's parties, so
a reversal keeps its original's class) is (g), on any account; and, as
before, a payment whose every operating line is on a taxes_licenses
account is (g). Every party a government: the whole payment. A
government and a supplier named together: the payment is split by their
resolved shares (B-35), the government's part (g), the rest (d), since
ASC 230-10-45-22 has "each separately identifiable source or each
separately identifiable use" classed "on the basis of the nature of the
underlying cash flows" (EY p. 44; quoted in fasb-asc.md; it names the
three activities, read for 45-25's classes too as a CONVENTION). The
government's part is the payment x its share / the whole, rounded half
to even to the base currency's places, the rest exact, so the line still
sums to the payment and a reversal mirrors it. Shares that don't
resolve leave the whole payment (d). One naming no party is decided by
its accounts. Receipts are untouched (a refund from a government stays
(c)). No total changes, only which operating row a payment is in.

Two limits, from the review of this change: a government selling goods
or services (postage) is 45-17(b)'s supplier "for other goods or
services", so the rule says, and the handbook tells the agent, to add
such a payee as a vendor, never with the government_agency role (the
books can't tell what a payment bought; the role says it). And payroll
keeps its structure (CONVENTION): an entry with a line on the salary
expense or a payroll payable control account, or on a pay tax category
(salaries_wages, officer_compensation, employee_benefits,
pension_profit_sharing), stays (d) whoever it names: withheld taxes
remitted are the employees' pay, and the payables mix them with the
employer's taxes, which a remittance can't tell apart. So a remittance
to the IRS is (d) as it was before this change; the employer's payroll
tax paid on its own expense account is a tax, (g).

The first company's books have this case: their state filing fee moves
from "Cash paid to suppliers and employees" to "Other operating cash
payments" at every date once its entry names the state's agency with the
government_agency role and its own entry is the one whose cash the
statement lists (paid from the bank, or on a card that is a lender's
credit, D-42, or by a member through the members' account while it is
a financing account, B-14 rule 11, the way that fee was paid);
charged to an operating-payable card, its cash leaves in the card
payment, whose payee is the bank, and it stays with suppliers.
STATUS says how to check both, and that the cutover goldens' cash-flow
snapshots will differ on the two class lines only (totals the same),
so the next cutover diff isn't read as a regression.

Docs: rules.md B-14 rule 5 (the rule, its source quoted, the date and
what it was before); fasb-asc.md (45-17(b) and (c) quoted exactly, with
the guide's SHA-256); the handbook's entries section (name the agency as
the party on a tax, fee, fine or penalty paid to a government; a shared
payment named with both amounts; a government selling services added as
a vendor; payroll remittances stay with pay; a fee on a card follows the
card payment); fasb-asc.md also quotes 45-22 and 45-22A; bookspec
example cash-flow.government-fees.toml (a filing fee and a lawyer on
one organization-costs account, a license on taxes and licenses with no
party, a penalty to a tax agency on the wrong account, paper from a
supplier, an annual report paid through a registered agent, 37.00 the
state's and 100.00 the agent's; every figure by hand) with a new `class.<a..g>`
key in the
cash_flow query (bookspec README; the runner checks every class the
statement has is given); testing.md; STATUS.

Tests: ledger-core tests/spec_cash_flow.rs
`a_payment_to_a_government_is_other_operating` (the filing fee and a
penalty to a tax agency (g); the same account paid to a lawyer, or with
no payee, (d); its reversal next month keeps (g); a refund (c)),
`a_government_payee_moves_a_fee_on_any_account` (a taxes_licenses
license with no party (g); a payment shared half and half between a
government and a printer split 50.00 (g) and 50.00 (d), the government
alone all (g)), `a_shared_government_payment_splits_to_the_cent` (a
third by percent of 100.01: 33.34 (g), 66.67 (d); amounts split as
given; the reversal mirrors to the cent),
`a_payroll_remittance_to_a_tax_agency_stays_pay` (withheld taxes
remitted from a payable holding its payroll role, and pay on a
salaries_wages account, stay (d) with the IRS named; the employer's tax
on its own expense account is (g)),
`a_government_selling_services_is_a_supplier` (postage to a vendor (d);
given government_agency, (g): the limit the handbook guards); src
cashflow.rs `tests` `a_government_part_rounds_half_to_even` and the
proptest `a_government_part_sums_and_mirrors`; the bookspec example;
from the P4 review, `a_reversal_takes_its_originals_payee` (a fee and
its reversal next month land in one class, the chain's payee deciding:
the books keep a chain's parties one, so reading the reversal's own
parties is an equivalent mutant) and
`a_shared_government_payment_a_member_paid_splits_both_ways` (a shared
payment a member paid: each part with its own on_behalf; fails with
the on-behalf part unsplit). docs/mcp-interface.md: books.cash_flow
and the Roles paragraph say a government or tax agency payee makes a
payment other operating, and a shared one two operating lines.
Broken to confirm: without the payee check the five spec tests and the
example fail; without the split for mixed payees the two shared-payment
tests and the example (class.d -549.50) fail; without the payroll
exclusion the payroll test fails; rounding toward zero fails both unit
tests; without the tax-category route
`operating_classes_follow_asc_230_10_45_25`, the second test and the
example fail. The viewer's cash flow page,
rendered on made-up books before and after: the fee moved from "Cash
paid to suppliers and employees" to "Other operating cash payments",
every total the same; and on made-up books with an annual report
shared between the state and an agent: each part on its own class
line, every total the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 19:31:18 -06:00

3653 lines
136 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The statement of cash flows (rules.md B-14), from the rule and its
//! sources, never from the implementation.
//!
//! Every expected figure is worked by hand in the test's doc comment from
//! the paragraph it cites (testing.md rank 1). The random-book properties
//! at the end check the statement against an independent model: the plain
//! sum of the generated cash lines.
use chrono::NaiveDate;
use ledger_core::reports::foot;
use ledger_core::{
AccountId, AccountType, CashFlowLine, CashFlowParams, CashFlowSection, CashFlowStatement,
CashFlowWarningKind, Currency, EntryId, Ledger, LedgerError, MemberCapital, Money, NewAsset,
OperatingClass, ReconcilingKind, Side, cash_flow,
};
use rust_decimal::Decimal;
const DR: Side = Side::Debit;
const CR: Side = Side::Credit;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
/// An exact amount from text: "2340.00".
fn x(s: &str) -> Decimal {
s.parse().unwrap()
}
/// A small company to post to by account code.
struct Books {
l: Ledger,
members: Vec<MemberCapital>,
}
impl Books {
fn new() -> Self {
Books {
l: Ledger::new(Currency::USD),
members: Vec::new(),
}
}
fn acct(&mut self, code: &str, name: &str, t: AccountType) -> AccountId {
self.l.add_account(code, name, t, None, false).unwrap()
}
fn id(&self, code: &str) -> AccountId {
self.l.chart().find_by_code(code).unwrap().id
}
fn post(&mut self, date: NaiveDate, desc: &str, lines: &[(&str, Side, &str)]) -> EntryId {
let e = self.l.create_entry(date, desc);
for &(code, side, amount) in lines {
let a = self.id(code);
self.l
.add_line(e, a, side, Money::new(x(amount), Currency::USD))
.unwrap();
}
self.l.post_entry(e).unwrap();
e
}
fn reverse(&mut self, e: EntryId, date: NaiveDate) -> EntryId {
let desc = format!("Reverse entry {e}");
self.l.reverse_entry(e, date, &desc).unwrap()
}
/// A registered asset on these accounts, so they are investing.
fn asset(&mut self, cost: &str, accum: &str, expense: &str, amount: &str) {
let (c, a, e) = (self.id(cost), self.id(accum), self.id(expense));
self.l
.register_asset(NewAsset {
description: format!("Asset on {cost}"),
in_service_date: d(2026, 1, 1),
cost: Money::new(x(amount), Currency::USD),
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
useful_life_months: 36,
cost_account: c,
accum_depr_account: a,
expense_account: e,
})
.unwrap();
}
fn member(&mut self, name: &str, capital: &str) {
let capital_account = self.id(capital);
self.members.push(MemberCapital {
name: name.to_string(),
capital_account,
distribution_account: None,
share: Decimal::ZERO,
});
}
fn params(
&self,
start: NaiveDate,
end: NaiveDate,
cash: &[&str],
financing: &[&str],
investing: &[&str],
) -> CashFlowParams {
let ids = |codes: &[&str]| codes.iter().map(|c| self.id(c)).collect();
CashFlowParams {
start,
end,
cash_accounts: ids(cash),
financing_accounts: ids(financing),
investing_accounts: ids(investing),
members: self.members.clone(),
}
}
fn cf(
&self,
start: NaiveDate,
end: NaiveDate,
cash: &[&str],
financing: &[&str],
investing: &[&str],
) -> CashFlowStatement {
cash_flow(
&self.l,
&self.params(start, end, cash, financing, investing),
)
.unwrap()
}
}
/// (entry, amount) of some lines.
fn em(lines: &[CashFlowLine]) -> Vec<(EntryId, Decimal)> {
lines.iter().map(|l| (l.entry, l.amount)).collect()
}
/// (kind, amount) of a section's kinds.
fn kinds(cf: &CashFlowStatement, s: CashFlowSection) -> Vec<(String, Decimal)> {
cf.kinds(s)
.into_iter()
.map(|k| (k.kind, k.amount))
.collect()
}
/// (entry, amount) of the disclosures.
fn disclosed(cf: &CashFlowStatement) -> Vec<(EntryId, Decimal)> {
cf.noncash_disclosures
.iter()
.map(|n| (n.entry, n.amount))
.collect()
}
/// (label, amount) of the reconciling items.
fn recon(cf: &CashFlowStatement) -> Vec<(String, Decimal)> {
cf.operating_reconciliation
.lines
.iter()
.map(|l| (l.label.clone(), l.amount))
.collect()
}
fn s(t: &str) -> String {
t.to_string()
}
/// The laws every statement keeps (rules.md B-14 rules 8 and 9): each
/// section is its lines, and its kinds; the sections are the change in
/// cash; net income and the reconciling items are operating.
fn foots(cf: &CashFlowStatement) {
for sec in CashFlowSection::ALL {
let lines: Decimal = cf.lines(sec).iter().map(|l| l.amount).sum();
assert_eq!(lines, cf.total(sec), "{sec:?} lines");
let kinds: Decimal = cf.kinds(sec).iter().map(|k| k.amount).sum();
assert_eq!(kinds, cf.total(sec), "{sec:?} kinds");
}
let sum = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
assert_eq!(sum, cf.net_change_in_cash);
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
assert_eq!(cf.reconciles.sections_sum, sum);
assert_eq!(cf.reconciles.net_change, cf.net_change_in_cash);
assert_eq!(cf.reconciles.difference, Decimal::ZERO);
let r = &cf.operating_reconciliation;
let rec: Decimal = r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>();
assert_eq!(rec, cf.cash_from_operations);
assert_eq!(r.net_cash_from_operating, cf.cash_from_operations);
}
/// No label anywhere says "distribution" (D1-c): used where nothing was
/// distributed.
fn no_distribution(cf: &CashFlowStatement) {
let text = format!("{cf:?}").to_lowercase();
assert!(!text.contains("distribution"), "{cf:#?}");
}
// ---------------------------------------------------------------------------
// The named regressions (defect 1's reproductions), also bookspec examples
// (bookspec/examples/cash-flow.*.toml).
// ---------------------------------------------------------------------------
/// The minimal reproduction of defect 1. Property contributed (Dr
/// Equipment 2,340 / Cr Capital), the asset registered, the entry reversed
/// the same day. No cash moved, and the entry and its reversal cancel
/// (rules.md B-14 rules 1, 6): every section 0.00, no lines, no
/// disclosures, footing 0 / 0 / 0. Before the fix: investing −2,340.00
/// against a change in cash of 0.00 (the reversal's credit to the
/// equipment account belonged to no section).
#[test]
fn defect1_reversed_contributions_foot_regression() {
let mut b = Books::new();
b.acct("1000", "Cash", AccountType::Asset);
b.acct("1500", "Equipment", AccountType::Asset);
b.acct("1510", "Accumulated depreciation", AccountType::Asset);
b.acct("6100", "Depreciation", AccountType::Expense);
b.acct("3000", "Capital", AccountType::Equity);
let e1 = b.post(
d(2026, 1, 10),
"Property contributed",
&[("1500", DR, "2340.00"), ("3000", CR, "2340.00")],
);
b.asset("1500", "1510", "6100", "2340.00");
b.reverse(e1, d(2026, 1, 10));
let cf = b.cf(d(2026, 1, 1), d(2026, 12, 31), &["1000"], &[], &[]);
foots(&cf);
for sec in CashFlowSection::ALL {
assert_eq!(cf.total(sec), Decimal::ZERO);
assert!(cf.lines(sec).is_empty());
}
assert!(cf.noncash_disclosures.is_empty());
assert_eq!(
(
cf.reconciles.sections_sum,
cf.reconciles.net_change,
cf.reconciles.difference
),
(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO)
);
assert!(cf.operating_reconciliation.lines.is_empty());
no_distribution(&cf);
}
/// A two-member LLC's first weeks, in the shape of the books defect 1 was
/// found in: the same 23 entries in the same order, on the same accounts
/// and roles, every amount and date this replica's own (members "Member
/// A" and "Member B", vendors generic). Cash is 1000; 2100 (due to
/// members) is financing; 1500/1510/6500 carry registered assets. Also
/// the bookspec example `cash-flow.two-member-llc-first-year`.
///
/// | # | date | entry | Dr | Cr |
/// |----|-------|-----------------------------------------|-----------|-----------------|
/// | 1 | 03-02 | formation fee, member-paid | 6000 85.00 | 2100 |
/// | 2 | 03-03 | domain, member-paid | 6050 12.97 | 2100 |
/// | 3 | 03-05 | developer fee, member-paid | 6200 99.00 | 2100 |
/// | 4 | 03-09 | Member A contributes cash | 1000 1,200 | 3000 |
/// | 5 | 03-10 | Member B contributes cash | 1000 800 | 3001 |
/// | 6 | 03-11 | subscription top-up, member-paid | 6300 7.49 | 2100 |
/// | 7 | 03-16 | developer membership, member-paid | 6400 129.00 | 2100 |
/// | 8 | 03-23 | reimburse members (#1, #2, #3, #6, #7) | 2100 333.46 | 1000 |
/// | 9 | 03-20 | 2-year domain, member-paid (expensed) | 6050 46.38 | 2100 |
/// | 10 | 03-25 | API credits on the card | 6300 14.20 | 2050 |
/// | 11 | 03-26 | reimburse members (#20) | 2100 46.38 | 1000 |
/// | 12 | 03-27 | laptop contributed by Member A | 1500 1,480 | 3000 |
/// | 13 | 03-27 | reverses #12 | | |
/// | 14 | 03-27 | laptop contributed jointly | 1500 1,480 | 3000 740, 3001 740 |
/// | 15 | 03-27 | laptop contributed jointly | 1500 1,190 | 3000 595, 3001 595 |
/// | 16 | 03-27 | desktop contributed jointly | 1500 2,210 | 3000/3001 1,105 |
/// | 17 | 03-27 | tablet contributed jointly | 1500 630 | 3000/3001 315 |
/// | 18 | 03-27 | reverses #15 | | |
/// | 19 | 03-27 | laptop 1,151 and its service plan 39 | 1500 1,151, 1300 39 | 3000/3001 595 |
/// | 20 | 03-20 | 2-year domain, member-paid, prepaid | 1300 46.38 | 2100 |
/// | 21 | 03-12 | subscription, member-paid | 6300 216.40 | 2100 |
/// | 22 | 03-20 | reverses #9 | | |
/// | 23 | 03-30 | API credits on the card | 6300 42.75 | 2050 |
///
/// By hand, the year to 2026-03-31 (rules.md B-14; D-42 for what the
/// members paid):
/// - Operating: the members paid #1 85.00, #2 12.97, #3 99.00, #6 7.49,
/// #21 216.40, #7 129.00 and #20 46.38 (the prepaid domain) for the
/// company through 2100, each borrowed and spent on its date (rule 11):
/// 85.00 + 12.97 + 99.00 + 7.49 + 216.40 + 129.00 + 46.38 = 596.24, so
/// −596.24, all class (d). #9 and its reversal #22 net out (rule 6).
/// The card's #10 and #23 moved no cash and 2050 is not financing: no
/// line. Nothing else moved cash through an operating account.
/// - Investing: every asset was contributed as property the members
/// owned (no cash, ASC 230-10-50-4): 0.00; #12/#13 and #15/#18 net out.
/// - Financing: #4 +1,200.00 and #5 +800.00 (contributions, 45-14(a));
/// borrowed from the members through 2100 +596.24 (the seven above,
/// 45-14(b)); #8 −333.46 (85.00 + 12.97 + 99.00 + 7.49 + 129.00) and
/// #11 −46.38 (repayments, 45-15(b)), −379.84 together: 1,200.00 +
/// 800.00 + 596.24 − 379.84 = +2,216.40.
/// - Sum: −596.24 + 0 + 2,216.40 = +1,620.16, the change in cash (in
/// 1,200.00 + 800.00, out 333.46 + 46.38: 0.00 to 1,620.16);
/// difference 0.00.
/// - Reconciliation (45-28 to 45-30): net income is minus the expenses,
/// 85.00 + 12.97 + 99.00 + 7.49 + 129.00 + 46.38 − 46.38 + 14.20 +
/// 216.40 + 42.75 = 606.81, so −606.81; other noncash +39.00 (#19's
/// service plan, contributed with the laptop: noncash); increase in
/// 1300, 39.00 + 46.38, so −85.38; increase in 2050, 14.20 + 42.75, so
/// +56.95. Total −606.81 + 39.00 − 85.38 + 56.95 = −596.24, the
/// operating section.
/// - Disclosed (50-3, 50-4), by date: #14 1,480.00, #16 2,210.00, #17
/// 630.00, #19 1,190.00 (5,510.00, the property contributed); #12/#13
/// and #15/#18 drop out.
///
/// Before the fix the statement didn't foot: the reversals' credits to
/// equipment (#13, #18) belonged to no section, and the contributed
/// devices read as purchases.
fn two_member_llc() -> Books {
let mut b = Books::new();
for (code, name, t) in [
("1000", "Checking", AccountType::Asset),
("1300", "Prepaid Expenses", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("1510", "Accumulated Depreciation", AccountType::Asset),
("2050", "Business Card", AccountType::Liability),
("2100", "Due to Members", AccountType::Liability),
("3000", "Capital — Member A", AccountType::Equity),
("3001", "Capital — Member B", AccountType::Equity),
("6000", "Organizational Costs", AccountType::Expense),
("6050", "Domain & Hosting", AccountType::Expense),
("6200", "Developer Fees", AccountType::Expense),
("6300", "Software & Subscriptions", AccountType::Expense),
("6400", "Memberships & Licenses", AccountType::Expense),
("6500", "Depreciation Expense", AccountType::Expense),
] {
b.acct(code, name, t);
}
b.member("Member A", "3000");
b.member("Member B", "3001");
let p = |b: &mut Books, m, day, desc: &str, lines: &[(&str, Side, &str)]| {
b.post(d(2026, m, day), desc, lines)
};
let paid = |exp: &'static str, amount: &'static str| [(exp, DR, amount), ("2100", CR, amount)];
p(&mut b, 3, 2, "Formation filing fee", &paid("6000", "85.00"));
p(&mut b, 3, 3, "Domain, 1 year", &paid("6050", "12.97"));
p(
&mut b,
3,
5,
"Developer registration",
&paid("6200", "99.00"),
);
p(
&mut b,
3,
9,
"Contribution — Member A",
&[("1000", DR, "1200.00"), ("3000", CR, "1200.00")],
);
p(
&mut b,
3,
10,
"Contribution — Member B",
&[("1000", DR, "800.00"), ("3001", CR, "800.00")],
);
p(&mut b, 3, 11, "Subscription top-up", &paid("6300", "7.49"));
p(
&mut b,
3,
16,
"Developer membership",
&paid("6400", "129.00"),
);
p(
&mut b,
3,
23,
"Reimburse members",
&[("2100", DR, "333.46"), ("1000", CR, "333.46")],
);
let e9 = p(&mut b, 3, 20, "Domain, 2 years", &paid("6050", "46.38"));
p(
&mut b,
3,
25,
"API credits",
&[("6300", DR, "14.20"), ("2050", CR, "14.20")],
);
p(
&mut b,
3,
26,
"Reimburse members",
&[("2100", DR, "46.38"), ("1000", CR, "46.38")],
);
let e12 = p(
&mut b,
3,
27,
"Laptop contributed — Member A",
&[("1500", DR, "1480.00"), ("3000", CR, "1480.00")],
);
assert_eq!(b.reverse(e12, d(2026, 3, 27)), 13);
let joint = |amount: &'static str, half: &'static str| {
[("1500", DR, amount), ("3000", CR, half), ("3001", CR, half)]
};
p(
&mut b,
3,
27,
"Laptop contributed jointly",
&joint("1480.00", "740.00"),
);
let e15 = p(
&mut b,
3,
27,
"Laptop contributed jointly",
&joint("1190.00", "595.00"),
);
p(
&mut b,
3,
27,
"Desktop contributed jointly",
&joint("2210.00", "1105.00"),
);
p(
&mut b,
3,
27,
"Tablet contributed jointly",
&joint("630.00", "315.00"),
);
assert_eq!(b.reverse(e15, d(2026, 3, 27)), 18);
p(
&mut b,
3,
27,
"Laptop and its service plan contributed jointly",
&[
("1500", DR, "1151.00"),
("1300", DR, "39.00"),
("3000", CR, "595.00"),
("3001", CR, "595.00"),
],
);
p(
&mut b,
3,
20,
"Domain, 2 years, prepaid",
&paid("1300", "46.38"),
);
p(&mut b, 3, 12, "Subscription", &paid("6300", "216.40"));
assert_eq!(b.reverse(e9, d(2026, 3, 20)), 22);
let e23 = p(
&mut b,
3,
30,
"API credits",
&[("6300", DR, "42.75"), ("2050", CR, "42.75")],
);
assert_eq!(e23, 23);
for amount in ["1480.00", "1151.00", "2210.00", "630.00"] {
b.asset("1500", "1510", "6500", amount);
}
b
}
#[test]
fn defect1_two_member_llc_first_year_regression() {
let b = two_member_llc();
let cf = b.cf(d(2026, 1, 1), d(2026, 3, 31), &["1000"], &["2100"], &[]);
foots(&cf);
no_distribution(&cf);
// What the members paid, by date: borrowed and spent on its own date.
let paid = [
(1, x("85.00")),
(2, x("12.97")),
(3, x("99.00")),
(6, x("7.49")),
(21, x("216.40")),
(7, x("129.00")),
(20, x("46.38")),
];
let neg = |v: &[(EntryId, Decimal)]| -> Vec<(EntryId, Decimal)> {
v.iter().map(|&(e, a)| (e, -a)).collect()
};
assert_eq!(em(&cf.operating), neg(&paid));
assert!(cf.operating.iter().all(|l| l.on_behalf == l.amount));
assert_eq!(
cf.operating_by_class(),
[(OperatingClass::ToSuppliersAndEmployees, x("-596.24"))]
);
assert_eq!(cf.cash_from_operations, x("-596.24"));
assert!(cf.investing.is_empty());
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
let mut financing = vec![(4, x("1200.00")), (5, x("800.00"))];
financing.extend(paid);
financing.extend([(8, x("-333.46")), (11, x("-46.38"))]);
assert_eq!(em(&cf.financing), financing);
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[
(s("Contributions from members — Member A"), x("1200.00")),
(s("Contributions from members — Member B"), x("800.00")),
(
s("Proceeds from borrowing — 2100 Due to Members"),
x("596.24")
),
(
s("Repayments of borrowing — 2100 Due to Members"),
x("-379.84")
),
]
);
let on_behalf: Decimal = cf.financing.iter().map(|l| l.on_behalf).sum();
assert_eq!(on_behalf, x("596.24"));
assert_eq!(cf.cash_from_financing, x("2216.40"));
assert_eq!(
(cf.cash_beginning, cf.cash_ending, cf.net_change_in_cash),
(x("0.00"), x("1620.16"), x("1620.16"))
);
assert_eq!(
(
cf.reconciles.sections_sum,
cf.reconciles.net_change,
cf.reconciles.difference
),
(x("1620.16"), x("1620.16"), x("0.00"))
);
let r = &cf.operating_reconciliation;
assert_eq!(r.net_income, x("-606.81"));
assert_eq!(
recon(&cf),
[
(s("Other noncash items"), x("39.00")),
(s("Increase in 1300 Prepaid Expenses"), x("-85.38")),
(s("Increase in 2050 Business Card"), x("56.95")),
]
);
assert_eq!(r.lines[0].kind, ReconcilingKind::OtherNoncash);
assert_eq!(r.lines[0].entries, [19]);
assert_eq!(r.net_cash_from_operating, x("-596.24"));
assert_eq!(
disclosed(&cf),
[
(14, x("1480.00")),
(16, x("2210.00")),
(17, x("630.00")),
(19, x("1190.00")),
]
);
let total: Decimal = cf.noncash_disclosures.iter().map(|n| n.amount).sum();
assert_eq!(total, x("5510.00"));
// #19: 1,190 moved into the company without cash; 1,151 of it was an
// exchange inside investing and financing (the laptop for capital).
let e19 = &cf.noncash_disclosures[3];
assert_eq!(
(e19.operating, e19.investing, e19.financing, e19.exchanged),
(x("39.00"), x("1151.00"), x("-1190.00"), x("1151.00"))
);
assert!(cf.warnings.is_empty());
}
/// The same books as of 2026-03-18. By hand: entries #1 to #7 and #21 are
/// dated by then; #9, #20 and #22 are dated 03-20, after it, and the rest
/// later. The members paid #1 85.00, #2 12.97, #3 99.00, #6 7.49, #21
/// 216.40 and #7 129.00 for the company (D-42), 549.86 in all: operating
/// −549.86, class (d); financing #4 +1,200.00, #5 +800.00 and +549.86
/// borrowed from the members, +2,549.86; the change in cash 2,000.00 (#4,
/// #5); net income −549.86 with no reconciling item (no operating balance
/// moved yet); nothing disclosed.
#[test]
fn defect1_two_member_llc_as_of_march_18_regression() {
let b = two_member_llc();
let cf = b.cf(d(2026, 1, 1), d(2026, 3, 18), &["1000"], &["2100"], &[]);
foots(&cf);
let paid = [
(1, x("85.00")),
(2, x("12.97")),
(3, x("99.00")),
(6, x("7.49")),
(21, x("216.40")),
(7, x("129.00")),
];
let mut financing = vec![(4, x("1200.00")), (5, x("800.00"))];
financing.extend(paid);
assert_eq!(em(&cf.financing), financing);
assert_eq!(
em(&cf.operating),
paid.iter().map(|&(e, a)| (e, -a)).collect::<Vec<_>>()
);
assert_eq!(cf.cash_from_operations, x("-549.86"));
assert_eq!(cf.cash_from_financing, x("2549.86"));
assert_eq!(cf.net_change_in_cash, x("2000.00"));
assert_eq!(cf.operating_reconciliation.net_income, x("-549.86"));
assert!(cf.operating_reconciliation.lines.is_empty());
assert!(cf.noncash_disclosures.is_empty());
}
// ---------------------------------------------------------------------------
// The rule, case by case.
// ---------------------------------------------------------------------------
/// A company with every kind of account the rule names. Registered fixed
/// assets on 1500/1510 and 1520/1530 (so they are investing); interest
/// accounts carry their tax categories.
fn co() -> Books {
let mut b = Books::new();
for (code, name, t) in [
("1000", "Cash", AccountType::Asset),
("1010", "Savings", AccountType::Asset),
("1200", "Accounts Receivable", AccountType::Asset),
("1210", "Due from buyer", AccountType::Asset),
("1250", "Note receivable", AccountType::Asset),
("1300", "Prepaid", AccountType::Asset),
("1400", "Loan to member", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("1510", "Accumulated depreciation", AccountType::Asset),
("1520", "Vehicles", AccountType::Asset),
(
"1530",
"Accumulated depreciation, vehicles",
AccountType::Asset,
),
("2000", "Accounts Payable", AccountType::Liability),
("2050", "Card", AccountType::Liability),
("2100", "Due to members", AccountType::Liability),
("2300", "Deferred revenue", AccountType::Liability),
("2500", "Bank loan", AccountType::Liability),
("3000", "Capital — Member A", AccountType::Equity),
("3001", "Capital — Member B", AccountType::Equity),
("4000", "Revenue", AccountType::Revenue),
("4100", "Interest income", AccountType::Revenue),
("4900", "Gain on disposal", AccountType::Revenue),
("6000", "Supplies", AccountType::Expense),
("6100", "Depreciation", AccountType::Expense),
("6200", "Interest expense", AccountType::Expense),
("6900", "Loss on disposal", AccountType::Expense),
] {
b.acct(code, name, t);
}
let (ii, ie) = (b.id("4100"), b.id("6200"));
b.l.set_account_tax(ii, Some("interest_income"), None)
.unwrap();
b.l.set_account_tax(ie, Some("interest_expense"), None)
.unwrap();
b.asset("1500", "1510", "6100", "1000.00");
b.asset("1520", "1530", "6100", "2000.00");
b.member("Member A", "3000");
b.member("Member B", "3001");
b
}
/// Opening cash: Member A contributes 10,000 on Jan 2 (before every
/// period the tests below look at).
fn funded() -> Books {
let mut b = co();
b.post(
d(2026, 1, 2),
"Opening contribution",
&[("1000", DR, "10000.00"), ("3000", CR, "10000.00")],
);
b
}
const FEB: (NaiveDate, NaiveDate) = (
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 2, 28).unwrap(),
);
fn feb(b: &Books, financing: &[&str], investing: &[&str]) -> CashFlowStatement {
let cf = b.cf(FEB.0, FEB.1, &["1000", "1010"], financing, investing);
foots(&cf);
cf
}
/// ASC 230-10-45-12(c): "Receipts from sales of property, plant, and
/// equipment" are investing, so the whole proceeds are; 45-28(b): the
/// gain or loss is removed from net income in the reconciliation.
/// (Plan-named test.) Bought for 1,000 cash, 800 depreciated, then:
/// - sold for 500 (Dr cash 500, Dr accum 800 / Cr cost 1,000, Cr gain
/// 300): investing +500, operating 0, net income +300, G −300;
/// - in other books, sold for 100 at a 100 loss: investing +100, G +100;
/// - in other books, sold for 600 before any depreciation (no accumulated
/// depreciation line, defect 1's other shape): investing +600, G +400.
#[test]
fn disposal_proceeds_all_investing() {
let sale = |proceeds: &str, accum: &str, gain_or_loss: (&str, Side, &str)| {
let mut b = funded();
b.post(
d(2026, 1, 10),
"Buy equipment",
&[("1500", DR, "1000.00"), ("1000", CR, "1000.00")],
);
if accum != "0" {
b.post(
d(2026, 1, 31),
"Depreciation",
&[("6100", DR, accum), ("1510", CR, accum)],
);
}
let mut lines = vec![("1000", DR, proceeds), ("1500", CR, "1000.00")];
if accum != "0" {
lines.push(("1510", DR, accum));
}
lines.push(gain_or_loss);
b.post(d(2026, 2, 15), "Sell equipment", &lines);
feb(&b, &[], &[])
};
let gain = sale("500.00", "800.00", ("4900", CR, "300.00"));
assert_eq!(
kinds(&gain, CashFlowSection::Investing),
[(
s("Proceeds from sales of property and equipment"),
x("500.00")
)]
);
assert!(gain.operating.is_empty());
assert_eq!(gain.operating_reconciliation.net_income, x("300.00"));
assert_eq!(
recon(&gain),
[(
s("(Gain) loss and other noncash changes in investing assets"),
x("-300.00")
)]
);
assert!(gain.noncash_disclosures.is_empty());
let loss = sale("100.00", "800.00", ("6900", DR, "100.00"));
assert_eq!(loss.cash_from_investing, x("100.00"));
assert_eq!(loss.cash_from_operations, Decimal::ZERO);
assert_eq!(recon(&loss)[0].1, x("100.00"));
let early = sale("600.00", "0", ("6900", DR, "400.00"));
assert_eq!(early.cash_from_investing, x("600.00"));
assert_eq!(early.cash_from_operations, Decimal::ZERO);
assert_eq!(early.operating_reconciliation.net_income, x("-400.00"));
assert_eq!(recon(&early)[0].1, x("400.00"));
}
/// A loss on disposal is a loss whichever account type holds it (the
/// defect found on main: the old code counted only revenue credits and
/// expense debits, so a loss debited to a revenue-typed "Gain (loss) on
/// disposal" account tripped a debug assertion and came out wrong in
/// release). Bought for 1,000 cash, 800 depreciated, sold for 100: a loss
/// of 100 (Dr cash 100, Dr accum 800, Dr loss 100 / Cr cost 1,000).
/// - Booked to 4900, a revenue account (a debit to it): investing +100
/// (ASC 230-10-45-12(c), the whole proceeds), operating 0, net income
/// −100, and the reconciliation adds the loss back (45-28(b)): +100.
/// - Booked to 6900, an expense account: the same figures.
#[test]
fn a_loss_on_disposal_on_a_revenue_or_expense_account_is_investing() {
for loss_account in ["4900", "6900"] {
let mut b = funded();
b.post(
d(2026, 1, 10),
"Buy equipment",
&[("1500", DR, "1000.00"), ("1000", CR, "1000.00")],
);
b.post(
d(2026, 1, 31),
"Depreciation",
&[("6100", DR, "800.00"), ("1510", CR, "800.00")],
);
let sale = b.post(
d(2026, 2, 15),
"Sell equipment at a loss",
&[
("1000", DR, "100.00"),
("1510", DR, "800.00"),
(loss_account, DR, "100.00"),
("1500", CR, "1000.00"),
],
);
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.investing), [(sale, x("100.00"))], "{loss_account}");
assert_eq!(
cf.investing[0].kind,
"Proceeds from sales of property and equipment"
);
assert!(cf.operating.is_empty(), "{loss_account}");
assert_eq!(cf.operating_reconciliation.net_income, x("-100.00"));
assert_eq!(
recon(&cf),
[(
s("(Gain) loss and other noncash changes in investing assets"),
x("100.00")
)],
"{loss_account}"
);
assert!(cf.noncash_disclosures.is_empty());
}
}
/// Equipment sold for an IOU (Dr receivable 500, Dr accum 800 / Cr cost
/// 1,000, Cr gain 300). No cash moved, so no line; the noncash investing
/// part is disclosed (ASC 230-10-50-3): 500. Its later collection is
/// operating by the partition, though 45-12(c) makes it investing: D-35
/// flags it, with the fix (review item 4: it is not "(a) from customers";
/// on a receivable that isn't the customers' control account it is (c)
/// other). With the fix (the receivable on a note listed as investing),
/// the sale is disclosed as an exchange of 500 and the collection is
/// investing.
#[test]
fn a_sale_on_account_is_a_noncash_disclosure() {
let sell = |b: &mut Books, on: &'static str| {
b.post(
d(2026, 1, 31),
"Depreciation",
&[("6100", DR, "800.00"), ("1510", CR, "800.00")],
);
b.post(
d(2026, 2, 10),
"Sell equipment on account",
&[
(on, DR, "500.00"),
("1510", DR, "800.00"),
("1500", CR, "1000.00"),
("4900", CR, "300.00"),
],
);
b.post(
d(2026, 2, 20),
"Buyer pays",
&[("1000", DR, "500.00"), (on, CR, "500.00")],
)
};
let mut b = funded();
let collected = sell(&mut b, "1210");
let cf = feb(&b, &[], &[]);
assert!(cf.investing.is_empty());
assert_eq!(disclosed(&cf), [(collected - 1, x("500.00"))]);
let n = &cf.noncash_disclosures[0];
assert_eq!((n.operating, n.investing), (x("500.00"), x("-500.00")));
assert_eq!(em(&cf.operating), [(collected, x("500.00"))]);
assert_eq!(cf.operating[0].class, Some(OperatingClass::OtherReceipts));
assert_eq!(cf.warnings.len(), 1);
assert_eq!(
cf.warnings[0].kind,
CashFlowWarningKind::AssetSoldOnOperatingReceivable
);
assert!(cf.warnings[0].message.contains("books.investing_accounts"));
let mut fixed = funded();
let collected = sell(&mut fixed, "1250");
let cf = feb(&fixed, &[], &["1250"]);
assert!(cf.warnings.is_empty());
assert_eq!(disclosed(&cf), [(collected - 1, x("500.00"))]);
assert_eq!(cf.noncash_disclosures[0].exchanged, x("500.00"));
assert!(cf.operating.is_empty());
assert_eq!(
kinds(&cf, CashFlowSection::Investing),
[(s("Collections on 1250 Note receivable"), x("500.00"))]
);
}
/// A member's contribution of property (Dr Equipment 1,340 / Cr
/// Capital): no cash, so no line in any section (230-10-45-1: the
/// statement reports cash effects), disclosed in full: 1,340 (50-3, 50-4).
#[test]
fn a_property_contribution_is_a_noncash_disclosure() {
let mut b = funded();
let e = b.post(
d(2026, 2, 3),
"Laptop contributed",
&[("1500", DR, "1340.00"), ("3000", CR, "1340.00")],
);
let cf = feb(&b, &[], &[]);
for sec in CashFlowSection::ALL {
assert!(cf.lines(sec).is_empty());
}
assert_eq!(disclosed(&cf), [(e, x("1340.00"))]);
let n = &cf.noncash_disclosures[0];
assert_eq!(
(n.operating, n.investing, n.financing, n.exchanged),
(x("0"), x("1340.00"), x("-1340.00"), x("1340.00"))
);
assert_eq!(n.label, format!("Laptop contributed (#{e})"));
}
/// D-42, rules.md B-14 rule 11 (ASC 230 is silent on a payment a third
/// party makes on the entity's behalf; PwC FSP 6.9.3.1, DART Roadmap 7.2
/// and BDO Blueprint 3.4 present it by substance, as constructive receipt
/// and disbursement). An expense a member pays personally (Dr Supplies
/// 100 / Cr 2100, the members' financing account) is cash the company
/// borrowed from the member (45-14(b)) and spent (45-17(b)), both on the
/// entry's date: financing +100 under "Proceeds from borrowing — 2100",
/// operating −100, class (d); each line's `on_behalf` is all of it. No
/// disclosure, no reconciling item but net income (−100 = operating).
/// Replaces `an_expense_a_member_paid_is_a_noncash_financing_disclosure`
/// (D-35's first point, revised by D-42). With 2100 not financing it is an
/// operating payable: the expense and the payable net, nothing moves.
#[test]
fn an_expense_a_member_paid_is_borrowed_and_spent() {
let mut b = funded();
let e = b.post(
d(2026, 2, 5),
"Software, paid by Member A",
&[("6000", DR, "100.00"), ("2100", CR, "100.00")],
);
let cf = feb(&b, &["2100"], &[]);
assert_eq!(em(&cf.operating), [(e, x("-100.00"))]);
assert_eq!(
cf.operating[0].class,
Some(OperatingClass::ToSuppliersAndEmployees)
);
assert_eq!(cf.operating[0].on_behalf, x("-100.00"));
assert_eq!(cf.operating[0].accounts, [s("6000")]);
assert_eq!(em(&cf.financing), [(e, x("100.00"))]);
assert_eq!(
cf.financing[0].kind,
"Proceeds from borrowing — 2100 Due to members"
);
assert_eq!(cf.financing[0].on_behalf, x("100.00"));
assert_eq!(cf.financing[0].date, d(2026, 2, 5));
assert!(cf.investing.is_empty());
assert_eq!(cf.net_change_in_cash, Decimal::ZERO);
assert!(cf.noncash_disclosures.is_empty());
assert_eq!(cf.operating_reconciliation.net_income, x("-100.00"));
assert!(cf.operating_reconciliation.lines.is_empty());
let cf = feb(&b, &[], &[]);
assert!(cf.operating.is_empty() && cf.financing.is_empty());
assert!(cf.noncash_disclosures.is_empty());
assert_eq!(
recon(&cf),
[(s("Increase in 2100 Due to members"), x("100.00"))]
);
}
/// D-42, ASC 230-10-45-15(b): reimbursing the member (Dr 2100 / Cr cash
/// 100) is a repayment of borrowing, −100, the only line of its own; the
/// expense the member paid was borrowed and spent on its own date (above).
/// February: operating −100 (#e), financing +100 (#e) −100 (#r), the
/// change in cash −100. With 2100 not financing the reimbursement is an
/// operating payment to a supplier, class (d), and the expense an accrual.
#[test]
fn reimbursing_a_member_is_a_repayment_of_borrowing() {
let mut b = funded();
let e = b.post(
d(2026, 2, 5),
"Software, paid by Member A",
&[("6000", DR, "100.00"), ("2100", CR, "100.00")],
);
let r = b.post(
d(2026, 2, 25),
"Reimburse Member A",
&[("2100", DR, "100.00"), ("1000", CR, "100.00")],
);
let cf = feb(&b, &["2100"], &[]);
assert_eq!(em(&cf.financing), [(e, x("100.00")), (r, x("-100.00"))]);
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[
(
s("Proceeds from borrowing — 2100 Due to members"),
x("100.00")
),
(
s("Repayments of borrowing — 2100 Due to members"),
x("-100.00")
),
]
);
assert_eq!(cf.financing[1].on_behalf, Decimal::ZERO);
assert_eq!(em(&cf.operating), [(e, x("-100.00"))]);
assert_eq!(cf.net_change_in_cash, x("-100.00"));
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.operating), [(r, x("-100.00"))]);
assert_eq!(
cf.operating[0].class,
Some(OperatingClass::ToSuppliersAndEmployees)
);
assert_eq!(cf.operating[0].on_behalf, Decimal::ZERO);
}
/// D-42 for a lender (DART Roadmap 7.2's third-party financing; BDO
/// Blueprint 3.4, Example 3-5: a bank remits the price of equipment to
/// the supplier): Dr Vehicles 2,000 / Cr Bank loan 2,000, the loan
/// financing, no cash. Investing −2,000 ("Purchases of property and
/// equipment", 45-13(c)), financing +2,000 ("Proceeds from borrowing —
/// 2500", 45-14(b)), both the entry's date; nothing disclosed (nothing was
/// exchanged once the constructive cash is counted); no warning. Its first
/// repayment of 500 is financing −500 (45-15(b)).
#[test]
fn equipment_a_lender_paid_for_is_borrowed_and_invested() {
let mut b = funded();
let e = b.post(
d(2026, 2, 6),
"Van, paid by the bank",
&[("1520", DR, "2000.00"), ("2500", CR, "2000.00")],
);
let p = b.post(
d(2026, 2, 27),
"Loan payment",
&[("2500", DR, "500.00"), ("1000", CR, "500.00")],
);
let cf = feb(&b, &["2500"], &[]);
assert!(cf.operating.is_empty());
assert_eq!(em(&cf.investing), [(e, x("-2000.00"))]);
assert_eq!(cf.investing[0].kind, "Purchases of property and equipment");
assert_eq!(cf.investing[0].on_behalf, x("-2000.00"));
assert_eq!(em(&cf.financing), [(e, x("2000.00")), (p, x("-500.00"))]);
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[
(s("Proceeds from borrowing — 2500 Bank loan"), x("2000.00")),
(s("Repayments of borrowing — 2500 Bank loan"), x("-500.00")),
]
);
assert!(cf.noncash_disclosures.is_empty());
assert!(cf.warnings.is_empty());
assert_eq!(cf.net_change_in_cash, x("-500.00"));
}
/// The expense-to-capital case (rules.md B-14 rule 11): a member pays a
/// vendor for the company and the books credit the member's capital (Dr
/// Supplies 80 / Cr Capital — Member A), no cash. The member paid cash to
/// a third party on the company's behalf, as through 2100; only the form
/// of what the company owes back differs (nothing: capital). BDO Blueprint
/// 3.4 names this case (a parent paying a subsidiary's expenses) among
/// those for constructive cash flows, and the books' own K-1 worksheet
/// already counts such an entry as a cash contribution (k1.rs INVARIANT
/// 3). So: financing +80 "Contributions from members — Member A"
/// (45-14(a)), operating −80, class (d); nothing disclosed. A guaranteed
/// payment credited to capital is the member's own charge (no one was
/// paid): it stays noncash, reconciled through 3000 and disclosed.
#[test]
fn an_expense_a_member_paid_into_capital_is_contributed_and_spent() {
let mut b = funded();
let gp = b.acct("6600", "Guaranteed payments", AccountType::Expense);
b.l.set_account_tax(gp, Some("guaranteed_payments"), None)
.unwrap();
let e = b.post(
d(2026, 2, 7),
"Supplies, paid by Member A, contributed",
&[("6000", DR, "80.00"), ("3000", CR, "80.00")],
);
let g = b.post(
d(2026, 2, 28),
"Guaranteed payment to Member A, credited to capital",
&[("6600", DR, "300.00"), ("3000", CR, "300.00")],
);
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.operating), [(e, x("-80.00"))]);
assert_eq!(
cf.operating[0].class,
Some(OperatingClass::ToSuppliersAndEmployees)
);
assert_eq!(em(&cf.financing), [(e, x("80.00"))]);
assert_eq!(
cf.financing[0].kind,
"Contributions from members — Member A"
);
assert_eq!(cf.financing[0].on_behalf, x("80.00"));
assert_eq!(disclosed(&cf), [(g, x("300.00"))]);
assert_eq!(
recon(&cf),
[(s("Noncash: through 3000 Capital — Member A"), x("300.00"))]
);
assert_eq!(cf.operating_reconciliation.net_income, x("-380.00"));
}
/// D-42 keeps property a member already owned noncash (ASC 230-10-50-4:
/// no cash moved, to anyone): a laptop and its service plan contributed
/// (Dr Equipment 1,000, Dr Prepaid 60 / Cr Capital 1,060). Only revenue
/// and expense lines can a member pay for through capital (rule 11), so
/// nothing is constructive: no line; disclosed 1,060 (the laptop 1,000
/// exchanged for capital); the prepaid's 60 reconciled as other noncash.
#[test]
fn property_a_member_contributes_stays_noncash() {
let mut b = funded();
let e = b.post(
d(2026, 2, 3),
"Laptop and its service plan contributed",
&[
("1500", DR, "1000.00"),
("1300", DR, "60.00"),
("3000", CR, "1060.00"),
],
);
let cf = feb(&b, &["2100"], &[]);
for sec in CashFlowSection::ALL {
assert!(cf.lines(sec).is_empty(), "{sec:?}");
}
assert_eq!(disclosed(&cf), [(e, x("1060.00"))]);
let n = &cf.noncash_disclosures[0];
assert_eq!(
(n.operating, n.investing, n.financing, n.exchanged),
(x("60.00"), x("1000.00"), x("-1060.00"), x("1000.00"))
);
assert_eq!(
recon(&cf),
[
(s("Other noncash items"), x("60.00")),
(s("Increase in 1300 Prepaid"), x("-60.00")),
]
);
// The same with no members configured (equity named by account, not
// a member's): still property for capital, noncash; while an expense
// an owner paid into that capital is contributed and spent.
let mut b = funded();
b.members.clear();
let e = b.post(
d(2026, 2, 3),
"Laptop contributed",
&[("1500", DR, "1000.00"), ("3000", CR, "1000.00")],
);
let x2 = b.post(
d(2026, 2, 4),
"Supplies, paid by the owner, contributed",
&[("6000", DR, "30.00"), ("3000", CR, "30.00")],
);
let cf = feb(&b, &["2100"], &[]);
assert!(cf.investing.is_empty());
assert_eq!(disclosed(&cf), [(e, x("1000.00"))]);
assert_eq!(em(&cf.operating), [(x2, x("-30.00"))]);
assert_eq!(em(&cf.financing), [(x2, x("30.00"))]);
assert_eq!(
cf.financing[0].kind,
"Contributions from owners — 3000 Capital — Member A"
);
}
/// Seller financing stays noncash (ASC 230-10-50-4: "acquiring assets by
/// assuming directly related liabilities"; EY FRD 5.3.1): equipment
/// bought on the seller's own credit, an operating payable (Dr Equipment
/// 1,200 / Cr Accounts Payable 1,200). No line; disclosed 1,200; flagged
/// `asset_on_operating_payable` (D-35's IOU flag), whose text says the
/// purchase is noncash and how to list a lender's credit instead.
#[test]
fn equipment_on_the_sellers_credit_stays_noncash_and_flagged() {
let mut b = funded();
let e = b.post(
d(2026, 2, 9),
"Equipment on the seller's terms",
&[("1500", DR, "1200.00"), ("2000", CR, "1200.00")],
);
let cf = feb(&b, &["2100", "2500"], &[]);
for sec in CashFlowSection::ALL {
assert!(cf.lines(sec).is_empty(), "{sec:?}");
}
assert_eq!(disclosed(&cf), [(e, x("1200.00"))]);
assert_eq!(cf.warnings.len(), 1);
let w = &cf.warnings[0];
assert_eq!(
(w.entry, w.kind),
(e, CashFlowWarningKind::AssetOnOperatingPayable)
);
assert!(w.message.contains("seller's own credit"), "{}", w.message);
assert!(w.message.contains("borrowed and spent"), "{}", w.message);
}
/// rules.md B-14 rule 6 with rule 11: a payment a member made for the
/// company and its reversal, both in February, drop out together (their
/// flows, constructive cash included, are exact negatives: Kani
/// `constructive_mirrors`); no line, no disclosure, no total moves. The
/// reversal alone (its original in January) is listed under the
/// original's kinds and class with its own sign: operating +100 class
/// (d), financing −100 under "Proceeds from borrowing — 2100", both
/// labelled "Reversal of #e", `on_behalf` too.
#[test]
fn a_payment_made_for_the_company_and_its_reversal_net() {
let mut b = funded();
let before = b.cf(FEB.0, FEB.1, &["1000", "1010"], &["2100"], &[]);
let e = b.post(
d(2026, 2, 5),
"Software, paid by Member A",
&[("6000", DR, "100.00"), ("2100", CR, "100.00")],
);
b.reverse(e, d(2026, 2, 6));
let cf = feb(&b, &["2100"], &[]);
for sec in CashFlowSection::ALL {
assert!(cf.lines(sec).is_empty(), "{sec:?}");
assert_eq!(cf.total(sec), before.total(sec));
}
assert!(cf.noncash_disclosures.is_empty());
assert!(cf.operating_reconciliation.lines.is_empty());
let mut b = funded();
let e = b.post(
d(2026, 1, 20),
"Software, paid by Member A",
&[("6000", DR, "100.00"), ("2100", CR, "100.00")],
);
let r = b.reverse(e, d(2026, 2, 6));
let cf = feb(&b, &["2100"], &[]);
assert_eq!(em(&cf.operating), [(r, x("100.00"))]);
assert_eq!(
cf.operating[0].class,
Some(OperatingClass::ToSuppliersAndEmployees)
);
assert_eq!(cf.operating[0].on_behalf, x("100.00"));
assert_eq!(em(&cf.financing), [(r, x("-100.00"))]);
assert_eq!(
cf.financing[0].kind,
"Proceeds from borrowing — 2100 Due to members"
);
assert_eq!(cf.financing[0].on_behalf, x("-100.00"));
assert_eq!(
cf.financing[0].label,
format!("Reversal of #{e} — Software, paid by Member A")
);
assert!(cf.noncash_disclosures.is_empty());
no_distribution(&cf);
// The same for what only a payment can settle (a prepaid, equipment a
// lender paid for): the reversal alone receives back what its root
// paid, under the root's kinds: operating +50, investing +2,000,
// financing −2,050; nothing disclosed.
let mut b = funded();
let p = b.post(
d(2026, 1, 20),
"Insurance, prepaid by Member A",
&[("1300", DR, "50.00"), ("2100", CR, "50.00")],
);
let v = b.post(
d(2026, 1, 21),
"Van, paid by the bank",
&[("1520", DR, "2000.00"), ("2500", CR, "2000.00")],
);
let rp = b.reverse(p, d(2026, 2, 6));
let rv = b.reverse(v, d(2026, 2, 7));
let cf = feb(&b, &["2100", "2500"], &[]);
assert_eq!(em(&cf.operating), [(rp, x("50.00"))]);
assert_eq!(em(&cf.investing), [(rv, x("2000.00"))]);
assert_eq!(cf.investing[0].kind, "Purchases of property and equipment");
assert_eq!(em(&cf.financing), [(rp, x("-50.00")), (rv, x("-2000.00"))]);
assert!(cf.noncash_disclosures.is_empty());
}
/// Rule 11, part paid by the company and part by a member, in one entry
/// (Dr Supplies 100 / Cr Cash 40 / Cr 2100 60): the cash settles 40
/// (50-5), the member the other 60, so operating −100 with 60 on the
/// company's behalf, financing +60: the same as the two entries apart.
/// And money a member received for the company (a vendor's refund of 30
/// to the member's card, kept against what the company owes them: Dr 2100
/// / Cr Supplies 30) is collected and repaid: operating +30, financing
/// −30 (PwC FSP 6.9.3.1: cash "received or paid" by a third party).
#[test]
fn part_paid_by_a_member_and_a_refund_a_member_received() {
let mut b = funded();
let e = b.post(
d(2026, 2, 5),
"Supplies, part paid by Member A",
&[
("6000", DR, "100.00"),
("1000", CR, "40.00"),
("2100", CR, "60.00"),
],
);
let r = b.post(
d(2026, 2, 12),
"Refund of supplies, to Member A's card",
&[("2100", DR, "30.00"), ("6000", CR, "30.00")],
);
let cf = feb(&b, &["2100"], &[]);
// By class: the refund is (c), other receipts; the supplies (d).
assert_eq!(em(&cf.operating), [(r, x("30.00")), (e, x("-100.00"))]);
assert_eq!(cf.operating[0].class, Some(OperatingClass::OtherReceipts));
assert_eq!(cf.operating[0].on_behalf, x("30.00"));
assert_eq!(cf.operating[1].on_behalf, x("-60.00"));
assert_eq!(em(&cf.financing), [(e, x("60.00")), (r, x("-30.00"))]);
assert_eq!(cf.net_change_in_cash, x("-40.00"));
assert!(cf.noncash_disclosures.is_empty());
}
/// Rule 11's CONVENTION, pinned: what a lender or the members' account
/// pays for goes to operating balance-sheet lines before revenue and
/// expense, so a member's capital (which can pay only for revenue and
/// expense) is left the expenses. Supplies 100 and a prepaid 50, paid 50
/// through 2100 and 100 into Member A's capital (Dr 6000 100, Dr 1300 50
/// / Cr 2100 50, Cr 3000 100): 2100 pays the prepaid, capital the
/// supplies; all of it constructive (operating −150, financing +150),
/// nothing disclosed. Taken the other way round, 2100 would pay 50 of the
/// supplies, capital the other 50, and the prepaid's 50 would be left a
/// noncash contribution. And a customer paying a member for an invoice
/// (Dr 2100 / Cr Accounts Receivable 70) is collected for the company:
/// operating +70, class (a), and a repayment −70.
#[test]
fn a_lender_pays_balance_sheet_lines_first_and_a_customer_pays_a_member() {
let mut b = funded();
let e = b.post(
d(2026, 2, 5),
"Supplies and a prepaid, paid by Member A",
&[
("6000", DR, "100.00"),
("1300", DR, "50.00"),
("2100", CR, "50.00"),
("3000", CR, "100.00"),
],
);
let c = b.post(
d(2026, 2, 9),
"Customer paid Member A",
&[("2100", DR, "70.00"), ("1200", CR, "70.00")],
);
let cf = feb(&b, &["2100"], &[]);
assert_eq!(em(&cf.operating), [(c, x("70.00")), (e, x("-150.00"))]);
assert_eq!(cf.operating[0].class, Some(OperatingClass::FromCustomers));
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[
(s("Contributions from members — Member A"), x("100.00")),
(
s("Proceeds from borrowing — 2100 Due to members"),
x("50.00")
),
(
s("Repayments of borrowing — 2100 Due to members"),
x("-70.00")
),
]
);
assert!(cf.noncash_disclosures.is_empty());
}
/// Rule 11's exceptions: interest charged into a loan (Dr Interest
/// expense 25 / Cr Bank loan 25) and a guaranteed payment credited to what
/// the company owes a member (Dr Guaranteed payments 300 / Cr 2100) are
/// the lender's and the member's own charges, paid to no one: noncash.
/// Each is disclosed as a noncash financing activity (50-3) and reconciled
/// through its account; no line. ("Interest paid", 45-25(e), is cash
/// paid.)
#[test]
fn interest_and_guaranteed_payments_charged_to_a_creditor_stay_noncash() {
let mut b = funded();
let gp = b.acct("6600", "Guaranteed payments", AccountType::Expense);
b.l.set_account_tax(gp, Some("guaranteed_payments"), None)
.unwrap();
let i = b.post(
d(2026, 2, 28),
"Interest added to the loan",
&[("6200", DR, "25.00"), ("2500", CR, "25.00")],
);
let g = b.post(
d(2026, 2, 28),
"Guaranteed payment owed to Member A",
&[("6600", DR, "300.00"), ("2100", CR, "300.00")],
);
let cf = feb(&b, &["2100", "2500"], &[]);
for sec in CashFlowSection::ALL {
assert!(cf.lines(sec).is_empty(), "{sec:?}");
}
assert_eq!(disclosed(&cf), [(i, x("25.00")), (g, x("300.00"))]);
assert_eq!(
recon(&cf),
[
(s("Noncash: through 2100 Due to members"), x("300.00")),
(s("Noncash: through 2500 Bank loan"), x("25.00")),
]
);
}
/// rules.md B-14 rule 6 (B-9: a correction undoes what it corrects): an
/// entry and its reversal both in the period drop out of every line and
/// disclosure, and no total moves (their flows are exact negatives, Kani
/// `entry_flows_of_a_reversal_mirror`).
#[test]
fn an_entry_and_its_reversal_in_the_period_drop_out() {
let mut b = funded();
b.post(
d(2026, 2, 3),
"Supplies",
&[("6000", DR, "40.00"), ("1000", CR, "40.00")],
);
let before = feb(&b, &["2100"], &[]);
let mut after = funded();
after.post(
d(2026, 2, 3),
"Supplies",
&[("6000", DR, "40.00"), ("1000", CR, "40.00")],
);
let c = after.post(
d(2026, 2, 4),
"Contribution",
&[("1000", DR, "700.00"), ("3001", CR, "700.00")],
);
let p = after.post(
d(2026, 2, 5),
"Laptop contributed",
&[("1500", DR, "900.00"), ("3001", CR, "900.00")],
);
after.reverse(c, d(2026, 2, 20));
after.reverse(p, d(2026, 2, 21));
let cf = feb(&after, &["2100"], &[]);
assert_eq!(em(&cf.operating), em(&before.operating));
assert!(cf.financing.is_empty() && cf.investing.is_empty());
assert!(cf.noncash_disclosures.is_empty());
assert_eq!(cf.cash_from_financing, Decimal::ZERO);
assert_eq!(cf.net_change_in_cash, before.net_change_in_cash);
}
/// rules.md B-14 rule 6: a reversal shown alone (its original in an
/// earlier period) is labelled "Reversal of #N — <N's description>" and
/// listed under its original's kind, never as a distribution (D1-c).
#[test]
fn a_reversal_alone_is_labelled_reversal_of() {
let mut b = funded();
let c = b.post(
d(2026, 1, 20),
"Contribution — Member B",
&[("1000", DR, "700.00"), ("3001", CR, "700.00")],
);
let r = b.reverse(c, d(2026, 2, 10));
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.financing), [(r, x("-700.00"))]);
let line = &cf.financing[0];
assert_eq!(
line.label,
format!("Reversal of #{c} — Contribution — Member B")
);
assert_eq!(line.kind, "Contributions from members — Member B");
assert_eq!(line.reverses, Some(c));
no_distribution(&cf);
}
/// rules.md B-14 rules 4 and 6: a reversal takes its chain root's
/// sections, class and kind, with its own sign. Reversing January's cash
/// sale at a 300 gain in February: investing −500 under "Proceeds from
/// sales of property and equipment", G +300 (the gain, reversed, is still
/// investing). Reversing January's supplier payment of 100: operating
/// +100 under (d).
#[test]
fn a_reversal_takes_its_originals_sections_and_class() {
let mut b = funded();
b.post(
d(2026, 1, 3),
"Buy equipment",
&[("1500", DR, "1000.00"), ("1000", CR, "1000.00")],
);
b.post(
d(2026, 1, 5),
"Depreciation",
&[("6100", DR, "800.00"), ("1510", CR, "800.00")],
);
let sale = b.post(
d(2026, 1, 25),
"Sell equipment",
&[
("1000", DR, "500.00"),
("1510", DR, "800.00"),
("1500", CR, "1000.00"),
("4900", CR, "300.00"),
],
);
let pay = b.post(
d(2026, 1, 26),
"Supplies",
&[("6000", DR, "100.00"), ("1000", CR, "100.00")],
);
let rs = b.reverse(sale, d(2026, 2, 2));
let rp = b.reverse(pay, d(2026, 2, 3));
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.investing), [(rs, x("-500.00"))]);
assert_eq!(
cf.investing[0].kind,
"Proceeds from sales of property and equipment"
);
assert_eq!(cf.operating_reconciliation.net_income, x("-200.00"));
assert_eq!(
recon(&cf),
[(
s("(Gain) loss and other noncash changes in investing assets"),
x("300.00")
)]
);
assert_eq!(em(&cf.operating), [(rp, x("100.00"))]);
assert_eq!(
cf.operating[0].class,
Some(OperatingClass::ToSuppliersAndEmployees)
);
assert!(cf.noncash_disclosures.is_empty());
}
/// Review item 1: a reversal of a reversal. A ← B ← C all in the period:
/// pairing walks the chain from its root, so (A, B) drop out and C is
/// shown, "Reinstatement of #A (reverses #B)", under A's kind and with
/// A's sections (a cash sale at a gain: C's gain is investing). With A
/// before the period, (B, C) pair and nothing is shown; with C after it,
/// B is shown alone.
#[test]
fn a_reversal_of_a_reversal_is_shown_and_foots() {
let mut b = funded();
let a = b.post(
d(2026, 2, 2),
"Contribution — Member B",
&[("1000", DR, "300.00"), ("3001", CR, "300.00")],
);
let r = b.reverse(a, d(2026, 2, 3));
let c = b.reverse(r, d(2026, 2, 4));
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.financing), [(c, x("300.00"))]);
assert_eq!(
cf.financing[0].label,
format!("Reinstatement of #{a} (reverses #{r})")
);
assert_eq!(
cf.financing[0].kind,
"Contributions from members — Member B"
);
// A disposal chain: C takes A's sections.
let mut b = funded();
b.post(
d(2026, 1, 3),
"Buy equipment",
&[("1500", DR, "1000.00"), ("1000", CR, "1000.00")],
);
let a = b.post(
d(2026, 2, 2),
"Sell equipment",
&[
("1000", DR, "1300.00"),
("1500", CR, "1000.00"),
("4900", CR, "300.00"),
],
);
let r = b.reverse(a, d(2026, 2, 3));
let c = b.reverse(r, d(2026, 2, 4));
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.investing), [(c, x("1300.00"))]);
assert!(cf.operating.is_empty());
assert_eq!(recon(&cf)[0].1, x("-300.00"));
// The root before the period: B and C pair.
let mut b = funded();
let a = b.post(
d(2026, 1, 20),
"Contribution — Member B",
&[("1000", DR, "300.00"), ("3001", CR, "300.00")],
);
let r = b.reverse(a, d(2026, 2, 3));
b.reverse(r, d(2026, 2, 4));
let cf = feb(&b, &[], &[]);
assert!(cf.financing.is_empty());
assert_eq!(cf.cash_from_financing, Decimal::ZERO);
// The reinstatement after the period: B alone.
let mut b = funded();
let a = b.post(
d(2026, 1, 20),
"Contribution — Member B",
&[("1000", DR, "300.00"), ("3001", CR, "300.00")],
);
let r = b.reverse(a, d(2026, 2, 3));
b.reverse(r, d(2026, 3, 4));
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.financing), [(r, x("-300.00"))]);
assert!(cf.financing[0].label.starts_with("Reversal of #"));
}
/// ASC 230-10-50-5: "only the cash portion shall be reported". Equipment
/// 1,000 for 300 cash and 700 owed to the seller on its own terms (2000
/// Accounts Payable, operating): investing −300; the 700 is disclosed
/// (investing +700, operating −700: 50-4's "acquiring assets by assuming
/// directly related liabilities") and flagged. The same purchase with the
/// 700 lent by a bank that paid the seller (2500, financing) is D-42's:
/// investing −1,000 (700 of it on the company's behalf), financing +700,
/// nothing disclosed.
#[test]
fn part_cash_part_noncash_reports_only_the_cash() {
let mut b = funded();
let e = b.post(
d(2026, 2, 8),
"Equipment, part on the seller's terms",
&[
("1520", DR, "1000.00"),
("1000", CR, "300.00"),
("2000", CR, "700.00"),
],
);
let cf = feb(&b, &["2500"], &[]);
assert_eq!(em(&cf.investing), [(e, x("-300.00"))]);
assert_eq!(cf.investing[0].kind, "Purchases of property and equipment");
assert_eq!(cf.investing[0].on_behalf, Decimal::ZERO);
assert!(cf.financing.is_empty() && cf.operating.is_empty());
assert_eq!(disclosed(&cf), [(e, x("700.00"))]);
let n = &cf.noncash_disclosures[0];
assert_eq!(
(n.operating, n.investing, n.financing, n.exchanged),
(x("-700.00"), x("700.00"), x("0"), x("0"))
);
assert_eq!(cf.warnings.len(), 1);
let mut b = funded();
let e = b.post(
d(2026, 2, 8),
"Equipment, part on a bank loan",
&[
("1520", DR, "1000.00"),
("1000", CR, "300.00"),
("2500", CR, "700.00"),
],
);
let cf = feb(&b, &["2500"], &[]);
assert_eq!(em(&cf.investing), [(e, x("-1000.00"))]);
assert_eq!(cf.investing[0].on_behalf, x("-700.00"));
assert_eq!(em(&cf.financing), [(e, x("700.00"))]);
assert_eq!(cf.financing[0].on_behalf, x("700.00"));
assert!(cf.noncash_disclosures.is_empty());
assert!(cf.warnings.is_empty());
}
/// A fee the bank withholds from a 10,000 loan (Dr cash 9,900, Dr fee 100
/// / Cr loan 10,000). Posted to an expense account, the fee is an
/// operating payment the lender made out of the loan for the company
/// (D-42): financing +10,000 (9,900 cash, 100 on behalf), operating −100.
/// Debt issue costs are financing (ASC 230-10-45-15(f)): posted to a
/// contra-liability listed in books.financing_accounts, the loan reads as
/// its net proceeds, financing +9,900, the 100 a noncash exchange inside
/// financing (disclosed 100).
#[test]
fn a_fee_withheld_from_a_loan_follows_its_account() {
let mut b = funded();
let e = b.post(
d(2026, 2, 8),
"Bank loan, fee withheld",
&[
("1000", DR, "9900.00"),
("6000", DR, "100.00"),
("2500", CR, "10000.00"),
],
);
let cf = feb(&b, &["2500"], &[]);
assert_eq!(em(&cf.financing), [(e, x("10000.00"))]);
assert_eq!(cf.financing[0].on_behalf, x("100.00"));
assert_eq!(
cf.financing[0].kind,
"Proceeds from borrowing — 2500 Bank loan"
);
assert_eq!(em(&cf.operating), [(e, x("-100.00"))]);
assert!(cf.noncash_disclosures.is_empty());
assert!(cf.operating_reconciliation.lines.is_empty());
let mut b = funded();
b.acct("2510", "Debt issuance costs", AccountType::Liability);
let e = b.post(
d(2026, 2, 8),
"Bank loan, fee withheld",
&[
("1000", DR, "9900.00"),
("2510", DR, "100.00"),
("2500", CR, "10000.00"),
],
);
let cf = feb(&b, &["2500", "2510"], &[]);
assert_eq!(em(&cf.financing), [(e, x("9900.00"))]);
assert_eq!(cf.financing[0].on_behalf, Decimal::ZERO);
assert!(cf.operating.is_empty());
assert_eq!(disclosed(&cf), [(e, x("100.00"))]);
assert_eq!(cf.noncash_disclosures[0].exchanged, x("100.00"));
}
/// ASC 230-10-45-15(b) and 45-25(e): a loan payment of 1,000 with 100
/// interest is a repayment of 900 (financing) and interest paid of 100
/// (operating, class (e)).
#[test]
fn interest_paid_with_principal_splits_by_section() {
let mut b = funded();
let e = b.post(
d(2026, 2, 8),
"Loan payment",
&[
("2500", DR, "900.00"),
("6200", DR, "100.00"),
("1000", CR, "1000.00"),
],
);
let cf = feb(&b, &["2500"], &[]);
assert_eq!(em(&cf.financing), [(e, x("-900.00"))]);
assert_eq!(em(&cf.operating), [(e, x("-100.00"))]);
assert_eq!(cf.operating[0].class, Some(OperatingClass::InterestPaid));
}
/// ASC 230-10-45-12(a) and 45-16(b): collecting a 1,000 loan the company
/// made, with 40 interest: the principal is investing, the interest
/// (tax category interest_income) stays operating, class (b): rule 4's
/// exception.
#[test]
fn interest_collected_with_a_loan_stays_operating() {
let mut b = funded();
b.post(
d(2026, 1, 10),
"Lend to Member B",
&[("1400", DR, "1000.00"), ("1000", CR, "1000.00")],
);
let e = b.post(
d(2026, 2, 10),
"Member B repays with interest",
&[
("1000", DR, "1040.00"),
("1400", CR, "1000.00"),
("4100", CR, "40.00"),
],
);
let cf = feb(&b, &[], &["1400"]);
assert_eq!(em(&cf.investing), [(e, x("1000.00"))]);
assert_eq!(cf.investing[0].kind, "Collections on 1400 Loan to member");
assert_eq!(em(&cf.operating), [(e, x("40.00"))]);
assert_eq!(
cf.operating[0].class,
Some(OperatingClass::InterestAndDividendsReceived)
);
assert!(cf.operating_reconciliation.lines.is_empty());
}
/// ASC 230-10-45-25 (a)-(g), one entry per class, decided by structure:
/// (a) a sale (Cr revenue), and a deposit from a customer (Cr deferred
/// revenue, the customer named: review item 10); (b) interest received;
/// (c) a supplier's refund; (d) supplies paid; (e) interest paid; (g) a
/// refund to a customer (Dr revenue), and a government fee (tax category
/// `taxes_licenses`: payments to governments for taxes and fees are
/// 45-17(c), apart from suppliers' 45-17(b), and 45-25 names only income
/// taxes). The printed rows are the classes in order, each its sum.
#[test]
fn operating_classes_follow_asc_230_10_45_25() {
let mut b = funded();
let cash = |b: &mut Books, day, desc: &str, other: &'static str, side: Side, amount| {
let cash_side = if side == DR { CR } else { DR };
b.post(
d(2026, 2, day),
desc,
&[("1000", cash_side, amount), (other, side, amount)],
)
};
let a1 = cash(&mut b, 2, "Sale", "4000", CR, "300.00");
let a2 = cash(&mut b, 3, "Deposit", "2300", CR, "200.00");
let customer = b.l.add_customer("Acme LLC").unwrap();
let party = b.l.party_of_customer(customer).unwrap();
b.l.set_entry_party(a2, Some(party)).unwrap();
let b1 = cash(&mut b, 4, "Bank interest", "4100", CR, "7.00");
let c1 = cash(&mut b, 5, "Supplier refund", "6000", CR, "15.00");
let d1 = cash(&mut b, 6, "Supplies", "6000", DR, "120.00");
let e1 = cash(&mut b, 7, "Interest", "6200", DR, "30.00");
let g1 = cash(&mut b, 8, "Customer refund", "4000", DR, "20.00");
// A tax or a government fee is not a supplier's (45-17(c) apart from
// 45-17(b)): (g), as 45-25 names only income taxes, (f).
let fees = b.acct("6700", "Taxes and licenses", AccountType::Expense);
b.l.set_account_tax(fees, Some("taxes_licenses"), None)
.unwrap();
let g2 = cash(&mut b, 9, "Annual report fee", "6700", DR, "25.00");
let cf = feb(&b, &[], &[]);
let class = |e| cf.operating.iter().find(|l| l.entry == e).unwrap().class;
use OperatingClass::*;
assert_eq!(class(a1), Some(FromCustomers));
assert_eq!(class(a2), Some(FromCustomers));
assert_eq!(class(b1), Some(InterestAndDividendsReceived));
assert_eq!(class(c1), Some(OtherReceipts));
assert_eq!(class(d1), Some(ToSuppliersAndEmployees));
assert_eq!(class(e1), Some(InterestPaid));
assert_eq!(class(g1), Some(OtherPayments));
assert_eq!(class(g2), Some(OtherPayments));
assert_eq!(
cf.operating_by_class(),
[
(FromCustomers, x("500.00")),
(InterestAndDividendsReceived, x("7.00")),
(OtherReceipts, x("15.00")),
(ToSuppliersAndEmployees, x("-120.00")),
(InterestPaid, x("-30.00")),
(OtherPayments, x("-45.00")),
]
);
assert_eq!(cf.cash_from_operations, x("327.00"));
}
/// ASC 230-10-45-28(b): depreciation is an item of net income with no
/// cash effect, a reconciling item; it is not an investing or financing
/// activity, so nothing is disclosed (rules.md B-14 rule 7).
#[test]
fn depreciation_is_reconciling_not_disclosed() {
let mut b = funded();
b.post(
d(2026, 2, 28),
"Depreciation",
&[("6100", DR, "100.00"), ("1510", CR, "100.00")],
);
let cf = feb(&b, &[], &[]);
assert!(cf.noncash_disclosures.is_empty());
assert_eq!(recon(&cf), [(s("Depreciation"), x("100.00"))]);
assert_eq!(
cf.operating_reconciliation.lines[0].kind,
ReconcilingKind::Depreciation
);
}
/// Defect D1-e: cash spent through an account that is also an asset's
/// depreciation expense account is spending, not an add-back. 50 paid
/// from cash to 6100: operating −50, class (d), and no depreciation line.
#[test]
fn cash_spent_on_a_depreciation_expense_account_is_not_added_back() {
let mut b = funded();
let e = b.post(
d(2026, 2, 3),
"Misposted repair",
&[("6100", DR, "50.00"), ("1000", CR, "50.00")],
);
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.operating), [(e, x("-50.00"))]);
assert!(cf.operating_reconciliation.lines.is_empty());
assert_eq!(cf.operating_reconciliation.net_income, x("-50.00"));
}
/// ASC 230-10-45-29 and 45-30: net income plus the reconciling items is
/// net cash from operating activities. A month of mixed activity: sales
/// on account 1,000 (500 collected), supplies on the card 200 (paid 150),
/// a prepaid 120, depreciation 50. By hand: operating cash 500 − 150 −
/// 120 = +230; net income 1,000 − 200 − 50 = 750; depreciation +50;
/// receivables −500; prepaid −120; card +50: 750 + 50 − 500 − 120 + 50 =
/// 230.
#[test]
fn operating_reconciliation_ties_to_operating_cash() {
let mut b = funded();
b.post(
d(2026, 2, 2),
"Invoice",
&[("1200", DR, "1000.00"), ("4000", CR, "1000.00")],
);
b.post(
d(2026, 2, 9),
"Collect",
&[("1000", DR, "500.00"), ("1200", CR, "500.00")],
);
b.post(
d(2026, 2, 10),
"Supplies",
&[("6000", DR, "200.00"), ("2050", CR, "200.00")],
);
b.post(
d(2026, 2, 20),
"Pay card",
&[("2050", DR, "150.00"), ("1000", CR, "150.00")],
);
b.post(
d(2026, 2, 21),
"Prepay",
&[("1300", DR, "120.00"), ("1000", CR, "120.00")],
);
b.post(
d(2026, 2, 28),
"Depreciation",
&[("6100", DR, "50.00"), ("1510", CR, "50.00")],
);
let cf = feb(&b, &[], &[]);
assert_eq!(cf.cash_from_operations, x("230.00"));
assert_eq!(cf.operating_reconciliation.net_income, x("750.00"));
assert_eq!(
recon(&cf),
[
(s("Depreciation"), x("50.00")),
(s("Increase in 1200 Accounts Receivable"), x("-500.00")),
(s("Increase in 1300 Prepaid"), x("-120.00")),
(s("Increase in 2050 Card"), x("50.00")),
]
);
assert_eq!(
cf.operating_by_class(),
[
(OperatingClass::FromCustomers, x("500.00")),
(OperatingClass::ToSuppliersAndEmployees, x("-270.00")),
]
);
}
/// rules.md B-11 and B-14 rule 10: a period close moves earnings into
/// equity; it earns and spends nothing, so it is never in the statement.
#[test]
fn closing_entries_never_appear() {
let mut b = funded();
b.post(
d(2026, 2, 2),
"Sale",
&[("1000", DR, "300.00"), ("4000", CR, "300.00")],
);
let open = feb(&b, &[], &[]);
let p = b.l.create_period("2026-02", FEB.0, FEB.1).unwrap();
let close = b.l.close_period(p).unwrap().unwrap();
let closed = feb(&b, &[], &[]);
assert_eq!(closed, open);
let text = format!("{closed:?}");
assert!(!text.contains(&format!("entry: {close},")));
}
/// The period is [start, end] inclusive (rules.md B-14 rule 1): a
/// contribution on the start date is a flow, not opening cash.
#[test]
fn cash_flow_starts_counting_on_the_start_date() {
let b = funded();
let cf = b.cf(d(2026, 1, 2), d(2026, 1, 2), &["1000"], &[], &[]);
foots(&cf);
assert_eq!(cf.cash_beginning, Decimal::ZERO);
assert_eq!(cf.cash_ending, x("10000.00"));
assert_eq!(em(&cf.financing), [(1, x("10000.00"))]);
let cf = b.cf(d(2026, 1, 3), d(2026, 1, 3), &["1000"], &[], &[]);
assert_eq!(cf.cash_beginning, x("10000.00"));
assert!(cf.financing.is_empty());
assert!(matches!(
cash_flow(
&b.l,
&b.params(d(2026, 1, 3), d(2026, 1, 2), &["1000"], &[], &[])
),
Err(LedgerError::InvalidDateRange { .. })
));
}
/// ASC 230-10-45-24 (the statement explains the change in cash): moving
/// money between two cash accounts is no flow.
#[test]
fn a_transfer_between_cash_accounts_is_not_a_flow() {
let mut b = funded();
b.post(
d(2026, 2, 2),
"To savings",
&[("1010", DR, "4000.00"), ("1000", CR, "4000.00")],
);
let cf = feb(&b, &[], &[]);
for sec in CashFlowSection::ALL {
assert!(cf.lines(sec).is_empty());
}
assert_eq!(cf.net_change_in_cash, Decimal::ZERO);
assert!(cf.noncash_disclosures.is_empty());
}
/// Review round 2, footing in odd shapes (rules.md B-14 rules 1, 6, 9,
/// 11), by hand:
/// - two cash accounts in one entry, a transfer less its fee (Dr Savings
/// 99, Dr Supplies 1 / Cr Cash 100): only the net, −1, moves cash,
/// operating (d); no investing or financing;
/// - one account on both sides (Dr Supplies 50, Dr 2100 20 / Cr 2100 70):
/// 2100 nets to a 50 credit first, which pays the supplies: operating
/// −50, financing +50;
/// - a chain across the period start: a member's payment on Jan 20, its
/// reversal on Feb 3 and the reinstatement on Feb 4. February: the
/// reversal and reinstatement pair, nothing shown, no total moves;
/// January: the payment, borrowed and spent;
/// - closing February changes nothing.
#[test]
fn odd_shapes_foot_and_net() {
let mut b = funded();
let t = b.post(
d(2026, 2, 2),
"To savings, less the fee",
&[
("1010", DR, "99.00"),
("6000", DR, "1.00"),
("1000", CR, "100.00"),
],
);
let n = b.post(
d(2026, 2, 3),
"Supplies, Member A, netted",
&[
("6000", DR, "50.00"),
("2100", DR, "20.00"),
("2100", CR, "70.00"),
],
);
let a = b.post(
d(2026, 1, 20),
"Software, paid by Member A",
&[("6000", DR, "40.00"), ("2100", CR, "40.00")],
);
let r = b.reverse(a, d(2026, 2, 3));
b.reverse(r, d(2026, 2, 4));
let cf = feb(&b, &["2100"], &[]);
assert_eq!(em(&cf.operating), [(t, x("-1.00")), (n, x("-50.00"))]);
assert!(cf.investing.is_empty());
assert_eq!(em(&cf.financing), [(n, x("50.00"))]);
assert_eq!(cf.net_change_in_cash, x("-1.00"));
assert!(cf.noncash_disclosures.is_empty());
let jan = b.cf(
d(2026, 1, 1),
d(2026, 1, 31),
&["1000", "1010"],
&["2100"],
&[],
);
foots(&jan);
assert_eq!(em(&jan.financing)[1..], [(a, x("40.00"))]);
assert_eq!(em(&jan.operating), [(a, x("-40.00"))]);
let p = b.l.create_period("2026-02", FEB.0, FEB.1).unwrap();
b.l.close_period(p).unwrap();
assert_eq!(feb(&b, &["2100"], &[]), cf);
}
/// Review item 2, ASC 230-10-50-4's first example, "converting debt to
/// equity": a member's 500 balance in 2100 turned into capital (Dr 2100 /
/// Cr 3000, both financing) is disclosed as an exchange of 500, though no
/// section's noncash part moves.
#[test]
fn converting_member_debt_to_capital_is_disclosed() {
let mut b = funded();
let e = b.post(
d(2026, 2, 3),
"Member A's advances to capital",
&[("2100", DR, "500.00"), ("3000", CR, "500.00")],
);
let cf = feb(&b, &["2100"], &[]);
assert_eq!(disclosed(&cf), [(e, x("500.00"))]);
let n = &cf.noncash_disclosures[0];
assert_eq!(
(n.operating, n.investing, n.financing, n.exchanged),
(x("0"), x("0"), x("0"), x("500.00"))
);
}
/// ASC 230-10-50-4, "exchanging noncash assets": a 700 note received with
/// 300 cash for equipment (cost 1,000, accumulated 800, gain 800).
/// Investing +300; disclosed 700, the note for the equipment.
#[test]
fn a_note_received_for_equipment_is_disclosed() {
let mut b = funded();
b.post(
d(2026, 1, 5),
"Depreciation",
&[("6100", DR, "800.00"), ("1510", CR, "800.00")],
);
let e = b.post(
d(2026, 2, 3),
"Sell equipment for a note and cash",
&[
("1250", DR, "700.00"),
("1000", DR, "300.00"),
("1510", DR, "800.00"),
("1500", CR, "1000.00"),
("4900", CR, "800.00"),
],
);
let cf = feb(&b, &[], &["1250"]);
assert_eq!(em(&cf.investing), [(e, x("300.00"))]);
assert_eq!(disclosed(&cf), [(e, x("700.00"))]);
assert_eq!(recon(&cf)[0].1, x("-800.00"));
}
/// ASC 230-10-50-4: a trade-in. A new vehicle (2,000, on 1520) for the
/// old equipment (cost 1,000 on 1500, accumulated 800, gain 300) and
/// 1,500 cash: investing −1,500 and an exchange of 500 disclosed. Within
/// one cost account a trade-in nets away (a limit B-14 records).
#[test]
fn a_trade_in_is_disclosed() {
let mut b = funded();
b.post(
d(2026, 1, 5),
"Depreciation",
&[("6100", DR, "800.00"), ("1510", CR, "800.00")],
);
let e = b.post(
d(2026, 2, 3),
"Trade in",
&[
("1520", DR, "2000.00"),
("1510", DR, "800.00"),
("1500", CR, "1000.00"),
("4900", CR, "300.00"),
("1000", CR, "1500.00"),
],
);
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.investing), [(e, x("-1500.00"))]);
assert_eq!(disclosed(&cf), [(e, x("500.00"))]);
assert_eq!(cf.noncash_disclosures[0].exchanged, x("500.00"));
}
/// D-35, ASC 230-10-45-13(c): a bill payment carries its bill's split,
/// equipment part first. A bill (from a later version, restored as the
/// journal holds it) for equipment 1,600 and supplies 100 on payables; it
/// is paid 1,000 in February and 700 in March. February: investing −1,000
/// (the equipment, paid soon after purchase), and the 600 still owed for
/// equipment disclosed (EY FRD 5.3.3: the part unpaid at the end). March:
/// investing −600 and operating −100. Payables' equipment part never
/// reaches the operating reconciliation (DART 6.1.3).
#[test]
fn a_bill_payment_carries_its_bills_split() {
let mut b = funded();
let entry = b.post(
d(2026, 2, 2),
"Bill 1",
&[
("1500", DR, "1600.00"),
("6000", DR, "100.00"),
("2000", CR, "1700.00"),
],
);
let vendor = b.l.add_vendor("Acme LLC").unwrap();
let bill = b.l.next_bill_id();
b.l.replay_bill(
bill,
vendor,
d(2026, 2, 2),
d(2026, 3, 2),
vec![ledger_core::DocumentLine {
description: "Equipment and supplies".into(),
amount: x("1700.00"),
class_id: None,
}],
entry,
)
.unwrap();
let cash = b.id("1000");
let p1 =
b.l.pay_bill(bill, d(2026, 2, 20), x("1000.00"), cash)
.unwrap();
let p2 =
b.l.pay_bill(bill, d(2026, 3, 5), x("700.00"), cash)
.unwrap();
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.investing), [(p1, x("-1000.00"))]);
assert!(cf.operating.is_empty());
assert_eq!(disclosed(&cf), [(entry, x("600.00"))]);
assert_eq!(
recon(&cf),
[(s("Increase in 2000 Accounts Payable"), x("100.00"))]
);
assert!(cf.warnings.is_empty());
let march = b.cf(d(2026, 3, 1), d(2026, 3, 31), &["1000", "1010"], &[], &[]);
foots(&march);
assert_eq!(em(&march.investing), [(p2, x("-600.00"))]);
assert_eq!(em(&march.operating), [(p2, x("-100.00"))]);
// Paid in full within one period: no disclosure at all.
let both = b.cf(d(2026, 2, 1), d(2026, 3, 31), &["1000", "1010"], &[], &[]);
foots(&both);
assert!(both.noncash_disclosures.is_empty());
assert_eq!(both.cash_from_investing, x("-1600.00"));
}
/// D-35 (unchanged by D-42): a card purchase of equipment follows the
/// card's classification. On a card that is an operating payable:
/// disclosed (investing +1,000, operating −1,000), flagged, and its
/// payment is operating (d). On a card listed as financing, the issuer is
/// a lender that paid the seller (D-42): borrowed and invested on the
/// purchase's date (financing +1,000, investing −1,000), no flag, and its
/// payment a repayment of borrowing.
#[test]
fn a_card_purchase_follows_the_cards_classification() {
let mut b = funded();
let e = b.post(
d(2026, 2, 3),
"Equipment on the card",
&[("1500", DR, "1000.00"), ("2050", CR, "1000.00")],
);
let pay = b.post(
d(2026, 2, 25),
"Pay the card",
&[("2050", DR, "1000.00"), ("1000", CR, "1000.00")],
);
let cf = feb(&b, &[], &[]);
assert_eq!(disclosed(&cf), [(e, x("1000.00"))]);
assert_eq!(
cf.warnings
.iter()
.map(|w| (w.entry, w.kind))
.collect::<Vec<_>>(),
[(e, CashFlowWarningKind::AssetOnOperatingPayable)]
);
assert_eq!(em(&cf.operating), [(pay, x("-1000.00"))]);
let cf = feb(&b, &["2050"], &[]);
assert!(cf.noncash_disclosures.is_empty());
assert!(cf.warnings.is_empty());
assert_eq!(em(&cf.investing), [(e, x("-1000.00"))]);
assert_eq!(cf.investing[0].on_behalf, x("-1000.00"));
assert_eq!(em(&cf.financing), [(e, x("1000.00")), (pay, x("-1000.00"))]);
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[
(s("Proceeds from borrowing — 2050 Card"), x("1000.00")),
(s("Repayments of borrowing — 2050 Card"), x("-1000.00")),
]
);
}
/// Kinds (D-35): each member's contributions and distributions are
/// listed under their own name, an entry crediting two members split
/// between them (each its own movement: no noncash part); equity not a
/// member's is named by account.
#[test]
fn financing_lists_each_members_contributions_and_distributions() {
let mut b = funded();
let both = b.post(
d(2026, 2, 2),
"Joint contribution",
&[
("1000", DR, "600.00"),
("3000", CR, "400.00"),
("3001", CR, "200.00"),
],
);
let dist = b.post(
d(2026, 2, 3),
"Distribution to Member B",
&[("3001", DR, "50.00"), ("1000", CR, "50.00")],
);
let cf = feb(&b, &[], &[]);
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[
(s("Contributions from members — Member A"), x("400.00")),
(s("Contributions from members — Member B"), x("200.00")),
(s("Distributions to members — Member B"), x("-50.00")),
]
);
assert_eq!(cf.kinds(CashFlowSection::Financing)[0].entries, [both]);
assert_eq!(cf.kinds(CashFlowSection::Financing)[2].entries, [dist]);
// No members configured: equity is named by account.
let mut plain = b;
plain.members.clear();
let cf = feb(&plain, &[], &[]);
assert_eq!(
cf.financing[0].kind,
"Contributions from owners — 3000 Capital — Member A"
);
}
/// ASC 230-10-45-25 requires interest received (b) and interest paid (e)
/// "at a minimum" apart from the other classes, so an entry's operating
/// cash is classed kind by kind: its interest lines are (b) or (e)
/// whatever else it settles, the rest by its structure (rules.md B-14
/// rule 5). By hand:
/// - a payment of 900 on a loan the settings leave operating, with 100
/// interest (Dr 2500 900, Dr 6200 100 / Cr cash 1,000): (d) −900 and
/// (e) −100, not (e) −1,000;
/// - a customer's 1,000 collected with 10 interest (Dr cash 1,010 / Cr
/// 1200 1,000, Cr 4100 10): (a) +1,000 and (b) +10, not (b) +1,010;
/// - the payment reversed in March: (d) +900 and (e) +100, its root's
/// classes with its own sign.
#[test]
fn operating_cash_is_classed_kind_by_kind() {
let mut b = funded();
let pay = b.post(
d(2026, 2, 8),
"Loan payment",
&[
("2500", DR, "900.00"),
("6200", DR, "100.00"),
("1000", CR, "1000.00"),
],
);
let got = b.post(
d(2026, 2, 9),
"Customer pays, with interest",
&[
("1000", DR, "1010.00"),
("1200", CR, "1000.00"),
("4100", CR, "10.00"),
],
);
let cf = feb(&b, &[], &[]);
use OperatingClass::*;
let lines: Vec<_> = cf
.operating
.iter()
.map(|l| (l.entry, l.class, l.amount, l.accounts.clone()))
.collect();
assert_eq!(
lines,
[
(got, Some(FromCustomers), x("1000.00"), vec![s("1200")]),
(
got,
Some(InterestAndDividendsReceived),
x("10.00"),
vec![s("4100")]
),
(
pay,
Some(ToSuppliersAndEmployees),
x("-900.00"),
vec![s("2500")]
),
(pay, Some(InterestPaid), x("-100.00"), vec![s("6200")]),
]
);
assert!(cf.noncash_disclosures.is_empty());
b.reverse(pay, d(2026, 3, 2));
let march = b.cf(d(2026, 3, 1), d(2026, 3, 31), &["1000", "1010"], &[], &[]);
foots(&march);
assert_eq!(
march.operating_by_class(),
[
(ToSuppliersAndEmployees, x("900.00")),
(InterestPaid, x("100.00"))
]
);
}
/// One line per entry, section and kind: two of an entry's operating
/// kinds that fall in one class are one line. A refund to a customer of
/// 20 with 10 of the interest it was charged (Dr 4000 20, Dr 4100 10 / Cr
/// cash 30): both parts are (g) other payments (cash refunds to customers,
/// 45-17; interest paid back out is neither (b) nor (e)), one line of −30
/// on both accounts.
#[test]
fn two_kinds_of_one_class_are_one_line() {
let mut b = funded();
let e = b.post(
d(2026, 2, 14),
"Refund, with the interest charged",
&[
("4000", DR, "20.00"),
("4100", DR, "10.00"),
("1000", CR, "30.00"),
],
);
let cf = feb(&b, &[], &[]);
let lines: Vec<_> = cf
.operating
.iter()
.map(|l| (l.entry, l.class, l.amount, l.accounts.clone()))
.collect();
assert_eq!(
lines,
[(
e,
Some(OperatingClass::OtherPayments),
x("-30.00"),
vec![s("4000"), s("4100")]
)]
);
}
/// Interest is operating in any entry (ASC 230-10-45-17(d), 45-25(e)),
/// even one that disposes of equipment, where rule 4 makes the other
/// revenue and expense lines investing. Equipment (cost 1,000) handed to
/// the bank against 1,000 of the loan, with 50 interest paid in cash (Dr
/// 2500 1,000, Dr 6200 50 / Cr 1500 1,000, Cr cash 50): operating −50,
/// class (e); no investing cash; the equipment for the debt is an
/// exchange, disclosed (50-4): 1,000.
#[test]
fn interest_paid_in_a_disposal_stays_operating() {
let mut b = funded();
let e = b.post(
d(2026, 2, 12),
"Equipment to the bank, with interest",
&[
("2500", DR, "1000.00"),
("6200", DR, "50.00"),
("1500", CR, "1000.00"),
("1000", CR, "50.00"),
],
);
let cf = feb(&b, &["2500"], &[]);
assert_eq!(em(&cf.operating), [(e, x("-50.00"))]);
assert_eq!(cf.operating[0].class, Some(OperatingClass::InterestPaid));
assert!(cf.investing.is_empty() && cf.financing.is_empty());
assert_eq!(disclosed(&cf), [(e, x("1000.00"))]);
assert!(
cf.operating_reconciliation
.lines
.iter()
.all(|l| l.kind != ReconcilingKind::InvestingGainLoss)
);
}
/// Rule 4 for an investing account: selling a 1,000 note receivable (an
/// `investing_accounts` account) for 1,200 (Dr cash 1,200 / Cr 1250 1,000,
/// Cr gain 200). Receipts from sales of other entities' debt instruments
/// are investing (ASC 230-10-45-12(a)), the whole proceeds: investing
/// +1,200, operating 0, the gain taken out of net income (45-28(b)): G
/// −200.
#[test]
fn an_investments_gain_is_investing() {
let mut b = funded();
b.post(
d(2026, 1, 10),
"Buy a note",
&[("1250", DR, "1000.00"), ("1000", CR, "1000.00")],
);
let e = b.post(
d(2026, 2, 10),
"Sell the note",
&[
("1000", DR, "1200.00"),
("1250", CR, "1000.00"),
("4900", CR, "200.00"),
],
);
let cf = feb(&b, &[], &["1250"]);
assert_eq!(em(&cf.investing), [(e, x("1200.00"))]);
assert_eq!(cf.investing[0].kind, "Collections on 1250 Note receivable");
assert!(cf.operating.is_empty());
assert_eq!(
recon(&cf),
[(
s("(Gain) loss and other noncash changes in investing assets"),
x("-200.00")
)]
);
}
/// Rule 6, three levels down: A ← B ← C ← D with A before February. B and
/// C pair (walking from the root); D, a reversal of the reinstatement, is
/// shown alone, labelled as the reversal it is ("Reversal of #C — …"),
/// never "Reinstatement", under A's kind with its own sign: −300.
#[test]
fn a_reversal_three_levels_down_is_labelled_a_reversal() {
let mut b = funded();
let a = b.post(
d(2026, 1, 20),
"Contribution — Member B",
&[("1000", DR, "300.00"), ("3001", CR, "300.00")],
);
let r = b.reverse(a, d(2026, 2, 3));
let c = b.reverse(r, d(2026, 2, 4));
let dd = b.reverse(c, d(2026, 2, 5));
let cf = feb(&b, &[], &[]);
assert_eq!(em(&cf.financing), [(dd, x("-300.00"))]);
let line = &cf.financing[0];
assert_eq!(line.label, format!("Reversal of #{c} — Reverse entry {r}"));
assert_eq!(line.kind, "Contributions from members — Member B");
assert_eq!(line.reverses, Some(c));
}
/// Rule 3's CONVENTION, pinned: an entry's cash goes to the sections
/// moving against it in the order operating, investing, financing. Supplies
/// 100 and a vehicle 1,000 for 600 cash, Member A putting in the other 500
/// as capital (Dr 6000 100, Dr 1520 1,000 / Cr cash 600, Cr 3000 500):
/// operating takes its 100 first, investing the other 500; the vehicle's
/// 500 for capital is noncash (property for capital, rule 11: capital pays
/// constructively only for revenue and expense, and the supplies were
/// already paid in cash), disclosed (investing +500, financing −500). Had
/// the member's 500 been a loan through 2100 (financing), it would have
/// paid for the rest of the vehicle (D-42): investing −1,000, financing
/// +500.
#[test]
fn cash_goes_to_the_sections_in_order() {
let mut b = funded();
let e = b.post(
d(2026, 2, 6),
"Supplies and a vehicle, part contributed",
&[
("6000", DR, "100.00"),
("1520", DR, "1000.00"),
("1000", CR, "600.00"),
("3000", CR, "500.00"),
],
);
let cf = feb(&b, &["2500"], &[]);
assert_eq!(em(&cf.operating), [(e, x("-100.00"))]);
assert_eq!(em(&cf.investing), [(e, x("-500.00"))]);
assert!(cf.financing.is_empty());
let n = &cf.noncash_disclosures[0];
assert_eq!(
(n.entry, n.operating, n.investing, n.financing, n.amount),
(e, x("0"), x("500.00"), x("-500.00"), x("500.00"))
);
let mut b = funded();
let e = b.post(
d(2026, 2, 6),
"Supplies and a vehicle, part lent by Member A",
&[
("6000", DR, "100.00"),
("1520", DR, "1000.00"),
("1000", CR, "600.00"),
("2100", CR, "500.00"),
],
);
let cf = feb(&b, &["2100"], &[]);
assert_eq!(em(&cf.operating), [(e, x("-100.00"))]);
assert_eq!(em(&cf.investing), [(e, x("-1000.00"))]);
assert_eq!(cf.investing[0].on_behalf, x("-500.00"));
assert_eq!(em(&cf.financing), [(e, x("500.00"))]);
assert!(cf.noncash_disclosures.is_empty());
}
/// Rule 5's CONVENTION, pinned: within a section the cash goes to its
/// kinds in kind order (members in their order, then other equity, then
/// borrowing). 500 cash and 100 of the loan repaid for 400 from Member A
/// and 200 from Member B (Dr cash 500, Dr 2500 100 / Cr 3000 400, Cr 3001
/// 200): Member A +400, Member B +100; Member B's other 100 went straight
/// to the bank, an exchange inside financing, disclosed (50-4): 100.
#[test]
fn cash_goes_to_a_sections_kinds_in_order() {
let mut b = funded();
let e = b.post(
d(2026, 2, 6),
"Contributions, part to the bank",
&[
("1000", DR, "500.00"),
("2500", DR, "100.00"),
("3000", CR, "400.00"),
("3001", CR, "200.00"),
],
);
let cf = feb(&b, &["2500"], &[]);
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[
(s("Contributions from members — Member A"), x("400.00")),
(s("Contributions from members — Member B"), x("100.00")),
]
);
assert_eq!(disclosed(&cf), [(e, x("100.00"))]);
assert_eq!(cf.noncash_disclosures[0].exchanged, x("100.00"));
}
/// Rule 12: a bill payment reversed in the period unpays the bill again.
/// Equipment 1,000 on a bill (restored as a later version would journal
/// it), paid on Feb 20 and the payment reversed on Feb 21: the payment
/// and its reversal drop out, and the 1,000 still owed at the end of
/// February is disclosed (EY FRD 5.3.3: what remains unpaid).
#[test]
fn a_reversed_bill_payment_leaves_the_purchase_unpaid() {
let mut b = funded();
let entry = b.post(
d(2026, 2, 2),
"Bill 1",
&[("1500", DR, "1000.00"), ("2000", CR, "1000.00")],
);
let vendor = b.l.add_vendor("Acme LLC").unwrap();
let bill = b.l.next_bill_id();
b.l.replay_bill(
bill,
vendor,
d(2026, 2, 2),
d(2026, 3, 2),
vec![ledger_core::DocumentLine {
description: "Equipment".into(),
amount: x("1000.00"),
class_id: None,
}],
entry,
)
.unwrap();
let cash = b.id("1000");
let pay =
b.l.pay_bill(bill, d(2026, 2, 20), x("1000.00"), cash)
.unwrap();
let undo = b.reverse(pay, d(2026, 2, 21));
let cf = feb(&b, &[], &[]);
assert!(cf.investing.is_empty() && cf.operating.is_empty());
assert_eq!(disclosed(&cf), [(entry, x("1000.00"))]);
// Through the payment only (Feb 1-20): paid, nothing disclosed.
let early = b.cf(FEB.0, d(2026, 2, 20), &["1000", "1010"], &[], &[]);
foots(&early);
assert_eq!(em(&early.investing), [(pay, x("-1000.00"))]);
assert!(early.noncash_disclosures.is_empty());
// The reversal alone (Feb 21 on): the payment's investing part, back.
let late = b.cf(d(2026, 2, 21), FEB.1, &["1000", "1010"], &[], &[]);
foots(&late);
assert_eq!(em(&late.investing), [(undo, x("1000.00"))]);
}
// ---------------------------------------------------------------------------
// Role refusals (rules.md B-14 rule 2) and the footing check (rule 9).
// ---------------------------------------------------------------------------
fn refused(b: &Books, cash: &[&str], financing: &[&str], investing: &[&str]) -> String {
match cash_flow(&b.l, &b.params(FEB.0, FEB.1, cash, financing, investing)) {
Err(LedgerError::CashFlowRoles(m)) => m,
other => panic!("expected a role refusal, got {other:?}"),
}
}
/// An account belongs to one section: a code in two lists is refused,
/// naming both settings.
#[test]
fn role_lists_that_overlap_are_refused() {
let b = funded();
let m = refused(&b, &["1000"], &[], &["1000"]);
assert!(m.contains("both cash_accounts"), "{m}");
assert!(m.contains("books.investing_accounts"), "{m}");
let m = refused(&b, &["1000", "1000"], &[], &[]);
assert!(m.contains("1000 twice"), "{m}");
let m = refused(&b, &[], &[], &[]);
assert!(m.contains("no cash accounts"), "{m}");
}
/// ASC 230-10-45-14(b): financing liabilities are borrowing. An asset
/// listed as financing is refused, naming the setting; equity listed is
/// accepted (it is financing anyway).
#[test]
fn a_financing_account_must_be_a_liability() {
let b = funded();
let m = refused(&b, &["1000"], &["1500"], &[]);
assert!(
m.contains("financing_accounts (from the call or books.financing_accounts) lists 1500"),
"{m}"
);
assert!(
m.contains(
"an asset account: financing accounts are liabilities (equity is always financing)"
),
"{m}"
);
let with_equity = b.cf(FEB.0, FEB.1, &["1000"], &["3000", "2500"], &[]);
foots(&with_equity);
}
/// ASC 230-10-45-12, -13: investing accounts are assets (loans made,
/// investments bought).
#[test]
fn an_investing_account_must_be_an_asset() {
let m = refused(&funded(), &["1000"], &[], &["2500"]);
assert!(
m.contains("a liability account: investing accounts are asset accounts"),
"{m}"
);
}
/// Cash is an asset; the statement explains its change.
#[test]
fn a_cash_account_must_be_an_asset() {
let m = refused(&funded(), &["2100"], &[], &[]);
assert!(m.contains("cash accounts are asset accounts"), "{m}");
}
/// A registered fixed asset's accounts are investing, never cash.
#[test]
fn a_registered_asset_account_cannot_be_cash() {
let m = refused(&funded(), &["1000", "1510"], &[], &[]);
assert!(m.contains("a registered fixed asset's account"), "{m}");
}
/// rules.md B-14 rule 9: any difference between the sections and the
/// change in cash is an engine error, never a statement.
#[test]
fn foot_refuses_any_difference() {
let ok = foot(x("1620.16"), x("1620.16")).unwrap();
assert_eq!(ok.difference, Decimal::ZERO);
for (sum, change) in [("-1980.32", "1620.16"), ("0.01", "0"), ("0", "-0.01")] {
match foot(x(sum), x(change)) {
Err(LedgerError::CashFlowDoesNotFoot {
expected, found, ..
}) => {
assert_eq!((expected, found), (x(change), x(sum)));
}
other => panic!("expected an engine error, got {other:?}"),
}
}
let msg = foot(x("1"), x("0")).unwrap_err().to_string();
assert!(msg.starts_with("engine error"), "{msg}");
}
// ---------------------------------------------------------------------------
// Properties over random books (rules.md B-14; plan P3: 5,000 cases).
// ---------------------------------------------------------------------------
mod random {
use super::*;
use proptest::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
/// One generated event: a posting from a template, or a reversal of an
/// earlier entry (any earlier entry, reversals included: chains).
#[derive(Clone, Debug)]
pub enum Op {
Post {
day: u32,
template: u8,
amounts: [i64; 3],
who: bool,
},
Reverse {
of: usize,
after: u32,
},
}
/// The lines of a template, in cents. Every shape the rule names:
/// contributions of cash and property, member-paid expenses and
/// reimbursements, sales on account and collections, card spending
/// and payments, asset purchases (cash, on a note, part cash),
/// depreciation, disposals (cash, on account, gain, loss, before any
/// depreciation, the loss on a revenue or an expense account), loans in
/// and out with interest, transfers, debt to equity, a fee withheld;
/// and what a member or lender pays or receives for the company (rule
/// 11, D-42): an expense into capital, a prepaid through 2100, part
/// cash and part a member's, a refund to a member, a customer paying a
/// member, a member paying the card, revenue a member kept, and the
/// shapes that stay noncash: interest charged into the loan, equipment
/// on the seller's credit.
pub fn template(t: u8, [a, b, c]: [i64; 3], who: bool) -> Vec<(&'static str, Side, i64)> {
let cap = if who { "3000" } else { "3001" };
let cash = if who { "1000" } else { "1010" };
// A disposal: proceeds `a` to `to`, accumulated `b` (none if 0),
// cost `c` + 1, the difference a gain or a loss.
let dispose = |to: &'static str, accum: i64| {
let cost = c + 1;
let mut v = vec![(to, DR, a), ("1500", CR, cost)];
if accum > 0 {
v.push(("1510", DR, accum));
}
let gain = a + accum - cost;
// A loss on a revenue-typed gain/loss account too (a debit
// to revenue), as on an expense account.
let loss_to = if who { "6900" } else { "4900" };
if gain > 0 {
v.push(("4900", CR, gain));
} else if gain < 0 {
v.push((loss_to, DR, -gain));
}
v
};
match t {
0 => vec![(cash, DR, a), (cap, CR, a)],
1 => vec![("1500", DR, a), (cap, CR, a)],
2 => vec![("6000", DR, a), ("2100", CR, a)],
3 => vec![("2100", DR, a), (cash, CR, a)],
4 => vec![("1200", DR, a), ("4000", CR, a)],
5 => vec![(cash, DR, a), ("1200", CR, a)],
6 => vec![("6000", DR, a), ("2050", CR, a)],
7 => vec![("2050", DR, a), (cash, CR, a)],
8 => vec![("1520", DR, a), (cash, CR, a)],
9 => vec![("1520", DR, a), ("2500", CR, a)],
10 => vec![("1500", DR, a + b), (cash, CR, a), ("2500", CR, b)],
11 => vec![("6100", DR, a), ("1510", CR, a)],
12 => dispose(cash, b),
13 => dispose("1210", b),
14 => dispose(cash, 0),
15 => vec![(cash, DR, a), ("2500", CR, a)],
16 => vec![("2500", DR, a), ("6200", DR, b), (cash, CR, a + b)],
17 => vec![("1400", DR, a), (cash, CR, a)],
18 => vec![(cash, DR, a + b), ("1400", CR, a), ("4100", CR, b)],
19 => vec![("1010", DR, a), ("1000", CR, a)],
20 => vec![("2100", DR, a), (cap, CR, a)],
21 => vec![(cash, DR, a), ("6000", DR, b), ("2500", CR, a + b)],
22 => vec![(cash, DR, a), ("4000", CR, a)],
24 => vec![("6000", DR, a), (cap, CR, a)],
25 => vec![("1300", DR, a), ("2100", CR, a)],
26 => vec![("6000", DR, a + b), (cash, CR, a), ("2100", CR, b)],
27 => vec![("2100", DR, a), ("6000", CR, a)],
28 => vec![("2100", DR, a), ("1200", CR, a)],
29 => vec![("6200", DR, a), ("2500", CR, a)],
30 => vec![("1500", DR, a), ("2000", CR, a)],
31 => vec![("2050", DR, a), ("2100", CR, a)],
32 => vec![(cap, DR, a), ("4000", CR, a)],
_ => vec![("6000", DR, a), (cash, CR, a)],
}
}
/// How many templates.
pub const TEMPLATES: u8 = 33;
/// Templates whose entries are pure: their cash settles every section
/// they move (or they move no cash and no section), or a member or
/// lender settles it all for the company (rule 11), whether 2100 is
/// financing or not.
const PURE: [u8; 21] = [
0, 2, 3, 5, 7, 8, 12, 14, 15, 16, 17, 18, 19, 22, 24, 25, 26, 27, 28, 31, 32,
];
fn arb_op(pure: bool) -> impl Strategy<Value = Op> {
let template = if pure {
prop::sample::select(PURE.to_vec()).boxed()
} else {
(0u8..TEMPLATES).boxed()
};
prop_oneof![
4 => (0u32..365, template, [1i64..100_000, 1i64..100_000, 1i64..100_000], any::<bool>())
.prop_map(|(day, template, amounts, who)| Op::Post { day, template, amounts, who }),
1 => (any::<usize>(), 0u32..60).prop_map(|(of, after)| Op::Reverse { of, after }),
]
}
pub fn arb_ops(pure: bool) -> impl Strategy<Value = Vec<Op>> {
prop::collection::vec(arb_op(pure), 1..30)
}
fn day(n: u32) -> NaiveDate {
d(2026, 1, 1) + chrono::Days::new(u64::from(n.min(364)))
}
/// The model: each posted entry's date and lines, as generated (a
/// reversal's are its original's, sides flipped), and whether it
/// mirrors its chain's root (an odd number of reversals down).
pub type Model = Vec<(EntryId, NaiveDate, Vec<(&'static str, Side, i64)>, bool)>;
/// Post the ops to fresh books, amounts times `scale`.
pub fn build(ops: &[Op], scale: i64) -> (Books, Model) {
let mut b = co();
let mut model: Model = Vec::new();
let mut reversed = BTreeSet::new();
for op in ops {
match op {
Op::Post {
day: n,
template: t,
amounts,
who,
} => {
let lines = template(*t, *amounts, *who);
let text: Vec<(&str, Side, String)> = lines
.iter()
.map(|&(a, s, c)| (a, s, Decimal::new(c * scale, 2).to_string()))
.collect();
let refs: Vec<(&str, Side, &str)> =
text.iter().map(|(a, s, t)| (*a, *s, t.as_str())).collect();
let e = b.post(day(*n), "Event", &refs);
model.push((e, day(*n), lines, false));
}
Op::Reverse { of, after } => {
if model.is_empty() {
continue;
}
let (orig, date, lines, mirrored) = model[of % model.len()].clone();
if !reversed.insert(orig) {
continue;
}
let when = date + chrono::Days::new(u64::from(*after));
let when = when.min(d(2026, 12, 31));
let r = b.reverse(orig, when);
let flipped = lines.iter().map(|&(a, s, c)| (a, flip(s), c)).collect();
model.push((r, when, flipped, !mirrored));
}
}
}
(b, model)
}
pub const CASH: [&str; 2] = ["1000", "1010"];
pub fn params(b: &Books, a: NaiveDate, e: NaiveDate, designate: bool) -> CashFlowParams {
let fin: &[&str] = if designate { &["2100", "2500"] } else { &[] };
b.params(a, e, &CASH, fin, &["1250", "1400"])
}
fn flip(s: Side) -> Side {
if s == DR { CR } else { DR }
}
fn signed(s: Side, c: i64) -> i64 {
if s == DR { c } else { -c }
}
/// The model's change in cash over [a, e]: the plain sum of the
/// generated cash lines.
pub fn model_cash(model: &Model, a: NaiveDate, e: NaiveDate) -> Decimal {
let cents: i64 = model
.iter()
.filter(|(_, d, _, _)| *d >= a && *d <= e)
.flat_map(|(_, _, ls, _)| ls)
.filter(|(acct, _, _)| CASH.contains(acct))
.map(|&(_, s, c)| signed(s, c))
.sum();
Decimal::new(cents, 2)
}
/// The model's section of an account, for pure books: gains and
/// losses appear only in disposals, so they are investing.
fn model_section(acct: &str, designate: bool) -> Option<usize> {
match acct {
"1000" | "1010" => None,
"1500" | "1510" | "1520" | "1530" | "1250" | "1400" | "4900" | "6900" => Some(1),
"3000" | "3001" => Some(2),
"2100" | "2500" if designate => Some(2),
_ => Some(0),
}
}
/// Rule 11 for a whole entry, independently of the engine: no cash
/// line, and every line either a source (2100 or 2500 when financing;
/// a member's capital) or a use on the other side; capital pays or
/// receives only revenue and expense; a liability pays for any
/// operating or investing account and receives only revenue, expense
/// or receivables; interest never.
pub fn constructive_whole(lines: &[(&str, Side, i64)], designate: bool) -> bool {
if lines.iter().any(|(acct, _, _)| CASH.contains(acct)) {
return false;
}
let lender = |a: &str| designate && matches!(a, "2100" | "2500");
let member = |a: &str| matches!(a, "3000" | "3001");
type Lines<'a, 'b> = Vec<&'a (&'b str, Side, i64)>;
let (src, uses): (Lines, Lines) =
lines.iter().partition(|(a, _, _)| lender(a) || member(a));
let (Some(first), false) = (src.first(), uses.is_empty()) else {
return false;
};
let side = first.1;
if src.iter().any(|l| l.1 != side) || uses.iter().any(|l| l.1 == side) {
return false;
}
let pl = |a: &str| matches!(a, "4000" | "6000");
if member(first.0) {
src.iter().all(|l| member(l.0)) && uses.iter().all(|l| pl(l.0))
} else if side == CR {
uses.iter().all(|l| {
model_section(l.0, designate) != Some(2) && !matches!(l.0, "6200" | "4100")
})
} else {
uses.iter().all(|l| pl(l.0) || l.0 == "1200")
}
}
/// For pure books, each section's total is minus the movement of its
/// accounts over the entries that touch cash (plan P3's wording), or
/// that a member or lender settled whole for the company (rule 11).
pub fn model_sections(
model: &Model,
a: NaiveDate,
e: NaiveDate,
designate: bool,
) -> [Decimal; 3] {
let mut out = [0i64; 3];
for (_, d, lines, mirrored) in model.iter().filter(|(_, d, _, _)| *d >= a && *d <= e) {
let _ = d;
// A reversal mirrors its root (rule 6): judged by the root's
// shape, its lines flipped back.
let root: Vec<(&str, Side, i64)> = lines
.iter()
.map(|&(acct, side, c)| (acct, if *mirrored { flip(side) } else { side }, c))
.collect();
if !lines.iter().any(|(acct, _, _)| CASH.contains(acct))
&& !constructive_whole(&root, designate)
{
continue;
}
for &(acct, s, c) in lines {
if let Some(k) = model_section(acct, designate) {
out[k] -= signed(s, c);
}
}
}
out.map(|c| Decimal::new(c, 2))
}
/// Every figure of a statement, scaled.
pub fn scaled(cf: &CashFlowStatement, k: i64) -> CashFlowStatement {
let k = Decimal::from(k);
let mut out = cf.clone();
for sec in [&mut out.operating, &mut out.investing, &mut out.financing] {
for l in sec.iter_mut() {
l.amount *= k;
l.on_behalf *= k;
}
}
out.cash_from_operations *= k;
out.cash_from_investing *= k;
out.cash_from_financing *= k;
out.net_change_in_cash *= k;
out.cash_beginning *= k;
out.cash_ending *= k;
for n in &mut out.noncash_disclosures {
n.amount *= k;
n.operating *= k;
n.investing *= k;
n.financing *= k;
n.exchanged *= k;
}
let r = &mut out.operating_reconciliation;
r.net_income *= k;
r.net_cash_from_operating *= k;
for l in &mut r.lines {
l.amount *= k;
}
out.reconciles.sections_sum *= k;
out.reconciles.net_change *= k;
out.reconciles.difference *= k;
out
}
/// The cash net of a model entry.
pub fn entry_cash(model: &Model) -> BTreeMap<EntryId, i64> {
model
.iter()
.map(|(id, _, ls, _)| {
let c = ls
.iter()
.filter(|(a, _, _)| CASH.contains(a))
.map(|&(_, s, c)| signed(s, c))
.sum();
(*id, c)
})
.collect()
}
/// Normalize amounts' scale so scaled statements compare equal.
pub fn norm(cf: &CashFlowStatement) -> String {
let text = format!("{cf:?}");
// Decimals print with their scale; normalize "1.50" and "1.5".
let mut out = String::new();
let mut num = String::new();
let flush = |num: &mut String, out: &mut String| {
if !num.is_empty() {
match num.parse::<Decimal>() {
Ok(d) if num.contains('.') => out.push_str(&d.normalize().to_string()),
_ => out.push_str(num),
}
num.clear();
}
};
for ch in text.chars() {
if ch.is_ascii_digit() || ch == '.' || (ch == '-' && num.is_empty()) {
num.push(ch);
} else {
flush(&mut num, &mut out);
out.push(ch);
}
}
flush(&mut num, &mut out);
out
}
}
/// How many random books: `CF_CASES` (default 5,000, the plan's count).
fn cases() -> u32 {
std::env::var("CF_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000)
}
use proptest::prelude::*;
use random::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(cases()))]
/// rules.md B-14 over random books, reversals of reversals included:
/// 1. the sections sum to the change in cash, which is the model's;
/// 2. each section is its lines and its kinds, and 3. net income and
/// the reconciling items are operating (`foots`);
/// 4. every line's entry moved cash, and every disclosure's amount is
/// the larger of its noncash parts' positive sum and its exchange;
/// 5. posting an entry and its reversal inside the period changes
/// nothing;
/// 6. [a, m] and (m, e] add up to [a, e], section by section;
/// 7. closing every month changes nothing;
/// 8. scaling every amount scales every figure.
#[test]
fn prop_cash_flow_laws(
ops in arb_ops(false),
extra in (0u8..TEMPLATES, [1i64..100_000, 1i64..100_000, 1i64..100_000], any::<bool>()),
(a, m, e) in (0u32..365, 0u32..365, 0u32..365),
designate in any::<bool>(),
k in 2i64..=5,
) {
let mut days = [a, m, e];
days.sort_unstable();
let day = |n: u32| d(2026, 1, 1) + chrono::Days::new(u64::from(n));
let (a, m, e) = (day(days[0]), day(days[1]), day(days[2]));
let (b, model) = build(&ops, 1);
let cf = cash_flow(&b.l, &params(&b, a, e, designate)).unwrap();
foots(&cf);
prop_assert_eq!(cf.net_change_in_cash, model_cash(&model, a, e));
// 4: an entry's lines are its cash (what a member or lender
// handled for it nets to zero), each line's cash part has the
// entry's sign, and an entry no cash moved shows only what was
// handled for it.
let cash_of = entry_cash(&model);
let mut shown: std::collections::BTreeMap<EntryId, (Decimal, Decimal)> =
std::collections::BTreeMap::new();
for sec in CashFlowSection::ALL {
for l in cf.lines(sec) {
prop_assert!(!l.amount.is_zero());
let c = Decimal::new(cash_of[&l.entry], 2);
let real = l.amount - l.on_behalf;
prop_assert!(real.is_zero() || (real > Decimal::ZERO) == (c > Decimal::ZERO), "{:?}", l);
prop_assert!(l.on_behalf.is_zero() || (l.on_behalf > Decimal::ZERO) == (l.amount > Decimal::ZERO), "{:?}", l);
let t = shown.entry(l.entry).or_default();
t.0 += l.amount;
t.1 += l.on_behalf;
}
}
for (entry, (amount, on_behalf)) in &shown {
prop_assert_eq!(*amount, Decimal::new(cash_of[entry], 2), "entry #{}", entry);
prop_assert_eq!(*on_behalf, Decimal::ZERO, "entry #{}", entry);
}
if !designate {
// Only capital can then pay for the company, and only revenue
// and expense: no investing line is handled for it.
prop_assert!(cf.investing.iter().all(|l| l.on_behalf.is_zero()));
}
for n in &cf.noncash_disclosures {
let moved: Decimal = [n.operating, n.investing, n.financing]
.iter()
.filter(|x| **x > Decimal::ZERO)
.sum();
prop_assert_eq!(n.amount, moved.max(n.exchanged));
prop_assert!(n.amount > Decimal::ZERO);
prop_assert_eq!(n.operating + n.investing + n.financing, Decimal::ZERO);
}
// 5: an entry and its reversal inside the period.
let mut more = ops.clone();
more.push(Op::Post { day: days[0], template: extra.0, amounts: extra.1, who: extra.2 });
let (mut with, _) = build(&more, 1);
let last = with.l.next_entry_id() - 1;
with.reverse(last, a.max(with.l.get_entry(last).unwrap().date()).min(e));
let cf5 = cash_flow(&with.l, &params(&with, a, e, designate)).unwrap();
foots(&cf5);
prop_assert_eq!(&cf5, &cf);
// 6: split at m.
let p1 = cash_flow(&b.l, &params(&b, a, m, designate)).unwrap();
if m < e {
let p2 = cash_flow(&b.l, &params(&b, m + chrono::Days::new(1), e, designate)).unwrap();
for sec in CashFlowSection::ALL {
prop_assert_eq!(p1.total(sec) + p2.total(sec), cf.total(sec));
}
prop_assert_eq!(p1.cash_beginning, cf.cash_beginning);
prop_assert_eq!(p2.cash_ending, cf.cash_ending);
}
// 7: closing every month changes nothing.
let (mut closed, _) = build(&ops, 1);
for month in 1..=12u32 {
let start = d(2026, month, 1);
let end = if month == 12 { d(2026, 12, 31) } else { d(2026, month + 1, 1) - chrono::Days::new(1) };
let p = closed.l.create_period(&format!("2026-{month:02}"), start, end).unwrap();
closed.l.close_period(p).unwrap();
}
let cf7 = cash_flow(&closed.l, &params(&closed, a, e, designate)).unwrap();
prop_assert_eq!(norm(&cf7), norm(&cf));
// 8: scaling.
let (big, _) = build(&ops, k);
let cf8 = cash_flow(&big.l, &params(&big, a, e, designate)).unwrap();
prop_assert_eq!(norm(&cf8), norm(&scaled(&cf, k)));
}
/// Plan P3's original wording, for books of pure entries only (each
/// entry's cash settles every section it moves; review item 7): each
/// section's total is minus the movement of the section's accounts
/// over the entries that touch cash, by an independent model of the
/// sections.
#[test]
fn prop_pure_entries_sections_are_movements(
ops in arb_ops(true),
(a, e) in (0u32..365, 0u32..365),
designate in any::<bool>(),
) {
let day = |n: u32| d(2026, 1, 1) + chrono::Days::new(u64::from(n));
let (a, e) = (day(a.min(e)), day(a.max(e)));
let (b, model) = build(&ops, 1);
let cf = cash_flow(&b.l, &params(&b, a, e, designate)).unwrap();
foots(&cf);
let want = model_sections(&model, a, e, designate);
for (sec, w) in CashFlowSection::ALL.iter().zip(want) {
prop_assert_eq!(cf.total(*sec), w, "{:?}", sec);
}
// A pure entry's cash settles all it moves: nothing is noncash.
prop_assert!(cf.noncash_disclosures.is_empty());
}
}
/// Rule 2 (P3 seam review): a distribution declared to a liability that
/// holds only distributions (Dr capital / Cr 2400 Distributions Payable)
/// and paid later is a distribution paid, financing (ASC
/// 230-10-45-15(a)), under the payable's kind; the declaration is an
/// exchange inside financing, disclosed (50-3, 50-4). Before the fix the
/// payment was "Cash paid to suppliers and employees" and the declaration
/// an "Other noncash items" line beside an increase in the payable.
#[test]
fn a_distribution_declared_then_paid_is_a_distribution_paid() {
let mut b = funded();
b.acct("2400", "Distributions payable", AccountType::Liability);
let declared = b.post(
d(2026, 1, 31),
"Distribution declared",
&[
("3000", DR, "600.00"),
("3001", DR, "400.00"),
("2400", CR, "1000.00"),
],
);
let paid = b.post(
d(2026, 2, 10),
"Distribution paid",
&[("2400", DR, "1000.00"), ("1000", CR, "1000.00")],
);
let cf = feb(&b, &[], &[]);
assert!(cf.operating.is_empty(), "{:?}", cf.operating);
assert_eq!(
kinds(&cf, CashFlowSection::Financing),
[(
s("Distributions to members — 2400 Distributions payable"),
x("-1000.00")
)]
);
assert_eq!(em(&cf.financing), [(paid, x("-1000.00"))]);
assert!(recon(&cf).is_empty(), "{:?}", recon(&cf));
// The declaration's month: no cash, the exchange disclosed, and no
// operating item.
let jan = b.cf(d(2026, 1, 1), d(2026, 1, 31), &["1000", "1010"], &[], &[]);
foots(&jan);
assert_eq!(disclosed(&jan), [(declared, x("1000.00"))]);
assert!(
recon(&jan).is_empty(),
"{:?}",
jan.operating_reconciliation.lines
);
// A liability credited for anything else as well is no distributions
// payable: an expense accrued to it keeps it operating.
b.post(
d(2026, 2, 20),
"Accrued supplies",
&[("6000", DR, "50.00"), ("2400", CR, "50.00")],
);
let cf = feb(&b, &[], &[]);
assert_eq!(
cf.operating.iter().map(|l| l.entry).collect::<Vec<_>>(),
[paid]
);
}
/// Rule 8 (P3 seam review): a disposal's lines on the asset's
/// depreciation expense account (the days a month's run posted past the
/// disposal, credited back) are depreciation on the reconciliation, not
/// gain or loss, so its depreciation line is the P&L's depreciation
/// expense.
#[test]
fn a_disposals_depreciation_credit_back_is_depreciation() {
let mut b = funded();
b.post(
d(2026, 1, 10),
"Buy equipment",
&[("1500", DR, "1000.00"), ("1000", CR, "1000.00")],
);
b.post(
d(2026, 2, 28),
"Depreciation",
&[("6100", DR, "30.00"), ("1510", CR, "30.00")],
);
// Sold Feb 14: 15 of the month's 30.00 taken back, a 10.00 loss on
// the 985.00 book value.
b.post(
d(2026, 2, 14),
"Sell equipment",
&[
("1000", DR, "975.00"),
("1510", DR, "30.00"),
("6900", DR, "10.00"),
("1500", CR, "1000.00"),
("6100", CR, "15.00"),
],
);
let cf = feb(&b, &[], &[]);
assert_eq!(
recon(&cf),
[
(s("Depreciation"), x("15.00")),
(
s("(Gain) loss and other noncash changes in investing assets"),
x("10.00")
),
]
);
assert_eq!(cf.cash_from_investing, x("975.00"));
}
/// rules.md B-14 rule 5 (the P3 cutover review's open point 2): ASC
/// 230-10-45-17(c), "Cash payments to governments for taxes, duties,
/// fines, and other fees or penalties", is not 45-17(b)'s "Cash payments
/// to other suppliers and employees": a state's filing fee booked to an
/// organization-costs account (tax category `organization_startup`), its
/// payee a government agency, is (g), other operating cash payments, not
/// (d). So is a penalty paid to a tax agency on any account. The same
/// account paid to a lawyer stays (d), and so does a fee with no payee
/// named (nothing says it went to a government). A reversal in the next
/// period takes its original's class (rule 6), and a refund from a
/// government is a receipt, untouched: (c).
#[test]
fn a_payment_to_a_government_is_other_operating() {
let mut b = funded();
let org = b.acct("6050", "Organization costs", AccountType::Expense);
b.l.set_account_tax(org, Some("organization_startup"), None)
.unwrap();
let state =
b.l.add_party(
"Department of Commerce, Corporations Division",
&[ledger_core::Role::GovernmentAgency],
)
.unwrap();
let revenue =
b.l.add_party("Department of Revenue", &[ledger_core::Role::TaxAgency])
.unwrap();
let lawyer = b.l.add_vendor("Ashgrove Legal LLP").unwrap();
let lawyer = b.l.party_of_vendor(lawyer).unwrap();
let pay = |b: &mut Books, day, desc: &str, acct: &str, amount| {
b.post(
d(2026, 2, day),
desc,
&[(acct, DR, amount), ("1000", CR, amount)],
)
};
let fee = pay(&mut b, 2, "Filing fee", "6050", "75.00");
b.l.set_entry_party(fee, Some(state)).unwrap();
let legal = pay(&mut b, 3, "Operating agreement", "6050", "350.00");
b.l.set_entry_party(legal, Some(lawyer)).unwrap();
let unnamed = pay(&mut b, 4, "Fee, payee not named", "6050", "20.00");
let penalty = pay(&mut b, 5, "Late filing penalty", "6000", "15.00");
b.l.set_entry_party(penalty, Some(revenue)).unwrap();
let refund = b.post(
d(2026, 2, 6),
"Fee refunded",
&[("1000", DR, "10.00"), ("6050", CR, "10.00")],
);
b.l.set_entry_party(refund, Some(state)).unwrap();
let cf = feb(&b, &[], &[]);
let class = |e| cf.operating.iter().find(|l| l.entry == e).unwrap().class;
use OperatingClass::*;
assert_eq!(class(fee), Some(OtherPayments));
assert_eq!(class(penalty), Some(OtherPayments));
assert_eq!(class(legal), Some(ToSuppliersAndEmployees));
assert_eq!(class(unnamed), Some(ToSuppliersAndEmployees));
assert_eq!(class(refund), Some(OtherReceipts));
assert_eq!(
cf.operating_by_class(),
[
(OtherReceipts, x("10.00")),
(ToSuppliersAndEmployees, x("-370.00")),
(OtherPayments, x("-90.00")),
]
);
// Reversed in March: the fee's class, its own sign.
let back = b.reverse(fee, d(2026, 3, 2));
let march = b.cf(d(2026, 3, 1), d(2026, 3, 31), &["1000"], &[], &[]);
foots(&march);
let line = march.operating.iter().find(|l| l.entry == back).unwrap();
assert_eq!((line.class, line.amount), (Some(OtherPayments), x("75.00")));
}
/// rules.md B-14 rule 5: the payee decides on any account, and the tax
/// category still does without one: a license on `taxes_licenses` with no
/// party is (g). An entry naming a government and a supplier together
/// (half each) is split by their shares, as ASC 230-10-45-22 has each
/// separately identifiable use classed by its nature: half (g), half (d).
/// Named the city alone, the whole payment is the government's.
#[test]
fn a_government_payee_moves_a_fee_on_any_account() {
let mut b = funded();
let fees = b.acct("6700", "Taxes and licenses", AccountType::Expense);
b.l.set_account_tax(fees, Some("taxes_licenses"), None)
.unwrap();
let city =
b.l.add_party("City Treasurer", &[ledger_core::Role::GovernmentAgency])
.unwrap();
let printer = b.l.add_vendor("Print Shop").unwrap();
let printer = b.l.party_of_vendor(printer).unwrap();
let pay = |b: &mut Books, day, desc: &str, acct: &str, amount| {
b.post(
d(2026, 2, day),
desc,
&[(acct, DR, amount), ("1000", CR, amount)],
)
};
let license = pay(&mut b, 2, "Business license", "6700", "40.00");
let shared = pay(&mut b, 3, "Permit and signage", "6000", "100.00");
let half = |party| ledger_core::PartyShare {
party,
share: ledger_core::Share::Percent(x("50")),
};
b.l.set_entry_parties(shared, &[half(city), half(printer)])
.unwrap();
let cf = feb(&b, &[], &[]);
let classes = |cf: &CashFlowStatement, e| {
cf.operating
.iter()
.filter(|l| l.entry == e)
.map(|l| (l.class.unwrap(), l.amount))
.collect::<Vec<_>>()
};
use OperatingClass::*;
assert_eq!(classes(&cf, license), [(OtherPayments, x("-40.00"))]);
assert_eq!(
classes(&cf, shared),
[
(ToSuppliersAndEmployees, x("-50.00")),
(OtherPayments, x("-50.00"))
]
);
// Named the city alone, the same payment is the government's.
b.l.set_entry_party(shared, Some(city)).unwrap();
let cf = feb(&b, &[], &[]);
assert_eq!(classes(&cf, shared), [(OtherPayments, x("-100.00"))]);
}
/// rules.md B-14 rule 5: a split by shares that leaves an odd cent rounds
/// the government's part half to even, the supplier's the rest, so the
/// line still sums to the payment; its reversal next month mirrors it to
/// the cent. Fixed amounts split as given.
#[test]
fn a_shared_government_payment_splits_to_the_cent() {
let mut b = funded();
let state =
b.l.add_party(
"Department of Commerce",
&[ledger_core::Role::GovernmentAgency],
)
.unwrap();
let agent = b.l.add_vendor("Registered Agents Co.").unwrap();
let agent = b.l.party_of_vendor(agent).unwrap();
let pay = |b: &mut Books, day, amount| {
b.post(
d(2026, 2, day),
"Filing through an agent",
&[("6000", DR, amount), ("1000", CR, amount)],
)
};
let share = |party, share| ledger_core::PartyShare { party, share };
use ledger_core::Share::{Amount, Percent};
// A third to the state: 100.01 / 3 = 33.336..., 33.34 to the state.
let third = pay(&mut b, 2, "100.01");
b.l.set_entry_parties(
third,
&[
share(state, Percent(x("33.333333333333333333333333333"))),
share(agent, Percent(x("66.666666666666666666666666667"))),
],
)
.unwrap();
// 37.00 of 137.00 to the state, as amounts.
let fixed = pay(&mut b, 3, "137.00");
b.l.set_entry_parties(
fixed,
&[
share(state, Amount(x("37.00"))),
share(agent, Amount(x("100.00"))),
],
)
.unwrap();
let cf = feb(&b, &[], &[]);
let classes = |cf: &CashFlowStatement, e| {
cf.operating
.iter()
.filter(|l| l.entry == e)
.map(|l| (l.class.unwrap(), l.amount))
.collect::<Vec<_>>()
};
use OperatingClass::*;
assert_eq!(
classes(&cf, third),
[
(ToSuppliersAndEmployees, x("-66.67")),
(OtherPayments, x("-33.34"))
]
);
assert_eq!(
classes(&cf, fixed),
[
(ToSuppliersAndEmployees, x("-100.00")),
(OtherPayments, x("-37.00"))
]
);
let back = b.reverse(third, d(2026, 3, 2));
let march = b.cf(d(2026, 3, 1), d(2026, 3, 31), &["1000"], &[], &[]);
foots(&march);
assert_eq!(
classes(&march, back),
[
(ToSuppliersAndEmployees, x("66.67")),
(OtherPayments, x("33.34"))
]
);
}
/// rules.md B-14 rule 5: a reversal takes its original's payee, so the
/// pair always lands in one class and nets. A state's fee paid in
/// February and reversed in March: (g) both. The books keep a reversal
/// chain's parties one (Ledger::set_entry_parties INVARIANT 5): tagging
/// the reversal to the agent tags the fee too, and both move to (d);
/// untagging it untags both, still (d). (So the cash flow reading the
/// reversal's own parties rather than its chain root's can't differ: the
/// P4 review's mutant is equivalent.)
#[test]
fn a_reversal_takes_its_originals_payee() {
let mut b = funded();
let state =
b.l.add_party(
"Department of Commerce",
&[ledger_core::Role::GovernmentAgency],
)
.unwrap();
let agent = b.l.add_vendor("Registered Agents Co.").unwrap();
let agent = b.l.party_of_vendor(agent).unwrap();
let fee = b.post(
d(2026, 2, 2),
"Filing fee",
&[("6000", DR, "75.00"), ("1000", CR, "75.00")],
);
b.l.set_entry_party(fee, Some(state)).unwrap();
let back = b.reverse(fee, d(2026, 3, 2));
let classes = |b: &Books| {
let class = |month: u32, e| {
let last = if month == 2 { 28 } else { 31 };
let cf = b.cf(d(2026, month, 1), d(2026, month, last), &["1000"], &[], &[]);
foots(&cf);
cf.operating
.iter()
.filter(|l| l.entry == e)
.map(|l| (l.class.unwrap(), l.amount))
.collect::<Vec<_>>()
};
(class(2, fee), class(3, back))
};
use OperatingClass::*;
let both = |class| (vec![(class, x("-75.00"))], vec![(class, x("75.00"))]);
assert_eq!(classes(&b), both(OtherPayments));
b.l.set_entry_party(back, Some(agent)).unwrap();
assert_eq!(b.l.entry_party(fee), Some(agent), "the chain is tagged");
assert_eq!(classes(&b), both(ToSuppliersAndEmployees));
b.l.set_entry_party(back, None).unwrap();
assert!(b.l.entry_parties(fee).is_empty());
assert_eq!(classes(&b), both(ToSuppliersAndEmployees));
}
/// rules.md B-14 rules 5 and 11: a payment shared between a state and an
/// agent that a member paid personally (Dr 6000 137.00 / Cr 2100, the
/// members' financing account) is borrowed and spent, and its spending is
/// split by the parties' shares as a bank payment's is, the part no bank
/// account moved (`on_behalf`) with it: other operating −37.00, all on
/// behalf; to suppliers −100.00, all on behalf; financing +137.00. A
/// third to the state of 100.01 paid the same way: −33.34 and −66.67,
/// each on behalf.
#[test]
fn a_shared_government_payment_a_member_paid_splits_both_ways() {
let mut b = funded();
let state =
b.l.add_party(
"Department of Commerce",
&[ledger_core::Role::GovernmentAgency],
)
.unwrap();
let agent = b.l.add_vendor("Registered Agents Co.").unwrap();
let agent = b.l.party_of_vendor(agent).unwrap();
let share = |party, share| ledger_core::PartyShare { party, share };
use ledger_core::Share::{Amount, Percent};
let fixed = b.post(
d(2026, 2, 3),
"Annual report through an agent, paid by Member A",
&[("6000", DR, "137.00"), ("2100", CR, "137.00")],
);
b.l.set_entry_parties(
fixed,
&[
share(state, Amount(x("37.00"))),
share(agent, Amount(x("100.00"))),
],
)
.unwrap();
let third = b.post(
d(2026, 2, 4),
"Filing through an agent, paid by Member A",
&[("6000", DR, "100.01"), ("2100", CR, "100.01")],
);
b.l.set_entry_parties(
third,
&[
share(state, Percent(x("33.333333333333333333333333333"))),
share(agent, Percent(x("66.666666666666666666666666667"))),
],
)
.unwrap();
let cf = feb(&b, &["2100"], &[]);
let lines = |e| {
cf.operating
.iter()
.filter(|l| l.entry == e)
.map(|l| (l.class.unwrap(), l.amount, l.on_behalf))
.collect::<Vec<_>>()
};
use OperatingClass::*;
assert_eq!(
lines(fixed),
[
(ToSuppliersAndEmployees, x("-100.00"), x("-100.00")),
(OtherPayments, x("-37.00"), x("-37.00"))
]
);
assert_eq!(
lines(third),
[
(ToSuppliersAndEmployees, x("-66.67"), x("-66.67")),
(OtherPayments, x("-33.34"), x("-33.34"))
]
);
assert_eq!(
em(&cf.financing),
[(fixed, x("137.00")), (third, x("100.01"))]
);
assert_eq!(cf.net_change_in_cash, Decimal::ZERO);
}
/// rules.md B-14 rule 5 (CONVENTION): payroll keeps its structure. Taxes
/// withheld from employees and remitted to the tax agency are the
/// employees' pay (45-17(b), "Cash payments to other suppliers and
/// employees"), and the payroll payables mix them with the employer's
/// own taxes, so a remittance from them stays (d) though its payee is a
/// tax agency; so does pay on a wages tax category. The employer's payroll
/// tax paid on its own expense account is a tax: (g).
#[test]
fn a_payroll_remittance_to_a_tax_agency_stays_pay() {
let mut b = funded();
let wages = b.acct("6160", "Salaries", AccountType::Expense);
b.l.assign_role("salary_expense", Some(wages)).unwrap();
b.acct("6110", "Payroll Tax Expense", AccountType::Expense);
// The withholding payable at a code of the chart's own, given its
// role.
let withheld = b.acct("2210", "Withholding payable", AccountType::Liability);
b.l.assign_role("federal_withholding", Some(withheld))
.unwrap();
let bonus = b.acct("6150", "Bonuses", AccountType::Expense);
b.l.set_account_tax(bonus, Some("salaries_wages"), None)
.unwrap();
let irs =
b.l.add_party("Internal Revenue Service", &[ledger_core::Role::TaxAgency])
.unwrap();
b.post(
d(2026, 1, 31),
"January payroll",
&[
("6160", DR, "1000.00"),
("2210", CR, "200.00"),
("1000", CR, "800.00"),
],
);
let remit = b.post(
d(2026, 2, 3),
"Remit withheld taxes",
&[("2210", DR, "200.00"), ("1000", CR, "200.00")],
);
b.l.set_entry_party(remit, Some(irs)).unwrap();
let garnish = b.post(
d(2026, 2, 4),
"Bonus paid to a levy",
&[("6150", DR, "30.00"), ("1000", CR, "30.00")],
);
b.l.set_entry_party(garnish, Some(irs)).unwrap();
let employer = b.post(
d(2026, 2, 5),
"Employer FUTA",
&[("6110", DR, "42.00"), ("1000", CR, "42.00")],
);
b.l.set_entry_party(employer, Some(irs)).unwrap();
let cf = feb(&b, &[], &[]);
let class = |e| cf.operating.iter().find(|l| l.entry == e).unwrap().class;
use OperatingClass::*;
assert_eq!(class(remit), Some(ToSuppliersAndEmployees));
assert_eq!(class(garnish), Some(ToSuppliersAndEmployees));
assert_eq!(class(employer), Some(OtherPayments));
}
/// rules.md B-14 rule 5: a government selling goods or services is a
/// supplier (45-17(b), "for other goods or services"), not 45-17(c)'s
/// taxes, fees or penalties: the post office named with the `vendor`
/// role, postage stays (d). The roles decide, so a post office given the
/// `government_agency` role would read as (g): the handbook says not to.
#[test]
fn a_government_selling_services_is_a_supplier() {
let mut b = funded();
b.acct("6120", "Postage", AccountType::Expense);
let usps = b.l.add_vendor("Postal Service").unwrap();
let usps = b.l.party_of_vendor(usps).unwrap();
let stamps = b.post(
d(2026, 2, 4),
"Stamps",
&[("6120", DR, "30.00"), ("1000", CR, "30.00")],
);
b.l.set_entry_party(stamps, Some(usps)).unwrap();
let cf = feb(&b, &[], &[]);
let line = cf.operating.iter().find(|l| l.entry == stamps).unwrap();
assert_eq!(line.class, Some(OperatingClass::ToSuppliersAndEmployees));
b.l.set_party_role(usps, ledger_core::Role::GovernmentAgency, true)
.unwrap();
let cf = feb(&b, &[], &[]);
let line = cf.operating.iter().find(|l| l.entry == stamps).unwrap();
assert_eq!(line.class, Some(OperatingClass::OtherPayments));
}