From eb4329bb2a03b16fa66445dcb61f78dbc9dd7e1c Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 20 Oct 2026 18:10:47 -0600 Subject: [PATCH] P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42) 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 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- README.md | 5 +- bookspec/README.md | 181 + .../1099-worksheet-through-as-of.toml | 30 +- .../as-of-package-stops-at-as-of.toml | 33 +- .../examples/cash-flow.debt-to-equity.toml | 94 + ...cash-flow.paid-on-the-companys-behalf.toml | 187 + .../cash-flow.part-cash-purchase.toml | 90 + .../cash-flow.reversal-of-a-reversal.toml | 90 + ...h-flow.reversed-property-contribution.toml | 84 + .../cash-flow.two-member-llc-first-year.toml | 443 +++ .../examples/iif-quickbooks-journal-file.toml | 31 +- crates/ledger-config/src/lib.rs | 3 +- crates/ledger-core/src/cashflow.rs | 2056 +++++++++++ crates/ledger-core/src/error.rs | 23 + crates/ledger-core/src/kernel.rs | 349 +- crates/ledger-core/src/lib.rs | 19 +- crates/ledger-core/src/proofs.rs | 382 +- crates/ledger-core/src/reports.rs | 463 +-- crates/ledger-core/src/settings.rs | 6 +- crates/ledger-core/tests/bookspec.rs | 1261 +++++++ crates/ledger-core/tests/bookspec_members.rs | 398 --- crates/ledger-core/tests/invariants.rs | 80 +- .../tests/kernel_bridge.proptest-regressions | 2 + crates/ledger-core/tests/kernel_bridge.rs | 70 + .../tests/spec_cash_flow.proptest-regressions | 11 + crates/ledger-core/tests/spec_cash_flow.rs | 3126 +++++++++++++++++ crates/ledger-core/tests/spec_parties.rs | 6 +- .../tests/spec_reports.proptest-regressions | 11 + crates/ledger-core/tests/spec_reports.rs | 173 +- crates/ledger-core/tests/statements.rs | 166 +- crates/ledger-gui/src/attention.rs | 132 +- crates/ledger-gui/src/fmt.rs | 6 +- crates/ledger-gui/src/pages/reports.rs | 596 +++- crates/ledger-import/src/pdf.rs | 25 +- crates/ledger-import/src/report.rs | 147 +- crates/ledger-import/src/xlsx.rs | 11 + crates/ledger-import/templates/statement.typ | 12 +- crates/ledger-import/tests/asset_register.rs | 22 +- crates/ledger-import/tests/spec_import.rs | 182 +- crates/ledger-mcp/handbook/entries.md | 1 + crates/ledger-mcp/handbook/tracing-figures.md | 1 + crates/ledger-mcp/src/server.rs | 384 +- crates/ledger-mcp/src/types.rs | 191 +- crates/ledger-mcp/tests/mcp_stdio.rs | 90 +- crates/ledger-mcp/tests/oracle_mcp.rs | 290 +- crates/ledger-mcp/tests/spec_mcp.rs | 8 +- crates/ledger-package/src/package.rs | 26 +- crates/ledger-package/src/reports.rs | 29 +- crates/ledger-package/src/text.rs | 4 +- crates/ledger-package/tests/package.rs | 53 +- crates/ledger-store/tests/store.rs | 32 +- docs/capability-parity.md | 2 +- docs/mcp-interface.md | 72 +- docs/parity.md | 2 +- docs/requirements.md | 4 +- docs/spec/README.md | 11 +- docs/spec/rules.md | 350 +- docs/spec/sources/fasb-asc.md | 194 +- docs/testing.md | 162 +- docs/verification.md | 109 +- plan/served.md | 1 + verify/lean/Audit.lean | 18 + verify/lean/Wanderbooks/Ledger.lean | 74 + verify/lean/Wanderbooks/Statements.lean | 581 ++- 64 files changed, 12128 insertions(+), 1567 deletions(-) create mode 100644 bookspec/README.md create mode 100644 bookspec/examples/cash-flow.debt-to-equity.toml create mode 100644 bookspec/examples/cash-flow.paid-on-the-companys-behalf.toml create mode 100644 bookspec/examples/cash-flow.part-cash-purchase.toml create mode 100644 bookspec/examples/cash-flow.reversal-of-a-reversal.toml create mode 100644 bookspec/examples/cash-flow.reversed-property-contribution.toml create mode 100644 bookspec/examples/cash-flow.two-member-llc-first-year.toml create mode 100644 crates/ledger-core/src/cashflow.rs create mode 100644 crates/ledger-core/tests/bookspec.rs delete mode 100644 crates/ledger-core/tests/bookspec_members.rs create mode 100644 crates/ledger-core/tests/spec_cash_flow.proptest-regressions create mode 100644 crates/ledger-core/tests/spec_cash_flow.rs create mode 100644 crates/ledger-core/tests/spec_reports.proptest-regressions diff --git a/README.md b/README.md index 823133c..d4aa4ed 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ through MCP. Self-hosted, no rent. - **`ledger-core`** — the semantics engine. Exact decimal money, five account types, balanced journal entries, immutable posted log with - reversing entries, trial balance, P&L, balance sheet, indirect-method - cash flow, fixed-asset register with straight-line depreciation. + reversing entries, trial balance, P&L, balance sheet, the statement of + cash flows (direct method, with the reconciliation and noncash + schedules), fixed-asset register with straight-line depreciation. - **`ledger-store`** — durable storage. The source of truth is an append-only, hash-chained JSONL journal; SQLite is a disposable projection rebuilt from it; receipts live in a content-addressed blob diff --git a/bookspec/README.md b/bookspec/README.md new file mode 100644 index 0000000..42db595 --- /dev/null +++ b/bookspec/README.md @@ -0,0 +1,181 @@ +# bookspec: examples (seed) + +bookspec is the formal accounting specification planned in +`plan/served.md` (P13, D-34): every rule stated normatively, sourced to its +authority, with a symbolic form and concrete examples, independent of any +engine. Its own repository comes later. Until then this directory holds its +first examples: P3's regression tests, each written here as it is built, so +the spec starts from cases a real engine got wrong. + +Every example is checked against wanderbooks on every `cargo test` +(`crates/ledger-core/tests/bookspec.rs`, and for the formats appendix +`crates/ledger-package/tests/package.rs`): an example the engine disagrees +with fails the build, so the examples can't rot. An expected value is worked +by hand from the cited source, never copied from an engine's output. + +## The shape of an example + +One TOML file per example in `examples/`, named for its `id`. + +```toml +id = "cash-flow.reversed-property-contribution" # stable; never reused +title = "A property contribution reversed the same day" +rule = "One normative sentence: what MUST (or MUST NOT) hold." +status = "AUTHORITY" # AUTHORITY | CONVENTION | CHOICE +sources = ["ASC 230-10-45-24", "ASC 230-10-50-3"] # exact citations +tags = ["accrual", "book", "us"] # applicability + +[setup] +currency = "USD" # ISO 4217 +fiscal_year_start = "01-01" # MM-DD +basis = "accrual" # accrual | cash +rounding = "half-even" # half-even | half-away-from-zero | + # largest-remainder: how a figure is + # rounded to the currency's minor unit, + # where a rule rounds (none here) + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" # asset | liability | equity | revenue | expense + +[[events]] # in order; event N is entry #N +date = "2026-01-10" +description = "Property contributed" +lines = [ + { account = "1500", side = "debit", amount = "2340.00" }, + { account = "3000", side = "credit", amount = "2340.00" }, +] + +[[events]] +date = "2026-01-10" +description = "Reverse entry 1" +reverses = 1 # the event (1-based) this one reverses: + # its lines, sides flipped; no `lines` here + +[[expect]] +query = "cash_flow" # what is asked of the books (see below) +from = "2026-01-01" # inclusive +to = "2026-12-31" # inclusive (a query as of a day: as_of) + +[expect.values] # every value an exact decimal string +operating = "0.00" +``` + +Events count from 1, as entries do: the first `[[events]]` is event 1 +(entry #1), and `reverses = N` reverses the Nth event, one before it; +`reverses = 0` names no event and is refused. An event is an entry +(`lines`), a reversal (`reverses`) or a close (`close`, below), exactly +one of them, and posts entry #N. A key the shape doesn't have is refused +too, never ignored. + +`rounding` names the rule the example's figures are rounded by where one +rounds: `half-even` or `half-away-from-zero` to the currency's minor unit, +or `largest-remainder` for an amount split among parts (each part rounded +down, the units left over to the largest remainders, ties to the part +listed first; rules.md B-13). An example that closes to the members +declares `largest-remainder`, the rule a members close splits by. + +Required everywhere: the top-level keys, `[setup]` with its four keys and +its accounts, the events, and at least one `[[expect]]` with values: a +figure query's are exact decimal strings (`"2340.00"`, `"-12.50"`; never a +float), a format query's are text (below). Amounts on lines are positive; +the side gives the direction. + +### Optional keys, for the rules that need them + +- `appendix = "formats"` (top level): the example shows an interchange + format, not an accounting rule (P13's appendix of formats; the IIF + file today). It loads like any other; only it may ask a format query. +- `parent` on an account: its parent account's code. A parent is declared + before its subaccounts. +- `cash_accounts`, `financing_accounts`, `investing_accounts`: arrays of + account codes, the statement of cash flows' account roles (ASC + 230-10-45-10 to 45-15). The cash and financing accounts are also what a + payee is paid from, for `vendor_1099_paid` (a payment card is neither: + its charges go on Form 1099-K; rules.md B-19), and the cash accounts + are an IIF file's bank accounts (B-34). +- `[[setup.fixed_assets]]` `{ cost_account, accumulated_account, + expense_account }`: accounts that hold property and equipment (their + cost and accumulated depreciation are investing; ASC 230-10-45-12(c), + 45-13(c)). +- `[[setup.members]]` `{ name, capital_account, share? }`: the members of + a multi-member LLC. `share` is the member's share of income, an exact + decimal fraction (`"0.5"`, `"0.3333"`); every member has one or none + does, and the shares sum to 1. A close to the members and + `members_equity` need them. +- `[[setup.payees]]` `{ name, form }`: a payee marked for an information + return, `form` `"1099-NEC"` or `"1099-MISC"`; each name once. +- `payee` on an event: the payee (by name, one of `setup.payees`) the + entry paid. Not on a reversal, which takes its original's, nor on a + close. +- `close = { start, to, rounding? }` as an event: closes the period from + `start` through the event's `date` (rules.md B-11, B-13), `to` + `"members"` (split by share, in the order listed) or + `"retained_earnings"`. A close to the members splits cumulatively, each + close giving each member their split of everything the members' closes + have closed less what earlier closes gave them; `rounding = "per-close"` + splits that close's net on its own instead (books from an engine that + rounded each close by itself). A close must have something to close: it + posts entry #N like any other event. +- `after = N` on an expectation: asked of the books after event N, not + after the last (1 to the number of events; not on a format query). + +### Queries and their values + +A query over a period takes `from` and `to`; a query as of a day takes +`as_of`; the other is refused. A query no crate answers is refused, never +skipped. + +| query | dates | values | checked by | +|---|---|---|---| +| `cash_flow` | `from`, `to` | figures (below) | `ledger-core` `tests/bookspec.rs` | +| `trial_balance` | `as_of` | each account's balance by code, debits less credits (a credit balance negative), every account listed | `ledger-core` `tests/bookspec.rs` | +| `profit_and_loss` | `from`, `to` | each revenue and expense account's activity by code, on its normal side, closing entries left out, every one listed; `net_income`, revenue less expenses | `ledger-core` `tests/bookspec.rs` | +| `entry` | `entry = N`, no dates | entry N's lines, `".debit"` and `".credit"` each the sum of that account's lines on that side, every line of the entry named | `ledger-core` `tests/bookspec.rs` | +| `balances` | `as_of` | each account named by code, its balance on its normal side (a member's capital after a loss is negative); the example names the accounts it shows | `ledger-core` `tests/bookspec.rs` | +| `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `".beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | +| `vendor_1099_paid` | `from` (January 1 of `to`'s year), `to` | each payee's payments by name, out of the cash and financing accounts (`cash_accounts` required), every payee listed | `ledger-core` `tests/bookspec.rs` | +| `iif_lines` | `as_of` | formats appendix: the file's lines in order, each its fields joined by tabs, without the CR LF that ends each | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | +| `iif_changes` | `as_of` | formats appendix: each text the file writes differently from the books, in file order, as `[what, books' text, file's text]` (the example's header gives `what`) | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | + +A figure query is a table (`[expect.values]`, or `values = { ... }`); a +format query is an array (`values = [ ... ]`). + +`cash_flow` (rules.md B-14): the statement of cash flows for `[from, to]`. + +| key | the value | +|---|---| +| `operating`, `investing`, `financing` | each section's net cash, + in | +| `net_change`, `cash_beginning`, `cash_ending` | the cash accounts' change and balances | +| `difference` | sections less the change in cash: always `"0.00"` | +| `net_income` | revenue less expenses, closing entries excluded | +| `
.` | entry N's cash in that section (`financing.4`) | +| `
..on_behalf` | of it, what a member or a lender paid or received for the company, no cash account moving (constructive receipt and disbursement) | +| `
.count` | how many entries the section lists | +| `noncash.` | entry N's noncash investing and financing disclosure | +| `noncash.count` | how many entries are disclosed | +| `reconciliation.depreciation` | depreciation added back | +| `reconciliation.investing_gain_loss` | gains and losses whose cash is investing, taken out | +| `reconciliation.noncash_through.` | operating movements no cash settled, through that account | +| `reconciliation.other_noncash` | other operating movements no cash settled | +| `reconciliation.balance_change.` | minus the change in an operating asset or liability | + +An entry named in a `
.` (or `.on_behalf`) or `noncash.` +key must be the only such entries when `
.count` or +`noncash.count` is given too; a +reconciliation key not given must be zero or absent. + +Every example, in any appendix, is loaded by +`crates/ledger-core/tests/bookspec.rs` (`every_bookspec_example_holds`): +its shape (a key the shape doesn't have is refused), its setup, and every +event posted. Each query is then checked by the crate that answers it (the +table above), so the shape never rots either. + +## Licence and sources + +The examples state rules in their own words with the paragraph cited +(FASB's Codification is copyrighted: cited, never copied; see +`docs/spec/sources/fasb-asc.md`). The examples are MIT OR Apache-2.0, as +the rest of this repository, until bookspec moves to its own repository +(planned: the spec under CC BY 4.0). diff --git a/bookspec/examples/1099-worksheet-through-as-of.toml b/bookspec/examples/1099-worksheet-through-as-of.toml index cb930ef..4d17f2d 100644 --- a/bookspec/examples/1099-worksheet-through-as-of.toml +++ b/bookspec/examples/1099-worksheet-through-as-of.toml @@ -6,13 +6,13 @@ # rules.md B-19, B-33; regression test `the_1099_worksheet_stops_at_as_of` # (ledger-package tests/package.rs). # -# Setup beyond the accounts: `cash_accounts` are the accounts payments -# are made from; each `[[setup.payees]]` is a payee marked for a form; -# an event's `payee` tags the payment to them. Expected values: -# vendor_1099_paid gives each marked payee's payments counted from `from` -# through `to` (both in one calendar year). The 2026 threshold is $2,000 -# (B-19), so the payee is not yet reportable as of June 30 (1400.00) and -# is for the year (3050.00). +# Setup beyond the accounts (bookspec/README.md): `cash_accounts` are the +# accounts payments are made from; each `[[setup.payees]]` is a payee +# marked for a form; an event's `payee` tags the payment to them. Event N +# is entry N. Expected values: vendor_1099_paid gives each marked payee's +# payments counted from `from`, January 1, through `to`, every payee +# listed. The 2026 threshold is $2,000 (B-19), so the payee is not yet +# reportable as of June 30 (1400.00) and is for the year (3050.00). id = "1099-worksheet-through-as-of" title = "A 1099 worksheet as of a day counts the year's payments through that day" @@ -29,7 +29,7 @@ tags = ["1099", "as-of", "calendar-year", "information-return", "period-boundary currency = "USD" fiscal_year_start = "01-01" basis = "cash" -rounding = "half_away_from_zero" +rounding = "half-away-from-zero" cash_accounts = ["1000"] [[setup.accounts]] @@ -56,7 +56,7 @@ type = "expense" name = "Contractor A" form = "1099-NEC" -# 0: the year before. +# Entry 1: the year before. [[events]] date = "2025-12-31" description = "Contract work, December" @@ -66,7 +66,7 @@ lines = [ { account = "1000", side = "credit", amount = "100.00" }, ] -# 1 +# Entry 2. [[events]] date = "2026-01-02" description = "Owner contribution" @@ -75,7 +75,7 @@ lines = [ { account = "3000", side = "credit", amount = "10000.00" }, ] -# 2 +# Entry 3. [[events]] date = "2026-03-15" description = "Contract work, March" @@ -85,7 +85,7 @@ lines = [ { account = "1000", side = "credit", amount = "900.00" }, ] -# 3: by card, so reported on Form 1099-K, never counted here. +# Entry 4: by card, so reported on Form 1099-K, never counted here. [[events]] date = "2026-06-10" description = "Contract work, June, by card" @@ -95,7 +95,7 @@ lines = [ { account = "2200", side = "credit", amount = "300.00" }, ] -# 4: made on the day itself, so counted. +# Entry 5: made on the day itself, so counted. [[events]] date = "2026-06-30" description = "Contract work, June" @@ -105,7 +105,7 @@ lines = [ { account = "1000", side = "credit", amount = "500.00" }, ] -# 5: the day after. +# Entry 6: the day after. [[events]] date = "2026-07-01" description = "Contract work, July" @@ -115,7 +115,7 @@ lines = [ { account = "1000", side = "credit", amount = "700.00" }, ] -# 6 +# Entry 7. [[events]] date = "2026-11-20" description = "Contract work, November" diff --git a/bookspec/examples/as-of-package-stops-at-as-of.toml b/bookspec/examples/as-of-package-stops-at-as-of.toml index 4a19df7..0994451 100644 --- a/bookspec/examples/as-of-package-stops-at-as-of.toml +++ b/bookspec/examples/as-of-package-stops-at-as-of.toml @@ -6,10 +6,11 @@ # `every_file_in_an_as_of_package_stops_at_as_of` and # `as_of_package_iif_stops_at_as_of` (ledger-package tests/package.rs). # -# Expected values: trial_balance gives each account's balance, debit -# positive and credit negative, every account listed; profit_and_loss -# gives each revenue and expense account's activity for the period as a -# positive amount, and net_income (revenue less expenses). +# Expected values (bookspec/README.md): trial_balance gives each +# account's balance as of the day, debit positive and credit negative, +# every account listed; profit_and_loss gives each revenue and expense +# account's activity for the period on its normal side, every one +# listed, and net_income (revenue less expenses). id = "as-of-package-stops-at-as-of" title = "A report as of a day leaves out what is dated after it" @@ -25,7 +26,7 @@ tags = ["as-of", "trial-balance", "profit-and-loss", "reversal", "period-boundar currency = "USD" fiscal_year_start = "01-01" basis = "accrual" -rounding = "half_away_from_zero" +rounding = "half-away-from-zero" [[setup.accounts]] code = "1000" @@ -47,7 +48,7 @@ code = "6000" name = "Rent" type = "expense" -# 0 +# Entry 1. [[events]] date = "2026-01-02" description = "Owner contribution" @@ -56,7 +57,7 @@ lines = [ { account = "3000", side = "credit", amount = "10000.00" }, ] -# 1 +# Entry 2. [[events]] date = "2026-02-01" description = "Sale" @@ -65,7 +66,7 @@ lines = [ { account = "4000", side = "credit", amount = "4000.00" }, ] -# 2: dated the day itself, so in. +# Entry 3: dated the day itself, so in. [[events]] date = "2026-06-30" description = "June rent" @@ -74,7 +75,7 @@ lines = [ { account = "1000", side = "credit", amount = "1200.00" }, ] -# 3: the day after, so out of anything as of 2026-06-30. +# Entry 4: the day after, so out of anything as of 2026-06-30. [[events]] date = "2026-07-01" description = "July rent" @@ -83,20 +84,15 @@ lines = [ { account = "1000", side = "credit", amount = "1200.00" }, ] -# 4: a reversal dated after the day leaves its original in. +# Entry 5: a reversal dated after the day leaves its original in. [[events]] date = "2026-07-15" description = "Reversal of the February sale" -reverses = 1 -lines = [ - { account = "4000", side = "debit", amount = "4000.00" }, - { account = "1000", side = "credit", amount = "4000.00" }, -] +reverses = 2 [[expect]] query = "trial_balance" -from = "2026-01-01" -to = "2026-06-30" +as_of = "2026-06-30" values = { "1000" = "12800.00", "3000" = "-10000.00", "4000" = "-4000.00", "6000" = "1200.00" } [[expect]] @@ -108,8 +104,7 @@ values = { "4000" = "4000.00", "6000" = "1200.00", net_income = "2800.00" } # The same books a month later: the July rent and the reversal are in. [[expect]] query = "trial_balance" -from = "2026-01-01" -to = "2026-07-31" +as_of = "2026-07-31" values = { "1000" = "7600.00", "3000" = "-10000.00", "4000" = "0.00", "6000" = "2400.00" } [[expect]] diff --git a/bookspec/examples/cash-flow.debt-to-equity.toml b/bookspec/examples/cash-flow.debt-to-equity.toml new file mode 100644 index 0000000..d170594 --- /dev/null +++ b/bookspec/examples/cash-flow.debt-to-equity.toml @@ -0,0 +1,94 @@ +id = "cash-flow.debt-to-equity" +title = "An expense a member paid personally, then the member's advance converted to capital" +rule = "An expense a member pays for the company on credit to the member MUST be shown as cash borrowed from the member and spent, on its date; converting that debt to equity MUST be disclosed as a noncash financing activity, not reported as a cash flow, though both its accounts are financing." +# CHOICE: entry 1. ASC 230 doesn't address a payment a third party makes +# on the entity's behalf; the firm guides present it by substance as a +# constructive receipt and disbursement (a financing inflow and an +# operating outflow), and the owner chose that (wanderbooks D-42, revising +# D-35's noncash presentation). Entry 2's disclosure is 50-4's own example. +status = "CHOICE" +sources = [ + "ASC 230-10-45-14(b)", + "ASC 230-10-45-17(b)", + "ASC 230-10-50-3", + "ASC 230-10-50-4", + "PwC Viewpoint, Financial statement presentation 6.9.3.1 (ASC 230 does not address cash a third party receives or pays on the entity's behalf; include it as though through the entity's accounts)", + "Deloitte Roadmap, Statement of cash flows 7.2 (constructive receipt and disbursement)", + "BDO Blueprint, Statement of cash flows under ASC 230 (December 2025) 3.4", + "wanderbooks D-42 (owner, 2026-10-20: a member or lender paying for the company is cash borrowed and spent)", +] +tags = ["accrual", "book", "us", "cash-flow", "noncash", "constructive", "partnership"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "2100" +name = "Due to members" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Member A" +type = "equity" + +[[setup.accounts]] +code = "6000" +name = "Supplies" +type = "expense" + +[[setup.members]] +name = "Member A" +capital_account = "3000" + +[[events]] +date = "2026-02-03" +description = "Supplies, paid by Member A personally" +lines = [ + { account = "6000", side = "debit", amount = "500.00" }, + { account = "2100", side = "credit", amount = "500.00" }, +] + +[[events]] +date = "2026-02-10" +description = "Member A's advance converted to capital" +lines = [ + { account = "2100", side = "debit", amount = "500.00" }, + { account = "3000", side = "credit", amount = "500.00" }, +] + +# By hand: no company cash moved. Entry 1 is borrowed from the member +# (financing +500.00, 45-14(b)) and spent (operating -500.00, class (d), +# 45-17(b)) on 2026-02-03, all of it on the company's behalf; net income +# -500.00 needs no reconciling item. Entry 2 converts debt to equity, 50-4's +# first example: disclosed 500.00, though both its accounts are financing. +[[expect]] +query = "cash_flow" +from = "2026-02-01" +to = "2026-02-28" + +[expect.values] +operating = "-500.00" +investing = "0.00" +financing = "500.00" +net_change = "0.00" +difference = "0.00" +net_income = "-500.00" +"operating.1" = "-500.00" +"operating.1.on_behalf" = "-500.00" +"operating.count" = "1" +"financing.1" = "500.00" +"financing.1.on_behalf" = "500.00" +"financing.count" = "1" +"noncash.2" = "500.00" +"noncash.count" = "1" diff --git a/bookspec/examples/cash-flow.paid-on-the-companys-behalf.toml b/bookspec/examples/cash-flow.paid-on-the-companys-behalf.toml new file mode 100644 index 0000000..6757237 --- /dev/null +++ b/bookspec/examples/cash-flow.paid-on-the-companys-behalf.toml @@ -0,0 +1,187 @@ +id = "cash-flow.paid-on-the-companys-behalf" +title = "A member and a lender pay for the company directly; the member is repaid" +rule = "When a member or a lender pays a company expense or purchase directly, with no cash account moving, the statement of cash flows MUST show a financing inflow (borrowed from the member or lender, or contributed when the member's capital is credited for an expense) and the operating or investing outflow, both on that payment's date; repaying the member or lender MUST be a financing outflow; such a payment and its reversal MUST net out together." +# CHOICE: ASC 230 does not address cash a third party pays on the entity's +# behalf. The firm guides present it by substance as a constructive +# receipt and disbursement (PwC 6.9.3.1; Deloitte 7.2, Example 7-2, a +# lender paying an insurer; BDO 3.4, Example 3-5, a bank paying the +# supplier of equipment, and a parent paying a subsidiary's expenses), +# and the owner chose that (wanderbooks D-42). KPMG also accepts a noncash +# presentation for equipment a lender pays for. +status = "CHOICE" +sources = [ + "ASC 230-10-45-13(c)", + "ASC 230-10-45-14(a)", + "ASC 230-10-45-14(b)", + "ASC 230-10-45-15(b)", + "ASC 230-10-45-17(b)", + "PwC Viewpoint, Financial statement presentation 6.9.3.1 (cash a third party receives or pays on the entity's behalf is included as though through the entity's accounts)", + "Deloitte Roadmap, Statement of cash flows 7.2 (constructive receipt and disbursement; Example 7-2)", + "BDO Blueprint, Statement of cash flows under ASC 230 (December 2025) 3.4 and Example 3-5", + "EY Financial reporting developments, Statement of cash flows (July 2026) 4.6.2 and 5.3.1", + "wanderbooks D-42 (owner, 2026-10-20: a member or lender paying for the company is cash borrowed and spent)", +] +tags = ["accrual", "book", "us", "cash-flow", "constructive", "partnership"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +financing_accounts = ["2100", "2500"] + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated depreciation" +type = "asset" + +[[setup.accounts]] +code = "2100" +name = "Due to members" +type = "liability" + +[[setup.accounts]] +code = "2500" +name = "Bank loan" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Member A" +type = "equity" + +[[setup.accounts]] +code = "6000" +name = "Supplies" +type = "expense" + +[[setup.accounts]] +code = "6100" +name = "Depreciation" +type = "expense" + +[[setup.fixed_assets]] +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6100" + +[[setup.members]] +name = "Member A" +capital_account = "3000" + +[[events]] +date = "2026-03-02" +description = "Member A contributes cash" +lines = [ + { account = "1000", side = "debit", amount = "1000.00" }, + { account = "3000", side = "credit", amount = "1000.00" }, +] + +[[events]] +date = "2026-03-05" +description = "Supplies, paid by Member A personally (owed back)" +lines = [ + { account = "6000", side = "debit", amount = "120.00" }, + { account = "2100", side = "credit", amount = "120.00" }, +] + +[[events]] +date = "2026-03-09" +description = "Equipment, paid to the seller by the bank (a loan)" +lines = [ + { account = "1500", side = "debit", amount = "2400.00" }, + { account = "2500", side = "credit", amount = "2400.00" }, +] + +[[events]] +date = "2026-03-12" +description = "Supplies, paid by Member A personally (contributed)" +lines = [ + { account = "6000", side = "debit", amount = "45.00" }, + { account = "3000", side = "credit", amount = "45.00" }, +] + +[[events]] +date = "2026-03-15" +description = "Supplies, paid by Member A personally (posted in error)" +lines = [ + { account = "6000", side = "debit", amount = "30.00" }, + { account = "2100", side = "credit", amount = "30.00" }, +] + +[[events]] +date = "2026-03-16" +description = "Reverse entry 5" +reverses = 5 + +[[events]] +date = "2026-03-20" +description = "Reimburse Member A" +lines = [ + { account = "2100", side = "debit", amount = "120.00" }, + { account = "1000", side = "credit", amount = "120.00" }, +] + +[[events]] +date = "2026-03-28" +description = "Loan payment" +lines = [ + { account = "2500", side = "debit", amount = "200.00" }, + { account = "1000", side = "credit", amount = "200.00" }, +] + +# By hand, March. Operating: entry 2, -120.00, and entry 4, -45.00 (cash +# paid to suppliers, 45-17(b), by the member for the company): -165.00; +# entries 5 and 6 net out. Investing: entry 3, -2,400.00 (equipment, +# 45-13(c), paid by the bank). Financing: entry 1 +1,000.00 and entry 4 +# +45.00 (contributions, 45-14(a)), entry 2 +120.00 and entry 3 +2,400.00 +# (borrowing, 45-14(b)), entry 7 -120.00 and entry 8 -200.00 (repayments, +# 45-15(b)): +3,245.00. Sum -165.00 - 2,400.00 + 3,245.00 = +680.00, the +# change in cash (1,000.00 - 120.00 - 200.00). Net income -165.00 equals +# operating: no reconciling item. Nothing is disclosed: no part of any +# entry went unsettled. +[[expect]] +query = "cash_flow" +from = "2026-03-01" +to = "2026-03-31" + +[expect.values] +operating = "-165.00" +investing = "-2400.00" +financing = "3245.00" +net_change = "680.00" +cash_beginning = "0.00" +cash_ending = "680.00" +difference = "0.00" +net_income = "-165.00" +"operating.2" = "-120.00" +"operating.2.on_behalf" = "-120.00" +"operating.4" = "-45.00" +"operating.count" = "2" +"investing.3" = "-2400.00" +"investing.3.on_behalf" = "-2400.00" +"investing.count" = "1" +"financing.1" = "1000.00" +"financing.1.on_behalf" = "0.00" +"financing.2" = "120.00" +"financing.2.on_behalf" = "120.00" +"financing.3" = "2400.00" +"financing.3.on_behalf" = "2400.00" +"financing.4" = "45.00" +"financing.4.on_behalf" = "45.00" +"financing.7" = "-120.00" +"financing.7.on_behalf" = "0.00" +"financing.8" = "-200.00" +"financing.count" = "6" +"noncash.count" = "0" diff --git a/bookspec/examples/cash-flow.part-cash-purchase.toml b/bookspec/examples/cash-flow.part-cash-purchase.toml new file mode 100644 index 0000000..3fc8ea4 --- /dev/null +++ b/bookspec/examples/cash-flow.part-cash-purchase.toml @@ -0,0 +1,90 @@ +id = "cash-flow.part-cash-purchase" +title = "Equipment bought part for cash, part on the seller's note" +rule = "Of a transaction that is part cash and part noncash, only the cash portion MUST be reported in the statement of cash flows; the rest MUST be disclosed as a noncash investing and financing activity." +status = "AUTHORITY" +sources = ["ASC 230-10-50-5", "ASC 230-10-50-3", "ASC 230-10-50-4", "ASC 230-10-45-13(c)", "EY Financial reporting developments, Statement of cash flows (July 2026) 5.3.1 (seller-financed purchases are noncash to the extent of the seller's financing)"] +tags = ["accrual", "book", "us", "cash-flow", "noncash"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +# The note is the seller's own credit, not a lender's: it is not listed as +# financing (a lender that paid the seller would be: the purchase would +# then be borrowed and invested, cash-flow.paid-on-the-companys-behalf). + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "1520" +name = "Vehicles" +type = "asset" + +[[setup.accounts]] +code = "1530" +name = "Accumulated depreciation, vehicles" +type = "asset" + +[[setup.accounts]] +code = "2500" +name = "Note payable to the seller" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6100" +name = "Depreciation" +type = "expense" + +[[setup.fixed_assets]] +cost_account = "1520" +accumulated_account = "1530" +expense_account = "6100" + +[[events]] +date = "2026-01-02" +description = "Opening contribution" +lines = [ + { account = "1000", side = "debit", amount = "10000.00" }, + { account = "3000", side = "credit", amount = "10000.00" }, +] + +[[events]] +date = "2026-02-08" +description = "Vehicle: 300 down, 700 on the seller's note" +lines = [ + { account = "1520", side = "debit", amount = "1000.00" }, + { account = "1000", side = "credit", amount = "300.00" }, + { account = "2500", side = "credit", amount = "700.00" }, +] + +# By hand: 300 left the bank for the vehicle (investing, 45-13(c)); the +# 700 the seller financed moved no cash, to anyone, and is disclosed (50-4: +# acquiring an asset by assuming a directly related liability). Cash +# 10,000.00 to 9,700.00 in February. +[[expect]] +query = "cash_flow" +from = "2026-02-01" +to = "2026-02-28" + +[expect.values] +operating = "0.00" +investing = "-300.00" +financing = "0.00" +net_change = "-300.00" +cash_beginning = "10000.00" +cash_ending = "9700.00" +difference = "0.00" +"investing.2" = "-300.00" +"investing.count" = "1" +"noncash.2" = "700.00" +"noncash.count" = "1" diff --git a/bookspec/examples/cash-flow.reversal-of-a-reversal.toml b/bookspec/examples/cash-flow.reversal-of-a-reversal.toml new file mode 100644 index 0000000..abbf567 --- /dev/null +++ b/bookspec/examples/cash-flow.reversal-of-a-reversal.toml @@ -0,0 +1,90 @@ +id = "cash-flow.reversal-of-a-reversal" +title = "A contribution, its reversal and the reversal's reversal" +rule = "Reversals MUST pair along the chain from its root: an entry and its reversal both in the period drop out together, and a reversal of a reversal whose partner is already paired MUST be reported, as its root would be." +status = "AUTHORITY" +sources = [ + "ASC 230-10-45-14(a)", + "ASC 230-10-45-24", + "Pacioli, Summa (1494), ch. 31 (the correcting entry)", +] +tags = ["accrual", "book", "us", "cash-flow", "reversal"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3001" +name = "Capital — Member B" +type = "equity" + +[[setup.members]] +name = "Member B" +capital_account = "3001" + +[[events]] +date = "2026-01-20" +description = "Opening contribution" +lines = [ + { account = "1000", side = "debit", amount = "1000.00" }, + { account = "3001", side = "credit", amount = "1000.00" }, +] + +[[events]] +date = "2026-02-02" +description = "Contribution — Member B" +lines = [ + { account = "1000", side = "debit", amount = "300.00" }, + { account = "3001", side = "credit", amount = "300.00" }, +] + +[[events]] +date = "2026-02-03" +description = "Reverse entry 2" +reverses = 2 + +[[events]] +date = "2026-02-04" +description = "Reverse entry 3: the contribution stands" +reverses = 3 + +# By hand, February: entries 2 and 3 pair (the root and its reversal); +# entry 4 reinstates the contribution and is reported: financing +300.00, +# the change in cash. +[[expect]] +query = "cash_flow" +from = "2026-02-01" +to = "2026-02-28" + +[expect.values] +financing = "300.00" +net_change = "300.00" +cash_beginning = "1000.00" +cash_ending = "1300.00" +difference = "0.00" +"financing.4" = "300.00" +"financing.count" = "1" +"noncash.count" = "0" + +# From February 3 the root is before the period: entries 3 and 4 pair, and +# nothing is reported (no cash moved net, 1,300.00 to 1,300.00). +[[expect]] +query = "cash_flow" +from = "2026-02-03" +to = "2026-02-28" + +[expect.values] +financing = "0.00" +net_change = "0.00" +cash_beginning = "1300.00" +cash_ending = "1300.00" +difference = "0.00" +"financing.count" = "0" diff --git a/bookspec/examples/cash-flow.reversed-property-contribution.toml b/bookspec/examples/cash-flow.reversed-property-contribution.toml new file mode 100644 index 0000000..97ccd5a --- /dev/null +++ b/bookspec/examples/cash-flow.reversed-property-contribution.toml @@ -0,0 +1,84 @@ +id = "cash-flow.reversed-property-contribution" +title = "A property contribution reversed the same day leaves the statement of cash flows empty" +rule = "An entry and its reversal both in the period MUST contribute nothing to any section or disclosure, and the sections MUST sum to the change in cash." +status = "AUTHORITY" +sources = [ + "ASC 230-10-45-1", + "ASC 230-10-45-24", + "ASC 230-10-50-3", + "Pacioli, Summa (1494), ch. 31 (the correcting entry)", +] +tags = ["accrual", "book", "us", "cash-flow", "reversal", "regression:p3-defect-1"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated depreciation" +type = "asset" + +[[setup.accounts]] +code = "6100" +name = "Depreciation" +type = "expense" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.fixed_assets]] +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6100" + +[[events]] +date = "2026-01-10" +description = "Property contributed" +lines = [ + { account = "1500", side = "debit", amount = "2340.00" }, + { account = "3000", side = "credit", amount = "2340.00" }, +] + +[[events]] +date = "2026-01-10" +description = "Reverse entry 1" +reverses = 1 + +# By hand: no cash moved (sections 0.00, change in cash 0.00), and the +# entry and its reversal cancel, so nothing is disclosed either. Before the +# fix (wanderbooks P3, defect 1) investing showed -2,340.00 against a +# change of 0.00: the reversal's credit to equipment fell in no section. +[[expect]] +query = "cash_flow" +from = "2026-01-01" +to = "2026-12-31" + +[expect.values] +operating = "0.00" +investing = "0.00" +financing = "0.00" +net_change = "0.00" +cash_beginning = "0.00" +cash_ending = "0.00" +difference = "0.00" +net_income = "0.00" +"operating.count" = "0" +"investing.count" = "0" +"financing.count" = "0" +"noncash.count" = "0" diff --git a/bookspec/examples/cash-flow.two-member-llc-first-year.toml b/bookspec/examples/cash-flow.two-member-llc-first-year.toml new file mode 100644 index 0000000..47a877c --- /dev/null +++ b/bookspec/examples/cash-flow.two-member-llc-first-year.toml @@ -0,0 +1,443 @@ +id = "cash-flow.two-member-llc-first-year" +title = "A two-member LLC's first weeks: contributions, member-paid expenses, property contributed, corrections" +rule = "The statement of cash flows MUST report cash receipts and payments, by section, with each reversal netted against its original in the period; expenses members paid personally on credit to them MUST be shown as cash borrowed from the members and spent, on each payment's date; property the members contributed MUST be disclosed as a noncash activity; the sections MUST sum to the change in cash." +# CHOICE: the member-paid expenses. ASC 230 doesn't address a payment made +# on the entity's behalf; the firm guides present it by substance as a +# constructive receipt and disbursement (a financing inflow and an +# operating outflow), and the owner chose that (D-42, revising D-35's +# noncash presentation). The rest is the Codification's. +status = "CHOICE" +sources = [ + "ASC 230-10-10-1", + "ASC 230-10-45-14(a)", + "ASC 230-10-45-14(b)", + "ASC 230-10-45-15(b)", + "ASC 230-10-45-17(b)", + "ASC 230-10-45-24", + "ASC 230-10-45-28", + "ASC 230-10-45-30", + "ASC 230-10-50-3", + "ASC 230-10-50-4", + "PwC Viewpoint, Financial statement presentation 6.9.3.1 (cash a third party receives or pays on the entity's behalf is included as though through the entity's accounts)", + "Deloitte Roadmap, Statement of cash flows 7.2 (constructive receipt and disbursement)", + "BDO Blueprint, Statement of cash flows under ASC 230 (December 2025) 3.4 (a parent paying a subsidiary's expenses)", + "wanderbooks D-42 (owner, 2026-10-20: a member or lender paying for the company is cash borrowed and spent)", +] +tags = ["accrual", "book", "us", "cash-flow", "partnership", "constructive", "regression:p3-defect-1"] + +# A two-member LLC's first weeks, in the shape of the books P3 defect 1 +# was found in: the same 23 entries in the same order, on the same +# accounts and roles (two contributions in cash, expenses the members paid +# personally through Due to Members and two reimbursements, a domain +# expensed then corrected to a prepaid, two card charges, four devices the +# members owned contributed, two of them first entered wrongly and +# reversed), with every amount and date this example's own. Before the fix +# the statement didn't foot: the reversals' credits to equipment (#13, +# #18) belonged to no section, and the contributed devices read as +# purchases. + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "1300" +name = "Prepaid Expenses" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "2050" +name = "Business Card" +type = "liability" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Member A" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital — Member B" +type = "equity" + +[[setup.accounts]] +code = "6000" +name = "Organizational Costs" +type = "expense" + +[[setup.accounts]] +code = "6050" +name = "Domain & Hosting" +type = "expense" + +[[setup.accounts]] +code = "6200" +name = "Developer Fees" +type = "expense" + +[[setup.accounts]] +code = "6300" +name = "Software & Subscriptions" +type = "expense" + +[[setup.accounts]] +code = "6400" +name = "Memberships & Licenses" +type = "expense" + +[[setup.accounts]] +code = "6500" +name = "Depreciation Expense" +type = "expense" + +[[setup.fixed_assets]] +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6500" + +[[setup.members]] +name = "Member A" +capital_account = "3000" + +[[setup.members]] +name = "Member B" +capital_account = "3001" + +# Entry 1. +[[events]] +date = "2026-03-02" +description = "Formation filing fee, paid by the members" +lines = [ + { account = "6000", side = "debit", amount = "85.00" }, + { account = "2100", side = "credit", amount = "85.00" }, +] + +# Entry 2. +[[events]] +date = "2026-03-03" +description = "Domain, 1 year, paid by the members" +lines = [ + { account = "6050", side = "debit", amount = "12.97" }, + { account = "2100", side = "credit", amount = "12.97" }, +] + +# Entry 3. +[[events]] +date = "2026-03-05" +description = "Developer registration, paid by the members" +lines = [ + { account = "6200", side = "debit", amount = "99.00" }, + { account = "2100", side = "credit", amount = "99.00" }, +] + +# Entry 4. +[[events]] +date = "2026-03-09" +description = "Contribution — Member A" +lines = [ + { account = "1000", side = "debit", amount = "1200.00" }, + { account = "3000", side = "credit", amount = "1200.00" }, +] + +# Entry 5. +[[events]] +date = "2026-03-10" +description = "Contribution — Member B" +lines = [ + { account = "1000", side = "debit", amount = "800.00" }, + { account = "3001", side = "credit", amount = "800.00" }, +] + +# Entry 6. +[[events]] +date = "2026-03-11" +description = "Subscription top-up, paid by the members" +lines = [ + { account = "6300", side = "debit", amount = "7.49" }, + { account = "2100", side = "credit", amount = "7.49" }, +] + +# Entry 7. +[[events]] +date = "2026-03-16" +description = "Developer membership, paid by the members" +lines = [ + { account = "6400", side = "debit", amount = "129.00" }, + { account = "2100", side = "credit", amount = "129.00" }, +] + +# Entry 8. +[[events]] +date = "2026-03-23" +description = "Reimburse members" +lines = [ + { account = "2100", side = "debit", amount = "333.46" }, + { account = "1000", side = "credit", amount = "333.46" }, +] + +# Entry 9. +[[events]] +date = "2026-03-20" +description = "Domain, 2 years, paid by the members" +lines = [ + { account = "6050", side = "debit", amount = "46.38" }, + { account = "2100", side = "credit", amount = "46.38" }, +] + +# Entry 10. +[[events]] +date = "2026-03-25" +description = "API credits on the card" +lines = [ + { account = "6300", side = "debit", amount = "14.20" }, + { account = "2050", side = "credit", amount = "14.20" }, +] + +# Entry 11. +[[events]] +date = "2026-03-26" +description = "Reimburse members" +lines = [ + { account = "2100", side = "debit", amount = "46.38" }, + { account = "1000", side = "credit", amount = "46.38" }, +] + +# Entry 12. +[[events]] +date = "2026-03-27" +description = "Laptop contributed by Member A" +lines = [ + { account = "1500", side = "debit", amount = "1480.00" }, + { account = "3000", side = "credit", amount = "1480.00" }, +] + +# Entry 13. +[[events]] +date = "2026-03-27" +description = "Reverse entry 12: contributed jointly" +reverses = 12 + +# Entry 14. +[[events]] +date = "2026-03-27" +description = "Laptop contributed jointly" +lines = [ + { account = "1500", side = "debit", amount = "1480.00" }, + { account = "3000", side = "credit", amount = "740.00" }, + { account = "3001", side = "credit", amount = "740.00" }, +] + +# Entry 15. +[[events]] +date = "2026-03-27" +description = "Laptop contributed jointly" +lines = [ + { account = "1500", side = "debit", amount = "1190.00" }, + { account = "3000", side = "credit", amount = "595.00" }, + { account = "3001", side = "credit", amount = "595.00" }, +] + +# Entry 16. +[[events]] +date = "2026-03-27" +description = "Desktop contributed jointly" +lines = [ + { account = "1500", side = "debit", amount = "2210.00" }, + { account = "3000", side = "credit", amount = "1105.00" }, + { account = "3001", side = "credit", amount = "1105.00" }, +] + +# Entry 17. +[[events]] +date = "2026-03-27" +description = "Tablet contributed jointly" +lines = [ + { account = "1500", side = "debit", amount = "630.00" }, + { account = "3000", side = "credit", amount = "315.00" }, + { account = "3001", side = "credit", amount = "315.00" }, +] + +# Entry 18. +[[events]] +date = "2026-03-27" +description = "Reverse entry 15: the service plan is separate" +reverses = 15 + +# Entry 19. +[[events]] +date = "2026-03-27" +description = "Laptop and its service plan contributed jointly" +lines = [ + { account = "1500", side = "debit", amount = "1151.00" }, + { account = "1300", side = "debit", amount = "39.00" }, + { account = "3000", side = "credit", amount = "595.00" }, + { account = "3001", side = "credit", amount = "595.00" }, +] + +# Entry 20. +[[events]] +date = "2026-03-20" +description = "Domain, 2 years, paid by the members, prepaid" +lines = [ + { account = "1300", side = "debit", amount = "46.38" }, + { account = "2100", side = "credit", amount = "46.38" }, +] + +# Entry 21. +[[events]] +date = "2026-03-12" +description = "Subscription, paid by the members" +lines = [ + { account = "6300", side = "debit", amount = "216.40" }, + { account = "2100", side = "credit", amount = "216.40" }, +] + +# Entry 22. +[[events]] +date = "2026-03-20" +description = "Reverse entry 9: prepaid, not expensed" +reverses = 9 + +# Entry 23. +[[events]] +date = "2026-03-30" +description = "API credits on the card" +lines = [ + { account = "6300", side = "debit", amount = "42.75" }, + { account = "2050", side = "credit", amount = "42.75" }, +] +# By hand, the year to 2026-03-31 (rules.md B-14; D-42 for what the +# members paid). Cash is 1000; 2100 (Due to Members) is financing. +# - Operating: the members paid #1 85.00, #2 12.97, #3 99.00, #6 7.49, +# #21 216.40, #7 129.00 and #20 46.38 (the prepaid domain) for the +# company through 2100, each borrowed (financing, 45-14(b)) and spent +# (operating, 45-17(b)) on its date: 85.00 + 12.97 + 99.00 + 7.49 + +# 216.40 + 129.00 + 46.38 = 596.24, so operating -596.24, all cash paid +# to suppliers. #9 and its reversal #22 net out (rule 6). The card's #10 +# and #23 moved no cash and 2050 is not financing: no line. +# - Investing 0.00: every device was property the members owned (50-4), +# disclosed below; #12/#13 and #15/#18 net out. +# - Financing: #4 +1,200.00 and #5 +800.00 (contributions, 45-14(a)); +# +596.24 borrowed from the members (the seven above); the +# reimbursements #8 -333.46 (#1, #2, #3, #6 and #7: 85.00 + 12.97 + +# 99.00 + 7.49 + 129.00) and #11 -46.38 (#20), 45-15(b): 1,200.00 + +# 800.00 + 596.24 - 333.46 - 46.38 = +2,216.40. +# - Sum: -596.24 + 0.00 + 2,216.40 = +1,620.16, the change in cash (in +# 1,200.00 + 800.00, out 333.46 + 46.38; 0.00 to 1,620.16); difference +# 0.00. +# - Reconciliation (45-28 to 45-30): net income is minus the expenses, +# 85.00 + 12.97 + 99.00 + 7.49 + 129.00 + 46.38 - 46.38 + 14.20 + 216.40 +# + 42.75 = 606.81, so -606.81; other noncash +39.00 (#19's service +# plan, contributed with the laptop); increase in 1300, 39.00 + 46.38, +# so -85.38; increase in 2050, 14.20 + 42.75, so +56.95. Total -606.81 + +# 39.00 - 85.38 + 56.95 = -596.24, the operating section. +# - Disclosed (50-3, 50-4), the property contributed: #14 1,480.00, #16 +# 2,210.00, #17 630.00, #19 1,190.00 (1,151.00 equipment and 39.00 a +# prepaid service plan), 5,510.00 in all; #12/#13 and #15/#18 drop out. +[[expect]] +query = "cash_flow" +from = "2026-01-01" +to = "2026-03-31" + +[expect.values] +operating = "-596.24" +investing = "0.00" +financing = "2216.40" +net_change = "1620.16" +cash_beginning = "0.00" +cash_ending = "1620.16" +difference = "0.00" +net_income = "-606.81" +"operating.1" = "-85.00" +"operating.2" = "-12.97" +"operating.3" = "-99.00" +"operating.6" = "-7.49" +"operating.7" = "-129.00" +"operating.20" = "-46.38" +"operating.21" = "-216.40" +"operating.count" = "7" +"investing.count" = "0" +"financing.1" = "85.00" +"financing.1.on_behalf" = "85.00" +"financing.2" = "12.97" +"financing.3" = "99.00" +"financing.4" = "1200.00" +"financing.4.on_behalf" = "0.00" +"financing.5" = "800.00" +"financing.6" = "7.49" +"financing.7" = "129.00" +"financing.8" = "-333.46" +"financing.11" = "-46.38" +"financing.20" = "46.38" +"financing.20.on_behalf" = "46.38" +"financing.21" = "216.40" +"financing.count" = "11" +"reconciliation.other_noncash" = "39.00" +"reconciliation.balance_change.1300" = "-85.38" +"reconciliation.balance_change.2050" = "56.95" +"noncash.14" = "1480.00" +"noncash.16" = "2210.00" +"noncash.17" = "630.00" +"noncash.19" = "1190.00" +"noncash.count" = "4" + +# The same books to 2026-03-18: #1 to #7 and #21 are dated by then (#9, +# #20 and #22 on 03-20, the rest later). The members paid #1, #2, #3, #6, +# #7 and #21 for the company, 85.00 + 12.97 + 99.00 + 7.49 + 129.00 + +# 216.40 = 549.86: operating -549.86; financing #4 +1,200.00, #5 +800.00 +# and the 549.86 borrowed, +2,549.86; the change in cash 2,000.00 +# (-549.86 + 2,549.86); net income -549.86 with no reconciling item (no +# operating balance has moved yet); nothing disclosed. +[[expect]] +query = "cash_flow" +from = "2026-01-01" +to = "2026-03-18" + +[expect.values] +operating = "-549.86" +investing = "0.00" +financing = "2549.86" +net_change = "2000.00" +difference = "0.00" +net_income = "-549.86" +"operating.1" = "-85.00" +"operating.2" = "-12.97" +"operating.3" = "-99.00" +"operating.6" = "-7.49" +"operating.7" = "-129.00" +"operating.21" = "-216.40" +"operating.count" = "6" +"financing.1" = "85.00" +"financing.2" = "12.97" +"financing.3" = "99.00" +"financing.4" = "1200.00" +"financing.5" = "800.00" +"financing.6" = "7.49" +"financing.7" = "129.00" +"financing.21" = "216.40" +"financing.count" = "8" +"noncash.count" = "0" diff --git a/bookspec/examples/iif-quickbooks-journal-file.toml b/bookspec/examples/iif-quickbooks-journal-file.toml index 483cc3e..5a3192a 100644 --- a/bookspec/examples/iif-quickbooks-journal-file.toml +++ b/bookspec/examples/iif-quickbooks-journal-file.toml @@ -18,16 +18,15 @@ # Setup beyond the accounts: `cash_accounts` are the bank accounts (BANK); # an account's QuickBooks type otherwise follows its type (asset OCASSET, # liability OCLIAB, equity EQUITY, revenue INC, expense EXP); an account's -# `parent` is its parent's code. Each event is a posted entry, its id one -# more than its index. Expected values: iif_lines gives the file's lines -# in order, each a string of fields joined by tabs ("\t"), every line -# ending CR LF in the file; iif_changes gives each text written -# differently from the books, in file order (the accounts as declared: -# by code, each after its parent; then the entries), as [what, books' -# text, file's text], where what is "name ", "number" (left out of -# ACCNUM when the file's text is empty), "top level " (written -# apart from its parent, the books' text: the parent's code) or -# "memo ". +# `parent` is its parent's code. Event N is entry N (bookspec/README.md). +# Expected values: iif_lines gives the file's lines in order, each a +# string of fields joined by tabs ("\t"), every line ending CR LF in the +# file; iif_changes gives each text written differently from the books, +# in file order (the accounts as declared: by code, each after its +# parent; then the entries), as [what, books' text, file's text], where +# what is "name ", "number" (left out of ACCNUM when the file's +# text is empty), "top level " (written apart from its parent, the +# books' text: the parent's code) or "memo ". id = "iif-quickbooks-journal-file" appendix = "formats" @@ -48,7 +47,7 @@ tags = ["iif", "quickbooks", "as-of", "format", "transliteration"] currency = "USD" fiscal_year_start = "01-01" basis = "cash" -rounding = "half_away_from_zero" +rounding = "half-away-from-zero" cash_accounts = ["1000"] [[setup.accounts]] @@ -96,7 +95,7 @@ code = "6100" name = "Professional services and consulting fees" type = "expense" -# 0: entry 1. +# Entry 1. [[events]] date = "2026-01-02" description = "Owner contribution" @@ -105,7 +104,7 @@ lines = [ { account = "3000", side = "credit", amount = "5000.00" }, ] -# 1: entry 2. An em dash and double quotes. +# Entry 2. An em dash and double quotes. [[events]] date = "2026-01-15" description = "Lunch — \"team\"" @@ -114,7 +113,7 @@ lines = [ { account = "1000", side = "credit", amount = "42.50" }, ] -# 2: entry 3. An account named past 31 characters. +# Entry 3. An account named past 31 characters. [[events]] date = "2026-01-20" description = "Consulting" @@ -123,7 +122,7 @@ lines = [ { account = "1000", side = "credit", amount = "300.00" }, ] -# 3: entry 4, dated the day itself: in. +# Entry 4, dated the day itself: in. [[events]] date = "2026-02-01" description = "Sale" @@ -132,7 +131,7 @@ lines = [ { account = "4000", side = "credit", amount = "1000.00" }, ] -# 4: entry 5, the day after: out. +# Entry 5, the day after: out. [[events]] date = "2026-02-02" description = "Second sale" diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index a08109c..1c743b6 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -38,7 +38,8 @@ pub struct BooksSettings { pub cash_accounts: Vec, /// `[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, /// `[books] investing_accounts`: asset account codes the cash flow /// statement treats as investing (loans the company makes, investments diff --git a/crates/ledger-core/src/cashflow.rs b/crates/ledger-core/src/cashflow.rs new file mode 100644 index 0000000..a9f5551 --- /dev/null +++ b/crates/ledger-core/src/cashflow.rs @@ -0,0 +1,2056 @@ +//! The statement of cash flows (rules.md B-14), by the direct method: each +//! entry that moved cash, in the section of the accounts it settled, gross; +//! the reconciliation of net income to operating cash as a separate +//! schedule; and the investing and financing activities no cash settled, +//! disclosed apart from the body. +//! +//! The arithmetic is the kernel's ([`kernel::entry_flows`], +//! [`kernel::apply_cash`], [`kernel::constructive`], +//! [`kernel::exchanged`]), proved by Kani at `i64` and modelled in Lean +//! (`cashflow_sections_sum_to_cash_change`, `constructive_nets`, +//! `operating_reconciliation_ties`). This module decides where each line +//! falls, what a member or lender paid for the company, pairs reversals, +//! names the kinds and checks the result foots. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use chrono::NaiveDate; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use crate::kernel::{self, EntryFlows, Flow, OnBehalf}; +use crate::reports::MemberCapital; +use crate::{Account, AccountId, AccountType, EntryId, JournalEntry, Ledger, LedgerError, Side}; + +/// A section of the statement of cash flows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CashFlowSection { + /// Operating activities. + Operating, + /// Investing activities. + Investing, + /// Financing activities. + Financing, +} + +impl CashFlowSection { + /// The three, in the statement's order. + pub const ALL: [CashFlowSection; 3] = [ + CashFlowSection::Operating, + CashFlowSection::Investing, + CashFlowSection::Financing, + ]; + + /// "Operating activities", ... + pub fn label(self) -> &'static str { + match self { + CashFlowSection::Operating => "Operating activities", + CashFlowSection::Investing => "Investing activities", + CashFlowSection::Financing => "Financing activities", + } + } + + /// `"operating"`, as the exports spell it. + pub fn key(self) -> &'static str { + match self { + CashFlowSection::Operating => "operating", + CashFlowSection::Investing => "investing", + CashFlowSection::Financing => "financing", + } + } +} + +/// The classes of operating cash ASC 230-10-45-25 (a)-(g) requires, at a +/// minimum, of a direct-method statement. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperatingClass { + /// (a) Cash collected from customers. + FromCustomers, + /// (b) Interest and dividends received. + InterestAndDividendsReceived, + /// (c) Other operating cash receipts. + OtherReceipts, + /// (d) Cash paid to employees and other suppliers. + ToSuppliersAndEmployees, + /// (e) Interest paid. + InterestPaid, + /// (f) Income taxes paid. No tax category marks an entity-level income + /// tax yet, so nothing is filed here (rules.md B-14 rule 5). + IncomeTaxesPaid, + /// (g) Other operating cash payments. + OtherPayments, +} + +impl OperatingClass { + /// Every class, (a) through (g). + pub const ALL: [OperatingClass; 7] = [ + OperatingClass::FromCustomers, + OperatingClass::InterestAndDividendsReceived, + OperatingClass::OtherReceipts, + OperatingClass::ToSuppliersAndEmployees, + OperatingClass::InterestPaid, + OperatingClass::IncomeTaxesPaid, + OperatingClass::OtherPayments, + ]; + + /// The caption a printed statement uses. + pub fn label(self) -> &'static str { + match self { + OperatingClass::FromCustomers => "Cash received from customers", + OperatingClass::InterestAndDividendsReceived => "Interest and dividends received", + OperatingClass::OtherReceipts => "Other operating cash receipts", + OperatingClass::ToSuppliersAndEmployees => "Cash paid to suppliers and employees", + OperatingClass::InterestPaid => "Interest paid", + OperatingClass::IncomeTaxesPaid => "Income taxes paid", + OperatingClass::OtherPayments => "Other operating cash payments", + } + } + + /// `"from_customers"`, as the exports spell it. + pub fn key(self) -> &'static str { + match self { + OperatingClass::FromCustomers => "from_customers", + OperatingClass::InterestAndDividendsReceived => "interest_and_dividends_received", + OperatingClass::OtherReceipts => "other_receipts", + OperatingClass::ToSuppliersAndEmployees => "to_suppliers_and_employees", + OperatingClass::InterestPaid => "interest_paid", + OperatingClass::IncomeTaxesPaid => "income_taxes_paid", + OperatingClass::OtherPayments => "other_payments", + } + } + + fn order(self) -> u8 { + match self { + OperatingClass::FromCustomers => 0, + OperatingClass::InterestAndDividendsReceived => 1, + OperatingClass::OtherReceipts => 2, + OperatingClass::ToSuppliersAndEmployees => 3, + OperatingClass::InterestPaid => 4, + OperatingClass::IncomeTaxesPaid => 5, + OperatingClass::OtherPayments => 6, + } + } +} + +/// Parameters for [`cash_flow`]. +#[derive(Clone, Debug)] +pub struct CashFlowParams { + /// Period start, inclusive. + pub start: NaiveDate, + /// Period end, inclusive. + pub end: NaiveDate, + /// Accounts that hold cash (asset accounts). Their net change is what + /// the statement explains. + pub cash_accounts: Vec, + /// Liability accounts treated as financing (debt, including what the + /// owners advanced: ASC 230-10-45-14(b) "other short- or long-term + /// borrowing"). Equity is always financing, listed or not. Default + /// empty: other liabilities are operating. + pub financing_accounts: Vec, + /// 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. + pub investing_accounts: Vec, + /// The members of a multi-member LLC, so financing names each member's + /// contributions and distributions (D-35). Empty: equity is named by + /// account. Shares are not used here. + pub members: Vec, +} + +/// One entry's cash in one kind of one section: a line of the statement's +/// body. Positive is cash in. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CashFlowLine { + /// The entry that moved the cash. + pub entry: EntryId, + /// Its date. + pub date: NaiveDate, + /// `" (#N)"`, `"Reversal of #N — "`, or + /// `"Reinstatement of #A (reverses #B)"`: never derived from the sign. + pub label: String, + /// The kind the line is listed under: an operating class's caption + /// ("Cash paid to suppliers and employees"), or an investing or + /// financing caption ("Contributions from members — Member A"). + pub kind: String, + /// The cash, + in. + pub amount: Decimal, + /// Of `amount`, what a member or a lender paid or received for the + /// company with no cash account moving (rules.md B-14 rule 11, D-42): + /// a constructive receipt or payment. Zero for most lines. + pub on_behalf: Decimal, + /// Codes of this kind's accounts the entry moved, sorted. + pub accounts: Vec, + /// The entry this one reverses, if any. + pub reverses: Option, + /// The operating class (operating lines only). + pub class: Option, +} + +/// A kind's total in one section, with the entries listed under it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CashFlowKind { + /// The caption. + pub kind: String, + /// The operating class, for an operating kind. + pub class: Option, + /// The kind's cash, + in. + pub amount: Decimal, + /// Its entries, in the order listed. + pub entries: Vec, +} + +/// An investing or financing activity no cash settled (ASC 230-10-50-3, +/// 50-4), or the part of one cash didn't (50-5). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct NoncashDisclosure { + /// The entry. + pub entry: EntryId, + /// Its date. + pub date: NaiveDate, + /// Its label, as a line's. + pub label: String, + /// The amount disclosed: the larger of what moved into sections + /// without cash (`Σ max(part, 0)`) and the exchange inside investing + /// and financing. + pub amount: Decimal, + /// The operating part no cash settled, debit-positive. + pub operating: Decimal, + /// The investing part no cash settled, debit-positive. + pub investing: Decimal, + /// The financing part no cash settled, debit-positive. + pub financing: Decimal, + /// What was exchanged inside investing and financing without cash + /// (debt converted to equity, one asset for another). + pub exchanged: Decimal, + /// The entry it reverses, if any. + pub reverses: Option, +} + +/// What a reconciling item is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReconcilingKind { + /// Depreciation: an operating expense no cash paid, against + /// accumulated depreciation. + Depreciation, + /// The revenue and expense lines of entries that dispose of or write + /// down investing assets (gains, losses), whose cash is investing. + InvestingGainLoss, + /// Operating movements no cash settled, through one investing or + /// financing account (interest charged into a loan; a prepaid a member + /// contributed). + NoncashThrough, + /// Other operating movements no cash settled. + OtherNoncash, + /// The change in an operating asset or liability. + OperatingBalanceChange, +} + +impl ReconcilingKind { + /// `"noncash_through"`, as the exports spell it. + pub fn key(self) -> &'static str { + match self { + ReconcilingKind::Depreciation => "depreciation", + ReconcilingKind::InvestingGainLoss => "investing_gain_loss", + ReconcilingKind::NoncashThrough => "noncash_through", + ReconcilingKind::OtherNoncash => "other_noncash", + ReconcilingKind::OperatingBalanceChange => "operating_balance_change", + } + } +} + +/// One line of the reconciliation of net income to operating cash. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ReconcilingLine { + /// What it is. + pub kind: ReconcilingKind, + /// "Depreciation", "Noncash: through 2500 Bank loan", "Increase + /// in 1300 Prepaid Expenses", ... + pub label: String, + /// Its amount, added to net income. + pub amount: Decimal, + /// The account it is about (`NoncashThrough`, + /// `OperatingBalanceChange`). + pub account: Option, + /// The entries behind it, for the entry-based kinds (an entry and its + /// reversal in the period are left out). + pub entries: Vec, +} + +/// Net income reconciled to net cash from operating activities, the +/// separate schedule a direct-method statement carries (ASC +/// 230-10-45-30). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OperatingReconciliation { + /// The period's net income (closing entries excluded). + pub net_income: Decimal, + /// The reconciling items: depreciation, gains and losses on investing + /// assets, noncash items by account, other noncash items, then the + /// changes in operating assets and liabilities by code. + pub lines: Vec, + /// Net income plus every line: equal to the operating section. + pub net_cash_from_operating: Decimal, +} + +/// The footing check (rules.md B-14 rule 9). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CashFlowFooting { + /// Operating + investing + financing. + pub sections_sum: Decimal, + /// The cash accounts' ending balance less their beginning balance. + pub net_change: Decimal, + /// `sections_sum - net_change`: always zero on output. + pub difference: Decimal, +} + +/// Why the statement flags an entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CashFlowWarningKind { + /// A fixed asset or investment bought on an operating payable: its + /// later payment shows as operating, following how the payable is + /// classified (D-35). + AssetOnOperatingPayable, + /// A fixed asset or investment sold for an amount owed on an operating + /// receivable: its later collection would show as operating, though + /// ASC 230-10-45-12(c) makes it investing (D-35: flagged with a fix). + AssetSoldOnOperatingReceivable, +} + +impl CashFlowWarningKind { + /// `"asset_on_operating_payable"`, as the exports spell it. + pub fn key(self) -> &'static str { + match self { + CashFlowWarningKind::AssetOnOperatingPayable => "asset_on_operating_payable", + CashFlowWarningKind::AssetSoldOnOperatingReceivable => { + "asset_sold_on_operating_receivable" + } + } + } +} + +/// A note on an entry whose later settlement the books can't classify by +/// themselves. Figures are unaffected. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CashFlowWarning { + /// The entry. + pub entry: EntryId, + /// What it is. + pub kind: CashFlowWarningKind, + /// What happened and what to change. + pub message: String, +} + +/// The statement of cash flows, direct method (rules.md B-14). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CashFlowStatement { + /// Period start, inclusive. + pub start: NaiveDate, + /// Period end, inclusive. + pub end: NaiveDate, + /// Operating lines, by class, then date and entry. + pub operating: Vec, + /// Net cash from operating activities. + pub cash_from_operations: Decimal, + /// Investing lines, by kind, then date and entry. + pub investing: Vec, + /// Net cash from investing activities. + pub cash_from_investing: Decimal, + /// Financing lines, by kind, then date and entry. + pub financing: Vec, + /// Net cash from financing activities. + pub cash_from_financing: Decimal, + /// Net change in cash: the three sections summed. + pub net_change_in_cash: Decimal, + /// The cash accounts' balance at the start. + pub cash_beginning: Decimal, + /// Their balance at the end. + pub cash_ending: Decimal, + /// Noncash investing and financing activities, by date and entry. + pub noncash_disclosures: Vec, + /// Net income reconciled to operating cash. + pub operating_reconciliation: OperatingReconciliation, + /// The footing check. + pub reconciles: CashFlowFooting, + /// Entries whose later settlement the books can't classify. + pub warnings: Vec, +} + +impl CashFlowStatement { + /// A section's lines. + pub fn lines(&self, section: CashFlowSection) -> &[CashFlowLine] { + match section { + CashFlowSection::Operating => &self.operating, + CashFlowSection::Investing => &self.investing, + CashFlowSection::Financing => &self.financing, + } + } + + /// A section's total. + pub fn total(&self, section: CashFlowSection) -> Decimal { + match section { + CashFlowSection::Operating => self.cash_from_operations, + CashFlowSection::Investing => self.cash_from_investing, + CashFlowSection::Financing => self.cash_from_financing, + } + } + + /// A section's kinds, in the order listed, each with its total and + /// entries. They sum to the section's total. + pub fn kinds(&self, section: CashFlowSection) -> Vec { + let mut out: Vec = Vec::new(); + for l in self.lines(section) { + match out.last_mut() { + Some(k) if k.kind == l.kind => { + k.amount += l.amount; + k.entries.push(l.entry); + } + _ => out.push(CashFlowKind { + kind: l.kind.clone(), + class: l.class, + amount: l.amount, + entries: vec![l.entry], + }), + } + } + out + } + + /// Operating cash by class, (a) through (g), classes with no line left + /// out. + pub fn operating_by_class(&self) -> Vec<(OperatingClass, Decimal)> { + self.kinds(CashFlowSection::Operating) + .into_iter() + .filter_map(|k| Some((k.class?, k.amount))) + .collect() + } +} + +/// Which role list a code comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RoleList { + /// `cash_accounts`. + Cash, + /// `financing_accounts`. + Financing, + /// `investing_accounts`. + Investing, +} + +/// A contradiction among the cash flow account roles (rules.md B-14 rule +/// 2): the statement refuses rather than guess. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RoleProblem { + /// An account of the wrong type in a list. + WrongType { + /// The list. + list: RoleList, + /// Its code. + code: String, + /// Its name. + name: String, + /// Its type. + found: AccountType, + }, + /// A code listed twice in one list. + Repeated { + /// The list. + list: RoleList, + /// The code. + code: String, + }, + /// A code in two lists. + InTwoLists { + /// The code. + code: String, + /// Its name. + name: String, + /// One list. + first: RoleList, + /// The other. + second: RoleList, + }, + /// A registered fixed asset's cost or accumulated-depreciation account + /// listed as cash. + RegisteredAssetIsCash { + /// The code. + code: String, + /// Its name. + name: String, + }, +} + +fn an(t: AccountType) -> &'static str { + match t { + AccountType::Asset => "an asset", + AccountType::Liability => "a liability", + AccountType::Equity => "an equity", + AccountType::Revenue => "a revenue", + AccountType::Expense => "an expense", + } +} + +impl RoleProblem { + /// What is wrong and what to change. `setting` names each list as the + /// caller knows it ("books.financing_accounts", or "financing_accounts + /// (from the call or books.financing_accounts)"). + pub fn message(&self, setting: impl Fn(RoleList) -> String) -> String { + match self { + RoleProblem::WrongType { + list, + code, + name, + found, + } => { + let want = match list { + RoleList::Cash => { + "cash accounts are asset accounts (bank, savings, cash on hand)" + } + RoleList::Investing => { + "investing accounts are asset accounts (loans the company makes, investments it buys)" + } + RoleList::Financing => { + "financing accounts are liabilities (equity is always financing)" + } + }; + format!( + "{} lists {code} \"{name}\", {} account: {want}; remove it from {}", + setting(*list), + an(*found), + setting(*list) + ) + } + RoleProblem::Repeated { list, code } => format!( + "{} lists {code} twice: list each account once", + setting(*list) + ), + RoleProblem::InTwoLists { + code, + name, + first, + second, + } => format!( + "{code} \"{name}\" is in both {} and {}: an account belongs to one section; remove it from one", + setting(*first), + setting(*second) + ), + RoleProblem::RegisteredAssetIsCash { code, name } => format!( + "{} lists {code} \"{name}\", a registered fixed asset's account: equipment is investing, not cash; remove it from {}", + setting(RoleList::Cash), + setting(RoleList::Cash) + ), + } + } +} + +/// Every contradiction among the cash flow account roles (rules.md B-14 +/// rule 2), in a fixed order. Codes the chart lacks are the caller's to +/// report. The one check behind both the statement's refusal and the +/// settings' problems, so the two never drift apart. +/// +/// ```text +/// GOAL: the statement's account roles partition the chart, so every +/// account is in exactly one section. +/// SOURCE: rules.md B-14 rule 2 (ASC 230-10-45-10: every cash receipt and +/// payment is investing, financing or operating). +/// INVARIANTS: +/// 1. Cash and investing accounts MUST be assets; financing accounts +/// liabilities or equity (equity is financing anyway). +/// 2. NEVER a code twice in one list, or in two lists. +/// 3. NEVER a registered fixed asset's cost or accumulated-depreciation +/// account as cash. +/// ``` +pub fn role_problems( + ledger: &Ledger, + cash: &[AccountId], + financing: &[AccountId], + investing: &[AccountId], +) -> Vec { + let chart = ledger.chart(); + let mut out = Vec::new(); + let lists = [ + (RoleList::Cash, cash), + (RoleList::Financing, financing), + (RoleList::Investing, investing), + ]; + for (list, ids) in lists { + let mut seen = BTreeSet::new(); + for &id in ids { + let Some(a) = chart.get(id) else { + continue; + }; + if !seen.insert(id) { + out.push(RoleProblem::Repeated { + list, + code: a.code.clone(), + }); + continue; + } + let ok = match list { + RoleList::Cash | RoleList::Investing => a.account_type == AccountType::Asset, + RoleList::Financing => { + matches!(a.account_type, AccountType::Liability | AccountType::Equity) + } + }; + if !ok { + out.push(RoleProblem::WrongType { + list, + code: a.code.clone(), + name: a.name.clone(), + found: a.account_type, + }); + } + } + } + for (i, &(first, xs)) in lists.iter().enumerate() { + for &(second, ys) in &lists[i + 1..] { + let both: BTreeSet<&str> = xs + .iter() + .filter(|x| ys.contains(x)) + .filter_map(|&x| chart.get(x).map(|a| a.code.as_str())) + .collect(); + for code in both { + let a = chart.find_by_code(code).expect("just found"); + out.push(RoleProblem::InTwoLists { + code: a.code.clone(), + name: a.name.clone(), + first, + second, + }); + } + } + } + let mut asset_accounts = BTreeSet::new(); + for asset in ledger.registered_assets() { + for id in [asset.cost_account, asset.accum_depr_account] { + if cash.contains(&id) + && let Some(a) = chart.get(id) + { + asset_accounts.insert((a.code.clone(), a.name.clone())); + } + } + } + for (code, name) in asset_accounts { + out.push(RoleProblem::RegisteredAssetIsCash { code, name }); + } + out +} + +/// rules.md B-14 rule 9, on its own so the check is tested directly (it +/// cannot fail on posted entries): the sections sum to the change in +/// cash, or nothing is produced. +pub fn foot(sections_sum: Decimal, net_change: Decimal) -> Result { + let difference = sections_sum + .checked_sub(net_change) + .ok_or_else(|| too_large("the cash flow statement's footing"))?; + if !difference.is_zero() { + return Err(LedgerError::CashFlowDoesNotFoot { + what: "sections".to_string(), + expected: net_change, + found: sections_sum, + }); + } + Ok(CashFlowFooting { + sections_sum, + net_change, + difference, + }) +} + +fn too_large(what: &str) -> LedgerError { + LedgerError::TooLargeToCompute(what.to_string()) +} + +fn add(a: Decimal, b: Decimal, what: &str) -> Result { + a.checked_add(b).ok_or_else(|| too_large(what)) +} + +fn signed(side: Side, amount: Decimal) -> Decimal { + match side { + Side::Debit => amount, + Side::Credit => -amount, + } +} + +/// What a line part belongs to within its section: the kind its cash is +/// listed under. Order matters: kinds are split in this order. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum Bucket { + /// Operating balance-sheet lines (prepaids, payables, ...), the + /// receivables control aside: classed per entry by the structure of + /// the entry's operating lines (rule 5), with `Receivables`, `Income` + /// and `GuaranteedPayments`. + Operating, + /// The receivables control account: classed with `Operating`. Apart + /// because what a member or lender collects of it for the company is + /// a receipt on its behalf (rule 11), and another operating asset + /// handed over is not. + Receivables, + /// Operating revenue and expense lines other than interest and + /// guaranteed payments: classed with `Operating`. Apart from it + /// because only these can a member pay for through equity (rule 11), + /// and after it so cash and lenders settle balance-sheet lines first. + Income, + /// Guaranteed payments to members (tax category + /// `guaranteed_payments`): classed with `Operating`. Apart because a + /// member never pays their own guaranteed payment for the company: + /// one charged to what the company owes them is accrued, not paid + /// (rule 11). + GuaranteedPayments, + /// Operating: a line on an `interest_income` account, class (b) (ASC + /// 230-10-45-25(b)), whatever else the entry settles. + InterestReceived, + /// Operating: a line on an `interest_expense` account, class (e) + /// (45-25(e)), whatever else the entry settles. + InterestPaid, + /// Registered fixed assets (cost, accumulated depreciation, and the + /// gains and losses of entries disposing of them). + Property, + /// An `investing_accounts` account, by code. + Investment(String), + /// A member's capital or distributions account, by member. + Member(usize), + /// Another equity account, by code. + Equity(String), + /// A `financing_accounts` liability, by code. + Borrowing(String), +} + +/// One line of an entry, or one part of a line split between sections +/// (the bill split, rule 12), tagged with its place on the statement. +#[derive(Clone, Debug)] +struct Part { + account: AccountId, + flow: Flow, + side: Side, + amount: Decimal, + /// A revenue or expense line. + pl: bool, + /// A line on an accumulated-depreciation account. + accum: bool, + bucket: Bucket, +} + +impl Part { + fn signed(&self) -> Decimal { + signed(self.side, self.amount) + } +} + +/// The account roles, checked. +struct Roles<'a> { + ledger: &'a Ledger, + cash: BTreeSet, + financing: BTreeSet, + investing: BTreeSet, + cost: BTreeSet, + accum: BTreeSet, + receivables: Option, + member_of: HashMap, + members: Vec, +} + +impl<'a> Roles<'a> { + fn checked(ledger: &'a Ledger, params: &CashFlowParams) -> Result { + let chart = ledger.chart(); + for &id in params + .cash_accounts + .iter() + .chain(¶ms.financing_accounts) + .chain(¶ms.investing_accounts) + { + if chart.get(id).is_none() { + return Err(LedgerError::UnknownAccount(id)); + } + } + if params.cash_accounts.is_empty() { + return Err(LedgerError::CashFlowRoles( + "no cash accounts: pass cash_accounts, or set books.cash_accounts (the bank, savings and cash-on-hand accounts the statement explains)".to_string(), + )); + } + let problems = role_problems( + ledger, + ¶ms.cash_accounts, + ¶ms.financing_accounts, + ¶ms.investing_accounts, + ); + if !problems.is_empty() { + let name = |l: RoleList| { + let key = match l { + RoleList::Cash => "cash_accounts", + RoleList::Financing => "financing_accounts", + RoleList::Investing => "investing_accounts", + }; + format!("{key} (from the call or books.{key})") + }; + let text: Vec = problems.iter().map(|p| p.message(name)).collect(); + return Err(LedgerError::CashFlowRoles(text.join("; "))); + } + let mut cost = BTreeSet::new(); + let mut accum = BTreeSet::new(); + for asset in ledger.registered_assets() { + cost.insert(asset.cost_account); + accum.insert(asset.accum_depr_account); + } + let mut member_of = HashMap::new(); + for (i, m) in params.members.iter().enumerate() { + member_of.insert(m.capital_account, i); + if let Some(d) = m.distribution_account { + member_of.insert(d, i); + } + } + Ok(Roles { + ledger, + cash: params.cash_accounts.iter().copied().collect(), + financing: params.financing_accounts.iter().copied().collect(), + investing: params.investing_accounts.iter().copied().collect(), + cost, + accum, + receivables: ledger.role_account(&crate::RECEIVABLES), + member_of, + members: params.members.iter().map(|m| m.name.clone()).collect(), + }) + } + + fn account(&self, id: AccountId) -> Result<&'a Account, LedgerError> { + self.ledger + .chart() + .get(id) + .ok_or(LedgerError::UnknownAccount(id)) + } + + /// An investing asset other than accumulated depreciation: a + /// registered cost account or an `investing_accounts` account. + fn investing_asset(&self, id: AccountId) -> bool { + self.cost.contains(&id) || (self.investing.contains(&id) && !self.accum.contains(&id)) + } + + /// Where a balance-sheet account falls (rule 2): its section and kind. + fn place(&self, a: &Account) -> (Flow, Bucket) { + if self.cash.contains(&a.id) { + (Flow::Cash, Bucket::Operating) + } else if self.cost.contains(&a.id) || self.accum.contains(&a.id) { + (Flow::Investing, Bucket::Property) + } else if self.investing.contains(&a.id) { + (Flow::Investing, Bucket::Investment(a.code.clone())) + } else if a.account_type == AccountType::Equity { + match self.member_of.get(&a.id) { + Some(&i) => (Flow::Financing, Bucket::Member(i)), + None => (Flow::Financing, Bucket::Equity(a.code.clone())), + } + } else if self.financing.contains(&a.id) { + (Flow::Financing, Bucket::Borrowing(a.code.clone())) + } else if Some(a.id) == self.receivables { + (Flow::Operating, Bucket::Receivables) + } else { + (Flow::Operating, Bucket::Operating) + } + } + + /// Rule 4: the kind an entry's revenue and expense lines go to when it + /// credits a registered cost account (property) or an investing + /// account (the lowest code it credits); `None` otherwise. + fn disposal(&self, basis: &JournalEntry) -> Result, LedgerError> { + let mut investment: Option = None; + for l in basis.lines() { + if l.side != Side::Credit { + continue; + } + if self.cost.contains(&l.account) { + return Ok(Some(Bucket::Property)); + } + if self.investing_asset(l.account) { + let code = self.account(l.account)?.code.clone(); + if investment.as_ref().is_none_or(|c| code < *c) { + investment = Some(code); + } + } + } + Ok(investment.map(Bucket::Investment)) + } + + /// The caption a kind is listed under, and its place in the section's + /// order. `inflow` is the root's direction: a reversal is listed + /// under its original's kind. + fn kind(&self, bucket: &Bucket, inflow: bool) -> (KindOrder, String) { + let named = |code: &str| { + self.ledger + .chart() + .find_by_code(code) + .map_or(code.to_string(), |a| format!("{} {}", a.code, a.name)) + }; + let owners = if self.members.is_empty() { + "owners" + } else { + "members" + }; + match (bucket, inflow) { + // Operating kinds are classes (`class_of`); no operating part + // is ever in another section. + ( + Bucket::Operating + | Bucket::Receivables + | Bucket::Income + | Bucket::GuaranteedPayments + | Bucket::InterestReceived + | Bucket::InterestPaid, + _, + ) => ((9, 0, String::new()), "Other activities".to_string()), + (Bucket::Property, false) => ( + (0, 0, String::new()), + "Purchases of property and equipment".to_string(), + ), + (Bucket::Property, true) => ( + (1, 0, String::new()), + "Proceeds from sales of property and equipment".to_string(), + ), + (Bucket::Investment(c), false) => { + ((2, 0, c.clone()), format!("Payments for {}", named(c))) + } + (Bucket::Investment(c), true) => { + ((3, 0, c.clone()), format!("Collections on {}", named(c))) + } + (Bucket::Member(i), true) => ( + (0, 0, format!("{i:08}")), + format!("Contributions from members — {}", self.members[*i]), + ), + (Bucket::Member(i), false) => ( + (1, 0, format!("{i:08}")), + format!("Distributions to members — {}", self.members[*i]), + ), + (Bucket::Equity(c), true) => ( + (0, 1, c.clone()), + format!("Contributions from {owners} — {}", named(c)), + ), + (Bucket::Equity(c), false) => ( + (1, 1, c.clone()), + format!("Distributions to {owners} — {}", named(c)), + ), + (Bucket::Borrowing(c), true) => ( + (2, 0, c.clone()), + format!("Proceeds from borrowing — {}", named(c)), + ), + (Bucket::Borrowing(c), false) => ( + (3, 0, c.clone()), + format!("Repayments of borrowing — {}", named(c)), + ), + } + } +} + +/// Where a kind sits in its section: its group (contributions before +/// distributions, purchases before sales, ...), then its member or +/// account. +type KindOrder = (u8, u8, String); + +/// Rule 12 (D-35): a bill whose entry debits an investing asset carries +/// that part, equipment first, to its payables lines and to its payments, +/// so the payment is an investing outflow (ASC 230-10-45-13(c)). A bill +/// posts to the general-expenses control today, so this is empty until a +/// bill can carry an equipment line. +#[derive(Default)] +struct BillSplits { + /// By (entry, line index): the investing part of that payables line, + /// and the kind it goes to. + parts: HashMap<(EntryId, usize), (Decimal, Bucket)>, + /// By bill entry: the equipment part each payment paid, and its date; + /// a payment's reversal unpays it again (a negative part, on the + /// reversal's date). The disclosure of a purchase on account is what + /// is still unpaid at the period's end (EY FRD 5.3.3; DART 6.1.3). + paid: HashMap>, +} + +fn bill_splits(roles: &Roles<'_>, chains: &Chains) -> Result { + let ledger = roles.ledger; + let mut out = BillSplits::default(); + let operating_liability = |id: AccountId| -> Result { + let a = roles.account(id)?; + Ok(a.account_type == AccountType::Liability && roles.place(a).0 == Flow::Operating) + }; + for bill in ledger.bills() { + let Some(b) = ledger.get_entry(bill.entry_id) else { + continue; + }; + let mut equipment = Decimal::ZERO; + let mut bucket: Option = None; + for l in b.lines() { + if l.side == Side::Debit && roles.investing_asset(l.account) { + equipment = add(equipment, l.amount.amount, "a bill's equipment part")?; + let kind = roles.place(roles.account(l.account)?).1; + if bucket.as_ref().is_none_or(|k| kind < *k) { + bucket = Some(kind); + } + } + } + let Some(bucket) = bucket else { + continue; + }; + // The bill's payables credits carry the equipment part first. + let mut left = equipment; + for (i, l) in b.lines().iter().enumerate() { + if l.side == Side::Credit && left > Decimal::ZERO && operating_liability(l.account)? { + let part = left.min(l.amount.amount); + out.parts.insert((b.id(), i), (part, bucket.clone())); + left -= part; + } + } + // Each payment pays the equipment part first, as the bill records + // them, in order: the part its payables carried (none when the + // payable is financing: its payments are repayments already). + // A payment's own amount bounds what it paid of this bill. A + // reversed payment still counts against what later payments carry + // (the bill keeps it as paid), but not against what is unpaid at a + // period's end after the reversal. + let mut owed = equipment - left; + for &(pay, amount) in ledger.bill_payments(bill.id) { + let Some(p) = ledger.get_entry(pay) else { + continue; + }; + let mut this = amount; + for (i, l) in p.lines().iter().enumerate() { + if l.side == Side::Debit + && owed > Decimal::ZERO + && this > Decimal::ZERO + && operating_liability(l.account)? + { + let part = owed.min(l.amount.amount).min(this); + out.parts.insert((p.id(), i), (part, bucket.clone())); + let paid = out.paid.entry(b.id()).or_default(); + paid.push((p.date(), part)); + // Its reversal unpays it; a reinstatement pays it again. + let mut undo = -part; + let mut cur = p.id(); + while let Some(&r) = chains.reversed_by.get(&cur) { + let Some(re) = ledger.get_entry(r).filter(|_| r > cur) else { + break; + }; + paid.push((re.date(), undo)); + undo = -undo; + cur = r; + } + owed -= part; + this -= part; + } + } + } + } + Ok(out) +} + +/// One kept entry, analysed. +struct Analysed<'a> { + entry: &'a JournalEntry, + /// Its reversal chain, root first. + chain: Vec, + /// Its place in the chain: 0 for the root. + depth: usize, + /// It mirrors its root at an odd depth: its kinds and class are its + /// root's, its direction the opposite. + flip: bool, + /// The entry whose structure decides its sections and class: its + /// root, when it mirrors the root line for line; else itself. + basis: &'a JournalEntry, + parts: Vec, + /// Its cash flows on the statement: each section's cash (constructive + /// cash included, rule 11; it nets to zero within the entry, so they + /// still sum to the entry's cash), and what no cash settled. + flows: EntryFlows, + /// Per section, the kinds with cash, in kind order. + kinds: [Vec; 3], + exchanged: Decimal, +} + +/// One kind's cash in one entry. +#[derive(Clone, Debug, PartialEq)] +struct KindCash { + bucket: Bucket, + /// Its cash, + in: what cash settled plus what was settled on the + /// company's behalf. + cash: Decimal, + /// Of `cash`, what a member or lender paid or received for the company + /// (rule 11). + on_behalf: Decimal, +} + +/// One kind's movement in one entry while its cash is worked out. +struct KindWork { + bucket: Bucket, + /// Its net movement, debit-positive. + moved: Decimal, + /// The entry's cash it took (rule 3). + cash: Decimal, + /// What a member or lender settled of it for the company (rule 11). + on_behalf: Decimal, +} + +impl KindWork { + /// What no cash, real or constructive, has settled yet. + fn left(&self) -> Result { + add( + add(self.moved, self.cash, "a kind's movement")?, + self.on_behalf, + "a kind's movement", + ) + } +} + +/// Rule 11 (D-42), one pass: the kinds `source` picks pay for, or receive +/// for, the kinds `uses` picks, by `kernel::constructive`, each in kind +/// order (sections in rule 3's order, operating first). +fn on_behalf( + kinds: &mut [Vec; 3], + way: OnBehalf, + source: impl Fn(usize, &Bucket) -> bool, + uses: impl Fn(usize, &Bucket) -> bool, +) -> Result<(), LedgerError> { + let pick = |f: &dyn Fn(usize, &Bucket) -> bool| -> Vec<(usize, usize)> { + kinds + .iter() + .enumerate() + .flat_map(|(k, ks)| { + ks.iter() + .enumerate() + .filter(move |(_, w)| f(k, &w.bucket)) + .map(move |(i, _)| (k, i)) + }) + .collect() + }; + let (src, dst) = (pick(&source), pick(&uses)); + if src.is_empty() || dst.is_empty() { + return Ok(()); + } + let left = |at: &[(usize, usize)]| -> Result, LedgerError> { + at.iter().map(|&(k, i)| kinds[k][i].left()).collect() + }; + let (s, u) = (left(&src)?, left(&dst)?); + let mut from = vec![Decimal::ZERO; s.len()]; + let mut to = vec![Decimal::ZERO; u.len()]; + kernel::constructive(way, &s, &u, &mut from, &mut to) + .ok_or_else(|| too_large("cash paid on the company's behalf"))?; + for (&(k, i), x) in src.iter().chain(&dst).zip(from.into_iter().chain(to)) { + let w = &mut kinds[k][i]; + w.on_behalf = add(w.on_behalf, x, "cash paid on the company's behalf")?; + } + Ok(()) +} + +/// Whether `entry`'s lines are `root`'s, line for line, sides flipped when +/// `flipped`. +fn mirrors(root: &JournalEntry, entry: &JournalEntry, flipped: bool) -> bool { + root.lines().len() == entry.lines().len() + && root.lines().iter().zip(entry.lines()).all(|(r, e)| { + let side = if flipped { + kernel::flip(r.side) + } else { + r.side + }; + r.account == e.account && e.side == side && r.amount.amount == e.amount.amount + }) +} + +fn analyse<'a>( + roles: &Roles<'a>, + splits: &BillSplits, + chains: &Chains, + entry: &'a JournalEntry, +) -> Result, LedgerError> { + let ledger = roles.ledger; + let chain = chains.chain(ledger, entry.id()); + let depth = chain.iter().position(|&e| e == entry.id()).unwrap_or(0); + let (basis, flip) = match ledger.get_entry(chain[0]) { + Some(root) if depth > 0 && mirrors(root, entry, depth % 2 == 1) => (root, depth % 2 == 1), + _ => (entry, false), + }; + let disposal = roles.disposal(basis)?; + let mut parts = Vec::with_capacity(entry.lines().len()); + for (i, line) in entry.lines().iter().enumerate() { + let a = roles.account(line.account)?; + let amount = line.amount.amount; + let pl = !a.account_type.is_balance_sheet(); + let (flow, bucket) = if pl { + // Rule 4: a disposal's gains and losses are investing, except + // interest, received or paid, which is operating in any entry + // (ASC 230-10-45-16(b), 45-17(d)) and its own class (rule 5). + let interest = match a.tax_category.as_deref() { + Some("interest_income") => Some(Bucket::InterestReceived), + Some("interest_expense") => Some(Bucket::InterestPaid), + _ => None, + }; + match (interest, &disposal) { + (Some(i), _) => (Flow::Operating, i), + (None, Some(b)) => (Flow::Investing, b.clone()), + (None, None) if a.tax_category.as_deref() == Some("guaranteed_payments") => { + (Flow::Operating, Bucket::GuaranteedPayments) + } + (None, None) => (Flow::Operating, Bucket::Income), + } + } else { + roles.place(a) + }; + let mut part = Part { + account: line.account, + flow, + side: line.side, + amount, + pl, + accum: roles.accum.contains(&line.account), + bucket, + }; + // Rule 12: the investing part of a bill's payables line. + if !pl + && flow == Flow::Operating + && let Some((inv, kind)) = splits.parts.get(&(basis.id(), i)) + { + let inv = (*inv).min(amount); + if inv > Decimal::ZERO { + parts.push(Part { + flow: Flow::Investing, + amount: inv, + bucket: kind.clone(), + ..part.clone() + }); + } + part.amount = amount - inv; + if part.amount.is_zero() { + continue; + } + } + parts.push(part); + } + let tagged: Vec<(Flow, Side, Decimal)> = + parts.iter().map(|p| (p.flow, p.side, p.amount)).collect(); + let Some(cash) = kernel::entry_flows(&tagged) else { + let net = kernel::net(tagged.iter().map(|&(_, s, a)| (s, a))) + .ok_or_else(|| too_large("an entry's cash flows"))?; + return Err(if net.is_zero() { + too_large("an entry's cash flows") + } else { + LedgerError::CashFlowDoesNotFoot { + what: format!("entry #{}", entry.id()), + expected: Decimal::ZERO, + found: net, + } + }); + }; + // Each section's movement by kind, in kind order. + let mut work: [Vec; 3] = Default::default(); + for (k, section) in Flow::SECTIONS.iter().enumerate() { + let mut moved: BTreeMap = BTreeMap::new(); + for p in parts.iter().filter(|p| p.flow == *section) { + let m = moved.entry(p.bucket.clone()).or_default(); + *m = add(*m, p.signed(), "a kind's movement")?; + } + work[k] = moved + .into_iter() + .map(|(bucket, moved)| KindWork { + bucket, + moved, + cash: Decimal::ZERO, + on_behalf: Decimal::ZERO, + }) + .collect(); + // Rule 3: the section's cash among its kinds, in kind order (the + // convention again, within the section). + if !cash.flows[k].is_zero() { + let movements: Vec = work[k].iter().map(|w| w.moved).collect(); + let mut applied = vec![Decimal::ZERO; movements.len()]; + kernel::apply_cash(cash.flows[k], &movements, &mut applied).ok_or_else(|| { + LedgerError::CashFlowDoesNotFoot { + what: format!("entry #{} kinds", entry.id()), + expected: cash.flows[k], + found: Decimal::ZERO, + } + })?; + for (w, a) in work[k].iter_mut().zip(applied) { + w.cash = a; + } + } + } + // Rule 11 (D-42): what no cash settled, a member or a lender paid (or + // received) for the company. A financing liability pays for operating + // and investing kinds; a member's equity only for revenue and expense + // (property a member contributes is noncash, ASC 230-10-50-4). Either + // receives for the company only what a third party pays cash for: + // revenue, a refund of an expense, a receivable collected (property + // handed over in kind is noncash). Never interest or guaranteed + // payments: those are the lender's or the member's own charge, accrued + // into what the company owes them, paid to no one. A reversal mirrors + // its root (rule 6): what the root paid, it receives back. + let lender = |k: usize, b: &Bucket| k == 2 && matches!(b, Bucket::Borrowing(_)); + let member = + |k: usize, b: &Bucket| k == 2 && matches!(b, Bucket::Member(_) | Bucket::Equity(_)); + let lender_pays_for = |k: usize, b: &Bucket| { + (k == 0 && matches!(b, Bucket::Operating | Bucket::Receivables | Bucket::Income)) || k == 1 + }; + let member_pays_for = |k: usize, b: &Bucket| k == 0 && *b == Bucket::Income; + let receives_for = + |k: usize, b: &Bucket| k == 0 && matches!(b, Bucket::Receivables | Bucket::Income); + let way = |w: OnBehalf| if flip { w.mirror() } else { w }; + on_behalf(&mut work, way(OnBehalf::Paid), lender, lender_pays_for)?; + on_behalf(&mut work, way(OnBehalf::Received), lender, receives_for)?; + on_behalf(&mut work, way(OnBehalf::Paid), member, member_pays_for)?; + on_behalf(&mut work, way(OnBehalf::Received), member, receives_for)?; + // The entry's flows: each section's cash, real and constructive (the + // latter nets to zero within the entry, so they still sum to its + // cash), and what neither settled. + let mut flows = EntryFlows { + cash: cash.cash, + flows: [Decimal::ZERO; 3], + noncash: [Decimal::ZERO; 3], + }; + let mut kinds: [Vec; 3] = Default::default(); + // Cash in and out of investing and financing, for rule 7. + let (mut inflow, mut outflow) = (Decimal::ZERO, Decimal::ZERO); + for (k, ws) in work.into_iter().enumerate() { + for w in ws { + let total = add(w.cash, w.on_behalf, "a kind's cash")?; + flows.flows[k] = add(flows.flows[k], total, "a section's cash")?; + flows.noncash[k] = add(flows.noncash[k], w.left()?, "a section's noncash part")?; + if k > 0 { + if total > Decimal::ZERO { + inflow = add(inflow, total, "an entry's exchange")?; + } else { + outflow = add(outflow, -total, "an entry's exchange")?; + } + } + if !total.is_zero() { + kinds[k].push(KindCash { + bucket: w.bucket, + cash: total, + on_behalf: w.on_behalf, + }); + } + } + } + // Rule 7: the exchange inside investing and financing, account by + // account (accumulated depreciation and gains and losses left out). + let mut net: BTreeMap = BTreeMap::new(); + for p in &parts { + if matches!(p.flow, Flow::Investing | Flow::Financing) && !p.pl && !p.accum { + let m = net.entry(p.account).or_default(); + *m = add(*m, p.signed(), "an entry's exchange")?; + } + } + let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO); + for m in net.into_values() { + if m > Decimal::ZERO { + debits = add(debits, m, "an entry's exchange")?; + } else { + credits = add(credits, -m, "an entry's exchange")?; + } + } + let exchanged = kernel::exchanged(debits, credits, inflow, outflow) + .ok_or_else(|| too_large("an entry's exchange"))?; + Ok(Analysed { + entry, + chain, + depth, + flip, + basis, + parts, + flows, + kinds, + exchanged, + }) +} + +/// Reversal links, gathered once (`Ledger::reversal_chain` searches the +/// whole log at each step). +struct Chains { + reversed_by: HashMap, +} + +impl Chains { + fn new(ledger: &Ledger) -> Self { + let mut reversed_by = HashMap::new(); + for e in ledger.posted_entries() { + if let Some(o) = e.reversal_of() { + // Each entry is reversed at most once; the first stands. + reversed_by.entry(o).or_insert(e.id()); + } + } + Chains { reversed_by } + } + + /// As `Ledger::reversal_chain`: the original it reverses (and so on + /// up), itself, the entry reversing it (and so on down). + fn chain(&self, ledger: &Ledger, entry: EntryId) -> Vec { + let mut top = entry; + let mut up = 0; + while let Some(o) = ledger.get_entry(top).and_then(JournalEntry::reversal_of) { + if ledger.get_entry(o).is_none() || o == entry || up > self.reversed_by.len() { + break; + } + top = o; + up += 1; + } + let mut chain = vec![top]; + let mut cur = top; + while let Some(&r) = self.reversed_by.get(&cur) { + if chain.contains(&r) { + break; + } + chain.push(r); + cur = r; + } + chain + } +} + +fn negated(f: &EntryFlows) -> EntryFlows { + EntryFlows { + cash: -f.cash, + flows: f.flows.map(|x| -x), + noncash: f.noncash.map(|x| -x), + } +} + +/// Rule 6: pair each entry with its reversal when both are in the period, +/// walking each chain from its root, so a reversal of a reversal is shown +/// (review item 1). A pair nets only when its flows, kinds and exchange +/// are exact negatives (review item 8); otherwise both are shown. +fn pairs(kept: &[Analysed<'_>]) -> HashSet { + let at: HashMap = kept + .iter() + .enumerate() + .map(|(i, a)| (a.entry.id(), i)) + .collect(); + let mut paired = HashSet::new(); + let mut walked = HashSet::new(); + for a in kept { + if !walked.insert(a.chain[0]) { + continue; + } + let chain = &a.chain; + let mut i = 0; + while i + 1 < chain.len() { + let (Some(&x), Some(&r)) = (at.get(&chain[i]), at.get(&chain[i + 1])) else { + i += 1; + continue; + }; + let (x, r) = (&kept[x], &kept[r]); + let mirror = r.flows == negated(&x.flows) + && r.exchanged == x.exchanged + && (0..3).all(|k| { + r.kinds[k].len() == x.kinds[k].len() + && r.kinds[k].iter().zip(&x.kinds[k]).all(|(rk, xk)| { + rk.bucket == xk.bucket + && rk.cash == -xk.cash + && rk.on_behalf == -xk.on_behalf + }) + }); + if mirror { + paired.insert(x.entry.id()); + paired.insert(r.entry.id()); + i += 2; + } else { + i += 1; + } + } + } + paired +} + +/// A line's or disclosure's label (rule 6): never from the sign. +fn label(ledger: &Ledger, a: &Analysed<'_>) -> String { + let id = a.entry.id(); + match a.entry.reversal_of() { + None => format!("{} (#{id})", a.entry.description()), + Some(orig) if a.depth >= 2 && a.depth.is_multiple_of(2) => { + format!("Reinstatement of #{} (reverses #{orig})", a.chain[0]) + } + Some(orig) => { + let what = ledger + .get_entry(orig) + .map_or(String::new(), |o| format!(" — {}", o.description())); + format!("Reversal of #{orig}{what}") + } + } +} + +/// Rule 5: the class of one kind of an entry's operating cash, from the +/// direction of its root's cash: interest received or paid by its own +/// lines (whatever else the entry settles); everything else by the +/// structure of the entry's other operating lines. +fn class_of( + roles: &Roles<'_>, + a: &Analysed<'_>, + bucket: &Bucket, + receipt: bool, +) -> Result { + match (bucket, receipt) { + (Bucket::InterestReceived, true) => { + return Ok(OperatingClass::InterestAndDividendsReceived); + } + // Interest paid back out, or refunded to the company: neither (b) + // nor (e), so the other class of its direction. + (Bucket::InterestReceived, false) => return Ok(OperatingClass::OtherPayments), + (Bucket::InterestPaid, false) => return Ok(OperatingClass::InterestPaid), + (Bucket::InterestPaid, true) => return Ok(OperatingClass::OtherReceipts), + _ => {} + } + let mut ops: Vec<&Account> = Vec::new(); + for p in a.parts.iter().filter(|p| { + p.flow == Flow::Operating + && matches!( + p.bucket, + Bucket::Operating + | Bucket::Receivables + | Bucket::Income + | Bucket::GuaranteedPayments + ) + }) { + ops.push(roles.account(p.account)?); + } + let receivable = |x: &Account| Some(x.id) == roles.receivables; + Ok(if receipt { + let customer = roles + .ledger + .entry_parties(a.basis.id()) + .iter() + .filter_map(|s| roles.ledger.get_party(s.party)) + .any(|p| p.all_roles().contains(&crate::Role::Customer)); + if customer + || ops + .iter() + .any(|x| x.account_type == AccountType::Revenue || receivable(x)) + { + OperatingClass::FromCustomers + } else { + OperatingClass::OtherReceipts + } + } else if !ops.is_empty() + && ops + .iter() + .all(|x| x.tax_category.as_deref() == Some("taxes_licenses")) + { + // Taxes and fees paid to governments are not suppliers' (ASC + // 230-10-45-17(c) apart from 45-17(b)); 45-25 names only income + // taxes, (f), so the rest are (g). + OperatingClass::OtherPayments + } else if ops.iter().any(|x| match x.account_type { + AccountType::Expense | AccountType::Liability => true, + AccountType::Asset => !receivable(x), + _ => false, + }) { + OperatingClass::ToSuppliersAndEmployees + } else { + OperatingClass::OtherPayments + }) +} + +/// The codes of the accounts in some parts, sorted, once each. +fn codes<'p>( + roles: &Roles<'_>, + parts: impl Iterator, +) -> Result, LedgerError> { + let mut out = BTreeSet::new(); + for p in parts { + out.insert(roles.account(p.account)?.code.clone()); + } + Ok(out.into_iter().collect()) +} + +/// The statement of cash flows for a period, direct method (rules.md +/// B-14). +/// +/// ```text +/// GOAL: the statement of cash flows for a period: each entry's cash by +/// section and kind, the noncash investing and financing activities, +/// and net income reconciled to operating cash; footing exactly or +/// not at all. +/// SOURCE: rules.md B-14 (ASC 230-10-10-1 cash receipts and payments; +/// 45-4, 45-5 what cash is; 45-7, 45-26 gross; 45-10 the three +/// sections; 45-12 to 45-17 what each holds; 45-24 reconciles +/// beginning and ending cash; 45-25 the +/// operating classes; 45-28 to 45-30 the reconciliation, a +/// separate schedule with the direct method; 50-3 to 50-5 noncash +/// activities disclosed, only the cash portion reported; D-35; +/// D-42 constructive receipt and disbursement, PwC FSP 6.9.3.1, +/// DART Roadmap 7.2, BDO Blueprint 3.4: ASC 230 is silent). +/// INVARIANTS: +/// 1. Every non-cash account MUST be in exactly one section: investing +/// (registered fixed assets' cost and accumulated depreciation, +/// investing_accounts), financing (equity, financing_accounts), or +/// operating (everything else); contradictory roles are refused, +/// naming the setting. +/// 2. An entry MUST contribute to the body only its cash, applied to the +/// sections moving against it in the order operating, investing, +/// financing, each up to its own movement (CONVENTION: the order), +/// and what a member or lender paid or received for it (rule 11), +/// which nets to zero within the entry; what neither settled is +/// disclosed, never reported as cash. +/// 3. The revenue and expense lines of an entry crediting a registered +/// cost or investing account MUST be investing, interest (received +/// or paid) excepted: it is operating in any entry, and its own +/// class; a reversal takes its chain root's sections, classes and +/// kinds, with its own sign. +/// 4. Each section's total MUST equal the sum of its lines; the +/// sections MUST sum to the cash accounts' change (beginning to +/// ending balance); net income plus the reconciling items MUST equal +/// operating. Any failure is CashFlowDoesNotFoot and nothing is +/// produced. +/// 5. An entry and its reversal both in the period MUST drop out of +/// every line and disclosure, pairing along the chain from its root; +/// a reversal shown alone is labelled "Reversal of #N" ("Reinstatement +/// of #A" a level down), NEVER by the sign. +/// 6. NEVER count closing entries. +/// 7. The period is [start, end], inclusive. +/// SCENARIOS: +/// 1. IF a member contributes property (Dr equipment / Cr capital): +/// - SHOULD show no line, and disclose it as noncash +/// BECAUSE no cash moved (ASC 230-10-50-3). +/// 2. IF a member pays a company expense personally (Dr expense / Cr +/// a financing liability, or / Cr the member's capital), or a lender +/// pays for equipment (Dr equipment / Cr the loan): +/// - SHOULD show it as borrowed (or contributed) and spent, on that +/// entry's date: a financing inflow and the operating or investing +/// outflow, `on_behalf` on both lines +/// - NEVER disclose it as noncash +/// BECAUSE the owner chose constructive receipt and disbursement +/// (D-42: ASC 230 is silent; the firm guides present it so); its +/// reimbursement is a financing outflow. +/// 3. IF interest is charged into a loan (Dr interest / Cr the loan): +/// - SHOULD reconcile it as noncash, and disclose it +/// BECAUSE the lender's own charge was paid to no one. +/// 4. IF equipment is bought on the seller's credit (an operating +/// payable), or sold for a receivable (an IOU): +/// - SHOULD disclose the noncash investing part and warn, with a fix +/// BECAUSE no cash moved (ASC 230-10-50-4), and its later payment or +/// collection would be operating (D-35). +/// ``` +pub fn cash_flow( + ledger: &Ledger, + params: &CashFlowParams, +) -> Result { + if params.end < params.start { + return Err(LedgerError::InvalidDateRange { + start: params.start.to_string(), + end: params.end.to_string(), + }); + } + let roles = Roles::checked(ledger, params)?; + let chains = Chains::new(ledger); + let splits = bill_splits(&roles, &chains)?; + let closings = ledger.closing_entry_ids(); + let in_period = |d: NaiveDate| d >= params.start && d <= params.end; + + // Rules 1, 3, 4: every non-closing entry in the period. + let mut kept: Vec> = Vec::new(); + for entry in ledger.posted_entries() { + if in_period(entry.date()) && !closings.contains(&entry.id()) { + kept.push(analyse(&roles, &splits, &chains, entry)?); + } + } + let paired = pairs(&kept); + + // Rule 5: the body, one line per entry, section and kind. + type Sorted = (KindOrder, NaiveDate, EntryId, CashFlowLine); + let mut body: [Vec; 3] = Default::default(); + let mut totals = [Decimal::ZERO; 3]; + for a in &kept { + for (t, f) in totals.iter_mut().zip(a.flows.flows) { + *t = add(*t, f, "a section's total")?; + } + if paired.contains(&a.entry.id()) { + continue; + } + for (k, section) in CashFlowSection::ALL.iter().enumerate() { + // This entry's lines in the section: kinds that name the same + // caption (two operating kinds of one class) are one line. + let mut mine: Vec = Vec::new(); + for KindCash { + bucket, + cash: amount, + on_behalf, + } in &a.kinds[k] + { + let inflow = (*amount > Decimal::ZERO) != a.flip; + let (order, kind, class) = if *section == CashFlowSection::Operating { + let class = class_of(&roles, a, bucket, inflow)?; + ( + (class.order(), 0, String::new()), + class.label().to_string(), + Some(class), + ) + } else { + let (order, kind) = roles.kind(bucket, inflow); + (order, kind, None) + }; + let accounts = codes( + &roles, + a.parts + .iter() + .filter(|p| p.flow == Flow::SECTIONS[k] && p.bucket == *bucket), + )?; + if let Some((_, _, _, same)) = + mine.iter_mut().find(|m| m.0 == order && m.3.kind == kind) + { + same.amount = add(same.amount, *amount, "an entry's line")?; + same.on_behalf = add(same.on_behalf, *on_behalf, "an entry's line")?; + same.accounts.extend(accounts); + same.accounts.sort(); + same.accounts.dedup(); + continue; + } + let line = CashFlowLine { + entry: a.entry.id(), + date: a.entry.date(), + label: label(ledger, a), + kind, + amount: *amount, + on_behalf: *on_behalf, + accounts, + reverses: a.entry.reversal_of(), + class, + }; + mine.push((order, a.entry.date(), a.entry.id(), line)); + } + body[k].extend(mine.into_iter().filter(|m| !m.3.amount.is_zero())); + } + } + let mut lines: [Vec; 3] = Default::default(); + for (((out, mut sorted), total), section) in lines + .iter_mut() + .zip(body) + .zip(totals) + .zip(CashFlowSection::ALL) + { + sorted.sort_by(|x, y| (&x.0, x.1, x.2).cmp(&(&y.0, y.1, y.2))); + *out = sorted.into_iter().map(|(_, _, _, l)| l).collect(); + // Rule 9: each section's total is its lines (the pairs net to + // zero). + let mut sum = Decimal::ZERO; + for l in out.iter() { + sum = add(sum, l.amount, "a section's lines")?; + } + if sum != total { + return Err(LedgerError::CashFlowDoesNotFoot { + what: format!("{} lines", section.key()), + expected: total, + found: sum, + }); + } + } + + // Rule 7: noncash investing and financing activities. + let mut noncash_disclosures = Vec::new(); + for a in kept.iter().filter(|a| !paired.contains(&a.entry.id())) { + let r = a.flows.noncash; + let investing_asset_moved = a + .parts + .iter() + .any(|p| p.flow == Flow::Investing && !p.pl && !p.accum); + // A purchase on a bill: only what is still unpaid at the end is + // noncash (what was paid is an investing outflow already). + let mut exchanged = a.exchanged; + if a.depth == 0 + && let Some(paid) = splits.paid.get(&a.entry.id()) + { + for &(date, part) in paid { + if date <= params.end { + exchanged -= part; + } + } + exchanged = exchanged.max(Decimal::ZERO); + } + let disclose = !r[2].is_zero() + || (!r[1].is_zero() && investing_asset_moved) + || exchanged > Decimal::ZERO; + if !disclose { + continue; + } + let mut moved = Decimal::ZERO; + for x in r { + if x > Decimal::ZERO { + moved = add(moved, x, "a disclosure")?; + } + } + noncash_disclosures.push(NoncashDisclosure { + entry: a.entry.id(), + date: a.entry.date(), + label: label(ledger, a), + amount: moved.max(exchanged), + operating: r[0], + investing: r[1], + financing: r[2], + exchanged, + reverses: a.entry.reversal_of(), + }); + } + noncash_disclosures.sort_by_key(|d| (d.date, d.entry)); + + let operating_reconciliation = reconcile(&roles, &kept, &paired, totals[0])?; + let warnings = warnings(&roles, &kept, &paired)?; + + // Rule 9: the cash accounts' balances, and the footing. + let (mut cash_beginning, mut cash_ending) = (Decimal::ZERO, Decimal::ZERO); + for e in ledger.posted_entries() { + for l in e.lines() { + if roles.cash.contains(&l.account) { + let m = signed(l.side, l.amount.amount); + if e.date() < params.start { + cash_beginning = add(cash_beginning, m, "the cash balance")?; + } + if e.date() <= params.end { + cash_ending = add(cash_ending, m, "the cash balance")?; + } + } + } + } + let net_change_in_cash = cash_ending + .checked_sub(cash_beginning) + .ok_or_else(|| too_large("the change in cash"))?; + let sections_sum = add( + add(totals[0], totals[1], "the sections' sum")?, + totals[2], + "the sections' sum", + )?; + let reconciles = foot(sections_sum, net_change_in_cash)?; + let [operating, investing, financing] = lines; + Ok(CashFlowStatement { + start: params.start, + end: params.end, + operating, + cash_from_operations: totals[0], + investing, + cash_from_investing: totals[1], + financing, + cash_from_financing: totals[2], + net_change_in_cash, + cash_beginning, + cash_ending, + noncash_disclosures, + operating_reconciliation, + reconciles, + warnings, + }) +} + +/// Rule 8: net cash from operating = NI + G + X + W, over every kept entry +/// (netted pairs included: they sum to zero), checked against the +/// operating section. +fn reconcile( + roles: &Roles<'_>, + kept: &[Analysed<'_>], + paired: &HashSet, + operating: Decimal, +) -> Result { + const WHAT: &str = "the operating reconciliation"; + let mut net_income = Decimal::ZERO; + let mut gains = (Decimal::ZERO, Vec::new()); + // X by key: 0 depreciation, 1 through an account (by code), 2 other. + let mut noncash: BTreeMap<(u8, String), (Decimal, Vec)> = BTreeMap::new(); + // W by code: the operating balance-sheet accounts' movements. + let mut balances: BTreeMap = BTreeMap::new(); + for a in kept { + let shown = !paired.contains(&a.entry.id()); + let mut g = Decimal::ZERO; + for p in &a.parts { + if p.pl { + net_income = add(net_income, -p.signed(), WHAT)?; + if p.flow == Flow::Investing { + g = add(g, p.signed(), WHAT)?; + } + } else if p.flow == Flow::Operating { + let code = roles.account(p.account)?.code.clone(); + let w = balances.entry(code).or_insert((p.account, Decimal::ZERO)); + w.1 = add(w.1, p.signed(), WHAT)?; + } + } + if !g.is_zero() { + gains.0 = add(gains.0, g, WHAT)?; + if shown { + gains.1.push(a.entry.id()); + } + } + let x = a.flows.noncash[0]; + if x.is_zero() { + continue; + } + let others: Vec<&Part> = a + .parts + .iter() + .filter(|p| matches!(p.flow, Flow::Investing | Flow::Financing) && !p.pl) + .collect(); + let through: BTreeSet = others + .iter() + .filter(|p| !p.accum) + .map(|p| p.account) + .collect(); + let key = if !others.is_empty() && through.is_empty() { + (0, String::new()) + } else if through.len() == 1 { + let id = *through.iter().next().expect("one account"); + (1, roles.account(id)?.code.clone()) + } else { + (2, String::new()) + }; + let slot = noncash.entry(key).or_insert((Decimal::ZERO, Vec::new())); + slot.0 = add(slot.0, x, WHAT)?; + if shown { + slot.1.push(a.entry.id()); + } + } + let mut lines = Vec::new(); + let chart = roles.ledger.chart(); + let named = |code: &str| { + chart + .find_by_code(code) + .map_or(code.to_string(), |a| format!("{} {}", a.code, a.name)) + }; + let mut depreciation = None; + let mut rest = Vec::new(); + for ((key, code), (amount, entries)) in noncash { + if amount.is_zero() { + continue; + } + let line = match key { + 0 => ReconcilingLine { + kind: ReconcilingKind::Depreciation, + label: "Depreciation".to_string(), + amount, + account: None, + entries, + }, + 1 => ReconcilingLine { + kind: ReconcilingKind::NoncashThrough, + label: format!("Noncash: through {}", named(&code)), + amount, + account: Some(code), + entries, + }, + _ => ReconcilingLine { + kind: ReconcilingKind::OtherNoncash, + label: "Other noncash items".to_string(), + amount, + account: None, + entries, + }, + }; + if key == 0 { + depreciation = Some(line); + } else { + rest.push(line); + } + } + lines.extend(depreciation); + if !gains.0.is_zero() { + lines.push(ReconcilingLine { + kind: ReconcilingKind::InvestingGainLoss, + label: "(Gain) loss and other noncash changes in investing assets".to_string(), + amount: gains.0, + account: None, + entries: gains.1, + }); + } + lines.extend(rest); + for (code, (id, movement)) in balances { + if movement.is_zero() { + continue; + } + let a = roles.account(id)?; + let up = match a.account_type { + AccountType::Liability | AccountType::Equity => movement < Decimal::ZERO, + _ => movement > Decimal::ZERO, + }; + lines.push(ReconcilingLine { + kind: ReconcilingKind::OperatingBalanceChange, + label: format!( + "{} {}", + if up { "Increase in" } else { "Decrease in" }, + named(&code) + ), + amount: -movement, + account: Some(code), + entries: Vec::new(), + }); + } + let mut total = net_income; + for l in &lines { + total = add(total, l.amount, WHAT)?; + } + if total != operating { + return Err(LedgerError::CashFlowDoesNotFoot { + what: "operating reconciliation".to_string(), + expected: operating, + found: total, + }); + } + Ok(OperatingReconciliation { + net_income, + lines, + net_cash_from_operating: total, + }) +} + +/// D-35: entries whose later settlement the books can't classify. Only an +/// original (or a reinstatement): its reversal undoes it. +fn warnings( + roles: &Roles<'_>, + kept: &[Analysed<'_>], + paired: &HashSet, +) -> Result, LedgerError> { + let mut out = Vec::new(); + for a in kept { + if paired.contains(&a.entry.id()) || a.depth % 2 == 1 { + continue; + } + let id = a.entry.id(); + // The accounts of some of the entry's parts, "code name", once + // each. + let named = |keep: &dyn Fn(&Part, &Account) -> bool| -> Result, LedgerError> { + let mut v = BTreeSet::new(); + for p in &a.parts { + let acct = roles.account(p.account)?; + if keep(p, acct) { + v.insert(format!("{} {}", acct.code, acct.name)); + } + } + Ok(v.into_iter().collect()) + }; + let investing = |side: Side| { + named(&|p, _| { + p.flow == Flow::Investing && roles.investing_asset(p.account) && p.side == side + }) + }; + let operating = |side: Side, t: AccountType| { + named(&|p, acct| { + p.flow == Flow::Operating && !p.pl && p.side == side && acct.account_type == t + }) + }; + // Only what no cash, investing or financing account settled: an + // investing part left over, against an operating account. + let left = a.flows.noncash[1]; + let bought = investing(Side::Debit)?; + let payables = operating(Side::Credit, AccountType::Liability)?; + if left > Decimal::ZERO && !bought.is_empty() && !payables.is_empty() { + let on = payables.join(", "); + out.push(CashFlowWarning { + entry: id, + kind: CashFlowWarningKind::AssetOnOperatingPayable, + message: format!( + "Entry #{id} acquires {} on {on}, an operating payable: the purchase is disclosed as noncash (the seller's own credit, ASC 230-10-50-4), and its payment will show as cash paid to suppliers (operating), following how {on} is classified. If {on} is a lender's credit (a card balance carried, a loan), list it in books.financing_accounts: the purchase then shows as borrowed and spent (D-42) and its payment as a repayment of borrowing.", + bought.join(", ") + ), + }); + } + let sold = investing(Side::Credit)?; + let receivables = operating(Side::Debit, AccountType::Asset)?; + if left < Decimal::ZERO && !sold.is_empty() && !receivables.is_empty() { + let on = receivables.join(", "); + out.push(CashFlowWarning { + entry: id, + kind: CashFlowWarningKind::AssetSoldOnOperatingReceivable, + message: format!( + "Entry #{id} disposes of {} for an amount owed on {on}, an operating account: its collection would show as cash from operations, but receipts from sales of property and investments are investing (ASC 230-10-45-12). Fix: reverse it and post the amount owed to an account listed in books.investing_accounts (a note receivable), so its collection is investing.", + sold.join(", ") + ), + }); + } + } + Ok(out) +} diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 04ec93d..89c74cc 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -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), diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 4f11ae9..8252efa 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -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( 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 { + /// 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(remaining: T, movement: T) -> Option { + 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(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(lines: &[(Flow, Side, T)]) -> Option> { + 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(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( + 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(debits: T, credits: T, inflow: T, outflow: T) -> Option { + 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)); diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 58f5a77..ccad369 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -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, diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index 8bb12a9..a3a6c88 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -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 // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 007c907..d6151ac 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -1146,465 +1146,14 @@ pub fn snapshot(ledger: &Ledger) -> Result { } // --------------------------------------------------------------------------- -// 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, -} - -/// 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, - /// 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, - /// 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, -} - -/// 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, - /// Net cash from operating activities. - pub cash_from_operations: Decimal, - /// Investing flows: asset purchases and disposal proceeds. - pub investing: Vec, - /// Net cash from investing activities. - pub cash_from_investing: Decimal, - /// Financing flows: equity changes and designated debt. - pub financing: Vec, - /// 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 { - 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 = HashSet::new(); - let mut accum_accounts: HashSet = HashSet::new(); - let mut depr_expense_accounts: HashSet = HashSet::new(); - // cost_account -> accum_account, for disposal identification. - let mut cost_to_accum: HashMap = 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 = params.cash_accounts.iter().copied().collect(); - let financing_set: HashSet = - params.financing_accounts.iter().copied().collect(); - let investing_set: HashSet = - 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 = Vec::new(); - let mut investing: Vec = Vec::new(); - let mut financing: Vec = 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::(); - 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 ¶ms.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)] diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index daa0f24..01c4ce4 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -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", diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs new file mode 100644 index 0000000..b85e620 --- /dev/null +++ b/crates/ledger-core/tests/bookspec.rs @@ -0,0 +1,1261 @@ +//! The bookspec examples (`bookspec/examples/*.toml`, P13, D-34), checked +//! against the engine on every run, so they can't rot. This is the one +//! place every example is loaded. +//! +//! Each example is a setup, events and expected values worked by hand from +//! its cited source (`bookspec/README.md` gives the shape). Every example +//! must load, whatever it asks or whichever appendix it is in: its shape, +//! its accounts, fixed assets, members and payees, and every event, an +//! entry, a reversal or a close, posting entry #N as event N. The queries +//! the core answers (`cash_flow`, `trial_balance`, `profit_and_loss`, +//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`) are then +//! checked value by value, each after the event it names (`after`) or the +//! last; the formats appendix's (`iif_lines`, `iif_changes`) are checked by +//! ledger-package (`the_bookspec_iif_example_matches_the_engine`), and a +//! query no crate answers is refused. A key the shape doesn't have is +//! refused too, never ignored. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use chrono::NaiveDate; +use ledger_core::{ + AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, EntryId, + Form1099, Ledger, MemberCapital, MemberClose, Money, NewAsset, PaymentAccounts, + ReconcilingKind, Side, VendorId, cash_flow, members_equity, profit_and_loss_for_period, + trial_balance_as_of, vendor_1099_report_with_members, +}; +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, + tags: Vec, + /// `"formats"`: an interchange format rather than an accounting rule + /// (P13's appendix of formats); absent for the rules themselves. + #[serde(default)] + appendix: Option, + setup: Setup, + events: Vec, + expect: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Setup { + currency: String, + fiscal_year_start: String, + basis: String, + rounding: String, + accounts: Vec, + #[serde(default)] + cash_accounts: Vec, + #[serde(default)] + financing_accounts: Vec, + #[serde(default)] + investing_accounts: Vec, + #[serde(default)] + fixed_assets: Vec, + #[serde(default)] + members: Vec, + #[serde(default)] + payees: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AccountSpec { + code: String, + name: String, + #[serde(rename = "type")] + kind: String, + /// The parent account's code, declared before it. + #[serde(default)] + parent: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FixedAsset { + cost_account: String, + accumulated_account: String, + expense_account: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MemberSpec { + name: String, + capital_account: String, + /// Their share of income, an exact decimal fraction; every member has + /// one or none does, and the shares sum to 1. A close to the members + /// needs them. + #[serde(default)] + share: Option, +} + +/// A payee marked for an information return (rules.md B-19). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PayeeSpec { + name: String, + form: String, +} + +/// One event: an entry (`lines`), a reversal (`reverses`) or a close +/// (`close`), exactly one of them. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Event { + date: String, + description: String, + #[serde(default)] + lines: Vec, + reverses: Option, + #[serde(default)] + close: Option, + /// The payee (in `setup.payees`) the entry paid. + #[serde(default)] + payee: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LineSpec { + account: String, + side: String, + amount: String, +} + +/// A close of the period from `start` through the event's date, to +/// `"members"` (by share, in the order listed) or `"retained_earnings"`; +/// `rounding = "per-close"`: a members close split on its own (books from +/// an engine that rounded each close by itself), else the cumulative rule +/// (rules.md B-13). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CloseSpec { + start: String, + to: String, + #[serde(default)] + rounding: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Expect { + query: String, + from: Option, + to: Option, + as_of: Option, + /// The entry an `entry` query asks about. + entry: Option, + /// The books after event N, not after the last. + after: Option, + values: Values, +} + +/// What a query expects: figures by key (every figure an exact decimal +/// string), or, for a file format, its lines or its rows of fields. +#[derive(Deserialize)] +#[serde(untagged)] +enum Values { + Figures(BTreeMap), + Lines(Vec), + Rows(Vec>), +} + +impl Values { + fn is_empty(&self) -> bool { + match self { + Values::Figures(f) => f.is_empty(), + Values::Lines(l) => l.is_empty(), + Values::Rows(r) => r.is_empty(), + } + } +} + +impl Expect { + /// The figures a figure query expects; panics on lines or rows. + fn figures(&self, id: &str) -> &BTreeMap { + match &self.values { + Values::Figures(f) => f, + _ => panic!("{id}: {} expects figures (a table)", self.query), + } + } + + /// The period `[from, to]` a period query covers; panics on `as_of`. + fn period(&self, id: &str) -> (NaiveDate, NaiveDate) { + assert!( + self.as_of.is_none(), + "{id}: {} covers a period (from, to), not as_of", + self.query + ); + let from = date( + self.from + .as_deref() + .unwrap_or_else(|| panic!("{id}: {} needs from", self.query)), + id, + ); + let to = date( + self.to + .as_deref() + .unwrap_or_else(|| panic!("{id}: {} needs to", self.query)), + id, + ); + assert!(from <= to, "{id}: {} from {from} after to {to}", self.query); + (from, to) + } + + /// The day an as-of query is made as of; panics on `from` or `to`. + fn day(&self, id: &str) -> NaiveDate { + assert!( + self.from.is_none() && self.to.is_none(), + "{id}: {} is as of a day (as_of), not a period", + self.query + ); + date( + self.as_of + .as_deref() + .unwrap_or_else(|| panic!("{id}: {} needs as_of", self.query)), + id, + ) + } +} + +fn examples_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples") +} + +fn date(s: &str, what: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap_or_else(|e| panic!("{what}: {s:?}: {e}")) +} + +/// An exact decimal string: digits, an optional sign and point; never a +/// float's exponent. +fn exact(s: &str, what: &str) -> Decimal { + assert!( + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_digit() || c == '.' || c == '-') + && s.matches('.').count() <= 1, + "{what}: {s:?} is not an exact decimal string" + ); + s.parse() + .unwrap_or_else(|e| panic!("{what}: {s:?} is not a decimal: {e}")) +} + +/// An example's books: its setup, then its events as they are applied. +struct Books<'a> { + ex: &'a Example, + l: Ledger, + currency: Currency, + /// The members, with their shares (zero when the example gives none). + members: Vec, + /// The payees' vendor records, by name. + payees: BTreeMap, +} + +impl<'a> Books<'a> { + /// The books an example sets up, before any event: its shape checked, + /// its accounts, fixed assets, members and payees. + fn new(ex: &'a Example) -> Self { + let s = &ex.setup; + let currency = Currency::new(&s.currency).unwrap(); + let mut l = Ledger::new(currency); + assert!( + ex.appendix.as_deref().is_none_or(|a| a == "formats"), + "{}: appendix {:?}", + ex.id, + ex.appendix + ); + assert!( + matches!(s.basis.as_str(), "accrual" | "cash"), + "{}: basis {:?}", + ex.id, + s.basis + ); + let (m, d) = s + .fiscal_year_start + .split_once('-') + .unwrap_or_else(|| panic!("{}: fiscal_year_start {:?}", ex.id, s.fiscal_year_start)); + assert!( + NaiveDate::from_ymd_opt(2001, m.parse().unwrap(), d.parse().unwrap()).is_some(), + "{}: fiscal_year_start", + ex.id + ); + assert!( + matches!( + s.rounding.as_str(), + "half-even" | "half-away-from-zero" | "largest-remainder" + ), + "{}: rounding {:?}", + ex.id, + s.rounding + ); + // The engine splits a close among the members by largest + // remainder (rules.md B-13): an example closing to the members + // says so. + assert!( + s.rounding == "largest-remainder" + || !ex + .events + .iter() + .any(|e| e.close.as_ref().is_some_and(|c| c.to == "members")), + "{}: a close to the members rounds by largest-remainder", + ex.id + ); + let id = |l: &Ledger, code: &str| { + l.chart() + .find_by_code(code) + .unwrap_or_else(|| panic!("{}: no account {code}", ex.id)) + .id + }; + for a in &s.accounts { + let t = match a.kind.as_str() { + "asset" => AccountType::Asset, + "liability" => AccountType::Liability, + "equity" => AccountType::Equity, + "revenue" => AccountType::Revenue, + "expense" => AccountType::Expense, + other => panic!("{}: account type {other:?}", ex.id), + }; + // A parent is declared before its subaccounts. + let parent = a.parent.as_deref().map(|p| id(&l, p)); + l.add_account(&a.code, &a.name, t, parent, false) + .unwrap_or_else(|e| panic!("{}: account {}: {e}", ex.id, a.code)); + } + let first = ex + .events + .first() + .map_or(date("2026-01-01", "start"), |e| date(&e.date, "date")); + for fa in &s.fixed_assets { + l.register_asset(NewAsset { + description: format!("Fixed asset on {}", fa.cost_account), + purchase_date: first, + cost: Money::new(Decimal::ONE, currency), + salvage_value: Money::new(Decimal::ZERO, currency), + useful_life_months: 12, + cost_account: id(&l, &fa.cost_account), + accum_depr_account: id(&l, &fa.accumulated_account), + expense_account: id(&l, &fa.expense_account), + }) + .unwrap_or_else(|e| panic!("{}: fixed asset: {e}", ex.id)); + } + let shared = s.members.iter().filter(|m| m.share.is_some()).count(); + assert!( + shared == 0 || shared == s.members.len(), + "{}: every member has a share, or none does", + ex.id + ); + let members: Vec = s + .members + .iter() + .map(|m| MemberCapital { + name: m.name.clone(), + capital_account: id(&l, &m.capital_account), + distribution_account: None, + share: m.share.as_deref().map_or(Decimal::ZERO, |x| { + let share = exact(x, &format!("{} {} share", ex.id, m.name)); + assert!(share > Decimal::ZERO, "{}: {}'s share", ex.id, m.name); + share + }), + }) + .collect(); + if shared > 0 { + let total: Decimal = members.iter().map(|m| m.share).sum(); + assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id); + } + let mut payees = BTreeMap::new(); + for p in &s.payees { + let form = Form1099::parse(&p.form) + .unwrap_or_else(|| panic!("{}: payee {}: form {:?}", ex.id, p.name, p.form)); + let v = l + .add_vendor(&p.name) + .unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)); + l.set_vendor_1099(v, Some(form), false).unwrap(); + assert!( + payees.insert(p.name.clone(), v).is_none(), + "{}: payee {} twice", + ex.id, + p.name + ); + } + Books { + ex, + l, + currency, + members, + payees, + } + } + + 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 + } + + /// Event `n` (counting from 1), which must post entry #n. + fn apply(&mut self, n: EntryId, e: &Event) { + let ex = &self.ex.id; + let when = date(&e.date, &format!("{ex} event {n}")); + assert!( + e.payee.is_none() || (e.reverses.is_none() && e.close.is_none()), + "{ex} event {n}: a reversal takes its original's payee, and a close has none" + ); + 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, &format!("{ex} event {n}")), + when, + ) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + let members: Vec = self + .members + .iter() + .map(|m| MemberClose { + capital: m.capital_account, + share: m.share, + distributions: None, + }) + .collect(); + if c.to == "members" { + assert!( + !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), + "{ex} event {n}: a close to the members needs their shares" + ); + } + 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} event {n}: close {other:?}"), + }; + self.l + .close_period_to(p, &to) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")) + .unwrap_or_else(|| panic!("{ex} event {n}: the close has nothing to close")) + } + (None, Some(of), true) => { + // Events count from 1, as entries do: `reverses = 1` is + // the first event (bookspec/README.md). + assert!( + of >= 1, + "{ex} event {n}: reverses = {of}: events count from 1" + ); + assert!( + (of as EntryId) < n, + "{ex} event {n}: reverses a later event" + ); + self.l + .reverse_entry(of as EntryId, when, &e.description) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")) + } + (None, Some(_), false) => panic!("{ex} event {n}: a reversal has no lines"), + (None, None, false) => { + let entry = self.l.create_entry(when, &e.description); + for line in &e.lines { + let side = match line.side.as_str() { + "debit" => Side::Debit, + "credit" => Side::Credit, + other => panic!("{ex} event {n}: side {other:?}"), + }; + let amount = exact(&line.amount, ex); + assert!( + amount > Decimal::ZERO, + "{ex} event {n}: amounts are positive" + ); + let account = self.id(&line.account); + self.l + .add_line(entry, account, side, Money::new(amount, self.currency)) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + } + self.l + .post_entry(entry) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + entry + } + _ => panic!("{ex} event {n}: an event is one of lines, reverses or close"), + }; + assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}"); + if let Some(p) = &e.payee { + let v = *self + .payees + .get(p) + .unwrap_or_else(|| panic!("{ex} event {n}: payee {p:?} not in setup")); + self.l + .set_entry_vendor(n, Some(v)) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + } + } +} + +/// The books an example sets up, with every event applied and nothing +/// checked. +fn load(ex: &Example) -> Books<'_> { + let mut b = Books::new(ex); + for (i, e) in ex.events.iter().enumerate() { + b.apply(i as EntryId + 1, e); + } + b +} + +/// The `cash_flow` query's value for `key` (bookspec/README.md), or None +/// for a key it doesn't know. +fn cash_flow_value(cf: &CashFlowStatement, key: &str) -> Option { + let sections = [ + ("operating", CashFlowSection::Operating), + ("investing", CashFlowSection::Investing), + ("financing", CashFlowSection::Financing), + ]; + let count = |n: usize| Decimal::from(n as u64); + Some(match key { + "net_change" => cf.net_change_in_cash, + "cash_beginning" => cf.cash_beginning, + "cash_ending" => cf.cash_ending, + "difference" => cf.reconciles.difference, + "net_income" => cf.operating_reconciliation.net_income, + "noncash.count" => count(cf.noncash_disclosures.len()), + _ => { + if let Some(&(_, s)) = sections.iter().find(|(k, _)| *k == key) { + return Some(cf.total(s)); + } + let (head, tail) = key.split_once('.')?; + if head == "noncash" { + let n: EntryId = tail.parse().ok()?; + return Some( + cf.noncash_disclosures + .iter() + .filter(|d| d.entry == n) + .map(|d| d.amount) + .sum(), + ); + } + if head == "reconciliation" { + let (kind, account) = match tail.split_once('.') { + Some((k, a)) => (k, Some(a)), + None => (tail, None), + }; + let kind = match kind { + "depreciation" => ReconcilingKind::Depreciation, + "investing_gain_loss" => ReconcilingKind::InvestingGainLoss, + "noncash_through" => ReconcilingKind::NoncashThrough, + "other_noncash" => ReconcilingKind::OtherNoncash, + "balance_change" => ReconcilingKind::OperatingBalanceChange, + _ => return None, + }; + return Some( + cf.operating_reconciliation + .lines + .iter() + .filter(|l| l.kind == kind && l.account.as_deref() == account) + .map(|l| l.amount) + .sum(), + ); + } + let &(_, s) = sections.iter().find(|(k, _)| *k == head)?; + let lines = cf.lines(s); + if tail == "count" { + let entries: BTreeSet = lines.iter().map(|l| l.entry).collect(); + return Some(count(entries.len())); + } + // `
.`, or `
..on_behalf`: of entry N's + // cash there, what a member or lender handled for the company. + let (n, on_behalf) = match tail.split_once('.') { + Some((n, "on_behalf")) => (n, true), + Some(_) => return None, + None => (tail, false), + }; + let n: EntryId = n.parse().ok()?; + lines + .iter() + .filter(|l| l.entry == n) + .map(|l| if on_behalf { l.on_behalf } else { l.amount }) + .sum() + } + }) +} + +/// The reconciliation key a line answers to. +fn reconciliation_key(l: &ledger_core::ReconcilingLine) -> String { + let kind = match l.kind { + ReconcilingKind::Depreciation => "depreciation", + ReconcilingKind::InvestingGainLoss => "investing_gain_loss", + ReconcilingKind::NoncashThrough => "noncash_through", + ReconcilingKind::OtherNoncash => "other_noncash", + ReconcilingKind::OperatingBalanceChange => "balance_change", + }; + match &l.account { + Some(a) => format!("reconciliation.{kind}.{a}"), + None => format!("reconciliation.{kind}"), + } +} + +/// Check one `cash_flow` expectation, every value it gives. +fn check_cash_flow(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { + let (start, end) = x.period(&ex.id); + let values = x.figures(&ex.id); + let params = CashFlowParams { + start, + end, + cash_accounts: codes(l, &ex.setup.cash_accounts, &ex.id), + financing_accounts: codes(l, &ex.setup.financing_accounts, &ex.id), + investing_accounts: codes(l, &ex.setup.investing_accounts, &ex.id), + members: members.to_vec(), + }; + let cf = cash_flow(l, ¶ms).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); + for (key, want) in values { + let want = exact(want, &format!("{} {key}", ex.id)); + let got = cash_flow_value(&cf, key) + .unwrap_or_else(|| panic!("{}: unknown cash_flow key {key:?}", ex.id)); + assert_eq!( + got, want, + "{} [{} .. {}] {key}", + ex.id, params.start, params.end + ); + } + // A count given with entries listed: those are all the entries. + for section in ["operating", "investing", "financing", "noncash"] { + let listed: BTreeSet<&str> = values + .keys() + .filter_map(|k| k.strip_prefix(section)?.strip_prefix('.')) + .filter(|t| *t != "count") + .map(|t| t.split_once('.').map_or(t, |(n, _)| n)) + .collect(); + if let Some(n) = values.get(&format!("{section}.count")) + && !listed.is_empty() + { + assert_eq!( + Decimal::from(listed.len() as u64), + exact(n, &ex.id), + "{}: {section}.count against the entries listed", + ex.id + ); + } + } + // Reconciliation keys given: every line the statement has is one. + if values.keys().any(|k| k.starts_with("reconciliation.")) { + for line in &cf.operating_reconciliation.lines { + let key = reconciliation_key(line); + assert!( + values.contains_key(&key), + "{}: the statement has {key} ({}), the example doesn't", + ex.id, + line.amount + ); + } + } +} + +/// The accounts the codes name. +fn codes(l: &Ledger, cs: &[String], id: &str) -> Vec { + cs.iter() + .map(|c| { + l.chart() + .find_by_code(c) + .unwrap_or_else(|| panic!("{id}: no account {c}")) + .id + }) + .collect() +} + +/// Every key is one of `keys`, and every one of `keys` is given: a figure +/// query lists everything it covers, so an example can't leave a figure +/// unchecked by leaving it out. +fn exactly(ex: &Example, x: &Expect, keys: &BTreeSet) { + let given: BTreeSet = x.figures(&ex.id).keys().cloned().collect(); + assert_eq!( + &given, keys, + "{}: {} lists exactly what it covers", + ex.id, x.query + ); +} + +/// Check one `trial_balance` expectation: each account's balance as of +/// the day, debits less credits (a credit balance negative), every +/// account listed (rules.md B-6, B-33). +fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) { + let as_of = x.day(&ex.id); + let tb = trial_balance_as_of(l, as_of); + assert!(tb.balanced(), "{}: the trial balance balances", ex.id); + let keys = ex.setup.accounts.iter().map(|a| a.code.clone()).collect(); + exactly(ex, x, &keys); + for (code, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{} {code}", ex.id)); + let got: Decimal = tb + .lines + .iter() + .filter(|t| &t.code == code) + .map(|t| t.debit - t.credit) + .sum(); + assert_eq!(got, want, "{} trial balance as of {as_of}: {code}", ex.id); + } +} + +/// Check one `profit_and_loss` expectation: each revenue and expense +/// account's activity over the period on its normal side, closing entries +/// left out, and `net_income`, revenue less expenses; every revenue and +/// expense account listed (rules.md B-14, B-33). +fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) { + let (start, end) = x.period(&ex.id); + let pl = profit_and_loss_for_period(l, start, end, None) + .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); + let mut keys: BTreeSet = ex + .setup + .accounts + .iter() + .filter(|a| matches!(a.kind.as_str(), "revenue" | "expense")) + .map(|a| a.code.clone()) + .collect(); + keys.insert("net_income".to_string()); + exactly(ex, x, &keys); + for (key, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{} {key}", ex.id)); + let got = if key == "net_income" { + pl.net_income() + } else { + pl.revenue + .iter() + .chain(&pl.expenses) + .filter(|s| &s.code == key) + .map(|s| s.amount) + .sum() + }; + assert_eq!( + got, want, + "{} profit and loss [{start} .. {end}]: {key}", + ex.id + ); + } +} + +/// Check one `vendor_1099_paid` expectation: each payee's payments out of +/// the cash (and financing) accounts from January 1 through `to`, a +/// payment card's left out, every payee listed (rules.md B-19, B-33). +fn check_vendor_1099_paid( + ex: &Example, + l: &Ledger, + payees: &BTreeMap, + x: &Expect, +) { + let (from, to) = x.period(&ex.id); + let year = chrono::Datelike::year(&to); + assert_eq!( + Some(from), + NaiveDate::from_ymd_opt(year, 1, 1), + "{}: a 1099 counts a calendar year: from is January 1 of to's year", + ex.id + ); + assert!( + !ex.setup.cash_accounts.is_empty(), + "{}: vendor_1099_paid needs cash_accounts (what payees are paid from)", + ex.id + ); + let sources = PaymentAccounts { + cash: codes(l, &ex.setup.cash_accounts, &ex.id), + financing: codes(l, &ex.setup.financing_accounts, &ex.id), + }; + let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &[]) + .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); + exactly(ex, x, &payees.keys().cloned().collect()); + for (name, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{} {name}", ex.id)); + let got: Decimal = r + .vendors + .iter() + .filter(|v| v.vendor_id == payees[name]) + .map(|v| v.paid) + .sum(); + assert_eq!(got, want, "{} 1099 paid [{from} .. {to}]: {name}", ex.id); + } +} + +/// The formats appendix's queries, and the test (another crate's) that +/// checks them against the engine. +const FORMAT_QUERIES: [(&str, &str); 2] = [ + ( + "iif_lines", + "ledger-package the_bookspec_iif_example_matches_the_engine", + ), + ( + "iif_changes", + "ledger-package the_bookspec_iif_example_matches_the_engine", + ), +]; + +/// A format query's shape, checked here; its values are checked by +/// [`FORMAT_QUERIES`]' test. +fn check_format_shape(ex: &Example, x: &Expect) { + assert_eq!( + ex.appendix.as_deref(), + Some("formats"), + "{}: {} is a format's: the example is in appendix \"formats\"", + ex.id, + x.query + ); + x.day(&ex.id); + assert!( + x.after.is_none(), + "{}: {}: a format query is of the books after every event", + ex.id, + x.query + ); + match (x.query.as_str(), &x.values) { + ("iif_lines", Values::Lines(_)) => {} + ("iif_changes", Values::Rows(rows)) => { + for r in rows { + assert_eq!(r.len(), 3, "{}: iif_changes row {r:?}", ex.id); + } + } + (q, _) => panic!("{}: {q}: values of the wrong shape", ex.id), + } +} + +/// Check one `entry` expectation: entry N's lines, `".debit"` and +/// `".credit"` each summing that account's lines on that side, +/// every line of the entry named. +fn check_entry(ex: &Example, l: &Ledger, x: &Expect) { + assert!( + x.from.is_none() && x.to.is_none() && x.as_of.is_none(), + "{}: entry asks about one entry, no dates", + ex.id + ); + let n = x + .entry + .unwrap_or_else(|| panic!("{}: an entry query names its entry", ex.id)); + let entry = l + .get_entry(n) + .unwrap_or_else(|| panic!("{}: no entry {n}", ex.id)); + let mut got: BTreeMap = BTreeMap::new(); + for line in entry.lines() { + let code = &l.chart().get(line.account).unwrap().code; + let side = match line.side { + Side::Debit => "debit", + Side::Credit => "credit", + }; + *got.entry(format!("{code}.{side}")).or_default() += line.amount.amount; + } + let keys = got.keys().cloned().collect(); + exactly(ex, x, &keys); + for (key, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{} {key}", ex.id)); + assert_eq!(got[key], want, "{} entry {n}: {key}", ex.id); + } +} + +/// Check one `balances` expectation: each account named, its balance as +/// of the day on its normal side (so a member's capital after a loss is +/// negative); the accounts named are the example's choice. +fn check_balances(ex: &Example, l: &Ledger, x: &Expect) { + let as_of = x.day(&ex.id); + let tb = trial_balance_as_of(l, as_of); + for (code, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{} {code}", ex.id)); + let account = l + .chart() + .find_by_code(code) + .unwrap_or_else(|| panic!("{}: no account {code}", ex.id)); + let (dr, cr) = tb + .lines + .iter() + .find(|t| &t.code == code) + .map_or((Decimal::ZERO, Decimal::ZERO), |t| (t.debit, t.credit)); + let got = match account.account_type.normal_side() { + Side::Debit => dr - cr, + Side::Credit => cr - dr, + }; + assert_eq!(got, want, "{} balance as of {as_of}: {code}", ex.id); + } +} + +/// Check one `members_equity` expectation: the statement of changes in +/// members' equity over the period, `"."` for the columns +/// `beginning`, `contributions`, `net_income`, `distributions`, `other` +/// and `ending`; the figures named are the example's choice (rules.md +/// B-13). +fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { + let (from, to) = x.period(&ex.id); + assert!( + !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), + "{}: members_equity needs the members and their shares", + ex.id + ); + let st = members_equity(l, from, to, members).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); + let mut got = BTreeMap::new(); + for m in &st.members { + for (column, v) in [ + ("beginning", m.beginning), + ("contributions", m.contributions), + ("net_income", m.net_income), + ("distributions", m.distributions), + ("other", m.other), + ("ending", m.ending), + ] { + got.insert(format!("{}.{column}", m.name), v); + } + } + for (key, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{} {key}", ex.id)); + let got = got + .get(key) + .unwrap_or_else(|| panic!("{}: members_equity has no {key}", ex.id)); + assert_eq!( + *got, want, + "{} members' equity [{from} .. {to}]: {key}", + ex.id + ); + } +} + +/// Check one expectation against the books as they stand: a query the +/// core answers, value by value; a format query's shape, its values left +/// to the test [`FORMAT_QUERIES`] names (returned); a query no crate +/// answers is refused. +fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { + let (ex, l) = (b.ex, &b.l); + assert!( + !x.values.is_empty(), + "{}: {} expects nothing", + ex.id, + x.query + ); + assert!( + x.entry.is_none() || x.query == "entry", + "{}: only an entry query names an entry", + ex.id + ); + match x.query.as_str() { + "cash_flow" => check_cash_flow(ex, l, &b.members, x), + "trial_balance" => check_trial_balance(ex, l, x), + "profit_and_loss" => check_profit_and_loss(ex, l, x), + "vendor_1099_paid" => check_vendor_1099_paid(ex, l, &b.payees, x), + "entry" => check_entry(ex, l, x), + "balances" => check_balances(ex, l, x), + "members_equity" => check_members_equity(ex, l, &b.members, x), + q => { + let &(_, by) = FORMAT_QUERIES + .iter() + .find(|(f, _)| *f == q) + .unwrap_or_else(|| panic!("{}: no crate answers query {q:?}", ex.id)); + check_format_shape(ex, x); + return Some(by); + } + } + None +} + +/// Load an example and check every expectation: each one with `after = N` +/// on the books after event N, the rest after the last. Gives each query +/// checked, with the test of another crate that checks its values, if +/// any. +fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { + for x in &ex.expect { + assert!( + x.after.is_none_or(|n| n >= 1 && n <= ex.events.len()), + "{}: after = {:?}: events count from 1, to {}", + ex.id, + x.after, + ex.events.len() + ); + } + let mut b = Books::new(ex); + let mut out = Vec::new(); + for (i, e) in ex.events.iter().enumerate() { + b.apply(i as EntryId + 1, e); + for x in ex.expect.iter().filter(|x| x.after == Some(i + 1)) { + out.push((x.query.clone(), check(&b, x))); + } + } + for x in ex.expect.iter().filter(|x| x.after.is_none()) { + out.push((x.query.clone(), check(&b, x))); + } + out +} + +#[test] +fn every_bookspec_example_holds() { + let mut files: Vec = std::fs::read_dir(examples_dir()) + .expect("bookspec/examples") + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|x| x == "toml")) + .collect(); + files.sort(); + let mut ids = BTreeSet::new(); + let mut checked: BTreeMap = BTreeMap::new(); + for f in &files { + let text = std::fs::read_to_string(f).unwrap(); + let ex: Example = toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display())); + let stem = f.file_stem().unwrap().to_string_lossy(); + assert_eq!(ex.id, stem, "{}: the file is named for its id", f.display()); + assert!(ids.insert(ex.id.clone()), "{}: id used twice", ex.id); + assert!( + !ex.title.is_empty() && !ex.rule.is_empty() && !ex.tags.is_empty(), + "{}: title, rule and tags", + ex.id + ); + assert!( + matches!(ex.status.as_str(), "AUTHORITY" | "CONVENTION" | "CHOICE"), + "{}: status {:?}", + ex.id, + ex.status + ); + assert!(!ex.sources.is_empty(), "{}: every rule is sourced", ex.id); + assert!(!ex.expect.is_empty(), "{}: something to expect", ex.id); + for (q, by) in run(&ex) { + if let Some(by) = by { + eprintln!("{}: {q} is checked by {by}", ex.id); + } + *checked.entry(q).or_insert(0) += 1; + } + } + // Each query the examples use ran: the cash flow's six examples at + // least, the as-of and 1099 examples', the members closes' and the + // formats appendix's. + for (q, at_least) in [ + ("cash_flow", 6), + ("trial_balance", 2), + ("profit_and_loss", 2), + ("vendor_1099_paid", 3), + ("entry", 8), + ("balances", 4), + ("members_equity", 5), + ("iif_lines", 1), + ("iif_changes", 1), + ] { + let n = checked.get(q).copied().unwrap_or(0); + assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}"); + } +} + +/// An inline example: two contributions, then `reverses` on the third +/// event. +fn reversing(reverses: usize) -> Example { + toml::from_str(&format!( + r#" +id = "loader.reverses" +title = "reverses counts events from 1" +rule = "An event's reverses names the event it reverses, counting from 1." +status = "CONVENTION" +sources = ["bookspec/README.md"] +tags = ["loader"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[events]] +date = "2026-01-02" +description = "First" +lines = [ + {{ account = "1000", side = "debit", amount = "100.00" }}, + {{ account = "3000", side = "credit", amount = "100.00" }}, +] + +[[events]] +date = "2026-01-03" +description = "Second" +lines = [ + {{ account = "1000", side = "debit", amount = "250.00" }}, + {{ account = "3000", side = "credit", amount = "250.00" }}, +] + +[[events]] +date = "2026-01-04" +description = "Reverse the second" +reverses = {reverses} + +[[expect]] +query = "none" +[expect.values] +none = "0" +"# + )) + .unwrap() +} + +/// bookspec/README.md: `reverses = N` is the Nth event (entry #N), +/// counting from 1. `reverses = 2` reverses the second event, 250.00, not +/// the first (as counting from 0 would read it) nor itself. +#[test] +fn reverses_counts_events_from_one() { + let ex = reversing(2); + let b = load(&ex); + let r = b.l.get_entry(3).unwrap(); + assert_eq!(r.reversal_of(), Some(2)); + let amounts: Vec = r.lines().iter().map(|x| x.amount.amount).collect(); + assert_eq!(amounts, [Decimal::new(25000, 2), Decimal::new(25000, 2)]); + assert_eq!(r.lines()[0].side, Side::Credit); +} + +/// `reverses = 0` names no event: refused, saying events count from 1. +#[test] +#[should_panic(expected = "events count from 1")] +fn reverses_zero_is_refused() { + load(&reversing(0)); +} + +/// A key the shape doesn't have (a typo for a setting) is refused, never +/// ignored: an example can't silently run with other roles than it says. +#[test] +fn an_unknown_key_is_refused() { + let text = std::fs::read_to_string( + examples_dir().join("cash-flow.reversed-property-contribution.toml"), + ) + .unwrap() + .replace("cash_accounts = ", "cash_account = "); + assert!(toml::from_str::(&text).is_err()); +} + +/// The example `file`'s text, changed by `edit`, loaded and its +/// expectations checked as `every_bookspec_example_holds` checks them. +fn run_edited(file: &str, edit: impl Fn(String) -> String) { + let text = edit(std::fs::read_to_string(examples_dir().join(file)).unwrap()); + let before = std::fs::read_to_string(examples_dir().join(file)).unwrap(); + assert_ne!(text, before, "the edit changed nothing"); + let ex: Example = toml::from_str(&text).unwrap(); + run(&ex); +} + +/// A query no crate answers is refused, never skipped: its values would +/// otherwise go unchecked. +#[test] +#[should_panic(expected = "no crate answers query")] +fn an_unknown_query_is_refused() { + run_edited("as-of-package-stops-at-as-of.toml", |t| { + t.replacen("query = \"trial_balance\"", "query = \"trial_balances\"", 1) + }); +} + +/// A format's query belongs to the formats appendix: an example without +/// `appendix = "formats"` can't ask it. +#[test] +#[should_panic(expected = "appendix \"formats\"")] +fn a_format_query_outside_the_formats_appendix_is_refused() { + run_edited("iif-quickbooks-journal-file.toml", |t| { + t.replacen("appendix = \"formats\"\n", "", 1) + }); +} + +/// The appendix is "formats" or absent. +#[test] +#[should_panic(expected = "appendix Some(\"format\")")] +fn an_unknown_appendix_is_refused() { + run_edited("iif-quickbooks-journal-file.toml", |t| { + t.replacen("appendix = \"formats\"", "appendix = \"format\"", 1) + }); +} + +/// An event's payee must be one of the setup's payees. +#[test] +#[should_panic(expected = "not in setup")] +fn a_payee_not_in_the_setup_is_refused() { + run_edited("1099-worksheet-through-as-of.toml", |t| { + t.replacen( + "payee = \"Contractor A\"\nlines", + "payee = \"Contractor B\"\nlines", + 1, + ) + }); +} + +/// A figure query lists everything it covers: a trial balance leaving an +/// account out is refused, so no balance goes unchecked. +#[test] +#[should_panic(expected = "lists exactly what it covers")] +fn a_trial_balance_leaving_an_account_out_is_refused() { + run_edited("as-of-package-stops-at-as-of.toml", |t| { + t.replacen(", \"6000\" = \"1200.00\" }", " }", 1) + }); +} + +/// A trial balance is as of a day, a profit and loss over a period: the +/// wrong dates are refused, never read some other way. +#[test] +#[should_panic(expected = "is as of a day")] +fn a_trial_balance_over_a_period_is_refused() { + run_edited("as-of-package-stops-at-as-of.toml", |t| { + t.replacen( + "query = \"trial_balance\"\nas_of = \"2026-06-30\"", + "query = \"trial_balance\"\nfrom = \"2026-01-01\"\nto = \"2026-06-30\"", + 1, + ) + }); +} + +/// An `entry` query names every line of the entry: one left out is +/// refused, so the entry is exactly what is expected. +#[test] +#[should_panic(expected = "lists exactly what it covers")] +fn an_entry_leaving_a_line_out_is_refused() { + run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { + t.replacen("\"3000.debit\" = \"0.01\"\n", "", 1) + }); +} + +/// `after = N` checks the books after event N: the statement to April 30 +/// asked before April's close (A 0.03, B 0.01) fails when asked after it +/// (A 0.02, B 0.02). +#[test] +#[should_panic(expected = "members' equity [2026-01-01 .. 2026-04-30]: A.ending")] +fn after_is_the_books_after_that_event() { + run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { + t.replacen("after = 7\n", "", 1) + }); +} + +/// `after` counts events from 1 up to the last. +#[test] +#[should_panic(expected = "events count from 1")] +fn after_past_the_last_event_is_refused() { + run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { + t.replacen("after = 6\n", "after = 9\n", 1) + }); +} + +/// A close to the members needs every member's share, and the shares sum +/// to 1. +#[test] +#[should_panic(expected = "the shares sum to 1")] +fn shares_that_dont_sum_to_one_are_refused() { + run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { + t.replacen("share = \"0.5\"", "share = \"0.4\"", 1) + }); +} + +/// The rounding an example declares is the engine's for what it asks: a +/// members close splits by largest remainder. +#[test] +#[should_panic(expected = "rounds by largest-remainder")] +fn a_members_close_declared_with_another_rounding_is_refused() { + run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { + t.replacen( + "rounding = \"largest-remainder\"", + "rounding = \"half-even\"", + 1, + ) + }); +} diff --git a/crates/ledger-core/tests/bookspec_members.rs b/crates/ledger-core/tests/bookspec_members.rs deleted file mode 100644 index 6510256..0000000 --- a/crates/ledger-core/tests/bookspec_members.rs +++ /dev/null @@ -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, `".debit"` and -//! `".credit"` summing each account's lines on that side, every -//! line of the entry named), `balances` (`as_of`: each account named, -//! `""`, 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, `".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, - tags: Vec, - setup: Setup, - events: Vec, - expect: Vec, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Setup { - currency: String, - fiscal_year_start: String, - basis: String, - rounding: String, - accounts: Vec, - #[serde(default)] - members: Vec, -} - -#[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, - #[serde(default)] - reverses: Option, - #[serde(default)] - close: Option, -} - -#[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, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Expect { - query: String, - #[serde(default)] - from: Option, - #[serde(default)] - to: Option, - #[serde(default)] - as_of: Option, - #[serde(default)] - entry: Option, - #[serde(default)] - after: Option, - values: BTreeMap, -} - -fn examples() -> Vec { - let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples"); - let mut paths: Vec = 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 { - 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 = 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 { - 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); - } - } -} diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index bc04314..aa65759 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -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::(); + 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. diff --git a/crates/ledger-core/tests/kernel_bridge.proptest-regressions b/crates/ledger-core/tests/kernel_bridge.proptest-regressions index 01004fb..b1d386f 100644 --- a/crates/ledger-core/tests/kernel_bridge.proptest-regressions +++ b/crates/ledger-core/tests/kernel_bridge.proptest-regressions @@ -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 = [] diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index bdc1c19..57022c4 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -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 = 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::>(), 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::(), + 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 = sources.iter().copied().map(dec).collect(); + let ud: Vec = 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::>(), fd); + prop_assert_eq!(ti.into_iter().map(dec).collect::>(), td); + } } diff --git a/crates/ledger-core/tests/spec_cash_flow.proptest-regressions b/crates/ledger-core/tests/spec_cash_flow.proptest-regressions new file mode 100644 index 0000000..ac23762 --- /dev/null +++ b/crates/ledger-core/tests/spec_cash_flow.proptest-regressions @@ -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 diff --git a/crates/ledger-core/tests/spec_cash_flow.rs b/crates/ledger-core/tests/spec_cash_flow.rs new file mode 100644 index 0000000..ec91637 --- /dev/null +++ b/crates/ledger-core/tests/spec_cash_flow.rs @@ -0,0 +1,3126 @@ +//! The statement of cash flows (rules.md B-14), from the rule and its +//! sources, never from the implementation. +//! +//! Every expected figure is worked by hand in the test's doc comment from +//! the paragraph it cites (testing.md rank 1). The random-book properties +//! at the end check the statement against an independent model: the plain +//! sum of the generated cash lines. + +use chrono::NaiveDate; +use ledger_core::reports::foot; +use ledger_core::{ + AccountId, AccountType, CashFlowLine, CashFlowParams, CashFlowSection, CashFlowStatement, + CashFlowWarningKind, Currency, EntryId, Ledger, LedgerError, MemberCapital, Money, NewAsset, + OperatingClass, ReconcilingKind, Side, cash_flow, +}; +use rust_decimal::Decimal; + +const DR: Side = Side::Debit; +const CR: Side = Side::Credit; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +/// An exact amount from text: "2340.00". +fn x(s: &str) -> Decimal { + s.parse().unwrap() +} + +/// A small company to post to by account code. +struct Books { + l: Ledger, + members: Vec, +} + +impl Books { + fn new() -> Self { + Books { + l: Ledger::new(Currency::USD), + members: Vec::new(), + } + } + + fn acct(&mut self, code: &str, name: &str, t: AccountType) -> AccountId { + self.l.add_account(code, name, t, None, false).unwrap() + } + + fn id(&self, code: &str) -> AccountId { + self.l.chart().find_by_code(code).unwrap().id + } + + fn post(&mut self, date: NaiveDate, desc: &str, lines: &[(&str, Side, &str)]) -> EntryId { + let e = self.l.create_entry(date, desc); + for &(code, side, amount) in lines { + let a = self.id(code); + self.l + .add_line(e, a, side, Money::new(x(amount), Currency::USD)) + .unwrap(); + } + self.l.post_entry(e).unwrap(); + e + } + + fn reverse(&mut self, e: EntryId, date: NaiveDate) -> EntryId { + let desc = format!("Reverse entry {e}"); + self.l.reverse_entry(e, date, &desc).unwrap() + } + + /// A registered asset on these accounts, so they are investing. + fn asset(&mut self, cost: &str, accum: &str, expense: &str, amount: &str) { + let (c, a, e) = (self.id(cost), self.id(accum), self.id(expense)); + self.l + .register_asset(NewAsset { + description: format!("Asset on {cost}"), + purchase_date: d(2026, 1, 1), + cost: Money::new(x(amount), Currency::USD), + salvage_value: Money::new(Decimal::ZERO, Currency::USD), + useful_life_months: 36, + cost_account: c, + accum_depr_account: a, + expense_account: e, + }) + .unwrap(); + } + + fn member(&mut self, name: &str, capital: &str) { + let capital_account = self.id(capital); + self.members.push(MemberCapital { + name: name.to_string(), + capital_account, + distribution_account: None, + share: Decimal::ZERO, + }); + } + + fn params( + &self, + start: NaiveDate, + end: NaiveDate, + cash: &[&str], + financing: &[&str], + investing: &[&str], + ) -> CashFlowParams { + let ids = |codes: &[&str]| codes.iter().map(|c| self.id(c)).collect(); + CashFlowParams { + start, + end, + cash_accounts: ids(cash), + financing_accounts: ids(financing), + investing_accounts: ids(investing), + members: self.members.clone(), + } + } + + fn cf( + &self, + start: NaiveDate, + end: NaiveDate, + cash: &[&str], + financing: &[&str], + investing: &[&str], + ) -> CashFlowStatement { + cash_flow( + &self.l, + &self.params(start, end, cash, financing, investing), + ) + .unwrap() + } +} + +/// (entry, amount) of some lines. +fn em(lines: &[CashFlowLine]) -> Vec<(EntryId, Decimal)> { + lines.iter().map(|l| (l.entry, l.amount)).collect() +} + +/// (kind, amount) of a section's kinds. +fn kinds(cf: &CashFlowStatement, s: CashFlowSection) -> Vec<(String, Decimal)> { + cf.kinds(s) + .into_iter() + .map(|k| (k.kind, k.amount)) + .collect() +} + +/// (entry, amount) of the disclosures. +fn disclosed(cf: &CashFlowStatement) -> Vec<(EntryId, Decimal)> { + cf.noncash_disclosures + .iter() + .map(|n| (n.entry, n.amount)) + .collect() +} + +/// (label, amount) of the reconciling items. +fn recon(cf: &CashFlowStatement) -> Vec<(String, Decimal)> { + cf.operating_reconciliation + .lines + .iter() + .map(|l| (l.label.clone(), l.amount)) + .collect() +} + +fn s(t: &str) -> String { + t.to_string() +} + +/// The laws every statement keeps (rules.md B-14 rules 8 and 9): each +/// section is its lines, and its kinds; the sections are the change in +/// cash; net income and the reconciling items are operating. +fn foots(cf: &CashFlowStatement) { + for sec in CashFlowSection::ALL { + let lines: Decimal = cf.lines(sec).iter().map(|l| l.amount).sum(); + assert_eq!(lines, cf.total(sec), "{sec:?} lines"); + let kinds: Decimal = cf.kinds(sec).iter().map(|k| k.amount).sum(); + assert_eq!(kinds, cf.total(sec), "{sec:?} kinds"); + } + let sum = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing; + assert_eq!(sum, cf.net_change_in_cash); + assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash); + assert_eq!(cf.reconciles.sections_sum, sum); + assert_eq!(cf.reconciles.net_change, cf.net_change_in_cash); + assert_eq!(cf.reconciles.difference, Decimal::ZERO); + let r = &cf.operating_reconciliation; + let rec: Decimal = r.net_income + r.lines.iter().map(|l| l.amount).sum::(); + assert_eq!(rec, cf.cash_from_operations); + assert_eq!(r.net_cash_from_operating, cf.cash_from_operations); +} + +/// No label anywhere says "distribution" (D1-c): used where nothing was +/// distributed. +fn no_distribution(cf: &CashFlowStatement) { + let text = format!("{cf:?}").to_lowercase(); + assert!(!text.contains("distribution"), "{cf:#?}"); +} + +// --------------------------------------------------------------------------- +// The named regressions (defect 1's reproductions), also bookspec examples +// (bookspec/examples/cash-flow.*.toml). +// --------------------------------------------------------------------------- + +/// The minimal reproduction of defect 1. Property contributed (Dr +/// Equipment 2,340 / Cr Capital), the asset registered, the entry reversed +/// the same day. No cash moved, and the entry and its reversal cancel +/// (rules.md B-14 rules 1, 6): every section 0.00, no lines, no +/// disclosures, footing 0 / 0 / 0. Before the fix: investing −2,340.00 +/// against a change in cash of 0.00 (the reversal's credit to the +/// equipment account belonged to no section). +#[test] +fn defect1_reversed_contributions_foot_regression() { + let mut b = Books::new(); + b.acct("1000", "Cash", AccountType::Asset); + b.acct("1500", "Equipment", AccountType::Asset); + b.acct("1510", "Accumulated depreciation", AccountType::Asset); + b.acct("6100", "Depreciation", AccountType::Expense); + b.acct("3000", "Capital", AccountType::Equity); + let e1 = b.post( + d(2026, 1, 10), + "Property contributed", + &[("1500", DR, "2340.00"), ("3000", CR, "2340.00")], + ); + b.asset("1500", "1510", "6100", "2340.00"); + b.reverse(e1, d(2026, 1, 10)); + let cf = b.cf(d(2026, 1, 1), d(2026, 12, 31), &["1000"], &[], &[]); + foots(&cf); + for sec in CashFlowSection::ALL { + assert_eq!(cf.total(sec), Decimal::ZERO); + assert!(cf.lines(sec).is_empty()); + } + assert!(cf.noncash_disclosures.is_empty()); + assert_eq!( + ( + cf.reconciles.sections_sum, + cf.reconciles.net_change, + cf.reconciles.difference + ), + (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO) + ); + assert!(cf.operating_reconciliation.lines.is_empty()); + no_distribution(&cf); +} + +/// A two-member LLC's first weeks, in the shape of the books defect 1 was +/// found in: the same 23 entries in the same order, on the same accounts +/// and roles, every amount and date this replica's own (members "Member +/// A" and "Member B", vendors generic). Cash is 1000; 2100 (due to +/// members) is financing; 1500/1510/6500 carry registered assets. Also +/// the bookspec example `cash-flow.two-member-llc-first-year`. +/// +/// | # | date | entry | Dr | Cr | +/// |----|-------|-----------------------------------------|-----------|-----------------| +/// | 1 | 03-02 | formation fee, member-paid | 6000 85.00 | 2100 | +/// | 2 | 03-03 | domain, member-paid | 6050 12.97 | 2100 | +/// | 3 | 03-05 | developer fee, member-paid | 6200 99.00 | 2100 | +/// | 4 | 03-09 | Member A contributes cash | 1000 1,200 | 3000 | +/// | 5 | 03-10 | Member B contributes cash | 1000 800 | 3001 | +/// | 6 | 03-11 | subscription top-up, member-paid | 6300 7.49 | 2100 | +/// | 7 | 03-16 | developer membership, member-paid | 6400 129.00 | 2100 | +/// | 8 | 03-23 | reimburse members (#1, #2, #3, #6, #7) | 2100 333.46 | 1000 | +/// | 9 | 03-20 | 2-year domain, member-paid (expensed) | 6050 46.38 | 2100 | +/// | 10 | 03-25 | API credits on the card | 6300 14.20 | 2050 | +/// | 11 | 03-26 | reimburse members (#20) | 2100 46.38 | 1000 | +/// | 12 | 03-27 | laptop contributed by Member A | 1500 1,480 | 3000 | +/// | 13 | 03-27 | reverses #12 | | | +/// | 14 | 03-27 | laptop contributed jointly | 1500 1,480 | 3000 740, 3001 740 | +/// | 15 | 03-27 | laptop contributed jointly | 1500 1,190 | 3000 595, 3001 595 | +/// | 16 | 03-27 | desktop contributed jointly | 1500 2,210 | 3000/3001 1,105 | +/// | 17 | 03-27 | tablet contributed jointly | 1500 630 | 3000/3001 315 | +/// | 18 | 03-27 | reverses #15 | | | +/// | 19 | 03-27 | laptop 1,151 and its service plan 39 | 1500 1,151, 1300 39 | 3000/3001 595 | +/// | 20 | 03-20 | 2-year domain, member-paid, prepaid | 1300 46.38 | 2100 | +/// | 21 | 03-12 | subscription, member-paid | 6300 216.40 | 2100 | +/// | 22 | 03-20 | reverses #9 | | | +/// | 23 | 03-30 | API credits on the card | 6300 42.75 | 2050 | +/// +/// By hand, the year to 2026-03-31 (rules.md B-14; D-42 for what the +/// members paid): +/// - Operating: the members paid #1 85.00, #2 12.97, #3 99.00, #6 7.49, +/// #21 216.40, #7 129.00 and #20 46.38 (the prepaid domain) for the +/// company through 2100, each borrowed and spent on its date (rule 11): +/// 85.00 + 12.97 + 99.00 + 7.49 + 216.40 + 129.00 + 46.38 = 596.24, so +/// −596.24, all class (d). #9 and its reversal #22 net out (rule 6). +/// The card's #10 and #23 moved no cash and 2050 is not financing: no +/// line. Nothing else moved cash through an operating account. +/// - Investing: every asset was contributed as property the members +/// owned (no cash, ASC 230-10-50-4): 0.00; #12/#13 and #15/#18 net out. +/// - Financing: #4 +1,200.00 and #5 +800.00 (contributions, 45-14(a)); +/// borrowed from the members through 2100 +596.24 (the seven above, +/// 45-14(b)); #8 −333.46 (85.00 + 12.97 + 99.00 + 7.49 + 129.00) and +/// #11 −46.38 (repayments, 45-15(b)), −379.84 together: 1,200.00 + +/// 800.00 + 596.24 − 379.84 = +2,216.40. +/// - Sum: −596.24 + 0 + 2,216.40 = +1,620.16, the change in cash (in +/// 1,200.00 + 800.00, out 333.46 + 46.38: 0.00 to 1,620.16); +/// difference 0.00. +/// - Reconciliation (45-28 to 45-30): net income is minus the expenses, +/// 85.00 + 12.97 + 99.00 + 7.49 + 129.00 + 46.38 − 46.38 + 14.20 + +/// 216.40 + 42.75 = 606.81, so −606.81; other noncash +39.00 (#19's +/// service plan, contributed with the laptop: noncash); increase in +/// 1300, 39.00 + 46.38, so −85.38; increase in 2050, 14.20 + 42.75, so +/// +56.95. Total −606.81 + 39.00 − 85.38 + 56.95 = −596.24, the +/// operating section. +/// - Disclosed (50-3, 50-4), by date: #14 1,480.00, #16 2,210.00, #17 +/// 630.00, #19 1,190.00 (5,510.00, the property contributed); #12/#13 +/// and #15/#18 drop out. +/// +/// Before the fix the statement didn't foot: the reversals' credits to +/// equipment (#13, #18) belonged to no section, and the contributed +/// devices read as purchases. +fn two_member_llc() -> Books { + let mut b = Books::new(); + for (code, name, t) in [ + ("1000", "Checking", AccountType::Asset), + ("1300", "Prepaid Expenses", AccountType::Asset), + ("1500", "Equipment", AccountType::Asset), + ("1510", "Accumulated Depreciation", AccountType::Asset), + ("2050", "Business Card", AccountType::Liability), + ("2100", "Due to Members", AccountType::Liability), + ("3000", "Capital — Member A", AccountType::Equity), + ("3001", "Capital — Member B", AccountType::Equity), + ("6000", "Organizational Costs", AccountType::Expense), + ("6050", "Domain & Hosting", AccountType::Expense), + ("6200", "Developer Fees", AccountType::Expense), + ("6300", "Software & Subscriptions", AccountType::Expense), + ("6400", "Memberships & Licenses", AccountType::Expense), + ("6500", "Depreciation Expense", AccountType::Expense), + ] { + b.acct(code, name, t); + } + b.member("Member A", "3000"); + b.member("Member B", "3001"); + let p = |b: &mut Books, m, day, desc: &str, lines: &[(&str, Side, &str)]| { + b.post(d(2026, m, day), desc, lines) + }; + let paid = |exp: &'static str, amount: &'static str| [(exp, DR, amount), ("2100", CR, amount)]; + p(&mut b, 3, 2, "Formation filing fee", &paid("6000", "85.00")); + p(&mut b, 3, 3, "Domain, 1 year", &paid("6050", "12.97")); + p( + &mut b, + 3, + 5, + "Developer registration", + &paid("6200", "99.00"), + ); + p( + &mut b, + 3, + 9, + "Contribution — Member A", + &[("1000", DR, "1200.00"), ("3000", CR, "1200.00")], + ); + p( + &mut b, + 3, + 10, + "Contribution — Member B", + &[("1000", DR, "800.00"), ("3001", CR, "800.00")], + ); + p(&mut b, 3, 11, "Subscription top-up", &paid("6300", "7.49")); + p( + &mut b, + 3, + 16, + "Developer membership", + &paid("6400", "129.00"), + ); + p( + &mut b, + 3, + 23, + "Reimburse members", + &[("2100", DR, "333.46"), ("1000", CR, "333.46")], + ); + let e9 = p(&mut b, 3, 20, "Domain, 2 years", &paid("6050", "46.38")); + p( + &mut b, + 3, + 25, + "API credits", + &[("6300", DR, "14.20"), ("2050", CR, "14.20")], + ); + p( + &mut b, + 3, + 26, + "Reimburse members", + &[("2100", DR, "46.38"), ("1000", CR, "46.38")], + ); + let e12 = p( + &mut b, + 3, + 27, + "Laptop contributed — Member A", + &[("1500", DR, "1480.00"), ("3000", CR, "1480.00")], + ); + assert_eq!(b.reverse(e12, d(2026, 3, 27)), 13); + let joint = |amount: &'static str, half: &'static str| { + [("1500", DR, amount), ("3000", CR, half), ("3001", CR, half)] + }; + p( + &mut b, + 3, + 27, + "Laptop contributed jointly", + &joint("1480.00", "740.00"), + ); + let e15 = p( + &mut b, + 3, + 27, + "Laptop contributed jointly", + &joint("1190.00", "595.00"), + ); + p( + &mut b, + 3, + 27, + "Desktop contributed jointly", + &joint("2210.00", "1105.00"), + ); + p( + &mut b, + 3, + 27, + "Tablet contributed jointly", + &joint("630.00", "315.00"), + ); + assert_eq!(b.reverse(e15, d(2026, 3, 27)), 18); + p( + &mut b, + 3, + 27, + "Laptop and its service plan contributed jointly", + &[ + ("1500", DR, "1151.00"), + ("1300", DR, "39.00"), + ("3000", CR, "595.00"), + ("3001", CR, "595.00"), + ], + ); + p( + &mut b, + 3, + 20, + "Domain, 2 years, prepaid", + &paid("1300", "46.38"), + ); + p(&mut b, 3, 12, "Subscription", &paid("6300", "216.40")); + assert_eq!(b.reverse(e9, d(2026, 3, 20)), 22); + let e23 = p( + &mut b, + 3, + 30, + "API credits", + &[("6300", DR, "42.75"), ("2050", CR, "42.75")], + ); + assert_eq!(e23, 23); + for amount in ["1480.00", "1151.00", "2210.00", "630.00"] { + b.asset("1500", "1510", "6500", amount); + } + b +} + +#[test] +fn defect1_two_member_llc_first_year_regression() { + let b = two_member_llc(); + let cf = b.cf(d(2026, 1, 1), d(2026, 3, 31), &["1000"], &["2100"], &[]); + foots(&cf); + no_distribution(&cf); + // What the members paid, by date: borrowed and spent on its own date. + let paid = [ + (1, x("85.00")), + (2, x("12.97")), + (3, x("99.00")), + (6, x("7.49")), + (21, x("216.40")), + (7, x("129.00")), + (20, x("46.38")), + ]; + let neg = |v: &[(EntryId, Decimal)]| -> Vec<(EntryId, Decimal)> { + v.iter().map(|&(e, a)| (e, -a)).collect() + }; + assert_eq!(em(&cf.operating), neg(&paid)); + assert!(cf.operating.iter().all(|l| l.on_behalf == l.amount)); + assert_eq!( + cf.operating_by_class(), + [(OperatingClass::ToSuppliersAndEmployees, x("-596.24"))] + ); + assert_eq!(cf.cash_from_operations, x("-596.24")); + assert!(cf.investing.is_empty()); + assert_eq!(cf.cash_from_investing, Decimal::ZERO); + let mut financing = vec![(4, x("1200.00")), (5, x("800.00"))]; + financing.extend(paid); + financing.extend([(8, x("-333.46")), (11, x("-46.38"))]); + assert_eq!(em(&cf.financing), financing); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [ + (s("Contributions from members — Member A"), x("1200.00")), + (s("Contributions from members — Member B"), x("800.00")), + ( + s("Proceeds from borrowing — 2100 Due to Members"), + x("596.24") + ), + ( + s("Repayments of borrowing — 2100 Due to Members"), + x("-379.84") + ), + ] + ); + let on_behalf: Decimal = cf.financing.iter().map(|l| l.on_behalf).sum(); + assert_eq!(on_behalf, x("596.24")); + assert_eq!(cf.cash_from_financing, x("2216.40")); + assert_eq!( + (cf.cash_beginning, cf.cash_ending, cf.net_change_in_cash), + (x("0.00"), x("1620.16"), x("1620.16")) + ); + assert_eq!( + ( + cf.reconciles.sections_sum, + cf.reconciles.net_change, + cf.reconciles.difference + ), + (x("1620.16"), x("1620.16"), x("0.00")) + ); + let r = &cf.operating_reconciliation; + assert_eq!(r.net_income, x("-606.81")); + assert_eq!( + recon(&cf), + [ + (s("Other noncash items"), x("39.00")), + (s("Increase in 1300 Prepaid Expenses"), x("-85.38")), + (s("Increase in 2050 Business Card"), x("56.95")), + ] + ); + assert_eq!(r.lines[0].kind, ReconcilingKind::OtherNoncash); + assert_eq!(r.lines[0].entries, [19]); + assert_eq!(r.net_cash_from_operating, x("-596.24")); + assert_eq!( + disclosed(&cf), + [ + (14, x("1480.00")), + (16, x("2210.00")), + (17, x("630.00")), + (19, x("1190.00")), + ] + ); + let total: Decimal = cf.noncash_disclosures.iter().map(|n| n.amount).sum(); + assert_eq!(total, x("5510.00")); + // #19: 1,190 moved into the company without cash; 1,151 of it was an + // exchange inside investing and financing (the laptop for capital). + let e19 = &cf.noncash_disclosures[3]; + assert_eq!( + (e19.operating, e19.investing, e19.financing, e19.exchanged), + (x("39.00"), x("1151.00"), x("-1190.00"), x("1151.00")) + ); + assert!(cf.warnings.is_empty()); +} + +/// The same books as of 2026-03-18. By hand: entries #1 to #7 and #21 are +/// dated by then; #9, #20 and #22 are dated 03-20, after it, and the rest +/// later. The members paid #1 85.00, #2 12.97, #3 99.00, #6 7.49, #21 +/// 216.40 and #7 129.00 for the company (D-42), 549.86 in all: operating +/// −549.86, class (d); financing #4 +1,200.00, #5 +800.00 and +549.86 +/// borrowed from the members, +2,549.86; the change in cash 2,000.00 (#4, +/// #5); net income −549.86 with no reconciling item (no operating balance +/// moved yet); nothing disclosed. +#[test] +fn defect1_two_member_llc_as_of_march_18_regression() { + let b = two_member_llc(); + let cf = b.cf(d(2026, 1, 1), d(2026, 3, 18), &["1000"], &["2100"], &[]); + foots(&cf); + let paid = [ + (1, x("85.00")), + (2, x("12.97")), + (3, x("99.00")), + (6, x("7.49")), + (21, x("216.40")), + (7, x("129.00")), + ]; + let mut financing = vec![(4, x("1200.00")), (5, x("800.00"))]; + financing.extend(paid); + assert_eq!(em(&cf.financing), financing); + assert_eq!( + em(&cf.operating), + paid.iter().map(|&(e, a)| (e, -a)).collect::>() + ); + assert_eq!(cf.cash_from_operations, x("-549.86")); + assert_eq!(cf.cash_from_financing, x("2549.86")); + assert_eq!(cf.net_change_in_cash, x("2000.00")); + assert_eq!(cf.operating_reconciliation.net_income, x("-549.86")); + assert!(cf.operating_reconciliation.lines.is_empty()); + assert!(cf.noncash_disclosures.is_empty()); +} + +// --------------------------------------------------------------------------- +// The rule, case by case. +// --------------------------------------------------------------------------- + +/// A company with every kind of account the rule names. Registered fixed +/// assets on 1500/1510 and 1520/1530 (so they are investing); interest +/// accounts carry their tax categories. +fn co() -> Books { + let mut b = Books::new(); + for (code, name, t) in [ + ("1000", "Cash", AccountType::Asset), + ("1010", "Savings", AccountType::Asset), + ("1200", "Accounts Receivable", AccountType::Asset), + ("1210", "Due from buyer", AccountType::Asset), + ("1250", "Note receivable", AccountType::Asset), + ("1300", "Prepaid", AccountType::Asset), + ("1400", "Loan to member", AccountType::Asset), + ("1500", "Equipment", AccountType::Asset), + ("1510", "Accumulated depreciation", AccountType::Asset), + ("1520", "Vehicles", AccountType::Asset), + ( + "1530", + "Accumulated depreciation, vehicles", + AccountType::Asset, + ), + ("2000", "Accounts Payable", AccountType::Liability), + ("2050", "Card", AccountType::Liability), + ("2100", "Due to members", AccountType::Liability), + ("2300", "Deferred revenue", AccountType::Liability), + ("2500", "Bank loan", AccountType::Liability), + ("3000", "Capital — Member A", AccountType::Equity), + ("3001", "Capital — Member B", AccountType::Equity), + ("4000", "Revenue", AccountType::Revenue), + ("4100", "Interest income", AccountType::Revenue), + ("4900", "Gain on disposal", AccountType::Revenue), + ("6000", "Supplies", AccountType::Expense), + ("6100", "Depreciation", AccountType::Expense), + ("6200", "Interest expense", AccountType::Expense), + ("6900", "Loss on disposal", AccountType::Expense), + ] { + b.acct(code, name, t); + } + let (ii, ie) = (b.id("4100"), b.id("6200")); + b.l.set_account_tax(ii, Some("interest_income"), None) + .unwrap(); + b.l.set_account_tax(ie, Some("interest_expense"), None) + .unwrap(); + b.asset("1500", "1510", "6100", "1000.00"); + b.asset("1520", "1530", "6100", "2000.00"); + b.member("Member A", "3000"); + b.member("Member B", "3001"); + b +} + +/// Opening cash: Member A contributes 10,000 on Jan 2 (before every +/// period the tests below look at). +fn funded() -> Books { + let mut b = co(); + b.post( + d(2026, 1, 2), + "Opening contribution", + &[("1000", DR, "10000.00"), ("3000", CR, "10000.00")], + ); + b +} + +const FEB: (NaiveDate, NaiveDate) = ( + NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 2, 28).unwrap(), +); + +fn feb(b: &Books, financing: &[&str], investing: &[&str]) -> CashFlowStatement { + let cf = b.cf(FEB.0, FEB.1, &["1000", "1010"], financing, investing); + foots(&cf); + cf +} + +/// ASC 230-10-45-12(c): "Receipts from sales of property, plant, and +/// equipment" are investing, so the whole proceeds are; 45-28(b): the +/// gain or loss is removed from net income in the reconciliation. +/// (Plan-named test.) Bought for 1,000 cash, 800 depreciated, then: +/// - sold for 500 (Dr cash 500, Dr accum 800 / Cr cost 1,000, Cr gain +/// 300): investing +500, operating 0, net income +300, G −300; +/// - in other books, sold for 100 at a 100 loss: investing +100, G +100; +/// - in other books, sold for 600 before any depreciation (no accumulated +/// depreciation line, defect 1's other shape): investing +600, G +400. +#[test] +fn disposal_proceeds_all_investing() { + let sale = |proceeds: &str, accum: &str, gain_or_loss: (&str, Side, &str)| { + let mut b = funded(); + b.post( + d(2026, 1, 10), + "Buy equipment", + &[("1500", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + if accum != "0" { + b.post( + d(2026, 1, 31), + "Depreciation", + &[("6100", DR, accum), ("1510", CR, accum)], + ); + } + let mut lines = vec![("1000", DR, proceeds), ("1500", CR, "1000.00")]; + if accum != "0" { + lines.push(("1510", DR, accum)); + } + lines.push(gain_or_loss); + b.post(d(2026, 2, 15), "Sell equipment", &lines); + feb(&b, &[], &[]) + }; + let gain = sale("500.00", "800.00", ("4900", CR, "300.00")); + assert_eq!( + kinds(&gain, CashFlowSection::Investing), + [( + s("Proceeds from sales of property and equipment"), + x("500.00") + )] + ); + assert!(gain.operating.is_empty()); + assert_eq!(gain.operating_reconciliation.net_income, x("300.00")); + assert_eq!( + recon(&gain), + [( + s("(Gain) loss and other noncash changes in investing assets"), + x("-300.00") + )] + ); + assert!(gain.noncash_disclosures.is_empty()); + + let loss = sale("100.00", "800.00", ("6900", DR, "100.00")); + assert_eq!(loss.cash_from_investing, x("100.00")); + assert_eq!(loss.cash_from_operations, Decimal::ZERO); + assert_eq!(recon(&loss)[0].1, x("100.00")); + + let early = sale("600.00", "0", ("6900", DR, "400.00")); + assert_eq!(early.cash_from_investing, x("600.00")); + assert_eq!(early.cash_from_operations, Decimal::ZERO); + assert_eq!(early.operating_reconciliation.net_income, x("-400.00")); + assert_eq!(recon(&early)[0].1, x("400.00")); +} + +/// A loss on disposal is a loss whichever account type holds it (the +/// defect found on main: the old code counted only revenue credits and +/// expense debits, so a loss debited to a revenue-typed "Gain (loss) on +/// disposal" account tripped a debug assertion and came out wrong in +/// release). Bought for 1,000 cash, 800 depreciated, sold for 100: a loss +/// of 100 (Dr cash 100, Dr accum 800, Dr loss 100 / Cr cost 1,000). +/// - Booked to 4900, a revenue account (a debit to it): investing +100 +/// (ASC 230-10-45-12(c), the whole proceeds), operating 0, net income +/// −100, and the reconciliation adds the loss back (45-28(b)): +100. +/// - Booked to 6900, an expense account: the same figures. +#[test] +fn a_loss_on_disposal_on_a_revenue_or_expense_account_is_investing() { + for loss_account in ["4900", "6900"] { + let mut b = funded(); + b.post( + d(2026, 1, 10), + "Buy equipment", + &[("1500", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + b.post( + d(2026, 1, 31), + "Depreciation", + &[("6100", DR, "800.00"), ("1510", CR, "800.00")], + ); + let sale = b.post( + d(2026, 2, 15), + "Sell equipment at a loss", + &[ + ("1000", DR, "100.00"), + ("1510", DR, "800.00"), + (loss_account, DR, "100.00"), + ("1500", CR, "1000.00"), + ], + ); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.investing), [(sale, x("100.00"))], "{loss_account}"); + assert_eq!( + cf.investing[0].kind, + "Proceeds from sales of property and equipment" + ); + assert!(cf.operating.is_empty(), "{loss_account}"); + assert_eq!(cf.operating_reconciliation.net_income, x("-100.00")); + assert_eq!( + recon(&cf), + [( + s("(Gain) loss and other noncash changes in investing assets"), + x("100.00") + )], + "{loss_account}" + ); + assert!(cf.noncash_disclosures.is_empty()); + } +} + +/// Equipment sold for an IOU (Dr receivable 500, Dr accum 800 / Cr cost +/// 1,000, Cr gain 300). No cash moved, so no line; the noncash investing +/// part is disclosed (ASC 230-10-50-3): 500. Its later collection is +/// operating by the partition, though 45-12(c) makes it investing: D-35 +/// flags it, with the fix (review item 4: it is not "(a) from customers"; +/// on a receivable that isn't the customers' control account it is (c) +/// other). With the fix (the receivable on a note listed as investing), +/// the sale is disclosed as an exchange of 500 and the collection is +/// investing. +#[test] +fn a_sale_on_account_is_a_noncash_disclosure() { + let sell = |b: &mut Books, on: &'static str| { + b.post( + d(2026, 1, 31), + "Depreciation", + &[("6100", DR, "800.00"), ("1510", CR, "800.00")], + ); + b.post( + d(2026, 2, 10), + "Sell equipment on account", + &[ + (on, DR, "500.00"), + ("1510", DR, "800.00"), + ("1500", CR, "1000.00"), + ("4900", CR, "300.00"), + ], + ); + b.post( + d(2026, 2, 20), + "Buyer pays", + &[("1000", DR, "500.00"), (on, CR, "500.00")], + ) + }; + let mut b = funded(); + let collected = sell(&mut b, "1210"); + let cf = feb(&b, &[], &[]); + assert!(cf.investing.is_empty()); + assert_eq!(disclosed(&cf), [(collected - 1, x("500.00"))]); + let n = &cf.noncash_disclosures[0]; + assert_eq!((n.operating, n.investing), (x("500.00"), x("-500.00"))); + assert_eq!(em(&cf.operating), [(collected, x("500.00"))]); + assert_eq!(cf.operating[0].class, Some(OperatingClass::OtherReceipts)); + assert_eq!(cf.warnings.len(), 1); + assert_eq!( + cf.warnings[0].kind, + CashFlowWarningKind::AssetSoldOnOperatingReceivable + ); + assert!(cf.warnings[0].message.contains("books.investing_accounts")); + + let mut fixed = funded(); + let collected = sell(&mut fixed, "1250"); + let cf = feb(&fixed, &[], &["1250"]); + assert!(cf.warnings.is_empty()); + assert_eq!(disclosed(&cf), [(collected - 1, x("500.00"))]); + assert_eq!(cf.noncash_disclosures[0].exchanged, x("500.00")); + assert!(cf.operating.is_empty()); + assert_eq!( + kinds(&cf, CashFlowSection::Investing), + [(s("Collections on 1250 Note receivable"), x("500.00"))] + ); +} + +/// A member's contribution of property (Dr Equipment 1,340 / Cr +/// Capital): no cash, so no line in any section (230-10-45-1: the +/// statement reports cash effects), disclosed in full: 1,340 (50-3, 50-4). +#[test] +fn a_property_contribution_is_a_noncash_disclosure() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 3), + "Laptop contributed", + &[("1500", DR, "1340.00"), ("3000", CR, "1340.00")], + ); + let cf = feb(&b, &[], &[]); + for sec in CashFlowSection::ALL { + assert!(cf.lines(sec).is_empty()); + } + assert_eq!(disclosed(&cf), [(e, x("1340.00"))]); + let n = &cf.noncash_disclosures[0]; + assert_eq!( + (n.operating, n.investing, n.financing, n.exchanged), + (x("0"), x("1340.00"), x("-1340.00"), x("1340.00")) + ); + assert_eq!(n.label, format!("Laptop contributed (#{e})")); +} + +/// D-42, rules.md B-14 rule 11 (ASC 230 is silent on a payment a third +/// party makes on the entity's behalf; PwC FSP 6.9.3.1, DART Roadmap 7.2 +/// and BDO Blueprint 3.4 present it by substance, as constructive receipt +/// and disbursement). An expense a member pays personally (Dr Supplies +/// 100 / Cr 2100, the members' financing account) is cash the company +/// borrowed from the member (45-14(b)) and spent (45-17(b)), both on the +/// entry's date: financing +100 under "Proceeds from borrowing — 2100", +/// operating −100, class (d); each line's `on_behalf` is all of it. No +/// disclosure, no reconciling item but net income (−100 = operating). +/// Replaces `an_expense_a_member_paid_is_a_noncash_financing_disclosure` +/// (D-35's first point, revised by D-42). With 2100 not financing it is an +/// operating payable: the expense and the payable net, nothing moves. +#[test] +fn an_expense_a_member_paid_is_borrowed_and_spent() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 5), + "Software, paid by Member A", + &[("6000", DR, "100.00"), ("2100", CR, "100.00")], + ); + let cf = feb(&b, &["2100"], &[]); + assert_eq!(em(&cf.operating), [(e, x("-100.00"))]); + assert_eq!( + cf.operating[0].class, + Some(OperatingClass::ToSuppliersAndEmployees) + ); + assert_eq!(cf.operating[0].on_behalf, x("-100.00")); + assert_eq!(cf.operating[0].accounts, [s("6000")]); + assert_eq!(em(&cf.financing), [(e, x("100.00"))]); + assert_eq!( + cf.financing[0].kind, + "Proceeds from borrowing — 2100 Due to members" + ); + assert_eq!(cf.financing[0].on_behalf, x("100.00")); + assert_eq!(cf.financing[0].date, d(2026, 2, 5)); + assert!(cf.investing.is_empty()); + assert_eq!(cf.net_change_in_cash, Decimal::ZERO); + assert!(cf.noncash_disclosures.is_empty()); + assert_eq!(cf.operating_reconciliation.net_income, x("-100.00")); + assert!(cf.operating_reconciliation.lines.is_empty()); + + let cf = feb(&b, &[], &[]); + assert!(cf.operating.is_empty() && cf.financing.is_empty()); + assert!(cf.noncash_disclosures.is_empty()); + assert_eq!( + recon(&cf), + [(s("Increase in 2100 Due to members"), x("100.00"))] + ); +} + +/// D-42, ASC 230-10-45-15(b): reimbursing the member (Dr 2100 / Cr cash +/// 100) is a repayment of borrowing, −100, the only line of its own; the +/// expense the member paid was borrowed and spent on its own date (above). +/// February: operating −100 (#e), financing +100 (#e) −100 (#r), the +/// change in cash −100. With 2100 not financing the reimbursement is an +/// operating payment to a supplier, class (d), and the expense an accrual. +#[test] +fn reimbursing_a_member_is_a_repayment_of_borrowing() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 5), + "Software, paid by Member A", + &[("6000", DR, "100.00"), ("2100", CR, "100.00")], + ); + let r = b.post( + d(2026, 2, 25), + "Reimburse Member A", + &[("2100", DR, "100.00"), ("1000", CR, "100.00")], + ); + let cf = feb(&b, &["2100"], &[]); + assert_eq!(em(&cf.financing), [(e, x("100.00")), (r, x("-100.00"))]); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [ + ( + s("Proceeds from borrowing — 2100 Due to members"), + x("100.00") + ), + ( + s("Repayments of borrowing — 2100 Due to members"), + x("-100.00") + ), + ] + ); + assert_eq!(cf.financing[1].on_behalf, Decimal::ZERO); + assert_eq!(em(&cf.operating), [(e, x("-100.00"))]); + assert_eq!(cf.net_change_in_cash, x("-100.00")); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.operating), [(r, x("-100.00"))]); + assert_eq!( + cf.operating[0].class, + Some(OperatingClass::ToSuppliersAndEmployees) + ); + assert_eq!(cf.operating[0].on_behalf, Decimal::ZERO); +} + +/// D-42 for a lender (DART Roadmap 7.2's third-party financing; BDO +/// Blueprint 3.4, Example 3-5: a bank remits the price of equipment to +/// the supplier): Dr Vehicles 2,000 / Cr Bank loan 2,000, the loan +/// financing, no cash. Investing −2,000 ("Purchases of property and +/// equipment", 45-13(c)), financing +2,000 ("Proceeds from borrowing — +/// 2500", 45-14(b)), both the entry's date; nothing disclosed (nothing was +/// exchanged once the constructive cash is counted); no warning. Its first +/// repayment of 500 is financing −500 (45-15(b)). +#[test] +fn equipment_a_lender_paid_for_is_borrowed_and_invested() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 6), + "Van, paid by the bank", + &[("1520", DR, "2000.00"), ("2500", CR, "2000.00")], + ); + let p = b.post( + d(2026, 2, 27), + "Loan payment", + &[("2500", DR, "500.00"), ("1000", CR, "500.00")], + ); + let cf = feb(&b, &["2500"], &[]); + assert!(cf.operating.is_empty()); + assert_eq!(em(&cf.investing), [(e, x("-2000.00"))]); + assert_eq!(cf.investing[0].kind, "Purchases of property and equipment"); + assert_eq!(cf.investing[0].on_behalf, x("-2000.00")); + assert_eq!(em(&cf.financing), [(e, x("2000.00")), (p, x("-500.00"))]); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [ + (s("Proceeds from borrowing — 2500 Bank loan"), x("2000.00")), + (s("Repayments of borrowing — 2500 Bank loan"), x("-500.00")), + ] + ); + assert!(cf.noncash_disclosures.is_empty()); + assert!(cf.warnings.is_empty()); + assert_eq!(cf.net_change_in_cash, x("-500.00")); +} + +/// The expense-to-capital case (rules.md B-14 rule 11): a member pays a +/// vendor for the company and the books credit the member's capital (Dr +/// Supplies 80 / Cr Capital — Member A), no cash. The member paid cash to +/// a third party on the company's behalf, as through 2100; only the form +/// of what the company owes back differs (nothing: capital). BDO Blueprint +/// 3.4 names this case (a parent paying a subsidiary's expenses) among +/// those for constructive cash flows, and the books' own K-1 worksheet +/// already counts such an entry as a cash contribution (k1.rs INVARIANT +/// 3). So: financing +80 "Contributions from members — Member A" +/// (45-14(a)), operating −80, class (d); nothing disclosed. A guaranteed +/// payment credited to capital is the member's own charge (no one was +/// paid): it stays noncash, reconciled through 3000 and disclosed. +#[test] +fn an_expense_a_member_paid_into_capital_is_contributed_and_spent() { + let mut b = funded(); + let gp = b.acct("6600", "Guaranteed payments", AccountType::Expense); + b.l.set_account_tax(gp, Some("guaranteed_payments"), None) + .unwrap(); + let e = b.post( + d(2026, 2, 7), + "Supplies, paid by Member A, contributed", + &[("6000", DR, "80.00"), ("3000", CR, "80.00")], + ); + let g = b.post( + d(2026, 2, 28), + "Guaranteed payment to Member A, credited to capital", + &[("6600", DR, "300.00"), ("3000", CR, "300.00")], + ); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.operating), [(e, x("-80.00"))]); + assert_eq!( + cf.operating[0].class, + Some(OperatingClass::ToSuppliersAndEmployees) + ); + assert_eq!(em(&cf.financing), [(e, x("80.00"))]); + assert_eq!( + cf.financing[0].kind, + "Contributions from members — Member A" + ); + assert_eq!(cf.financing[0].on_behalf, x("80.00")); + assert_eq!(disclosed(&cf), [(g, x("300.00"))]); + assert_eq!( + recon(&cf), + [(s("Noncash: through 3000 Capital — Member A"), x("300.00"))] + ); + assert_eq!(cf.operating_reconciliation.net_income, x("-380.00")); +} + +/// D-42 keeps property a member already owned noncash (ASC 230-10-50-4: +/// no cash moved, to anyone): a laptop and its service plan contributed +/// (Dr Equipment 1,000, Dr Prepaid 60 / Cr Capital 1,060). Only revenue +/// and expense lines can a member pay for through capital (rule 11), so +/// nothing is constructive: no line; disclosed 1,060 (the laptop 1,000 +/// exchanged for capital); the prepaid's 60 reconciled as other noncash. +#[test] +fn property_a_member_contributes_stays_noncash() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 3), + "Laptop and its service plan contributed", + &[ + ("1500", DR, "1000.00"), + ("1300", DR, "60.00"), + ("3000", CR, "1060.00"), + ], + ); + let cf = feb(&b, &["2100"], &[]); + for sec in CashFlowSection::ALL { + assert!(cf.lines(sec).is_empty(), "{sec:?}"); + } + assert_eq!(disclosed(&cf), [(e, x("1060.00"))]); + let n = &cf.noncash_disclosures[0]; + assert_eq!( + (n.operating, n.investing, n.financing, n.exchanged), + (x("60.00"), x("1000.00"), x("-1060.00"), x("1000.00")) + ); + assert_eq!( + recon(&cf), + [ + (s("Other noncash items"), x("60.00")), + (s("Increase in 1300 Prepaid"), x("-60.00")), + ] + ); + + // The same with no members configured (equity named by account, not + // a member's): still property for capital, noncash; while an expense + // an owner paid into that capital is contributed and spent. + let mut b = funded(); + b.members.clear(); + let e = b.post( + d(2026, 2, 3), + "Laptop contributed", + &[("1500", DR, "1000.00"), ("3000", CR, "1000.00")], + ); + let x2 = b.post( + d(2026, 2, 4), + "Supplies, paid by the owner, contributed", + &[("6000", DR, "30.00"), ("3000", CR, "30.00")], + ); + let cf = feb(&b, &["2100"], &[]); + assert!(cf.investing.is_empty()); + assert_eq!(disclosed(&cf), [(e, x("1000.00"))]); + assert_eq!(em(&cf.operating), [(x2, x("-30.00"))]); + assert_eq!(em(&cf.financing), [(x2, x("30.00"))]); + assert_eq!( + cf.financing[0].kind, + "Contributions from owners — 3000 Capital — Member A" + ); +} + +/// Seller financing stays noncash (ASC 230-10-50-4: "acquiring assets by +/// assuming directly related liabilities"; EY FRD 5.3.1): equipment +/// bought on the seller's own credit, an operating payable (Dr Equipment +/// 1,200 / Cr Accounts Payable 1,200). No line; disclosed 1,200; flagged +/// `asset_on_operating_payable` (D-35's IOU flag), whose text says the +/// purchase is noncash and how to list a lender's credit instead. +#[test] +fn equipment_on_the_sellers_credit_stays_noncash_and_flagged() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 9), + "Equipment on the seller's terms", + &[("1500", DR, "1200.00"), ("2000", CR, "1200.00")], + ); + let cf = feb(&b, &["2100", "2500"], &[]); + for sec in CashFlowSection::ALL { + assert!(cf.lines(sec).is_empty(), "{sec:?}"); + } + assert_eq!(disclosed(&cf), [(e, x("1200.00"))]); + assert_eq!(cf.warnings.len(), 1); + let w = &cf.warnings[0]; + assert_eq!( + (w.entry, w.kind), + (e, CashFlowWarningKind::AssetOnOperatingPayable) + ); + assert!(w.message.contains("seller's own credit"), "{}", w.message); + assert!(w.message.contains("borrowed and spent"), "{}", w.message); +} + +/// rules.md B-14 rule 6 with rule 11: a payment a member made for the +/// company and its reversal, both in February, drop out together (their +/// flows, constructive cash included, are exact negatives: Kani +/// `constructive_mirrors`); no line, no disclosure, no total moves. The +/// reversal alone (its original in January) is listed under the +/// original's kinds and class with its own sign: operating +100 class +/// (d), financing −100 under "Proceeds from borrowing — 2100", both +/// labelled "Reversal of #e", `on_behalf` too. +#[test] +fn a_payment_made_for_the_company_and_its_reversal_net() { + let mut b = funded(); + let before = b.cf(FEB.0, FEB.1, &["1000", "1010"], &["2100"], &[]); + let e = b.post( + d(2026, 2, 5), + "Software, paid by Member A", + &[("6000", DR, "100.00"), ("2100", CR, "100.00")], + ); + b.reverse(e, d(2026, 2, 6)); + let cf = feb(&b, &["2100"], &[]); + for sec in CashFlowSection::ALL { + assert!(cf.lines(sec).is_empty(), "{sec:?}"); + assert_eq!(cf.total(sec), before.total(sec)); + } + assert!(cf.noncash_disclosures.is_empty()); + assert!(cf.operating_reconciliation.lines.is_empty()); + + let mut b = funded(); + let e = b.post( + d(2026, 1, 20), + "Software, paid by Member A", + &[("6000", DR, "100.00"), ("2100", CR, "100.00")], + ); + let r = b.reverse(e, d(2026, 2, 6)); + let cf = feb(&b, &["2100"], &[]); + assert_eq!(em(&cf.operating), [(r, x("100.00"))]); + assert_eq!( + cf.operating[0].class, + Some(OperatingClass::ToSuppliersAndEmployees) + ); + assert_eq!(cf.operating[0].on_behalf, x("100.00")); + assert_eq!(em(&cf.financing), [(r, x("-100.00"))]); + assert_eq!( + cf.financing[0].kind, + "Proceeds from borrowing — 2100 Due to members" + ); + assert_eq!(cf.financing[0].on_behalf, x("-100.00")); + assert_eq!( + cf.financing[0].label, + format!("Reversal of #{e} — Software, paid by Member A") + ); + assert!(cf.noncash_disclosures.is_empty()); + no_distribution(&cf); + + // The same for what only a payment can settle (a prepaid, equipment a + // lender paid for): the reversal alone receives back what its root + // paid, under the root's kinds: operating +50, investing +2,000, + // financing −2,050; nothing disclosed. + let mut b = funded(); + let p = b.post( + d(2026, 1, 20), + "Insurance, prepaid by Member A", + &[("1300", DR, "50.00"), ("2100", CR, "50.00")], + ); + let v = b.post( + d(2026, 1, 21), + "Van, paid by the bank", + &[("1520", DR, "2000.00"), ("2500", CR, "2000.00")], + ); + let rp = b.reverse(p, d(2026, 2, 6)); + let rv = b.reverse(v, d(2026, 2, 7)); + let cf = feb(&b, &["2100", "2500"], &[]); + assert_eq!(em(&cf.operating), [(rp, x("50.00"))]); + assert_eq!(em(&cf.investing), [(rv, x("2000.00"))]); + assert_eq!(cf.investing[0].kind, "Purchases of property and equipment"); + assert_eq!(em(&cf.financing), [(rp, x("-50.00")), (rv, x("-2000.00"))]); + assert!(cf.noncash_disclosures.is_empty()); +} + +/// Rule 11, part paid by the company and part by a member, in one entry +/// (Dr Supplies 100 / Cr Cash 40 / Cr 2100 60): the cash settles 40 +/// (50-5), the member the other 60, so operating −100 with 60 on the +/// company's behalf, financing +60: the same as the two entries apart. +/// And money a member received for the company (a vendor's refund of 30 +/// to the member's card, kept against what the company owes them: Dr 2100 +/// / Cr Supplies 30) is collected and repaid: operating +30, financing +/// −30 (PwC FSP 6.9.3.1: cash "received or paid" by a third party). +#[test] +fn part_paid_by_a_member_and_a_refund_a_member_received() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 5), + "Supplies, part paid by Member A", + &[ + ("6000", DR, "100.00"), + ("1000", CR, "40.00"), + ("2100", CR, "60.00"), + ], + ); + let r = b.post( + d(2026, 2, 12), + "Refund of supplies, to Member A's card", + &[("2100", DR, "30.00"), ("6000", CR, "30.00")], + ); + let cf = feb(&b, &["2100"], &[]); + // By class: the refund is (c), other receipts; the supplies (d). + assert_eq!(em(&cf.operating), [(r, x("30.00")), (e, x("-100.00"))]); + assert_eq!(cf.operating[0].class, Some(OperatingClass::OtherReceipts)); + assert_eq!(cf.operating[0].on_behalf, x("30.00")); + assert_eq!(cf.operating[1].on_behalf, x("-60.00")); + assert_eq!(em(&cf.financing), [(e, x("60.00")), (r, x("-30.00"))]); + assert_eq!(cf.net_change_in_cash, x("-40.00")); + assert!(cf.noncash_disclosures.is_empty()); +} + +/// Rule 11's CONVENTION, pinned: what a lender or the members' account +/// pays for goes to operating balance-sheet lines before revenue and +/// expense, so a member's capital (which can pay only for revenue and +/// expense) is left the expenses. Supplies 100 and a prepaid 50, paid 50 +/// through 2100 and 100 into Member A's capital (Dr 6000 100, Dr 1300 50 +/// / Cr 2100 50, Cr 3000 100): 2100 pays the prepaid, capital the +/// supplies; all of it constructive (operating −150, financing +150), +/// nothing disclosed. Taken the other way round, 2100 would pay 50 of the +/// supplies, capital the other 50, and the prepaid's 50 would be left a +/// noncash contribution. And a customer paying a member for an invoice +/// (Dr 2100 / Cr Accounts Receivable 70) is collected for the company: +/// operating +70, class (a), and a repayment −70. +#[test] +fn a_lender_pays_balance_sheet_lines_first_and_a_customer_pays_a_member() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 5), + "Supplies and a prepaid, paid by Member A", + &[ + ("6000", DR, "100.00"), + ("1300", DR, "50.00"), + ("2100", CR, "50.00"), + ("3000", CR, "100.00"), + ], + ); + let c = b.post( + d(2026, 2, 9), + "Customer paid Member A", + &[("2100", DR, "70.00"), ("1200", CR, "70.00")], + ); + let cf = feb(&b, &["2100"], &[]); + assert_eq!(em(&cf.operating), [(c, x("70.00")), (e, x("-150.00"))]); + assert_eq!(cf.operating[0].class, Some(OperatingClass::FromCustomers)); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [ + (s("Contributions from members — Member A"), x("100.00")), + ( + s("Proceeds from borrowing — 2100 Due to members"), + x("50.00") + ), + ( + s("Repayments of borrowing — 2100 Due to members"), + x("-70.00") + ), + ] + ); + assert!(cf.noncash_disclosures.is_empty()); +} + +/// Rule 11's exceptions: interest charged into a loan (Dr Interest +/// expense 25 / Cr Bank loan 25) and a guaranteed payment credited to what +/// the company owes a member (Dr Guaranteed payments 300 / Cr 2100) are +/// the lender's and the member's own charges, paid to no one: noncash. +/// Each is disclosed as a noncash financing activity (50-3) and reconciled +/// through its account; no line. ("Interest paid", 45-25(e), is cash +/// paid.) +#[test] +fn interest_and_guaranteed_payments_charged_to_a_creditor_stay_noncash() { + let mut b = funded(); + let gp = b.acct("6600", "Guaranteed payments", AccountType::Expense); + b.l.set_account_tax(gp, Some("guaranteed_payments"), None) + .unwrap(); + let i = b.post( + d(2026, 2, 28), + "Interest added to the loan", + &[("6200", DR, "25.00"), ("2500", CR, "25.00")], + ); + let g = b.post( + d(2026, 2, 28), + "Guaranteed payment owed to Member A", + &[("6600", DR, "300.00"), ("2100", CR, "300.00")], + ); + let cf = feb(&b, &["2100", "2500"], &[]); + for sec in CashFlowSection::ALL { + assert!(cf.lines(sec).is_empty(), "{sec:?}"); + } + assert_eq!(disclosed(&cf), [(i, x("25.00")), (g, x("300.00"))]); + assert_eq!( + recon(&cf), + [ + (s("Noncash: through 2100 Due to members"), x("300.00")), + (s("Noncash: through 2500 Bank loan"), x("25.00")), + ] + ); +} + +/// rules.md B-14 rule 6 (B-9: a correction undoes what it corrects): an +/// entry and its reversal both in the period drop out of every line and +/// disclosure, and no total moves (their flows are exact negatives, Kani +/// `entry_flows_of_a_reversal_mirror`). +#[test] +fn an_entry_and_its_reversal_in_the_period_drop_out() { + let mut b = funded(); + b.post( + d(2026, 2, 3), + "Supplies", + &[("6000", DR, "40.00"), ("1000", CR, "40.00")], + ); + let before = feb(&b, &["2100"], &[]); + let mut after = funded(); + after.post( + d(2026, 2, 3), + "Supplies", + &[("6000", DR, "40.00"), ("1000", CR, "40.00")], + ); + let c = after.post( + d(2026, 2, 4), + "Contribution", + &[("1000", DR, "700.00"), ("3001", CR, "700.00")], + ); + let p = after.post( + d(2026, 2, 5), + "Laptop contributed", + &[("1500", DR, "900.00"), ("3001", CR, "900.00")], + ); + after.reverse(c, d(2026, 2, 20)); + after.reverse(p, d(2026, 2, 21)); + let cf = feb(&after, &["2100"], &[]); + assert_eq!(em(&cf.operating), em(&before.operating)); + assert!(cf.financing.is_empty() && cf.investing.is_empty()); + assert!(cf.noncash_disclosures.is_empty()); + assert_eq!(cf.cash_from_financing, Decimal::ZERO); + assert_eq!(cf.net_change_in_cash, before.net_change_in_cash); +} + +/// rules.md B-14 rule 6: a reversal shown alone (its original in an +/// earlier period) is labelled "Reversal of #N — " and +/// listed under its original's kind, never as a distribution (D1-c). +#[test] +fn a_reversal_alone_is_labelled_reversal_of() { + let mut b = funded(); + let c = b.post( + d(2026, 1, 20), + "Contribution — Member B", + &[("1000", DR, "700.00"), ("3001", CR, "700.00")], + ); + let r = b.reverse(c, d(2026, 2, 10)); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.financing), [(r, x("-700.00"))]); + let line = &cf.financing[0]; + assert_eq!( + line.label, + format!("Reversal of #{c} — Contribution — Member B") + ); + assert_eq!(line.kind, "Contributions from members — Member B"); + assert_eq!(line.reverses, Some(c)); + no_distribution(&cf); +} + +/// rules.md B-14 rules 4 and 6: a reversal takes its chain root's +/// sections, class and kind, with its own sign. Reversing January's cash +/// sale at a 300 gain in February: investing −500 under "Proceeds from +/// sales of property and equipment", G +300 (the gain, reversed, is still +/// investing). Reversing January's supplier payment of 100: operating +/// +100 under (d). +#[test] +fn a_reversal_takes_its_originals_sections_and_class() { + let mut b = funded(); + b.post( + d(2026, 1, 3), + "Buy equipment", + &[("1500", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + b.post( + d(2026, 1, 5), + "Depreciation", + &[("6100", DR, "800.00"), ("1510", CR, "800.00")], + ); + let sale = b.post( + d(2026, 1, 25), + "Sell equipment", + &[ + ("1000", DR, "500.00"), + ("1510", DR, "800.00"), + ("1500", CR, "1000.00"), + ("4900", CR, "300.00"), + ], + ); + let pay = b.post( + d(2026, 1, 26), + "Supplies", + &[("6000", DR, "100.00"), ("1000", CR, "100.00")], + ); + let rs = b.reverse(sale, d(2026, 2, 2)); + let rp = b.reverse(pay, d(2026, 2, 3)); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.investing), [(rs, x("-500.00"))]); + assert_eq!( + cf.investing[0].kind, + "Proceeds from sales of property and equipment" + ); + assert_eq!(cf.operating_reconciliation.net_income, x("-200.00")); + assert_eq!( + recon(&cf), + [( + s("(Gain) loss and other noncash changes in investing assets"), + x("300.00") + )] + ); + assert_eq!(em(&cf.operating), [(rp, x("100.00"))]); + assert_eq!( + cf.operating[0].class, + Some(OperatingClass::ToSuppliersAndEmployees) + ); + assert!(cf.noncash_disclosures.is_empty()); +} + +/// Review item 1: a reversal of a reversal. A ← B ← C all in the period: +/// pairing walks the chain from its root, so (A, B) drop out and C is +/// shown, "Reinstatement of #A (reverses #B)", under A's kind and with +/// A's sections (a cash sale at a gain: C's gain is investing). With A +/// before the period, (B, C) pair and nothing is shown; with C after it, +/// B is shown alone. +#[test] +fn a_reversal_of_a_reversal_is_shown_and_foots() { + let mut b = funded(); + let a = b.post( + d(2026, 2, 2), + "Contribution — Member B", + &[("1000", DR, "300.00"), ("3001", CR, "300.00")], + ); + let r = b.reverse(a, d(2026, 2, 3)); + let c = b.reverse(r, d(2026, 2, 4)); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.financing), [(c, x("300.00"))]); + assert_eq!( + cf.financing[0].label, + format!("Reinstatement of #{a} (reverses #{r})") + ); + assert_eq!( + cf.financing[0].kind, + "Contributions from members — Member B" + ); + + // A disposal chain: C takes A's sections. + let mut b = funded(); + b.post( + d(2026, 1, 3), + "Buy equipment", + &[("1500", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + let a = b.post( + d(2026, 2, 2), + "Sell equipment", + &[ + ("1000", DR, "1300.00"), + ("1500", CR, "1000.00"), + ("4900", CR, "300.00"), + ], + ); + let r = b.reverse(a, d(2026, 2, 3)); + let c = b.reverse(r, d(2026, 2, 4)); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.investing), [(c, x("1300.00"))]); + assert!(cf.operating.is_empty()); + assert_eq!(recon(&cf)[0].1, x("-300.00")); + + // The root before the period: B and C pair. + let mut b = funded(); + let a = b.post( + d(2026, 1, 20), + "Contribution — Member B", + &[("1000", DR, "300.00"), ("3001", CR, "300.00")], + ); + let r = b.reverse(a, d(2026, 2, 3)); + b.reverse(r, d(2026, 2, 4)); + let cf = feb(&b, &[], &[]); + assert!(cf.financing.is_empty()); + assert_eq!(cf.cash_from_financing, Decimal::ZERO); + + // The reinstatement after the period: B alone. + let mut b = funded(); + let a = b.post( + d(2026, 1, 20), + "Contribution — Member B", + &[("1000", DR, "300.00"), ("3001", CR, "300.00")], + ); + let r = b.reverse(a, d(2026, 2, 3)); + b.reverse(r, d(2026, 3, 4)); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.financing), [(r, x("-300.00"))]); + assert!(cf.financing[0].label.starts_with("Reversal of #")); +} + +/// ASC 230-10-50-5: "only the cash portion shall be reported". Equipment +/// 1,000 for 300 cash and 700 owed to the seller on its own terms (2000 +/// Accounts Payable, operating): investing −300; the 700 is disclosed +/// (investing +700, operating −700: 50-4's "acquiring assets by assuming +/// directly related liabilities") and flagged. The same purchase with the +/// 700 lent by a bank that paid the seller (2500, financing) is D-42's: +/// investing −1,000 (700 of it on the company's behalf), financing +700, +/// nothing disclosed. +#[test] +fn part_cash_part_noncash_reports_only_the_cash() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 8), + "Equipment, part on the seller's terms", + &[ + ("1520", DR, "1000.00"), + ("1000", CR, "300.00"), + ("2000", CR, "700.00"), + ], + ); + let cf = feb(&b, &["2500"], &[]); + assert_eq!(em(&cf.investing), [(e, x("-300.00"))]); + assert_eq!(cf.investing[0].kind, "Purchases of property and equipment"); + assert_eq!(cf.investing[0].on_behalf, Decimal::ZERO); + assert!(cf.financing.is_empty() && cf.operating.is_empty()); + assert_eq!(disclosed(&cf), [(e, x("700.00"))]); + let n = &cf.noncash_disclosures[0]; + assert_eq!( + (n.operating, n.investing, n.financing, n.exchanged), + (x("-700.00"), x("700.00"), x("0"), x("0")) + ); + assert_eq!(cf.warnings.len(), 1); + + let mut b = funded(); + let e = b.post( + d(2026, 2, 8), + "Equipment, part on a bank loan", + &[ + ("1520", DR, "1000.00"), + ("1000", CR, "300.00"), + ("2500", CR, "700.00"), + ], + ); + let cf = feb(&b, &["2500"], &[]); + assert_eq!(em(&cf.investing), [(e, x("-1000.00"))]); + assert_eq!(cf.investing[0].on_behalf, x("-700.00")); + assert_eq!(em(&cf.financing), [(e, x("700.00"))]); + assert_eq!(cf.financing[0].on_behalf, x("700.00")); + assert!(cf.noncash_disclosures.is_empty()); + assert!(cf.warnings.is_empty()); +} + +/// A fee the bank withholds from a 10,000 loan (Dr cash 9,900, Dr fee 100 +/// / Cr loan 10,000). Posted to an expense account, the fee is an +/// operating payment the lender made out of the loan for the company +/// (D-42): financing +10,000 (9,900 cash, 100 on behalf), operating −100. +/// Debt issue costs are financing (ASC 230-10-45-15(f)): posted to a +/// contra-liability listed in books.financing_accounts, the loan reads as +/// its net proceeds, financing +9,900, the 100 a noncash exchange inside +/// financing (disclosed 100). +#[test] +fn a_fee_withheld_from_a_loan_follows_its_account() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 8), + "Bank loan, fee withheld", + &[ + ("1000", DR, "9900.00"), + ("6000", DR, "100.00"), + ("2500", CR, "10000.00"), + ], + ); + let cf = feb(&b, &["2500"], &[]); + assert_eq!(em(&cf.financing), [(e, x("10000.00"))]); + assert_eq!(cf.financing[0].on_behalf, x("100.00")); + assert_eq!( + cf.financing[0].kind, + "Proceeds from borrowing — 2500 Bank loan" + ); + assert_eq!(em(&cf.operating), [(e, x("-100.00"))]); + assert!(cf.noncash_disclosures.is_empty()); + assert!(cf.operating_reconciliation.lines.is_empty()); + + let mut b = funded(); + b.acct("2510", "Debt issuance costs", AccountType::Liability); + let e = b.post( + d(2026, 2, 8), + "Bank loan, fee withheld", + &[ + ("1000", DR, "9900.00"), + ("2510", DR, "100.00"), + ("2500", CR, "10000.00"), + ], + ); + let cf = feb(&b, &["2500", "2510"], &[]); + assert_eq!(em(&cf.financing), [(e, x("9900.00"))]); + assert_eq!(cf.financing[0].on_behalf, Decimal::ZERO); + assert!(cf.operating.is_empty()); + assert_eq!(disclosed(&cf), [(e, x("100.00"))]); + assert_eq!(cf.noncash_disclosures[0].exchanged, x("100.00")); +} + +/// ASC 230-10-45-15(b) and 45-25(e): a loan payment of 1,000 with 100 +/// interest is a repayment of 900 (financing) and interest paid of 100 +/// (operating, class (e)). +#[test] +fn interest_paid_with_principal_splits_by_section() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 8), + "Loan payment", + &[ + ("2500", DR, "900.00"), + ("6200", DR, "100.00"), + ("1000", CR, "1000.00"), + ], + ); + let cf = feb(&b, &["2500"], &[]); + assert_eq!(em(&cf.financing), [(e, x("-900.00"))]); + assert_eq!(em(&cf.operating), [(e, x("-100.00"))]); + assert_eq!(cf.operating[0].class, Some(OperatingClass::InterestPaid)); +} + +/// ASC 230-10-45-12(a) and 45-16(b): collecting a 1,000 loan the company +/// made, with 40 interest: the principal is investing, the interest +/// (tax category interest_income) stays operating, class (b): rule 4's +/// exception. +#[test] +fn interest_collected_with_a_loan_stays_operating() { + let mut b = funded(); + b.post( + d(2026, 1, 10), + "Lend to Member B", + &[("1400", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + let e = b.post( + d(2026, 2, 10), + "Member B repays with interest", + &[ + ("1000", DR, "1040.00"), + ("1400", CR, "1000.00"), + ("4100", CR, "40.00"), + ], + ); + let cf = feb(&b, &[], &["1400"]); + assert_eq!(em(&cf.investing), [(e, x("1000.00"))]); + assert_eq!(cf.investing[0].kind, "Collections on 1400 Loan to member"); + assert_eq!(em(&cf.operating), [(e, x("40.00"))]); + assert_eq!( + cf.operating[0].class, + Some(OperatingClass::InterestAndDividendsReceived) + ); + assert!(cf.operating_reconciliation.lines.is_empty()); +} + +/// ASC 230-10-45-25 (a)-(g), one entry per class, decided by structure: +/// (a) a sale (Cr revenue), and a deposit from a customer (Cr deferred +/// revenue, the customer named: review item 10); (b) interest received; +/// (c) a supplier's refund; (d) supplies paid; (e) interest paid; (g) a +/// refund to a customer (Dr revenue), and a government fee (tax category +/// `taxes_licenses`: payments to governments for taxes and fees are +/// 45-17(c), apart from suppliers' 45-17(b), and 45-25 names only income +/// taxes). The printed rows are the classes in order, each its sum. +#[test] +fn operating_classes_follow_asc_230_10_45_25() { + let mut b = funded(); + let cash = |b: &mut Books, day, desc: &str, other: &'static str, side: Side, amount| { + let cash_side = if side == DR { CR } else { DR }; + b.post( + d(2026, 2, day), + desc, + &[("1000", cash_side, amount), (other, side, amount)], + ) + }; + let a1 = cash(&mut b, 2, "Sale", "4000", CR, "300.00"); + let a2 = cash(&mut b, 3, "Deposit", "2300", CR, "200.00"); + let customer = b.l.add_customer("Acme LLC").unwrap(); + let party = b.l.party_of_customer(customer).unwrap(); + b.l.set_entry_party(a2, Some(party)).unwrap(); + let b1 = cash(&mut b, 4, "Bank interest", "4100", CR, "7.00"); + let c1 = cash(&mut b, 5, "Supplier refund", "6000", CR, "15.00"); + let d1 = cash(&mut b, 6, "Supplies", "6000", DR, "120.00"); + let e1 = cash(&mut b, 7, "Interest", "6200", DR, "30.00"); + let g1 = cash(&mut b, 8, "Customer refund", "4000", DR, "20.00"); + // A tax or a government fee is not a supplier's (45-17(c) apart from + // 45-17(b)): (g), as 45-25 names only income taxes, (f). + let fees = b.acct("6700", "Taxes and licenses", AccountType::Expense); + b.l.set_account_tax(fees, Some("taxes_licenses"), None) + .unwrap(); + let g2 = cash(&mut b, 9, "Annual report fee", "6700", DR, "25.00"); + let cf = feb(&b, &[], &[]); + let class = |e| cf.operating.iter().find(|l| l.entry == e).unwrap().class; + use OperatingClass::*; + assert_eq!(class(a1), Some(FromCustomers)); + assert_eq!(class(a2), Some(FromCustomers)); + assert_eq!(class(b1), Some(InterestAndDividendsReceived)); + assert_eq!(class(c1), Some(OtherReceipts)); + assert_eq!(class(d1), Some(ToSuppliersAndEmployees)); + assert_eq!(class(e1), Some(InterestPaid)); + assert_eq!(class(g1), Some(OtherPayments)); + assert_eq!(class(g2), Some(OtherPayments)); + assert_eq!( + cf.operating_by_class(), + [ + (FromCustomers, x("500.00")), + (InterestAndDividendsReceived, x("7.00")), + (OtherReceipts, x("15.00")), + (ToSuppliersAndEmployees, x("-120.00")), + (InterestPaid, x("-30.00")), + (OtherPayments, x("-45.00")), + ] + ); + assert_eq!(cf.cash_from_operations, x("327.00")); +} + +/// ASC 230-10-45-28(b): depreciation is an item of net income with no +/// cash effect, a reconciling item; it is not an investing or financing +/// activity, so nothing is disclosed (rules.md B-14 rule 7). +#[test] +fn depreciation_is_reconciling_not_disclosed() { + let mut b = funded(); + b.post( + d(2026, 2, 28), + "Depreciation", + &[("6100", DR, "100.00"), ("1510", CR, "100.00")], + ); + let cf = feb(&b, &[], &[]); + assert!(cf.noncash_disclosures.is_empty()); + assert_eq!(recon(&cf), [(s("Depreciation"), x("100.00"))]); + assert_eq!( + cf.operating_reconciliation.lines[0].kind, + ReconcilingKind::Depreciation + ); +} + +/// Defect D1-e: cash spent through an account that is also an asset's +/// depreciation expense account is spending, not an add-back. 50 paid +/// from cash to 6100: operating −50, class (d), and no depreciation line. +#[test] +fn cash_spent_on_a_depreciation_expense_account_is_not_added_back() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 3), + "Misposted repair", + &[("6100", DR, "50.00"), ("1000", CR, "50.00")], + ); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.operating), [(e, x("-50.00"))]); + assert!(cf.operating_reconciliation.lines.is_empty()); + assert_eq!(cf.operating_reconciliation.net_income, x("-50.00")); +} + +/// ASC 230-10-45-29 and 45-30: net income plus the reconciling items is +/// net cash from operating activities. A month of mixed activity: sales +/// on account 1,000 (500 collected), supplies on the card 200 (paid 150), +/// a prepaid 120, depreciation 50. By hand: operating cash 500 − 150 − +/// 120 = +230; net income 1,000 − 200 − 50 = 750; depreciation +50; +/// receivables −500; prepaid −120; card +50: 750 + 50 − 500 − 120 + 50 = +/// 230. +#[test] +fn operating_reconciliation_ties_to_operating_cash() { + let mut b = funded(); + b.post( + d(2026, 2, 2), + "Invoice", + &[("1200", DR, "1000.00"), ("4000", CR, "1000.00")], + ); + b.post( + d(2026, 2, 9), + "Collect", + &[("1000", DR, "500.00"), ("1200", CR, "500.00")], + ); + b.post( + d(2026, 2, 10), + "Supplies", + &[("6000", DR, "200.00"), ("2050", CR, "200.00")], + ); + b.post( + d(2026, 2, 20), + "Pay card", + &[("2050", DR, "150.00"), ("1000", CR, "150.00")], + ); + b.post( + d(2026, 2, 21), + "Prepay", + &[("1300", DR, "120.00"), ("1000", CR, "120.00")], + ); + b.post( + d(2026, 2, 28), + "Depreciation", + &[("6100", DR, "50.00"), ("1510", CR, "50.00")], + ); + let cf = feb(&b, &[], &[]); + assert_eq!(cf.cash_from_operations, x("230.00")); + assert_eq!(cf.operating_reconciliation.net_income, x("750.00")); + assert_eq!( + recon(&cf), + [ + (s("Depreciation"), x("50.00")), + (s("Increase in 1200 Accounts Receivable"), x("-500.00")), + (s("Increase in 1300 Prepaid"), x("-120.00")), + (s("Increase in 2050 Card"), x("50.00")), + ] + ); + assert_eq!( + cf.operating_by_class(), + [ + (OperatingClass::FromCustomers, x("500.00")), + (OperatingClass::ToSuppliersAndEmployees, x("-270.00")), + ] + ); +} + +/// rules.md B-11 and B-14 rule 10: a period close moves earnings into +/// equity; it earns and spends nothing, so it is never in the statement. +#[test] +fn closing_entries_never_appear() { + let mut b = funded(); + b.post( + d(2026, 2, 2), + "Sale", + &[("1000", DR, "300.00"), ("4000", CR, "300.00")], + ); + let open = feb(&b, &[], &[]); + let p = b.l.create_period("2026-02", FEB.0, FEB.1).unwrap(); + let close = b.l.close_period(p).unwrap().unwrap(); + let closed = feb(&b, &[], &[]); + assert_eq!(closed, open); + let text = format!("{closed:?}"); + assert!(!text.contains(&format!("entry: {close},"))); +} + +/// The period is [start, end] inclusive (rules.md B-14 rule 1): a +/// contribution on the start date is a flow, not opening cash. +#[test] +fn cash_flow_starts_counting_on_the_start_date() { + let b = funded(); + let cf = b.cf(d(2026, 1, 2), d(2026, 1, 2), &["1000"], &[], &[]); + foots(&cf); + assert_eq!(cf.cash_beginning, Decimal::ZERO); + assert_eq!(cf.cash_ending, x("10000.00")); + assert_eq!(em(&cf.financing), [(1, x("10000.00"))]); + let cf = b.cf(d(2026, 1, 3), d(2026, 1, 3), &["1000"], &[], &[]); + assert_eq!(cf.cash_beginning, x("10000.00")); + assert!(cf.financing.is_empty()); + assert!(matches!( + cash_flow( + &b.l, + &b.params(d(2026, 1, 3), d(2026, 1, 2), &["1000"], &[], &[]) + ), + Err(LedgerError::InvalidDateRange { .. }) + )); +} + +/// ASC 230-10-45-24 (the statement explains the change in cash): moving +/// money between two cash accounts is no flow. +#[test] +fn a_transfer_between_cash_accounts_is_not_a_flow() { + let mut b = funded(); + b.post( + d(2026, 2, 2), + "To savings", + &[("1010", DR, "4000.00"), ("1000", CR, "4000.00")], + ); + let cf = feb(&b, &[], &[]); + for sec in CashFlowSection::ALL { + assert!(cf.lines(sec).is_empty()); + } + assert_eq!(cf.net_change_in_cash, Decimal::ZERO); + assert!(cf.noncash_disclosures.is_empty()); +} + +/// Review round 2, footing in odd shapes (rules.md B-14 rules 1, 6, 9, +/// 11), by hand: +/// - two cash accounts in one entry, a transfer less its fee (Dr Savings +/// 99, Dr Supplies 1 / Cr Cash 100): only the net, −1, moves cash, +/// operating (d); no investing or financing; +/// - one account on both sides (Dr Supplies 50, Dr 2100 20 / Cr 2100 70): +/// 2100 nets to a 50 credit first, which pays the supplies: operating +/// −50, financing +50; +/// - a chain across the period start: a member's payment on Jan 20, its +/// reversal on Feb 3 and the reinstatement on Feb 4. February: the +/// reversal and reinstatement pair, nothing shown, no total moves; +/// January: the payment, borrowed and spent; +/// - closing February changes nothing. +#[test] +fn odd_shapes_foot_and_net() { + let mut b = funded(); + let t = b.post( + d(2026, 2, 2), + "To savings, less the fee", + &[ + ("1010", DR, "99.00"), + ("6000", DR, "1.00"), + ("1000", CR, "100.00"), + ], + ); + let n = b.post( + d(2026, 2, 3), + "Supplies, Member A, netted", + &[ + ("6000", DR, "50.00"), + ("2100", DR, "20.00"), + ("2100", CR, "70.00"), + ], + ); + let a = b.post( + d(2026, 1, 20), + "Software, paid by Member A", + &[("6000", DR, "40.00"), ("2100", CR, "40.00")], + ); + let r = b.reverse(a, d(2026, 2, 3)); + b.reverse(r, d(2026, 2, 4)); + let cf = feb(&b, &["2100"], &[]); + assert_eq!(em(&cf.operating), [(t, x("-1.00")), (n, x("-50.00"))]); + assert!(cf.investing.is_empty()); + assert_eq!(em(&cf.financing), [(n, x("50.00"))]); + assert_eq!(cf.net_change_in_cash, x("-1.00")); + assert!(cf.noncash_disclosures.is_empty()); + let jan = b.cf( + d(2026, 1, 1), + d(2026, 1, 31), + &["1000", "1010"], + &["2100"], + &[], + ); + foots(&jan); + assert_eq!(em(&jan.financing)[1..], [(a, x("40.00"))]); + assert_eq!(em(&jan.operating), [(a, x("-40.00"))]); + let p = b.l.create_period("2026-02", FEB.0, FEB.1).unwrap(); + b.l.close_period(p).unwrap(); + assert_eq!(feb(&b, &["2100"], &[]), cf); +} + +/// Review item 2, ASC 230-10-50-4's first example, "converting debt to +/// equity": a member's 500 balance in 2100 turned into capital (Dr 2100 / +/// Cr 3000, both financing) is disclosed as an exchange of 500, though no +/// section's noncash part moves. +#[test] +fn converting_member_debt_to_capital_is_disclosed() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 3), + "Member A's advances to capital", + &[("2100", DR, "500.00"), ("3000", CR, "500.00")], + ); + let cf = feb(&b, &["2100"], &[]); + assert_eq!(disclosed(&cf), [(e, x("500.00"))]); + let n = &cf.noncash_disclosures[0]; + assert_eq!( + (n.operating, n.investing, n.financing, n.exchanged), + (x("0"), x("0"), x("0"), x("500.00")) + ); +} + +/// ASC 230-10-50-4, "exchanging noncash assets": a 700 note received with +/// 300 cash for equipment (cost 1,000, accumulated 800, gain 800). +/// Investing +300; disclosed 700, the note for the equipment. +#[test] +fn a_note_received_for_equipment_is_disclosed() { + let mut b = funded(); + b.post( + d(2026, 1, 5), + "Depreciation", + &[("6100", DR, "800.00"), ("1510", CR, "800.00")], + ); + let e = b.post( + d(2026, 2, 3), + "Sell equipment for a note and cash", + &[ + ("1250", DR, "700.00"), + ("1000", DR, "300.00"), + ("1510", DR, "800.00"), + ("1500", CR, "1000.00"), + ("4900", CR, "800.00"), + ], + ); + let cf = feb(&b, &[], &["1250"]); + assert_eq!(em(&cf.investing), [(e, x("300.00"))]); + assert_eq!(disclosed(&cf), [(e, x("700.00"))]); + assert_eq!(recon(&cf)[0].1, x("-800.00")); +} + +/// ASC 230-10-50-4: a trade-in. A new vehicle (2,000, on 1520) for the +/// old equipment (cost 1,000 on 1500, accumulated 800, gain 300) and +/// 1,500 cash: investing −1,500 and an exchange of 500 disclosed. Within +/// one cost account a trade-in nets away (a limit B-14 records). +#[test] +fn a_trade_in_is_disclosed() { + let mut b = funded(); + b.post( + d(2026, 1, 5), + "Depreciation", + &[("6100", DR, "800.00"), ("1510", CR, "800.00")], + ); + let e = b.post( + d(2026, 2, 3), + "Trade in", + &[ + ("1520", DR, "2000.00"), + ("1510", DR, "800.00"), + ("1500", CR, "1000.00"), + ("4900", CR, "300.00"), + ("1000", CR, "1500.00"), + ], + ); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.investing), [(e, x("-1500.00"))]); + assert_eq!(disclosed(&cf), [(e, x("500.00"))]); + assert_eq!(cf.noncash_disclosures[0].exchanged, x("500.00")); +} + +/// D-35, ASC 230-10-45-13(c): a bill payment carries its bill's split, +/// equipment part first. A bill (from a later version, restored as the +/// journal holds it) for equipment 1,600 and supplies 100 on payables; it +/// is paid 1,000 in February and 700 in March. February: investing −1,000 +/// (the equipment, paid soon after purchase), and the 600 still owed for +/// equipment disclosed (EY FRD 5.3.3: the part unpaid at the end). March: +/// investing −600 and operating −100. Payables' equipment part never +/// reaches the operating reconciliation (DART 6.1.3). +#[test] +fn a_bill_payment_carries_its_bills_split() { + let mut b = funded(); + let entry = b.post( + d(2026, 2, 2), + "Bill 1", + &[ + ("1500", DR, "1600.00"), + ("6000", DR, "100.00"), + ("2000", CR, "1700.00"), + ], + ); + let vendor = b.l.add_vendor("Acme LLC").unwrap(); + let bill = b.l.next_bill_id(); + b.l.replay_bill( + bill, + vendor, + d(2026, 2, 2), + d(2026, 3, 2), + vec![ledger_core::DocumentLine { + description: "Equipment and supplies".into(), + amount: x("1700.00"), + }], + entry, + ) + .unwrap(); + let cash = b.id("1000"); + let p1 = + b.l.pay_bill(bill, d(2026, 2, 20), x("1000.00"), cash) + .unwrap(); + let p2 = + b.l.pay_bill(bill, d(2026, 3, 5), x("700.00"), cash) + .unwrap(); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.investing), [(p1, x("-1000.00"))]); + assert!(cf.operating.is_empty()); + assert_eq!(disclosed(&cf), [(entry, x("600.00"))]); + assert_eq!( + recon(&cf), + [(s("Increase in 2000 Accounts Payable"), x("100.00"))] + ); + assert!(cf.warnings.is_empty()); + let march = b.cf(d(2026, 3, 1), d(2026, 3, 31), &["1000", "1010"], &[], &[]); + foots(&march); + assert_eq!(em(&march.investing), [(p2, x("-600.00"))]); + assert_eq!(em(&march.operating), [(p2, x("-100.00"))]); + // Paid in full within one period: no disclosure at all. + let both = b.cf(d(2026, 2, 1), d(2026, 3, 31), &["1000", "1010"], &[], &[]); + foots(&both); + assert!(both.noncash_disclosures.is_empty()); + assert_eq!(both.cash_from_investing, x("-1600.00")); +} + +/// D-35 (unchanged by D-42): a card purchase of equipment follows the +/// card's classification. On a card that is an operating payable: +/// disclosed (investing +1,000, operating −1,000), flagged, and its +/// payment is operating (d). On a card listed as financing, the issuer is +/// a lender that paid the seller (D-42): borrowed and invested on the +/// purchase's date (financing +1,000, investing −1,000), no flag, and its +/// payment a repayment of borrowing. +#[test] +fn a_card_purchase_follows_the_cards_classification() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 3), + "Equipment on the card", + &[("1500", DR, "1000.00"), ("2050", CR, "1000.00")], + ); + let pay = b.post( + d(2026, 2, 25), + "Pay the card", + &[("2050", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + let cf = feb(&b, &[], &[]); + assert_eq!(disclosed(&cf), [(e, x("1000.00"))]); + assert_eq!( + cf.warnings + .iter() + .map(|w| (w.entry, w.kind)) + .collect::>(), + [(e, CashFlowWarningKind::AssetOnOperatingPayable)] + ); + assert_eq!(em(&cf.operating), [(pay, x("-1000.00"))]); + let cf = feb(&b, &["2050"], &[]); + assert!(cf.noncash_disclosures.is_empty()); + assert!(cf.warnings.is_empty()); + assert_eq!(em(&cf.investing), [(e, x("-1000.00"))]); + assert_eq!(cf.investing[0].on_behalf, x("-1000.00")); + assert_eq!(em(&cf.financing), [(e, x("1000.00")), (pay, x("-1000.00"))]); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [ + (s("Proceeds from borrowing — 2050 Card"), x("1000.00")), + (s("Repayments of borrowing — 2050 Card"), x("-1000.00")), + ] + ); +} + +/// Kinds (D-35): each member's contributions and distributions are +/// listed under their own name, an entry crediting two members split +/// between them (each its own movement: no noncash part); equity not a +/// member's is named by account. +#[test] +fn financing_lists_each_members_contributions_and_distributions() { + let mut b = funded(); + let both = b.post( + d(2026, 2, 2), + "Joint contribution", + &[ + ("1000", DR, "600.00"), + ("3000", CR, "400.00"), + ("3001", CR, "200.00"), + ], + ); + let dist = b.post( + d(2026, 2, 3), + "Distribution to Member B", + &[("3001", DR, "50.00"), ("1000", CR, "50.00")], + ); + let cf = feb(&b, &[], &[]); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [ + (s("Contributions from members — Member A"), x("400.00")), + (s("Contributions from members — Member B"), x("200.00")), + (s("Distributions to members — Member B"), x("-50.00")), + ] + ); + assert_eq!(cf.kinds(CashFlowSection::Financing)[0].entries, [both]); + assert_eq!(cf.kinds(CashFlowSection::Financing)[2].entries, [dist]); + // No members configured: equity is named by account. + let mut plain = b; + plain.members.clear(); + let cf = feb(&plain, &[], &[]); + assert_eq!( + cf.financing[0].kind, + "Contributions from owners — 3000 Capital — Member A" + ); +} + +/// ASC 230-10-45-25 requires interest received (b) and interest paid (e) +/// "at a minimum" apart from the other classes, so an entry's operating +/// cash is classed kind by kind: its interest lines are (b) or (e) +/// whatever else it settles, the rest by its structure (rules.md B-14 +/// rule 5). By hand: +/// - a payment of 900 on a loan the settings leave operating, with 100 +/// interest (Dr 2500 900, Dr 6200 100 / Cr cash 1,000): (d) −900 and +/// (e) −100, not (e) −1,000; +/// - a customer's 1,000 collected with 10 interest (Dr cash 1,010 / Cr +/// 1200 1,000, Cr 4100 10): (a) +1,000 and (b) +10, not (b) +1,010; +/// - the payment reversed in March: (d) +900 and (e) +100, its root's +/// classes with its own sign. +#[test] +fn operating_cash_is_classed_kind_by_kind() { + let mut b = funded(); + let pay = b.post( + d(2026, 2, 8), + "Loan payment", + &[ + ("2500", DR, "900.00"), + ("6200", DR, "100.00"), + ("1000", CR, "1000.00"), + ], + ); + let got = b.post( + d(2026, 2, 9), + "Customer pays, with interest", + &[ + ("1000", DR, "1010.00"), + ("1200", CR, "1000.00"), + ("4100", CR, "10.00"), + ], + ); + let cf = feb(&b, &[], &[]); + use OperatingClass::*; + let lines: Vec<_> = cf + .operating + .iter() + .map(|l| (l.entry, l.class, l.amount, l.accounts.clone())) + .collect(); + assert_eq!( + lines, + [ + (got, Some(FromCustomers), x("1000.00"), vec![s("1200")]), + ( + got, + Some(InterestAndDividendsReceived), + x("10.00"), + vec![s("4100")] + ), + ( + pay, + Some(ToSuppliersAndEmployees), + x("-900.00"), + vec![s("2500")] + ), + (pay, Some(InterestPaid), x("-100.00"), vec![s("6200")]), + ] + ); + assert!(cf.noncash_disclosures.is_empty()); + b.reverse(pay, d(2026, 3, 2)); + let march = b.cf(d(2026, 3, 1), d(2026, 3, 31), &["1000", "1010"], &[], &[]); + foots(&march); + assert_eq!( + march.operating_by_class(), + [ + (ToSuppliersAndEmployees, x("900.00")), + (InterestPaid, x("100.00")) + ] + ); +} + +/// One line per entry, section and kind: two of an entry's operating +/// kinds that fall in one class are one line. A refund to a customer of +/// 20 with 10 of the interest it was charged (Dr 4000 20, Dr 4100 10 / Cr +/// cash 30): both parts are (g) other payments (cash refunds to customers, +/// 45-17; interest paid back out is neither (b) nor (e)), one line of −30 +/// on both accounts. +#[test] +fn two_kinds_of_one_class_are_one_line() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 14), + "Refund, with the interest charged", + &[ + ("4000", DR, "20.00"), + ("4100", DR, "10.00"), + ("1000", CR, "30.00"), + ], + ); + let cf = feb(&b, &[], &[]); + let lines: Vec<_> = cf + .operating + .iter() + .map(|l| (l.entry, l.class, l.amount, l.accounts.clone())) + .collect(); + assert_eq!( + lines, + [( + e, + Some(OperatingClass::OtherPayments), + x("-30.00"), + vec![s("4000"), s("4100")] + )] + ); +} + +/// Interest is operating in any entry (ASC 230-10-45-17(d), 45-25(e)), +/// even one that disposes of equipment, where rule 4 makes the other +/// revenue and expense lines investing. Equipment (cost 1,000) handed to +/// the bank against 1,000 of the loan, with 50 interest paid in cash (Dr +/// 2500 1,000, Dr 6200 50 / Cr 1500 1,000, Cr cash 50): operating −50, +/// class (e); no investing cash; the equipment for the debt is an +/// exchange, disclosed (50-4): 1,000. +#[test] +fn interest_paid_in_a_disposal_stays_operating() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 12), + "Equipment to the bank, with interest", + &[ + ("2500", DR, "1000.00"), + ("6200", DR, "50.00"), + ("1500", CR, "1000.00"), + ("1000", CR, "50.00"), + ], + ); + let cf = feb(&b, &["2500"], &[]); + assert_eq!(em(&cf.operating), [(e, x("-50.00"))]); + assert_eq!(cf.operating[0].class, Some(OperatingClass::InterestPaid)); + assert!(cf.investing.is_empty() && cf.financing.is_empty()); + assert_eq!(disclosed(&cf), [(e, x("1000.00"))]); + assert!( + cf.operating_reconciliation + .lines + .iter() + .all(|l| l.kind != ReconcilingKind::InvestingGainLoss) + ); +} + +/// Rule 4 for an investing account: selling a 1,000 note receivable (an +/// `investing_accounts` account) for 1,200 (Dr cash 1,200 / Cr 1250 1,000, +/// Cr gain 200). Receipts from sales of other entities' debt instruments +/// are investing (ASC 230-10-45-12(a)), the whole proceeds: investing +/// +1,200, operating 0, the gain taken out of net income (45-28(b)): G +/// −200. +#[test] +fn an_investments_gain_is_investing() { + let mut b = funded(); + b.post( + d(2026, 1, 10), + "Buy a note", + &[("1250", DR, "1000.00"), ("1000", CR, "1000.00")], + ); + let e = b.post( + d(2026, 2, 10), + "Sell the note", + &[ + ("1000", DR, "1200.00"), + ("1250", CR, "1000.00"), + ("4900", CR, "200.00"), + ], + ); + let cf = feb(&b, &[], &["1250"]); + assert_eq!(em(&cf.investing), [(e, x("1200.00"))]); + assert_eq!(cf.investing[0].kind, "Collections on 1250 Note receivable"); + assert!(cf.operating.is_empty()); + assert_eq!( + recon(&cf), + [( + s("(Gain) loss and other noncash changes in investing assets"), + x("-200.00") + )] + ); +} + +/// Rule 6, three levels down: A ← B ← C ← D with A before February. B and +/// C pair (walking from the root); D, a reversal of the reinstatement, is +/// shown alone, labelled as the reversal it is ("Reversal of #C — …"), +/// never "Reinstatement", under A's kind with its own sign: −300. +#[test] +fn a_reversal_three_levels_down_is_labelled_a_reversal() { + let mut b = funded(); + let a = b.post( + d(2026, 1, 20), + "Contribution — Member B", + &[("1000", DR, "300.00"), ("3001", CR, "300.00")], + ); + let r = b.reverse(a, d(2026, 2, 3)); + let c = b.reverse(r, d(2026, 2, 4)); + let dd = b.reverse(c, d(2026, 2, 5)); + let cf = feb(&b, &[], &[]); + assert_eq!(em(&cf.financing), [(dd, x("-300.00"))]); + let line = &cf.financing[0]; + assert_eq!(line.label, format!("Reversal of #{c} — Reverse entry {r}")); + assert_eq!(line.kind, "Contributions from members — Member B"); + assert_eq!(line.reverses, Some(c)); +} + +/// Rule 3's CONVENTION, pinned: an entry's cash goes to the sections +/// moving against it in the order operating, investing, financing. Supplies +/// 100 and a vehicle 1,000 for 600 cash, Member A putting in the other 500 +/// as capital (Dr 6000 100, Dr 1520 1,000 / Cr cash 600, Cr 3000 500): +/// operating takes its 100 first, investing the other 500; the vehicle's +/// 500 for capital is noncash (property for capital, rule 11: capital pays +/// constructively only for revenue and expense, and the supplies were +/// already paid in cash), disclosed (investing +500, financing −500). Had +/// the member's 500 been a loan through 2100 (financing), it would have +/// paid for the rest of the vehicle (D-42): investing −1,000, financing +/// +500. +#[test] +fn cash_goes_to_the_sections_in_order() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 6), + "Supplies and a vehicle, part contributed", + &[ + ("6000", DR, "100.00"), + ("1520", DR, "1000.00"), + ("1000", CR, "600.00"), + ("3000", CR, "500.00"), + ], + ); + let cf = feb(&b, &["2500"], &[]); + assert_eq!(em(&cf.operating), [(e, x("-100.00"))]); + assert_eq!(em(&cf.investing), [(e, x("-500.00"))]); + assert!(cf.financing.is_empty()); + let n = &cf.noncash_disclosures[0]; + assert_eq!( + (n.entry, n.operating, n.investing, n.financing, n.amount), + (e, x("0"), x("500.00"), x("-500.00"), x("500.00")) + ); + + let mut b = funded(); + let e = b.post( + d(2026, 2, 6), + "Supplies and a vehicle, part lent by Member A", + &[ + ("6000", DR, "100.00"), + ("1520", DR, "1000.00"), + ("1000", CR, "600.00"), + ("2100", CR, "500.00"), + ], + ); + let cf = feb(&b, &["2100"], &[]); + assert_eq!(em(&cf.operating), [(e, x("-100.00"))]); + assert_eq!(em(&cf.investing), [(e, x("-1000.00"))]); + assert_eq!(cf.investing[0].on_behalf, x("-500.00")); + assert_eq!(em(&cf.financing), [(e, x("500.00"))]); + assert!(cf.noncash_disclosures.is_empty()); +} + +/// Rule 5's CONVENTION, pinned: within a section the cash goes to its +/// kinds in kind order (members in their order, then other equity, then +/// borrowing). 500 cash and 100 of the loan repaid for 400 from Member A +/// and 200 from Member B (Dr cash 500, Dr 2500 100 / Cr 3000 400, Cr 3001 +/// 200): Member A +400, Member B +100; Member B's other 100 went straight +/// to the bank, an exchange inside financing, disclosed (50-4): 100. +#[test] +fn cash_goes_to_a_sections_kinds_in_order() { + let mut b = funded(); + let e = b.post( + d(2026, 2, 6), + "Contributions, part to the bank", + &[ + ("1000", DR, "500.00"), + ("2500", DR, "100.00"), + ("3000", CR, "400.00"), + ("3001", CR, "200.00"), + ], + ); + let cf = feb(&b, &["2500"], &[]); + assert_eq!( + kinds(&cf, CashFlowSection::Financing), + [ + (s("Contributions from members — Member A"), x("400.00")), + (s("Contributions from members — Member B"), x("100.00")), + ] + ); + assert_eq!(disclosed(&cf), [(e, x("100.00"))]); + assert_eq!(cf.noncash_disclosures[0].exchanged, x("100.00")); +} + +/// Rule 12: a bill payment reversed in the period unpays the bill again. +/// Equipment 1,000 on a bill (restored as a later version would journal +/// it), paid on Feb 20 and the payment reversed on Feb 21: the payment +/// and its reversal drop out, and the 1,000 still owed at the end of +/// February is disclosed (EY FRD 5.3.3: what remains unpaid). +#[test] +fn a_reversed_bill_payment_leaves_the_purchase_unpaid() { + let mut b = funded(); + let entry = b.post( + d(2026, 2, 2), + "Bill 1", + &[("1500", DR, "1000.00"), ("2000", CR, "1000.00")], + ); + let vendor = b.l.add_vendor("Acme LLC").unwrap(); + let bill = b.l.next_bill_id(); + b.l.replay_bill( + bill, + vendor, + d(2026, 2, 2), + d(2026, 3, 2), + vec![ledger_core::DocumentLine { + description: "Equipment".into(), + amount: x("1000.00"), + }], + entry, + ) + .unwrap(); + let cash = b.id("1000"); + let pay = + b.l.pay_bill(bill, d(2026, 2, 20), x("1000.00"), cash) + .unwrap(); + let undo = b.reverse(pay, d(2026, 2, 21)); + let cf = feb(&b, &[], &[]); + assert!(cf.investing.is_empty() && cf.operating.is_empty()); + assert_eq!(disclosed(&cf), [(entry, x("1000.00"))]); + // Through the payment only (Feb 1-20): paid, nothing disclosed. + let early = b.cf(FEB.0, d(2026, 2, 20), &["1000", "1010"], &[], &[]); + foots(&early); + assert_eq!(em(&early.investing), [(pay, x("-1000.00"))]); + assert!(early.noncash_disclosures.is_empty()); + // The reversal alone (Feb 21 on): the payment's investing part, back. + let late = b.cf(d(2026, 2, 21), FEB.1, &["1000", "1010"], &[], &[]); + foots(&late); + assert_eq!(em(&late.investing), [(undo, x("1000.00"))]); +} + +// --------------------------------------------------------------------------- +// Role refusals (rules.md B-14 rule 2) and the footing check (rule 9). +// --------------------------------------------------------------------------- + +fn refused(b: &Books, cash: &[&str], financing: &[&str], investing: &[&str]) -> String { + match cash_flow(&b.l, &b.params(FEB.0, FEB.1, cash, financing, investing)) { + Err(LedgerError::CashFlowRoles(m)) => m, + other => panic!("expected a role refusal, got {other:?}"), + } +} + +/// An account belongs to one section: a code in two lists is refused, +/// naming both settings. +#[test] +fn role_lists_that_overlap_are_refused() { + let b = funded(); + let m = refused(&b, &["1000"], &[], &["1000"]); + assert!(m.contains("both cash_accounts"), "{m}"); + assert!(m.contains("books.investing_accounts"), "{m}"); + let m = refused(&b, &["1000", "1000"], &[], &[]); + assert!(m.contains("1000 twice"), "{m}"); + let m = refused(&b, &[], &[], &[]); + assert!(m.contains("no cash accounts"), "{m}"); +} + +/// ASC 230-10-45-14(b): financing liabilities are borrowing. An asset +/// listed as financing is refused, naming the setting; equity listed is +/// accepted (it is financing anyway). +#[test] +fn a_financing_account_must_be_a_liability() { + let b = funded(); + let m = refused(&b, &["1000"], &["1500"], &[]); + assert!( + m.contains("financing_accounts (from the call or books.financing_accounts) lists 1500"), + "{m}" + ); + assert!( + m.contains( + "an asset account: financing accounts are liabilities (equity is always financing)" + ), + "{m}" + ); + let with_equity = b.cf(FEB.0, FEB.1, &["1000"], &["3000", "2500"], &[]); + foots(&with_equity); +} + +/// ASC 230-10-45-12, -13: investing accounts are assets (loans made, +/// investments bought). +#[test] +fn an_investing_account_must_be_an_asset() { + let m = refused(&funded(), &["1000"], &[], &["2500"]); + assert!( + m.contains("a liability account: investing accounts are asset accounts"), + "{m}" + ); +} + +/// Cash is an asset; the statement explains its change. +#[test] +fn a_cash_account_must_be_an_asset() { + let m = refused(&funded(), &["2100"], &[], &[]); + assert!(m.contains("cash accounts are asset accounts"), "{m}"); +} + +/// A registered fixed asset's accounts are investing, never cash. +#[test] +fn a_registered_asset_account_cannot_be_cash() { + let m = refused(&funded(), &["1000", "1510"], &[], &[]); + assert!(m.contains("a registered fixed asset's account"), "{m}"); +} + +/// rules.md B-14 rule 9: any difference between the sections and the +/// change in cash is an engine error, never a statement. +#[test] +fn foot_refuses_any_difference() { + let ok = foot(x("1620.16"), x("1620.16")).unwrap(); + assert_eq!(ok.difference, Decimal::ZERO); + for (sum, change) in [("-1980.32", "1620.16"), ("0.01", "0"), ("0", "-0.01")] { + match foot(x(sum), x(change)) { + Err(LedgerError::CashFlowDoesNotFoot { + expected, found, .. + }) => { + assert_eq!((expected, found), (x(change), x(sum))); + } + other => panic!("expected an engine error, got {other:?}"), + } + } + let msg = foot(x("1"), x("0")).unwrap_err().to_string(); + assert!(msg.starts_with("engine error"), "{msg}"); +} + +// --------------------------------------------------------------------------- +// Properties over random books (rules.md B-14; plan P3: 5,000 cases). +// --------------------------------------------------------------------------- + +mod random { + use super::*; + use proptest::prelude::*; + use std::collections::{BTreeMap, BTreeSet}; + + /// One generated event: a posting from a template, or a reversal of an + /// earlier entry (any earlier entry, reversals included: chains). + #[derive(Clone, Debug)] + pub enum Op { + Post { + day: u32, + template: u8, + amounts: [i64; 3], + who: bool, + }, + Reverse { + of: usize, + after: u32, + }, + } + + /// The lines of a template, in cents. Every shape the rule names: + /// contributions of cash and property, member-paid expenses and + /// reimbursements, sales on account and collections, card spending + /// and payments, asset purchases (cash, on a note, part cash), + /// depreciation, disposals (cash, on account, gain, loss, before any + /// depreciation, the loss on a revenue or an expense account), loans in + /// and out with interest, transfers, debt to equity, a fee withheld; + /// and what a member or lender pays or receives for the company (rule + /// 11, D-42): an expense into capital, a prepaid through 2100, part + /// cash and part a member's, a refund to a member, a customer paying a + /// member, a member paying the card, revenue a member kept, and the + /// shapes that stay noncash: interest charged into the loan, equipment + /// on the seller's credit. + pub fn template(t: u8, [a, b, c]: [i64; 3], who: bool) -> Vec<(&'static str, Side, i64)> { + let cap = if who { "3000" } else { "3001" }; + let cash = if who { "1000" } else { "1010" }; + // A disposal: proceeds `a` to `to`, accumulated `b` (none if 0), + // cost `c` + 1, the difference a gain or a loss. + let dispose = |to: &'static str, accum: i64| { + let cost = c + 1; + let mut v = vec![(to, DR, a), ("1500", CR, cost)]; + if accum > 0 { + v.push(("1510", DR, accum)); + } + let gain = a + accum - cost; + // A loss on a revenue-typed gain/loss account too (a debit + // to revenue), as on an expense account. + let loss_to = if who { "6900" } else { "4900" }; + if gain > 0 { + v.push(("4900", CR, gain)); + } else if gain < 0 { + v.push((loss_to, DR, -gain)); + } + v + }; + match t { + 0 => vec![(cash, DR, a), (cap, CR, a)], + 1 => vec![("1500", DR, a), (cap, CR, a)], + 2 => vec![("6000", DR, a), ("2100", CR, a)], + 3 => vec![("2100", DR, a), (cash, CR, a)], + 4 => vec![("1200", DR, a), ("4000", CR, a)], + 5 => vec![(cash, DR, a), ("1200", CR, a)], + 6 => vec![("6000", DR, a), ("2050", CR, a)], + 7 => vec![("2050", DR, a), (cash, CR, a)], + 8 => vec![("1520", DR, a), (cash, CR, a)], + 9 => vec![("1520", DR, a), ("2500", CR, a)], + 10 => vec![("1500", DR, a + b), (cash, CR, a), ("2500", CR, b)], + 11 => vec![("6100", DR, a), ("1510", CR, a)], + 12 => dispose(cash, b), + 13 => dispose("1210", b), + 14 => dispose(cash, 0), + 15 => vec![(cash, DR, a), ("2500", CR, a)], + 16 => vec![("2500", DR, a), ("6200", DR, b), (cash, CR, a + b)], + 17 => vec![("1400", DR, a), (cash, CR, a)], + 18 => vec![(cash, DR, a + b), ("1400", CR, a), ("4100", CR, b)], + 19 => vec![("1010", DR, a), ("1000", CR, a)], + 20 => vec![("2100", DR, a), (cap, CR, a)], + 21 => vec![(cash, DR, a), ("6000", DR, b), ("2500", CR, a + b)], + 22 => vec![(cash, DR, a), ("4000", CR, a)], + 24 => vec![("6000", DR, a), (cap, CR, a)], + 25 => vec![("1300", DR, a), ("2100", CR, a)], + 26 => vec![("6000", DR, a + b), (cash, CR, a), ("2100", CR, b)], + 27 => vec![("2100", DR, a), ("6000", CR, a)], + 28 => vec![("2100", DR, a), ("1200", CR, a)], + 29 => vec![("6200", DR, a), ("2500", CR, a)], + 30 => vec![("1500", DR, a), ("2000", CR, a)], + 31 => vec![("2050", DR, a), ("2100", CR, a)], + 32 => vec![(cap, DR, a), ("4000", CR, a)], + _ => vec![("6000", DR, a), (cash, CR, a)], + } + } + + /// How many templates. + pub const TEMPLATES: u8 = 33; + + /// Templates whose entries are pure: their cash settles every section + /// they move (or they move no cash and no section), or a member or + /// lender settles it all for the company (rule 11), whether 2100 is + /// financing or not. + const PURE: [u8; 21] = [ + 0, 2, 3, 5, 7, 8, 12, 14, 15, 16, 17, 18, 19, 22, 24, 25, 26, 27, 28, 31, 32, + ]; + + fn arb_op(pure: bool) -> impl Strategy { + let template = if pure { + prop::sample::select(PURE.to_vec()).boxed() + } else { + (0u8..TEMPLATES).boxed() + }; + prop_oneof![ + 4 => (0u32..365, template, [1i64..100_000, 1i64..100_000, 1i64..100_000], any::()) + .prop_map(|(day, template, amounts, who)| Op::Post { day, template, amounts, who }), + 1 => (any::(), 0u32..60).prop_map(|(of, after)| Op::Reverse { of, after }), + ] + } + + pub fn arb_ops(pure: bool) -> impl Strategy> { + prop::collection::vec(arb_op(pure), 1..30) + } + + fn day(n: u32) -> NaiveDate { + d(2026, 1, 1) + chrono::Days::new(u64::from(n.min(364))) + } + + /// The model: each posted entry's date and lines, as generated (a + /// reversal's are its original's, sides flipped), and whether it + /// mirrors its chain's root (an odd number of reversals down). + pub type Model = Vec<(EntryId, NaiveDate, Vec<(&'static str, Side, i64)>, bool)>; + + /// Post the ops to fresh books, amounts times `scale`. + pub fn build(ops: &[Op], scale: i64) -> (Books, Model) { + let mut b = co(); + let mut model: Model = Vec::new(); + let mut reversed = BTreeSet::new(); + for op in ops { + match op { + Op::Post { + day: n, + template: t, + amounts, + who, + } => { + let lines = template(*t, *amounts, *who); + let text: Vec<(&str, Side, String)> = lines + .iter() + .map(|&(a, s, c)| (a, s, Decimal::new(c * scale, 2).to_string())) + .collect(); + let refs: Vec<(&str, Side, &str)> = + text.iter().map(|(a, s, t)| (*a, *s, t.as_str())).collect(); + let e = b.post(day(*n), "Event", &refs); + model.push((e, day(*n), lines, false)); + } + Op::Reverse { of, after } => { + if model.is_empty() { + continue; + } + let (orig, date, lines, mirrored) = model[of % model.len()].clone(); + if !reversed.insert(orig) { + continue; + } + let when = date + chrono::Days::new(u64::from(*after)); + let when = when.min(d(2026, 12, 31)); + let r = b.reverse(orig, when); + let flipped = lines.iter().map(|&(a, s, c)| (a, flip(s), c)).collect(); + model.push((r, when, flipped, !mirrored)); + } + } + } + (b, model) + } + + pub const CASH: [&str; 2] = ["1000", "1010"]; + + pub fn params(b: &Books, a: NaiveDate, e: NaiveDate, designate: bool) -> CashFlowParams { + let fin: &[&str] = if designate { &["2100", "2500"] } else { &[] }; + b.params(a, e, &CASH, fin, &["1250", "1400"]) + } + + fn flip(s: Side) -> Side { + if s == DR { CR } else { DR } + } + + fn signed(s: Side, c: i64) -> i64 { + if s == DR { c } else { -c } + } + + /// The model's change in cash over [a, e]: the plain sum of the + /// generated cash lines. + pub fn model_cash(model: &Model, a: NaiveDate, e: NaiveDate) -> Decimal { + let cents: i64 = model + .iter() + .filter(|(_, d, _, _)| *d >= a && *d <= e) + .flat_map(|(_, _, ls, _)| ls) + .filter(|(acct, _, _)| CASH.contains(acct)) + .map(|&(_, s, c)| signed(s, c)) + .sum(); + Decimal::new(cents, 2) + } + + /// The model's section of an account, for pure books: gains and + /// losses appear only in disposals, so they are investing. + fn model_section(acct: &str, designate: bool) -> Option { + match acct { + "1000" | "1010" => None, + "1500" | "1510" | "1520" | "1530" | "1250" | "1400" | "4900" | "6900" => Some(1), + "3000" | "3001" => Some(2), + "2100" | "2500" if designate => Some(2), + _ => Some(0), + } + } + + /// Rule 11 for a whole entry, independently of the engine: no cash + /// line, and every line either a source (2100 or 2500 when financing; + /// a member's capital) or a use on the other side; capital pays or + /// receives only revenue and expense; a liability pays for any + /// operating or investing account and receives only revenue, expense + /// or receivables; interest never. + pub fn constructive_whole(lines: &[(&str, Side, i64)], designate: bool) -> bool { + if lines.iter().any(|(acct, _, _)| CASH.contains(acct)) { + return false; + } + let lender = |a: &str| designate && matches!(a, "2100" | "2500"); + let member = |a: &str| matches!(a, "3000" | "3001"); + type Lines<'a, 'b> = Vec<&'a (&'b str, Side, i64)>; + let (src, uses): (Lines, Lines) = + lines.iter().partition(|(a, _, _)| lender(a) || member(a)); + let (Some(first), false) = (src.first(), uses.is_empty()) else { + return false; + }; + let side = first.1; + if src.iter().any(|l| l.1 != side) || uses.iter().any(|l| l.1 == side) { + return false; + } + let pl = |a: &str| matches!(a, "4000" | "6000"); + if member(first.0) { + src.iter().all(|l| member(l.0)) && uses.iter().all(|l| pl(l.0)) + } else if side == CR { + uses.iter().all(|l| { + model_section(l.0, designate) != Some(2) && !matches!(l.0, "6200" | "4100") + }) + } else { + uses.iter().all(|l| pl(l.0) || l.0 == "1200") + } + } + + /// For pure books, each section's total is minus the movement of its + /// accounts over the entries that touch cash (plan P3's wording), or + /// that a member or lender settled whole for the company (rule 11). + pub fn model_sections( + model: &Model, + a: NaiveDate, + e: NaiveDate, + designate: bool, + ) -> [Decimal; 3] { + let mut out = [0i64; 3]; + for (_, d, lines, mirrored) in model.iter().filter(|(_, d, _, _)| *d >= a && *d <= e) { + let _ = d; + // A reversal mirrors its root (rule 6): judged by the root's + // shape, its lines flipped back. + let root: Vec<(&str, Side, i64)> = lines + .iter() + .map(|&(acct, side, c)| (acct, if *mirrored { flip(side) } else { side }, c)) + .collect(); + if !lines.iter().any(|(acct, _, _)| CASH.contains(acct)) + && !constructive_whole(&root, designate) + { + continue; + } + for &(acct, s, c) in lines { + if let Some(k) = model_section(acct, designate) { + out[k] -= signed(s, c); + } + } + } + out.map(|c| Decimal::new(c, 2)) + } + + /// Every figure of a statement, scaled. + pub fn scaled(cf: &CashFlowStatement, k: i64) -> CashFlowStatement { + let k = Decimal::from(k); + let mut out = cf.clone(); + for sec in [&mut out.operating, &mut out.investing, &mut out.financing] { + for l in sec.iter_mut() { + l.amount *= k; + l.on_behalf *= k; + } + } + out.cash_from_operations *= k; + out.cash_from_investing *= k; + out.cash_from_financing *= k; + out.net_change_in_cash *= k; + out.cash_beginning *= k; + out.cash_ending *= k; + for n in &mut out.noncash_disclosures { + n.amount *= k; + n.operating *= k; + n.investing *= k; + n.financing *= k; + n.exchanged *= k; + } + let r = &mut out.operating_reconciliation; + r.net_income *= k; + r.net_cash_from_operating *= k; + for l in &mut r.lines { + l.amount *= k; + } + out.reconciles.sections_sum *= k; + out.reconciles.net_change *= k; + out.reconciles.difference *= k; + out + } + + /// The cash net of a model entry. + pub fn entry_cash(model: &Model) -> BTreeMap { + model + .iter() + .map(|(id, _, ls, _)| { + let c = ls + .iter() + .filter(|(a, _, _)| CASH.contains(a)) + .map(|&(_, s, c)| signed(s, c)) + .sum(); + (*id, c) + }) + .collect() + } + + /// Normalize amounts' scale so scaled statements compare equal. + pub fn norm(cf: &CashFlowStatement) -> String { + let text = format!("{cf:?}"); + // Decimals print with their scale; normalize "1.50" and "1.5". + let mut out = String::new(); + let mut num = String::new(); + let flush = |num: &mut String, out: &mut String| { + if !num.is_empty() { + match num.parse::() { + Ok(d) if num.contains('.') => out.push_str(&d.normalize().to_string()), + _ => out.push_str(num), + } + num.clear(); + } + }; + for ch in text.chars() { + if ch.is_ascii_digit() || ch == '.' || (ch == '-' && num.is_empty()) { + num.push(ch); + } else { + flush(&mut num, &mut out); + out.push(ch); + } + } + flush(&mut num, &mut out); + out + } +} + +/// How many random books: `CF_CASES` (default 5,000, the plan's count). +fn cases() -> u32 { + std::env::var("CF_CASES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5000) +} + +use proptest::prelude::*; +use random::*; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(cases()))] + + /// rules.md B-14 over random books, reversals of reversals included: + /// 1. the sections sum to the change in cash, which is the model's; + /// 2. each section is its lines and its kinds, and 3. net income and + /// the reconciling items are operating (`foots`); + /// 4. every line's entry moved cash, and every disclosure's amount is + /// the larger of its noncash parts' positive sum and its exchange; + /// 5. posting an entry and its reversal inside the period changes + /// nothing; + /// 6. [a, m] and (m, e] add up to [a, e], section by section; + /// 7. closing every month changes nothing; + /// 8. scaling every amount scales every figure. + #[test] + fn prop_cash_flow_laws( + ops in arb_ops(false), + extra in (0u8..TEMPLATES, [1i64..100_000, 1i64..100_000, 1i64..100_000], any::()), + (a, m, e) in (0u32..365, 0u32..365, 0u32..365), + designate in any::(), + k in 2i64..=5, + ) { + let mut days = [a, m, e]; + days.sort_unstable(); + let day = |n: u32| d(2026, 1, 1) + chrono::Days::new(u64::from(n)); + let (a, m, e) = (day(days[0]), day(days[1]), day(days[2])); + let (b, model) = build(&ops, 1); + let cf = cash_flow(&b.l, ¶ms(&b, a, e, designate)).unwrap(); + foots(&cf); + prop_assert_eq!(cf.net_change_in_cash, model_cash(&model, a, e)); + // 4: an entry's lines are its cash (what a member or lender + // handled for it nets to zero), each line's cash part has the + // entry's sign, and an entry no cash moved shows only what was + // handled for it. + let cash_of = entry_cash(&model); + let mut shown: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for sec in CashFlowSection::ALL { + for l in cf.lines(sec) { + prop_assert!(!l.amount.is_zero()); + let c = Decimal::new(cash_of[&l.entry], 2); + let real = l.amount - l.on_behalf; + prop_assert!(real.is_zero() || (real > Decimal::ZERO) == (c > Decimal::ZERO), "{:?}", l); + prop_assert!(l.on_behalf.is_zero() || (l.on_behalf > Decimal::ZERO) == (l.amount > Decimal::ZERO), "{:?}", l); + let t = shown.entry(l.entry).or_default(); + t.0 += l.amount; + t.1 += l.on_behalf; + } + } + for (entry, (amount, on_behalf)) in &shown { + prop_assert_eq!(*amount, Decimal::new(cash_of[entry], 2), "entry #{}", entry); + prop_assert_eq!(*on_behalf, Decimal::ZERO, "entry #{}", entry); + } + if !designate { + // Only capital can then pay for the company, and only revenue + // and expense: no investing line is handled for it. + prop_assert!(cf.investing.iter().all(|l| l.on_behalf.is_zero())); + } + for n in &cf.noncash_disclosures { + let moved: Decimal = [n.operating, n.investing, n.financing] + .iter() + .filter(|x| **x > Decimal::ZERO) + .sum(); + prop_assert_eq!(n.amount, moved.max(n.exchanged)); + prop_assert!(n.amount > Decimal::ZERO); + prop_assert_eq!(n.operating + n.investing + n.financing, Decimal::ZERO); + } + + // 5: an entry and its reversal inside the period. + let mut more = ops.clone(); + more.push(Op::Post { day: days[0], template: extra.0, amounts: extra.1, who: extra.2 }); + let (mut with, _) = build(&more, 1); + let last = with.l.next_entry_id() - 1; + with.reverse(last, a.max(with.l.get_entry(last).unwrap().date()).min(e)); + let cf5 = cash_flow(&with.l, ¶ms(&with, a, e, designate)).unwrap(); + foots(&cf5); + prop_assert_eq!(&cf5, &cf); + + // 6: split at m. + let p1 = cash_flow(&b.l, ¶ms(&b, a, m, designate)).unwrap(); + if m < e { + let p2 = cash_flow(&b.l, ¶ms(&b, m + chrono::Days::new(1), e, designate)).unwrap(); + for sec in CashFlowSection::ALL { + prop_assert_eq!(p1.total(sec) + p2.total(sec), cf.total(sec)); + } + prop_assert_eq!(p1.cash_beginning, cf.cash_beginning); + prop_assert_eq!(p2.cash_ending, cf.cash_ending); + } + + // 7: closing every month changes nothing. + let (mut closed, _) = build(&ops, 1); + for month in 1..=12u32 { + let start = d(2026, month, 1); + let end = if month == 12 { d(2026, 12, 31) } else { d(2026, month + 1, 1) - chrono::Days::new(1) }; + let p = closed.l.create_period(&format!("2026-{month:02}"), start, end).unwrap(); + closed.l.close_period(p).unwrap(); + } + let cf7 = cash_flow(&closed.l, ¶ms(&closed, a, e, designate)).unwrap(); + prop_assert_eq!(norm(&cf7), norm(&cf)); + + // 8: scaling. + let (big, _) = build(&ops, k); + let cf8 = cash_flow(&big.l, ¶ms(&big, a, e, designate)).unwrap(); + prop_assert_eq!(norm(&cf8), norm(&scaled(&cf, k))); + } + + /// Plan P3's original wording, for books of pure entries only (each + /// entry's cash settles every section it moves; review item 7): each + /// section's total is minus the movement of the section's accounts + /// over the entries that touch cash, by an independent model of the + /// sections. + #[test] + fn prop_pure_entries_sections_are_movements( + ops in arb_ops(true), + (a, e) in (0u32..365, 0u32..365), + designate in any::(), + ) { + let day = |n: u32| d(2026, 1, 1) + chrono::Days::new(u64::from(n)); + let (a, e) = (day(a.min(e)), day(a.max(e))); + let (b, model) = build(&ops, 1); + let cf = cash_flow(&b.l, ¶ms(&b, a, e, designate)).unwrap(); + foots(&cf); + let want = model_sections(&model, a, e, designate); + for (sec, w) in CashFlowSection::ALL.iter().zip(want) { + prop_assert_eq!(cf.total(*sec), w, "{:?}", sec); + } + // A pure entry's cash settles all it moves: nothing is noncash. + prop_assert!(cf.noncash_disclosures.is_empty()); + } +} diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index ec28f70..db0752a 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -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")]); diff --git a/crates/ledger-core/tests/spec_reports.proptest-regressions b/crates/ledger-core/tests/spec_reports.proptest-regressions new file mode 100644 index 0000000..c245572 --- /dev/null +++ b/crates/ledger-core/tests/spec_reports.proptest-regressions @@ -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) diff --git a/crates/ledger-core/tests/spec_reports.rs b/crates/ledger-core/tests/spec_reports.rs index 2feb071..a9e4701 100644 --- a/crates/ledger-core/tests/spec_reports.rs +++ b/crates/ledger-core/tests/spec_reports.rs @@ -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 { 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, ¶ms).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 = 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, ¶ms).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, ¶ms).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, ¶ms).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 = 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::()) { 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, ¶ms).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::()); + prop_assert_eq!(cf.reconciles.difference, Decimal::ZERO); prop_assert_eq!(cf.cash_from_investing, cf.investing.iter().map(|l| l.amount).sum::()); prop_assert_eq!(cf.cash_from_financing, cf.financing.iter().map(|l| l.amount).sum::()); } - /// 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::()) { 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, ¶ms).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::(); + let debits = |qs: &[i64]| qs.iter().map(|q| (*q).max(0)).sum::(); + 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); diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index d4a205f..dd10b45 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -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 — "). #[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> = 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::(), + 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, ¶ms(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, ¶ms(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, ¶ms).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, ¶ms).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, ¶ms).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 { diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index 83ff4b9..4b7e8d4 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -76,7 +76,10 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option Vec { 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 diff --git a/crates/ledger-gui/src/fmt.rs b/crates/ledger-gui/src/fmt.rs index 813d8ff..716739f 100644 --- a/crates/ledger-gui/src/fmt.rs +++ b/crates/ledger-gui/src/fmt.rs @@ -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 { 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")); diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 8972600..2e50a90 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -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 { - 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); +/// 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> = 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 { 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| { - let mut labels: Vec<(String, Option)> = 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| { + let mut order: Vec<(String, String, Option)> = 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 = 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 = 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(¬e) { + 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 = 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)> = 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)> = 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 = 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::>() + ); + } + + /// 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] diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index 4504a53..130d6d4 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -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)}, ], }) ); diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs index 77dbf75..e83d7b4 100644 --- a/crates/ledger-import/src/report.rs +++ b/crates/ledger-import/src/report.rs @@ -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 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 + '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)"); diff --git a/crates/ledger-import/src/xlsx.rs b/crates/ledger-import/src/xlsx.rs index d59e49a..6c2199c 100644 --- a/crates/ledger-import/src/xlsx.rs +++ b/crates/ledger-import/src/xlsx.rs @@ -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), diff --git a/crates/ledger-import/templates/statement.typ b/crates/ledger-import/templates/statement.typ index e97a167..0842ba4 100644 --- a/crates/ledger-import/templates/statement.typ +++ b/crates/ledger-import/templates/statement.typ @@ -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)))), ) } diff --git a/crates/ledger-import/tests/asset_register.rs b/crates/ledger-import/tests/asset_register.rs index f3cbc9a..736fe5b 100644 --- a/crates/ledger-import/tests/asset_register.rs +++ b/crates/ledger-import/tests/asset_register.rs @@ -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", diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs index 9b4ee9c..9588bec 100644 --- a/crates/ledger-import/tests/spec_import.rs +++ b/crates/ledger-import/tests/spec_import.rs @@ -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::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::(), - 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. diff --git a/crates/ledger-mcp/handbook/entries.md b/crates/ledger-mcp/handbook/entries.md index dce4168..30e4f2f 100644 --- a/crates/ledger-mcp/handbook/entries.md +++ b/crates/ledger-mcp/handbook/entries.md @@ -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. diff --git a/crates/ledger-mcp/handbook/tracing-figures.md b/crates/ledger-mcp/handbook/tracing-figures.md index 25624ed..9098b0a 100644 --- a/crates/ledger-mcp/handbook/tracing-figures.md +++ b/crates/ledger-mcp/handbook/tracing-figures.md @@ -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. diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 69f1def..b339601 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1245,6 +1245,46 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { } 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; 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 { } }; 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) -> Result, 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::>(), - "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::>(), - "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::>(), - "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, + date: Option, + label: &str, + amount: rust_decimal::Decimal, + on_behalf: Option| { + 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 // --------------------------------------------------------------------------- diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 1eb9534..4019ccf 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -807,61 +807,200 @@ pub struct CashFlowParams { pub start: Option, /// Period end, YYYY-MM-DD. Defaults to today. pub end: Option, - /// 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, - /// 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, - /// 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, } -/// 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, + /// " (#N)", "Reversal of #N — ", 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 — "). + 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, + /// Codes of this kind's accounts the entry moved. + pub accounts: Vec, + /// The entry this one reverses, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub reverses: Option, + /// 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, } -/// 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, + /// The kind's cash, decimal string. + pub amount: String, + /// Its entries. + pub entries: Vec, +} + +/// 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, + /// The entries behind it (an entry and its reversal in the period + /// left out). + pub entries: Vec, +} + +/// 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, + /// 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, +} + +/// 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, - /// Cash from operations, decimal string. + /// Operating cash by ASC 230-10-45-25 class, (a) to (g). + pub operating_by_class: Vec, + /// Every entry's operating cash, by class, then date. + pub operating: Vec, + /// 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, + /// Every entry's investing cash, by kind, then date. pub investing: Vec, - /// 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, + /// Every entry's financing cash, by kind, then date. pub financing: Vec, - /// 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, + /// 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, } /// Parameters for `books.import_bank_csv`. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 002a0c1..fa231a6 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -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 diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index 3e36f2f..b886760 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -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> = 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 diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 2d897b6..32e41dc 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -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}" ); } diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 14ef10c..ba362af 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -170,19 +170,19 @@ pub fn build_package( ), ( "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)> = None; diff --git a/crates/ledger-package/src/reports.rs b/crates/ledger-package/src/reports.rs index 58c4f52..50962a3 100644 --- a/crates/ledger-package/src/reports.rs +++ b/crates/ledger-package/src/reports.rs @@ -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 { let resolve = |codes: &[String], what: &str| -> Result, 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, ) } diff --git a/crates/ledger-package/src/text.rs b/crates/ledger-package/src/text.rs index 215c536..0ac8b28 100644 --- a/crates/ledger-package/src/text.rs +++ b/crates/ledger-package/src/text.rs @@ -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"); } diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index 44d2b15..f1b0aca 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -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, `&` 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("&", "&") } /// 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")); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index da4cf17..de1f046 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -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); diff --git a/docs/capability-parity.md b/docs/capability-parity.md index be0be47..53b40c7 100644 --- a/docs/capability-parity.md +++ b/docs/capability-parity.md @@ -16,7 +16,7 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed), | 2 | Chart of accounts | engine + `books.chart` (with live balances) | covered | | 3 | Live P&L tab | engine + `books.profit_and_loss` | covered | | 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered | -| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered | +| 5 | Live Cash Flow tab | engine `cash_flow` (direct, with the reconciliation schedule) + `books.cash_flow` | covered | | 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered | | 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered | | 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered | diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 7499bba..49e2eed 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -453,10 +453,58 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. `revenue_accounts` and `expense_accounts` for the range. `end` defaults to today, `start` to the first posted entry. Closing entries are excluded, so a closed period still shows what it earned. -- `books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }` (each defaults to `[books]` in wanderbooks.toml; cash is required one way or the other) - → indirect-method cash flow statement. `cash_accounts` are the reconciling - accounts (required); the three sections always sum to their net change. - Disposal gains/losses move from operating to investing automatically. +- `books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }` (each defaults to the books' settings, `books.cash_accounts` and so on; cash is required one way or the other) + → the statement of cash flows, direct method (rules.md B-14, P3 defect + 1): `{ method: "direct", start, end, operating_by_class, operating, + cash_from_operations, investing_by_kind, investing, cash_from_investing, + financing_by_kind, financing, cash_from_financing, net_change_in_cash, + cash_beginning, cash_ending, noncash_disclosures, + operating_reconciliation, reconciles, warnings }`. + - Lines (`operating`, `investing`, `financing`) are one per entry, section + and kind: `{ entry, date, label, kind, amount, on_behalf?, accounts, + reverses?, class? }`; every transaction is listed (D-35), each under its + kind. An entry's interest is its own operating class, (b) or (e), apart + from the rest of its operating cash. What a member or a lender paid or + received for the company with no cash account moving is cash borrowed + (or contributed) and spent, on the entry's date (D-42, rules.md B-14 + rule 11): an expense a member paid personally through Due to Members + is a "Proceeds from borrowing — " line and an operating + line, each with `on_behalf`, the part of its amount so handled (absent + when none; an entry's `on_behalf` parts net to zero). + Labels never come from a reversal's sign: "Reversal of #N — …", + "Reinstatement of #A (reverses #B)". An entry and its reversal in the + same period drop out together. + - `operating_by_class`, `investing_by_kind`, `financing_by_kind`: + `{ kind, class?, amount, entries }`, how the printed statement shows the + sections: operating by ASC 230-10-45-25 class (`from_customers`, + `interest_and_dividends_received`, `other_receipts`, + `to_suppliers_and_employees`, `interest_paid`, `income_taxes_paid`, + `other_payments`), investing and financing by kind ("Contributions from + members — ", "Repayments of borrowing — ", "Purchases + of property and equipment", …). + - `noncash_disclosures`: `{ entry, date, label, amount, operating, + investing, financing, exchanged, reverses? }`, the investing and + financing activities no cash settled (ASC 230-10-50-3): property + contributed, debt converted to equity, a purchase on the seller's + credit, interest charged into a loan, the noncash part of a part-cash + purchase. + - `operating_reconciliation`: `{ net_income, lines: [{ kind, label, + amount, account?, entries }], net_cash_from_operating }`, net income + reconciled to operating cash (ASC 230-10-45-30), kinds `depreciation`, + `investing_gain_loss`, `noncash_through`, `other_noncash`, + `operating_balance_change`. + - `reconciles`: `{ sections_sum, net_change, difference }`; `difference` + is always `"0.00"`: a statement that wouldn't foot is an engine error, + never output. + - `warnings`: `{ entry, kind, message }` for an entry whose later payment + or collection the books can't classify (`asset_on_operating_payable`, + `asset_sold_on_operating_receivable`), with the fix. + - Refused, naming the setting, when the roles contradict the chart or + each other (cash and investing accounts must be assets, financing + accounts liabilities or equity; no account in two lists or twice in + one; no registered asset's account as cash). + - Changed in P3 (breaking): `net_income`, `depreciation` and + `working_capital` are gone (net income is in `operating_reconciliation`). - `books.digest { since: "2026-10-01" }` → `{ entries_posted: [...], totals_by_account: [...], stale_rules: [...] }`. The human review surface: "what did the AI do with my books lately." The @@ -1133,8 +1181,15 @@ posting. - `books.export { format, report, start?, end?, as_of?, cash_accounts?, financing_accounts?, investing_accounts? }` → `{ content, content_encoding?, warnings? }`. - - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs - `cash_accounts`), `trial_balance`, `members_equity` (statement of + - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (direct + method; needs `cash_accounts`; json is `books.cash_flow`'s answer, csv + is `section,kind,class,entry,date,label,amount,on_behalf` (`on_behalf` + on a section's lines only) with sections + `operating`, `investing`, `financing`, `reconciliation`, `noncash`, + `footing` and `warning` (its kind, entry and message; no amount); xlsx + and pdf are the printed statement, operating by class and investing + and financing by kind with each kind's transactions under it, with its + two schedules), `trial_balance`, `members_equity` (statement of changes in members' equity; members from `wanderbooks.toml`; earnings not yet closed are split the way the next members close will split them, so a member's capital on a day never changes when its month is @@ -1506,7 +1561,10 @@ entries. README says which. With TINs it adds `party_id, tin_last4, tin, tin_type, tin_note`. A zip with the full set, each statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`, - `cash-flow` (reconciled to `cash_accounts`), `members-equity` + `cash-flow` (the direct method, as `books.export` prints it: operating + by class, investing and financing by kind with each member named, and + its two schedules; reconciled to the package's cash accounts), + `members-equity` (multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and `general-journal.xlsx`. Period reports cover the fiscal year to date. What the CPA needs; they never log into the system. diff --git a/docs/parity.md b/docs/parity.md index 9df185e..afea3d3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -28,7 +28,7 @@ No other statuses. | Reversing entries | Reverse transaction | done | linked via `reversal_of` | | Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss | -| Cash flow statement (indirect) | Reports → Cash Flow | done | R-FUTURE-3 | +| Cash flow statement (direct, with the reconciliation schedule and noncash disclosures) | Reports → Cash Flow | done | R-FUTURE-3; rules.md B-14 (P3 defect 1) | | Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 | | Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 | | Bank reconciliation (cleared flags + report) | Reconcile | done | — | diff --git a/docs/requirements.md b/docs/requirements.md index 9ca6d78..962731e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -113,7 +113,9 @@ requirement conflict, the requirement wins. ## Promoted from future (done) -- **R-? — Cash flow statement** (indirect method). Was R-FUTURE-3. +- **R-? — Cash flow statement** (direct method, with the reconciliation + of net income to operating cash and the noncash investing and financing + activities; rules.md B-14, P3 defect 1). Was R-FUTURE-3. `ledger-core::reports::cash_flow` + `books.cash_flow`. - **R-? — QuickBooks-compatible import/export.** Was R-FUTURE-4. `ledger-import` crate: CSV bank/chart import, IIF and XLSX export. diff --git a/docs/spec/README.md b/docs/spec/README.md index d44c756..b6277ab 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -63,4 +63,13 @@ Read on 2026-10-13 through Deloitte's free DART roadmaps, which reproduce ASC text: ASC 230-10-45-16 and -28, 326-20-55-37/38, 606-10-25-23, 45-2, 55-37/38, 830-20-30-1. Read directly in the ASC basic view on 2026-10-14 (`sources/fasb-asc.md`): 360-10-35-4, 340-10-05-2/4/5, 210-10-45-1/2/5, -230-10-45-7/10/12/13/14/15, 606-10-55-36 through 55-38. None left open. +230-10-45-7/10/12/13/14/15, 606-10-55-36 through 55-38. Read on +2026-10-20 where public sources reproduce the Codification (EY's FRD on +the statement of cash flows, July 2026; FASB ASUs 2016-15 and 2016-18; +Deloitte's public DART Roadmap): ASC 230-10-10-1, 45-1, 45-4, 45-5, +45-8, 45-9, 45-12(c), 45-13(c), 45-14(a), 45-15(a)/(c), 45-16(a)/(b), +45-17, 45-24 to 45-26, 45-28 to 45-31, 50-3 to 50-8, and 830-230-45-1; +each checked again against the same sources in the P3 review (numbers, +pages and wording hold). Open: 105-10-05-1 to 05-3, read only in their +2009 text (ASU 2009-01, and as Wiley GAAP 2020 quotes them); their +current wording is unconfirmed. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index d1ba2b1..3119602 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -356,51 +356,309 @@ earnings the same running way, D-38, 2026-10-20) `a_yen_members_page_splits_in_whole_yen`; the bookspec examples `members-close.*`. -## B-14 Statements: P&L for a period; cash flow by the indirect method +## B-14 Statements: P&L for a period; cash flows by the direct method -**Status:** AUTHORITY +**Status:** AUTHORITY, except where marked CONVENTION or DECISION -ASC 230-10-45-28 (cited): an entity not using the direct method "shall -determine and report the same amount for net cash flow from operating -activities indirectly by adjusting net income … (the indirect or -reconciliation method)." ASC 230-10-45-16: "All of the following are cash -inflows from operating activities: Cash receipts from sales of goods or -services…". A closing entry is not income or spending (B-11), so period -statements exclude it. +The P&L covers a period; a closing entry is not income or spending +(B-11), so period statements exclude it. -Investing and financing are gross (ASC 230-10-45-7, read directly -2026-10-14, `sources/fasb-asc.md`: -"information about the gross amounts of cash receipts and cash payments -during a period is more relevant than information about the net -amounts"; 45-8 allows netting only where "the turnover is quick, the -amounts are large, and the maturities are short"). Financing inflows -include "Proceeds from issuing equity instruments" and "other short- or -long-term borrowing" (45-14(a), (b)); outflows include "distributions to -owners" and "Repayments of amounts borrowed" (45-15(a), (b)). Every cash -flow is investing, financing, or operating (45-10). Investing -"include[s] making and collecting loans and acquiring and disposing of -debt or equity instruments and property, plant, and equipment" (ASC -230-10-20). Which accounts hold loans made, investments, and debt is a -fact of each company's chart, so it is a setting: `[books] -financing_accounts` and `investing_accounts` in wanderbooks.toml (with -`cash_accounts`), read by the MCP tools and the viewer alike. +The statement of cash flows (P3 defect 1, D-35, D-42; the paragraphs were read +in public sources that reproduce the Codification, `sources/fasb-asc.md`): -Money an owner advances, by paying a company expense personally (a -"Due to owners" liability), is borrowing: PwC Viewpoint, Financial -statement presentation 6.9.3.1 (constructive receipts and -disbursements) presents cash a third party pays on the entity's behalf -"as though the transaction took place through the bank accounts of the -reporting entity": a financing inflow and an operating outflow. Listing -that liability in `financing_accounts` gives exactly that; repaying the -owner is then a repayment of amounts borrowed. "Judgment is required" -(PwC), so it is the company's setting, not a fixed rule. +1. **Cash receipts and payments.** The statement reports "the cash + effects" of operations, investing and financing (ASC 230-10-45-1; its + objective, 10-1: "cash receipts and cash payments"). An entry + contributes to the body only its net movement of the cash accounts, + and what a member or a lender paid or received for it (rule 11, which + nets to zero within the entry); an entry that moves neither + contributes nothing; a transfer between two cash accounts is no flow. The period is [start, end], inclusive. The + cash accounts are every account holding cash, cash equivalents or + restricted cash: the statement explains the change in their total + (45-4, as ASU 2016-18 amended it), and transfers among them "are not + reported as cash flow activities" (45-5). Which accounts those are is + the books' `books.cash_accounts`. +2. **Partition.** Every receipt and payment is investing, financing or + operating (45-10), so every non-cash account is in exactly one section: + investing (the cost and accumulated-depreciation accounts of every + registered fixed asset, and `books.investing_accounts`); financing + (equity, and `books.financing_accounts`); operating (every other + balance-sheet account, and revenue and expense, but see rule 4). The + roles must agree with the chart and each other, or the statement is + refused with the setting to change: cash and investing accounts are + assets; financing accounts are liabilities (equity listed is accepted: + it is financing anyway); no code twice in a list or in two lists; no + registered asset's account as cash; at least one cash account. One + check (`cashflow::role_problems`) is behind both the refusal and + `books.settings`' problems. +3. **Only the cash portion** of a part-cash transaction is reported + (50-5). An entry's cash `c` is 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 entry's noncash + part, disclosed (rule 7). CONVENTION: the order (the ASC is silent on + allocating within one entry); it matters only for an entry with a + noncash source and two or more sections paid by cash, and is disclosed. + Within a section the same order splits the cash among its kinds (rule + 5). +4. **Gains and losses on investing assets are investing**: receipts from + sales of PP&E are investing (45-12(c)), so the whole proceeds are, and + the gain or loss is taken out of net income (45-28(b)). In an entry that + credits a registered cost account or an investing account, the revenue + and expense lines are investing, except interest, received or paid (tax + categories `interest_income` and `interest_expense`): interest is + operating in any entry (45-16(b), 45-17(d)). The trigger is broad (a + return, a reclass or an impairment credits the cost account too), so the + reconciling line reads "(Gain) loss and other noncash changes in + investing assets". +5. **Gross lines by kind** (45-7; investing and financing inflows and + outflows "reported separately", 45-26). One line per entry, section and + kind, with the entry, date, label and the kind's accounts. Operating + lines carry the class 45-25 requires "at a minimum" (a)-(g), kind by + kind within the entry, by the direction of the cash: the cash an + entry's `interest_income` lines take is (b) interest received, and its + `interest_expense` lines' (e) interest paid, whatever else the entry + settles (a loan payment on an operating payable is (d) for the + principal and (e) for the interest, never all (e)); the rest of its + operating cash, by its other operating lines: a receipt is (a) if a + line is revenue or the receivables control, or the entry's party is a + customer, else (c); a payment is (g) if every such line is on an + account whose tax category is `taxes_licenses` (payments to + governments for taxes and fees are 45-17(c), apart from suppliers' + 45-17(b); 45-25 names only income taxes, (f)), else (d) if a line is + an expense, a liability or a non-receivables asset, else (g). (Interest paid back out + is (g); interest refunded to the company, (c).) The operating cash is + split among these kinds as a section's among its kinds (rule 3), in + the order: the rest, interest received, interest paid (CONVENTION). + Class (f) stays empty until a tax category marks an entity-level income + tax (a + partnership pays none "as such", IRC §701, but can under §6225 or a + state pass-through election). Investing and financing kinds (D-35): + purchases and sales of property and equipment, payments for and + collections on each investing account, contributions from and + distributions to each member (by name; equity not a member's by + account), proceeds of and repayments of each financing liability. An + original's direction is its nature (45-15(a): cash out of equity is a + distribution). The printed statement and the viewer show operating one + row per class, and investing and financing one row per kind with its + total, each transaction listed under it (D-35); `books.cash_flow` and + the exports carry every line. Known limits: an entry's other operating + lines take one class, so its mixed receipts and payments other than + interest net within it; class (b) relies on portfolio interest being + categorized `interest_income` (interest receivable collected is (c)). +6. **Reversals.** A reversal takes its chain root's sections, class and + kind, with its own sign (`Ledger::reversal_chain`). Pairing walks each + chain from its root: an entry and its reversal both in the period drop + out of every line and disclosure, when their flows are exact negatives + (proved, and checked at runtime), so a reversal of a reversal whose + partner is paired is shown. A reversal shown alone is labelled + "Reversal of #N — ", a reinstatement "Reinstatement + of #A (reverses #B)", listed under the original's kind: never a label + from a reversal's sign (D1-c). +7. **Noncash investing and financing activities are disclosed** (50-3, + 50-4; placement, 50-6). Disclosed: every entry not netted whose noncash + part moves financing, or moves investing through an account that is + neither accumulated depreciation nor a rule-4 P&L line, or that + exchanges inside investing and financing without cash (50-4's + "converting debt to equity", a note for equipment, a trade-in): `x = + max(0, min(D - out, C - in))` over the net debits `D` and credits `C` + of its investing and financing balance-sheet accounts, `in` and `out` + the cash those two sections took in and paid out, what a member or + lender handled for the entry included (rule 11: equipment a bank paid + for is 1,000 in and 1,000 out, nothing exchanged). Amount: `max(Σ + max(r_k, 0), x)`. Depreciation is a + reconciling item, not an activity. Limit: a trade-in within one cost + account nets away. +8. **Net income reconciled to operating cash**, a separate schedule with + the direct method (45-30; major classes of reconciling items, 45-29; + what is removed, 45-28): operating = NI + G + X + W, over the period's + non-closing entries. NI, net income; G, the rule-4 P&L lines; X, each + entry's operating noncash part, as "Depreciation" (its investing and + financing balance-sheet lines all accumulated depreciation), "Noncash: + through " (those lines, accumulated depreciation aside, all on A), + or "Other noncash items"; W, minus each + operating balance-sheet account's movement ("Increase in" / "Decrease + in"). An identity over balanced entries (Lean + `operating_reconciliation_ties`), checked at runtime. +9. **The sections foot** (45-24: the statement reconciles beginning and + ending cash). `reconciles {sections_sum, net_change, difference}`; any + difference, a section unequal to its lines, or a reconciliation unequal + to operating is `CashFlowDoesNotFoot`, an engine error: nothing is + produced. It can't happen on posted entries (Lean + `cashflow_sections_sum_to_cash_change`, `Reachable.cashflow_foots`). +10. **Closing entries are never in the statement** (B-11). A close moves + only revenue, expense and equity, and cash accounts are assets, so a + close has no cash line. +11. **What a member or a lender pays, or receives, for the company is + cash the company borrowed (or a member contributed) and spent** + (DECISION, D-42, the owner, 2026-10-20, revising D-35's first point; + bookspec's CHOICE: the standard leaves it open). + *Source.* The Codification does not address a payment a third party + makes on the entity's behalf (PwC Viewpoint FSP 6.9.3: "ASC 230 does + not address these situations"; BDO Blueprint 3.4: "While ASC 230 is + silent"; EY FRD 4.6.2: "judgment will be needed"; KPMG Handbook + Q8.3.10), so 105-10-05-2 sends the question to nonauthoritative + guidance. There the firms present it by substance, as constructive + receipt and disbursement: PwC 6.9.3.1, "a reporting entity should + include cash flows received or paid by a third party on behalf of the + reporting entity" (a bank paying a vendor: "a financing inflow ... and + an operating outflow"); DART Roadmap 7.2 (third-party financing, + Example 7-2: a lender paying an insurance premium, an operating outflow + and a financing inflow; the contrast, a supplier's own finance arm, is + AICPA TQA 1300.16 and the SEC staff's 2005 view: noncash); BDO 3.4 + (Example 3-5: a bank paying for equipment, an investing outflow and a + financing inflow; it names "a parent might pay on behalf of a + subsidiary expenses"); EY 5.2.4 and 5.3.1 (third-party financing, + unlike the seller's, is a financing inflow and an operating or + investing outflow). KPMG Q8.3.10 also accepts a noncash presentation + for equipment a lender pays for. A CPA would present it as the firms + do; the change in cash is the same either way. The rule, entry by + entry, after its own cash (rule 3; `kernel::constructive`): + - **Paid for the company.** A financing liability + (`books.financing_accounts`: the members' financing account such as + Due to Members, a loan, a card listed as financing) credited against + operating or investing lines debited paid for them: a financing + inflow under the liability's kind ("Proceeds from borrowing — 2100 + Due to Members", 45-14(b)) and the operating outflow (its class by + rule 5) or the investing outflow (45-13(c)), both on the entry's + date. A member's capital credited against revenue or expense debited + paid for them likewise, a contribution ("Contributions from members + — ", 45-14(a)): the expense-to-capital case below. + - **Received for the company.** A financing liability or a member's + capital debited against revenue, expense or the receivables control + credited (a customer paying a member, a vendor refunding to a + member's card): an operating inflow, and a repayment or a + distribution (PwC 6.9.3.1: cash "received or paid"). + - Each line's `on_behalf` is the part of its cash so handled. The parts + net to zero within the entry, so the sections still sum to the + change in cash (rule 9; Kani `constructive_nets_to_zero`). Kinds take + it in order, operating before investing and, within operating, + balance-sheet lines before revenue and expense (CONVENTION, as rule + 3's: it matters only for an entry whose sources cover part of its + uses, and then leaves capital the revenue and expense it can pay + for). A reversal mirrors its root (rule 6; Kani + `constructive_mirrors`), so a payment and its reversal net out, and + a reimbursement is the repayment it is (45-15(b)). + - **Stays noncash**, disclosed (rule 7): property a member contributes + that it already owned (capital credited against an asset, a prepaid + included: no cash moved, to anyone, 50-4); property handed over in + kind (an asset credited against a financing liability or capital + debited); a debt exchanged for another or for equity (50-4's + "converting debt to equity"); interest and guaranteed payments + charged into what the company owes the lender or member (tax + categories `interest_expense`, `interest_income`, + `guaranteed_payments`: their own charges, accrued, paid to no one; + "interest paid", 45-25(e), is cash paid); and a purchase on the + seller's own credit, an operating payable (50-4, "acquiring assets by + assuming directly related liabilities"; EY 5.3.1), flagged + `asset_on_operating_payable` (rule 12). + - **An expense a member pays into capital** (Dr expense / Cr the + member's capital, no cash) is a constructive contribution and an + operating outflow. The member paid cash to a vendor for the company; + whether the company owes it back (Due to Members) or not (capital) + is only the form of the financing, not the substance of the payment, + which the guides follow ("the same economic result", DART 7.2). BDO + 3.4 lists a parent paying a subsidiary's expenses among the cases + for constructive cash flows without asking how the subsidiary + records what it owes back (a parent's payment is often booked as a + contribution), and none of the guides read presents an expense + someone paid on the entity's behalf as noncash. The books' own K-1 + worksheet already counts it as a cash contribution (`k1.rs` + INVARIANT 3, DECISION 2026-10-15), so the two agree. + - **Card purchases** follow the card's classification (D-35, + unchanged): on a card listed as financing, the issuer is a lender + that paid the merchant, so the purchase is borrowed and spent and the + card payment a repayment; on a card that is an operating payable, the + purchase is an accrual and its payment operating (d). + - *Limits* (the books can't tell who was paid). A lender's or member's + own charge credited to what the company owes them, other than + interest and guaranteed payments (a card's annual fee, rent a member + charges), reads as paid for the company: its operating outflow shows + when charged, its repayment as financing (each section's total over + the charge and its payment is right; the timing is the charge's). + Post what the company owes a member for their own goods or services + to a payable, not to the members' financing account. A note to the + seller listed in `books.financing_accounts` reads as a lender's loan + (borrowed and invested, where 50-4 makes it noncash): keep a seller's + note out of it (its principal payments then show as operating, where + 45-13(c) makes them financing; flagged). An expense a member + contributes in kind (an item already owned, expensed at once) reads + as paid: post it to an asset first. A debt a member or a new lender + pays off directly is a noncash exchange (disclosed), where PwC + 6.9.3.1 and BDO Example 3-6 would gross it up. Debt issue costs, a + financing outflow (45-15(f)), posted to an expense account read as + operating: post them to a contra-liability listed as financing, so a + fee the lender withholds is a noncash exchange inside financing and + the loan reads as its net proceeds. An entry that moves revenue or + expense straight to capital by hand, as a close does, reads as paid + or received for the company: close periods with `books.close_period`, + whose entries are never in the statement (rule 10). +12. **Equipment paid for, or sold, later** (D-35; 45-12(c), 45-13(c)). A + bill payment carries its bill's split, equipment part first: the + payables line's equipment part is investing, so its payment is an + investing outflow, the reconciliation's payables change leaves it out, + and the purchase is disclosed only for what is unpaid at the period's + end (a payment reversed by then is unpaid again; EY FRD 5.3.3; DART + 6.1.3; bills post to the general-expenses + control today, so this waits for bills with account lines). A card + purchase follows the card's classification: on an operating payable it + is disclosed and flagged (`asset_on_operating_payable`), on a financing + one it is borrowed and invested (rule 11) and its payment a repayment. A sale for an amount owed on an + operating receivable is disclosed and flagged with the fix + (`asset_sold_on_operating_receivable`: post it to an investing + account). The flags are the statement's `warnings`, and items in the + viewer's Needs attention. Limits: payments more than "soon after" purchase are + financing by 45-13(c) when the seller financed it, which the books + can't tell from a bill; unregistered equipment is operating (the asset + register's `unregistered_cost` flags it). -**Enforced:** `profit_and_loss_for_period`, `cash_flow`. **Checked:** Lean -`statements_add_up`; `prop_period_pnl_partitions`; -`cash_flow_reconciles_to_change_in_cash`; -`financing_is_gross_borrowing_apart_from_repayment`, -`a_loan_the_entity_makes_is_investing_gross`, -`an_expense_the_owner_paid_is_borrowing_then_operating`. +Scope: single-currency books (R-5). A foreign-currency cash account's +exchange effect would be a separate reconciling line (830-230-45-1); a +hand-posted revaluation of a cash account shows as an operating receipt. + +**Enforced:** `profit_and_loss_for_period`; `cashflow::cash_flow` (with +`kernel::entry_flows`, `apply_cash`, `constructive`, `exchanged`), +`role_problems`, `foot`; `ledger_package::cash_flow_for` (the server, the +package and the viewer). **Checked:** Lean `statements_add_up`, +`cashflow_sections_sum_to_cash_change`, `entry_flows_sum`, +`flows_reversal`, `constructive_nets`, `constructive_neg`, +`constructive_sections_net`, `stmt_flows_sum`, `stmt_flows_reversal`, +`operating_reconciliation_ties`, `cashflow_reversal_nets`, +`Reachable.cashflow_foots`, `Reachable.cashflow_is_change_in_cash`; Kani +`entry_flows_*`, `no_cash_no_flow`, `take_is_odd_and_bounded`, +`apply_cash_mirrors`, `apply_cash_spends_exactly`, +`exchanged_is_bounded`, `constructive_nets_to_zero`, +`constructive_mirrors`; `prop_period_pnl_partitions`, +`prop_cash_flow_laws` (5,000 cases, reversals of reversals, rule 11's +shapes), `prop_pure_entries_sections_are_movements`; regressions +`defect1_reversed_contributions_foot_regression`, +`defect1_two_member_llc_first_year_regression`, +`defect1_two_member_llc_as_of_march_18_regression` (and bookspec +`cash-flow.*`, `cash-flow.paid-on-the-companys-behalf` for rule 11); +`disposal_proceeds_all_investing`, +`an_expense_a_member_paid_is_borrowed_and_spent`, +`reimbursing_a_member_is_a_repayment_of_borrowing`, +`equipment_a_lender_paid_for_is_borrowed_and_invested`, +`an_expense_a_member_paid_into_capital_is_contributed_and_spent`, +`property_a_member_contributes_stays_noncash`, +`equipment_on_the_sellers_credit_stays_noncash_and_flagged`, +`a_payment_made_for_the_company_and_its_reversal_net`, +`part_paid_by_a_member_and_a_refund_a_member_received`, +`a_lender_pays_balance_sheet_lines_first_and_a_customer_pays_a_member`, +`interest_and_guaranteed_payments_charged_to_a_creditor_stay_noncash`, +`a_fee_withheld_from_a_loan_follows_its_account`, +`odd_shapes_foot_and_net`, `operating_classes_follow_asc_230_10_45_25`, +`a_reversal_of_a_reversal_is_shown_and_foots`, +`a_reversal_three_levels_down_is_labelled_a_reversal`, +`operating_cash_is_classed_kind_by_kind`, +`two_kinds_of_one_class_are_one_line`, +`interest_paid_in_a_disposal_stays_operating`, +`a_loss_on_disposal_on_a_revenue_or_expense_account_is_investing`, +`an_investments_gain_is_investing`, `cash_goes_to_the_sections_in_order`, +`cash_goes_to_a_sections_kinds_in_order`, +`converting_member_debt_to_capital_is_disclosed`, +`a_bill_payment_carries_its_bills_split`, +`a_reversed_bill_payment_leaves_the_purchase_unpaid`, the role refusals, +`foot_refuses_any_difference` (`crates/ledger-core/tests/spec_cash_flow.rs`). ## B-15 Depreciation: straight line, salvage never depreciated, partial months by days @@ -491,10 +749,11 @@ Attorneys' gross proceeds stay at $600 (not modeled). What counts as paid: payments made in the calendar year (§6041(a)), so a prepayment counts when made. A worksheet as of a day (the CPA package's, -B-33) counts the year's payments from January 1 through that day. The engine counts credits minus debits to the -payment sources on a vendor's tagged entries: `[books] cash_accounts` and -`financing_accounts` (a member or lender paying on the company's behalf; -the company is still the payer). A card charge leaves neither (1099-K); a +B-33) counts the year's payments from January 1 through that day. The +engine counts credits minus debits to the payment sources on a vendor's +tagged entries: `[books] cash_accounts` and `financing_accounts` (a +member or lender paying on the company's behalf; the company is still the +payer). A card charge leaves neither (1099-K); a bill is not paid until it is (`form1099::vendor_1099_report_with`, checked by `payments_come_from_cash_and_financing_only`). A W-9 is on file by the vendor's flag or its party's W-9 facts. @@ -1067,7 +1326,8 @@ fixed asset register, mid-year and at year end; `as_of_package_iif_stops_at_as_o `iif_ties_to_the_trial_balance_as_of`, 2,000 cases); `ledger-mcp` `oracle_mcp.rs` (`cpa_package_holds_the_year_and_its_evidence`); the bookspec examples `bookspec/examples/as-of-package-stops-at-as-of.toml` -and `bookspec/examples/1099-worksheet-through-as-of.toml`. +and `bookspec/examples/1099-worksheet-through-as-of.toml` (`ledger-core` +`tests/bookspec.rs`). ## B-34 The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out diff --git a/docs/spec/sources/fasb-asc.md b/docs/spec/sources/fasb-asc.md index ac0d78f..f4d461f 100644 --- a/docs/spec/sources/fasb-asc.md +++ b/docs/spec/sources/fasb-asc.md @@ -1,6 +1,7 @@ # FASB Accounting Standards Codification: paragraphs read directly -Read 2026-10-14 in the ASC's basic view (asc.fasb.org, the owner's login). +Read 2026-10-14 in the ASC's basic view (asc.fasb.org, the owner's login), +except the ASC 230 section added 2026-10-20, which says where it was read. The Codification is copyrighted by the Financial Accounting Foundation, so this file keeps only short quotes and paraphrase, with the paragraph numbers to look up. Earlier ASC paragraphs were read through Deloitte's @@ -49,6 +50,197 @@ loans the entity makes are investing (45-12(a), 45-13(a)). Proceeds of → B-14: money owners advance (Due to Shareholders) is borrowing, so financing, gross; a loan the company makes is investing, gross. +## ASC 230 for the direct method (rules.md B-14, P3 defect 1) + +Read 2026-10-20, not in the ASC basic view: DART's Codification pages +need a login, and its public Roadmap on the statement of cash flows +quotes only some of these paragraphs. So they were read where public +sources reproduce the Codification's text: EY's Financial reporting +developments guide "Statement of cash flows" (July 2026, +https://www.ey.com/content/dam/ey-unified-site/ey-com/en-us/technical/accountinglink/documents/ey-frd42856-07-29-2026.pdf, +which quotes ASC 230 and 830-230 in full; page numbers its own), the +FASB's ASUs that last amended them (ASU 2016-15, +https://storage.fasb.org/ASU%202016-15.pdf; ASU 2016-18, +https://storage.fasb.org/ASU%202016-18.pdf), and Deloitte's public DART +Roadmap where it quotes them +(https://dart.deloitte.com/USDART/home/codification/presentation/asc230-10/roadmap-statement-cash-flow, +no trailing slash: with one the page is "Tree node not found"). +Quotes are short; the rest is paraphrase. + +- **230-10-10-1** (EY p. 1): the statement's primary objective is "to + provide relevant information about the cash receipts and cash payments + of an entity during a period." → B-14 rule 1. +- **230-10-45-1** (EY p. 1): the statement "shall report the cash effects + during a period of an entity's operations, its investing transactions, + and its financing transactions." (It says nothing of reconciling + beginning and ending cash: that is 45-24.) +- **230-10-45-4** (EY p. 5): the statement explains the change in the + total of cash, cash equivalents and restricted cash. ASU 2016-18 struck + its old sentence on the totals at the beginning and end of the period + (gone from the current text). → rule 1: the cash accounts are all of + them. (50-7 and 50-8, EY pp. 5-6, 30: the nature of restrictions, and + the balance-sheet lines that hold the total when there are several.) +- **230-10-45-5** (EY p. 5): purchases and sales of cash equivalents are + cash management, and transfers among cash, cash equivalents and + restricted cash "are not reported as cash flow activities". → rule 1: a + transfer between two cash accounts is no flow. +- **230-10-45-12(c)** (EY p. 41; ASU 2016-15 p. 25): investing inflows + include "Receipts from sales of property, plant, and equipment and other + productive assets". → rule 4. +- **230-10-45-13(c)** (EY p. 41; ASU 2016-15 p. 12): investing outflows + include payments "at the time of purchase or soon before or after + purchase" for PP&E; generally only those are investing, and incurring + debt directly to the seller is a financing transaction whose later + principal payments are financing. → rule 12 (the bill split), and the + limit B-14 records. +- **230-10-45-14(a)** (EY p. 42): financing inflows include "Proceeds from + issuing equity instruments". **45-15(a)**: financing outflows include + "Payments of dividends or other distributions to owners"; **45-15(c)**: + most principal payments on seller-financed debt for PP&E are financing. + (45-14(b) and 45-15(b): above, read directly.) +- **230-10-45-16(a), (b)** (EY p. 43): operating inflows include "Cash + receipts from sales of goods or services", including collections of + receivables from customers arising from those sales; and interest and + dividends received. 45-16(c): all other operating receipts. → rule 5, + rule 4's exception. +- **230-10-45-17** (EY pp. 43-44): operating outflows: payments for goods + for resale (a), to "other suppliers and employees" (b), to governments + for taxes, duties, fines and other fees (c), interest to lenders and + other creditors (d), and others ((e), (ee), (f)). (Income taxes fall + under (c); 45-25 names them a class.) +- **230-10-45-8, 45-9** (EY p. 12): only items whose "turnover is quick, + the amounts are large, and the maturities are short" (original maturity + "three months or less"; amounts due on demand count) may be reported + net, and even then need not be. **230-10-45-26** (EY p. 13): otherwise + investing and financing inflows and outflows are reported separately, + "proceeds of borrowings ... separately from repayments of debt". → rule + 5: gross lines, members' advances and their repayments apart. +- **230-10-45-24** (EY p. 15; ASU 2016-18 pp. 4-5): the statement reports + net cash from operating, investing and financing activities and "the + net effect of those flows" on cash, "in a manner that reconciles + beginning and ending totals". → rule 9, the footing. +- **230-10-45-25** (EY p. 8; ASU 2016-15 p. 10): entities "are encouraged + to report major classes of gross cash receipts and gross cash payments"; + those that do "shall, at a minimum, separately report" (a) cash + collected from customers, (b) interest and dividends received, (c) other + operating cash receipts, (d) cash paid to employees and other suppliers, + (e) interest paid, (f) income taxes paid, (g) other operating cash + payments. → rule 5, the operating classes; the direct method is a + choice the standard encourages, not one it requires. +- **230-10-45-28** (EY p. 10; DART 3.1): the indirect method adjusts net + income to remove (a) deferrals and accruals of operating receipts and + payments, such as changes in receivables and payables, and (b) items + with no operating cash effect, including those "whose cash effects are + related to investing or financing cash flows, such as gains or losses on + sales of property, plant, and equipment". → rule 8's G and W. +- **230-10-45-29** (EY p. 7; DART 3.1): the reconciliation of net income + to operating cash is required with either method; it "shall separately + report all major classes of reconciling items", at a minimum the + changes in operating receivables, inventory and payables. +- **230-10-45-30** (EY p. 8; DART 3.1): with the direct method, that + reconciliation "shall be provided in a separate schedule." → rule 8. +- **230-10-50-3** (EY p. 47): investing and financing activities that + "affect recognized assets or liabilities but that do not result in cash + receipts or cash payments in the period shall be disclosed", narrative + or in a schedule. → rule 7. +- **230-10-50-4** (EY p. 47): its examples, among them: "converting debt to equity"; + acquiring assets by assuming directly related liabilities; obtaining a + right-of-use asset for a lease liability (the old "capital lease" + wording, replaced); receiving a gift of an asset; exchanging noncash + assets or liabilities for others (also a beneficial interest obtained + in a securitization, not listed here). → rule 7's exchange inside one + section. +- **230-10-50-5** (EY p. 47): "Some transactions are part cash and part + noncash; only the cash portion shall be reported in the statement of + cash flows." → rule 3. +- **230-10-50-6** (EY p. 47): with only a few noncash transactions, they + may be on the same page as the statement; otherwise elsewhere, clearly + referenced to it. → the printed statement's noncash schedule. +- **830-230-45-1** (EY p. 81; DART 7.1): the effect of exchange-rate + changes on cash held in foreign currencies is reported as a separate + part of the reconciliation of the change in cash. → B-14's scope note + (single-currency books, R-5). +- **105-10-05-1, 05-2, 05-3** (read only in their 2009 text, ASU 2009-01 + Appendix B, pp. 17-18, https://storage.fasb.org/ASU2009-01.pdf; the + current wording is not confirmed): the Codification is "the source of + authoritative generally accepted accounting principles"; where it does + not specify, an entity first considers principles for similar + transactions "and then consider[s] nonauthoritative guidance from other + sources"; practices not in the Codification are nonauthoritative + (sources include accounting textbooks, handbooks and articles). → rule + 11: the Codification does not address payments made on the entity's + behalf (below), so this is where firm guidance comes in; rule 11 + follows it (D-42), a choice the standard leaves open, not a requirement + of any paragraph. + +Also read there, for the design (not authoritative, 105-10-05-3): + +- DART 6.1.3 and EY 5.3.1 and 5.3.3 (equipment on account: payments soon + after purchase are investing, "soon" read by the SEC staff as three + months or less; the change in payables in the reconciliation leaves + out payables for investing; the noncash disclosure is the liability + that remains unpaid at the period's end). → rule 12. +- Payments a third party makes, or cash it receives, on the entity's + behalf (rule 11, D-42). No ASC paragraph addresses them: PwC Viewpoint, + Financial statement presentation 6.9.3 ("ASC 230 does not address these + situations"); KPMG Handbook, Statement of cash flows (March 2026) + Q8.3.10 ("Topic 230 does not directly address"); BDO Blueprint, + Statement of cash flows under ASC 230 (December 2025) 3.4 ("While ASC 230 + is silent"); EY 4.6.2 ("we believe judgment will be needed"). Read + 2026-10-20 for D-42, in each firm's public text (PwC + https://viewpoint.pwc.com/dt/us/en/pwc/accounting_guides/financial_statement_/financial_statement___18_US/chapter_6_statement__US/69_supplementary_cash.html; + DART https://dart.deloitte.com/USDART/home/codification/presentation/asc230-10/roadmap-statement-cash-flow/chapter-7-common-issues-related-cash/7-2-constructive-receipt-disbursement; + BDO https://arch.bdo.com/Statement-of-Cash-Flows-Under-ASC-230; EY as + above): + - **PwC 6.9.3.1** (Constructive receipts and disbursements): "a reporting + entity should include cash flows received or paid by a third party on + behalf of the reporting entity", as though through its own accounts. + Its examples: a transfer agent moving a new lender's proceeds to pay + off the old lender (gross up both), and a bank paying a vendor at the + entity's instruction ("a financing inflow for the receipt of the debt + proceeds", and an operating outflow for the vendor). "Judgment is + required." + - **DART Roadmap 7.2** (Constructive receipt and disbursement): when a + third party handles cash on the entity's behalf, the entity may have + constructive receipt or disbursement, because "the same economic + result would be achieved if the cash had been received by or disbursed + to" it directly. Inventory financed by an unrelated lender that pays + the supplier: a financing inflow and an operating outflow, repayments + financing; a mortgage lender wiring the seller: a financing inflow and + an investing outflow; Example 7-2, a lender paying an insurance + premium: an operating outflow for the premium and a financing inflow. + The contrast is a supplier's own finance subsidiary (AICPA Technical + Q&A 1300.16, and the SEC staff at the 2005 AICPA conference): a trade + loan, noncash. + - **BDO 3.4** (Constructive disbursements and receipts): among its cases, + "A parent might pay on behalf of a subsidiary expenses that are + recognized in the subsidiary's separate financial statements"; an + entity "generally should report constructive cash flows when it + contracts with a third party to act on its behalf (for example, a + transfer agent, investment bank, or lender) or when cash does not flow + through the entity's cash accounts for administrative convenience". + Example 3-5: a bank remitting the price of equipment to the supplier, + an investing outflow and a financing inflow, later principal payments + financing (citing TQA 1300.19); Example 3-6: a refinancing through an + agent, a financing inflow and outflow; Example 3-4: the supplier's own + finance subsidiary, noncash (TQA 1300.16). + - **EY 5.2.4 and 5.3.1**: inventory or equipment financed by a third + party (not the seller) is an operating or investing outflow and a + financing inflow; seller-financed purchases are noncash "to the extent + of the initial seller financing", disclosed under 50-3 to 50-6, and + later principal payments are financing whichever financed them. + - **KPMG Q8.3.10** accepts a noncash presentation (citing 50-3) for + property and equipment a lender pays for. + + → rule 11 (D-42): constructive receipt and disbursement for what a + member (through the members' financing account, or capital for an + expense) or a lender pays or receives for the company; noncash for + property a member already owned (50-4) and for the seller's own credit + (EY 5.3.1). The expense-to-capital case follows BDO 3.4's parent paying + a subsidiary's expenses, and the substance test of DART 7.2 and PwC + 6.9.3.1, which turn on who paid whom, not on how the entity records + what it owes back. + ## 606-10-55-36 through 55-38 (Revenue: principal or agent) An entity controlling the specified good or service before transfer is diff --git a/docs/testing.md b/docs/testing.md index 5c22be7..5f8703a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,7 +23,7 @@ promise. | Model-based stateful | the Lean state machine, transcribed (3) | `tests/model.rs` | `just check` | ~3s | every change | | Differential: Decimal vs i64 | the kernel at two types (3) | `kernel_bridge.rs` | `just check` | <1s | every change | | Differential: GnuCash | another implementation, adjudicated (3) | `tests/oracle_gnucash.rs`, `oracle/gnucash/` | `just oracle` | ~2 min | weekly | -| Formal proofs | Kani (bounded) and Lean (unbounded) | `src/proofs.rs`, `verify/lean/` | `just prove` | ~3 min | weekly | +| Formal proofs | Kani (bounded) and Lean (unbounded) | `src/proofs.rs`, `verify/lean/` | `just prove` | ~15-20 min (bounded by `entry_flows_of_a_reversal_mirror`, ~14 min; all 29 harnesses ~43 min of CPU, run in parallel) | weekly | | Fuzzing | crash, hang, broken books (4); for `fuzz_store_replay` (validly chained journals with fuzz-chosen events): books that open must verify, and the viewer must accept exactly what the writer does | `fuzz/fuzz_targets/`, corpora in `fuzz/corpus/` | `just fuzz ` | 10 min each | weekly | | Miri | undefined behavior in our use of dependencies (4) | the core's unit, bridge, invariant tests | `just miri` | ~2 min | on demand | | Golden | `tests/golden/fixture_company.json` (5) | `invariants.rs` | `just check` | <1s | every change | @@ -606,7 +606,9 @@ worked by hand in the test's comment. anonymized, with a close to retained earnings between two members closes. The run rule left A −399.24 / B −399.22; the rule since P3 gives −399.23 each (March: Dr 3000 199.61, Dr 3001 199.62). Its bookspec example is -`bookspec/examples/members-close.odd-cent-across-closes.toml`. +`bookspec/examples/members-close.odd-cent-across-closes.toml`; it and the +other `members-close.*` examples are checked with every example by +`ledger-core` `tests/bookspec.rs` (`every_bookspec_example_holds`). **Core** (`ledger-core` `tests/odd_cent.rs`): a true-up from inception (`members_closes_true_up_from_inception`); a close that posts nothing @@ -763,7 +765,17 @@ fixtures: ones (`bookspec/examples/as-of-package-stops-at-as-of.toml`, `1099-worksheet-through-as-of.toml`) and the IIF's (`iif-quickbooks-journal-file.toml`, formats appendix), each expected - value derived by hand; `the_bookspec_iif_example_matches_the_engine` + value derived by hand. `ledger-core` `tests/bookspec.rs` loads every + example, the formats appendix's too, and checks the as-of ones' trial + balances, profits and losses and 1099 payments against the engine + (`every_bookspec_example_holds`, the one loader of every example; the + tests beside it refuse a query no crate answers, a format query outside + the formats appendix, an unknown appendix, a payee not in the setup, a + figure query leaving out an account, payee or entry line it covers, an + as-of query given a period, `after` past the last event, members' + shares not summing to 1 and a members close declared with another + rounding, and show `after` asks the books after that event); + `the_bookspec_iif_example_matches_the_engine` reads the IIF example and checks the engine gives exactly its file and changes. - Review R18 and the README: `one_list_of_cash_accounts_per_package`, @@ -895,6 +907,149 @@ condition and `receivable_payable`; `fonts.rs` and `theme.rs` `parties.rs` `tin_separator`'s arms and `tin_invisible`, and the gap that doesn't count them. +## P3: cash flow (defect 1, 2026-10-20) + +The statement of cash flows was rebuilt by the direct method (rules.md +B-14, D-35), and what a member or a lender pays for the company shown as +cash borrowed and spent (D-42, rule 11). Built, reviewed twice, `just +check` and `just prove`; no mutation run (D-33): what P12 must cover is at +the end. + +- **Named regressions** (`crates/ledger-core/tests/spec_cash_flow.rs`; + also bookspec examples, `bookspec/examples/cash-flow.*.toml`, checked by + `tests/bookspec.rs` on every run): + `defect1_reversed_contributions_foot_regression` (the minimal + reproduction: a property contribution reversed, which broke footing by + the contribution's amount), `defect1_two_member_llc_first_year_regression` + and `defect1_two_member_llc_as_of_march_18_regression` (a replica of + the 23 entries defect 1 was found in: the same shape, order, accounts + and roles, Member A and B and generic vendors, every amount and date + made up, so none is the real books'; to 2026-03-31, operating -596.24, + investing 0.00, financing +2,216.40, the change in cash +1,620.16, and + to 2026-03-18, operating -549.86, financing +2,549.86, worked by hand + in the test's doc comment). +- **The rule, case by case** (same file; each oracle worked by hand from + its paragraph in the test's doc comment): disposals at a gain, a loss + and before any depreciation (`disposal_proceeds_all_investing`); a sale + on an IOU (disclosed, flagged, with the fix); property contributed; + what a member or lender pays or receives for the company (D-42, rule + 11): an expense through Due to Members, equipment a bank paid for, an + expense paid into capital, a financing card, part cash and part a + member's, a refund to a member, a customer paying a member, a payment + made for the company and its reversal (in the period and alone, a + prepaid and equipment too), the reimbursement, the uses' order (rule + 11's convention); and what stays noncash: property a member owned (with + members configured and without), the seller's credit (flagged), interest + and guaranteed payments charged to a creditor, a fee withheld on a + contra-liability. From the second review: odd shapes (two cash accounts + in one entry, one account on both sides, a reversal chain across the + period start, a close: `odd_shapes_foot_and_net`) and taxes and + government fees as other payments, (g); reversals in the + period, alone, taking their root's sections and class, and reversals of + reversals (review item 1); part cash (50-5); a fee withheld from a loan; + interest with principal, paid and received; one entry per operating + class (a)-(g); depreciation; cash spent on a depreciation expense account + (D1-e); the reconciliation tying to operating; closes; the start date; + transfers between cash accounts; debt converted to capital, a note + received for equipment, a trade-in (review item 2); the bill split + (D-35, through a bill restored as a later version would journal it), + and a bill payment reversed in the period; a card purchase of + equipment, on an operating and a financing card; each member's kinds; + the five role refusals and `foot` directly. From the P3 review: interest + classed apart from the rest of an entry's operating cash + (`operating_cash_is_classed_kind_by_kind`) and kept operating in a + disposal; a loss on disposal on a revenue-typed or an expense-typed + account; an investment sold at a gain; a reversal three levels down; + and both of rule 3's conventions pinned (the sections' order, the kinds' + order). +- **Properties, 5,000 cases each** (`CF_CASES`, default 5,000, about 16 s): + `prop_cash_flow_laws` over random books of 33 templates (every shape + the rule names, rule 11's included) and reversals of any earlier entry, + reversals included: the sections are the change in cash and the + model's; each section is its lines and kinds; the reconciliation is + operating; an entry's lines are its cash, what was handled for it + netting to zero, each line's cash part with the entry's sign; every + disclosure is its parts' positive sum or its exchange; an entry and its + reversal in the period change nothing; periods split and add; closing + every month changes nothing; scaling scales every figure. + `prop_pure_entries_sections_are_movements`: for pure entries, each + section is minus its accounts' movement over the entries that touch + cash or that a member or lender settled whole for the company (the + plan's wording, with rule 11 modelled apart from the engine; review + item 7's definition of pure). The older properties in `spec_reports.rs` + are kept with new oracles (financing is the cash part of equity and + designated debt plus what the loan or capital paid or received for the + company, by an independent model of rules 3 and 11). +- **Bridge:** `entry_flows_agree`, `apply_cash_and_exchanged_agree`, + `constructive_agrees`. +- **Proofs:** Kani `entry_flows_sum_to_cash`, + `entry_flows_pure_are_movements`, `entry_flows_are_bounded`, + `entry_flows_of_a_reversal_mirror`, `no_cash_no_flow` (entries of up to + three lines), `take_is_odd_and_bounded`, `apply_cash_mirrors`, + `apply_cash_spends_exactly`, `exchanged_is_bounded`, + `constructive_nets_to_zero`, `constructive_mirrors`; Lean + `cashflow_sections_sum_to_cash_change`, `entry_flows_sum`, + `flows_reversal`, `constructive_nets`, `constructive_neg`, + `constructive_sections_net`, `stmt_flows_sum`, `stmt_flows_reversal`, + `operating_reconciliation_ties(_all)`, `cashflow_reversal_nets`, + `Reachable.cashflow_foots`, `Reachable.cashflow_is_change_in_cash` + (docs/verification.md, law 10). +- **Sensitivity (P3 review, by hand, no mutation run):** 18 deliberate + breaks to `cashflow.rs` and `kernel.rs` (a dropped reversal + flip, no netting, financing read as operating, no rule 4, no exchange + disclosure, no interest class, no warnings, the bill's payments + ignored, the label's depth parity, repeated codes allowed, depreciation + disclosed, the sections' or kinds' order reversed, the customer party + ignored, accumulated depreciation in the exchange, the interest + exception dropped, rule 4 for investing accounts): 17 fail a test (4 + of them only since the review's tests); one is equivalent today + (`mirrors` ignoring amounts: every reversal mirrors its original until + P4's drafts). +- **Sensitivity, second round (after D-42, by hand, no mutation run):** + 17 breaks to rule 11 and the kernel's `constructive` (no lender pass, no + member pass, no receipts for the company, a reversal not mirrored, a + lender paying for no investing, capital paying for property, receipts + for property, receivables not received, guaranteed payments or + interest paid for, `on_behalf` not reported, the exchange on the net + flow, owners' equity read as a lender, pairing that ignores + `on_behalf`, the kernel's received sign, `max` for `min`, the uses' + order reversed): 16 fail a test; one is equivalent today (pairing that + ignores `on_behalf`: a mirrored reversal's real and constructive parts + are each proved to mirror, Kani `entry_flows_of_a_reversal_mirror` and + `constructive_mirrors`, so the check is defensive, for P4's drafts). + The first round's 18 breaks, re-run on the reworked code (four + adapted where the code moved), and one more for the taxes class: 18 + fail a test, `mirrors` ignoring amounts still the one equivalent. +- **End to end:** the printed statement's footing, schedules and rows as + the engine's (`cash_flow_foots_and_reconciles`, + `cash_flow_adjustments_appear_only_when_there_are_some`, ledger-import); + the CPA package's statement with both schedules + (`the_cash_flow_carries_both_schedules`); the MCP tool and its exports + field for field against the core (`cash_flow_exports_equal_the_core_statement`), + the CSV's warnings, the settings' roles and a member-paid expense over stdio + (`cash_flow_reads_its_account_roles_from_the_books_settings`), refusals + naming the setting (`cash_flow_refuses_contradictory_roles`), the + settings' problems from the same check; the viewer's statement as the + server's, a loan the setting leaves out operating in both, a refusal + shown as a reason and no number, and the page painted + (`the_cash_flow_page_is_the_servers_statement`, + `a_refused_cash_flow_shows_why_and_no_number`, + `the_cash_flow_page_paints_its_schedules`; with `SHOT_DIR`, a PNG); the + statement's warnings as Needs attention items + (`cash_flow_warnings_are_items`); + every old-books fixture's statement computed or refused, never an + engine error (`every_figure_over_old_books_is_computed_or_refused`). + +**For P12** (D-33): `cargo mutants` on `ledger-core`'s `cashflow.rs` +(`cash_flow`, `analyse`, `pairs`, `label`, `class_of`, `reconcile`, +`warnings`, `bill_splits`, `role_problems`, `foot`, `on_behalf`) and +`kernel.rs`'s `take`, `apply_cash`, `entry_flows`, `constructive`, +`exchanged`; `ledger-import`'s +`report::cash_flow`; `ledger-mcp`'s `cash_flow_out`, `cash_flow_csv`, +`export_cash_flow` and `settings_problems`; zero unexplained survivors +(plan P3 accept). Likely equivalent, to adjudicate: `min` against `max` +in `take` where both bounds coincide, and the kind order's tie-breaks. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work @@ -940,6 +1095,7 @@ accounting question), CHANGE (a source says otherwise; work pending). | 6 | Reconciliation balances | CONFIRMED | Signed by the account's normal side (a card reads as owed), as statements show it; `reconciling_a_card_reads_its_normal_side_...` | | 7 | A member's ending on one equity statement may differ by a cent from the next statement's beginning | CHANGED 2026-10-13; CHANGED P3 | A capital account is one running balance (Treas. Reg. §1.704-1(b)(2)(iv)(b); the Item L quote this row once gave was misread, P3 review R-4). Undistributed earnings now split cumulatively as of each date, so each member's capital chains exactly (`each_members_capital_chains_across_statements`; it fails on the old code); since P3 the split is what the count's next close would give, so a date's capital never moves when its month is closed (D-38; `each_members_capital_chains_and_holds_through_closes`); rules.md B-13 | | 8 | Cash flow nets one line per account; investing comes only from registered assets | CHANGED 2026-10-13 | ASC 230-10-45-7: gross receipts and payments "more relevant" than net; 45-8 limits netting to quick-turnover items. Financing and investing are now gross; loans made and investments bought are investing (230-10-20, 45-13) via `[books] investing_accounts`. Owner-advanced expenses: PwC 6.9.3.1 constructive treatment via `[books] financing_accounts` (rules.md B-14) | +| 8a | Cash flow by the indirect method; member-paid expenses as constructive receipts | CHANGED 2026-10-20 | The direct method with the reconciliation as a separate schedule (ASC 230-10-45-25, 45-30); noncash investing and financing activities disclosed, only the cash portion reported (50-3, 50-5). Member-paid expenses stay constructive receipts and disbursements, now gross and on each payment's date, and the same for a lender's payments and an expense paid into capital: a choice (D-42, after a day as noncash under D-35), since ASC 230 doesn't address payments made on the entity's behalf and the firm guides present them so (PwC 6.9.3.1, DART 7.2, BDO 3.4, EY 4.6.2; KPMG Q8.3.10 also accepts noncash for property a lender pays for). rules.md B-14 rule 11, P3 defect 1 | | 9 | An asset's life ends at purchase + life months, that day not held; day-count proration | CONVENTION | Exactly N months of service; day proration and the mid-month convention are both accepted book policies (ASC 360-10-35-4, read 2026-10-14: any systematic and rational allocation) | | 10 | A payroll entry has one line per account total | CONFIRMED | The per-employee detail the IRS requires ("Amounts and dates of all wage ... payments", Employment Tax Recordkeeping) is kept in the run; a summary entry is standard | | 11 | A period with exactly the dates of an existing one is allowed | DESIGN | Nothing governs duplicate periods; harmless (the second close locks only). Refusing exact duplicates is an option | diff --git a/docs/verification.md b/docs/verification.md index e29fb2a..f805993 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -83,6 +83,58 @@ Each law, and what proves it. Kani harnesses are in `Reachable.accounting_equation_through` (any number of accounts) 9. **Period statements add up**: a year's P&L is the sum of its months'. - Lean `statements_add_up` +10. **The statement of cash flows foots** (rules.md B-14, plan P3 defect + 1): each entry's cash is applied to the sections moving against it, + operating, investing, financing, each up to its own movement; what a + member or a lender paid or received for the entry, with no cash + account moving (rule 11, D-42), is added as a constructive receipt + and disbursement that nets to zero within the entry; so each entry's + flows sum to its cash and what neither settled sums to zero, and + operating + investing + financing is exactly the change in the cash + accounts, over any entries, for any tagging whose cash tag follows + the account. A reversal's flows, constructive ones included, are its + original's negated (so an entry and its reversal in one period drop + out together), and net income plus the reconciling items (gains and + losses on investing assets, the noncash operating parts, the changes + in operating balances) is the operating section, whenever revenue and + expense lines are only operating or investing. The engine checks the + same identities at runtime and refuses (`CashFlowDoesNotFoot`) rather + than show a statement that doesn't foot. + - Kani `entry_flows_sum_to_cash`, `entry_flows_pure_are_movements`, + `entry_flows_are_bounded`, `entry_flows_of_a_reversal_mirror`, + `no_cash_no_flow` (end to end on entries of up to three lines, each + in any of the four places: four lines ran past half an hour per + harness; at three, the reversal's mirror is the slowest harness in + the suite, about 14 minutes on the development laptop, the others + seconds to 6 minutes), `take_is_odd_and_bounded`, `apply_cash_mirrors` (the + parts, for any amounts), `apply_cash_spends_exactly` (how a + section's cash splits among its kinds, operating's classes + included), `exchanged_is_bounded` (the + exchange inside investing and financing that is disclosed, given + the cash in and out of the two sections), + `constructive_nets_to_zero` and `constructive_mirrors` (what a + member or lender paid or received for the company, either way, for + any amounts: the parts net to zero, settle their own movements + toward zero and no further, leave no source beside a use it could + settle, and mirror under a reversal) + - Lean `net_splits_by_flow`, `applyCash_conserves`, + `applyCash_exhausts`, `applyCash_neg`, `entry_flows_sum`, + `flows_reversal`, `constructive_nets`, `constructive_neg`, + `constructive_sections_net` (the kernel's `constructive` modelled as + it is written: two passes of `applyCash`; its parts by section net + to zero), `stmt_flows_sum`, `stmt_flows_reversal` (an entry's + statement flows, its cash flows plus any constructive triple that + nets to zero, and mirrors for a reversal), and over them + `cashflow_sections_sum_to_cash_change`, + `secNet_cash_is_movement`, `operating_reconciliation_ties`, + `operating_reconciliation_ties_all`, `cashflow_reversal_nets`; + `Reachable.cashflow_foots` and `Reachable.cashflow_is_change_in_cash` + (in every reachable state: the sections over the entries between two + days are the cash accounts' ending balance less their beginning) + - bridge `entry_flows_agree`, `apply_cash_and_exchanged_agree`, + `constructive_agrees`; + properties `prop_cash_flow_laws`, + `prop_pure_entries_sections_are_movements` (5,000 cases each) ### The books over time @@ -94,21 +146,21 @@ Each step carries the checks the engine makes before it mutates. These theorems hold in **every state reachable from empty books, by any sequence of commands, of any length**: -10. **The invariant holds**: chart codes are unique, every posted entry is +11. **The invariant holds**: chart codes are unique, every posted entry is acceptable and on known accounts, and no draft is dated in a closed period. (That last one is why the store can journal a post before applying it: a post can never then fail on a closed period.) - Lean `Step.inv`, `Reachable.inv` -11. **The books always net to zero.** · Lean `Reachable.books_net_zero` -12. **Posted is forever**: posting only appends. After any sequence of +12. **The books always net to zero.** · Lean `Reachable.books_net_zero` +13. **Posted is forever**: posting only appends. After any sequence of commands the old log is a prefix of the new one. Nothing is edited, reordered, or removed. - Lean `Step.extends`, `Steps.extends` -13. **Closed periods are frozen**: once a period is closed, no sequence of +14. **Closed periods are frozen**: once a period is closed, no sequence of commands adds, removes, or changes an entry dated in it, and it stays closed. - Lean `Step.frozen`, `Steps.frozen` -13a. **How the books are kept changes no entry**: in the model, a record +14a. **How the books are kept changes no entry**: in the model, a record (a setting, convention, decision, open item or approval) leaves the chart, every posted entry, every draft and every period exactly as they were, and any number of them keeps a reachable state reachable. The @@ -123,7 +175,7 @@ sequence of commands, of any length**: ### Closing -14. **A closing line zeroes its account**, and **a closing entry balances +15. **A closing line zeroes its account**, and **a closing entry balances and zeroes every revenue and expense account** through the period's last day, whether the net goes to one account or is split among members. @@ -131,14 +183,14 @@ sequence of commands, of any length**: `closing_entry_balances` - Lean `closingEntry_net`, `closingEntry_zeroes`, `closingEntry_accepted`, `close_zeroes` -15. **A close credits equity with exactly the net income**: revenue minus +16. **A close credits equity with exactly the net income**: revenue minus expenses through the period's last day. - Lean `net_closings_is_net_income`, `close_credits_net_income` -16. **An allocation's parts sum to the whole, to the unit**, however the +17. **An allocation's parts sum to the whole, to the unit**, however the other shares were rounded, and only the last share changes. - Kani `plug_last_sums_exactly` · Lean `plugLast_total`, `plugLast_keeps` -17. **A run of member closes telescopes**: each member's total from a run +18. **A run of member closes telescopes**: each member's total from a run of cumulative closes is their allocation of everything the run closed (so its rounding is one allocation's, never a sum of them), and each close hands out exactly its net, for any allocation whose parts sum to @@ -249,7 +301,10 @@ can also prove false claims. So: - **The proofs are not vacuous.** `kani::cover!` statements make Kani show that the interesting cases are reachable: an accepted four-line entry, a reversal on one account with several lines, three distinct - accounts, a balanced three-account chart, a close to two members. `just prove` fails if any cover is unsatisfiable. + accounts, a balanced three-account chart, a close to two members, a + part-cash part-noncash investing entry, cash settling two sections at + once, a mixed entry reversed, property contributed for capital. `just + prove` fails if any cover is unsatisfiable. ## What is and isn't proved @@ -257,7 +312,8 @@ Be precise about the claims: - **Kani is bounded.** It proves the kernel for every input up to 4 lines over 3 accounts and amounts to 2^40 units (the accounting equation end - to end: up to 3 accounts). Lean proves the laws for any + to end: up to 3 accounts; the cash flow entry harnesses: up to 3 lines, + each tagged any of 4 ways). Lean proves the laws for any size, but of a *model*. The model is a direct restatement of the kernel, and the bridge property test ties the `Decimal` instantiation to the `i64` one Kani checked. @@ -276,6 +332,24 @@ Be precise about the claims: structurally), A/R and A/P documents, payroll, depreciation schedules (those have their own property tests), and persistence (the journal store's hash chain is tested in `ledger-store`). +- **The cash flow model tags lines per entry with any function** meeting + the engine's guarantees (the cash tag follows the account; revenue and + expense lines are operating or investing; a reversal takes its + original's tags); which tag the engine picks for a line (rule 4's + disposals, the bill split, the chain root) is checked by the property + and example tests, not proved. A line the engine splits between two + sections is two lines in the model, the entry's net unchanged. +- **Closing entries and the cash balance.** The engine leaves closing + entries out of the sections but not out of the cash accounts' + balances; the two agree because a close moves only revenue, expense + and equity (`Ledger::validate_close_to`) and cash accounts must be + assets (rules.md B-14 rule 2). The Lean model's close step lets a + target be any balance-sheet account, so `Reachable.cashflow_foots` + takes any selection of entries with the cash over the same selection, + and `Reachable.cashflow_is_change_in_cash` counts every entry, closes + included. That a close never moves cash is checked by tests + (`closing_entries_never_appear`, `prop_cash_flow_laws` law 7), not + proved. ### P0 guard rails (2026-10-16) @@ -324,8 +398,15 @@ mise install # once: Kani, Lean, and the rest, pinned in mise.toml just prove ``` -`just prove` runs every Kani harness, builds the Lean proofs (seconds), +`just prove` runs every Kani harness, in parallel (`-j $(nproc)`, under +a 30-minute timeout), builds the Lean proofs (seconds), and fails if any headline theorem depends on anything beyond Lean's standard axioms (`propext`, `Quot.sound`, `Classical.choice`). In -particular, `sorryAx` fails it. Kani downloads its pinned nightly and -CBMC into `~/.kani` on first use. +particular, `sorryAx` fails it. Its time is the slowest harness's: +`entry_flows_of_a_reversal_mirror`, about 14 minutes on the development +laptop (8 cores); the 20 harnesses before P3 take about 17 minutes of +CPU in all (`accounting_equation` the longest, about 4), the cash flow's +other ten about 17 (`constructive_mirrors` about 2.5 minutes, +`constructive_nets_to_zero` about 1.5), so the whole run is about 15 to +20 minutes. Kani +downloads its pinned nightly and CBMC into `~/.kani` on first use. diff --git a/plan/served.md b/plan/served.md index 83fb779..99abbcf 100644 --- a/plan/served.md +++ b/plan/served.md @@ -899,6 +899,7 @@ The first company keeps working between phases. - **Description.** Tool text (S:2202) is corrected. - **Proof.** Lean `cashflow_sections_sum_to_cash_change` in `Statements.lean`, derived from the per-entry balance law. Property test (5,000 cases). Kani on the section-sum kernel function. - **The first company.** FY2026 must foot (it didn't: its figures are in the company's private change log); the golden is recomputed by hand. +- **As built (2026-10-20).** `ledger-core/src/cashflow.rs` (re-exported from `reports`), the kernel's `take`, `apply_cash`, `entry_flows`, `constructive` and `exchanged`. The section rule is refined by ASC 230-10-50-5 ("only the cash portion"): each entry's cash is applied to the sections moving against it, operating, investing, financing, each up to its own movement (CONVENTION: the order), and the rest is disclosed (rules.md B-14 rule 3); the plan's wording holds exactly for entries whose cash settles all they move (`prop_pure_entries_sections_are_movements`). D-35's layout (operating by class, an entry's interest its own class; investing and financing by kind with each kind's total, each transaction under its kind), the bill split and the warnings are built; reversals pair along the chain from its root, and exchanges inside one section are disclosed (the review's two highs). What a member or a lender pays or receives for the company is cash borrowed (or contributed) and spent, on its date, per D-42 (rules.md B-14 rule 11, the kernel's `constructive`, Kani `constructive_nets_to_zero` and `constructive_mirrors`, Lean `constructive_nets`, `constructive_neg`): ASC 230 doesn't address it and the firm guides present it so; an expense a member pays into capital is a constructive contribution (BDO 3.4's parent paying a subsidiary's expenses; the K-1 worksheet already calls it cash); property a member owned, the seller's own credit, and interest or guaranteed payments charged to what the company owes stay noncash. D-35's noncash presentation was replaced the same day. The tool text is at `ledger-mcp/src/server.rs` (`books.cash_flow`). The first company's change log is kept with its goldens, outside this repository (5.6). **Defect 7, IIF** (`ledger-import/src/iif.rs:169,243`): DOCNUM = entry_id. Output encoded in Windows-1252; characters outside it are transliterated (deunicode) and listed in `warnings`. diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index 5fc961c..20bbd5c 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -49,3 +49,21 @@ open Wanderbooks #print axioms Apportion.allocW_cumulative_total #print axioms Step.record_changes_no_figure #print axioms Reachable.records +#print axioms net_splits_by_flow +#print axioms applyCash_conserves +#print axioms applyCash_exhausts +#print axioms applyCash_neg +#print axioms entry_flows_sum +#print axioms flows_reversal +#print axioms constructive_nets +#print axioms constructive_neg +#print axioms constructive_sections_net +#print axioms stmt_flows_sum +#print axioms stmt_flows_reversal +#print axioms cashflow_sections_sum_to_cash_change +#print axioms secNet_cash_is_movement +#print axioms operating_reconciliation_ties +#print axioms operating_reconciliation_ties_all +#print axioms cashflow_reversal_nets +#print axioms Reachable.cashflow_foots +#print axioms Reachable.cashflow_is_change_in_cash diff --git a/verify/lean/Wanderbooks/Ledger.lean b/verify/lean/Wanderbooks/Ledger.lean index 03972bc..754dbda 100644 --- a/verify/lean/Wanderbooks/Ledger.lean +++ b/verify/lean/Wanderbooks/Ledger.lean @@ -457,6 +457,80 @@ theorem statements_add_up (t : AccountType) (b c : Nat) (h : b ≤ c) (es : List + typeTotal t (allLines (between b c es)) chart := typeTotal_split t _ _ _ (fun a => movement_split a b c h es) chart +/-! ## The statement of cash flows foots (rules.md B-14 rule 9) -/ + +/-- Some entries' cash, summed, is the movement of the cash accounts over +their lines, when the cash tag follows the account. -/ +theorem cashTotal_is_movement (F : List Line → Line → Flow) (cash : List Nat) + (hd : Distinct cash) (hF : ∀ e l, F e l = .cash ↔ l.account ∈ cash) : + ∀ xs : List Entry, + cashTotal F (xs.map Entry.lines) = sumOver (fun a => movement a (allLines xs)) cash := by + intro xs + induction xs with + | nil => simpa [cashTotal, allLines] using (sumOver_zero cash).symm + | cons x xs ih => + simp only [List.map_cons, cashTotal, allLines, movement_append] + rw [sumOver_add, ← ih, cashOf, secNet_cash_is_movement (F x.lines) cash hd (hF x.lines)] + +/-- **The statement of cash flows foots**, in every reachable state: over +the posted entries dated after day `b` through day `c` (any selection of +them, `p`: the engine leaves out closing entries), operating + investing + +financing is the movement of the cash accounts over the same entries, for +any tagging whose cash tag follows the account, and whatever a member or +lender handled for each entry (`C`, rule 11) as long as it nets to zero +(`constructive_nets`). Every posted entry is accepted (`Reachable.inv`), +so each nets to zero. -/ +theorem Reachable.cashflow_foots {L : Ledger} (r : Reachable L) (b c : Nat) (p : Entry → Bool) + (F : List Line → Line → Flow) (C : List Line → List Int) (hC : ∀ e, sum3 (C e) = 0) + (cash : List Nat) (hd : Distinct cash) + (hF : ∀ e l, F e l = .cash ↔ l.account ∈ cash) : + let xs := (between b c L.posted).filter p + sectionTotal F C 0 (xs.map Entry.lines) + sectionTotal F C 1 (xs.map Entry.lines) + + sectionTotal F C 2 (xs.map Entry.lines) + = sumOver (fun a => movement a (allLines xs)) cash := by + intro xs + rw [cashflow_sections_sum_to_cash_change, cashTotal_is_movement F cash hd hF] + · intro e he + obtain ⟨x, hx, rfl⟩ := List.mem_map.1 he + have hb := (List.mem_filter.1 hx).1 + exact (r.inv.accepted x (List.mem_filter.1 hb).1).net_zero + · exact fun e _ => hC e + +/-- **The sections are the change in the cash accounts' balances**: with +every entry between `b` and `c` counted, the three sections sum to the +cash accounts' movement through `c` less their movement through `b` +(their ending balance less their beginning balance). -/ +theorem Reachable.cashflow_is_change_in_cash {L : Ledger} (r : Reachable L) (b c : Nat) + (hbc : b ≤ c) (F : List Line → Line → Flow) (C : List Line → List Int) + (hC : ∀ e, sum3 (C e) = 0) (cash : List Nat) (hd : Distinct cash) + (hF : ∀ e l, F e l = .cash ↔ l.account ∈ cash) : + let es := (between b c L.posted).map Entry.lines + sectionTotal F C 0 es + sectionTotal F C 1 es + sectionTotal F C 2 es + = sumOver (fun a => movement a (allLines (through c L.posted))) cash + - sumOver (fun a => movement a (allLines (through b L.posted))) cash := by + intro es + have h := r.cashflow_foots b c (fun _ => true) F C hC cash hd hF + have hall : (between b c L.posted).filter (fun _ => true) = between b c L.posted := + List.filter_eq_self.2 (fun _ _ => rfl) + simp only [hall] at h + rw [h] + have hs : ∀ a, movement a (allLines (through c L.posted)) + = movement a (allLines (through b L.posted)) + movement a (allLines (between b c L.posted)) := + fun a => movement_split a b c hbc L.posted + rw [sumOver_congr _ _ cash (fun a _ => hs a), sumOver_add] + omega + +/-- **A reversal and its original in one period drop out together**: for +an entry and its reversal (lines flipped, tagged as the original's, and +what was handled for it mirrored, `constructive_neg`), the sum of their +flows in each section is zero. -/ +theorem cashflow_reversal_nets (F : List Line → Line → Flow) (C : List Line → List Int) + (e : List Line) (hF : ∀ l ∈ e, F (reversal e) l.reverse = F e l) + (hC : C (reversal e) = (C e).map (fun x => -x)) (k : Nat) : + sectionTotal F C k [e, reversal e] = 0 := by + simp only [sectionTotal, stmt_flows_reversal F C e hF hC, getD_neg] + omega + /-! ## How the books are kept changes no figure -/ /-- **A record changes nothing the books hold**: a settings change, a diff --git a/verify/lean/Wanderbooks/Statements.lean b/verify/lean/Wanderbooks/Statements.lean index bd50e4c..b2f5ae3 100644 --- a/verify/lean/Wanderbooks/Statements.lean +++ b/verify/lean/Wanderbooks/Statements.lean @@ -4,7 +4,10 @@ import Wanderbooks.Entry # Balances and statements Mirrors `kernel::normal_balance`, `kernel::columns`, -`Ledger::account_balance`, and `reports::trial_balance`. +`Ledger::account_balance`, and `reports::trial_balance`; and the statement +of cash flows (`kernel::take`, `kernel::apply_cash`, `kernel::entry_flows`, +`kernel::constructive`, `cashflow::cash_flow`'s footing and reconciliation +checks). -/ namespace Wanderbooks @@ -101,4 +104,580 @@ theorem trial_balance_iff (ls : List Line) (as : List Nat) (hd : Distinct as) rw [sum_movements as hd ls hcov] at this omega +/-! ## The statement of cash flows (rules.md B-14) + +Mirrors `kernel::Flow`, `kernel::take`, `kernel::apply_cash` and +`kernel::entry_flows`, and the identities `cashflow::cash_flow` checks at +runtime: the sections sum to the cash accounts' change, a reversal's flows +are its original's negated, and net income reconciles to operating cash. + +Lines are tagged per entry (`F : List Line → Line → Flow`), as the engine +tags them: a revenue or expense line is operating in one entry and +investing in another (rule 4), and a reversal is tagged by its chain's +root. The theorems hold for every such tagging; their hypotheses say what +the engine guarantees (the cash tag follows the account; revenue and +expense lines are operating or investing; a reversal's lines take its +original's tags). A line the engine splits between two sections (the bill +split, rule 12) is two lines here; a split keeps the entry's net. -/ + +/-- Where a line falls on the statement of cash flows (`kernel::Flow`). -/ +inductive Flow where + | cash + | operating + | investing + | financing + deriving DecidableEq, Repr + +/-- The net movement of the lines `f` tags `k`. -/ +def secNet (f : Line → Flow) (k : Flow) : List Line → Int + | [] => 0 + | l :: ls => (if f l = k then l.signed else 0) + secNet f k ls + +@[simp] theorem secNet_nil (f : Line → Flow) (k : Flow) : secNet f k [] = 0 := rfl +@[simp] theorem secNet_cons (f : Line → Flow) (k : Flow) (l : Line) (ls : List Line) : + secNet f k (l :: ls) = (if f l = k then l.signed else 0) + secNet f k ls := rfl + +/-- **Every line is in exactly one place**: an entry's net is its cash plus +its three sections' movements. -/ +theorem net_splits_by_flow (f : Line → Flow) (ls : List Line) : + net ls = secNet f .cash ls + secNet f .operating ls + secNet f .investing ls + + secNet f .financing ls := by + induction ls with + | nil => rfl + | cons l ls ih => + simp only [net_cons, secNet_cons, ih] + cases f l <;> simp <;> omega + +/-- The cash one section takes from what is left (`kernel::take`): only a +section moving against the cash, never more than its own movement. -/ +def take (rem n : Int) : Int := + if 0 < rem ∧ n < 0 then min rem (-n) else if rem < 0 ∧ 0 < n then max rem (-n) else 0 + +/-- `kernel::apply_cash`: the cash applied to each movement in order, and +what is left. -/ +def applyCash : Int → List Int → List Int × Int + | rem, [] => ([], rem) + | rem, n :: ns => (take rem n :: (applyCash (rem - take rem n) ns).1, + (applyCash (rem - take rem n) ns).2) + +/-- A sum of integers. -/ +def isum : List Int → Int + | [] => 0 + | x :: xs => x + isum xs + +@[simp] theorem isum_nil : isum [] = 0 := rfl +@[simp] theorem isum_cons (x : Int) (xs : List Int) : isum (x :: xs) = x + isum xs := rfl + +theorem isum_append (xs ys : List Int) : isum (xs ++ ys) = isum xs + isum ys := by + induction xs with + | nil => simp + | cons x xs ih => simp only [List.cons_append, isum_cons, ih]; omega + +theorem isum_neg (xs : List Int) : isum (xs.map (fun x => -x)) = -isum xs := by + induction xs with + | nil => rfl + | cons x xs ih => simp only [List.map_cons, isum_cons, ih]; omega + +/-- Applying cash invents and loses nothing: the parts and what is left +sum to the cash. -/ +theorem applyCash_conserves (rem : Int) (ns : List Int) : + isum (applyCash rem ns).1 + (applyCash rem ns).2 = rem := by + induction ns generalizing rem with + | nil => simp [applyCash] + | cons n ns ih => + simp only [applyCash, isum_cons] + have := ih (rem - take rem n) + omega + +theorem applyCash_length (rem : Int) (ns : List Int) : + (applyCash rem ns).1.length = ns.length := by + induction ns generalizing rem with + | nil => rfl + | cons n ns ih => simp [applyCash, ih] + +theorem take_zero (n : Int) : take 0 n = 0 := by + simp [take] + +/-- With no cash, nothing is applied and nothing is left. -/ +theorem applyCash_zero (ns : List Int) : (applyCash 0 ns).2 = 0 := by + induction ns with + | nil => rfl + | cons n ns ih => simp [applyCash, take_zero, ih] + +/-- Cash in is used up when the movements against it cover it. -/ +theorem applyCash_exhausts_pos (rem : Int) (ns : List Int) (h0 : 0 ≤ rem) + (h : rem + isum ns ≤ 0) : (applyCash rem ns).2 = 0 := by + induction ns generalizing rem with + | nil => simp [applyCash] at h ⊢; omega + | cons n ns ih => + simp only [applyCash] + simp only [isum_cons] at h + by_cases hr : rem = 0 + · subst hr; simp [take_zero, applyCash_zero] + · by_cases hn : n < 0 + · by_cases hle : -n ≤ rem + · have ht : take rem n = -n := by + simp only [take]; rw [ite_eq_left (by omega)]; omega + rw [ht]; exact ih _ (by omega) (by omega) + · have ht : take rem n = rem := by + simp only [take]; rw [ite_eq_left (by omega)]; omega + rw [ht, Int.sub_self]; exact applyCash_zero ns + · have ht : take rem n = 0 := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] + rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) + +/-- Cash out is used up when the movements against it cover it. -/ +theorem applyCash_exhausts_neg (rem : Int) (ns : List Int) (h0 : rem ≤ 0) + (h : 0 ≤ rem + isum ns) : (applyCash rem ns).2 = 0 := by + induction ns generalizing rem with + | nil => simp [applyCash] at h ⊢; omega + | cons n ns ih => + simp only [applyCash] + simp only [isum_cons] at h + by_cases hr : rem = 0 + · subst hr; simp [take_zero, applyCash_zero] + · by_cases hn : 0 < n + · by_cases hle : rem ≤ -n + · have ht : take rem n = -n := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega + rw [ht]; exact ih _ (by omega) (by omega) + · have ht : take rem n = rem := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega + rw [ht, Int.sub_self]; exact applyCash_zero ns + · have ht : take rem n = 0 := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] + rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) + +/-- **Balanced, the cash is used up**: when the cash and the movements sum +to zero, nothing is left over. -/ +theorem applyCash_exhausts (rem : Int) (ns : List Int) (h : rem + isum ns = 0) : + (applyCash rem ns).2 = 0 := by + rcases Int.le_total 0 rem with h0 | h0 + · exact applyCash_exhausts_pos rem ns h0 (by omega) + · exact applyCash_exhausts_neg rem ns h0 (by omega) + +theorem take_neg (r n : Int) : take (-r) (-n) = -take r n := by + simp only [take] + by_cases h1 : 0 < r ∧ n < 0 + · rw [ite_eq_right (by omega), ite_eq_left (by omega), ite_eq_left h1]; omega + · by_cases h2 : r < 0 ∧ 0 < n + · rw [ite_eq_left (by omega), ite_eq_right h1, ite_eq_left h2]; omega + · rw [ite_eq_right (by omega), ite_eq_right (by omega), ite_eq_right h1, ite_eq_right h2]; omega + +/-- Applying minus the cash to minus the movements gives minus the parts: +what lets a reversal mirror its original. -/ +theorem applyCash_neg (rem : Int) (ns : List Int) : + applyCash (-rem) (ns.map (fun x => -x)) + = ((applyCash rem ns).1.map (fun x => -x), -(applyCash rem ns).2) := by + induction ns generalizing rem with + | nil => rfl + | cons n ns ih => + simp only [List.map_cons, applyCash, take_neg] + have : -rem - -take rem n = -(rem - take rem n) := by omega + rw [this, ih] + +/-- An entry's cash: the net of the lines tagged cash. -/ +def cashOf (F : List Line → Line → Flow) (e : List Line) : Int := secNet (F e) .cash e + +/-- An entry's section movements, `[operating, investing, financing]`. -/ +def moves (F : List Line → Line → Flow) (e : List Line) : List Int := + [secNet (F e) .operating e, secNet (F e) .investing e, secNet (F e) .financing e] + +/-- An entry's cash flows per section (`kernel::entry_flows`). -/ +def flows (F : List Line → Line → Flow) (e : List Line) : List Int := + (applyCash (cashOf F e) (moves F e)).1 + +/-- **An entry's cash flows sum to its cash** (`entry_flows_sum`): for a +balanced entry, the sections' cash is exactly the cash that moved. -/ +theorem entry_flows_sum (F : List Line → Line → Flow) (e : List Line) (h : net e = 0) : + isum (flows F e) = cashOf F e := by + have hb := net_splits_by_flow (F e) e + have hx : (applyCash (cashOf F e) (moves F e)).2 = 0 := by + apply applyCash_exhausts + simp only [moves, cashOf, isum_cons, isum_nil] + omega + have := applyCash_conserves (cashOf F e) (moves F e) + simp only [flows] + omega + +theorem flows_length (F : List Line → Line → Flow) (e : List Line) : (flows F e).length = 3 := by + simp [flows, applyCash_length, moves] + +theorem secNet_reversal (f g : Line → Flow) (k : Flow) (ls : List Line) + (h : ∀ l ∈ ls, g l.reverse = f l) : secNet g k (reversal ls) = -secNet f k ls := by + induction ls with + | nil => rfl + | cons l ls ih => + simp only [reversal, List.map_cons] at ih ⊢ + simp only [secNet_cons, h l (by simp), Line.reverse_signed, + ih (fun m hm => h m (by simp [hm]))] + split <;> omega + +/-- **A reversal's flows are its original's, negated** (`flows_reversal`), +when its lines take its original's tags: what lets an entry and its +reversal drop out of the statement together (rules.md B-14 rule 6). -/ +theorem flows_reversal (F : List Line → Line → Flow) (e : List Line) + (hF : ∀ l ∈ e, F (reversal e) l.reverse = F e l) : + flows F (reversal e) = (flows F e).map (fun x => -x) := by + simp only [flows, cashOf, moves] + rw [secNet_reversal (F e) (F (reversal e)) .cash e hF, + secNet_reversal (F e) (F (reversal e)) .operating e hF, + secNet_reversal (F e) (F (reversal e)) .investing e hF, + secNet_reversal (F e) (F (reversal e)) .financing e hF] + have := applyCash_neg (secNet (F e) .cash e) + [secNet (F e) .operating e, secNet (F e) .investing e, secNet (F e) .financing e] + simp only [List.map_cons, List.map_nil] at this + rw [this] + +theorem isum_three (xs : List Int) (h : xs.length = 3) : + isum xs = xs.getD 0 0 + xs.getD 1 0 + xs.getD 2 0 := by + match xs, h with + | [a, b, c], _ => simp; omega + +/-! ### What a member or lender pays or receives for the company (rule 11) + +`kernel::constructive` (D-42): what is left unsettled of an entry's +kinds that can pay or receive for the company (`s`, a financing liability +or a member's capital) settles what is left of the kinds they pay or +receive for (`u`), as if through the cash accounts. The engine sums the +parts by section into an entry's constructive triple; the entry theorems +below take any triple that nets to zero (and, for reversals, mirrors), +which `constructive_nets` and `constructive_neg` give the kernel's. -/ + +/-- The credits among some movements, `Σ max(-m, 0)`: what cash in can +settle. -/ +def creditTotal : List Int → Int + | [] => 0 + | m :: ms => max (-m) 0 + creditTotal ms + +/-- The debits among some movements, `Σ max(m, 0)`: what cash out can +settle. -/ +def debitTotal : List Int → Int + | [] => 0 + | m :: ms => max m 0 + debitTotal ms + +theorem creditTotal_nonneg (ms : List Int) : 0 ≤ creditTotal ms := by + induction ms with + | nil => simp [creditTotal] + | cons m ms ih => simp only [creditTotal]; omega + +theorem debitTotal_nonneg (ms : List Int) : 0 ≤ debitTotal ms := by + induction ms with + | nil => simp [debitTotal] + | cons m ms ih => simp only [debitTotal]; omega + +theorem creditTotal_neg (ms : List Int) : + creditTotal (ms.map (fun x => -x)) = debitTotal ms := by + induction ms with + | nil => rfl + | cons m ms ih => simp only [List.map_cons, creditTotal, debitTotal, ih]; omega + +theorem debitTotal_neg (ms : List Int) : + debitTotal (ms.map (fun x => -x)) = creditTotal ms := by + induction ms with + | nil => rfl + | cons m ms ih => + simp only [List.map_cons, creditTotal, debitTotal, ih] + +/-- Cash in is used up when the credits cover it (whatever the debits). -/ +theorem applyCash_exhausts_in (rem : Int) (ns : List Int) (h0 : 0 ≤ rem) + (h : rem ≤ creditTotal ns) : (applyCash rem ns).2 = 0 := by + induction ns generalizing rem with + | nil => simp [applyCash, creditTotal] at h ⊢; omega + | cons n ns ih => + simp only [applyCash] + simp only [creditTotal] at h + by_cases hr : rem = 0 + · subst hr; simp [take_zero, applyCash_zero] + · by_cases hn : n < 0 + · by_cases hle : -n ≤ rem + · have ht : take rem n = -n := by + simp only [take]; rw [ite_eq_left (by omega)]; omega + rw [ht]; exact ih _ (by omega) (by omega) + · have ht : take rem n = rem := by + simp only [take]; rw [ite_eq_left (by omega)]; omega + rw [ht, Int.sub_self]; exact applyCash_zero ns + · have ht : take rem n = 0 := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] + rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) + +/-- Cash out is used up when the debits cover it (whatever the credits). -/ +theorem applyCash_exhausts_out (rem : Int) (ns : List Int) (h0 : rem ≤ 0) + (h : -rem ≤ debitTotal ns) : (applyCash rem ns).2 = 0 := by + induction ns generalizing rem with + | nil => simp [applyCash, debitTotal] at h ⊢; omega + | cons n ns ih => + simp only [applyCash] + simp only [debitTotal] at h + by_cases hr : rem = 0 + · subst hr; simp [take_zero, applyCash_zero] + · by_cases hn : 0 < n + · by_cases hle : rem ≤ -n + · have ht : take rem n = -n := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega + rw [ht]; exact ih _ (by omega) (by omega) + · have ht : take rem n = rem := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega + rw [ht, Int.sub_self]; exact applyCash_zero ns + · have ht : take rem n = 0 := by + simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] + rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) + +/-- Which way the money went (`kernel::OnBehalf`). -/ +inductive OnBehalf where + | paid + | received + deriving DecidableEq, Repr + +/-- The other way: what a reversal does. -/ +def OnBehalf.mirror : OnBehalf → OnBehalf + | .paid => .received + | .received => .paid + +/-- The cash the sources take, + in (`kernel::constructive`): paid, the +smaller of the sources' credits and the uses' debits; received, minus the +smaller of the sources' debits and the uses' credits. -/ +def onBehalfCash : OnBehalf → List Int → List Int → Int + | .paid, s, u => min (creditTotal s) (debitTotal u) + | .received, s, u => -min (debitTotal s) (creditTotal u) + +/-- `kernel::constructive`: the sources' parts and the uses' parts. -/ +def constructive (w : OnBehalf) (s u : List Int) : List Int × List Int := + ((applyCash (onBehalfCash w s u) s).1, (applyCash (-onBehalfCash w s u) u).1) + +/-- **What a member or lender handled for the company nets to zero** +(`constructive_nets`; Kani `constructive_nets_to_zero`): the sources take +exactly what the uses give, so an entry's flows still sum to its cash. -/ +theorem constructive_nets (w : OnBehalf) (s u : List Int) : + isum (constructive w s u).1 + isum (constructive w s u).2 = 0 := by + have hs := applyCash_conserves (onBehalfCash w s u) s + have hu := applyCash_conserves (-onBehalfCash w s u) u + have cs := creditTotal_nonneg s + have ds := debitTotal_nonneg s + have cu := creditTotal_nonneg u + have du := debitTotal_nonneg u + have ⟨xs, xu⟩ : (applyCash (onBehalfCash w s u) s).2 = 0 ∧ + (applyCash (-onBehalfCash w s u) u).2 = 0 := by + cases w with + | paid => + simp only [onBehalfCash] + exact ⟨applyCash_exhausts_in _ _ (by omega) (by omega), + applyCash_exhausts_out _ _ (by omega) (by omega)⟩ + | received => + simp only [onBehalfCash] + exact ⟨applyCash_exhausts_out _ _ (by omega) (by omega), + applyCash_exhausts_in _ _ (by omega) (by omega)⟩ + simp only [constructive] + omega + +theorem onBehalfCash_mirror (w : OnBehalf) (s u : List Int) : + onBehalfCash w.mirror (s.map (fun x => -x)) (u.map (fun x => -x)) + = -onBehalfCash w s u := by + cases w <;> simp only [OnBehalf.mirror, onBehalfCash, creditTotal_neg, debitTotal_neg] <;> omega + +/-- **The other way, minus the sources and uses give minus the parts** +(`constructive_neg`; Kani `constructive_mirrors`): a payment made for the +company and its reversal drop out together. -/ +theorem constructive_neg (w : OnBehalf) (s u : List Int) : + constructive w.mirror (s.map (fun x => -x)) (u.map (fun x => -x)) + = ((constructive w s u).1.map (fun x => -x), (constructive w s u).2.map (fun x => -x)) := by + simp only [constructive, onBehalfCash_mirror] + rw [applyCash_neg (onBehalfCash w s u) s, applyCash_neg (-onBehalfCash w s u) u] + +/-- Three sections' figures, summed. -/ +def sum3 (xs : List Int) : Int := xs.getD 0 0 + xs.getD 1 0 + xs.getD 2 0 + +@[simp] theorem sum3_cons (a b c : Int) : sum3 [a, b, c] = a + b + c := by + simp [sum3] + +/-- **The kernel's parts, by section, net to zero**: split the uses into +operating ones then investing ones (`kernel::constructive` takes them in +that order), and the triple (operating, investing, financing = the +sources) the engine adds to an entry's flows sums to zero. -/ +theorem constructive_sections_net (w : OnBehalf) (s uo ui : List Int) : + sum3 [isum ((constructive w s (uo ++ ui)).2.take uo.length), + isum ((constructive w s (uo ++ ui)).2.drop uo.length), + isum (constructive w s (uo ++ ui)).1] = 0 := by + have h := constructive_nets w s (uo ++ ui) + have hs := isum_append ((constructive w s (uo ++ ui)).2.take uo.length) + ((constructive w s (uo ++ ui)).2.drop uo.length) + rw [List.take_append_drop] at hs + rw [sum3_cons] + omega + +/-- Passes add: two triples that net to zero add to one that does. -/ +theorem sum3_add (xs ys : List Int) (hx : sum3 xs = 0) (hy : sum3 ys = 0) : + sum3 [xs.getD 0 0 + ys.getD 0 0, xs.getD 1 0 + ys.getD 1 0, xs.getD 2 0 + ys.getD 2 0] = 0 := by + rw [sum3_cons] + simp only [sum3] at hx hy + omega + +/-- Two triples, added section by section. -/ +def add3 (xs ys : List Int) : List Int := + [xs.getD 0 0 + ys.getD 0 0, xs.getD 1 0 + ys.getD 1 0, xs.getD 2 0 + ys.getD 2 0] + +/-- An entry's flows on the statement: its cash flows, plus `C e`, what a +member or lender paid or received for it, by section (rule 11). -/ +def stmtFlows (F : List Line → Line → Flow) (C : List Line → List Int) (e : List Line) : + List Int := + add3 (flows F e) (C e) + +/-- **An entry's statement flows sum to its cash** when what was handled +for it nets to zero: rule 11 moves no cash (`stmt_flows_sum`). -/ +theorem stmt_flows_sum (F : List Line → Line → Flow) (C : List Line → List Int) + (e : List Line) (h : net e = 0) (hC : sum3 (C e) = 0) : + sum3 (stmtFlows F C e) = cashOf F e := by + have he := entry_flows_sum F e h + rw [isum_three _ (flows_length F e)] at he + simp only [stmtFlows, add3, sum3_cons] + simp only [sum3] at hC + omega + +theorem getD_neg (xs : List Int) (k : Nat) : + (xs.map (fun x => -x)).getD k 0 = -xs.getD k 0 := by + induction xs generalizing k with + | nil => simp + | cons x xs ih => cases k with + | zero => simp + | succ k => simp only [List.map_cons, List.getD_cons_succ, ih] + +/-- **A reversal's statement flows are its original's, negated**, when its +lines take the original's tags and what was handled for it mirrors +(`constructive_neg`). -/ +theorem stmt_flows_reversal (F : List Line → Line → Flow) (C : List Line → List Int) + (e : List Line) (hF : ∀ l ∈ e, F (reversal e) l.reverse = F e l) + (hC : C (reversal e) = (C e).map (fun x => -x)) : + stmtFlows F C (reversal e) = (stmtFlows F C e).map (fun x => -x) := by + simp only [stmtFlows, add3, flows_reversal F e hF, hC, getD_neg, List.map_cons, + List.map_nil, List.cons.injEq, and_true] + refine ⟨?_, ?_, ?_⟩ <;> omega + +/-- A section's total over some entries: the sum of each entry's cash in +it (`k` = 0 operating, 1 investing, 2 financing), what a member or lender +handled for it included. -/ +def sectionTotal (F : List Line → Line → Flow) (C : List Line → List Int) (k : Nat) : + List (List Line) → Int + | [] => 0 + | e :: es => (stmtFlows F C e).getD k 0 + sectionTotal F C k es + +/-- The entries' cash, summed. -/ +def cashTotal (F : List Line → Line → Flow) : List (List Line) → Int + | [] => 0 + | e :: es => cashOf F e + cashTotal F es + +/-- **The sections sum to the change in cash** (plan P3): over any balanced +entries, operating + investing + financing is the entries' cash, exactly, +whatever a member or lender handled for them as long as it nets to zero +within each entry (rule 11, `constructive_nets`). The statement's footing +(rules.md B-14 rule 9) can't fail on posted entries. -/ +theorem cashflow_sections_sum_to_cash_change (F : List Line → Line → Flow) + (C : List Line → List Int) (es : List (List Line)) (h : ∀ e ∈ es, net e = 0) + (hC : ∀ e ∈ es, sum3 (C e) = 0) : + sectionTotal F C 0 es + sectionTotal F C 1 es + sectionTotal F C 2 es = cashTotal F es := by + induction es with + | nil => rfl + | cons e es ih => + simp only [sectionTotal, cashTotal] + have he := stmt_flows_sum F C e (h e (by simp)) (hC e (by simp)) + simp only [sum3] at he + have := ih (fun x hx => h x (by simp [hx])) (fun x hx => hC x (by simp [hx])) + omega + +/-- When the cash tag follows the account (a line is cash exactly when +its account is a cash account), an entry's cash is the movement of the +cash accounts. -/ +theorem secNet_cash_is_movement (f : Line → Flow) (cash : List Nat) (hd : Distinct cash) + (hf : ∀ l, f l = .cash ↔ l.account ∈ cash) (ls : List Line) : + secNet f .cash ls = sumOver (fun a => movement a ls) cash := by + induction ls with + | nil => simpa using (sumOver_zero cash).symm + | cons l ls ih => + simp only [secNet_cons, movement_cons] + rw [sumOver_add, ← ih] + by_cases hc : l.account ∈ cash + · rw [ite_eq_left ((hf l).2 hc), sumOver_indicator l.account l.signed cash hd hc] + · rw [ite_eq_right (fun e => hc ((hf l).1 e)), sumOver_indicator_absent l.account l.signed cash hc] + +/-! ### Net income reconciled to operating cash (rules.md B-14 rule 8) -/ + +/-- Net movement of the lines on revenue and expense accounts (`pl`) +tagged `k` (`sel = true`), or on balance-sheet accounts (`sel = false`). -/ +def plNet (pl : Nat → Bool) (sel : Bool) (f : Line → Flow) (k : Flow) : List Line → Int + | [] => 0 + | l :: ls => (if pl l.account = sel ∧ f l = k then l.signed else 0) + plNet pl sel f k ls + +/-- Net movement of every revenue and expense line. -/ +def plAll (pl : Nat → Bool) : List Line → Int + | [] => 0 + | l :: ls => (if pl l.account = true then l.signed else 0) + plAll pl ls + +/-- Net income: revenue less expenses, `-(net of the P&L lines)`. -/ +def netIncome (pl : Nat → Bool) (e : List Line) : Int := -plAll pl e + +/-- G: the P&L lines rule 4 puts in investing (gains and losses). -/ +def gainLoss (pl : Nat → Bool) (F : List Line → Line → Flow) (e : List Line) : Int := + plNet pl true (F e) .investing e + +/-- W: minus the movement of the operating balance-sheet lines. -/ +def balanceChange (pl : Nat → Bool) (F : List Line → Line → Flow) (e : List Line) : Int := + -plNet pl false (F e) .operating e + +/-- X: what no cash, real or handled for the company, settled of the +operating movement. -/ +def noncashOp (F : List Line → Line → Flow) (C : List Line → List Int) (e : List Line) : Int := + secNet (F e) .operating e + (stmtFlows F C e).getD 0 0 + +theorem secNet_split (pl : Nat → Bool) (f : Line → Flow) (k : Flow) (ls : List Line) : + secNet f k ls = plNet pl true f k ls + plNet pl false f k ls := by + induction ls with + | nil => rfl + | cons l ls ih => + simp only [secNet_cons, plNet, ih] + cases pl l.account <;> by_cases h : f l = k <;> simp [h] <;> omega + +theorem plAll_split (pl : Nat → Bool) (f : Line → Flow) (ls : List Line) + (h : ∀ l ∈ ls, pl l.account = true → f l = .operating ∨ f l = .investing) : + plAll pl ls = plNet pl true f .operating ls + plNet pl true f .investing ls := by + induction ls with + | nil => rfl + | cons l ls ih => + simp only [plAll, plNet, ih (fun m hm => h m (by simp [hm]))] + cases hp : pl l.account + · simp + · rcases h l (by simp) hp with hk | hk <;> simp [hk] <;> omega + +/-- **Net income reconciles to operating cash**, entry by entry: +operating cash = NI + G + X + W, when revenue and expense lines are only +ever operating or investing (rules.md B-14 rule 8; ASC 230-10-45-28 to +45-30), whatever a member or lender handled for it (rule 11). -/ +theorem operating_reconciliation_ties (pl : Nat → Bool) (F : List Line → Line → Flow) + (C : List Line → List Int) (e : List Line) + (h : ∀ l ∈ e, pl l.account = true → F e l = .operating ∨ F e l = .investing) : + (stmtFlows F C e).getD 0 0 + = netIncome pl e + gainLoss pl F e + noncashOp F C e + balanceChange pl F e := by + have hs := secNet_split pl (F e) .operating e + have hp := plAll_split pl (F e) e h + simp only [netIncome, gainLoss, noncashOp, balanceChange] + omega + +/-- The operating section's total over some entries. -/ +def opTotal (pl : Nat → Bool) (F : List Line → Line → Flow) (C : List Line → List Int) : + List (List Line) → Int + | [] => 0 + | e :: es => netIncome pl e + gainLoss pl F e + noncashOp F C e + balanceChange pl F e + + opTotal pl F C es + +/-- **The reconciliation schedule ties to the operating section** over any +entries: the section's total is NI + G + X + W summed. -/ +theorem operating_reconciliation_ties_all (pl : Nat → Bool) (F : List Line → Line → Flow) + (C : List Line → List Int) (es : List (List Line)) + (h : ∀ e ∈ es, ∀ l ∈ e, pl l.account = true → F e l = .operating ∨ F e l = .investing) : + sectionTotal F C 0 es = opTotal pl F C es := by + induction es with + | nil => rfl + | cons e es ih => + simp only [sectionTotal, opTotal] + rw [operating_reconciliation_ties pl F C e (h e (by simp)), + ih (fun x hx => h x (by simp [hx]))] + end Wanderbooks