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

+2 -1
View File
@@ -38,7 +38,8 @@ pub struct BooksSettings {
pub cash_accounts: Vec<String>,
/// `[books] financing_accounts`: liability account codes the cash flow
/// statement treats as financing (loans, and what owners advanced:
/// ASC 230-10-45-14(b)). Empty: callers decide.
/// ASC 230-10-45-14(b)); equity is financing unlisted. Empty: other
/// liabilities are operating (the statement never guesses from names).
pub financing_accounts: Vec<String>,
/// `[books] investing_accounts`: asset account codes the cash flow
/// statement treats as investing (loans the company makes, investments
File diff suppressed because it is too large. Load diff
+23
View File
@@ -397,6 +397,29 @@ pub enum LedgerError {
end: String,
},
/// The statement of cash flows would not foot (rules.md B-14 rule 9):
/// an engine error, never output. It cannot happen on balanced books
/// (Lean `cashflow_sections_sum_to_cash_change`); if it does, the
/// engine is wrong, and nothing is shown rather than a wrong figure.
#[error(
"engine error: the cash flow statement does not foot ({what}: {found} against {expected}); nothing was produced, please report it"
)]
CashFlowDoesNotFoot {
/// Which check failed: "sections", "investing lines", "operating
/// reconciliation", "entry #N", ...
what: String,
/// The figure it should equal.
expected: Decimal,
/// The figure it came to.
found: Decimal,
},
/// The cash flow statement's account roles (cash, financing, investing)
/// contradict each other or the chart (rules.md B-14 rule 2). The text
/// names the setting and says what to change.
#[error("cash flow account roles: {0}")]
CashFlowRoles(String),
/// The referenced accounting period does not exist.
#[error("unknown period id {0}")]
UnknownPeriod(u64),
+348 -1
View File
@@ -29,7 +29,14 @@
//! remainder (B-13);
//! * a cumulative allocation hands out exactly the new amount, and leaves
//! each running total one allocation of everything allocated, so it never
//! drifts a unit from its exact share (B-13).
//! drifts a unit from its exact share (B-13);
//! * an entry's cash is applied to the sections of the statement of cash
//! flows moving against it, in a fixed order, each up to its own
//! movement: the parts sum to the entry's cash, what no cash settled sums
//! to zero, and a reversal's parts are the original's negated (B-14);
//! * what a member or lender paid or received for the company, with no
//! cash account moving, is split among the kinds it settled, netting to
//! zero, and mirrored by a reversal (B-14 rule 11).
use crate::{AccountType, Side};
use rust_decimal::Decimal;
@@ -409,10 +416,350 @@ pub fn apportion_cumulative<T: Amount>(
Some(())
}
/// Where a line falls on the statement of cash flows (rules.md B-14 rule
/// 2): a cash account, or one of the three sections.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Flow {
/// A cash account: what the statement explains the change in.
Cash,
/// Operating activities.
Operating,
/// Investing activities.
Investing,
/// Financing activities.
Financing,
}
impl Flow {
/// The three sections, in the order cash is applied to them (rules.md
/// B-14 rule 3): operating, investing, financing.
pub const SECTIONS: [Flow; 3] = [Flow::Operating, Flow::Investing, Flow::Financing];
}
/// One entry's effect on the statement of cash flows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EntryFlows<T> {
/// Net movement of the entry's cash lines, debit-positive: + is cash
/// in.
pub cash: T,
/// Cash per section, `[operating, investing, financing]`, + is in.
/// Sums to `cash`.
pub flows: [T; 3],
/// What no cash settled, per section, debit-positive: the section's
/// movement plus its cash. Sums to zero.
pub noncash: [T; 3],
}
/// The cash one section takes from what is left of an entry's cash
/// (`remaining`), given the section's net movement (debit-positive): only
/// a section moving against the cash takes any, and never more than its
/// own movement. Cash in (`remaining > 0`) settles credits (`movement <
/// 0`); cash out settles debits.
pub fn take<T: Amount>(remaining: T, movement: T) -> Option<T> {
let against = movement.checked_neg()?;
Some(if remaining > T::ZERO && movement < T::ZERO {
remaining.min(against)
} else if remaining < T::ZERO && movement > T::ZERO {
remaining.max(against)
} else {
T::ZERO
})
}
/// Apply `cash` to `movements` in order, each taking [`take`] of what is
/// left: `applied[k]` is the cash of part `k`. `None` when something is
/// left over (the movements against the cash are smaller than it), the
/// slices differ in length, or on overflow.
///
/// ```text
/// GOAL: split an entry's cash among the parts it settled, without
/// inventing or losing any.
/// SOURCE: rules.md B-14 rule 3 (ASC 230-10-50-5: only the cash portion
/// of a part-cash transaction is a cash flow; the order is a
/// CONVENTION, the ASC is silent).
/// INVARIANTS:
/// 1. On success the parts sum to `cash` exactly.
/// 2. Each part has the sign of `cash` (or is zero) and is no larger
/// than its own movement, which runs against the cash.
/// 3. Applying `-cash` to the negated movements gives the negated parts
/// (a reversal mirrors its original).
/// SCENARIOS:
/// 1. IF the movements against the cash sum to at least the cash:
/// - SHOULD succeed (a balanced entry always does)
/// 2. IF they don't: SHOULD return None, never a part out of thin air.
/// ```
pub fn apply_cash<T: Amount>(cash: T, movements: &[T], applied: &mut [T]) -> Option<()> {
if movements.len() != applied.len() {
return None;
}
let mut remaining = cash;
for (out, &movement) in applied.iter_mut().zip(movements) {
let part = take(remaining, movement)?;
*out = part;
remaining = remaining.checked_sub(part)?;
}
(remaining == T::ZERO).then_some(())
}
/// An entry's cash flows (rules.md B-14 rule 3): the net movement of its
/// cash lines, applied to the sections moving against it in the order
/// operating, investing, financing, each up to its own movement; what is
/// left of a section's movement is the part no cash settled. `None` when
/// the lines don't net to zero (never for a posted entry) or on overflow.
pub fn entry_flows<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<EntryFlows<T>> {
let mut cash = T::ZERO;
let mut moved = [T::ZERO; 3];
for &(flow, side, amount) in lines {
let delta = signed(side, amount)?;
let slot = match flow {
Flow::Cash => &mut cash,
Flow::Operating => &mut moved[0],
Flow::Investing => &mut moved[1],
Flow::Financing => &mut moved[2],
};
*slot = slot.checked_add(delta)?;
}
// Balanced lines only: every part below rests on it.
let mut total = cash;
for &m in &moved {
total = total.checked_add(m)?;
}
if total != T::ZERO {
return None;
}
let mut flows = [T::ZERO; 3];
apply_cash(cash, &moved, &mut flows)?;
let mut noncash = [T::ZERO; 3];
for ((n, m), f) in noncash.iter_mut().zip(moved).zip(flows) {
*n = m.checked_add(f)?;
}
Some(EntryFlows {
cash,
flows,
noncash,
})
}
/// The credits and the debits among some movements (debit-positive):
/// `(Σ max(-m, 0), Σ max(m, 0))`.
fn credits_and_debits<T: Amount>(movements: &[T]) -> Option<(T, T)> {
let (mut credits, mut debits) = (T::ZERO, T::ZERO);
for &m in movements {
if m < T::ZERO {
credits = credits.checked_sub(m)?;
} else {
debits = debits.checked_add(m)?;
}
}
Some((credits, debits))
}
/// Which way money a member or a lender handled for the company went
/// (rules.md B-14 rule 11, D-42).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum OnBehalf {
/// Paid for the company: sources credited (borrowed, contributed)
/// against uses debited (spent).
Paid,
/// Received for the company: sources debited (repaid, distributed)
/// against uses credited (collected).
Received,
}
impl OnBehalf {
/// The other way: what an entry's reversal does.
pub fn mirror(self) -> Self {
match self {
OnBehalf::Paid => OnBehalf::Received,
OnBehalf::Received => OnBehalf::Paid,
}
}
}
/// What a member or a lender paid, or received, for the company with no
/// cash account moving: a constructive receipt and disbursement (rules.md
/// B-14 rule 11, D-42). `sources` are what is left unsettled of the
/// entry's kinds that can pay or receive for the company (a financing
/// liability, a member's equity), `uses` of the kinds they can pay or
/// receive for, each debit-positive. [`OnBehalf::Paid`]: `amount =
/// min(source credits, use debits)`, cash in to the sources' credits and
/// out of the uses' debits (borrowed or contributed, then spent);
/// [`OnBehalf::Received`]: `amount = min(source debits, use credits)`,
/// cash in to the uses' credits and out of the sources' debits (collected,
/// then repaid or distributed). `from[i]` is source `i`'s cash (+ in) and
/// `to[j]` use `j`'s, each applied in order by [`apply_cash`]. `None` when
/// the slices differ in length, or on overflow.
///
/// ```text
/// GOAL: show money a member or lender handled for the company as the
/// company's own, borrowed (or contributed) and spent, without
/// inventing or losing a cent.
/// SOURCE: rules.md B-14 rule 11 (D-42; ASC 230 is silent, PwC Viewpoint
/// FSP 6.9.3.1, Deloitte DART Roadmap 7.2, BDO Blueprint 3.4).
/// INVARIANTS:
/// 1. The parts net to zero: Σ from + Σ to = 0, so the entry's cash
/// flows still sum to its cash.
/// 2. Each part settles its own movement, toward zero and never past it
/// (zero, or the opposite sign and no larger).
/// 3. The other way, minus the sources and minus the uses give minus
/// the parts (a reversal mirrors its original).
/// 4. As much is settled as can be: afterwards, paid, no source credit
/// is left beside a use debit; received, no source debit beside a use
/// credit.
/// ```
pub fn constructive<T: Amount>(
way: OnBehalf,
sources: &[T],
uses: &[T],
from: &mut [T],
to: &mut [T],
) -> Option<()> {
let (source_credits, source_debits) = credits_and_debits(sources)?;
let (use_credits, use_debits) = credits_and_debits(uses)?;
// The cash the sources take (+ in): what was paid, or minus what was
// received; the uses take the opposite.
let cash = match way {
OnBehalf::Paid => source_credits.min(use_debits),
OnBehalf::Received => source_debits.min(use_credits).checked_neg()?,
};
apply_cash(cash, sources, from)?;
apply_cash(cash.checked_neg()?, uses, to)
}
/// How much of an entry is an exchange inside investing and financing
/// that no cash settled (rules.md B-14 rule 7, ASC 230-10-50-4: debt
/// converted to equity, one asset traded for another). `debits` and
/// `credits` are the net debits and net credits of the entry's investing
/// and financing balance-sheet accounts (each account netted first; both
/// non-negative); `inflow` and `outflow` are the cash those two sections
/// took in and paid out (both non-negative; constructive cash included,
/// rule 11). Cash in settles credits and cash out settles debits; what's
/// left of the smaller side was exchanged:
/// `max(0, min(debits - outflow, credits - inflow))`.
pub fn exchanged<T: Amount>(debits: T, credits: T, inflow: T, outflow: T) -> Option<T> {
let d = debits.checked_sub(outflow)?;
let c = credits.checked_sub(inflow)?;
Some(d.min(c).max(T::ZERO))
}
#[cfg(test)]
mod tests {
use super::*;
/// rules.md B-14 rule 3, by hand. Equipment 1,000 for 300 cash and a
/// 700 note: investing takes the 300, the note's 700 and the rest of
/// the equipment's are the noncash part.
#[test]
fn part_cash_part_noncash_takes_only_the_cash() {
use Flow::*;
let f = entry_flows(&[
(Investing, Side::Debit, 1000i64),
(Cash, Side::Credit, 300),
(Financing, Side::Credit, 700),
])
.unwrap();
assert_eq!(f.cash, -300);
assert_eq!(f.flows, [0, -300, 0]);
assert_eq!(f.noncash, [0, 700, -700]);
// A fee withheld from a loan: Dr cash 9,900, Dr fee 100 / Cr loan
// 10,000. Operating moves with the cash (a debit), so takes none.
let f = entry_flows(&[
(Cash, Side::Debit, 9900i64),
(Operating, Side::Debit, 100),
(Financing, Side::Credit, 10000),
])
.unwrap();
assert_eq!(f.flows, [0, 0, 9900]);
assert_eq!(f.noncash, [100, 0, -100]);
}
/// Lines that don't net to zero are refused (review item 12): no part
/// is made up for them.
#[test]
fn unbalanced_lines_have_no_flows() {
use Flow::*;
assert_eq!(
entry_flows(&[(Operating, Side::Debit, 5i64), (Investing, Side::Debit, 5)]),
None
);
assert_eq!(
entry_flows(&[(Cash, Side::Debit, 5i64), (Investing, Side::Credit, 4)]),
None
);
// Too little against the cash: nothing is invented.
let mut out = [0i64; 2];
assert_eq!(apply_cash(10, &[-3, 4], &mut out), None);
assert_eq!(apply_cash(10, &[-3], &mut out), None);
}
/// The exchange measure (rules.md B-14 rule 7) on the review's
/// hand-checked cases.
#[test]
fn exchanged_matches_the_hand_checked_cases() {
// Debt to equity, 500.
assert_eq!(exchanged(500i64, 500, 0, 0), Some(500));
// Part-cash purchase with the seller's note: D 1,000, C 700, cash
// out 300.
assert_eq!(exchanged(1000i64, 700, 0, 300), Some(700));
// Note received for equipment: D 700, C 1,000, cash in 300.
assert_eq!(exchanged(700i64, 1000, 300, 0), Some(700));
// Trade-in with 1,500 boot: D 2,000, C 1,000, cash out 1,500.
assert_eq!(exchanged(2000i64, 1000, 0, 1500), Some(500));
// A cash sale: nothing debited, cash in 500.
assert_eq!(exchanged(0i64, 1000, 500, 0), Some(0));
// A cash purchase: cash out covers every debit.
assert_eq!(exchanged(1000i64, 0, 0, 1000), Some(0));
// Equipment a lender paid for (rule 11): 1,000 borrowed in, 1,000
// spent out, nothing exchanged.
assert_eq!(exchanged(1000i64, 1000, 1000, 1000), Some(0));
}
/// rules.md B-14 rule 11 (D-42), by hand.
#[test]
fn constructive_pays_and_receives_for_the_company() {
use OnBehalf::{Paid, Received};
// A member pays a 59 expense (Dr expense / Cr due to members): the
// source's 59 credit pays the use's 59 debit.
let (mut from, mut to) = ([0i64; 1], [0i64; 1]);
constructive(Paid, &[-59], &[59], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([59], [-59]));
// Its reversal, the other way, mirrors it.
constructive(Received, &[59], &[-59], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([-59], [59]));
// The reversal's shape read as a payment settles nothing.
constructive(Paid, &[59], &[-59], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([0], [0]));
// A 100 credit from the source against 60 of uses (the other 40
// against something else): only 60 is paid; uses take in order.
let (mut from, mut to) = ([0i64; 2], [0i64; 2]);
constructive(Paid, &[-100, 0], &[25, 35], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([60, 0], [-25, -35]));
// Two sources, one use smaller than both: the first source pays
// first.
constructive(Paid, &[-30, -30], &[40, 0], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([30, 10], [-40, 0]));
// Each way settles only its own side.
constructive(Paid, &[-50, 20], &[50, -20], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([50, 0], [-50, 0]));
constructive(Received, &[-50, 20], &[50, -20], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([0, -20], [0, 20]));
// Nothing against: nothing moves.
constructive(Paid, &[-50, 0], &[-10, 0], &mut from, &mut to).unwrap();
assert_eq!((from, to), ([0, 0], [0, 0]));
// Mismatched lengths are refused.
let one = [-1i64];
assert_eq!(
constructive(Paid, &one, &[1], &mut [0i64; 2], &mut [0]),
None
);
assert_eq!(
constructive(Paid, &one, &[1], &mut [0i64], &mut [0; 2]),
None
);
assert_eq!(Paid.mirror(), Received);
assert_eq!(Received.mirror(), Paid);
}
#[test]
fn max_line_amount_is_one_quadrillion() {
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
+13 -6
View File
@@ -21,6 +21,7 @@ pub mod analysis;
pub mod arap;
pub mod assets;
pub mod calendar;
pub mod cashflow;
pub mod classes;
pub mod conventions;
pub mod error;
@@ -53,6 +54,12 @@ pub use assets::{
ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value,
depreciation_schedule, disposal_amounts,
};
pub use cashflow::{
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
CashFlowStatement, CashFlowWarning, CashFlowWarningKind, NoncashDisclosure, OperatingClass,
OperatingReconciliation, ReconcilingKind, ReconcilingLine, RoleList, RoleProblem, cash_flow,
role_problems,
};
pub use classes::{Class, ClassId};
pub use error::LedgerError;
pub use form1099::{
@@ -60,6 +67,7 @@ pub use form1099::{
vendor_1099_report_with, vendor_1099_report_with_members,
};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use kernel::{EntryFlows, Flow};
pub use ledger::{
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
@@ -73,12 +81,11 @@ pub use periods::{
PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
};
pub use reports::{
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
CashFlowParams, CashFlowStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount,
LedgerPosting, MemberCapital, MemberEquityColumn, MembersEquityStatement, ProfitAndLoss,
ProfitAndLossStatement, StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of,
cash_flow, fixed_asset_register, general_ledger, members_equity, profit_and_loss_for_period,
trial_balance_as_of,
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, ClassProfitAndLoss,
GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, MemberEquityColumn,
MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
TrialBalanceLine, balance_sheet_as_of, fixed_asset_register, general_ledger, members_equity,
profit_and_loss_for_period, trial_balance_as_of,
};
pub use schedules::{
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
+380 -2
View File
@@ -9,8 +9,10 @@
//! property test checks the two instantiations agree.
//!
//! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and
//! amounts up to 2^40 in magnitude. The Lean model in `verify/lean`
//! proves the same laws for any number of lines and accounts.
//! amounts up to 2^40 in magnitude; the statement of cash flows' entry
//! harnesses, up to [`NF`] lines, each in any of the four places. The Lean
//! model in `verify/lean` proves the same laws for any number of lines and
//! accounts.
use crate::kernel::{self, Amount, LineError};
use crate::{AccountType, Side};
@@ -713,6 +715,382 @@ fn cumulative_closing_entry_balances() {
kani::cover!(true_up, "a true-up line on a member");
}
// ---------------------------------------------------------------------------
// The statement of cash flows
// ---------------------------------------------------------------------------
use kernel::Flow;
/// Most lines per entry the cash flow harnesses explore: three is the
/// fewest an entry needs to be mixed (cash and two sections), and each
/// line can land in any of the four places. (Four lines ran past half an
/// hour per harness, too long for `just prove`; the Lean model covers any
/// number.)
const NF: usize = 3;
/// Any count up to `NF`.
fn any_flow_len() -> usize {
let n: usize = kani::any();
kani::assume(n <= NF);
n
}
fn any_flow() -> Flow {
let t: u8 = kani::any();
kani::assume(t < 4);
match t {
0 => Flow::Cash,
1 => Flow::Operating,
2 => Flow::Investing,
_ => Flow::Financing,
}
}
/// `NF` lines, each tagged with its place on the statement (use a
/// prefix).
fn any_flow_lines() -> [(Flow, Side, i64); NF] {
core::array::from_fn(|_| (any_flow(), any_side(), any_amount()))
}
/// The spec's net movement of the lines tagged `f`.
fn tagged(lines: &[(Flow, Side, i64)], f: Flow) -> i64 {
let mut total = 0i64;
for &(g, s, a) in lines {
if g == f {
total += wide(s, a);
}
}
total
}
/// The spec's section movements, `[operating, investing, financing]`.
fn sections(lines: &[(Flow, Side, i64)]) -> [i64; 3] {
[
tagged(lines, Flow::Operating),
tagged(lines, Flow::Investing),
tagged(lines, Flow::Financing),
]
}
/// Whether the lines would post: their sides and amounts pass
/// `check_lines`.
fn accepted(lines: &[(Flow, Side, i64)]) -> bool {
let mut s = [(Side::Debit, 0i64); N];
for (o, &(_, side, a)) in s.iter_mut().zip(lines) {
*o = (side, a);
}
kernel::check_lines(&s[..lines.len()]).is_ok()
}
/// The building block of every cash flow harness below, for any two
/// amounts: what a section takes is odd (a reversal takes minus it), has
/// the cash's sign (or is zero), comes only from a movement against the
/// cash, and is never more than the cash left or the movement.
#[kani::proof]
fn take_is_odd_and_bounded() {
let r = any_amount();
let n = any_amount();
let t = kernel::take(r, n).expect("in range");
assert_eq!(kernel::take(-r, -n), Some(-t));
assert!(t == 0 || (t > 0) == (r > 0));
assert!(t == 0 || (t > 0) == (n < 0));
assert!(t.abs() <= r.abs() && t.abs() <= n.abs());
kani::cover!(
t != 0 && t.abs() < r.abs(),
"a movement smaller than the cash"
);
}
/// Applying minus the cash to minus the movements gives minus the parts,
/// part by part, and succeeds exactly when the original does.
#[kani::proof]
#[kani::unwind(5)]
fn apply_cash_mirrors() {
const M: usize = 3;
let cash = any_amount();
let moves: [i64; M] = core::array::from_fn(|_| any_amount());
let neg: [i64; M] = core::array::from_fn(|k| -moves[k]);
let mut a = [0i64; M];
let mut b = [0i64; M];
let ra = kernel::apply_cash(cash, &moves, &mut a);
let rb = kernel::apply_cash(-cash, &neg, &mut b);
assert_eq!(ra.is_some(), rb.is_some());
if ra.is_some() {
for k in 0..M {
assert_eq!(b[k], -a[k]);
}
kani::cover!(a[0] != 0 && a[2] != 0, "two sections settled, mirrored");
}
}
/// An accepted entry always has cash flows, and they explain its cash
/// exactly: the sections' cash sums to the entry's cash, and what no cash
/// settled (each section's movement plus its cash) sums to zero
/// (rules.md B-14 rules 3 and 9: the statement foots, entry by entry).
#[kani::proof]
#[kani::unwind(5)]
fn entry_flows_sum_to_cash() {
let all = any_flow_lines();
let lines = &all[..any_flow_len()];
if !accepted(lines) {
return;
}
let f = kernel::entry_flows(lines).expect("an accepted entry has cash flows");
let n = sections(lines);
assert_eq!(f.cash, tagged(lines, Flow::Cash));
assert_eq!(f.flows[0] + f.flows[1] + f.flows[2], f.cash);
assert_eq!(f.noncash[0] + f.noncash[1] + f.noncash[2], 0);
for k in 0..3 {
assert_eq!(f.noncash[k], n[k] + f.flows[k]);
}
kani::cover!(
f.flows[1] != 0 && f.noncash[1] != 0,
"a part-cash, part-noncash investing entry"
);
}
/// When every section moves against the cash (or not at all), the cash
/// settles every movement: each section's cash is minus its movement and
/// nothing is noncash. (With no cash at all the premise would hold
/// vacuously; review item 7: it needs cash.)
#[kani::proof]
#[kani::unwind(5)]
fn entry_flows_pure_are_movements() {
let all = any_flow_lines();
let lines = &all[..any_flow_len()];
if !accepted(lines) {
return;
}
let c = tagged(lines, Flow::Cash);
let n = sections(lines);
let against = |m: i64| m == 0 || (c > 0 && m < 0) || (c < 0 && m > 0);
if c == 0 || !(against(n[0]) && against(n[1]) && against(n[2])) {
return;
}
let f = kernel::entry_flows(lines).expect("an accepted entry has cash flows");
for k in 0..3 {
assert_eq!(f.flows[k], -n[k]);
assert_eq!(f.noncash[k], 0);
}
kani::cover!(n[0] != 0 && n[2] != 0, "cash settling two sections at once");
}
/// Each section's cash has the cash's sign (or is zero), is never larger
/// than the section's own movement, and comes only from a section moving
/// against the cash.
#[kani::proof]
#[kani::unwind(5)]
fn entry_flows_are_bounded() {
let all = any_flow_lines();
let lines = &all[..any_flow_len()];
let Some(f) = kernel::entry_flows(lines) else {
return;
};
let n = sections(lines);
for k in 0..3 {
let x = f.flows[k];
assert!(x == 0 || (x > 0) == (f.cash > 0));
assert!(x.abs() <= n[k].abs());
assert!(x == 0 || (x > 0) == (n[k] < 0));
}
kani::cover!(
f.flows[0] != 0 && f.flows[0].abs() < n[0].abs(),
"a section only partly settled by cash"
);
}
/// A reversal's cash flows are its original's, negated, part by part:
/// what lets an entry and its reversal in one period drop out of the
/// statement together (rules.md B-14 rule 6).
#[kani::proof]
#[kani::unwind(5)]
fn entry_flows_of_a_reversal_mirror() {
let all = any_flow_lines();
let n = any_flow_len();
let lines = &all[..n];
// Element by element (see `kani_symbolic_copy_canary`).
let mut rev = [(Flow::Cash, Side::Debit, 0i64); NF];
for i in 0..n {
rev[i] = kernel::reverse_line(lines[i]);
}
let a = kernel::entry_flows(lines);
let b = kernel::entry_flows(&rev[..n]);
assert_eq!(a.is_some(), b.is_some());
if let (Some(a), Some(b)) = (a, b) {
assert_eq!(b.cash, -a.cash);
for k in 0..3 {
assert_eq!(b.flows[k], -a.flows[k]);
assert_eq!(b.noncash[k], -a.noncash[k]);
}
kani::cover!(
a.flows[1] != 0 && a.noncash[2] != 0,
"a mixed entry, reversed"
);
}
}
/// An entry with no cash line has no cash flows: every movement is
/// noncash.
#[kani::proof]
#[kani::unwind(5)]
fn no_cash_no_flow() {
let all = any_flow_lines();
let lines = &all[..any_flow_len()];
for &(f, _, _) in lines {
kani::assume(f != Flow::Cash);
}
if !accepted(lines) {
return;
}
let f = kernel::entry_flows(lines).expect("an accepted entry has cash flows");
let n = sections(lines);
assert_eq!(f.cash, 0);
for k in 0..3 {
assert_eq!(f.flows[k], 0);
assert_eq!(f.noncash[k], n[k]);
}
kani::cover!(n[1] != 0 && n[2] != 0, "property contributed for capital");
}
/// How a section's cash splits among its kinds (contributions per member,
/// borrowing per lender): `apply_cash` succeeds exactly when the
/// movements against the cash cover it, and then the parts sum to the
/// cash, each with its sign and within its own movement.
#[kani::proof]
#[kani::unwind(5)]
fn apply_cash_spends_exactly() {
const M: usize = 3;
let cash = any_amount();
let moves: [i64; M] = core::array::from_fn(|_| any_amount());
let n: usize = kani::any();
kani::assume(n <= M);
let mut against = 0i64;
for &m in &moves[..n] {
if (cash > 0 && m < 0) || (cash < 0 && m > 0) {
against += m.abs();
}
}
let mut out = [0i64; M];
let ok = kernel::apply_cash(cash, &moves[..n], &mut out[..n]);
assert_eq!(ok.is_some(), cash.abs() <= against);
if ok.is_some() {
let mut sum = 0i64;
for k in 0..n {
let x = out[k];
sum += x;
assert!(x == 0 || (x > 0) == (cash > 0));
assert!(x.abs() <= moves[k].abs());
}
assert_eq!(sum, cash);
kani::cover!(n == M && out[2] != 0, "the third kind takes the rest");
}
}
/// Any amount a line may carry, not negative.
fn any_magnitude() -> i64 {
let a = any_amount();
kani::assume(a >= 0);
a
}
/// The exchange inside investing and financing is the smaller side left
/// once cash in has settled credits and cash out debits, never negative
/// and never more than either side.
#[kani::proof]
fn exchanged_is_bounded() {
let (d, c) = (any_magnitude(), any_magnitude());
let (inflow, outflow) = (any_magnitude(), any_magnitude());
let x = kernel::exchanged(d, c, inflow, outflow).expect("no overflow");
let spec = (d - outflow).min(c - inflow).max(0);
assert_eq!(x, spec);
assert!(0 <= x && x <= d && x <= c);
kani::cover!(x > 0 && outflow != 0, "a part-cash exchange");
kani::cover!(
x > 0 && inflow != 0 && outflow != 0,
"an exchange beside cash in and out"
);
}
fn any_way() -> kernel::OnBehalf {
if kani::any() {
kernel::OnBehalf::Paid
} else {
kernel::OnBehalf::Received
}
}
/// What a member or a lender paid or received for the company (rules.md
/// B-14 rule 11, D-42) invents and loses nothing: the parts net to zero,
/// so an entry's flows still sum to its cash; each part settles its own
/// movement toward zero, never past it; and as much is settled as can be.
#[kani::proof]
#[kani::unwind(4)]
fn constructive_nets_to_zero() {
const M: usize = 2;
let way = any_way();
let s: [i64; M] = core::array::from_fn(|_| any_amount());
let u: [i64; M] = core::array::from_fn(|_| any_amount());
let (mut from, mut to) = ([0i64; M], [0i64; M]);
kernel::constructive(way, &s, &u, &mut from, &mut to).expect("amounts in range always settle");
assert_eq!(from[0] + from[1] + to[0] + to[1], 0);
let settles = |x: i64, m: i64| (x == 0 || (x > 0) == (m < 0)) && x.abs() <= m.abs();
for k in 0..M {
assert!(settles(from[k], s[k]));
assert!(settles(to[k], u[k]));
}
let ls = [s[0] + from[0], s[1] + from[1]];
let lu = [u[0] + to[0], u[1] + to[1]];
let any = |xs: [i64; M], f: fn(i64) -> bool| f(xs[0]) || f(xs[1]);
match way {
kernel::OnBehalf::Paid => {
assert!(!(any(ls, |x| x < 0) && any(lu, |x| x > 0)));
// Paying never touches a source debit or a use credit.
for k in 0..M {
assert!(s[k] < 0 || from[k] == 0);
assert!(u[k] > 0 || to[k] == 0);
}
}
kernel::OnBehalf::Received => {
assert!(!(any(ls, |x| x > 0) && any(lu, |x| x < 0)));
for k in 0..M {
assert!(s[k] > 0 || from[k] == 0);
assert!(u[k] < 0 || to[k] == 0);
}
}
}
kani::cover!(from[0] > 0 && from[1] > 0, "two sources pay");
kani::cover!(from[0] < 0 && to[1] > 0, "received for the company");
kani::cover!(
ls[0] < 0 && to[0] < 0,
"a source larger than what it paid for"
);
}
/// The other way, minus the sources and minus the uses give minus the
/// parts: a reversal of a payment made on the company's behalf mirrors it
/// (rules.md B-14 rule 6), so the two drop out together.
#[kani::proof]
#[kani::unwind(4)]
fn constructive_mirrors() {
const M: usize = 2;
let way = any_way();
let s: [i64; M] = core::array::from_fn(|_| any_amount());
let u: [i64; M] = core::array::from_fn(|_| any_amount());
let (ns, nu) = ([-s[0], -s[1]], [-u[0], -u[1]]);
let (mut fa, mut ta) = ([0i64; M], [0i64; M]);
let (mut fb, mut tb) = ([0i64; M], [0i64; M]);
let a = kernel::constructive(way, &s, &u, &mut fa, &mut ta);
let b = kernel::constructive(way.mirror(), &ns, &nu, &mut fb, &mut tb);
assert_eq!(a.is_some(), b.is_some());
if a.is_some() {
for k in 0..M {
assert_eq!(fb[k], -fa[k]);
assert_eq!(tb[k], -ta[k]);
}
kani::cover!(fa[0] != 0 && ta[1] != 0, "a payment, mirrored");
}
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------
+6 -457
View File
@@ -1146,465 +1146,14 @@ pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
}
// ---------------------------------------------------------------------------
// Cash flow statement (indirect method, R-FUTURE-3).
// Cash flow statement (direct method, rules.md B-14): see `crate::cashflow`.
// ---------------------------------------------------------------------------
/// One labeled cash flow line. Positive is an inflow, negative an outflow.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CashFlowLine {
/// Human label, e.g. `"Increase in Accounts Receivable"` or account code/name.
pub label: String,
/// Signed amount: positive = cash in, negative = cash out.
pub amount: Decimal,
/// Code of the account the line is about, when it is about one (so a
/// renderer can relabel, e.g. a member's capital account by name).
#[serde(default)]
pub account: Option<String>,
}
/// Parameters for [`cash_flow`].
#[derive(Clone, Debug)]
pub struct CashFlowParams {
/// Period start, inclusive.
pub start: chrono::NaiveDate,
/// Period end, inclusive.
pub end: chrono::NaiveDate,
/// Accounts treated as cash. Their net change is the reconciling total.
pub cash_accounts: Vec<crate::AccountId>,
/// Liability accounts treated as financing (debt, including what the
/// owners advanced: ASC 230-10-45-14(b) "other short- or long-term
/// borrowing"). Default empty: all liabilities are operating working
/// capital. All equity is financing.
pub financing_accounts: Vec<crate::AccountId>,
/// Asset accounts treated as investing: loans the entity makes and
/// investments it buys (ASC 230-10-20; 230-10-45-13). Registered fixed
/// assets are investing without being listed. Default empty: other
/// assets are operating working capital.
pub investing_accounts: Vec<crate::AccountId>,
}
/// Cash flow statement, indirect method.
///
/// Starts from period net income, adds back non-cash depreciation, adjusts
/// for working-capital changes, then reports investing (fixed-asset
/// purchases at cost, disposal proceeds) and financing (equity changes,
/// designated debt accounts) flows. The three sections always sum to the
/// net change in the designated cash accounts.
///
/// Disposals are identified structurally: a posted entry that credits a
/// registered fixed-asset cost account and debits its linked accumulated
/// depreciation account. The gain/loss in that entry is removed from
/// operating and the proceeds are shown in investing.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CashFlowStatement {
/// Period start, inclusive.
pub start: chrono::NaiveDate,
/// Period end, inclusive.
pub end: chrono::NaiveDate,
/// Period net income (revenue minus expenses).
pub net_income: Decimal,
/// Non-cash depreciation added back to net income.
pub depreciation: Decimal,
/// Working-capital adjustments (non-cash balance-sheet changes).
pub working_capital: Vec<CashFlowLine>,
/// Net cash from operating activities.
pub cash_from_operations: Decimal,
/// Investing flows: asset purchases and disposal proceeds.
pub investing: Vec<CashFlowLine>,
/// Net cash from investing activities.
pub cash_from_investing: Decimal,
/// Financing flows: equity changes and designated debt.
pub financing: Vec<CashFlowLine>,
/// Net cash from financing activities.
pub cash_from_financing: Decimal,
/// Net change in cash: the three sections summed.
pub net_change_in_cash: Decimal,
/// Cash balance at period start.
pub cash_beginning: Decimal,
/// Cash balance at period end.
pub cash_ending: Decimal,
}
/// Build the indirect-method cash flow statement for a period.
///
/// Pure function of the ledger: net income plus non-cash depreciation plus
/// working-capital changes gives operating cash flow; fixed-asset purchases
/// and disposal proceeds, and the accounts named as investing, give
/// investing; equity and the debt named as financing give financing. The
/// three sections sum to the net change in `cash_accounts`.
///
/// ```text
/// GOAL: the statement of cash flows by the indirect method: net income
/// adjusted to operating cash, plus investing and financing, for a
/// period.
/// SOURCE: docs/spec/rules.md B-14 (ASC 230-10-45-28 indirect method;
/// 230-10-45-16 operating inflows; 230-10-45-7/8 gross over net;
/// 230-10-45-13/14/15 investing and financing flows; 230-10-20
/// investing activities).
/// INVARIANTS:
/// 1. Operating + investing + financing MUST equal the net change in the
/// cash accounts over the period, exactly.
/// 2. Operating MUST start from the period's net income (closing entries
/// excluded) and adjust for changes in non-cash working-capital
/// accounts.
/// 3. Investing MUST hold fixed-asset purchases and disposal proceeds
/// (a disposal's gain or loss moves out of operating into investing)
/// and the changes in the accounts named as investing.
/// 4. Financing MUST hold changes in equity (other than earnings) and in
/// the liabilities named as financing.
/// 4a. Investing and financing MUST be gross (ASC 230-10-45-7: gross
/// receipts and payments are "more relevant" than net): per account,
/// cash in and cash out are separate lines (contributions apart from
/// distributions, borrowing apart from repayment), netted only within
/// one entry.
/// 5. NEVER count closing entries.
/// 6. The period is `[start, end]`, inclusive: an entry dated `start` is
/// period activity, not part of the beginning cash balance.
/// 7. Every line's amount is its cash effect: positive is cash in. Its
/// label names the direction ("Increase in" / "Decrease in" for
/// working capital; "Proceeds from" / "Repayment of" for financing
/// debt; "Owner contributions" / "Owner distributions" for equity;
/// "Payments for" / "Collections on" for investing accounts).
/// ```
pub fn cash_flow(
ledger: &Ledger,
params: &CashFlowParams,
) -> Result<CashFlowStatement, LedgerError> {
use std::collections::{HashMap, HashSet};
if params.end < params.start {
return Err(LedgerError::InvalidDateRange {
start: params.start.to_string(),
end: params.end.to_string(),
});
}
// Fixed-asset account roles from the register.
let mut cost_accounts: HashSet<crate::AccountId> = HashSet::new();
let mut accum_accounts: HashSet<crate::AccountId> = HashSet::new();
let mut depr_expense_accounts: HashSet<crate::AccountId> = HashSet::new();
// cost_account -> accum_account, for disposal identification.
let mut cost_to_accum: HashMap<crate::AccountId, crate::AccountId> = HashMap::new();
for asset in ledger.registered_assets() {
cost_accounts.insert(asset.cost_account);
accum_accounts.insert(asset.accum_depr_account);
depr_expense_accounts.insert(asset.expense_account);
cost_to_accum.insert(asset.cost_account, asset.accum_depr_account);
}
let cash_set: HashSet<crate::AccountId> = params.cash_accounts.iter().copied().collect();
let financing_set: HashSet<crate::AccountId> =
params.financing_accounts.iter().copied().collect();
let investing_set: HashSet<crate::AccountId> =
params.investing_accounts.iter().copied().collect();
// Movement helpers: debits minus credits over a date predicate. Closing
// entries are skipped: a close moves earnings into retained earnings,
// which is neither net income nor an owner flow.
let closings = ledger.closing_entry_ids();
let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
let mut total = Decimal::ZERO;
for entry in ledger.posted_entries() {
if !pred(entry.date()) || closings.contains(&entry.id()) {
continue;
}
for line in entry.lines() {
if line.account == account {
match line.side {
crate::Side::Debit => total += line.amount.amount,
crate::Side::Credit => total -= line.amount.amount,
}
}
}
}
total
};
let begin_movement = |a: crate::AccountId| movement_where(a, &|d| d < params.start);
let period_movement =
|a: crate::AccountId| movement_where(a, &|d| d >= params.start && d <= params.end);
// Normal-side balance from a movement.
let normal = |account: crate::AccountId, movement: Decimal| -> Decimal {
let acct = ledger.chart().get(account).expect("chart account exists");
match acct.account_type.normal_side() {
crate::Side::Debit => movement,
crate::Side::Credit => -movement,
}
};
// --- Net income and depreciation (period P&L) ---
let mut net_income = Decimal::ZERO;
let mut depreciation = Decimal::ZERO;
for account in ledger.chart().all() {
if account.placeholder {
continue;
}
let pm = period_movement(account.id);
if pm.is_zero() {
continue;
}
match account.account_type {
AccountType::Revenue => net_income += -pm, // revenue movement is credits
AccountType::Expense => {
net_income -= pm;
if depr_expense_accounts.contains(&account.id) {
depreciation += pm;
}
}
_ => {}
}
}
// --- Classify balance-sheet accounts ---
let mut working_capital: Vec<CashFlowLine> = Vec::new();
let mut investing: Vec<CashFlowLine> = Vec::new();
let mut financing: Vec<CashFlowLine> = Vec::new();
// Account display helper.
let display = |account: crate::AccountId| -> String {
let a = ledger.chart().get(account).expect("chart account exists");
format!("{} {}", a.code, a.name)
};
// By code: the chart iterates in hash order, and a statement's lines
// must come out in the same order every time.
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
if account.placeholder {
continue;
}
let id = account.id;
if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) {
continue; // cash reconciles; fixed assets are investing (gross, below)
}
if account.account_type == AccountType::Equity
|| financing_set.contains(&id)
|| investing_set.contains(&id)
{
continue; // financing and investing roles are gross, below
}
let begin = normal(id, begin_movement(id));
let end = normal(id, period_movement(id) + begin_movement(id));
let delta = end - begin;
if delta.is_zero() {
continue;
}
// Cash effect of the balance change: for both assets and
// liabilities, it is minus the movement (debits - credits).
let movement_delta = period_movement(id);
let cash_effect = -movement_delta;
let (label, line) = match account.account_type {
AccountType::Asset => {
let dir = if delta > Decimal::ZERO {
"Increase in"
} else {
"Decrease in"
};
(format!("{dir} {}", display(id)), cash_effect)
}
AccountType::Liability => {
let dir = if delta > Decimal::ZERO {
"Increase in"
} else {
"Decrease in"
};
(format!("{dir} {}", display(id)), cash_effect)
}
_ => continue, // revenue/expense handled via net income
};
working_capital.push(CashFlowLine {
label,
amount: line,
account: Some(account.code.clone()),
});
}
// --- Financing and investing accounts, gross (ASC 230-10-45-7) ---
// Per account, cash in and cash out as separate lines; an entry's own
// debits and credits to the account net first (one transaction, one
// flow).
let mut roles: Vec<&crate::Account> = ledger
.chart()
.all()
.filter(|a| {
!a.placeholder
&& !cash_set.contains(&a.id)
&& !cost_accounts.contains(&a.id)
&& !accum_accounts.contains(&a.id)
&& (a.account_type == AccountType::Equity
|| financing_set.contains(&a.id)
|| investing_set.contains(&a.id))
})
.collect();
roles.sort_by(|a, b| a.code.cmp(&b.code));
for account in roles {
let (mut cash_in, mut cash_out) = (Decimal::ZERO, Decimal::ZERO);
for entry in ledger.posted_entries() {
let d = entry.date();
if d < params.start || d > params.end || closings.contains(&entry.id()) {
continue;
}
// Cash effect of this entry's change to the account: minus its
// movement (debits - credits), for assets and claims alike.
let effect: Decimal = entry
.lines()
.iter()
.filter(|l| l.account == account.id)
.map(|l| match l.side {
crate::Side::Debit => -l.amount.amount,
crate::Side::Credit => l.amount.amount,
})
.sum();
if effect > Decimal::ZERO {
cash_in += effect;
} else {
cash_out += effect;
}
}
let name = display(account.id);
let (in_label, out_label, target) = if investing_set.contains(&account.id) {
(
format!("Collections on {name}"),
format!("Payments for {name}"),
&mut investing,
)
} else if account.account_type == AccountType::Equity {
(
format!("Owner contributions — {name}"),
format!("Owner distributions — {name}"),
&mut financing,
)
} else {
(
format!("Proceeds from {name}"),
format!("Repayment of {name}"),
&mut financing,
)
};
for (label, amount) in [(in_label, cash_in), (out_label, cash_out)] {
if !amount.is_zero() {
target.push(CashFlowLine {
label,
amount,
account: Some(account.code.clone()),
});
}
}
}
// --- Investing, gross: purchases and disposal proceeds ---
// A disposal entry credits a cost account and debits its linked accum.
let mut disposal_gain_loss = Decimal::ZERO; // +gain / -loss, to remove from operating
for entry in ledger.posted_entries() {
let d = entry.date();
if d < params.start || d > params.end {
continue;
}
// Is this a disposal? Credit to a cost account + debit to its accum.
let mut disposed: Option<(crate::AccountId, Decimal, Decimal)> = None;
for line in entry.lines() {
if line.side == crate::Side::Credit
&& let Some(&accum) = cost_to_accum.get(&line.account)
{
// Confirm the linked accum account is debited in this entry.
let accum_debit: Decimal = entry
.lines()
.iter()
.filter(|l| l.account == accum && l.side == crate::Side::Debit)
.map(|l| l.amount.amount)
.sum();
if !accum_debit.is_zero() {
disposed = Some((line.account, line.amount.amount, accum_debit));
break;
}
}
}
if let Some((cost_acct, cost_amount, accum_debit)) = disposed {
let accum_acct = cost_to_accum[&cost_acct];
let mut proceeds = Decimal::ZERO;
let mut proceeds_label = String::new();
let mut gain = Decimal::ZERO;
let mut loss = Decimal::ZERO;
for line in entry.lines() {
if line.account == cost_acct || line.account == accum_acct {
continue;
}
let acct = ledger
.chart()
.get(line.account)
.expect("chart account exists");
match (acct.account_type, line.side) {
(AccountType::Asset, crate::Side::Debit) => {
proceeds += line.amount.amount;
proceeds_label = display(line.account);
}
(AccountType::Revenue, crate::Side::Credit) => gain += line.amount.amount,
(AccountType::Expense, crate::Side::Debit) => loss += line.amount.amount,
_ => {}
}
}
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
disposal_gain_loss += gain - loss;
investing.push(CashFlowLine {
label: format!("Sale of fixed assets — {proceeds_label}"),
amount: proceeds,
account: ledger.chart().get(cost_acct).map(|a| a.code.clone()),
});
continue;
}
// Not a disposal: debits to cost accounts are purchases.
for line in entry.lines() {
if cost_accounts.contains(&line.account) && line.side == crate::Side::Debit {
investing.push(CashFlowLine {
label: format!("Purchase of fixed assets — {}", display(line.account)),
amount: -line.amount.amount,
account: ledger.chart().get(line.account).map(|a| a.code.clone()),
});
}
}
}
// Remove disposal gains/losses from operating (they live in investing).
if !disposal_gain_loss.is_zero() {
working_capital.push(CashFlowLine {
label: "Less: gain on sale of fixed assets (in investing)".to_string(),
amount: -disposal_gain_loss,
account: None,
});
}
let cash_from_operations: Decimal =
net_income + depreciation + working_capital.iter().map(|l| l.amount).sum::<Decimal>();
let cash_from_investing: Decimal = investing.iter().map(|l| l.amount).sum();
let cash_from_financing: Decimal = financing.iter().map(|l| l.amount).sum();
// --- Cash reconciliation ---
let mut cash_beginning = Decimal::ZERO;
let mut cash_ending = Decimal::ZERO;
for &id in &params.cash_accounts {
cash_beginning += normal(id, begin_movement(id));
cash_ending += normal(id, begin_movement(id) + period_movement(id));
}
let net_change_in_cash = cash_ending - cash_beginning;
Ok(CashFlowStatement {
start: params.start,
end: params.end,
net_income,
depreciation,
working_capital,
cash_from_operations,
investing,
cash_from_investing,
financing,
cash_from_financing,
net_change_in_cash,
cash_beginning,
cash_ending,
})
}
pub use crate::cashflow::{
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
CashFlowStatement, CashFlowWarning, CashFlowWarningKind, NoncashDisclosure, OperatingClass,
OperatingReconciliation, ReconcilingKind, ReconcilingLine, cash_flow, foot,
};
/// One asset on the fixed asset register.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+3 -3
View File
@@ -294,21 +294,21 @@ pub static KEYS: &[Key] = &[
toml: ("books", "cash_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Account codes that hold cash (bank, savings, cash on hand): the cash flow statement reconciles to them.",
doc: "Asset account codes that hold cash (bank, savings, cash on hand): the cash flow statement explains their change.",
},
Key {
name: "books.financing_accounts",
toml: ("books", "financing_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Liability account codes the cash flow statement treats as financing (loans, and what owners advanced: ASC 230-10-45-14(b)).",
doc: "Liability account codes the cash flow statement treats as financing (loans, and what owners advanced: ASC 230-10-45-14(b)); equity is always financing. What a member or lender pays for the company through one is shown as borrowed and spent (D-42).",
},
Key {
name: "books.investing_accounts",
toml: ("books", "investing_accounts"),
kind: Kind::Codes,
sensitivity: Sensitivity::Public,
doc: "Asset account codes the cash flow statement treats as investing (loans the company makes, investments it buys: ASC 230-10-20).",
doc: "Asset account codes the cash flow statement treats as investing (loans the company makes, investments it buys: ASC 230-10-20); registered fixed assets are investing unlisted.",
},
Key {
name: "books.close_into",
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 {
+131 -1
View File
@@ -76,7 +76,10 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Rou
/// engine makes from a schedule (period closes, depreciation,
/// amortization: no counterparty by nature) (one item: Info under
/// suggest, Todo under require; its prompt names
/// books.set_entry_party).
/// books.set_entry_party); each warning of the fiscal year's
/// statement of cash flows (rules.md B-14 rule 12: equipment sold for
/// an IOU, Todo; bought on an operating payable, Info; one per
/// entry, its detail the warning's fix).
/// 3. Nothing else: books with none of those yield no items, except
/// that a figure behind an item that can't be computed (an
/// amortization plan past chrono's last day, an aging past exact
@@ -657,6 +660,46 @@ pub fn find(b: &Books) -> Vec<Attention> {
prompt: verify_prompt,
});
}
// The statement of cash flows' warnings this fiscal year (rules.md
// B-14 rule 12, D-35): equipment sold for an IOU, flagged with its fix
// (Todo); equipment bought on an operating payable, following how the
// payable is classified (Info). A statement the settings can't give
// (no cash accounts yet) has none.
let settings = ledger_package::Settings {
company: &b.settings.company,
cash_accounts: &b.settings.cash_accounts,
financing_accounts: &b.settings.financing_accounts,
investing_accounts: &b.settings.investing_accounts,
};
let (fy_start, _) = b.fiscal_ytd();
if let Ok(cf) = ledger_package::cash_flow_for(
&b.ledger,
&settings,
Some(&fy_start.to_string()),
Some(&today.to_string()),
&[],
&[],
&[],
today,
) {
for w in cf.warnings {
let sold = w.kind == ledger_core::CashFlowWarningKind::AssetSoldOnOperatingReceivable;
out.push(Attention {
severity: if sold { Severity::Todo } else { Severity::Info },
title: if sold {
format!("Equipment sold for an IOU: entry #{}", w.entry)
} else {
format!("Equipment bought on an operating payable: entry #{}", w.entry)
},
detail: w.message.clone(),
target: Some(Route::Entry(w.entry)),
prompt: format!(
"In wanderbooks, the cash flow statement flags entry {}: {} Show me the entry and what you would change, and change nothing until I agree.",
w.entry, w.message
),
});
}
}
out.sort_by_key(|a| a.severity);
out
}
@@ -681,6 +724,93 @@ mod tests {
.unwrap()
}
/// INVARIANT 2's cash flow items (rules.md B-14 rule 12, D-35): a
/// sale of equipment for an IOU is a Todo naming the entry, with the
/// statement's fix; equipment bought on an operating card is Info; a
/// sale for cash asks nothing.
#[test]
fn cash_flow_warnings_are_items() {
use crate::test_support::{Build, d};
use ledger_core::{AccountType, Currency, Money, NewAsset, Side};
let mut b = Build::new("attention-cf");
let mut add =
|code: &str, name: &str, ty| b.s.add_account(code, name, ty, None, false).unwrap();
let equipment = add("1500", "Equipment", AccountType::Asset);
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
let iou = add("1210", "Due from buyer", AccountType::Asset);
let card = add("2050", "Card", AccountType::Liability);
let depr = add("6500", "Depreciation", AccountType::Expense);
let import = ledger_config::codec::import(
"[books]\ncash_accounts = [\"1000\"]\n",
None,
b.s.ledger().settings(),
"USD",
)
.unwrap();
b.s.adopt_settings(
&import.changes,
&ledger_core::settings::Source {
decided_by: "the test".into(),
decided_on: d(2026, 1, 1),
citation: None,
note: None,
},
)
.unwrap();
let (cash, capital) = (b.cash, b.capital);
b.post(d(2026, 2, 1), cash, capital, 500_000);
b.s.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 2, 2),
cost: Money::new(rust_decimal::Decimal::new(100_000, 2), Currency::USD),
salvage_value: Money::new(rust_decimal::Decimal::ZERO, Currency::USD),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
let bought = b.post(d(2026, 2, 2), equipment, card, 100_000);
let usd = |c: i64| Money::new(rust_decimal::Decimal::new(c, 2), Currency::USD);
let e =
b.s.create_entry(d(2026, 3, 2), "Sell the laptop on an IOU")
.unwrap();
b.s.add_line(e, iou, Side::Debit, usd(100_000)).unwrap();
b.s.add_line(e, equipment, Side::Credit, usd(100_000))
.unwrap();
b.s.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
b.s.post_entry(e).unwrap();
let (_dir, books) = b.books();
let items = find(&books);
let sold = items
.iter()
.find(|a| a.title == format!("Equipment sold for an IOU: entry #{e}"))
.unwrap_or_else(|| panic!("{items:?}"));
assert_eq!(sold.severity, Severity::Todo);
assert_eq!(sold.target, Some(Route::Entry(e)));
assert!(
sold.detail.contains("books.investing_accounts"),
"{}",
sold.detail
);
assert!(sold.prompt.starts_with("In wanderbooks,"));
let on_card = items
.iter()
.find(|a| {
a.title == format!("Equipment bought on an operating payable: entry #{bought}")
})
.unwrap_or_else(|| panic!("{items:?}"));
assert_eq!(on_card.severity, Severity::Info);
assert_eq!(
items
.iter()
.filter(|a| a.title.starts_with("Equipment"))
.count(),
2
);
}
/// INVARIANTS 2 and 4: a dated rule past its review is one Todo item
/// whose prompt asks the agent to find the figure from the official
/// authority (named, never the repo's copy), confirm it with the human
+3 -3
View File
@@ -103,7 +103,7 @@ fn group(d: Decimal) -> String {
}
/// The plain decimal behind a displayed amount, for the clipboard:
/// `$1,234.56` → `1234.56`, `(207.68)` and `−12.00` → `-207.68`, `-12.00`.
/// `$1,234.56` → `1234.56`, `(318.42)` and `−12.00` → `-318.42`, `-12.00`.
/// Exact (the display never rounds). None when the text isn't an amount.
pub fn plain(display: &str) -> Option<String> {
let t = display.trim();
@@ -254,7 +254,7 @@ mod tests {
assert_eq!(money(dec("-12")), "\u{2212}12.00");
assert_eq!(money(dec("0")), "0.00");
assert_eq!(money(dec("-0.00")), "0.00");
assert_eq!(accounting(dec("-207.68")), "(207.68)");
assert_eq!(accounting(dec("-318.42")), "(318.42)");
assert_eq!(dollars(dec("999.999")), "$999.999");
assert_eq!(money(dec("0.125")), "0.125");
assert_eq!(compact(dec("-0.4")), "$0");
@@ -266,7 +266,7 @@ mod tests {
assert_eq!(label("irs_name_control"), "IRS name control");
assert_eq!(words("PrepaidExpense"), "Prepaid expense");
assert_eq!(plain("$1,234.56").as_deref(), Some("1234.56"));
assert_eq!(plain("(207.68)").as_deref(), Some("-207.68"));
assert_eq!(plain("(318.42)").as_deref(), Some("-318.42"));
assert_eq!(plain("\u{2212}$12.00").as_deref(), Some("-12.00"));
assert_eq!(plain("+1,000.005").as_deref(), Some("1000.005"));
assert_eq!(plain("0.00").as_deref(), Some("0.00"));
+522 -74
View File
@@ -15,8 +15,8 @@ use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, intervals};
use ledger_core::reports::{
CashFlowParams, balance_sheet_as_of, cash_flow, general_ledger, members_equity,
profit_and_loss_for_period, trial_balance_as_of,
balance_sheet_as_of, general_ledger, members_equity, profit_and_loss_for_period,
trial_balance_as_of,
};
use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_1099_report};
use rust_decimal::Decimal;
@@ -138,6 +138,9 @@ impl PdfInfo {
enum Kind {
Section,
Line,
/// A transaction under the line above it (a cash flow kind): part of
/// that line's amount, set further in and quieter.
Detail,
Subtotal,
Grand,
Note,
@@ -283,6 +286,7 @@ impl Table {
let kind = match r.kind {
Kind::Section => RowKind::Section,
Kind::Line => RowKind::Line,
Kind::Detail => RowKind::Detail,
Kind::Subtotal => RowKind::Subtotal,
Kind::Grand => RowKind::Total,
Kind::Note => RowKind::Subheading,
@@ -570,71 +574,67 @@ fn balance_sheet(b: &Books, dates: &[(NaiveDate, String)]) -> Table {
t
}
/// Financing accounts: from `[books] financing_accounts`, else liabilities
/// whose names say they are debt.
fn financing_accounts(b: &Books) -> Vec<AccountId> {
if !b.financing_accounts.is_empty() {
return b.financing_accounts.clone();
}
b.ledger
.chart()
.all()
.filter(|a| {
let n = a.name.to_lowercase();
a.account_type == AccountType::Liability
&& (n.contains("loan")
|| n.contains("note payable")
|| n.contains("line of credit"))
})
.map(|a| a.id)
.collect()
}
/// One row of the cash flow table, keyed across its columns: (key, label,
/// amount, the account it drills to).
type KeyedRow = (String, String, Decimal, Option<String>);
/// The statement of cash flows, one column per range: the server's own
/// statement (ledger_package::cash_flow_for, the roles and members from
/// the books' settings, refused alike), laid out as printed (D-35):
/// operating by ASC 230-10-45-25 class; investing and financing by kind,
/// each transaction listed under its kind; then net income reconciled to
/// operating cash and the noncash investing and financing activities.
fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table {
use ledger_core::{CashFlowSection, ReconcilingKind};
let mut cols = cols.to_vec();
if total && cols.len() > 1 {
cols.push((cols[0].0, cols[cols.len() - 1].1, "Total".into()));
}
let fin = financing_accounts(b);
let stmts: Vec<_> = cols
let settings = ledger_package::Settings {
company: &b.settings.company,
cash_accounts: &b.settings.cash_accounts,
financing_accounts: &b.settings.financing_accounts,
investing_accounts: &b.settings.investing_accounts,
};
let stmts: Vec<Result<ledger_core::CashFlowStatement, String>> = cols
.iter()
.map(|(s, e, _)| {
cash_flow(
ledger_package::cash_flow_for(
&b.ledger,
&CashFlowParams {
start: *s,
end: *e,
cash_accounts: b.cash_accounts.clone(),
financing_accounts: fin.clone(),
investing_accounts: b.investing_accounts.clone(),
},
&settings,
Some(&s.to_string()),
Some(&e.to_string()),
&[],
&[],
&[],
b.today,
)
.ok()
})
.collect();
let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect());
// A cell per column; blank where the statement was refused.
let val = |f: &dyn Fn(&ledger_core::CashFlowStatement) -> Decimal| -> Vec<Cell> {
stmts
.iter()
.map(|s| Cell {
value: s.as_ref().map(f),
value: s.as_ref().ok().map(f),
drill: None,
})
.collect()
};
let lines = |t: &mut Table,
pick: &dyn Fn(
&ledger_core::CashFlowStatement,
) -> &Vec<ledger_core::CashFlowLine>| {
let mut labels: Vec<(String, Option<String>)> = Vec::new();
// Rows keyed across the columns, in first-seen order.
let keyed = |t: &mut Table,
kind: Kind,
rows: &dyn Fn(&ledger_core::CashFlowStatement) -> Vec<KeyedRow>| {
let mut order: Vec<(String, String, Option<String>)> = Vec::new();
for s in stmts.iter().flatten() {
for l in pick(s) {
if !labels.iter().any(|(x, _)| x == &l.label) {
labels.push((l.label.clone(), l.account.clone()));
for (key, label, _, account) in rows(s) {
if !order.iter().any(|(k, _, _)| *k == key) {
order.push((key, label, account));
}
}
}
for (label, account) in labels {
for (key, label, account) in order {
let id = account
.as_deref()
.and_then(|c| b.ledger.chart().find_by_code(c))
@@ -643,12 +643,13 @@ fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table {
.iter()
.zip(&stmts)
.map(|((s, e, _), st)| Cell {
value: Some(
st.as_ref()
.and_then(|st| pick(st).iter().find(|l| l.label == label))
.map(|l| l.amount)
.unwrap_or_default(),
),
value: st.as_ref().ok().map(|st| {
rows(st)
.into_iter()
.filter(|r| r.0 == key)
.map(|r| r.2)
.sum()
}),
drill: id.map(|account| Drill {
account,
start: *s,
@@ -656,36 +657,68 @@ fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table {
}),
})
.collect();
t.row(Kind::Line, label, cells);
t.row(kind, label, cells);
}
};
// The account a line drills to: its one account, if it has one.
let one = |accounts: &[String]| match accounts {
[a] => Some(a.clone()),
_ => None,
};
t.section("Operating activities");
t.row(Kind::Line, "Net income", val(&|s| s.net_income));
t.row(
Kind::Line,
"Depreciation (non-cash)",
val(&|s| s.depreciation),
);
lines(&mut t, &|s| &s.working_capital);
keyed(&mut t, Kind::Line, &|s| {
s.operating_by_class()
.into_iter()
.map(|(c, a)| (c.label().to_string(), c.label().to_string(), a, None))
.collect()
});
t.row(
Kind::Subtotal,
"Cash from operations",
"Net cash from operating activities",
val(&|s| s.cash_from_operations),
);
t.section("Investing activities");
lines(&mut t, &|s| &s.investing);
t.row(
Kind::Subtotal,
"Cash from investing",
val(&|s| s.cash_from_investing),
);
t.section("Financing activities");
lines(&mut t, &|s| &s.financing);
t.row(
Kind::Subtotal,
"Cash from financing",
val(&|s| s.cash_from_financing),
);
for (sec, title, subtotal) in [
(
CashFlowSection::Investing,
"Investing activities",
"Net cash from investing activities",
),
(
CashFlowSection::Financing,
"Financing activities",
"Net cash from financing activities",
),
] {
t.section(title);
// Each kind with its total (D-35), then its transactions listed
// under it.
let mut kinds: Vec<String> = Vec::new();
for st in stmts.iter().flatten() {
for k in st.kinds(sec) {
if !kinds.contains(&k.kind) {
kinds.push(k.kind);
}
}
}
for kind in kinds {
keyed(&mut t, Kind::Line, &|s| {
s.kinds(sec)
.into_iter()
.filter(|k| k.kind == kind)
.map(|k| (k.kind.clone(), k.kind, k.amount, None))
.collect()
});
keyed(&mut t, Kind::Detail, &|s| {
s.lines(sec)
.iter()
.filter(|l| l.kind == kind)
.map(|l| (l.label.clone(), l.label.clone(), l.amount, one(&l.accounts)))
.collect()
});
}
t.row(Kind::Subtotal, subtotal, val(&|s| s.total(sec)));
}
t.row(
Kind::Grand,
"Net change in cash",
@@ -693,12 +726,114 @@ fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table {
);
t.row(Kind::Line, "Cash at beginning", val(&|s| s.cash_beginning));
t.row(Kind::Line, "Cash at end", val(&|s| s.cash_ending));
t.section("Reconciliation of net income to operating cash");
t.row(
Kind::Line,
"Net income (loss)",
val(&|s| s.operating_reconciliation.net_income),
);
// A balance's change is keyed by its account (review item 17): one
// row across the columns, whichever way it moved in each.
keyed(&mut t, Kind::Line, &|s| {
s.operating_reconciliation
.lines
.iter()
.map(|l| match (&l.kind, &l.account) {
(ReconcilingKind::OperatingBalanceChange, Some(code)) => {
let liability = b
.ledger
.chart()
.find_by_code(code)
.is_some_and(|a| a.account_type != AccountType::Asset);
let name = b
.ledger
.chart()
.find_by_code(code)
.map_or(code.clone(), |a| format!("{} {}", a.code, a.name));
let label = if liability {
format!("Increase (decrease) in {name}")
} else {
format!("(Increase) decrease in {name}")
};
(
format!("change {code}"),
label,
l.amount,
Some(code.clone()),
)
}
_ => (
l.label.clone(),
l.label.clone(),
l.amount,
l.account.clone(),
),
})
.collect()
});
t.row(
Kind::Subtotal,
"Net cash from operating activities",
val(&|s| s.operating_reconciliation.net_cash_from_operating),
);
t.section("Noncash investing and financing activities");
let any = stmts
.iter()
.flatten()
.any(|s| !s.noncash_disclosures.is_empty());
if any {
keyed(&mut t, Kind::Line, &|s| {
s.noncash_disclosures
.iter()
.map(|n| (n.label.clone(), n.label.clone(), n.amount, None))
.collect()
});
} else {
t.note("None");
}
let mut notes: Vec<String> = Vec::new();
for st in &stmts {
let note = match st {
Err(why) => format!("This statement can't be shown: {why}."),
Ok(s) => {
for w in &s.warnings {
let n = format!("Note: {}", w.message);
if !notes.contains(&n) {
notes.push(n);
}
}
continue;
}
};
if !notes.contains(&note) {
notes.push(note);
}
}
for n in notes {
t.note(n);
}
if cols.len() > 1 {
t.note(
"An entry and its reversal in the same column drop out of it; in different columns each shows, and the Total nets them.",
);
}
let names: Vec<String> = b
.settings
.cash_accounts
.iter()
.map(|a| b.account_label(*a))
.map(|code| {
b.ledger
.chart()
.find_by_code(code)
.map_or(code.clone(), |a| format!("{} {}", a.code, a.name))
})
.collect();
t.note(format!("Cash accounts: {}.", names.join(", ")));
if !names.is_empty() {
t.note(format!("Cash accounts: {}.", names.join(", ")));
}
t
}
@@ -1698,12 +1833,13 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) {
}
};
let (rr, resp) = ui.allocate_exact_size(vec2(width, h), Sense::hover());
if matches!(r.kind, Kind::Line) && resp.hovered() {
if matches!(r.kind, Kind::Line | Kind::Detail) && resp.hovered() {
ui.painter().rect_filled(rr, CornerRadius::same(4), p.hover);
}
let (font, color, indent) = match r.kind {
Kind::Section => (theme::semibold(13.5), p.text, 0.0),
Kind::Line => (theme::regular(13.5), p.text, 18.0),
Kind::Detail => (theme::regular(12.5), p.muted, 36.0),
Kind::Subtotal => (theme::semibold(13.5), p.text, 0.0),
Kind::Grand => (theme::bold(14.5), p.text, 0.0),
Kind::Note => (theme::regular(12.0), p.muted, 0.0),
@@ -2245,6 +2381,318 @@ mod tests {
);
}
/// A two-member LLC's first weeks (Member A and Member B, generic
/// vendors, every amount and date made up): cash contributions,
/// expenses the members paid personally (borrowed and spent, D-42) and
/// their reimbursement, property contributed (one contribution
/// reversed), a card, and a bank loan left out of the financing
/// setting. Settings journaled: cash 1000, financing 2100, the members
/// on 3000 and 3001.
fn two_member_llc_shape(tag: &str) -> (crate::test_support::TempDir, Books) {
use crate::test_support::{Build, d};
let mut b = Build::new(tag);
let mut add =
|code: &str, name: &str, ty| b.s.add_account(code, name, ty, None, false).unwrap();
let equipment = add("1500", "Equipment", AccountType::Asset);
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
let card = add("2050", "Business Card", AccountType::Liability);
let due = add("2100", "Due to Members", AccountType::Liability);
let loan = add("2500", "Bank Loan", AccountType::Liability);
let member_b = add("3001", "Capital — Member B", AccountType::Equity);
let depr = add("6500", "Depreciation Expense", AccountType::Expense);
let toml = "[company]\nname = \"Acme LLC\"\n\
[[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
[books]\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n";
let import =
ledger_config::codec::import(toml, None, b.s.ledger().settings(), "USD").unwrap();
b.s.adopt_settings(
&import.changes,
&ledger_core::settings::Source {
decided_by: "the test".into(),
decided_on: d(2026, 1, 1),
citation: None,
note: None,
},
)
.unwrap();
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post_as("Formation filing fee", d(2026, 3, 2), rent, due, 8_500);
b.post_as(
"Contribution — Member A",
d(2026, 3, 9),
cash,
capital,
120_000,
);
b.post_as(
"Contribution — Member B",
d(2026, 3, 10),
cash,
member_b,
80_000,
);
b.post_as("Subscription", d(2026, 3, 12), rent, due, 21_640);
b.post_as("Reimburse members", d(2026, 3, 23), due, cash, 30_140);
b.post_as("API credits", d(2026, 3, 25), rent, card, 1_420);
let laptop = b.post_as(
"Laptop contributed",
d(2026, 3, 27),
equipment,
capital,
148_000,
);
b.post_as(
"Tablet contributed",
d(2026, 3, 27),
equipment,
member_b,
63_000,
);
b.post_as("Bank loan", d(2026, 3, 27), cash, loan, 250_000);
b.s.register_asset(ledger_core::NewAsset {
description: "Tablet".into(),
purchase_date: d(2026, 3, 27),
cost: Money::new(Decimal::new(63_000, 2), ledger_core::Currency::USD),
salvage_value: Money::new(Decimal::ZERO, ledger_core::Currency::USD),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
b.s.reverse_entry(laptop, d(2026, 3, 27), "Contributed jointly instead")
.unwrap();
b.books()
}
use ledger_core::Money;
/// The viewer's cash flow is the server's statement (D1-f: no guess
/// from account names), laid out as printed: operating by class,
/// investing and financing by kind with each transaction under it,
/// the reconciliation and the noncash activities. The bank loan the
/// financing setting leaves out is operating in both.
#[test]
fn the_cash_flow_page_is_the_servers_statement() {
let (_dir, b) = two_member_llc_shape("cf-server");
let cols = vec![(
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 27).unwrap(),
"Total".to_string(),
)];
let t = cash_flow_table(&b, &cols, false);
let settings = ledger_package::Settings {
company: &b.settings.company,
cash_accounts: &b.settings.cash_accounts,
financing_accounts: &b.settings.financing_accounts,
investing_accounts: &b.settings.investing_accounts,
};
let cf = ledger_package::cash_flow_for(
&b.ledger,
&settings,
Some("2026-01-01"),
Some("2026-03-27"),
&[],
&[],
&[],
b.today,
)
.unwrap();
let rows: Vec<(String, Option<Decimal>)> = t
.rows
.iter()
.filter(|r| r.kind != Kind::Section)
.map(|r| (r.label.clone(), r.cells.first().and_then(|c| c.value)))
.collect();
let mut want: Vec<(String, Option<Decimal>)> = Vec::new();
for (c, a) in cf.operating_by_class() {
want.push((c.label().into(), Some(a)));
}
want.push((
"Net cash from operating activities".into(),
Some(cf.cash_from_operations),
));
for (sec, sub) in [
(
ledger_core::CashFlowSection::Investing,
"Net cash from investing activities",
),
(
ledger_core::CashFlowSection::Financing,
"Net cash from financing activities",
),
] {
for k in cf.kinds(sec) {
want.push((k.kind.clone(), Some(k.amount)));
for l in cf.lines(sec).iter().filter(|l| l.kind == k.kind) {
want.push((l.label.clone(), Some(l.amount)));
}
}
want.push((sub.into(), Some(cf.total(sec))));
}
want.push(("Net change in cash".into(), Some(cf.net_change_in_cash)));
want.push(("Cash at beginning".into(), Some(cf.cash_beginning)));
want.push(("Cash at end".into(), Some(cf.cash_ending)));
let r = &cf.operating_reconciliation;
want.push(("Net income (loss)".into(), Some(r.net_income)));
for l in &r.lines {
let label = match l.kind {
ledger_core::ReconcilingKind::OperatingBalanceChange => {
let a = b
.ledger
.chart()
.find_by_code(l.account.as_deref().unwrap())
.unwrap();
if a.account_type == AccountType::Asset {
format!("(Increase) decrease in {} {}", a.code, a.name)
} else {
format!("Increase (decrease) in {} {}", a.code, a.name)
}
}
_ => l.label.clone(),
};
want.push((label, Some(l.amount)));
}
want.push((
"Net cash from operating activities".into(),
Some(r.net_cash_from_operating),
));
for n in &cf.noncash_disclosures {
want.push((n.label.clone(), Some(n.amount)));
}
want.push(("Cash accounts: 1000 Cash.".into(), None));
assert_eq!(rows, want);
// Each investing and financing kind is a line with its total; its
// transactions are details under it, never summed into the section.
let kind_of = |label: &str| t.rows.iter().find(|r| r.label == label).map(|r| r.kind);
for sec in [
ledger_core::CashFlowSection::Investing,
ledger_core::CashFlowSection::Financing,
] {
for k in cf.kinds(sec) {
assert!(kind_of(&k.kind) == Some(Kind::Line), "{}", k.kind);
}
for l in cf.lines(sec) {
assert!(kind_of(&l.label) == Some(Kind::Detail), "{}", l.label);
}
}
// The figures, by hand: the loan (not a financing setting) is an
// operating receipt, 2,500.00; what the members paid personally
// (85.00 and 216.40, 301.40) is borrowed from them through 2100
// and spent (D-42, rules.md B-14 rule 11); the members'
// contributions are financing, each named (1,200.00 and 800.00);
// the reimbursement repays 2100 (301.40); the tablet is disclosed
// (630.00), the reversed laptop gone.
assert_eq!(
cf.operating_by_class(),
[
(
ledger_core::OperatingClass::OtherReceipts,
Decimal::new(250_000, 2)
),
(
ledger_core::OperatingClass::ToSuppliersAndEmployees,
Decimal::new(-30_140, 2)
),
]
);
let kinds: Vec<(String, Decimal)> = cf
.kinds(ledger_core::CashFlowSection::Financing)
.into_iter()
.map(|k| (k.kind, k.amount))
.collect();
assert_eq!(
kinds,
[
(
"Contributions from members — Member A".to_string(),
Decimal::new(120_000, 2)
),
(
"Contributions from members — Member B".to_string(),
Decimal::new(80_000, 2)
),
(
"Proceeds from borrowing — 2100 Due to Members".to_string(),
Decimal::new(30_140, 2)
),
(
"Repayments of borrowing — 2100 Due to Members".to_string(),
Decimal::new(-30_140, 2)
),
]
);
let disclosed: Vec<Decimal> = cf.noncash_disclosures.iter().map(|n| n.amount).collect();
assert_eq!(disclosed, [Decimal::new(63_000, 2)]);
assert!(
!t.rows
.iter()
.any(|r| r.label.contains("Bank Loan") && r.label.contains("borrowing"))
);
}
/// A statement the server refuses is refused here too: blank cells
/// and the reason, never a number.
#[test]
fn a_refused_cash_flow_shows_why_and_no_number() {
use crate::test_support::{Build, d};
let mut b = Build::new("cf-refused");
let (cash, capital) = (b.cash, b.capital);
b.post(d(2026, 10, 7), cash, capital, 50_000);
let (_dir, books) = b.books();
let cols = vec![(d(2026, 1, 1), d(2026, 10, 15), "Total".to_string())];
let t = cash_flow_table(&books, &cols, false);
assert!(
t.rows
.iter()
.flat_map(|r| &r.cells)
.all(|c| c.value.is_none())
);
assert!(
t.rows
.iter()
.any(|r| r.kind == Kind::Note && r.label.contains("cash_accounts is required")),
"{:?}",
t.rows.iter().map(|r| &r.label).collect::<Vec<_>>()
);
}
/// The page as painted (and, with `SHOT_DIR` set, as a PNG for eyes:
/// `cashflow-two-member-llc-shape.png`).
#[test]
fn the_cash_flow_page_paints_its_schedules() {
use crate::test_support::{find, harness, painted};
let (_dir, b) = two_member_llc_shape("cf-paint");
let mut h = harness(b, Route::Reports(ReportKind::CashFlow));
h.state_mut().state_mut().reports.range = Range::Custom(
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 27).unwrap(),
);
h.run_steps(4);
let texts = painted(h.output());
for text in [
"Operating activities",
"Other operating cash receipts",
"Cash paid to suppliers and employees",
"Contributions from members — Member A",
"Proceeds from borrowing — 2100 Due to Members",
"Repayments of borrowing — 2100 Due to Members",
"Reconciliation of net income to operating cash",
"Noncash investing and financing activities",
"Tablet contributed (#8)",
] {
find(&texts, text);
}
if let Ok(dir) = std::env::var("SHOT_DIR") {
let dir = std::path::PathBuf::from(dir);
std::fs::create_dir_all(&dir).unwrap();
let img = h.render().unwrap();
img.save(dir.join("cashflow-two-member-llc-shape.png"))
.unwrap();
}
}
/// Every table-backed report typesets as a PDF, in its plain, by-month,
/// and compared layouts. Set `PDF_DIR` to keep the files for eyes.
#[test]
+13 -12
View File
@@ -65,6 +65,7 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
RowKind::Section => "section",
RowKind::Subheading => "subheading",
RowKind::Line => "line",
RowKind::Detail => "detail",
RowKind::Subtotal => "subtotal",
RowKind::Total => "total",
RowKind::Spacer => "spacer",
@@ -146,7 +147,7 @@ mod tests {
ReportMeta {
company: "Test Co".to_string(),
basis: "Accrual".to_string(),
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
prepared_on: NaiveDate::from_ymd_opt(2026, 3, 19).unwrap(),
journal_seq: 42,
}
}
@@ -158,15 +159,15 @@ mod tests {
amount: Decimal::new(cents, 2),
};
ProfitAndLossStatement {
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
start: NaiveDate::from_ymd_opt(2026, 3, 2).unwrap(),
end: NaiveDate::from_ymd_opt(2026, 3, 18).unwrap(),
revenue: vec![],
expenses: vec![
line("6000", "Organizational Costs", 5900),
line("6400", "Memberships & Licenses", 10638),
line("6000", "Organizational Costs", 7200),
line("6400", "Memberships & Licenses", 11840),
],
total_revenue: Decimal::ZERO,
total_expenses: Decimal::new(16538, 2),
total_expenses: Decimal::new(19040, 2),
}
}
@@ -180,19 +181,19 @@ mod tests {
serde_json::json!({
"company": "Test Co",
"title": "Profit and Loss",
"period": "October 4 – October 11, 2026",
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
"period": "March 2 – March 18, 2026",
"footer": "Accrual basis · Prepared Thursday, March 19, 2026 · Journal seq 42",
"columns": ["Total"],
"rows": [
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("72.00", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("118.40", false)},
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("190.40", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("190.40", true)},
],
})
);
+85 -62
View File
@@ -14,7 +14,7 @@
use chrono::{Datelike, NaiveDate};
use ledger_core::RETAINED_EARNINGS_CODE;
use ledger_core::reports::{
BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement,
BalanceSheetStatement, CashFlowSection, CashFlowStatement, MembersEquityStatement,
ProfitAndLossStatement, StatementLine, TrialBalance,
};
use rust_decimal::Decimal;
@@ -67,6 +67,10 @@ pub enum RowKind {
Subheading,
/// One account or one flow, indented under its section.
Line,
/// One transaction listed under the line above it (a statement of
/// cash flows' kind): its amount is part of that line's, so it never
/// sums into the section, and it is set apart (further in, quieter).
Detail,
/// A section total ("Total revenue").
Subtotal,
/// A statement total ("Net income (loss)"); double rule beneath.
@@ -150,7 +154,10 @@ pub fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Ro
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
r.rule_above = match r.kind {
RowKind::Total => true,
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
RowKind::Subtotal => matches!(
above,
Some(RowKind::Line | RowKind::Detail | RowKind::Subtotal)
),
_ => false,
};
if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) {
@@ -310,73 +317,64 @@ pub fn balance_sheet(
)
}
/// Cash flow lines, naming members on their capital accounts.
fn flow_lines<'a>(
lines: &'a [CashFlowLine],
members: &'a [Member],
) -> impl Iterator<Item = Row> + 'a {
lines.iter().map(move |l| {
let member = members.iter().find(|m| {
let code = l.account.as_deref();
code == Some(m.capital_code.as_str()) || code == m.distribution_code.as_deref()
});
let label = match member {
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
Some(m) => format!("Member distributions — {}", m.name),
None => l.label.clone(),
};
one(RowKind::Line, label, l.amount)
})
}
/// Lay out a cash flow statement (indirect method).
/// Lay out a statement of cash flows, direct method (rules.md B-14, D-35):
/// operating cash one row per class ASC 230-10-45-25 requires; investing
/// and financing one row per kind ("Contributions from members — Name"),
/// each with its total and its transactions listed under it; then the
/// reconciliation of net income to operating cash, a separate schedule
/// (45-30), and the noncash investing and financing activities (50-3).
///
/// ```text
/// GOAL: the cash flow statement as printed, footing and reconciling.
/// SOURCE: rules.md B-14 (ASC 230); footing.
/// GOAL: the statement of cash flows as printed, footing and reconciling.
/// SOURCE: rules.md B-14 (ASC 230-10-45-24, -25, -29, -30; 50-3, 50-6:
/// the noncash schedule on the same page); D-35 (layout by kind).
/// INVARIANTS:
/// 1. Net income, depreciation, and the working-capital lines sum to
/// "Net cash from operating activities"; investing and financing lines
/// to theirs.
/// 2. The three section totals sum to "Net increase (decrease) in cash",
/// and beginning cash plus it is "Cash at end of period".
/// 3. A member's capital or distributions line names the member:
/// "Member contributions — Name" for cash in, "Member distributions
/// — Name" for cash out.
/// 1. The operating class rows sum to "Net cash from operating
/// activities"; each other section's kind rows to its own subtotal,
/// and each kind's transaction rows (details, never summed into the
/// section) to the kind's row.
/// 2. The three subtotals sum to "Net increase (decrease) in cash", and
/// beginning cash plus it is "Cash at end of period".
/// 3. "Net income (loss)" and the reconciling items sum to the
/// schedule's "Net cash from operating activities", the operating
/// subtotal.
/// 4. NEVER a label derived from an amount's sign here: kinds and labels
/// come from the engine, a reversal listed under its original's kind.
/// 5. The noncash schedule lists every disclosure, or "None".
/// ```
pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement {
let mut rows = vec![section("Operating activities")];
rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income));
if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() {
rows.push(subheading(
"Adjustments to reconcile net income (loss) to net cash from operating activities:",
));
pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
let mut rows = vec![section(CashFlowSection::Operating.label())];
for (class, amount) in cf.operating_by_class() {
rows.push(one(RowKind::Line, class.label(), amount));
}
if !cf.depreciation.is_zero() {
rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
}
rows.extend(flow_lines(&cf.working_capital, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from operating activities",
cf.cash_from_operations,
));
rows.push(spacer());
rows.push(section("Investing activities"));
rows.extend(flow_lines(&cf.investing, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from investing activities",
cf.cash_from_investing,
));
rows.push(spacer());
rows.push(section("Financing activities"));
rows.extend(flow_lines(&cf.financing, members));
rows.push(one(
RowKind::Subtotal,
"Net cash from financing activities",
cf.cash_from_financing,
));
for (sec, subtotal) in [
(
CashFlowSection::Investing,
"Net cash from investing activities",
),
(
CashFlowSection::Financing,
"Net cash from financing activities",
),
] {
rows.push(spacer());
rows.push(section(sec.label()));
// Each kind with its total, its transactions listed under it (the
// engine lists a kind's lines together, in the kinds' order).
let mut lines = cf.lines(sec).iter();
for k in cf.kinds(sec) {
rows.push(one(RowKind::Line, k.kind.clone(), k.amount));
for l in lines.by_ref().take(k.entries.len()) {
rows.push(one(RowKind::Detail, l.label.clone(), l.amount));
}
}
rows.push(one(RowKind::Subtotal, subtotal, cf.total(sec)));
}
rows.push(spacer());
rows.push(one(
RowKind::Subtotal,
@@ -389,6 +387,31 @@ pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement {
cf.cash_beginning,
));
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending));
rows.push(spacer());
let r = &cf.operating_reconciliation;
rows.push(section(
"Reconciliation of net income (loss) to net cash from operating activities",
));
rows.push(one(RowKind::Line, "Net income (loss)", r.net_income));
if !r.lines.is_empty() {
rows.push(subheading("Adjustments:"));
}
for l in &r.lines {
rows.push(one(RowKind::Line, l.label.clone(), l.amount));
}
rows.push(one(
RowKind::Subtotal,
"Net cash from operating activities",
r.net_cash_from_operating,
));
rows.push(spacer());
rows.push(section("Noncash investing and financing activities"));
if cf.noncash_disclosures.is_empty() {
rows.push(row(RowKind::Line, "None", vec![None]));
}
for n in &cf.noncash_disclosures {
rows.push(one(RowKind::Line, n.label.clone(), n.amount));
}
statement(
"Statement of Cash Flows",
period_label(cf.start, cf.end),
@@ -576,7 +599,7 @@ pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
}
/// Exact accounting format: thousands separators, two decimals, negatives
/// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float.
/// in parentheses. "1,234.56", "(318.42)", "0.00". Never a float.
///
/// ```text
/// GOAL: an amount as an accountant writes it.
@@ -643,8 +666,8 @@ mod tests {
assert_eq!(format_amount(dec("0")), "0.00");
assert_eq!(format_amount(dec("-0")), "0.00");
assert_eq!(format_amount(dec("5.8")), "5.80");
assert_eq!(format_amount(dec("207.68")), "207.68");
assert_eq!(format_amount(dec("-207.68")), "(207.68)");
assert_eq!(format_amount(dec("318.42")), "318.42");
assert_eq!(format_amount(dec("-318.42")), "(318.42)");
assert_eq!(format_amount(dec("1000")), "1,000.00");
assert_eq!(format_amount(dec("1234567.891")), "1,234,567.89");
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
+11
View File
@@ -58,6 +58,8 @@ pub(crate) struct Formats {
pub(crate) dollars_total: Format,
pub(crate) line_label: Format,
pub(crate) sub_label: Format,
pub(crate) detail_label: Format,
pub(crate) detail_amount: Format,
pub(crate) note: Format,
}
@@ -85,6 +87,14 @@ impl Formats {
.set_border_bottom(FormatBorder::Double),
line_label: Format::new().set_indent(1),
sub_label: Format::new().set_italic().set_indent(1),
detail_label: Format::new()
.set_indent(2)
.set_font_size(9)
.set_font_color("#666666"),
detail_amount: Format::new()
.set_num_format(PLAIN)
.set_font_size(9)
.set_font_color("#666666"),
note: Format::new()
.set_italic()
.set_font_size(9)
@@ -225,6 +235,7 @@ fn statement(
RowKind::Section => (&f.bold, &f.plain),
RowKind::Subheading => (&f.sub_label, &f.plain),
RowKind::Line => (&f.line_label, &f.plain),
RowKind::Detail => (&f.detail_label, &f.detail_amount),
RowKind::Subtotal if r.rule_above => (&f.bold, &f.dollars_sum),
RowKind::Subtotal => (&f.bold, &dollars_unruled),
RowKind::Total => (&f.bold, &f.dollars_total),
+8 -4
View File
@@ -7,8 +7,8 @@
// parsed as markup.
//
// Shape: { company, title, period, footer, columns: ["Total"] | [...],
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
// | "total" | "spacer",
// rows: [{ kind: "section" | "subheading" | "line" | "detail"
// | "subtotal" | "total" | "spacer",
// label, rule (rule above), currency ("$" on amounts),
// amounts: [null | { digits: "1,234.56", negative }] }] }
//
@@ -72,11 +72,15 @@
#let row-cells(r) = {
let bold = r.kind == "subtotal" or r.kind == "total"
let stroke = if r.rule { (top: if r.kind == "total" { firm } else { hair }) } else { none }
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else { r.label }
// A detail (a transaction under its kind's line) sits further in and
// quieter: its amount is part of the line above, not of the section.
let detail = r.kind == "detail"
let quietly(body) = if detail { text(size: 0.9em, fill: quiet, body) } else { body }
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else if detail { pad(left: 2.8em, quietly(r.label)) } else { r.label }
let label = if bold { text(weight: "bold", label) } else { label }
(
table.cell(stroke: stroke, label),
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))),
..r.amounts.map(a => table.cell(stroke: stroke, quietly(amount(a, r.currency)))),
)
}
+11 -11
View File
@@ -116,9 +116,9 @@ fn sample() -> AssetRegister {
// A member's personal camera: worth less than its basis, so tax
// basis is the fair market value and there's a built-in loss.
contributed(
row(4, "FL-0004", "Sony A7 IV camera", 165000, 0),
row(4, "FL-0004", "Sony A7 IV camera", 175000, 0),
TaxBasisRule::PersonalUseConversion,
191721,
200000,
),
// No tax basis recorded yet.
row(5, "FL-0005", "Standing desk", 80000, 0),
@@ -172,9 +172,9 @@ fn pdf_shows_assets_totals_and_reconciliation() {
"Active",
"Disposed 2026-09-30, proceeds 45.00",
"Total, assets held",
"6,249.00", // cost held: 2,500.00 + 1,299.00 + 1,650.00 + 800.00
"6,349.00", // cost held: 2,500.00 + 1,299.00 + 1,750.00 + 800.00
"852.53", // accumulated held
"5,396.47", // book value held
"5,496.47", // book value held: 6,349.00 - 852.53
"Cost per register",
"Cost per books",
"Difference (unregistered cost)",
@@ -189,7 +189,7 @@ fn pdf_shows_assets_totals_and_reconciliation() {
// The disposed asset's cost (400.00) is shown but in no total.
assert!(t.contains("400.00"));
let l = layout(&sample());
assert_eq!(l.totals, [cents(624900), cents(85253), cents(539647)]);
assert_eq!(l.totals, [cents(634900), cents(85253), cents(549647)]);
}
/// INVARIANTS 7-9: the tax table shows each rule in short words, the
@@ -209,12 +209,12 @@ fn pdf_shows_tax_basis() {
"Personal-use conversion",
"900.00", // MacBook: contributor's basis = tax basis
"399.00", // its built-in gain
"1,917.21", // camera: contributor's basis
"1,650.00", // its tax basis: the fair market value
"(267.21)", // its built-in loss
"2,000.00", // camera: contributor's basis
"1,750.00", // its tax basis: the fair market value
"(250.00)", // its built-in loss: 1,750.00 - 2,000.00
"Not recorded",
"Total tax basis, assets held",
"5,050.00", // 2,500.00 + 900.00 + 1,650.00; the desk has none
"5,150.00", // 2,500.00 + 900.00 + 1,750.00; the desk has none
"Tax basis not recorded for 1 asset",
"Notes",
"Book value is the fair market value at contribution",
@@ -227,7 +227,7 @@ fn pdf_shows_tax_basis() {
}
let l = layout(&r);
assert_eq!(l.tax_total, r.total_tax_basis);
assert_eq!(l.tax_total, cents(505000));
assert_eq!(l.tax_total, cents(515000));
assert_eq!(
l.missing.as_deref(),
Some("Tax basis not recorded for 1 asset")
@@ -311,7 +311,7 @@ fn rule_marks_match_their_notes() {
"(Ryzen 7 7840U, 64 GB, 4 TB) Cost 1 2,500.00",
"MacBook Air Carryover 2 1,299.00",
"Old monitor Cost 1 400.00",
"Sony A7 IV camera Personal-use conversion 3 1,650.00",
"Sony A7 IV camera Personal-use conversion 3 1,750.00",
"Standing desk Not recorded 800.00",
"1 Cost: property the company bought",
"2 Carryover (IRC §723): property a member contributes",
+119 -63
View File
@@ -245,7 +245,9 @@ fn assert_sections_foot(st: &Statement) -> Vec<(String, String)> {
}
sums = vec![Decimal::ZERO; st.columns.len()];
}
RowKind::Subheading | RowKind::Spacer => {}
// A detail's amount is part of the line above it, not of the
// section (cash flow kinds).
RowKind::Subheading | RowKind::Detail | RowKind::Spacer => {}
}
}
checked
@@ -315,7 +317,7 @@ fn assert_accounting_shape(s: &str) {
#[test]
fn format_amount_examples_from_the_spec() {
assert_eq!(format_amount(dec("1234.56")), "1,234.56");
assert_eq!(format_amount(dec("-207.68")), "(207.68)");
assert_eq!(format_amount(dec("-318.42")), "(318.42)");
assert_eq!(format_amount(Decimal::ZERO), "0.00");
// INVARIANT 2's own finding: -0.004 printed "(0.00)".
assert_eq!(format_amount(dec("-0.004")), "0.00");
@@ -552,13 +554,15 @@ proptest! {
}
}
/// cash_flow INVARIANTS 1-3: operating lines (net income, depreciation,
/// working capital) foot to "Net cash from operating activities",
/// investing and financing to theirs; the three sum to "Net increase
/// (decrease) in cash"; beginning cash plus it is "Cash at end of
/// period"; a member's capital/distributions line is "Member
/// contributions — Name" (cash in) or "Member distributions — Name"
/// (cash out).
/// cash_flow INVARIANTS 1-5 (direct method, rules.md B-14, D-35): the
/// operating class rows foot to "Net cash from operating activities",
/// each other section's kind rows to theirs, and each kind's
/// transaction details to its kind's row; the three sum to
/// "Net increase (decrease) in cash"; beginning cash plus it is "Cash
/// at end of period"; net income and the reconciling items foot to the
/// schedule's subtotal, which is operating's; every row is the
/// engine's, kind for kind and label for label (none made up from a
/// sign); the noncash schedule lists every disclosure, or "None".
#[test]
fn cash_flow_foots_and_reconciles((specs, oe, end_off) in books_strategy()) {
let b = build_books(&specs, oe);
@@ -569,14 +573,13 @@ proptest! {
cash_accounts: vec![b.ids[0], b.ids[1]],
financing_accounts: vec![b.ids[5]],
investing_accounts: vec![b.ids[3]],
members: member_capitals(&b),
}).unwrap();
let ms = members();
let st = report::cash_flow(&cf, &ms);
let st = report::cash_flow(&cf);
let checked = assert_sections_foot(&st);
prop_assert_eq!(checked.len(), 3, "three sections: {:?}", checked);
prop_assert_eq!(checked.len(), 4, "three sections and the schedule: {:?}", checked);
let op = find(&st, RowKind::Subtotal, "Net cash from operating activities").expect("operating subtotal");
prop_assert!(checked.iter().any(|(_, t)| t == &op.label));
let three: Decimal = checked.iter().map(|(_, t)| amt(st.rows.iter().find(|r| &r.label == t).unwrap(), 0)).sum();
let three: Decimal = checked[..3].iter().map(|(_, t)| amt(st.rows.iter().find(|r| &r.label == t).unwrap(), 0)).sum();
let net = st.rows.iter().find(|r| r.label == "Net increase (decrease) in cash").expect("net change row");
prop_assert_eq!(amt(net, 0), three);
let begin = st.rows.iter().find(|r| r.kind == RowKind::Line && r.label.to_lowercase().contains("beginning")).expect("beginning cash");
@@ -586,19 +589,52 @@ proptest! {
prop_assert_eq!(amt(op, 0), cf.cash_from_operations);
prop_assert_eq!(amt(begin, 0), cf.cash_beginning);
prop_assert_eq!(amt(endrow, 0), cf.cash_ending);
// INVARIANT 3: members named on their capital/distributions lines.
let mut fin: Vec<(String, Decimal)> = section_lines(&st, "Financing").iter().map(|r| (r.label.clone(), amt(r, 0))).collect();
for line in &cf.financing {
let Some(code) = &line.account else { continue };
let Some(m) = ms.iter().find(|m| &m.capital_code == code || m.distribution_code.as_ref() == Some(code)) else { continue };
let want = if line.amount > Decimal::ZERO {
format!("Member contributions — {}", m.name)
} else {
format!("Member distributions — {}", m.name)
};
let pos = fin.iter().position(|(l, a)| *l == want && *a == line.amount);
prop_assert!(pos.is_some(), "no {:?} {} among {:?}", want, line.amount, fin);
fin.remove(pos.unwrap());
// INVARIANT 3: the schedule closes on operating's figure.
let (sched, sched_total) = &checked[3];
prop_assert!(sched.starts_with("Reconciliation of net income"));
let sched_row = st.rows.iter().filter(|r| &r.label == sched_total).nth(1).expect("schedule subtotal");
prop_assert_eq!(amt(sched_row, 0), cf.cash_from_operations);
// INVARIANT 4: every row is the engine's, in its order.
let ops: Vec<(String, Decimal)> = section_lines(&st, "Operating").iter().map(|r| (r.label.clone(), amt(r, 0))).collect();
let want: Vec<(String, Decimal)> = cf.operating_by_class().into_iter().map(|(c, a)| (c.label().to_string(), a)).collect();
prop_assert_eq!(ops, want);
// Investing and financing: each kind a line with its total (D-35),
// its transactions listed under it as details that sum to it.
for (sec, name) in [(ledger_core::CashFlowSection::Investing, "Investing"), (ledger_core::CashFlowSection::Financing, "Financing")] {
let mut want = Vec::new();
let mut kind = None;
for l in cf.lines(sec) {
if kind != Some(&l.kind) {
let total: Decimal = cf.lines(sec).iter().filter(|x| x.kind == l.kind).map(|x| x.amount).sum();
want.push((RowKind::Line, l.kind.clone(), Some(total)));
kind = Some(&l.kind);
}
want.push((RowKind::Detail, l.label.clone(), Some(l.amount)));
}
let mut got = Vec::new();
let mut inside = false;
for r in &st.rows {
match r.kind {
RowKind::Section => inside = r.label.starts_with(name),
RowKind::Subtotal => inside = false,
RowKind::Line | RowKind::Detail | RowKind::Subheading if inside => {
got.push((r.kind, r.label.clone(), r.amounts.first().copied().flatten()));
}
_ => {}
}
}
prop_assert_eq!(got, want);
}
prop_assert!(st.rows.iter().all(|r| !r.label.starts_with("Member ")));
// INVARIANT 5: the noncash schedule.
let noncash = section_lines(&st, "Noncash");
if cf.noncash_disclosures.is_empty() {
prop_assert_eq!(noncash.len(), 1);
prop_assert_eq!(&noncash[0].label, "None");
} else {
let got: Vec<(String, Decimal)> = noncash.iter().map(|r| (r.label.clone(), amt(r, 0))).collect();
let want: Vec<(String, Decimal)> = cf.noncash_disclosures.iter().map(|n| (n.label.clone(), n.amount)).collect();
prop_assert_eq!(got, want);
}
}
@@ -2988,7 +3024,7 @@ fn xlsx_precision_boundaries() {
line("neg max exact", "-9999999999999.99"),
line("sixteen digits", "12345678901234.56"),
line("neg sixteen digits", "-12345678901234.56"),
line("plain", "-207.68"),
line("plain", "-318.42"),
],
);
check_xlsx(&st);
@@ -3049,6 +3085,7 @@ fn xlsx_renders_every_statement_and_ledger_report() {
cash_accounts: vec![b.ids[0]],
financing_accounts: vec![],
investing_accounts: vec![],
members: member_capitals(&b),
},
)
.unwrap();
@@ -3056,7 +3093,7 @@ fn xlsx_renders_every_statement_and_ledger_report() {
for st in [
report::profit_and_loss(&pl),
report::balance_sheet(&bs, ye, &members()),
report::cash_flow(&cf, &members()),
report::cash_flow(&cf),
report::trial_balance(&trial_balance_as_of(&b.ledger, e), e),
report::members_equity(&me),
] {
@@ -3868,46 +3905,65 @@ fn tax_summary_difference_and_schedule_k() {
);
}
/// The cash flow's "Adjustments" subheading and depreciation line appear
/// only when there is something to adjust.
/// The reconciliation's "Adjustments:" subheading appears only when there
/// is something to reconcile, and the noncash schedule says "None" when
/// nothing was disclosed (cash_flow INVARIANT 5). A contribution of
/// property is disclosed under its label; depreciation is an adjustment.
#[test]
fn cash_flow_adjustments_appear_only_when_there_are_some() {
let mk = |depreciation: Decimal, wc: Vec<ledger_core::CashFlowLine>| {
ledger_core::CashFlowStatement {
start: d(2026, 1, 1),
end: d(2026, 1, 31),
net_income: cents(1_000),
depreciation,
cash_from_operations: cents(1_000)
+ depreciation
+ wc.iter().map(|l| l.amount).sum::<Decimal>(),
working_capital: wc,
investing: vec![],
cash_from_investing: Decimal::ZERO,
financing: vec![],
cash_from_financing: Decimal::ZERO,
net_change_in_cash: Decimal::ZERO,
cash_beginning: Decimal::ZERO,
cash_ending: Decimal::ZERO,
}
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let equip = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let cap = add("3000", AccountType::Equity);
let rev = add("4000", AccountType::Revenue);
let depr = add("6100", AccountType::Expense);
let post = |l: &mut Ledger, day, desc: &str, dr, cr, n: i64| {
let e = l.create_entry(d(2026, 1, day), desc);
l.add_line(e, dr, Side::Debit, usd(n)).unwrap();
l.add_line(e, cr, Side::Credit, usd(n)).unwrap();
l.post_entry(e).unwrap();
};
post(&mut l, 2, "Sale", cash, rev, 1_000);
let st = |l: &Ledger| {
let cf = cash_flow(
l,
&CashFlowParams {
start: d(2026, 1, 1),
end: d(2026, 1, 31),
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
},
)
.unwrap();
report::cash_flow(&cf)
};
let has = |st: &Statement, p: &str| st.rows.iter().any(|r| r.label.starts_with(p));
let plain = report::cash_flow(&mk(Decimal::ZERO, vec![]), &[]);
let plain = st(&l);
assert!(!has(&plain, "Adjustments") && !has(&plain, "Depreciation"));
let dep = report::cash_flow(&mk(cents(100), vec![]), &[]);
assert_eq!(section_lines(&plain, "Noncash")[0].label, "None");
l.register_asset(ledger_core::NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 1, 3),
cost: usd(900),
salvage_value: usd(0),
useful_life_months: 36,
cost_account: equip,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
post(&mut l, 3, "Laptop contributed", equip, cap, 900);
post(&mut l, 31, "Depreciation", depr, accum, 25);
let dep = st(&l);
assert!(has(&dep, "Adjustments") && has(&dep, "Depreciation"));
let wc = report::cash_flow(
&mk(
Decimal::ZERO,
vec![ledger_core::CashFlowLine {
label: "Increase in AR".into(),
amount: cents(-50),
account: None,
}],
),
&[],
);
assert!(has(&wc, "Adjustments") && !has(&wc, "Depreciation"));
let noncash = section_lines(&dep, "Noncash");
assert_eq!(noncash.len(), 1);
assert_eq!(noncash[0].label, "Laptop contributed (#2)");
assert_eq!(noncash[0].amounts, vec![Some(cents(900))]);
}
/// A template takes at most 90 owners.
+1
View File
@@ -4,3 +4,4 @@
- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart; add a missing one with books.add_account first, never guess one.
- When unsure of the category, draft and ask. A draft is cheap; a wrong posting is a reversal.
- Name the entry's party when you draft it (books.draft_entry party), so the preview can show the split (the parties-and-tins section).
- An expense or purchase a member pays personally goes to the expense or asset against the members' financing account (Due to Members), the vendor named as the party; the cash flow statement shows it as borrowed from the member and spent. What the company owes a member for their own work or property (rent, a guaranteed payment) goes to a payable instead.
@@ -1,6 +1,7 @@
# Tracing figures
- Reports are pure functions of the posted entries. When the human questions a figure, trace it to its entries with the tools: the report (books.trial_balance, books.profit_and_loss, books.balance_sheet, books.cash_flow), then books.search_entries and books.get_entry for the entries behind it, and books.show to put them on the human's screen.
- A cash flow line names its entry (books.cash_flow: each line's entry, kind and accounts). An expense a member paid personally (or equipment a lender paid for) is cash borrowed and spent on its date: a financing line and an operating (or investing) line, each with on_behalf, the part no bank account moved; the reimbursement is a repayment. A noncash disclosure is an entry that moved no cash at all (property contributed, debt converted to capital). A warning names an entry whose later payment or collection the books can't classify, with the fix. A role refusal names the setting to change.
- Never guess, and never look for the books' files: the tools are the evidence.
- books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact.
- A member's odd cent: closes, the statement of members' equity, the K-1 worksheet and the viewer split earnings among the members one running way. Each close gives a member their share of everything closed to the same members and shares so far, to the cent (odd cents by largest remainder, ties to the member listed first when that count began), less what earlier closes gave them, so no member is ever a cent from their exact share. Earnings not yet closed are split the way their close will split them, so a member's capital on a day never changes when its month is closed. books.close_period's members shows each member's part, running total and exact share; the first close that posts may carry a one-time correction of the rounding left by closes made under an earlier rule (a cent or so per member; each member's this_close shows it) (books.list_periods' close_rule names each close's rule). A change of members or shares starts a new count.
+262 -122
View File
@@ -1245,6 +1245,46 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
} else {
" in the settings"
};
// The cash flow roles: codes the chart lacks here; the rest is the
// engine's own check, the one the statement refuses on (rules.md B-14
// rule 2), so the two never disagree.
let lists = [
(
ledger_core::RoleList::Cash,
label("books.cash_accounts", "[books] cash_accounts"),
&cfg.roles.cash,
),
(
ledger_core::RoleList::Financing,
label("books.financing_accounts", "[books] financing_accounts"),
&cfg.roles.financing,
),
(
ledger_core::RoleList::Investing,
label("books.investing_accounts", "[books] investing_accounts"),
&cfg.roles.investing,
),
];
let mut ids: [Vec<ledger_core::AccountId>; 3] = Default::default();
for ((_, setting, codes), ids) in lists.iter().zip(&mut ids) {
for code in codes.iter() {
match ledger.chart().find_by_code(code) {
Some(a) => ids.push(a.id),
None => out.push(format!(
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
)),
}
}
}
let named = |l: ledger_core::RoleList| {
lists
.iter()
.find(|(list, _, _)| *list == l)
.map_or(String::new(), |(_, setting, _)| setting.to_string())
};
for p in ledger_core::role_problems(ledger, &ids[0], &ids[1], &ids[2]) {
out.push(format!("{}{how}", p.message(named)));
}
let mut check = |setting: &str, code: &str, want: &[AccountType]| {
match ledger.chart().find_by_code(code) {
None => out.push(format!(
@@ -1260,27 +1300,7 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
}
};
use AccountType::*;
for code in &cfg.roles.cash {
check(
label("books.cash_accounts", "[books] cash_accounts"),
code,
&[Asset],
);
}
for code in &cfg.roles.financing {
check(
label("books.financing_accounts", "[books] financing_accounts"),
code,
&[Liability, Equity],
);
}
for code in &cfg.roles.investing {
check(
label("books.investing_accounts", "[books] investing_accounts"),
code,
&[Asset],
);
}
let c = &cfg.company;
for m in &c.members {
check(
@@ -2798,7 +2818,7 @@ impl BooksServer {
#[tool(
name = "books.cash_flow",
title = "Cash flow statement",
description = "Cash flow statement (indirect method) for a period: net income, depreciation add-back, working-capital changes, investing (asset purchases, disposal proceeds), financing. The three sections reconcile to the net change in the given cash accounts.",
description = "Statement of cash flows for a period, direct method (ASC 230): each entry that moved cash, by section and kind (operating by ASC 230-10-45-25 class; investing and financing by kind, such as each member's contributions), gross; an entry and its reversal in the same period drop out together. What a member or lender paid for the company is borrowed and spent on its date (on_behalf). Also noncash investing and financing activities (property contributed, debt converted), net income reconciled to operating cash, warnings, and reconciles: the sections sum to the change in the cash accounts. Account roles default to the books' settings.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn cash_flow(
@@ -2816,25 +2836,7 @@ impl BooksServer {
&p.financing_accounts,
&p.investing_accounts,
)?;
let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut {
label: l.label.clone(),
amount: money(l.amount),
};
Ok(Json(CashFlowOut {
start: stmt.start.to_string(),
end: stmt.end.to_string(),
net_income: money(stmt.net_income),
depreciation: money(stmt.depreciation),
working_capital: stmt.working_capital.iter().map(line).collect(),
cash_from_operations: money(stmt.cash_from_operations),
investing: stmt.investing.iter().map(line).collect(),
cash_from_investing: money(stmt.cash_from_investing),
financing: stmt.financing.iter().map(line).collect(),
cash_from_financing: money(stmt.cash_from_financing),
net_change_in_cash: money(stmt.net_change_in_cash),
cash_beginning: money(stmt.cash_beginning),
cash_ending: money(stmt.cash_ending),
}))
Ok(Json(cash_flow_out(&stmt)))
}
#[tool(
@@ -5330,7 +5332,7 @@ impl BooksServer {
#[tool(
name = "books.export",
title = "Export a report",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of, period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, fixed_asset_register, and iif (default today).",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of, period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, fixed_asset_register, and iif (default today).",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -5431,17 +5433,14 @@ impl BooksServer {
let earnings = self.year_earnings(ledger, bs.as_of)?;
report::balance_sheet(&bs, earnings, &members)
}
"cash_flow" => report::cash_flow(
&self.cash_flow_for(
ledger,
p.start.as_deref(),
p.end.as_deref(),
&p.cash_accounts,
&p.financing_accounts,
&p.investing_accounts,
)?,
&members,
),
"cash_flow" => report::cash_flow(&self.cash_flow_for(
ledger,
p.start.as_deref(),
p.end.as_deref(),
&p.cash_accounts,
&p.financing_accounts,
&p.investing_accounts,
)?),
"trial_balance" => {
let as_of = match &p.as_of {
Some(s) => parse_date(s)?,
@@ -8098,7 +8097,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
title = "CPA handoff package",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was bought by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -8602,78 +8601,219 @@ impl BooksServer {
&p.investing_accounts,
)?;
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"start": stmt.start.to_string(), "end": stmt.end.to_string(),
"net_income": money(stmt.net_income),
"depreciation": money(stmt.depreciation),
"working_capital": stmt.working_capital.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_operations": money(stmt.cash_from_operations),
"investing": stmt.investing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_investing": money(stmt.cash_from_investing),
"financing": stmt.financing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_financing": money(stmt.cash_from_financing),
"net_change_in_cash": money(stmt.net_change_in_cash),
"cash_beginning": money(stmt.cash_beginning),
"cash_ending": money(stmt.cash_ending),
}))
.map_err(|e| e.to_string());
// The tool's own answer, from the one function (they can't
// drift apart).
return serde_json::to_string_pretty(&cash_flow_out(&stmt)).map_err(|e| e.to_string());
}
let mut csv = String::from("section,label,amount\n");
csv.push_str(&format!(
"operating,Net income,{}\n",
money(stmt.net_income)
));
csv.push_str(&format!(
"operating,Depreciation,{}\n",
money(stmt.depreciation)
));
for l in &stmt.working_capital {
csv.push_str(&format!(
"operating,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"operating,Cash from operations,{}\n",
money(stmt.cash_from_operations)
));
for l in &stmt.investing {
csv.push_str(&format!(
"investing,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"investing,Cash from investing,{}\n",
money(stmt.cash_from_investing)
));
for l in &stmt.financing {
csv.push_str(&format!(
"financing,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"financing,Cash from financing,{}\n",
money(stmt.cash_from_financing)
));
csv.push_str(&format!(
"reconciliation,Net change in cash,{}\n",
money(stmt.net_change_in_cash)
));
Ok(csv)
Ok(cash_flow_csv(&stmt))
}
}
/// `books.cash_flow`'s answer, and `books.export`'s cash flow JSON.
fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut {
use ledger_core::CashFlowSection as S;
let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut {
entry: l.entry,
date: l.date.to_string(),
label: l.label.clone(),
kind: l.kind.clone(),
amount: money(l.amount),
on_behalf: (!l.on_behalf.is_zero()).then(|| money(l.on_behalf)),
accounts: l.accounts.clone(),
reverses: l.reverses,
class: l.class.map(|c| c.key().to_string()),
};
let kinds = |sec: S| {
stmt.kinds(sec)
.into_iter()
.map(|k| CashFlowKindOut {
kind: k.kind,
class: k.class.map(|c| c.key().to_string()),
amount: money(k.amount),
entries: k.entries,
})
.collect()
};
let r = &stmt.operating_reconciliation;
CashFlowOut {
method: "direct".to_string(),
start: stmt.start.to_string(),
end: stmt.end.to_string(),
operating_by_class: kinds(S::Operating),
operating: stmt.operating.iter().map(line).collect(),
cash_from_operations: money(stmt.cash_from_operations),
investing_by_kind: kinds(S::Investing),
investing: stmt.investing.iter().map(line).collect(),
cash_from_investing: money(stmt.cash_from_investing),
financing_by_kind: kinds(S::Financing),
financing: stmt.financing.iter().map(line).collect(),
cash_from_financing: money(stmt.cash_from_financing),
net_change_in_cash: money(stmt.net_change_in_cash),
cash_beginning: money(stmt.cash_beginning),
cash_ending: money(stmt.cash_ending),
noncash_disclosures: stmt
.noncash_disclosures
.iter()
.map(|n| NoncashDisclosureOut {
entry: n.entry,
date: n.date.to_string(),
label: n.label.clone(),
amount: money(n.amount),
operating: money(n.operating),
investing: money(n.investing),
financing: money(n.financing),
exchanged: money(n.exchanged),
reverses: n.reverses,
})
.collect(),
operating_reconciliation: OperatingReconciliationOut {
net_income: money(r.net_income),
lines: r
.lines
.iter()
.map(|l| ReconcilingLineOut {
kind: l.kind.key().to_string(),
label: l.label.clone(),
amount: money(l.amount),
account: l.account.clone(),
entries: l.entries.clone(),
})
.collect(),
net_cash_from_operating: money(r.net_cash_from_operating),
},
reconciles: CashFlowFootingOut {
sections_sum: money(stmt.reconciles.sections_sum),
net_change: money(stmt.reconciles.net_change),
difference: money(stmt.reconciles.difference),
},
warnings: stmt
.warnings
.iter()
.map(|w| CashFlowWarningOut {
entry: w.entry,
kind: w.kind.key().to_string(),
message: w.message.clone(),
})
.collect(),
}
}
/// `books.export`'s cash flow CSV:
/// `section,kind,class,entry,date,label,amount,on_behalf`, every line of
/// every section (`on_behalf`: of its amount, what a member or lender
/// paid or received for the company, D-42), the reconciliation, the
/// noncash disclosures, the footing and the warnings (`warning`, its
/// kind, its entry and its message; no amount). Only a section's lines
/// have an `on_behalf`.
fn cash_flow_csv(stmt: &ledger_core::CashFlowStatement) -> String {
use ledger_core::CashFlowSection as S;
let mut csv = String::from("section,kind,class,entry,date,label,amount,on_behalf\n");
let mut row = |section: &str,
kind: &str,
class: &str,
entry: Option<u64>,
date: Option<chrono::NaiveDate>,
label: &str,
amount: rust_decimal::Decimal,
on_behalf: Option<rust_decimal::Decimal>| {
csv.push_str(&format!(
"{section},{},{class},{},{},{},{},{}\n",
csv_escape(kind),
entry.map_or(String::new(), |e| e.to_string()),
date.map_or(String::new(), |d| d.to_string()),
csv_escape(label),
money(amount),
on_behalf.map_or(String::new(), money)
));
};
for (sec, total) in [
(S::Operating, "Net cash from operating activities"),
(S::Investing, "Net cash from investing activities"),
(S::Financing, "Net cash from financing activities"),
] {
for l in stmt.lines(sec) {
let class = l.class.map_or("", |c| c.key());
row(
sec.key(),
&l.kind,
class,
Some(l.entry),
Some(l.date),
&l.label,
l.amount,
Some(l.on_behalf),
);
}
row(sec.key(), "", "", None, None, total, stmt.total(sec), None);
}
let r = &stmt.operating_reconciliation;
row(
"reconciliation",
"",
"",
None,
None,
"Net income (loss)",
r.net_income,
None,
);
for l in &r.lines {
row(
"reconciliation",
l.kind.key(),
"",
None,
None,
&l.label,
l.amount,
None,
);
}
row(
"reconciliation",
"",
"",
None,
None,
"Net cash from operating activities",
r.net_cash_from_operating,
None,
);
for n in &stmt.noncash_disclosures {
row(
"noncash",
"",
"",
Some(n.entry),
Some(n.date),
&n.label,
n.amount,
None,
);
}
let f = &stmt.reconciles;
for (label, amount) in [
("Net increase (decrease) in cash", stmt.net_change_in_cash),
("Cash at beginning of period", stmt.cash_beginning),
("Cash at end of period", stmt.cash_ending),
("Sections sum", f.sections_sum),
("Difference", f.difference),
] {
row("footing", "", "", None, None, label, amount, None);
}
// The warnings, as the tool gives them: no amount (figures are
// unaffected).
for w in &stmt.warnings {
csv.push_str(&format!(
"warning,{},,{},,{},,\n",
w.kind.key(),
w.entry,
csv_escape(&w.message)
));
}
csv
}
// ---------------------------------------------------------------------------
// ServerHandler
// ---------------------------------------------------------------------------
+165 -26
View File
@@ -807,61 +807,200 @@ pub struct CashFlowParams {
pub start: Option<String>,
/// Period end, YYYY-MM-DD. Defaults to today.
pub end: Option<String>,
/// Account codes treated as cash (e.g. `["1000", "1010"]`): the
/// statement reconciles to the net change in these accounts. Defaults
/// to `[books] cash_accounts`; one or the other is required.
/// Asset account codes treated as cash (e.g. `["1000", "1010"]`): the
/// statement explains the change in these accounts. Defaults to the
/// books' cash accounts setting (books.settings shows it); one or the
/// other is required.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Account codes for liabilities treated as financing (loans, and what
/// owners advanced: ASC 230-10-45-14(b)). Defaults to `[books]
/// financing_accounts`, else empty (liabilities are working capital).
/// Liability account codes treated as financing (loans, and what
/// members advanced: ASC 230-10-45-14(b)); equity is always financing.
/// Defaults to the books' financing accounts setting, else empty
/// (other liabilities are operating).
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Asset account codes treated as investing for cash_flow: loans the
/// company makes, investments it buys (ASC 230-10-20). Defaults to
/// `[books] investing_accounts`.
/// Asset account codes treated as investing: loans the company makes,
/// investments it buys (ASC 230-10-20). Registered fixed assets are
/// investing unlisted. Defaults to the books' investing accounts
/// setting.
#[serde(default)]
pub investing_accounts: Vec<String>,
}
/// One cash flow line.
/// One entry's cash in one kind of one section of the cash flow
/// statement.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowLineOut {
/// Human label.
/// The entry that moved the cash.
pub entry: u64,
/// Its date, YYYY-MM-DD.
pub date: String,
/// "<description> (#N)", "Reversal of #N — <N's description>", or
/// "Reinstatement of #A (reverses #B)".
pub label: String,
/// Signed amount, decimal string. Positive = inflow.
/// The kind it is listed under: an operating class ("Cash paid to
/// suppliers and employees") or an investing or financing kind
/// ("Contributions from members — <member>").
pub kind: String,
/// The cash, decimal string; positive is cash in.
pub amount: String,
/// Of `amount`, what a member or a lender paid or received for the
/// company with no cash account moving (a constructive receipt or
/// payment, D-42: an expense a member paid personally is borrowed from
/// the member and spent), decimal string; absent when none.
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf: Option<String>,
/// Codes of this kind's accounts the entry moved.
pub accounts: Vec<String>,
/// The entry this one reverses, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub reverses: Option<u64>,
/// Operating lines: the ASC 230-10-45-25 class key
/// (`from_customers`, `interest_and_dividends_received`,
/// `other_receipts`, `to_suppliers_and_employees`, `interest_paid`,
/// `income_taxes_paid`, `other_payments`).
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
}
/// Result of `books.cash_flow`.
/// A kind's total: how the printed statement and the viewer show a
/// section.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowKindOut {
/// The caption.
pub kind: String,
/// The operating class key, for an operating kind.
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// The kind's cash, decimal string.
pub amount: String,
/// Its entries.
pub entries: Vec<u64>,
}
/// One reconciling item between net income and operating cash.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ReconcilingLineOut {
/// `depreciation`, `investing_gain_loss`, `noncash_through`,
/// `other_noncash`, or `operating_balance_change`.
pub kind: String,
/// "Depreciation", "Noncash: through 2500 Bank loan", "Increase
/// in 1300 Prepaid Expenses", ...
pub label: String,
/// Added to net income, decimal string.
pub amount: String,
/// The account it is about, if one.
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
/// The entries behind it (an entry and its reversal in the period
/// left out).
pub entries: Vec<u64>,
}
/// Net income reconciled to net cash from operating activities (ASC
/// 230-10-45-30: a separate schedule with the direct method).
#[derive(Debug, Serialize, JsonSchema)]
pub struct OperatingReconciliationOut {
/// The period's net income, decimal string.
pub net_income: String,
/// The reconciling items.
pub lines: Vec<ReconcilingLineOut>,
/// Net income plus the items: equal to cash_from_operations.
pub net_cash_from_operating: String,
}
/// A noncash investing or financing activity (ASC 230-10-50-3).
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoncashDisclosureOut {
/// The entry.
pub entry: u64,
/// Its date, YYYY-MM-DD.
pub date: String,
/// Its label.
pub label: String,
/// The amount disclosed, decimal string.
pub amount: String,
/// The operating part no cash settled, debit-positive.
pub operating: String,
/// The investing part no cash settled, debit-positive.
pub investing: String,
/// The financing part no cash settled, debit-positive.
pub financing: String,
/// What was exchanged inside investing and financing (debt to equity,
/// one asset for another).
pub exchanged: String,
/// The entry it reverses, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub reverses: Option<u64>,
}
/// The footing check: always a zero difference (a nonzero one is an
/// engine error, never output).
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowFootingOut {
/// Operating + investing + financing.
pub sections_sum: String,
/// The cash accounts' ending balance less their beginning balance.
pub net_change: String,
/// sections_sum − net_change: "0.00".
pub difference: String,
}
/// An entry whose later settlement the books can't classify by
/// themselves, with what to change.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowWarningOut {
/// The entry.
pub entry: u64,
/// `asset_on_operating_payable` or `asset_sold_on_operating_receivable`.
pub kind: String,
/// What happened and the fix.
pub message: String,
}
/// Result of `books.cash_flow`; `books.export` writes the same JSON.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowOut {
/// "direct" (ASC 230-10-45-25).
pub method: String,
/// Period start, YYYY-MM-DD.
pub start: String,
/// Period end, YYYY-MM-DD.
pub end: String,
/// Period net income, decimal string.
pub net_income: String,
/// Non-cash depreciation added back, decimal string.
pub depreciation: String,
/// Working-capital adjustments.
pub working_capital: Vec<CashFlowLineOut>,
/// Cash from operations, decimal string.
/// Operating cash by ASC 230-10-45-25 class, (a) to (g).
pub operating_by_class: Vec<CashFlowKindOut>,
/// Every entry's operating cash, by class, then date.
pub operating: Vec<CashFlowLineOut>,
/// Net cash from operating activities, decimal string.
pub cash_from_operations: String,
/// Investing flows (purchases, disposal proceeds).
/// Investing cash by kind (purchases and sales of property and
/// equipment, loans made and collected).
pub investing_by_kind: Vec<CashFlowKindOut>,
/// Every entry's investing cash, by kind, then date.
pub investing: Vec<CashFlowLineOut>,
/// Cash from investing, decimal string.
/// Net cash from investing activities, decimal string.
pub cash_from_investing: String,
/// Financing flows (equity, designated debt).
/// Financing cash by kind (contributions and distributions per member,
/// borrowing and repayments per lender).
pub financing_by_kind: Vec<CashFlowKindOut>,
/// Every entry's financing cash, by kind, then date.
pub financing: Vec<CashFlowLineOut>,
/// Cash from financing, decimal string.
/// Net cash from financing activities, decimal string.
pub cash_from_financing: String,
/// Net change in cash, decimal string.
pub net_change_in_cash: String,
/// Cash balance at period start, decimal string.
/// Cash at the start, decimal string.
pub cash_beginning: String,
/// Cash balance at period end, decimal string.
/// Cash at the end, decimal string.
pub cash_ending: String,
/// Noncash investing and financing activities.
pub noncash_disclosures: Vec<NoncashDisclosureOut>,
/// Net income reconciled to operating cash.
pub operating_reconciliation: OperatingReconciliationOut,
/// The footing check.
pub reconciles: CashFlowFootingOut,
/// Entries whose later settlement the books can't classify.
pub warnings: Vec<CashFlowWarningOut>,
}
/// Parameters for `books.import_bank_csv`.
+86 -4
View File
@@ -2379,8 +2379,11 @@ fn a_restarted_server_waits_for_the_old_one_to_let_go() {
}
/// `books.cash_flow` with no accounts passed reads the roles from
/// `[books]` in wanderbooks.toml (rules.md B-14): the owners' advance is
/// financing, gross; a loan to a member is investing.
/// `[books]` in wanderbooks.toml (rules.md B-14): the expense the owners
/// paid through 2100 is borrowed from them and spent on its date, each
/// line with `on_behalf` (D-42, rule 11); repaying the advance is
/// financing; a loan to a member is investing; the statement is the direct
/// method's, and it foots.
#[test]
fn cash_flow_reads_its_account_roles_from_the_books_settings() {
let dir = test_dir("cash-roles");
@@ -2448,10 +2451,89 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() {
.map(|l| l["amount"].as_str().unwrap().to_string())
.collect()
};
// Financing, gross and sorted by code: 2100 in and out, then capital.
assert_eq!(amounts("financing"), ["100.00", "-100.00", "1000.00"]);
// Financing by kind: the contribution, what the owners paid for the
// company (borrowed through 2100), then the repayment of 2100.
assert_eq!(amounts("financing"), ["1000.00", "100.00", "-100.00"]);
assert_eq!(cf["financing"][1]["entry"], 2);
assert_eq!(cf["financing"][1]["on_behalf"], "100.00");
assert!(cf["financing"][0].get("on_behalf").is_none());
let kinds: Vec<&str> = cf["financing_by_kind"]
.as_array()
.unwrap()
.iter()
.map(|k| k["kind"].as_str().unwrap())
.collect();
assert_eq!(
kinds,
[
"Contributions from owners — 3000 Capital",
"Proceeds from borrowing — 2100 Due to owners",
"Repayments of borrowing — 2100 Due to owners"
]
);
assert_eq!(amounts("investing"), ["-500.00"]);
assert_eq!(amounts("operating"), ["-100.00"]);
assert_eq!(cf["operating"][0]["on_behalf"], "-100.00");
assert_eq!(cf["operating"][0]["class"], "to_suppliers_and_employees");
assert_eq!(cf["method"], "direct");
assert_eq!(cf["net_change_in_cash"], "400.00");
assert_eq!(cf["reconciles"]["sections_sum"], "400.00");
assert_eq!(cf["reconciles"]["difference"], "0.00");
// Nothing noncash: the software is net income's only item.
assert_eq!(cf["noncash_disclosures"], json!([]));
let r = &cf["operating_reconciliation"];
assert_eq!(r["net_income"], "-100.00");
assert_eq!(r["lines"], json!([]));
assert_eq!(r["net_cash_from_operating"], "-100.00");
for gone in ["net_income", "depreciation", "working_capital"] {
assert!(cf.get(gone).is_none(), "{gone} is gone");
}
}
/// rules.md B-14 rule 2: roles that contradict the chart are refused,
/// naming the setting and the fix, never a statement on a wrong partition.
#[test]
fn cash_flow_refuses_contradictory_roles() {
let dir = test_dir("cash-refuse");
let mut mcp = Mcp::spawn(&dir);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [
("1000", "Bank", "asset"),
("1500", "Equipment", "asset"),
("2100", "Due to owners", "liability"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let err = mcp.call_err(
"books.cash_flow",
json!({"cash_accounts": ["1000"], "financing_accounts": ["1500"]}),
);
assert!(
err.contains("financing_accounts (from the call or books.financing_accounts) lists 1500"),
"{err}"
);
assert!(err.contains("financing accounts are liabilities"), "{err}");
let err = mcp.call_err(
"books.cash_flow",
json!({"cash_accounts": ["1000", "2100"]}),
);
assert!(err.contains("cash accounts are asset accounts"), "{err}");
let err = mcp.call_err(
"books.cash_flow",
json!({"cash_accounts": ["1000"], "investing_accounts": ["1000"]}),
);
assert!(err.contains("is in both cash_accounts"), "{err}");
}
/// A chart CSV with a bad row anywhere is refused whole: nothing is
+253 -37
View File
@@ -782,12 +782,28 @@ fn pnl_and_balance_sheet_exports_equal_the_core_statements() {
}
/// Cash flow CSV and JSON equal `ledger_core::cash_flow` for the same
/// account roles. Kills: export_cash_flow stubs and its json/csv switch.
/// account roles and members, field for field (rules.md B-14; the JSON is
/// the tool's own answer). Kills: export_cash_flow stubs and its json/csv
/// switch, and any field the mapping drops.
#[test]
fn cash_flow_exports_equal_the_core_statement() {
let dir = test_dir("cf");
let mut s = new_store(&dir);
rich_books(&mut s);
// Equipment on an operating payable: a warning, in the CSV too.
post(
&mut s,
"2025-04-02",
"Monitor on account",
&[("1500", Dr, "250.00"), ("2000", Cr, "250.00")],
);
let member = |name: &str, code: &str| ledger_core::MemberCapital {
name: name.into(),
capital_account: id_of(&s, code),
distribution_account: None,
share: Decimal::new(5, 1),
};
let members = vec![member("A", "3000"), member("B", "3001")];
let cf = ledger_core::cash_flow(
s.ledger(),
&CashFlowParams {
@@ -796,6 +812,7 @@ fn cash_flow_exports_equal_the_core_statement() {
cash_accounts: vec![id_of(&s, "1000")],
financing_accounts: vec![id_of(&s, "2100")],
investing_accounts: vec![],
members,
},
)
.unwrap();
@@ -805,54 +822,173 @@ fn cash_flow_exports_equal_the_core_statement() {
json!({"format": format, "report": "cash_flow", "start": START, "end": END,
"cash_accounts": ["1000"], "financing_accounts": ["2100"]})
};
use ledger_core::CashFlowSection as S;
let csv = m.export_text(args("csv"));
let rows = csv_rows(&csv, &["section", "label", "amount"]);
let mut want: Vec<(String, String, Decimal)> = vec![
("operating".into(), "Net income".into(), cf.net_income),
("operating".into(), "Depreciation".into(), cf.depreciation),
];
for l in &cf.working_capital {
want.push(("operating".into(), l.label.clone(), l.amount));
let rows = csv_rows(
&csv,
&[
"section",
"kind",
"class",
"entry",
"date",
"label",
"amount",
"on_behalf",
],
);
let row = |sec: &str, kind: &str, class: &str, entry: String, date: String, label: &str, v| {
(
sec.to_string(),
kind.to_string(),
class.to_string(),
entry,
date,
label.to_string(),
v,
None,
)
};
let mut want = Vec::new();
for (sec, total) in [
(S::Operating, "Net cash from operating activities"),
(S::Investing, "Net cash from investing activities"),
(S::Financing, "Net cash from financing activities"),
] {
for l in cf.lines(sec) {
let mut r = row(
sec.key(),
&l.kind,
l.class.map_or("", |c| c.key()),
l.entry.to_string(),
l.date.to_string(),
&l.label,
l.amount,
);
r.7 = Some(l.on_behalf);
want.push(r);
}
want.push(row(
sec.key(),
"",
"",
String::new(),
String::new(),
total,
cf.total(sec),
));
}
want.push((
"operating".into(),
"Cash from operations".into(),
cf.cash_from_operations,
let r = &cf.operating_reconciliation;
want.push(row(
"reconciliation",
"",
"",
String::new(),
String::new(),
"Net income (loss)",
r.net_income,
));
for l in &cf.investing {
want.push(("investing".into(), l.label.clone(), l.amount));
for l in &r.lines {
want.push(row(
"reconciliation",
l.kind.key(),
"",
String::new(),
String::new(),
&l.label,
l.amount,
));
}
want.push((
"investing".into(),
"Cash from investing".into(),
cf.cash_from_investing,
want.push(row(
"reconciliation",
"",
"",
String::new(),
String::new(),
"Net cash from operating activities",
r.net_cash_from_operating,
));
for l in &cf.financing {
want.push(("financing".into(), l.label.clone(), l.amount));
for n in &cf.noncash_disclosures {
want.push(row(
"noncash",
"",
"",
n.entry.to_string(),
n.date.to_string(),
&n.label,
n.amount,
));
}
want.push((
"financing".into(),
"Cash from financing".into(),
cf.cash_from_financing,
));
want.push((
"reconciliation".into(),
"Net change in cash".into(),
cf.net_change_in_cash,
));
for (label, v) in [
("Net increase (decrease) in cash", cf.net_change_in_cash),
("Cash at beginning of period", cf.cash_beginning),
("Cash at end of period", cf.cash_ending),
("Sections sum", cf.reconciles.sections_sum),
("Difference", cf.reconciles.difference),
] {
want.push(row(
"footing",
"",
"",
String::new(),
String::new(),
label,
v,
));
}
// The warnings close the CSV: kind, entry and message, no amount.
let (warned, rows): (Vec<_>, Vec<_>) = rows.into_iter().partition(|r| r[0] == "warning");
let want_warned: Vec<Vec<String>> = cf
.warnings
.iter()
.map(|w| {
vec![
"warning".to_string(),
w.kind.key().to_string(),
String::new(),
w.entry.to_string(),
String::new(),
w.message.clone(),
String::new(),
String::new(),
]
})
.collect();
assert_eq!(warned, want_warned, "cash flow CSV warnings");
assert_eq!(cf.warnings.len(), 1, "the monitor bought on account");
let got: Vec<_> = rows
.iter()
.map(|r| (r[0].clone(), r[1].clone(), amt(&r[2])))
.map(|r| {
(
r[0].clone(),
r[1].clone(),
r[2].clone(),
r[3].clone(),
r[4].clone(),
r[5].clone(),
amt(&r[6]),
(!r[7].is_empty()).then(|| amt(&r[7])),
)
})
.collect();
assert_eq!(got, want, "cash flow CSV");
assert!(
cf.operating.len() + cf.financing.len() > 1,
"the books move cash"
);
// The lunch a member paid through 2100 is borrowed and spent (D-42):
// its lines carry what was paid on the company's behalf.
assert!(
cf.financing.iter().any(|l| !l.on_behalf.is_zero()),
"a payment made for the company"
);
let j: Value = serde_json::from_str(&m.export_text(args("json"))).unwrap();
assert_eq!(j["method"], "direct");
assert_eq!(j["start"], START);
assert_eq!(j["end"], END);
for (k, v) in [
("net_income", cf.net_income),
("depreciation", cf.depreciation),
("cash_from_operations", cf.cash_from_operations),
("cash_from_investing", cf.cash_from_investing),
("cash_from_financing", cf.cash_from_financing),
@@ -862,18 +998,98 @@ fn cash_flow_exports_equal_the_core_statement() {
] {
assert_eq!(jamt(&j[k]), v, "cash flow {k}");
}
for (k, lines) in [
("working_capital", &cf.working_capital),
("investing", &cf.investing),
("financing", &cf.financing),
for (k, sec) in [
("operating", S::Operating),
("investing", S::Investing),
("financing", S::Financing),
] {
let lines = cf.lines(sec);
let got = j[k].as_array().unwrap();
assert_eq!(got.len(), lines.len(), "{k}");
for (g, l) in got.iter().zip(lines) {
assert_eq!(g["entry"], json!(l.entry));
assert_eq!(g["date"], l.date.to_string());
assert_eq!(g["label"], l.label.as_str());
assert_eq!(g["kind"], l.kind.as_str());
assert_eq!(jamt(&g["amount"]), l.amount);
if l.on_behalf.is_zero() {
assert!(g.get("on_behalf").is_none(), "{g}");
} else {
assert_eq!(jamt(&g["on_behalf"]), l.on_behalf);
}
assert_eq!(g["accounts"], json!(l.accounts));
assert_eq!(g["reverses"], json!(l.reverses));
assert_eq!(g["class"], json!(l.class.map(|c| c.key())));
}
let by = if sec == S::Operating {
"operating_by_class".to_string()
} else {
format!("{k}_by_kind")
};
let kinds = cf.kinds(sec);
let got = j[&by].as_array().unwrap();
assert_eq!(got.len(), kinds.len(), "{by}");
for (g, kd) in got.iter().zip(&kinds) {
assert_eq!(g["kind"], kd.kind.as_str());
assert_eq!(jamt(&g["amount"]), kd.amount);
assert_eq!(g["entries"], json!(kd.entries));
assert_eq!(g["class"], json!(kd.class.map(|c| c.key())));
}
}
let got = j["noncash_disclosures"].as_array().unwrap();
assert_eq!(got.len(), cf.noncash_disclosures.len());
for (g, n) in got.iter().zip(&cf.noncash_disclosures) {
assert_eq!(g["entry"], json!(n.entry));
assert_eq!(g["date"], n.date.to_string());
assert_eq!(g["label"], n.label.as_str());
for (k, v) in [
("amount", n.amount),
("operating", n.operating),
("investing", n.investing),
("financing", n.financing),
("exchanged", n.exchanged),
] {
assert_eq!(jamt(&g[k]), v, "disclosure {k}");
}
assert_eq!(g["reverses"], json!(n.reverses));
}
let rj = &j["operating_reconciliation"];
assert_eq!(jamt(&rj["net_income"]), r.net_income);
assert_eq!(
jamt(&rj["net_cash_from_operating"]),
r.net_cash_from_operating
);
let got = rj["lines"].as_array().unwrap();
assert_eq!(got.len(), r.lines.len());
for (g, l) in got.iter().zip(&r.lines) {
assert_eq!(g["kind"], l.kind.key());
assert_eq!(g["label"], l.label.as_str());
assert_eq!(jamt(&g["amount"]), l.amount);
assert_eq!(g["account"], json!(l.account));
assert_eq!(g["entries"], json!(l.entries));
}
assert_eq!(
jamt(&j["reconciles"]["sections_sum"]),
cf.reconciles.sections_sum
);
assert_eq!(
jamt(&j["reconciles"]["net_change"]),
cf.reconciles.net_change
);
assert_eq!(j["reconciles"]["difference"], "0.00");
let got = j["warnings"].as_array().unwrap();
assert_eq!(got.len(), cf.warnings.len());
for (g, w) in got.iter().zip(&cf.warnings) {
assert_eq!(g["entry"], json!(w.entry));
assert_eq!(g["kind"], w.kind.key());
assert_eq!(g["message"], w.message.as_str());
}
// The tool's answer is the export's JSON.
let tool = m.ok(
"books.cash_flow",
json!({"start": START, "end": END, "cash_accounts": ["1000"], "financing_accounts": ["2100"]}),
);
assert_eq!(tool, j);
}
/// The journal export, CSV and JSON, is every posted entry line by line
+6 -2
View File
@@ -2510,7 +2510,11 @@ fn settings_that_disagree_with_the_chart_are_problems() {
.map(|p| p.as_str().unwrap().to_string())
.collect();
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("[books] financing_accounts names 6000"));
assert!(
problems[0].contains("[books] financing_accounts lists 6000"),
"{problems:?}"
);
assert!(problems[0].ends_with("remove it from [books] financing_accounts in the settings"));
let text = std::fs::read_to_string(&toml)
.unwrap()
.replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]")
@@ -2527,7 +2531,7 @@ fn settings_that_disagree_with_the_chart_are_problems() {
"{problems}"
);
assert!(
problems.contains("[books] financing_accounts names 6000"),
problems.contains("[books] financing_accounts lists 6000"),
"{problems}"
);
}
+13 -13
View File
@@ -170,19 +170,19 @@ pub fn build_package<B: PackageBooks + ?Sized>(
),
(
"cash-flow",
report::cash_flow(
&cash_flow_for(
ledger,
settings,
Some(&fy_start),
Some(&as_of_s),
req.cash_accounts,
req.financing_accounts,
req.investing_accounts,
today,
)?,
&members,
),
// The package's one list of each (INVARIANT 7); investing
// accounts are the cash flow's alone (the request's, else the
// settings').
report::cash_flow(&cash_flow_for(
ledger,
settings,
Some(&fy_start),
Some(&as_of_s),
cash_codes,
financing_codes,
req.investing_accounts,
today,
)?),
),
];
let mut k1_csv: Option<(String, Vec<u8>)> = None;
+28 -1
View File
@@ -13,7 +13,12 @@
//! refused.
//! 2. The cash flow statement needs at least one cash account, and
//! every code it is given must be in the chart; account lists a call
//! leaves empty default to the settings' roles.
//! leaves empty default to the settings' roles. It foots, or it is
//! refused (an engine error, never a figure: rules.md B-14 rule 9);
//! roles that contradict each other or the chart are refused by the
//! engine, naming the setting, so the viewer and the server refuse
//! alike. Members are named on their contributions and
//! distributions (D-35).
//! 3. The year's earnings split net income year to date from the part
//! not yet closed into equity (closes are cumulative).
//! ```
@@ -98,6 +103,9 @@ pub fn bs_statement(
/// Build the cash flow statement from account codes, as the exports need it:
/// `start` defaults to the beginning of time, `end` to `today`, and at
/// least one cash account is required (the statement reconciles to them).
/// A member whose capital account the chart lacks is left unnamed (equity
/// is then named by account), never a refusal of the statement.
#[allow(clippy::too_many_arguments)]
pub fn cash_flow_statement(
ledger: &Ledger,
start: Option<&str>,
@@ -105,6 +113,7 @@ pub fn cash_flow_statement(
cash_codes: &[String],
financing_codes: &[String],
investing_codes: &[String],
members: &[Member],
today: NaiveDate,
) -> Result<ledger_core::reports::CashFlowStatement, String> {
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> {
@@ -129,6 +138,22 @@ pub fn cash_flow_statement(
let financing_accounts = resolve(financing_codes, "financing")?;
let investing_accounts = resolve(investing_codes, "investing")?;
let (start, end) = statement_range(ledger, start, end, today)?;
let chart = ledger.chart();
let members = members
.iter()
.filter_map(|m| {
Some(ledger_core::MemberCapital {
name: m.name.clone(),
capital_account: chart.find_by_code(&m.capital_account)?.id,
distribution_account: m
.distribution_account
.as_deref()
.and_then(|c| chart.find_by_code(c))
.map(|a| a.id),
share: m.share,
})
})
.collect();
ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
@@ -137,6 +162,7 @@ pub fn cash_flow_statement(
cash_accounts,
financing_accounts,
investing_accounts,
members,
},
)
.map_err(|e| e.to_string())
@@ -169,6 +195,7 @@ pub fn cash_flow_for(
&or(cash, settings.cash_accounts),
&or(financing, settings.financing_accounts),
&or(investing, settings.investing_accounts),
&settings.company.members,
today,
)
}
+2 -2
View File
@@ -19,7 +19,7 @@ use chrono::NaiveDate;
use rust_decimal::Decimal;
/// A money amount as a decimal string with at least two places: "0.00",
/// "207.68", "1000.00". Padding only, never rounding: an exact amount with
/// "318.42", "1000.00". Padding only, never rounding: an exact amount with
/// more places (an FX-converted line) keeps them. Never "-0".
pub fn money(n: Decimal) -> String {
let mut n = if n.is_zero() { Decimal::ZERO } else { n };
@@ -89,7 +89,7 @@ mod tests {
assert_eq!(m("-0"), "0.00");
assert_eq!(m("1000"), "1000.00");
assert_eq!(m("5.8"), "5.80");
assert_eq!(m("-207.68"), "-207.68");
assert_eq!(m("-318.42"), "-318.42");
assert_eq!(m("12.3456"), "12.3456");
}
+46 -7
View File
@@ -1734,16 +1734,23 @@ fn the_readme_names_the_entries_on_receivables_and_payables() {
assert!(!text(&p, "README.txt").contains("customer or vendor"));
}
/// An XLSX's shared strings, as text.
/// Every text the package's XLSX `name` holds: its XML parts (the shared
/// strings and the sheets), concatenated, `&amp;` read as `&`.
fn xlsx_strings(p: &Package, name: &str) -> String {
let (_, bytes) = p.files.iter().find(|(n, _)| n == name).unwrap();
let (_, bytes) = p
.files
.iter()
.find(|(n, _)| n == name)
.unwrap_or_else(|| panic!("{name} in {:?}", names(p)));
let mut x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = String::new();
x.by_name("xl/sharedStrings.xml")
.unwrap()
.read_to_string(&mut out)
.unwrap();
out
for i in 0..x.len() {
let mut part = x.by_index(i).unwrap();
if part.name().unwrap().ends_with(".xml") {
part.read_to_string(&mut out).unwrap();
}
}
out.replace("&amp;", "&")
}
/// Random books for [`iif_ties_to_the_trial_balance_as_of`]: entries of
@@ -1947,3 +1954,35 @@ fn the_bookspec_iif_example_matches_the_engine() {
}
assert_eq!(checked, 2);
}
/// The package's statement of cash flows is the direct method's (rules.md
/// B-14, D-35): operating by ASC 230-10-45-25 class, financing by kind
/// with each member named, and both schedules: the reconciliation of net
/// income to operating cash (45-30) and the noncash investing and
/// financing activities (50-3; "None" here: every entry moved cash).
#[test]
fn the_cash_flow_carries_both_schedules() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(true);
let cash = cash();
let p = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap();
let text = xlsx_strings(&p, "cash-flow.xlsx");
for m in &company.members {
let want = format!("Contributions from members — {}", m.name);
assert!(text.contains(&want), "{want:?} not in the cash flow");
}
for want in [
"Statement of Cash Flows",
"Cash received from customers",
"Cash paid to suppliers and employees",
"Members' contributions (#",
"Reconciliation of net income (loss) to net cash from operating activities",
"Noncash investing and financing activities",
"None",
] {
assert!(text.contains(want), "{want:?} not in the cash flow");
}
assert!(!text.contains("Member distributions"));
assert!(p.files.iter().any(|(n, _)| n == "cash-flow.pdf"));
}
+20 -12
View File
@@ -1821,19 +1821,27 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
tally(reports::profit_and_loss_for_period(l, start, end, None).map(|_| ()));
tally(reports::general_ledger(l, start, end).map(|_| ()));
tally(reports::members_equity(l, start, end, &members).map(|_| ()));
tally(
reports::cash_flow(
l,
&reports::CashFlowParams {
start,
end,
cash_accounts: cash.clone(),
financing_accounts: vec![],
investing_accounts: vec![],
},
)
.map(|_| ()),
// The statement of cash flows foots on every book, or is refused
// for a reason: never an engine error (rules.md B-14 rule 9).
let cf = reports::cash_flow(
l,
&reports::CashFlowParams {
start,
end,
cash_accounts: cash.clone(),
financing_accounts: vec![],
investing_accounts: vec![],
members: members.clone(),
},
);
assert!(
!matches!(
cf,
Err(ledger_core::LedgerError::CashFlowDoesNotFoot { .. })
),
"{cf:?}"
);
tally(cf.map(|_| ()));
tally(ledger_core::k1::k1_worksheet(l, start, end, &members, &cash).map(|_| ()));
tally(ledger_core::tax_summary(l, start, end, None).map(|_| ()));
let _ = reports::cancelled_in_span(l, start, end);