P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
check / check (push) Canceled after 0s
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:
64 files changed
+12128
-1567
No files matched your search
@@ -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)},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)))),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user