P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
check / check (push) Canceled after 0s

Direct method with the operating reconciliation and noncash schedules; it
foots by construction (Lean and Kani). Every non-cash account belongs to
one section; reversals net; a member or lender paying for the company is
cash borrowed and spent (D-42, constructive receipt and disbursement);
property a member contributes stays noncash. The bookspec seed: one loader
checks every example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 09:35:32 -06:00
1 parent 2c29d48ab9
commit eb4329bb2a
64 files changed
+12128 -1567

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -1,398 +0,0 @@
//! The bookspec examples about closes to the members
//! (`bookspec/examples/members-close.*.toml`; P13, D-34; P3 defect 17),
//! checked against the engine on every run, so they can't rot.
//!
//! Each example is a setup, events and expected values worked by hand from
//! its cited source. Its shape is bookspec's (`bookspec/README.md`) with
//! what closes need:
//!
//! - `[[setup.members]]` takes `share`, an exact decimal fraction (the
//! shares sum to 1), beside `name` and `capital_account`;
//! - an event may be a close instead of an entry: `close = { start, to,
//! rounding? }` closes the period from `start` through the event's
//! `date` to `"members"` (by share, in the order listed) or
//! `"retained_earnings"`; `rounding = "per-close"` makes a members close
//! split its own net alone (books from an engine that rounded each close
//! by itself), and the default is the cumulative rule. A close is an
//! event like any other: event N posts entry #N, so a close must have
//! something to close;
//! - an `[[expect]]` may take `after = N`: the books after event N, not
//! after the last;
//! - three queries: `entry` (`entry = N`: its lines, `"<code>.debit"` and
//! `"<code>.credit"` summing each account's lines on that side, every
//! line of the entry named), `balances` (`as_of`: each account named,
//! `"<code>"`, its balance on its normal side, so a member's capital
//! after a loss is negative), and `members_equity` (`from`, `to`: the
//! statement of changes in members' equity, `"<name>.beginning"`,
//! `.contributions`, `.net_income`, `.distributions`, `.other`,
//! `.ending`).
//!
//! A key the shape doesn't have is refused, never ignored.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ledger_core::{
AccountType, CloseTo, Currency, Ledger, MemberCapital, MemberClose, Money, Side,
members_equity, reports::trial_balance_as_of,
};
use rust_decimal::Decimal;
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Example {
id: String,
title: String,
rule: String,
status: String,
sources: Vec<String>,
tags: Vec<String>,
setup: Setup,
events: Vec<Event>,
expect: Vec<Expect>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Setup {
currency: String,
fiscal_year_start: String,
basis: String,
rounding: String,
accounts: Vec<AccountSpec>,
#[serde(default)]
members: Vec<MemberSpec>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AccountSpec {
code: String,
name: String,
#[serde(rename = "type")]
kind: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MemberSpec {
name: String,
capital_account: String,
share: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Event {
date: String,
description: String,
#[serde(default)]
lines: Vec<LineSpec>,
#[serde(default)]
reverses: Option<u64>,
#[serde(default)]
close: Option<CloseSpec>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LineSpec {
account: String,
side: String,
amount: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CloseSpec {
start: String,
to: String,
#[serde(default)]
rounding: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Expect {
query: String,
#[serde(default)]
from: Option<String>,
#[serde(default)]
to: Option<String>,
#[serde(default)]
as_of: Option<String>,
#[serde(default)]
entry: Option<u64>,
#[serde(default)]
after: Option<usize>,
values: BTreeMap<String, String>,
}
fn examples() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples");
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("members-close.") && n.ends_with(".toml"))
})
.collect();
paths.sort();
paths
}
fn date(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap_or_else(|_| panic!("date {s:?}"))
}
/// An exact decimal string, never a float.
fn exact(s: &str) -> Decimal {
assert!(
!s.contains(['e', 'E']) && s.trim() == s,
"{s:?} is not a decimal string"
);
s.parse()
.unwrap_or_else(|_| panic!("{s:?} is not a decimal"))
}
fn account_type(kind: &str) -> AccountType {
match kind {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
"expense" => AccountType::Expense,
other => panic!("account type {other:?}"),
}
}
fn side(s: &str) -> Side {
match s {
"debit" => Side::Debit,
"credit" => Side::Credit,
other => panic!("side {other:?}"),
}
}
struct Books<'a> {
ex: &'a Example,
l: Ledger,
currency: Currency,
}
impl Books<'_> {
fn id(&self, code: &str) -> ledger_core::AccountId {
self.l
.chart()
.find_by_code(code)
.unwrap_or_else(|| panic!("{}: no account {code}", self.ex.id))
.id
}
fn members(&self) -> Vec<MemberCapital> {
self.ex
.setup
.members
.iter()
.map(|m| MemberCapital {
name: m.name.clone(),
capital_account: self.id(&m.capital_account),
distribution_account: None,
share: exact(&m.share),
})
.collect()
}
/// Event `n` (1-based), which must post entry #n.
fn apply(&mut self, n: u64, e: &Event) {
let ex = &self.ex.id;
let day = date(&e.date);
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(Some(c), None, true) => {
let p = self
.l
.create_period(&format!("event {n}"), date(&c.start), day)
.unwrap();
let members: Vec<MemberClose> = self
.members()
.iter()
.map(|m| MemberClose {
capital: m.capital_account,
share: m.share,
distributions: None,
})
.collect();
let to = match (c.to.as_str(), c.rounding.as_deref()) {
("members", None) => CloseTo::Members(members),
("members", Some("per-close")) => CloseTo::MembersPerClose(members),
("retained_earnings", None) => CloseTo::RetainedEarnings { sweep: vec![] },
other => panic!("{ex}: close {other:?}"),
};
self.l
.close_period_to(p, &to)
.unwrap()
.unwrap_or_else(|| panic!("{ex}: event {n} closes nothing"))
}
(None, Some(k), true) => {
assert!(k >= 1 && k < n, "{ex}: event {n} reverses {k}");
self.l.reverse_entry(k, day, &e.description).unwrap()
}
(None, None, false) => {
let id = self.l.create_entry(day, &e.description);
for line in &e.lines {
let account = self.id(&line.account);
let money = Money::new(exact(&line.amount), self.currency);
self.l
.add_line(id, account, side(&line.side), money)
.unwrap();
}
self.l.post_entry(id).unwrap();
id
}
_ => panic!("{ex}: event {n} is one of lines, reverses or close"),
};
assert_eq!(posted, n, "{ex}: event {n} posted entry {posted}");
}
/// What `q` asks, for each of its keys.
fn answer(&self, q: &Expect) -> BTreeMap<String, Decimal> {
let ex = &self.ex.id;
match q.query.as_str() {
"entry" => {
let id = q
.entry
.unwrap_or_else(|| panic!("{ex}: entry query without entry"));
let mut out = BTreeMap::new();
for line in self.l.get_entry(id).unwrap().lines() {
let code = &self.l.chart().get(line.account).unwrap().code;
let side = match line.side {
Side::Debit => "debit",
Side::Credit => "credit",
};
*out.entry(format!("{code}.{side}")).or_default() += line.amount.amount;
}
out
}
"balances" => {
let as_of = date(q.as_of.as_deref().expect("balances take as_of"));
let tb = trial_balance_as_of(&self.l, as_of);
q.values
.keys()
.map(|code| {
let account = self.l.chart().get(self.id(code)).unwrap();
let (dr, cr) = tb
.lines
.iter()
.find(|l| &l.code == code)
.map_or((Decimal::ZERO, Decimal::ZERO), |l| (l.debit, l.credit));
let normal = match account.account_type.normal_side() {
Side::Debit => dr - cr,
Side::Credit => cr - dr,
};
(code.clone(), normal)
})
.collect()
}
"members_equity" => {
let from = date(q.from.as_deref().expect("members_equity takes from"));
let to = date(q.to.as_deref().expect("members_equity takes to"));
let st = members_equity(&self.l, from, to, &self.members()).unwrap();
let mut out = BTreeMap::new();
for m in &st.members {
for (field, v) in [
("beginning", m.beginning),
("contributions", m.contributions),
("net_income", m.net_income),
("distributions", m.distributions),
("other", m.other),
("ending", m.ending),
] {
out.insert(format!("{}.{field}", m.name), v);
}
}
out
}
other => panic!("{ex}: query {other:?}"),
}
}
fn check(&self, q: &Expect) {
let ex = &self.ex.id;
let got = self.answer(q);
for (key, want) in &q.values {
let got = got
.get(key)
.unwrap_or_else(|| panic!("{ex}: {} has no {key}", q.query));
assert_eq!(*got, exact(want), "{ex}: {} {key}", q.query);
}
if q.query == "entry" {
// Every line named: the entry is exactly what is expected.
let named: Vec<&String> = q.values.keys().collect();
let lines: Vec<&String> = got.keys().collect();
assert_eq!(lines, named, "{ex}: entry {:?}'s lines", q.entry);
}
}
}
/// Every members-close example loads, and the engine computes every value
/// it expects, after the event it names or the last.
#[test]
fn the_members_close_examples_hold() {
let paths = examples();
assert!(paths.len() >= 4, "{} examples", paths.len());
for path in paths {
let text = std::fs::read_to_string(&path).unwrap();
let ex: Example =
toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
assert_eq!(
path.file_stem().unwrap().to_str().unwrap(),
ex.id,
"an example's file is named for its id"
);
assert!(!ex.title.is_empty() && !ex.rule.is_empty() && !ex.sources.is_empty());
assert!(!ex.tags.is_empty());
assert!(["AUTHORITY", "CONVENTION", "CHOICE"].contains(&ex.status.as_str()));
assert_eq!(
(ex.setup.basis.as_str(), ex.setup.rounding.as_str()),
("accrual", "largest-remainder"),
"{}",
ex.id
);
assert!(ex.setup.fiscal_year_start.len() == 5, "{}", ex.id);
let shares: Decimal = ex.setup.members.iter().map(|m| exact(&m.share)).sum();
assert_eq!(shares, Decimal::ONE, "{}: shares", ex.id);
let currency = Currency::new(&ex.setup.currency).unwrap();
let mut books = Books {
ex: &ex,
l: Ledger::new(currency),
currency,
};
for a in &ex.setup.accounts {
books
.l
.add_account(&a.code, &a.name, account_type(&a.kind), None, false)
.unwrap();
}
for q in &ex.expect {
assert!(
q.after.is_none_or(|n| n >= 1 && n <= ex.events.len()),
"{}: after {:?}",
ex.id,
q.after
);
}
for (i, e) in ex.events.iter().enumerate() {
books.apply(i as u64 + 1, e);
for q in ex.expect.iter().filter(|q| q.after == Some(i + 1)) {
books.check(q);
}
}
for q in ex.expect.iter().filter(|q| q.after.is_none()) {
books.check(q);
}
}
}
+66 -14
View File
@@ -744,7 +744,7 @@ fn entry_movements_sum_to_zero() {
}
// ---------------------------------------------------------------------------
// Cash flow statement (indirect method).
// Cash flow statement (direct method, rules.md B-14).
// ---------------------------------------------------------------------------
use ledger_core::NewAsset;
@@ -757,6 +757,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
}
}
@@ -779,20 +780,36 @@ fn cash_flow_reconciles_to_change_in_cash() {
assert_eq!(cf.net_change_in_cash, Decimal::new(820000, 2));
// Net income: 4,000 revenue - 800 rent = 3,200.
assert_eq!(cf.net_income, Decimal::new(320000, 2));
let r = &cf.operating_reconciliation;
assert_eq!(r.net_income, Decimal::new(320000, 2));
// No depreciation: the fixture's equipment is not a registered asset.
assert_eq!(cf.depreciation, Decimal::ZERO);
assert!(r.lines.iter().all(|l| l.label != "Depreciation"));
// Operating: 3,200 net income
// - 2,500 increase in AR (4,000 billed - 1,500 collected)
// - 2,500 equipment purchase (not a registered asset: working capital)
// + 10,000 owner investment is equity -> financing, not operating.
// Financing: +10,000 owner contribution.
// => operating = 3,200 - 2,500 - 2,500 = -1,800
// => investing = 0, financing = 10,000. Sum = 8,200. Reconciles.
// Operating, by the direct method: collected 1,500 (class (a)); paid
// 2,500 for equipment (not a registered asset: operating, rules.md
// B-14's limit R4) and 800 rent (class (d)) = -1,800. Reconciled:
// 3,200 net income - 2,500 increase in AR (4,000 billed - 1,500
// collected) - 2,500 increase in equipment = -1,800.
// Financing: +10,000 owner contribution. Sum = 8,200. Reconciles.
assert_eq!(cf.cash_from_operations, Decimal::new(-180000, 2));
assert_eq!(
cf.operating_by_class(),
[
(
ledger_core::OperatingClass::FromCustomers,
Decimal::new(150000, 2)
),
(
ledger_core::OperatingClass::ToSuppliersAndEmployees,
Decimal::new(-330000, 2)
),
]
);
let total: Decimal = r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>();
assert_eq!(total, cf.cash_from_operations);
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
assert_eq!(cf.cash_from_financing, Decimal::new(1000000, 2));
assert_eq!(cf.reconciles.difference, Decimal::ZERO);
}
#[test]
@@ -894,11 +911,46 @@ fn cash_flow_with_registered_asset_and_disposal() {
// Cash: 100,000 - 60,000 + 59,600 = 99,600.
assert_eq!(cf.cash_ending, Decimal::new(9960000, 2));
// Net income: 148 gain - 548 depreciation = -400.
assert_eq!(cf.net_income, Decimal::new(-40000, 2));
// Depreciation added back: 548.
assert_eq!(cf.depreciation, Decimal::new(54800, 2));
// Operating: -400 + 548 - 148 (gain removed) = 0.
let r = &cf.operating_reconciliation;
assert_eq!(r.net_income, Decimal::new(-40000, 2));
// Depreciation added back: 548; the gain taken out: -148 (its cash is
// investing).
let rec: Vec<(&str, Decimal)> = r
.lines
.iter()
.map(|l| (l.label.as_str(), l.amount))
.collect();
assert_eq!(
rec,
[
("Depreciation", Decimal::new(54800, 2)),
(
"(Gain) loss and other noncash changes in investing assets",
Decimal::new(-14800, 2)
),
]
);
// Operating: -400 + 548 - 148 = 0.
assert_eq!(cf.cash_from_operations, Decimal::ZERO);
// Gross (ASC 230-10-45-7): the purchase and the sale apart.
let inv: Vec<(&str, Decimal)> = cf
.investing
.iter()
.map(|l| (l.kind.as_str(), l.amount))
.collect();
assert_eq!(
inv,
[
(
"Purchases of property and equipment",
Decimal::new(-6000000, 2)
),
(
"Proceeds from sales of property and equipment",
Decimal::new(5960000, 2)
),
]
);
// Investing: -60,000 purchase + 59,600 proceeds = -400.
assert_eq!(cf.cash_from_investing, Decimal::new(-40000, 2));
// Financing: +100,000 owner investment.
@@ -5,3 +5,5 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc bd44337eb42456f907441e7938e4909e0b66386ce70d26b9b54e7173c4d72fa7 # shrinks to parts = [427221022393, 672290605285], rems = [0, 0, 0, 0, 0], given = [0, 0, 0, 0, 0], short = 99, unpaired = false
cc 763c29fdb4a32c44ec7b41b2616685739f7accb3063872c1fc9df69e7f43c0d8 # shrinks to paid = false, sources = [1], uses = [-1]
cc badb0f9ff0f4e69ac29aa73c59c238de5afaeef1cd66f7aba7098a786a37a1fd # shrinks to paid = true, sources = [-1], uses = []
+70
View File
@@ -245,4 +245,74 @@ proptest! {
prop_assert!(drift.abs() < Decimal::from(big_w) / Decimal::from(100), "{drift}");
}
}
/// rules.md B-14 rule 3: the statement of cash flows' per-entry split
/// (`kernel::entry_flows`, Kani `entry_flows_*`) is the same at both
/// instantiations, on balanced and unbalanced lines alike.
#[test]
fn entry_flows_agree(lines in arb_lines()) {
let tagged = |a: u8| match a {
0 => kernel::Flow::Cash,
1 => kernel::Flow::Operating,
2 => kernel::Flow::Investing,
_ => kernel::Flow::Financing,
};
let li: Vec<_> = lines.iter().map(|&(k, s, a)| (tagged(k), s, a)).collect();
let ld: Vec<_> = lines.iter().map(|&(k, s, a)| (tagged(k), s, dec(a))).collect();
let fi = kernel::entry_flows(&li).map(|f| kernel::EntryFlows {
cash: dec(f.cash),
flows: f.flows.map(dec),
noncash: f.noncash.map(dec),
});
prop_assert_eq!(fi, kernel::entry_flows(&ld));
}
/// `kernel::apply_cash` (how a section's cash splits among its kinds)
/// and `kernel::exchanged` agree at both instantiations.
#[test]
fn apply_cash_and_exchanged_agree(
cash in arb_cents(),
moves in prop::collection::vec(arb_cents(), 0..5),
d in 0i64..=MAX_CENTS,
c in 0i64..=MAX_CENTS,
inflow in 0i64..=MAX_CENTS,
outflow in 0i64..=MAX_CENTS,
) {
let mut ai = vec![0i64; moves.len()];
let mut ad = vec![Decimal::ZERO; moves.len()];
let md: Vec<Decimal> = moves.iter().copied().map(dec).collect();
let oi = kernel::apply_cash(cash, &moves, &mut ai);
let od = kernel::apply_cash(dec(cash), &md, &mut ad);
prop_assert_eq!(oi, od);
if oi.is_some() {
prop_assert_eq!(ai.into_iter().map(dec).collect::<Vec<_>>(), ad);
}
prop_assert_eq!(
kernel::exchanged(d, c, inflow, outflow).map(dec),
kernel::exchanged(dec(d), dec(c), dec(inflow), dec(outflow))
);
}
/// rules.md B-14 rule 11: `kernel::constructive` (what a member or
/// lender paid or received for the company; Kani
/// `constructive_nets_to_zero`, `constructive_mirrors`) agrees at both
/// instantiations.
#[test]
fn constructive_agrees(
paid in any::<bool>(),
sources in prop::collection::vec(arb_cents(), 0..4),
uses in prop::collection::vec(arb_cents(), 0..4),
) {
let way = if paid { kernel::OnBehalf::Paid } else { kernel::OnBehalf::Received };
let (mut fi, mut ti) = (vec![0i64; sources.len()], vec![0i64; uses.len()]);
let sd: Vec<Decimal> = sources.iter().copied().map(dec).collect();
let ud: Vec<Decimal> = uses.iter().copied().map(dec).collect();
let (mut fd, mut td) = (vec![Decimal::ZERO; sd.len()], vec![Decimal::ZERO; ud.len()]);
let oi = kernel::constructive(way, &sources, &uses, &mut fi, &mut ti);
let od = kernel::constructive(way, &sd, &ud, &mut fd, &mut td);
prop_assert_eq!(oi, od);
prop_assert!(oi.is_some());
prop_assert_eq!(fi.into_iter().map(dec).collect::<Vec<_>>(), fd);
prop_assert_eq!(ti.into_iter().map(dec).collect::<Vec<_>>(), td);
}
}
@@ -0,0 +1,11 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 1a4be67a53e1782122f6ca192c3525f029bef45f52351847fa24cd7fb89ce84d # shrinks to ops = [Post { day: 44, template: 9, amounts: [1, 1, 1], who: false }], extra = (0, [1, 1, 1], false), (a, m, e) = (0, 0, 44), designate = true, k = 2
cc 69f4101495a47fc6b8c78e94de304cd3b728a1be3ada022514c5999d13048ae6 # shrinks to ops = [Post { day: 113, template: 25, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Reverse { of: 4714218928808477990, after: 54 }], (a, e) = (114, 0), designate = true
cc 0f108f7a6a6438f5cd89f1029b529e74b202af6b7ace3603d971b726ba7523af # shrinks to ops = [Post { day: 210, template: 24, amounts: [1, 1, 1], who: false }], (a, e) = (0, 210), designate = false
cc 3418b7695f5874cfdfde9e98e12ca276a457b5049339bfe0036bae842dc0346b # shrinks to ops = [Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }], extra = (9, [1, 1, 1], false), (a, m, e) = (0, 0, 0), designate = true, k = 2
cc 4f1c756113272b826d9234f5307df5d5839cb23ffe8a44b8eb370561679d44b3 # shrinks to ops = [Post { day: 93, template: 1, amounts: [1, 1, 1], who: false }], extra = (0, [1, 1, 1], false), (a, m, e) = (0, 0, 93), designate = false, k = 2
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1557,9 +1557,9 @@ fn entry_flow_scenarios() {
}
// 2. Reimbursing members; paying the card.
for liab in [members, card] {
let f = case(&[(liab, D, "387.64"), (bank, C, "387.64")]);
assert_eq!(f.net, dec("387.64"));
assert_eq!(f.what_for, vec![(liab, dec("387.64"))]);
let f = case(&[(liab, D, "412.30"), (bank, C, "412.30")]);
assert_eq!(f.net, dec("412.30"));
assert_eq!(f.what_for, vec![(liab, dec("412.30"))]);
}
// 3. A loan.
let f = case(&[(bank, D, "1000.00"), (loan, C, "1000.00")]);
@@ -0,0 +1,11 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 99b387de55e0ece2480575f9cc93c688e0ad408686703012ce503dd21f73ba85 # shrinks to gens = [Gen { day: 67, mode: 1, picks: [4, 0, 4], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 67), designate = false
cc 9f5e63191433240d6df895c196fdd89b56ce1a2a3d8d5225740c2f10dc720756 # shrinks to gens = [Gen { day: 100, mode: 2, picks: [3, 0, 4], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 100), designate = true
cc 5397d840688ed53dc033aceaf98b8db5df3a7a2616c4afe7f3f5fc2e0c8775f2 # shrinks to gens = [Gen { day: 140, mode: 2, picks: [0, 0, 2], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 140), designate = false
cc abd84709578e7889c275a0019ae0e71366d77526ae93072777be614d7ffb0dd5 # shrinks to gens = [Gen { day: 108, mode: 1, picks: [1, 5, 3], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 108), k = 2
cc 7481a0fb806d08439357ce4be66224389a7f76a7d91bf93ac320bb2b3c4d4891 # shrinks to gens = [Gen { day: 3, mode: 1, picks: [4, 5, 1], x: 1, y: 1, classes: [0, 0, 0] }], drafts = [Gen { day: 0, mode: 0, picks: [0, 0, 0], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 3)
+131 -42
View File
@@ -1331,6 +1331,7 @@ fn cf_transfer_between_cash_accounts_is_not_a_flow() {
cash_accounts: vec![cash, ar],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
},
)
.unwrap();
@@ -1340,6 +1341,7 @@ fn cf_transfer_between_cash_accounts_is_not_a_flow() {
assert_eq!(cf.cash_from_financing, Decimal::ZERO);
assert_eq!(cf.cash_beginning, c(100000));
assert_eq!(cf.cash_ending, c(100000));
assert!(cf.operating.is_empty() && cf.noncash_disclosures.is_empty());
}
// ===========================================================================
@@ -1406,12 +1408,13 @@ fn sorted_amounts(lines: &[ledger_core::CashFlowLine]) -> Vec<Decimal> {
v
}
/// Oracle: CF INVARIANTS 1, 2, 4 — hand-computed February (indirect): NI
/// 400; AR up 300 (-300), AP up 80 (+80); CFO 180; financing loan 300 +
/// contribution 100 = 400; net change 580 = cash 1000 -> 1580.
/// Oracle: CF INVARIANTS 1, 2, 4 — hand-computed February (direct):
/// collected 200, paid 20: CFO 180; reconciled from NI 400, AR up 300
/// (-300), AP up 80 (+80); financing loan 300 + contribution 100 = 400;
/// net change 580 = cash 1000 -> 1580.
///
/// Working capital is one line per account that changed; financing is gross per
/// account (docs/testing.md #8): here each financing account moved one way only.
/// The reconciliation has one line per operating account that changed;
/// financing is one line per entry (docs/testing.md #8).
#[test]
fn cf_hand_computed_with_designated_debt() {
let co = cf_example_books();
@@ -1421,16 +1424,21 @@ fn cf_hand_computed_with_designated_debt() {
cash_accounts: vec![co.cash],
financing_accounts: vec![co.loan],
investing_accounts: vec![],
members: vec![],
};
let cf = cash_flow(&co.l, &params).unwrap();
assert_eq!(cf.start, d(2026, 2, 1));
assert_eq!(cf.end, d(2026, 2, 28));
assert_eq!(cf.net_income, c(40000));
assert_eq!(cf.depreciation, Decimal::ZERO);
assert_eq!(
sorted_amounts(&cf.working_capital),
vec![c(-30000), c(8000)]
);
assert_eq!(cf.operating_reconciliation.net_income, c(40000));
let mut rec: Vec<Decimal> = cf
.operating_reconciliation
.lines
.iter()
.map(|l| l.amount)
.collect();
rec.sort();
assert_eq!(rec, vec![c(-30000), c(8000)]);
assert_eq!(sorted_amounts(&cf.operating), vec![c(-2000), c(20000)]);
assert_eq!(cf.cash_from_operations, c(18000));
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
assert_eq!(sorted_amounts(&cf.financing), vec![c(10000), c(30000)]);
@@ -1441,9 +1449,9 @@ fn cf_hand_computed_with_designated_debt() {
}
/// Oracle: CashFlowParams doc — by default all liabilities are operating
/// working capital (the loan moves to operating: CFO 480, financing 100).
/// (the loan's cash moves to operating: CFO 480, financing 100).
#[test]
fn cf_undesignated_liability_is_working_capital() {
fn cf_undesignated_liability_is_operating() {
let co = cf_example_books();
let params = CashFlowParams {
start: d(2026, 2, 1),
@@ -1451,6 +1459,7 @@ fn cf_undesignated_liability_is_working_capital() {
cash_accounts: vec![co.cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
};
let cf = cash_flow(&co.l, &params).unwrap();
assert_eq!(cf.cash_from_operations, c(48000));
@@ -1470,6 +1479,7 @@ fn cf_never_counts_closing_entries() {
cash_accounts: vec![co.cash],
financing_accounts: vec![co.loan],
investing_accounts: vec![],
members: vec![],
};
let before = cash_flow(&co.l, &params).unwrap();
let p1 =
@@ -1481,14 +1491,15 @@ fn cf_never_counts_closing_entries() {
.unwrap();
co.l.close_period(p2).unwrap();
let after = cash_flow(&co.l, &params).unwrap();
assert_eq!(after.net_income, c(40000));
assert_eq!(after.operating_reconciliation.net_income, c(40000));
assert_eq!(cf_key(&after), cf_key(&before));
}
/// Oracle: CF INVARIANTS 1-3 — fixed-asset purchase in investing,
/// depreciation added back, disposal gain moved out of operating with the
/// proceeds in investing (hand-computed: Feb NI -100, depr 100, CFO 0,
/// investing -1200; Mar NI -50, depr 100, CFO 0, investing +1050).
/// depreciation reconciling, disposal gain taken out of net income with
/// the proceeds in investing (hand-computed: Feb NI -100, depr 100, CFO 0,
/// investing -1200; Mar NI -50, depr 100, gain -50, CFO 0, investing
/// +1050).
///
/// ASSUMED: depreciation is recognised as credits to a registered asset's
/// accumulated-depreciation account against expense, and the disposal is
@@ -1552,11 +1563,13 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
},
)
.unwrap();
assert_eq!(feb.net_income, c(-10000));
assert_eq!(feb.depreciation, c(10000));
assert_eq!(feb.operating_reconciliation.net_income, c(-10000));
assert_eq!(feb.operating_reconciliation.lines[0].label, "Depreciation");
assert_eq!(feb.operating_reconciliation.lines[0].amount, c(10000));
assert_eq!(feb.cash_from_operations, Decimal::ZERO);
assert_eq!(feb.cash_from_investing, c(-120000));
assert_eq!(feb.cash_from_financing, Decimal::ZERO);
@@ -1570,11 +1583,18 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
},
)
.unwrap();
assert_eq!(mar.net_income, c(-5000));
assert_eq!(mar.depreciation, c(10000));
assert_eq!(mar.operating_reconciliation.net_income, c(-5000));
let rec: Vec<Decimal> = mar
.operating_reconciliation
.lines
.iter()
.map(|l| l.amount)
.collect();
assert_eq!(rec, vec![c(10000), c(-5000)]);
assert_eq!(mar.cash_from_operations, Decimal::ZERO);
assert_eq!(mar.cash_from_investing, c(105000));
assert_eq!(mar.net_change_in_cash, c(105000));
@@ -2000,7 +2020,7 @@ proptest! {
fn prop_drafts_change_no_report(gens in arb_books(), drafts in prop::collection::vec(arb_gen(), 1..5), (a, _m, e) in three_days()) {
let mut b = build(&gens);
let (s0, e0) = (day(a), day(e));
let cfp = CashFlowParams { start: s0, end: e0, cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![] };
let cfp = CashFlowParams { start: s0, end: e0, cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![], members: vec![] };
let tb = tb_lines(&trial_balance_as_of(&b.l, e0));
let pl = pnl_key(&profit_and_loss_for_period(&b.l, s0, e0, None).unwrap());
let bs = bs_key(&balance_sheet_as_of(&b.l, e0).unwrap());
@@ -2426,12 +2446,12 @@ proptest! {
/// Oracle: CF INVARIANTS 1-2, 4 — operating + investing + financing =
/// net change = ending - beginning cash (model), NI is the period P&L
/// (model, closes excluded), section totals are their line sums, CFO =
/// NI + depreciation + working capital; with or without a close and a
/// NI + the reconciling items; with or without a close and a
/// designated loan.
///
/// With no registered fixed assets and no investing accounts (docs/testing.md #8),
/// depreciation and investing
/// are zero (receivables are current, so working capital).
/// With no registered fixed assets and no investing accounts
/// (docs/testing.md #8), investing is zero (receivables are current,
/// so operating).
#[test]
fn prop_cf_sections_sum_to_change_in_cash(gens in arb_books(), (a, _m, e) in three_days(), month in opt_month(), designate in any::<bool>()) {
let mut b = build(&gens);
@@ -2442,6 +2462,7 @@ proptest! {
cash_accounts: vec![b.ids[CASH]],
financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] },
investing_accounts: vec![],
members: vec![],
};
let cf = cash_flow(&b.l, &params).unwrap();
prop_assert_eq!(cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash);
@@ -2450,18 +2471,27 @@ proptest! {
let end = model(&gens, early(), day(e), None).get("1000").copied().unwrap_or_default();
prop_assert_eq!(cf.cash_beginning, beg);
prop_assert_eq!(cf.cash_ending, end);
prop_assert_eq!(cf.net_income, model_ni(&model(&gens, day(a), day(e), None)));
prop_assert_eq!(cf.depreciation, Decimal::ZERO);
let r = &cf.operating_reconciliation;
prop_assert_eq!(r.net_income, model_ni(&model(&gens, day(a), day(e), None)));
prop_assert_eq!(cf.cash_from_investing, Decimal::ZERO);
let wc: Decimal = cf.working_capital.iter().map(|l| l.amount).sum();
prop_assert_eq!(cf.cash_from_operations, cf.net_income + cf.depreciation + wc);
let items: Decimal = r.lines.iter().map(|l| l.amount).sum();
prop_assert_eq!(cf.cash_from_operations, r.net_income + items);
prop_assert_eq!(cf.cash_from_operations, cf.operating.iter().map(|l| l.amount).sum::<Decimal>());
prop_assert_eq!(cf.reconciles.difference, Decimal::ZERO);
prop_assert_eq!(cf.cash_from_investing, cf.investing.iter().map(|l| l.amount).sum::<Decimal>());
prop_assert_eq!(cf.cash_from_financing, cf.financing.iter().map(|l| l.amount).sum::<Decimal>());
}
/// Oracle: CF INVARIANT 4 against the model — financing is the change in
/// equity accounts (other than earnings) plus designated debt; the rest
/// of the non-cash balance sheet is working capital.
/// Oracle: CF INVARIANT 2 against an independent model — financing is
/// the cash part of the equity and designated-debt movements: each
/// entry's cash applied to the sections moving against it, operating,
/// investing, financing, each up to its own movement (rules.md B-14
/// rule 3), then to the kinds within each section in their order;
/// plus what the loan or a member's capital paid or received for the
/// company (rule 11, D-42): the loan pays for operating lines (the
/// balance-sheet ones first) and receives for revenue and expense;
/// capital pays and receives for revenue and expense only. Written
/// here apart from the kernel.
#[test]
fn prop_cf_financing_is_equity_plus_designated_debt(gens in arb_books(), (a, _m, e) in three_days(), designate in any::<bool>()) {
let b = build(&gens);
@@ -2471,13 +2501,72 @@ proptest! {
cash_accounts: vec![b.ids[CASH]],
financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] },
investing_accounts: vec![],
members: vec![],
};
let cf = cash_flow(&b.l, &params).unwrap();
let m = model(&gens, day(a), day(e), None);
let credit = |code: &str| -m.get(code).copied().unwrap_or_default();
let mut fin = credit("3000") + credit("3001");
if designate { fin += credit("2500"); }
prop_assert_eq!(cf.cash_from_financing, fin);
// Take up to `amount` (>= 0) from the parts of `qs` with sign
// `debits`, in order, moving them toward zero.
fn settle(qs: &mut [i64], mut amount: i64, debits: bool) {
for q in qs.iter_mut() {
let room = if debits { (*q).max(0) } else { (-*q).max(0) };
let t = amount.min(room);
*q += if debits { -t } else { t };
amount -= t;
}
}
let credits = |qs: &[i64]| qs.iter().map(|q| (-q).max(0)).sum::<i64>();
let debits = |qs: &[i64]| qs.iter().map(|q| (*q).max(0)).sum::<i64>();
let mut fin = 0i64;
for g in gens.iter().filter(|g| g.date() >= day(a) && g.date() <= day(e)) {
// Kinds: cash; operating balance sheet, revenue and expense;
// capital A, capital B, the loan (financing when designated).
let mut cash = 0i64;
let mut op = [0i64; 2];
let mut fi = [0i64; 3];
for (i, side, cents) in g.legs(1) {
let m = if side == DR { cents } else { -cents };
match RCHART[i].0 {
"1000" => cash += m,
"3000" => fi[0] += m,
"3001" => fi[1] += m,
"2500" if designate => fi[2] += m,
"4000" | "4100" | "6000" | "6100" => op[1] += m,
_ => op[0] += m,
}
}
// Rule 3: operating, then financing (no investing here).
let take = |left: i64, m: i64| {
if left > 0 && m < 0 {
left.min(-m)
} else if left < 0 && m > 0 {
left.max(-m)
} else {
0
}
};
let to_op = take(cash, op[0] + op[1]);
let to_fin = take(cash - to_op, fi.iter().sum());
fin += to_fin;
if to_op > 0 { settle(&mut op, to_op, false) } else { settle(&mut op, -to_op, true) }
if to_fin > 0 { settle(&mut fi, to_fin, false) } else { settle(&mut fi, -to_fin, true) }
// Rule 11: the loan pays, then receives; then capital.
if designate {
let paid = credits(&fi[2..]).min(debits(&op));
fi[2] += paid;
settle(&mut op, paid, true);
let received = debits(&fi[2..]).min(credits(&op[1..]));
fi[2] -= received;
settle(&mut op[1..], received, false);
fin += paid - received;
}
let paid = credits(&fi[..2]).min(debits(&op[1..]));
settle(&mut fi[..2], paid, false);
settle(&mut op[1..], paid, true);
let received = debits(&fi[..2]).min(credits(&op[1..]));
settle(&mut fi[..2], received, true);
fin += paid - received;
}
prop_assert_eq!(cf.cash_from_financing, c(fin));
}
/// Oracle: CF INVARIANT 5 — closing a period changes no cash flow
@@ -2487,10 +2576,10 @@ proptest! {
let open = build(&gens);
let mut closed = build(&gens);
close_through(&mut closed, Some(month), &re_close());
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![b.ids[LOAN]], investing_accounts: vec![] };
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![b.ids[LOAN]], investing_accounts: vec![], members: vec![] };
let x = cash_flow(&open.l, &p(&open)).unwrap();
let y = cash_flow(&closed.l, &p(&closed)).unwrap();
prop_assert_eq!(x.net_income, y.net_income);
prop_assert_eq!(&x.operating_reconciliation, &y.operating_reconciliation);
prop_assert_eq!(x.cash_from_operations, y.cash_from_operations);
prop_assert_eq!(x.cash_from_investing, y.cash_from_investing);
prop_assert_eq!(x.cash_from_financing, y.cash_from_financing);
@@ -2504,10 +2593,10 @@ proptest! {
let kk = Decimal::from(k);
let one = build(&gens);
let many = build_scaled(&gens, k);
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![] };
let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![], members: vec![] };
let x = cash_flow(&one.l, &p(&one)).unwrap();
let y = cash_flow(&many.l, &p(&many)).unwrap();
prop_assert_eq!(y.net_income, x.net_income * kk);
prop_assert_eq!(y.operating_reconciliation.net_income, x.operating_reconciliation.net_income * kk);
prop_assert_eq!(y.cash_from_operations, x.cash_from_operations * kk);
prop_assert_eq!(y.cash_from_financing, x.cash_from_financing * kk);
prop_assert_eq!(y.cash_beginning, x.cash_beginning * kk);
+109 -57
View File
@@ -215,16 +215,17 @@ fn a_closed_period_still_reports_its_earnings() {
cash_accounts: vec![b.cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
},
)
.unwrap();
assert_eq!(cf.net_income, dec(1_700));
assert_eq!(cf.operating_reconciliation.net_income, dec(1_700));
assert_eq!(cf.financing.len(), 1);
assert_eq!(cf.cash_from_financing, dec(10_000));
assert_eq!(cf.net_change_in_cash, dec(11_700));
}
/// Cash flow lines come out in account-code order, every time. (The chart
/// Cash flow kinds come out in account-code order, every time. (The chart
/// iterates in hash order; the statement must not.)
#[test]
fn cash_flow_lines_are_in_code_order() {
@@ -251,14 +252,16 @@ fn cash_flow_lines_are_in_code_order() {
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
},
)
.unwrap();
// Labels read "Owner contributions — 3000 3000" (code, then name).
// Kinds read "Contributions from owners — 3000 3000" (code, then
// name): one per account, in code order.
let codes: Vec<&str> = cf
.financing
.iter()
.map(|f| f.label.rsplit(' ').next().unwrap())
.map(|f| f.kind.rsplit(' ').next().unwrap())
.collect();
assert_eq!(
codes,
@@ -525,9 +528,11 @@ fn members_must_be_configured_correctly() {
));
}
/// Each line names the accounts it moved, and is listed under its
/// member's kind (D-35: "Contributions from members — <member>").
#[test]
fn cash_flow_lines_name_their_account() {
let (l, _) = partnership(false);
let (l, members) = partnership(false);
let cf = cash_flow(
&l,
&CashFlowParams {
@@ -536,15 +541,23 @@ fn cash_flow_lines_name_their_account() {
cash_accounts: vec![1],
financing_accounts: vec![],
investing_accounts: vec![],
members,
},
)
.unwrap();
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
let accounts: Vec<&[String]> = cf.financing.iter().map(|f| &f.accounts[..]).collect();
// Gross (ASC 230-10-45-7): 3001 contributed and took a distribution,
// so it has a line for each.
assert_eq!(accounts, [Some("3000"), Some("3001"), Some("3001")]);
assert!(cf.financing[1].label.starts_with("Owner contributions"));
assert!(cf.financing[2].label.starts_with("Owner distributions"));
assert_eq!(accounts, [["3000"], ["3001"], ["3001"]]);
let kinds: Vec<&str> = cf.financing.iter().map(|f| f.kind.as_str()).collect();
assert_eq!(
kinds,
[
"Contributions from members — Alice",
"Contributions from members — Bob",
"Distributions to members — Bob"
]
);
assert!(cf.financing[1].amount > Decimal::ZERO && cf.financing[2].amount < Decimal::ZERO);
}
@@ -1023,17 +1036,24 @@ fn cf_params(start: NaiveDate, end: NaiveDate, cash: AccountId) -> CashFlowParam
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
}
}
fn reconciles(cf: &ledger_core::CashFlowStatement) {
// B-14 / cash_flow INVARIANT 1: the three sections sum to the change in
// cash, exactly.
// B-14 / cash_flow INVARIANT 4: the three sections sum to the change in
// cash, exactly, and net income and its reconciling items to operating.
assert_eq!(
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
cf.net_change_in_cash
);
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
assert_eq!(cf.reconciles.difference, Decimal::ZERO);
let r = &cf.operating_reconciliation;
assert_eq!(
r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>(),
cf.cash_from_operations
);
}
#[test]
@@ -1044,7 +1064,11 @@ fn cash_flow_starts_counting_on_the_start_date() {
let cf = cash_flow(&b.ledger, &cf_params(d(1, 15), d(1, 15), b.cash)).unwrap();
assert_eq!(cf.cash_beginning, dec(10_000));
assert_eq!(cf.cash_ending, dec(12_500));
assert_eq!(cf.net_income, dec(2_500));
assert_eq!(cf.operating_reconciliation.net_income, dec(2_500));
assert_eq!(
cf.operating_by_class(),
[(ledger_core::OperatingClass::FromCustomers, dec(2_500))]
);
reconciles(&cf);
// Backwards is refused.
assert!(matches!(
@@ -1093,8 +1117,12 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
post(&mut l, d(3, 5), "Repay", loan, cash, 300);
post(&mut l, d(3, 6), "Distribution", capital, cash, 400);
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
v.iter().map(|l| (l.label.clone(), l.amount)).collect()
let rec = |cf: &ledger_core::CashFlowStatement| -> Vec<(String, Decimal)> {
let r = &cf.operating_reconciliation.lines;
r.iter().map(|l| (l.label.clone(), l.amount)).collect()
};
let kinds = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
v.iter().map(|l| (l.kind.clone(), l.amount)).collect()
};
let s = |x: &str| x.to_string();
let params = |start, end| CashFlowParams {
@@ -1103,24 +1131,27 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
cash_accounts: vec![cash],
financing_accounts: vec![loan],
investing_accounts: vec![],
members: vec![],
};
// February: net income 100 - 50 = 50; receivable up 100 (cash out),
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400.
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400
// (the kinds in the statement's order: contributions, then
// borrowing; ASC 230-10-45-14(a), (b)).
let feb = cash_flow(&l, &params(d(2, 1), d(2, 28))).unwrap();
assert_eq!(feb.net_income, dec(50));
assert_eq!(feb.operating_reconciliation.net_income, dec(50));
assert_eq!(
lines(&feb.working_capital),
rec(&feb),
[
(s("Increase in 1100 Receivable"), dec(-100)),
(s("Increase in 2000 Payable"), dec(50)),
]
);
assert_eq!(
lines(&feb.financing),
kinds(&feb.financing),
[
(s("Proceeds from 2500 Loan"), dec(300)),
(s("Owner contributions — 3000 Capital"), dec(400)),
(s("Contributions from owners — 3000 Capital"), dec(400)),
(s("Proceeds from borrowing — 2500 Loan"), dec(300)),
]
);
assert_eq!(feb.cash_from_operations, dec(0));
@@ -1128,21 +1159,22 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
reconciles(&feb);
// March: the mirror image. Collected 100, paid 50, repaid 300,
// distributed 400.
// distributed 400 (45-15(a), (b)): an original's direction is its
// nature; only a reversal never takes a label from its sign.
let mar = cash_flow(&l, &params(d(3, 1), d(3, 31))).unwrap();
assert_eq!(mar.net_income, dec(0));
assert_eq!(mar.operating_reconciliation.net_income, dec(0));
assert_eq!(
lines(&mar.working_capital),
rec(&mar),
[
(s("Decrease in 1100 Receivable"), dec(100)),
(s("Decrease in 2000 Payable"), dec(-50)),
]
);
assert_eq!(
lines(&mar.financing),
kinds(&mar.financing),
[
(s("Repayment of 2500 Loan"), dec(-300)),
(s("Owner distributions — 3000 Capital"), dec(-400)),
(s("Distributions to owners — 3000 Capital"), dec(-400)),
(s("Repayments of borrowing — 2500 Loan"), dec(-300)),
]
);
assert_eq!(mar.net_change_in_cash, dec(-650));
@@ -1198,24 +1230,33 @@ fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
l.add_line(e, equipment, Side::Credit, usd(1_000)).unwrap();
l.post_entry(e).unwrap();
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
v.iter().map(|l| (l.label.clone(), l.amount)).collect()
};
// February: net income -300; the loss is added back (positive, it used
// no cash), so operations is 0; the 600 proceeds are investing.
// no cash), so operations is 0; the 600 proceeds are investing (ASC
// 230-10-45-12(c), 45-28(b)).
let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap();
assert_eq!(feb.net_income, dec(-300));
assert_eq!(feb.operating_reconciliation.net_income, dec(-300));
let rec: Vec<(&str, Decimal)> = feb
.operating_reconciliation
.lines
.iter()
.map(|l| (l.label.as_str(), l.amount))
.collect();
assert_eq!(
lines(&feb.working_capital),
rec,
[(
"Less: gain on sale of fixed assets (in investing)".to_string(),
"(Gain) loss and other noncash changes in investing assets",
dec(300)
)]
);
assert_eq!(feb.cash_from_operations, dec(0));
let inv: Vec<(&str, Decimal)> = feb
.investing
.iter()
.map(|l| (l.kind.as_str(), l.amount))
.collect();
assert_eq!(
lines(&feb.investing),
[("Sale of fixed assets — 1000 Cash".to_string(), dec(600))]
inv,
[("Proceeds from sales of property and equipment", dec(600))]
);
assert_eq!(feb.net_change_in_cash, dec(600));
reconciles(&feb);
@@ -1324,21 +1365,27 @@ fn roles() -> (Ledger, CashFlowParams) {
cash_accounts: vec![cash],
financing_accounts: vec![loan, due_owners],
investing_accounts: vec![member_loan],
members: vec![],
};
(l, params)
}
fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> {
ls.iter()
.map(|l| (l.account.clone().unwrap_or_default(), l.amount))
.map(|l| (l.accounts.join(","), l.amount))
.collect()
}
/// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash
/// receipts and cash payments during a period is more relevant than
/// information about the net amounts"), 230-10-45-14(b) (proceeds from
/// "other short- or long-term borrowing") and 45-15(2) ("Repayments of
/// amounts borrowed"): borrowing and repayment are separate lines.
/// "other short- or long-term borrowing") and 45-15(b) ("Repayments of
/// amounts borrowed"): borrowing and repayment are separate lines, one
/// per entry, borrowing first (each lender's, by code), then repayments.
/// The software the owner paid personally is borrowed from the owner and
/// spent on its date (rules.md B-14 rule 11, D-42: constructive receipt
/// and disbursement). Financing: +100 + 1,000 − 100 − 400 = 600; nothing
/// is disclosed.
#[test]
fn financing_is_gross_borrowing_apart_from_repayment() {
let (l, params) = roles();
@@ -1347,12 +1394,13 @@ fn financing_is_gross_borrowing_apart_from_repayment() {
lines(&cf.financing),
[
("2100".into(), dec(100)),
("2100".into(), dec(-100)),
("2500".into(), dec(1000)),
("2100".into(), dec(-100)),
("2500".into(), dec(-400)),
]
);
assert_eq!(cf.cash_from_financing, dec(600));
assert!(cf.noncash_disclosures.is_empty());
}
/// Oracle: ASC 230-10-20 ("Investing activities include making and
@@ -1365,37 +1413,39 @@ fn a_loan_the_entity_makes_is_investing_gross() {
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(
lines(&cf.investing),
[("1400".into(), dec(200)), ("1400".into(), dec(-500))]
[("1400".into(), dec(-500)), ("1400".into(), dec(200))]
);
assert!(cf.investing[0].label.starts_with("Collections on"));
assert!(cf.investing[1].label.starts_with("Payments for"));
assert!(cf.investing[0].kind.starts_with("Payments for"));
assert!(cf.investing[1].kind.starts_with("Collections on"));
assert!(
cf.working_capital
cf.operating_reconciliation
.lines
.iter()
.all(|w| w.account.as_deref() != Some("1400"))
);
assert_eq!(cf.cash_from_investing, dec(-300));
}
/// Oracle: PwC Viewpoint, Financial statement presentation 6.9.3.1
/// (constructive receipts and disbursements: cash a third party pays on
/// the entity's behalf is presented "as though the transaction took place
/// through the bank accounts of the reporting entity"). The owner paying
/// software personally is a borrowing (financing in) and an operating
/// payment (in net income); reimbursing the owner is a repayment.
/// Hand-computed: operating -100, investing -300, financing +600 = +200,
/// the change in cash.
/// Oracle: D-42 (rules.md B-14 rule 11; ASC 230 is silent on a payment
/// made on the entity's behalf, PwC FSP 6.9.3.1 and DART Roadmap 7.2
/// present it as constructive receipt and disbursement). The owner paying
/// software personally is cash the company borrowed (financing +100) and
/// spent (operating −100, 230-10-45-17(b)) on Mar 5; reimbursing the owner
/// is a repayment (−100, 45-15(b)). Hand-computed: operating −100 (net
/// income −100, no reconciling item), investing −300, financing +600 =
/// +200, the change in cash. Replaces
/// `an_expense_the_owner_paid_is_disclosed_then_repaid` (D-35).
#[test]
fn an_expense_the_owner_paid_is_borrowing_then_operating() {
fn an_expense_the_owner_paid_is_borrowed_spent_then_repaid() {
let (l, params) = roles();
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(cf.net_income, dec(-100));
assert_eq!(cf.operating_reconciliation.net_income, dec(-100));
assert_eq!(cf.cash_from_operations, dec(-100));
assert_eq!(lines(&cf.operating), [("6000".into(), dec(-100))]);
assert_eq!(cf.operating[0].on_behalf, dec(-100));
assert!(cf.operating_reconciliation.lines.is_empty());
assert_eq!(cf.net_change_in_cash, dec(200));
assert_eq!(
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
cf.net_change_in_cash
);
reconciles(&cf);
}
/// Gross lines net only within one entry: an entry that both debits and
@@ -1412,6 +1462,8 @@ fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() {
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(cf.cash_from_financing, dec(600));
assert_eq!(cf.financing.len(), 4);
// Nothing exchanged either: the account nets to zero first.
assert!(cf.noncash_disclosures.iter().all(|n| n.entry != 7));
}
mod chaining {