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

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

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

No files matched your search

+3 -2
View File
@@ -9,8 +9,9 @@ through MCP. Self-hosted, no rent.
- **`ledger-core`** — the semantics engine. Exact decimal money, five - **`ledger-core`** — the semantics engine. Exact decimal money, five
account types, balanced journal entries, immutable posted log with account types, balanced journal entries, immutable posted log with
reversing entries, trial balance, P&L, balance sheet, indirect-method reversing entries, trial balance, P&L, balance sheet, the statement of
cash flow, fixed-asset register with straight-line depreciation. 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 - **`ledger-store`** — durable storage. The source of truth is an
append-only, hash-chained JSONL journal; SQLite is a disposable append-only, hash-chained JSONL journal; SQLite is a disposable
projection rebuilt from it; receipts live in a content-addressed blob projection rebuilt from it; receipts live in a content-addressed blob
+181
View File
@@ -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, `"<code>.debit"` and `"<code>.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): `"<member>.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 |
| `<section>.<N>` | entry N's cash in that section (`financing.4`) |
| `<section>.<N>.on_behalf` | of it, what a member or a lender paid or received for the company, no cash account moving (constructive receipt and disbursement) |
| `<section>.count` | how many entries the section lists |
| `noncash.<N>` | 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.<code>` | operating movements no cash settled, through that account |
| `reconciliation.other_noncash` | other operating movements no cash settled |
| `reconciliation.balance_change.<code>` | minus the change in an operating asset or liability |
An entry named in a `<section>.<N>` (or `.on_behalf`) or `noncash.<N>`
key must be the only such entries when `<section>.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).
@@ -6,13 +6,13 @@
# rules.md B-19, B-33; regression test `the_1099_worksheet_stops_at_as_of` # rules.md B-19, B-33; regression test `the_1099_worksheet_stops_at_as_of`
# (ledger-package tests/package.rs). # (ledger-package tests/package.rs).
# #
# Setup beyond the accounts: `cash_accounts` are the accounts payments # Setup beyond the accounts (bookspec/README.md): `cash_accounts` are the
# are made from; each `[[setup.payees]]` is a payee marked for a form; # accounts payments are made from; each `[[setup.payees]]` is a payee
# an event's `payee` tags the payment to them. Expected values: # marked for a form; an event's `payee` tags the payment to them. Event N
# vendor_1099_paid gives each marked payee's payments counted from `from` # is entry N. Expected values: vendor_1099_paid gives each marked payee's
# through `to` (both in one calendar year). The 2026 threshold is $2,000 # payments counted from `from`, January 1, through `to`, every payee
# (B-19), so the payee is not yet reportable as of June 30 (1400.00) and # listed. The 2026 threshold is $2,000 (B-19), so the payee is not yet
# is for the year (3050.00). # reportable as of June 30 (1400.00) and is for the year (3050.00).
id = "1099-worksheet-through-as-of" id = "1099-worksheet-through-as-of"
title = "A 1099 worksheet as of a day counts the year's payments through that day" 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" currency = "USD"
fiscal_year_start = "01-01" fiscal_year_start = "01-01"
basis = "cash" basis = "cash"
rounding = "half_away_from_zero" rounding = "half-away-from-zero"
cash_accounts = ["1000"] cash_accounts = ["1000"]
[[setup.accounts]] [[setup.accounts]]
@@ -56,7 +56,7 @@ type = "expense"
name = "Contractor A" name = "Contractor A"
form = "1099-NEC" form = "1099-NEC"
# 0: the year before. # Entry 1: the year before.
[[events]] [[events]]
date = "2025-12-31" date = "2025-12-31"
description = "Contract work, December" description = "Contract work, December"
@@ -66,7 +66,7 @@ lines = [
{ account = "1000", side = "credit", amount = "100.00" }, { account = "1000", side = "credit", amount = "100.00" },
] ]
# 1 # Entry 2.
[[events]] [[events]]
date = "2026-01-02" date = "2026-01-02"
description = "Owner contribution" description = "Owner contribution"
@@ -75,7 +75,7 @@ lines = [
{ account = "3000", side = "credit", amount = "10000.00" }, { account = "3000", side = "credit", amount = "10000.00" },
] ]
# 2 # Entry 3.
[[events]] [[events]]
date = "2026-03-15" date = "2026-03-15"
description = "Contract work, March" description = "Contract work, March"
@@ -85,7 +85,7 @@ lines = [
{ account = "1000", side = "credit", amount = "900.00" }, { 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]] [[events]]
date = "2026-06-10" date = "2026-06-10"
description = "Contract work, June, by card" description = "Contract work, June, by card"
@@ -95,7 +95,7 @@ lines = [
{ account = "2200", side = "credit", amount = "300.00" }, { 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]] [[events]]
date = "2026-06-30" date = "2026-06-30"
description = "Contract work, June" description = "Contract work, June"
@@ -105,7 +105,7 @@ lines = [
{ account = "1000", side = "credit", amount = "500.00" }, { account = "1000", side = "credit", amount = "500.00" },
] ]
# 5: the day after. # Entry 6: the day after.
[[events]] [[events]]
date = "2026-07-01" date = "2026-07-01"
description = "Contract work, July" description = "Contract work, July"
@@ -115,7 +115,7 @@ lines = [
{ account = "1000", side = "credit", amount = "700.00" }, { account = "1000", side = "credit", amount = "700.00" },
] ]
# 6 # Entry 7.
[[events]] [[events]]
date = "2026-11-20" date = "2026-11-20"
description = "Contract work, November" description = "Contract work, November"
@@ -6,10 +6,11 @@
# `every_file_in_an_as_of_package_stops_at_as_of` and # `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). # `as_of_package_iif_stops_at_as_of` (ledger-package tests/package.rs).
# #
# Expected values: trial_balance gives each account's balance, debit # Expected values (bookspec/README.md): trial_balance gives each
# positive and credit negative, every account listed; profit_and_loss # account's balance as of the day, debit positive and credit negative,
# gives each revenue and expense account's activity for the period as a # every account listed; profit_and_loss gives each revenue and expense
# positive amount, and net_income (revenue less expenses). # 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" id = "as-of-package-stops-at-as-of"
title = "A report as of a day leaves out what is dated after it" 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" currency = "USD"
fiscal_year_start = "01-01" fiscal_year_start = "01-01"
basis = "accrual" basis = "accrual"
rounding = "half_away_from_zero" rounding = "half-away-from-zero"
[[setup.accounts]] [[setup.accounts]]
code = "1000" code = "1000"
@@ -47,7 +48,7 @@ code = "6000"
name = "Rent" name = "Rent"
type = "expense" type = "expense"
# 0 # Entry 1.
[[events]] [[events]]
date = "2026-01-02" date = "2026-01-02"
description = "Owner contribution" description = "Owner contribution"
@@ -56,7 +57,7 @@ lines = [
{ account = "3000", side = "credit", amount = "10000.00" }, { account = "3000", side = "credit", amount = "10000.00" },
] ]
# 1 # Entry 2.
[[events]] [[events]]
date = "2026-02-01" date = "2026-02-01"
description = "Sale" description = "Sale"
@@ -65,7 +66,7 @@ lines = [
{ account = "4000", side = "credit", amount = "4000.00" }, { account = "4000", side = "credit", amount = "4000.00" },
] ]
# 2: dated the day itself, so in. # Entry 3: dated the day itself, so in.
[[events]] [[events]]
date = "2026-06-30" date = "2026-06-30"
description = "June rent" description = "June rent"
@@ -74,7 +75,7 @@ lines = [
{ account = "1000", side = "credit", amount = "1200.00" }, { 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]] [[events]]
date = "2026-07-01" date = "2026-07-01"
description = "July rent" description = "July rent"
@@ -83,20 +84,15 @@ lines = [
{ account = "1000", side = "credit", amount = "1200.00" }, { 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]] [[events]]
date = "2026-07-15" date = "2026-07-15"
description = "Reversal of the February sale" description = "Reversal of the February sale"
reverses = 1 reverses = 2
lines = [
{ account = "4000", side = "debit", amount = "4000.00" },
{ account = "1000", side = "credit", amount = "4000.00" },
]
[[expect]] [[expect]]
query = "trial_balance" query = "trial_balance"
from = "2026-01-01" as_of = "2026-06-30"
to = "2026-06-30"
values = { "1000" = "12800.00", "3000" = "-10000.00", "4000" = "-4000.00", "6000" = "1200.00" } values = { "1000" = "12800.00", "3000" = "-10000.00", "4000" = "-4000.00", "6000" = "1200.00" }
[[expect]] [[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. # The same books a month later: the July rent and the reversal are in.
[[expect]] [[expect]]
query = "trial_balance" query = "trial_balance"
from = "2026-01-01" as_of = "2026-07-31"
to = "2026-07-31"
values = { "1000" = "7600.00", "3000" = "-10000.00", "4000" = "0.00", "6000" = "2400.00" } values = { "1000" = "7600.00", "3000" = "-10000.00", "4000" = "0.00", "6000" = "2400.00" }
[[expect]] [[expect]]
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -18,16 +18,15 @@
# Setup beyond the accounts: `cash_accounts` are the bank accounts (BANK); # Setup beyond the accounts: `cash_accounts` are the bank accounts (BANK);
# an account's QuickBooks type otherwise follows its type (asset OCASSET, # an account's QuickBooks type otherwise follows its type (asset OCASSET,
# liability OCLIAB, equity EQUITY, revenue INC, expense EXP); an account's # 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 # `parent` is its parent's code. Event N is entry N (bookspec/README.md).
# more than its index. Expected values: iif_lines gives the file's lines # Expected values: iif_lines gives the file's lines in order, each a
# in order, each a string of fields joined by tabs ("\t"), every line # string of fields joined by tabs ("\t"), every line ending CR LF in the
# ending CR LF in the file; iif_changes gives each text written # file; iif_changes gives each text written differently from the books,
# differently from the books, in file order (the accounts as declared: # in file order (the accounts as declared: by code, each after its
# by code, each after its parent; then the entries), as [what, books' # parent; then the entries), as [what, books' text, file's text], where
# text, file's text], where what is "name <code>", "number" (left out of # what is "name <code>", "number" (left out of ACCNUM when the file's
# ACCNUM when the file's text is empty), "top level <code>" (written # text is empty), "top level <code>" (written apart from its parent, the
# apart from its parent, the books' text: the parent's code) or # books' text: the parent's code) or "memo <entry id>".
# "memo <entry id>".
id = "iif-quickbooks-journal-file" id = "iif-quickbooks-journal-file"
appendix = "formats" appendix = "formats"
@@ -48,7 +47,7 @@ tags = ["iif", "quickbooks", "as-of", "format", "transliteration"]
currency = "USD" currency = "USD"
fiscal_year_start = "01-01" fiscal_year_start = "01-01"
basis = "cash" basis = "cash"
rounding = "half_away_from_zero" rounding = "half-away-from-zero"
cash_accounts = ["1000"] cash_accounts = ["1000"]
[[setup.accounts]] [[setup.accounts]]
@@ -96,7 +95,7 @@ code = "6100"
name = "Professional services and consulting fees" name = "Professional services and consulting fees"
type = "expense" type = "expense"
# 0: entry 1. # Entry 1.
[[events]] [[events]]
date = "2026-01-02" date = "2026-01-02"
description = "Owner contribution" description = "Owner contribution"
@@ -105,7 +104,7 @@ lines = [
{ account = "3000", side = "credit", amount = "5000.00" }, { 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]] [[events]]
date = "2026-01-15" date = "2026-01-15"
description = "Lunch — \"team\"" description = "Lunch — \"team\""
@@ -114,7 +113,7 @@ lines = [
{ account = "1000", side = "credit", amount = "42.50" }, { 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]] [[events]]
date = "2026-01-20" date = "2026-01-20"
description = "Consulting" description = "Consulting"
@@ -123,7 +122,7 @@ lines = [
{ account = "1000", side = "credit", amount = "300.00" }, { account = "1000", side = "credit", amount = "300.00" },
] ]
# 3: entry 4, dated the day itself: in. # Entry 4, dated the day itself: in.
[[events]] [[events]]
date = "2026-02-01" date = "2026-02-01"
description = "Sale" description = "Sale"
@@ -132,7 +131,7 @@ lines = [
{ account = "4000", side = "credit", amount = "1000.00" }, { account = "4000", side = "credit", amount = "1000.00" },
] ]
# 4: entry 5, the day after: out. # Entry 5, the day after: out.
[[events]] [[events]]
date = "2026-02-02" date = "2026-02-02"
description = "Second sale" description = "Second sale"
+2 -1
View File
@@ -38,7 +38,8 @@ pub struct BooksSettings {
pub cash_accounts: Vec<String>, pub cash_accounts: Vec<String>,
/// `[books] financing_accounts`: liability account codes the cash flow /// `[books] financing_accounts`: liability account codes the cash flow
/// statement treats as financing (loans, and what owners advanced: /// statement treats as financing (loans, and what owners advanced:
/// ASC 230-10-45-14(b)). Empty: callers decide. /// ASC 230-10-45-14(b)); equity is financing unlisted. Empty: other
/// liabilities are operating (the statement never guesses from names).
pub financing_accounts: Vec<String>, pub financing_accounts: Vec<String>,
/// `[books] investing_accounts`: asset account codes the cash flow /// `[books] investing_accounts`: asset account codes the cash flow
/// statement treats as investing (loans the company makes, investments /// statement treats as investing (loans the company makes, investments
File diff suppressed because it is too large. Load diff
+23
View File
@@ -397,6 +397,29 @@ pub enum LedgerError {
end: String, 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. /// The referenced accounting period does not exist.
#[error("unknown period id {0}")] #[error("unknown period id {0}")]
UnknownPeriod(u64), UnknownPeriod(u64),
+348 -1
View File
@@ -29,7 +29,14 @@
//! remainder (B-13); //! remainder (B-13);
//! * a cumulative allocation hands out exactly the new amount, and leaves //! * a cumulative allocation hands out exactly the new amount, and leaves
//! each running total one allocation of everything allocated, so it never //! 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 crate::{AccountType, Side};
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -409,10 +416,350 @@ pub fn apportion_cumulative<T: Amount>(
Some(()) Some(())
} }
/// Where a line falls on the statement of cash flows (rules.md B-14 rule
/// 2): a cash account, or one of the three sections.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Flow {
/// A cash account: what the statement explains the change in.
Cash,
/// Operating activities.
Operating,
/// Investing activities.
Investing,
/// Financing activities.
Financing,
}
impl Flow {
/// The three sections, in the order cash is applied to them (rules.md
/// B-14 rule 3): operating, investing, financing.
pub const SECTIONS: [Flow; 3] = [Flow::Operating, Flow::Investing, Flow::Financing];
}
/// One entry's effect on the statement of cash flows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EntryFlows<T> {
/// Net movement of the entry's cash lines, debit-positive: + is cash
/// in.
pub cash: T,
/// Cash per section, `[operating, investing, financing]`, + is in.
/// Sums to `cash`.
pub flows: [T; 3],
/// What no cash settled, per section, debit-positive: the section's
/// movement plus its cash. Sums to zero.
pub noncash: [T; 3],
}
/// The cash one section takes from what is left of an entry's cash
/// (`remaining`), given the section's net movement (debit-positive): only
/// a section moving against the cash takes any, and never more than its
/// own movement. Cash in (`remaining > 0`) settles credits (`movement <
/// 0`); cash out settles debits.
pub fn take<T: Amount>(remaining: T, movement: T) -> Option<T> {
let against = movement.checked_neg()?;
Some(if remaining > T::ZERO && movement < T::ZERO {
remaining.min(against)
} else if remaining < T::ZERO && movement > T::ZERO {
remaining.max(against)
} else {
T::ZERO
})
}
/// Apply `cash` to `movements` in order, each taking [`take`] of what is
/// left: `applied[k]` is the cash of part `k`. `None` when something is
/// left over (the movements against the cash are smaller than it), the
/// slices differ in length, or on overflow.
///
/// ```text
/// GOAL: split an entry's cash among the parts it settled, without
/// inventing or losing any.
/// SOURCE: rules.md B-14 rule 3 (ASC 230-10-50-5: only the cash portion
/// of a part-cash transaction is a cash flow; the order is a
/// CONVENTION, the ASC is silent).
/// INVARIANTS:
/// 1. On success the parts sum to `cash` exactly.
/// 2. Each part has the sign of `cash` (or is zero) and is no larger
/// than its own movement, which runs against the cash.
/// 3. Applying `-cash` to the negated movements gives the negated parts
/// (a reversal mirrors its original).
/// SCENARIOS:
/// 1. IF the movements against the cash sum to at least the cash:
/// - SHOULD succeed (a balanced entry always does)
/// 2. IF they don't: SHOULD return None, never a part out of thin air.
/// ```
pub fn apply_cash<T: Amount>(cash: T, movements: &[T], applied: &mut [T]) -> Option<()> {
if movements.len() != applied.len() {
return None;
}
let mut remaining = cash;
for (out, &movement) in applied.iter_mut().zip(movements) {
let part = take(remaining, movement)?;
*out = part;
remaining = remaining.checked_sub(part)?;
}
(remaining == T::ZERO).then_some(())
}
/// An entry's cash flows (rules.md B-14 rule 3): the net movement of its
/// cash lines, applied to the sections moving against it in the order
/// operating, investing, financing, each up to its own movement; what is
/// left of a section's movement is the part no cash settled. `None` when
/// the lines don't net to zero (never for a posted entry) or on overflow.
pub fn entry_flows<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<EntryFlows<T>> {
let mut cash = T::ZERO;
let mut moved = [T::ZERO; 3];
for &(flow, side, amount) in lines {
let delta = signed(side, amount)?;
let slot = match flow {
Flow::Cash => &mut cash,
Flow::Operating => &mut moved[0],
Flow::Investing => &mut moved[1],
Flow::Financing => &mut moved[2],
};
*slot = slot.checked_add(delta)?;
}
// Balanced lines only: every part below rests on it.
let mut total = cash;
for &m in &moved {
total = total.checked_add(m)?;
}
if total != T::ZERO {
return None;
}
let mut flows = [T::ZERO; 3];
apply_cash(cash, &moved, &mut flows)?;
let mut noncash = [T::ZERO; 3];
for ((n, m), f) in noncash.iter_mut().zip(moved).zip(flows) {
*n = m.checked_add(f)?;
}
Some(EntryFlows {
cash,
flows,
noncash,
})
}
/// The credits and the debits among some movements (debit-positive):
/// `(Σ max(-m, 0), Σ max(m, 0))`.
fn credits_and_debits<T: Amount>(movements: &[T]) -> Option<(T, T)> {
let (mut credits, mut debits) = (T::ZERO, T::ZERO);
for &m in movements {
if m < T::ZERO {
credits = credits.checked_sub(m)?;
} else {
debits = debits.checked_add(m)?;
}
}
Some((credits, debits))
}
/// Which way money a member or a lender handled for the company went
/// (rules.md B-14 rule 11, D-42).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum OnBehalf {
/// Paid for the company: sources credited (borrowed, contributed)
/// against uses debited (spent).
Paid,
/// Received for the company: sources debited (repaid, distributed)
/// against uses credited (collected).
Received,
}
impl OnBehalf {
/// The other way: what an entry's reversal does.
pub fn mirror(self) -> Self {
match self {
OnBehalf::Paid => OnBehalf::Received,
OnBehalf::Received => OnBehalf::Paid,
}
}
}
/// What a member or a lender paid, or received, for the company with no
/// cash account moving: a constructive receipt and disbursement (rules.md
/// B-14 rule 11, D-42). `sources` are what is left unsettled of the
/// entry's kinds that can pay or receive for the company (a financing
/// liability, a member's equity), `uses` of the kinds they can pay or
/// receive for, each debit-positive. [`OnBehalf::Paid`]: `amount =
/// min(source credits, use debits)`, cash in to the sources' credits and
/// out of the uses' debits (borrowed or contributed, then spent);
/// [`OnBehalf::Received`]: `amount = min(source debits, use credits)`,
/// cash in to the uses' credits and out of the sources' debits (collected,
/// then repaid or distributed). `from[i]` is source `i`'s cash (+ in) and
/// `to[j]` use `j`'s, each applied in order by [`apply_cash`]. `None` when
/// the slices differ in length, or on overflow.
///
/// ```text
/// GOAL: show money a member or lender handled for the company as the
/// company's own, borrowed (or contributed) and spent, without
/// inventing or losing a cent.
/// SOURCE: rules.md B-14 rule 11 (D-42; ASC 230 is silent, PwC Viewpoint
/// FSP 6.9.3.1, Deloitte DART Roadmap 7.2, BDO Blueprint 3.4).
/// INVARIANTS:
/// 1. The parts net to zero: Σ from + Σ to = 0, so the entry's cash
/// flows still sum to its cash.
/// 2. Each part settles its own movement, toward zero and never past it
/// (zero, or the opposite sign and no larger).
/// 3. The other way, minus the sources and minus the uses give minus
/// the parts (a reversal mirrors its original).
/// 4. As much is settled as can be: afterwards, paid, no source credit
/// is left beside a use debit; received, no source debit beside a use
/// credit.
/// ```
pub fn constructive<T: Amount>(
way: OnBehalf,
sources: &[T],
uses: &[T],
from: &mut [T],
to: &mut [T],
) -> Option<()> {
let (source_credits, source_debits) = credits_and_debits(sources)?;
let (use_credits, use_debits) = credits_and_debits(uses)?;
// The cash the sources take (+ in): what was paid, or minus what was
// received; the uses take the opposite.
let cash = match way {
OnBehalf::Paid => source_credits.min(use_debits),
OnBehalf::Received => source_debits.min(use_credits).checked_neg()?,
};
apply_cash(cash, sources, from)?;
apply_cash(cash.checked_neg()?, uses, to)
}
/// How much of an entry is an exchange inside investing and financing
/// that no cash settled (rules.md B-14 rule 7, ASC 230-10-50-4: debt
/// converted to equity, one asset traded for another). `debits` and
/// `credits` are the net debits and net credits of the entry's investing
/// and financing balance-sheet accounts (each account netted first; both
/// non-negative); `inflow` and `outflow` are the cash those two sections
/// took in and paid out (both non-negative; constructive cash included,
/// rule 11). Cash in settles credits and cash out settles debits; what's
/// left of the smaller side was exchanged:
/// `max(0, min(debits - outflow, credits - inflow))`.
pub fn exchanged<T: Amount>(debits: T, credits: T, inflow: T, outflow: T) -> Option<T> {
let d = debits.checked_sub(outflow)?;
let c = credits.checked_sub(inflow)?;
Some(d.min(c).max(T::ZERO))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn max_line_amount_is_one_quadrillion() { fn max_line_amount_is_one_quadrillion() {
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64)); assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
+13 -6
View File
@@ -21,6 +21,7 @@ pub mod analysis;
pub mod arap; pub mod arap;
pub mod assets; pub mod assets;
pub mod calendar; pub mod calendar;
pub mod cashflow;
pub mod classes; pub mod classes;
pub mod conventions; pub mod conventions;
pub mod error; pub mod error;
@@ -53,6 +54,12 @@ pub use assets::{
ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value, ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value,
depreciation_schedule, disposal_amounts, 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 classes::{Class, ClassId};
pub use error::LedgerError; pub use error::LedgerError;
pub use form1099::{ pub use form1099::{
@@ -60,6 +67,7 @@ pub use form1099::{
vendor_1099_report_with, vendor_1099_report_with_members, vendor_1099_report_with, vendor_1099_report_with_members,
}; };
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use kernel::{EntryFlows, Flow};
pub use ledger::{ pub use ledger::{
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS, ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts, REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
@@ -73,12 +81,11 @@ pub use periods::{
PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
}; };
pub use reports::{ pub use reports::{
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine, AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, ClassProfitAndLoss,
CashFlowParams, CashFlowStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, MemberEquityColumn,
LedgerPosting, MemberCapital, MemberEquityColumn, MembersEquityStatement, ProfitAndLoss, MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
ProfitAndLossStatement, StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, TrialBalanceLine, balance_sheet_as_of, fixed_asset_register, general_ledger, members_equity,
cash_flow, fixed_asset_register, general_ledger, members_equity, profit_and_loss_for_period, profit_and_loss_for_period, trial_balance_as_of,
trial_balance_as_of,
}; };
pub use schedules::{ pub use schedules::{
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId, AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
+380 -2
View File
@@ -9,8 +9,10 @@
//! property test checks the two instantiations agree. //! property test checks the two instantiations agree.
//! //!
//! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and //! 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` //! amounts up to 2^40 in magnitude; the statement of cash flows' entry
//! proves the same laws for any number of lines and accounts. //! 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::kernel::{self, Amount, LineError};
use crate::{AccountType, Side}; use crate::{AccountType, Side};
@@ -713,6 +715,382 @@ fn cumulative_closing_entry_balances() {
kani::cover!(true_up, "a true-up line on a member"); 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 // The checker itself
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+6 -457
View File
@@ -1146,465 +1146,14 @@ pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cash flow statement (indirect method, R-FUTURE-3). // Cash flow statement (direct method, rules.md B-14): see `crate::cashflow`.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// One labeled cash flow line. Positive is an inflow, negative an outflow. pub use crate::cashflow::{
#[derive(Clone, Debug, Serialize, Deserialize)] CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
pub struct CashFlowLine { CashFlowStatement, CashFlowWarning, CashFlowWarningKind, NoncashDisclosure, OperatingClass,
/// Human label, e.g. `"Increase in Accounts Receivable"` or account code/name. OperatingReconciliation, ReconcilingKind, ReconcilingLine, cash_flow, foot,
pub label: String, };
/// Signed amount: positive = cash in, negative = cash out.
pub amount: Decimal,
/// Code of the account the line is about, when it is about one (so a
/// renderer can relabel, e.g. a member's capital account by name).
#[serde(default)]
pub account: Option<String>,
}
/// Parameters for [`cash_flow`].
#[derive(Clone, Debug)]
pub struct CashFlowParams {
/// Period start, inclusive.
pub start: chrono::NaiveDate,
/// Period end, inclusive.
pub end: chrono::NaiveDate,
/// Accounts treated as cash. Their net change is the reconciling total.
pub cash_accounts: Vec<crate::AccountId>,
/// Liability accounts treated as financing (debt, including what the
/// owners advanced: ASC 230-10-45-14(b) "other short- or long-term
/// borrowing"). Default empty: all liabilities are operating working
/// capital. All equity is financing.
pub financing_accounts: Vec<crate::AccountId>,
/// Asset accounts treated as investing: loans the entity makes and
/// investments it buys (ASC 230-10-20; 230-10-45-13). Registered fixed
/// assets are investing without being listed. Default empty: other
/// assets are operating working capital.
pub investing_accounts: Vec<crate::AccountId>,
}
/// Cash flow statement, indirect method.
///
/// Starts from period net income, adds back non-cash depreciation, adjusts
/// for working-capital changes, then reports investing (fixed-asset
/// purchases at cost, disposal proceeds) and financing (equity changes,
/// designated debt accounts) flows. The three sections always sum to the
/// net change in the designated cash accounts.
///
/// Disposals are identified structurally: a posted entry that credits a
/// registered fixed-asset cost account and debits its linked accumulated
/// depreciation account. The gain/loss in that entry is removed from
/// operating and the proceeds are shown in investing.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CashFlowStatement {
/// Period start, inclusive.
pub start: chrono::NaiveDate,
/// Period end, inclusive.
pub end: chrono::NaiveDate,
/// Period net income (revenue minus expenses).
pub net_income: Decimal,
/// Non-cash depreciation added back to net income.
pub depreciation: Decimal,
/// Working-capital adjustments (non-cash balance-sheet changes).
pub working_capital: Vec<CashFlowLine>,
/// Net cash from operating activities.
pub cash_from_operations: Decimal,
/// Investing flows: asset purchases and disposal proceeds.
pub investing: Vec<CashFlowLine>,
/// Net cash from investing activities.
pub cash_from_investing: Decimal,
/// Financing flows: equity changes and designated debt.
pub financing: Vec<CashFlowLine>,
/// Net cash from financing activities.
pub cash_from_financing: Decimal,
/// Net change in cash: the three sections summed.
pub net_change_in_cash: Decimal,
/// Cash balance at period start.
pub cash_beginning: Decimal,
/// Cash balance at period end.
pub cash_ending: Decimal,
}
/// Build the indirect-method cash flow statement for a period.
///
/// Pure function of the ledger: net income plus non-cash depreciation plus
/// working-capital changes gives operating cash flow; fixed-asset purchases
/// and disposal proceeds, and the accounts named as investing, give
/// investing; equity and the debt named as financing give financing. The
/// three sections sum to the net change in `cash_accounts`.
///
/// ```text
/// GOAL: the statement of cash flows by the indirect method: net income
/// adjusted to operating cash, plus investing and financing, for a
/// period.
/// SOURCE: docs/spec/rules.md B-14 (ASC 230-10-45-28 indirect method;
/// 230-10-45-16 operating inflows; 230-10-45-7/8 gross over net;
/// 230-10-45-13/14/15 investing and financing flows; 230-10-20
/// investing activities).
/// INVARIANTS:
/// 1. Operating + investing + financing MUST equal the net change in the
/// cash accounts over the period, exactly.
/// 2. Operating MUST start from the period's net income (closing entries
/// excluded) and adjust for changes in non-cash working-capital
/// accounts.
/// 3. Investing MUST hold fixed-asset purchases and disposal proceeds
/// (a disposal's gain or loss moves out of operating into investing)
/// and the changes in the accounts named as investing.
/// 4. Financing MUST hold changes in equity (other than earnings) and in
/// the liabilities named as financing.
/// 4a. Investing and financing MUST be gross (ASC 230-10-45-7: gross
/// receipts and payments are "more relevant" than net): per account,
/// cash in and cash out are separate lines (contributions apart from
/// distributions, borrowing apart from repayment), netted only within
/// one entry.
/// 5. NEVER count closing entries.
/// 6. The period is `[start, end]`, inclusive: an entry dated `start` is
/// period activity, not part of the beginning cash balance.
/// 7. Every line's amount is its cash effect: positive is cash in. Its
/// label names the direction ("Increase in" / "Decrease in" for
/// working capital; "Proceeds from" / "Repayment of" for financing
/// debt; "Owner contributions" / "Owner distributions" for equity;
/// "Payments for" / "Collections on" for investing accounts).
/// ```
pub fn cash_flow(
ledger: &Ledger,
params: &CashFlowParams,
) -> Result<CashFlowStatement, LedgerError> {
use std::collections::{HashMap, HashSet};
if params.end < params.start {
return Err(LedgerError::InvalidDateRange {
start: params.start.to_string(),
end: params.end.to_string(),
});
}
// Fixed-asset account roles from the register.
let mut cost_accounts: HashSet<crate::AccountId> = HashSet::new();
let mut accum_accounts: HashSet<crate::AccountId> = HashSet::new();
let mut depr_expense_accounts: HashSet<crate::AccountId> = HashSet::new();
// cost_account -> accum_account, for disposal identification.
let mut cost_to_accum: HashMap<crate::AccountId, crate::AccountId> = HashMap::new();
for asset in ledger.registered_assets() {
cost_accounts.insert(asset.cost_account);
accum_accounts.insert(asset.accum_depr_account);
depr_expense_accounts.insert(asset.expense_account);
cost_to_accum.insert(asset.cost_account, asset.accum_depr_account);
}
let cash_set: HashSet<crate::AccountId> = params.cash_accounts.iter().copied().collect();
let financing_set: HashSet<crate::AccountId> =
params.financing_accounts.iter().copied().collect();
let investing_set: HashSet<crate::AccountId> =
params.investing_accounts.iter().copied().collect();
// Movement helpers: debits minus credits over a date predicate. Closing
// entries are skipped: a close moves earnings into retained earnings,
// which is neither net income nor an owner flow.
let closings = ledger.closing_entry_ids();
let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
let mut total = Decimal::ZERO;
for entry in ledger.posted_entries() {
if !pred(entry.date()) || closings.contains(&entry.id()) {
continue;
}
for line in entry.lines() {
if line.account == account {
match line.side {
crate::Side::Debit => total += line.amount.amount,
crate::Side::Credit => total -= line.amount.amount,
}
}
}
}
total
};
let begin_movement = |a: crate::AccountId| movement_where(a, &|d| d < params.start);
let period_movement =
|a: crate::AccountId| movement_where(a, &|d| d >= params.start && d <= params.end);
// Normal-side balance from a movement.
let normal = |account: crate::AccountId, movement: Decimal| -> Decimal {
let acct = ledger.chart().get(account).expect("chart account exists");
match acct.account_type.normal_side() {
crate::Side::Debit => movement,
crate::Side::Credit => -movement,
}
};
// --- Net income and depreciation (period P&L) ---
let mut net_income = Decimal::ZERO;
let mut depreciation = Decimal::ZERO;
for account in ledger.chart().all() {
if account.placeholder {
continue;
}
let pm = period_movement(account.id);
if pm.is_zero() {
continue;
}
match account.account_type {
AccountType::Revenue => net_income += -pm, // revenue movement is credits
AccountType::Expense => {
net_income -= pm;
if depr_expense_accounts.contains(&account.id) {
depreciation += pm;
}
}
_ => {}
}
}
// --- Classify balance-sheet accounts ---
let mut working_capital: Vec<CashFlowLine> = Vec::new();
let mut investing: Vec<CashFlowLine> = Vec::new();
let mut financing: Vec<CashFlowLine> = Vec::new();
// Account display helper.
let display = |account: crate::AccountId| -> String {
let a = ledger.chart().get(account).expect("chart account exists");
format!("{} {}", a.code, a.name)
};
// By code: the chart iterates in hash order, and a statement's lines
// must come out in the same order every time.
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
if account.placeholder {
continue;
}
let id = account.id;
if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) {
continue; // cash reconciles; fixed assets are investing (gross, below)
}
if account.account_type == AccountType::Equity
|| financing_set.contains(&id)
|| investing_set.contains(&id)
{
continue; // financing and investing roles are gross, below
}
let begin = normal(id, begin_movement(id));
let end = normal(id, period_movement(id) + begin_movement(id));
let delta = end - begin;
if delta.is_zero() {
continue;
}
// Cash effect of the balance change: for both assets and
// liabilities, it is minus the movement (debits - credits).
let movement_delta = period_movement(id);
let cash_effect = -movement_delta;
let (label, line) = match account.account_type {
AccountType::Asset => {
let dir = if delta > Decimal::ZERO {
"Increase in"
} else {
"Decrease in"
};
(format!("{dir} {}", display(id)), cash_effect)
}
AccountType::Liability => {
let dir = if delta > Decimal::ZERO {
"Increase in"
} else {
"Decrease in"
};
(format!("{dir} {}", display(id)), cash_effect)
}
_ => continue, // revenue/expense handled via net income
};
working_capital.push(CashFlowLine {
label,
amount: line,
account: Some(account.code.clone()),
});
}
// --- Financing and investing accounts, gross (ASC 230-10-45-7) ---
// Per account, cash in and cash out as separate lines; an entry's own
// debits and credits to the account net first (one transaction, one
// flow).
let mut roles: Vec<&crate::Account> = ledger
.chart()
.all()
.filter(|a| {
!a.placeholder
&& !cash_set.contains(&a.id)
&& !cost_accounts.contains(&a.id)
&& !accum_accounts.contains(&a.id)
&& (a.account_type == AccountType::Equity
|| financing_set.contains(&a.id)
|| investing_set.contains(&a.id))
})
.collect();
roles.sort_by(|a, b| a.code.cmp(&b.code));
for account in roles {
let (mut cash_in, mut cash_out) = (Decimal::ZERO, Decimal::ZERO);
for entry in ledger.posted_entries() {
let d = entry.date();
if d < params.start || d > params.end || closings.contains(&entry.id()) {
continue;
}
// Cash effect of this entry's change to the account: minus its
// movement (debits - credits), for assets and claims alike.
let effect: Decimal = entry
.lines()
.iter()
.filter(|l| l.account == account.id)
.map(|l| match l.side {
crate::Side::Debit => -l.amount.amount,
crate::Side::Credit => l.amount.amount,
})
.sum();
if effect > Decimal::ZERO {
cash_in += effect;
} else {
cash_out += effect;
}
}
let name = display(account.id);
let (in_label, out_label, target) = if investing_set.contains(&account.id) {
(
format!("Collections on {name}"),
format!("Payments for {name}"),
&mut investing,
)
} else if account.account_type == AccountType::Equity {
(
format!("Owner contributions — {name}"),
format!("Owner distributions — {name}"),
&mut financing,
)
} else {
(
format!("Proceeds from {name}"),
format!("Repayment of {name}"),
&mut financing,
)
};
for (label, amount) in [(in_label, cash_in), (out_label, cash_out)] {
if !amount.is_zero() {
target.push(CashFlowLine {
label,
amount,
account: Some(account.code.clone()),
});
}
}
}
// --- Investing, gross: purchases and disposal proceeds ---
// A disposal entry credits a cost account and debits its linked accum.
let mut disposal_gain_loss = Decimal::ZERO; // +gain / -loss, to remove from operating
for entry in ledger.posted_entries() {
let d = entry.date();
if d < params.start || d > params.end {
continue;
}
// Is this a disposal? Credit to a cost account + debit to its accum.
let mut disposed: Option<(crate::AccountId, Decimal, Decimal)> = None;
for line in entry.lines() {
if line.side == crate::Side::Credit
&& let Some(&accum) = cost_to_accum.get(&line.account)
{
// Confirm the linked accum account is debited in this entry.
let accum_debit: Decimal = entry
.lines()
.iter()
.filter(|l| l.account == accum && l.side == crate::Side::Debit)
.map(|l| l.amount.amount)
.sum();
if !accum_debit.is_zero() {
disposed = Some((line.account, line.amount.amount, accum_debit));
break;
}
}
}
if let Some((cost_acct, cost_amount, accum_debit)) = disposed {
let accum_acct = cost_to_accum[&cost_acct];
let mut proceeds = Decimal::ZERO;
let mut proceeds_label = String::new();
let mut gain = Decimal::ZERO;
let mut loss = Decimal::ZERO;
for line in entry.lines() {
if line.account == cost_acct || line.account == accum_acct {
continue;
}
let acct = ledger
.chart()
.get(line.account)
.expect("chart account exists");
match (acct.account_type, line.side) {
(AccountType::Asset, crate::Side::Debit) => {
proceeds += line.amount.amount;
proceeds_label = display(line.account);
}
(AccountType::Revenue, crate::Side::Credit) => gain += line.amount.amount,
(AccountType::Expense, crate::Side::Debit) => loss += line.amount.amount,
_ => {}
}
}
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
disposal_gain_loss += gain - loss;
investing.push(CashFlowLine {
label: format!("Sale of fixed assets — {proceeds_label}"),
amount: proceeds,
account: ledger.chart().get(cost_acct).map(|a| a.code.clone()),
});
continue;
}
// Not a disposal: debits to cost accounts are purchases.
for line in entry.lines() {
if cost_accounts.contains(&line.account) && line.side == crate::Side::Debit {
investing.push(CashFlowLine {
label: format!("Purchase of fixed assets — {}", display(line.account)),
amount: -line.amount.amount,
account: ledger.chart().get(line.account).map(|a| a.code.clone()),
});
}
}
}
// Remove disposal gains/losses from operating (they live in investing).
if !disposal_gain_loss.is_zero() {
working_capital.push(CashFlowLine {
label: "Less: gain on sale of fixed assets (in investing)".to_string(),
amount: -disposal_gain_loss,
account: None,
});
}
let cash_from_operations: Decimal =
net_income + depreciation + working_capital.iter().map(|l| l.amount).sum::<Decimal>();
let cash_from_investing: Decimal = investing.iter().map(|l| l.amount).sum();
let cash_from_financing: Decimal = financing.iter().map(|l| l.amount).sum();
// --- Cash reconciliation ---
let mut cash_beginning = Decimal::ZERO;
let mut cash_ending = Decimal::ZERO;
for &id in &params.cash_accounts {
cash_beginning += normal(id, begin_movement(id));
cash_ending += normal(id, begin_movement(id) + period_movement(id));
}
let net_change_in_cash = cash_ending - cash_beginning;
Ok(CashFlowStatement {
start: params.start,
end: params.end,
net_income,
depreciation,
working_capital,
cash_from_operations,
investing,
cash_from_investing,
financing,
cash_from_financing,
net_change_in_cash,
cash_beginning,
cash_ending,
})
}
/// One asset on the fixed asset register. /// One asset on the fixed asset register.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+3 -3
View File
@@ -294,21 +294,21 @@ pub static KEYS: &[Key] = &[
toml: ("books", "cash_accounts"), toml: ("books", "cash_accounts"),
kind: Kind::Codes, kind: Kind::Codes,
sensitivity: Sensitivity::Public, 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 { Key {
name: "books.financing_accounts", name: "books.financing_accounts",
toml: ("books", "financing_accounts"), toml: ("books", "financing_accounts"),
kind: Kind::Codes, kind: Kind::Codes,
sensitivity: Sensitivity::Public, 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 { Key {
name: "books.investing_accounts", name: "books.investing_accounts",
toml: ("books", "investing_accounts"), toml: ("books", "investing_accounts"),
kind: Kind::Codes, kind: Kind::Codes,
sensitivity: Sensitivity::Public, 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 { Key {
name: "books.close_into", name: "books.close_into",
File diff suppressed because it is too large. Load diff
@@ -1,398 +0,0 @@
//! The bookspec examples about closes to the members
//! (`bookspec/examples/members-close.*.toml`; P13, D-34; P3 defect 17),
//! checked against the engine on every run, so they can't rot.
//!
//! Each example is a setup, events and expected values worked by hand from
//! its cited source. Its shape is bookspec's (`bookspec/README.md`) with
//! what closes need:
//!
//! - `[[setup.members]]` takes `share`, an exact decimal fraction (the
//! shares sum to 1), beside `name` and `capital_account`;
//! - an event may be a close instead of an entry: `close = { start, to,
//! rounding? }` closes the period from `start` through the event's
//! `date` to `"members"` (by share, in the order listed) or
//! `"retained_earnings"`; `rounding = "per-close"` makes a members close
//! split its own net alone (books from an engine that rounded each close
//! by itself), and the default is the cumulative rule. A close is an
//! event like any other: event N posts entry #N, so a close must have
//! something to close;
//! - an `[[expect]]` may take `after = N`: the books after event N, not
//! after the last;
//! - three queries: `entry` (`entry = N`: its lines, `"<code>.debit"` and
//! `"<code>.credit"` summing each account's lines on that side, every
//! line of the entry named), `balances` (`as_of`: each account named,
//! `"<code>"`, its balance on its normal side, so a member's capital
//! after a loss is negative), and `members_equity` (`from`, `to`: the
//! statement of changes in members' equity, `"<name>.beginning"`,
//! `.contributions`, `.net_income`, `.distributions`, `.other`,
//! `.ending`).
//!
//! A key the shape doesn't have is refused, never ignored.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ledger_core::{
AccountType, CloseTo, Currency, Ledger, MemberCapital, MemberClose, Money, Side,
members_equity, reports::trial_balance_as_of,
};
use rust_decimal::Decimal;
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Example {
id: String,
title: String,
rule: String,
status: String,
sources: Vec<String>,
tags: Vec<String>,
setup: Setup,
events: Vec<Event>,
expect: Vec<Expect>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Setup {
currency: String,
fiscal_year_start: String,
basis: String,
rounding: String,
accounts: Vec<AccountSpec>,
#[serde(default)]
members: Vec<MemberSpec>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AccountSpec {
code: String,
name: String,
#[serde(rename = "type")]
kind: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MemberSpec {
name: String,
capital_account: String,
share: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Event {
date: String,
description: String,
#[serde(default)]
lines: Vec<LineSpec>,
#[serde(default)]
reverses: Option<u64>,
#[serde(default)]
close: Option<CloseSpec>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LineSpec {
account: String,
side: String,
amount: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CloseSpec {
start: String,
to: String,
#[serde(default)]
rounding: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Expect {
query: String,
#[serde(default)]
from: Option<String>,
#[serde(default)]
to: Option<String>,
#[serde(default)]
as_of: Option<String>,
#[serde(default)]
entry: Option<u64>,
#[serde(default)]
after: Option<usize>,
values: BTreeMap<String, String>,
}
fn examples() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples");
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("members-close.") && n.ends_with(".toml"))
})
.collect();
paths.sort();
paths
}
fn date(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap_or_else(|_| panic!("date {s:?}"))
}
/// An exact decimal string, never a float.
fn exact(s: &str) -> Decimal {
assert!(
!s.contains(['e', 'E']) && s.trim() == s,
"{s:?} is not a decimal string"
);
s.parse()
.unwrap_or_else(|_| panic!("{s:?} is not a decimal"))
}
fn account_type(kind: &str) -> AccountType {
match kind {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
"expense" => AccountType::Expense,
other => panic!("account type {other:?}"),
}
}
fn side(s: &str) -> Side {
match s {
"debit" => Side::Debit,
"credit" => Side::Credit,
other => panic!("side {other:?}"),
}
}
struct Books<'a> {
ex: &'a Example,
l: Ledger,
currency: Currency,
}
impl Books<'_> {
fn id(&self, code: &str) -> ledger_core::AccountId {
self.l
.chart()
.find_by_code(code)
.unwrap_or_else(|| panic!("{}: no account {code}", self.ex.id))
.id
}
fn members(&self) -> Vec<MemberCapital> {
self.ex
.setup
.members
.iter()
.map(|m| MemberCapital {
name: m.name.clone(),
capital_account: self.id(&m.capital_account),
distribution_account: None,
share: exact(&m.share),
})
.collect()
}
/// Event `n` (1-based), which must post entry #n.
fn apply(&mut self, n: u64, e: &Event) {
let ex = &self.ex.id;
let day = date(&e.date);
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(Some(c), None, true) => {
let p = self
.l
.create_period(&format!("event {n}"), date(&c.start), day)
.unwrap();
let members: Vec<MemberClose> = self
.members()
.iter()
.map(|m| MemberClose {
capital: m.capital_account,
share: m.share,
distributions: None,
})
.collect();
let to = match (c.to.as_str(), c.rounding.as_deref()) {
("members", None) => CloseTo::Members(members),
("members", Some("per-close")) => CloseTo::MembersPerClose(members),
("retained_earnings", None) => CloseTo::RetainedEarnings { sweep: vec![] },
other => panic!("{ex}: close {other:?}"),
};
self.l
.close_period_to(p, &to)
.unwrap()
.unwrap_or_else(|| panic!("{ex}: event {n} closes nothing"))
}
(None, Some(k), true) => {
assert!(k >= 1 && k < n, "{ex}: event {n} reverses {k}");
self.l.reverse_entry(k, day, &e.description).unwrap()
}
(None, None, false) => {
let id = self.l.create_entry(day, &e.description);
for line in &e.lines {
let account = self.id(&line.account);
let money = Money::new(exact(&line.amount), self.currency);
self.l
.add_line(id, account, side(&line.side), money)
.unwrap();
}
self.l.post_entry(id).unwrap();
id
}
_ => panic!("{ex}: event {n} is one of lines, reverses or close"),
};
assert_eq!(posted, n, "{ex}: event {n} posted entry {posted}");
}
/// What `q` asks, for each of its keys.
fn answer(&self, q: &Expect) -> BTreeMap<String, Decimal> {
let ex = &self.ex.id;
match q.query.as_str() {
"entry" => {
let id = q
.entry
.unwrap_or_else(|| panic!("{ex}: entry query without entry"));
let mut out = BTreeMap::new();
for line in self.l.get_entry(id).unwrap().lines() {
let code = &self.l.chart().get(line.account).unwrap().code;
let side = match line.side {
Side::Debit => "debit",
Side::Credit => "credit",
};
*out.entry(format!("{code}.{side}")).or_default() += line.amount.amount;
}
out
}
"balances" => {
let as_of = date(q.as_of.as_deref().expect("balances take as_of"));
let tb = trial_balance_as_of(&self.l, as_of);
q.values
.keys()
.map(|code| {
let account = self.l.chart().get(self.id(code)).unwrap();
let (dr, cr) = tb
.lines
.iter()
.find(|l| &l.code == code)
.map_or((Decimal::ZERO, Decimal::ZERO), |l| (l.debit, l.credit));
let normal = match account.account_type.normal_side() {
Side::Debit => dr - cr,
Side::Credit => cr - dr,
};
(code.clone(), normal)
})
.collect()
}
"members_equity" => {
let from = date(q.from.as_deref().expect("members_equity takes from"));
let to = date(q.to.as_deref().expect("members_equity takes to"));
let st = members_equity(&self.l, from, to, &self.members()).unwrap();
let mut out = BTreeMap::new();
for m in &st.members {
for (field, v) in [
("beginning", m.beginning),
("contributions", m.contributions),
("net_income", m.net_income),
("distributions", m.distributions),
("other", m.other),
("ending", m.ending),
] {
out.insert(format!("{}.{field}", m.name), v);
}
}
out
}
other => panic!("{ex}: query {other:?}"),
}
}
fn check(&self, q: &Expect) {
let ex = &self.ex.id;
let got = self.answer(q);
for (key, want) in &q.values {
let got = got
.get(key)
.unwrap_or_else(|| panic!("{ex}: {} has no {key}", q.query));
assert_eq!(*got, exact(want), "{ex}: {} {key}", q.query);
}
if q.query == "entry" {
// Every line named: the entry is exactly what is expected.
let named: Vec<&String> = q.values.keys().collect();
let lines: Vec<&String> = got.keys().collect();
assert_eq!(lines, named, "{ex}: entry {:?}'s lines", q.entry);
}
}
}
/// Every members-close example loads, and the engine computes every value
/// it expects, after the event it names or the last.
#[test]
fn the_members_close_examples_hold() {
let paths = examples();
assert!(paths.len() >= 4, "{} examples", paths.len());
for path in paths {
let text = std::fs::read_to_string(&path).unwrap();
let ex: Example =
toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
assert_eq!(
path.file_stem().unwrap().to_str().unwrap(),
ex.id,
"an example's file is named for its id"
);
assert!(!ex.title.is_empty() && !ex.rule.is_empty() && !ex.sources.is_empty());
assert!(!ex.tags.is_empty());
assert!(["AUTHORITY", "CONVENTION", "CHOICE"].contains(&ex.status.as_str()));
assert_eq!(
(ex.setup.basis.as_str(), ex.setup.rounding.as_str()),
("accrual", "largest-remainder"),
"{}",
ex.id
);
assert!(ex.setup.fiscal_year_start.len() == 5, "{}", ex.id);
let shares: Decimal = ex.setup.members.iter().map(|m| exact(&m.share)).sum();
assert_eq!(shares, Decimal::ONE, "{}: shares", ex.id);
let currency = Currency::new(&ex.setup.currency).unwrap();
let mut books = Books {
ex: &ex,
l: Ledger::new(currency),
currency,
};
for a in &ex.setup.accounts {
books
.l
.add_account(&a.code, &a.name, account_type(&a.kind), None, false)
.unwrap();
}
for q in &ex.expect {
assert!(
q.after.is_none_or(|n| n >= 1 && n <= ex.events.len()),
"{}: after {:?}",
ex.id,
q.after
);
}
for (i, e) in ex.events.iter().enumerate() {
books.apply(i as u64 + 1, e);
for q in ex.expect.iter().filter(|q| q.after == Some(i + 1)) {
books.check(q);
}
}
for q in ex.expect.iter().filter(|q| q.after.is_none()) {
books.check(q);
}
}
}
+66 -14
View File
@@ -744,7 +744,7 @@ fn entry_movements_sum_to_zero() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cash flow statement (indirect method). // Cash flow statement (direct method, rules.md B-14).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
use ledger_core::NewAsset; use ledger_core::NewAsset;
@@ -757,6 +757,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
cash_accounts: vec![cash], cash_accounts: vec![cash],
financing_accounts: vec![], financing_accounts: vec![],
investing_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)); assert_eq!(cf.net_change_in_cash, Decimal::new(820000, 2));
// Net income: 4,000 revenue - 800 rent = 3,200. // 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. // 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 // Operating, by the direct method: collected 1,500 (class (a)); paid
// - 2,500 increase in AR (4,000 billed - 1,500 collected) // 2,500 for equipment (not a registered asset: operating, rules.md
// - 2,500 equipment purchase (not a registered asset: working capital) // B-14's limit R4) and 800 rent (class (d)) = -1,800. Reconciled:
// + 10,000 owner investment is equity -> financing, not operating. // 3,200 net income - 2,500 increase in AR (4,000 billed - 1,500
// Financing: +10,000 owner contribution. // collected) - 2,500 increase in equipment = -1,800.
// => operating = 3,200 - 2,500 - 2,500 = -1,800 // Financing: +10,000 owner contribution. Sum = 8,200. Reconciles.
// => investing = 0, financing = 10,000. Sum = 8,200. Reconciles.
assert_eq!(cf.cash_from_operations, Decimal::new(-180000, 2)); assert_eq!(cf.cash_from_operations, Decimal::new(-180000, 2));
assert_eq!(
cf.operating_by_class(),
[
(
ledger_core::OperatingClass::FromCustomers,
Decimal::new(150000, 2)
),
(
ledger_core::OperatingClass::ToSuppliersAndEmployees,
Decimal::new(-330000, 2)
),
]
);
let total: Decimal = r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>();
assert_eq!(total, cf.cash_from_operations);
assert_eq!(cf.cash_from_investing, Decimal::ZERO); assert_eq!(cf.cash_from_investing, Decimal::ZERO);
assert_eq!(cf.cash_from_financing, Decimal::new(1000000, 2)); assert_eq!(cf.cash_from_financing, Decimal::new(1000000, 2));
assert_eq!(cf.reconciles.difference, Decimal::ZERO);
} }
#[test] #[test]
@@ -894,11 +911,46 @@ fn cash_flow_with_registered_asset_and_disposal() {
// Cash: 100,000 - 60,000 + 59,600 = 99,600. // Cash: 100,000 - 60,000 + 59,600 = 99,600.
assert_eq!(cf.cash_ending, Decimal::new(9960000, 2)); assert_eq!(cf.cash_ending, Decimal::new(9960000, 2));
// Net income: 148 gain - 548 depreciation = -400. // Net income: 148 gain - 548 depreciation = -400.
assert_eq!(cf.net_income, Decimal::new(-40000, 2)); let r = &cf.operating_reconciliation;
// Depreciation added back: 548. assert_eq!(r.net_income, Decimal::new(-40000, 2));
assert_eq!(cf.depreciation, Decimal::new(54800, 2)); // Depreciation added back: 548; the gain taken out: -148 (its cash is
// Operating: -400 + 548 - 148 (gain removed) = 0. // 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); 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. // Investing: -60,000 purchase + 59,600 proceeds = -400.
assert_eq!(cf.cash_from_investing, Decimal::new(-40000, 2)); assert_eq!(cf.cash_from_investing, Decimal::new(-40000, 2));
// Financing: +100,000 owner investment. // Financing: +100,000 owner investment.
@@ -5,3 +5,5 @@
# It is recommended to check this file in to source control so that # It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases. # 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 bd44337eb42456f907441e7938e4909e0b66386ce70d26b9b54e7173c4d72fa7 # shrinks to parts = [427221022393, 672290605285], rems = [0, 0, 0, 0, 0], given = [0, 0, 0, 0, 0], short = 99, unpaired = false
cc 763c29fdb4a32c44ec7b41b2616685739f7accb3063872c1fc9df69e7f43c0d8 # shrinks to paid = false, sources = [1], uses = [-1]
cc badb0f9ff0f4e69ac29aa73c59c238de5afaeef1cd66f7aba7098a786a37a1fd # shrinks to paid = true, sources = [-1], uses = []
+70
View File
@@ -245,4 +245,74 @@ proptest! {
prop_assert!(drift.abs() < Decimal::from(big_w) / Decimal::from(100), "{drift}"); prop_assert!(drift.abs() < Decimal::from(big_w) / Decimal::from(100), "{drift}");
} }
} }
/// rules.md B-14 rule 3: the statement of cash flows' per-entry split
/// (`kernel::entry_flows`, Kani `entry_flows_*`) is the same at both
/// instantiations, on balanced and unbalanced lines alike.
#[test]
fn entry_flows_agree(lines in arb_lines()) {
let tagged = |a: u8| match a {
0 => kernel::Flow::Cash,
1 => kernel::Flow::Operating,
2 => kernel::Flow::Investing,
_ => kernel::Flow::Financing,
};
let li: Vec<_> = lines.iter().map(|&(k, s, a)| (tagged(k), s, a)).collect();
let ld: Vec<_> = lines.iter().map(|&(k, s, a)| (tagged(k), s, dec(a))).collect();
let fi = kernel::entry_flows(&li).map(|f| kernel::EntryFlows {
cash: dec(f.cash),
flows: f.flows.map(dec),
noncash: f.noncash.map(dec),
});
prop_assert_eq!(fi, kernel::entry_flows(&ld));
}
/// `kernel::apply_cash` (how a section's cash splits among its kinds)
/// and `kernel::exchanged` agree at both instantiations.
#[test]
fn apply_cash_and_exchanged_agree(
cash in arb_cents(),
moves in prop::collection::vec(arb_cents(), 0..5),
d in 0i64..=MAX_CENTS,
c in 0i64..=MAX_CENTS,
inflow in 0i64..=MAX_CENTS,
outflow in 0i64..=MAX_CENTS,
) {
let mut ai = vec![0i64; moves.len()];
let mut ad = vec![Decimal::ZERO; moves.len()];
let md: Vec<Decimal> = moves.iter().copied().map(dec).collect();
let oi = kernel::apply_cash(cash, &moves, &mut ai);
let od = kernel::apply_cash(dec(cash), &md, &mut ad);
prop_assert_eq!(oi, od);
if oi.is_some() {
prop_assert_eq!(ai.into_iter().map(dec).collect::<Vec<_>>(), ad);
}
prop_assert_eq!(
kernel::exchanged(d, c, inflow, outflow).map(dec),
kernel::exchanged(dec(d), dec(c), dec(inflow), dec(outflow))
);
}
/// rules.md B-14 rule 11: `kernel::constructive` (what a member or
/// lender paid or received for the company; Kani
/// `constructive_nets_to_zero`, `constructive_mirrors`) agrees at both
/// instantiations.
#[test]
fn constructive_agrees(
paid in any::<bool>(),
sources in prop::collection::vec(arb_cents(), 0..4),
uses in prop::collection::vec(arb_cents(), 0..4),
) {
let way = if paid { kernel::OnBehalf::Paid } else { kernel::OnBehalf::Received };
let (mut fi, mut ti) = (vec![0i64; sources.len()], vec![0i64; uses.len()]);
let sd: Vec<Decimal> = sources.iter().copied().map(dec).collect();
let ud: Vec<Decimal> = uses.iter().copied().map(dec).collect();
let (mut fd, mut td) = (vec![Decimal::ZERO; sd.len()], vec![Decimal::ZERO; ud.len()]);
let oi = kernel::constructive(way, &sources, &uses, &mut fi, &mut ti);
let od = kernel::constructive(way, &sd, &ud, &mut fd, &mut td);
prop_assert_eq!(oi, od);
prop_assert!(oi.is_some());
prop_assert_eq!(fi.into_iter().map(dec).collect::<Vec<_>>(), fd);
prop_assert_eq!(ti.into_iter().map(dec).collect::<Vec<_>>(), td);
}
} }
@@ -0,0 +1,11 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 1a4be67a53e1782122f6ca192c3525f029bef45f52351847fa24cd7fb89ce84d # shrinks to ops = [Post { day: 44, template: 9, amounts: [1, 1, 1], who: false }], extra = (0, [1, 1, 1], false), (a, m, e) = (0, 0, 44), designate = true, k = 2
cc 69f4101495a47fc6b8c78e94de304cd3b728a1be3ada022514c5999d13048ae6 # shrinks to ops = [Post { day: 113, template: 25, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }, Reverse { of: 4714218928808477990, after: 54 }], (a, e) = (114, 0), designate = true
cc 0f108f7a6a6438f5cd89f1029b529e74b202af6b7ace3603d971b726ba7523af # shrinks to ops = [Post { day: 210, template: 24, amounts: [1, 1, 1], who: false }], (a, e) = (0, 210), designate = false
cc 3418b7695f5874cfdfde9e98e12ca276a457b5049339bfe0036bae842dc0346b # shrinks to ops = [Post { day: 0, template: 0, amounts: [1, 1, 1], who: false }], extra = (9, [1, 1, 1], false), (a, m, e) = (0, 0, 0), designate = true, k = 2
cc 4f1c756113272b826d9234f5307df5d5839cb23ffe8a44b8eb370561679d44b3 # shrinks to ops = [Post { day: 93, template: 1, amounts: [1, 1, 1], who: false }], extra = (0, [1, 1, 1], false), (a, m, e) = (0, 0, 93), designate = false, k = 2
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1557,9 +1557,9 @@ fn entry_flow_scenarios() {
} }
// 2. Reimbursing members; paying the card. // 2. Reimbursing members; paying the card.
for liab in [members, card] { for liab in [members, card] {
let f = case(&[(liab, D, "387.64"), (bank, C, "387.64")]); let f = case(&[(liab, D, "412.30"), (bank, C, "412.30")]);
assert_eq!(f.net, dec("387.64")); assert_eq!(f.net, dec("412.30"));
assert_eq!(f.what_for, vec![(liab, dec("387.64"))]); assert_eq!(f.what_for, vec![(liab, dec("412.30"))]);
} }
// 3. A loan. // 3. A loan.
let f = case(&[(bank, D, "1000.00"), (loan, C, "1000.00")]); let f = case(&[(bank, D, "1000.00"), (loan, C, "1000.00")]);
@@ -0,0 +1,11 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 99b387de55e0ece2480575f9cc93c688e0ad408686703012ce503dd21f73ba85 # shrinks to gens = [Gen { day: 67, mode: 1, picks: [4, 0, 4], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 67), designate = false
cc 9f5e63191433240d6df895c196fdd89b56ce1a2a3d8d5225740c2f10dc720756 # shrinks to gens = [Gen { day: 100, mode: 2, picks: [3, 0, 4], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 100), designate = true
cc 5397d840688ed53dc033aceaf98b8db5df3a7a2616c4afe7f3f5fc2e0c8775f2 # shrinks to gens = [Gen { day: 140, mode: 2, picks: [0, 0, 2], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 140), designate = false
cc abd84709578e7889c275a0019ae0e71366d77526ae93072777be614d7ffb0dd5 # shrinks to gens = [Gen { day: 108, mode: 1, picks: [1, 5, 3], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 108), k = 2
cc 7481a0fb806d08439357ce4be66224389a7f76a7d91bf93ac320bb2b3c4d4891 # shrinks to gens = [Gen { day: 3, mode: 1, picks: [4, 5, 1], x: 1, y: 1, classes: [0, 0, 0] }], drafts = [Gen { day: 0, mode: 0, picks: [0, 0, 0], x: 1, y: 1, classes: [0, 0, 0] }], (a, _m, e) = (0, 0, 3)
+131 -42
View File
@@ -1331,6 +1331,7 @@ fn cf_transfer_between_cash_accounts_is_not_a_flow() {
cash_accounts: vec![cash, ar], cash_accounts: vec![cash, ar],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}, },
) )
.unwrap(); .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_from_financing, Decimal::ZERO);
assert_eq!(cf.cash_beginning, c(100000)); assert_eq!(cf.cash_beginning, c(100000));
assert_eq!(cf.cash_ending, c(100000)); assert_eq!(cf.cash_ending, c(100000));
assert!(cf.operating.is_empty() && cf.noncash_disclosures.is_empty());
} }
// =========================================================================== // ===========================================================================
@@ -1406,12 +1408,13 @@ fn sorted_amounts(lines: &[ledger_core::CashFlowLine]) -> Vec<Decimal> {
v v
} }
/// Oracle: CF INVARIANTS 1, 2, 4 — hand-computed February (indirect): NI /// Oracle: CF INVARIANTS 1, 2, 4 — hand-computed February (direct):
/// 400; AR up 300 (-300), AP up 80 (+80); CFO 180; financing loan 300 + /// collected 200, paid 20: CFO 180; reconciled from NI 400, AR up 300
/// contribution 100 = 400; net change 580 = cash 1000 -> 1580. /// (-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 /// The reconciliation has one line per operating account that changed;
/// account (docs/testing.md #8): here each financing account moved one way only. /// financing is one line per entry (docs/testing.md #8).
#[test] #[test]
fn cf_hand_computed_with_designated_debt() { fn cf_hand_computed_with_designated_debt() {
let co = cf_example_books(); let co = cf_example_books();
@@ -1421,16 +1424,21 @@ fn cf_hand_computed_with_designated_debt() {
cash_accounts: vec![co.cash], cash_accounts: vec![co.cash],
financing_accounts: vec![co.loan], financing_accounts: vec![co.loan],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}; };
let cf = cash_flow(&co.l, &params).unwrap(); let cf = cash_flow(&co.l, &params).unwrap();
assert_eq!(cf.start, d(2026, 2, 1)); assert_eq!(cf.start, d(2026, 2, 1));
assert_eq!(cf.end, d(2026, 2, 28)); assert_eq!(cf.end, d(2026, 2, 28));
assert_eq!(cf.net_income, c(40000)); assert_eq!(cf.operating_reconciliation.net_income, c(40000));
assert_eq!(cf.depreciation, Decimal::ZERO); let mut rec: Vec<Decimal> = cf
assert_eq!( .operating_reconciliation
sorted_amounts(&cf.working_capital), .lines
vec![c(-30000), c(8000)] .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_operations, c(18000));
assert_eq!(cf.cash_from_investing, Decimal::ZERO); assert_eq!(cf.cash_from_investing, Decimal::ZERO);
assert_eq!(sorted_amounts(&cf.financing), vec![c(10000), c(30000)]); 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 /// 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] #[test]
fn cf_undesignated_liability_is_working_capital() { fn cf_undesignated_liability_is_operating() {
let co = cf_example_books(); let co = cf_example_books();
let params = CashFlowParams { let params = CashFlowParams {
start: d(2026, 2, 1), start: d(2026, 2, 1),
@@ -1451,6 +1459,7 @@ fn cf_undesignated_liability_is_working_capital() {
cash_accounts: vec![co.cash], cash_accounts: vec![co.cash],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}; };
let cf = cash_flow(&co.l, &params).unwrap(); let cf = cash_flow(&co.l, &params).unwrap();
assert_eq!(cf.cash_from_operations, c(48000)); assert_eq!(cf.cash_from_operations, c(48000));
@@ -1470,6 +1479,7 @@ fn cf_never_counts_closing_entries() {
cash_accounts: vec![co.cash], cash_accounts: vec![co.cash],
financing_accounts: vec![co.loan], financing_accounts: vec![co.loan],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}; };
let before = cash_flow(&co.l, &params).unwrap(); let before = cash_flow(&co.l, &params).unwrap();
let p1 = let p1 =
@@ -1481,14 +1491,15 @@ fn cf_never_counts_closing_entries() {
.unwrap(); .unwrap();
co.l.close_period(p2).unwrap(); co.l.close_period(p2).unwrap();
let after = cash_flow(&co.l, &params).unwrap(); let after = cash_flow(&co.l, &params).unwrap();
assert_eq!(after.net_income, c(40000)); assert_eq!(after.operating_reconciliation.net_income, c(40000));
assert_eq!(cf_key(&after), cf_key(&before)); assert_eq!(cf_key(&after), cf_key(&before));
} }
/// Oracle: CF INVARIANTS 1-3 — fixed-asset purchase in investing, /// Oracle: CF INVARIANTS 1-3 — fixed-asset purchase in investing,
/// depreciation added back, disposal gain moved out of operating with the /// depreciation reconciling, disposal gain taken out of net income with
/// proceeds in investing (hand-computed: Feb NI -100, depr 100, CFO 0, /// the proceeds in investing (hand-computed: Feb NI -100, depr 100, CFO 0,
/// investing -1200; Mar NI -50, depr 100, CFO 0, investing +1050). /// investing -1200; Mar NI -50, depr 100, gain -50, CFO 0, investing
/// +1050).
/// ///
/// ASSUMED: depreciation is recognised as credits to a registered asset's /// ASSUMED: depreciation is recognised as credits to a registered asset's
/// accumulated-depreciation account against expense, and the disposal is /// 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], cash_accounts: vec![cash],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}, },
) )
.unwrap(); .unwrap();
assert_eq!(feb.net_income, c(-10000)); assert_eq!(feb.operating_reconciliation.net_income, c(-10000));
assert_eq!(feb.depreciation, 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_operations, Decimal::ZERO);
assert_eq!(feb.cash_from_investing, c(-120000)); assert_eq!(feb.cash_from_investing, c(-120000));
assert_eq!(feb.cash_from_financing, Decimal::ZERO); assert_eq!(feb.cash_from_financing, Decimal::ZERO);
@@ -1570,11 +1583,18 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
cash_accounts: vec![cash], cash_accounts: vec![cash],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}, },
) )
.unwrap(); .unwrap();
assert_eq!(mar.net_income, c(-5000)); assert_eq!(mar.operating_reconciliation.net_income, c(-5000));
assert_eq!(mar.depreciation, c(10000)); let rec: Vec<Decimal> = mar
.operating_reconciliation
.lines
.iter()
.map(|l| l.amount)
.collect();
assert_eq!(rec, vec![c(10000), c(-5000)]);
assert_eq!(mar.cash_from_operations, Decimal::ZERO); assert_eq!(mar.cash_from_operations, Decimal::ZERO);
assert_eq!(mar.cash_from_investing, c(105000)); assert_eq!(mar.cash_from_investing, c(105000));
assert_eq!(mar.net_change_in_cash, 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()) { 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 mut b = build(&gens);
let (s0, e0) = (day(a), day(e)); 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 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 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()); 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 = /// Oracle: CF INVARIANTS 1-2, 4 — operating + investing + financing =
/// net change = ending - beginning cash (model), NI is the period P&L /// net change = ending - beginning cash (model), NI is the period P&L
/// (model, closes excluded), section totals are their line sums, CFO = /// (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. /// designated loan.
/// ///
/// With no registered fixed assets and no investing accounts (docs/testing.md #8), /// With no registered fixed assets and no investing accounts
/// depreciation and investing /// (docs/testing.md #8), investing is zero (receivables are current,
/// are zero (receivables are current, so working capital). /// so operating).
#[test] #[test]
fn prop_cf_sections_sum_to_change_in_cash(gens in arb_books(), (a, _m, e) in three_days(), month in opt_month(), designate in any::<bool>()) { fn prop_cf_sections_sum_to_change_in_cash(gens in arb_books(), (a, _m, e) in three_days(), month in opt_month(), designate in any::<bool>()) {
let mut b = build(&gens); let mut b = build(&gens);
@@ -2442,6 +2462,7 @@ proptest! {
cash_accounts: vec![b.ids[CASH]], cash_accounts: vec![b.ids[CASH]],
financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] }, financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] },
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}; };
let cf = cash_flow(&b.l, &params).unwrap(); let cf = cash_flow(&b.l, &params).unwrap();
prop_assert_eq!(cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash); 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(); 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_beginning, beg);
prop_assert_eq!(cf.cash_ending, end); prop_assert_eq!(cf.cash_ending, end);
prop_assert_eq!(cf.net_income, model_ni(&model(&gens, day(a), day(e), None))); let r = &cf.operating_reconciliation;
prop_assert_eq!(cf.depreciation, Decimal::ZERO); prop_assert_eq!(r.net_income, model_ni(&model(&gens, day(a), day(e), None)));
prop_assert_eq!(cf.cash_from_investing, Decimal::ZERO); prop_assert_eq!(cf.cash_from_investing, Decimal::ZERO);
let wc: Decimal = cf.working_capital.iter().map(|l| l.amount).sum(); let items: Decimal = r.lines.iter().map(|l| l.amount).sum();
prop_assert_eq!(cf.cash_from_operations, cf.net_income + cf.depreciation + wc); prop_assert_eq!(cf.cash_from_operations, r.net_income + items);
prop_assert_eq!(cf.cash_from_operations, cf.operating.iter().map(|l| l.amount).sum::<Decimal>());
prop_assert_eq!(cf.reconciles.difference, Decimal::ZERO);
prop_assert_eq!(cf.cash_from_investing, cf.investing.iter().map(|l| l.amount).sum::<Decimal>()); prop_assert_eq!(cf.cash_from_investing, cf.investing.iter().map(|l| l.amount).sum::<Decimal>());
prop_assert_eq!(cf.cash_from_financing, cf.financing.iter().map(|l| l.amount).sum::<Decimal>()); prop_assert_eq!(cf.cash_from_financing, cf.financing.iter().map(|l| l.amount).sum::<Decimal>());
} }
/// Oracle: CF INVARIANT 4 against the model — financing is the change in /// Oracle: CF INVARIANT 2 against an independent model — financing is
/// equity accounts (other than earnings) plus designated debt; the rest /// the cash part of the equity and designated-debt movements: each
/// of the non-cash balance sheet is working capital. /// 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] #[test]
fn prop_cf_financing_is_equity_plus_designated_debt(gens in arb_books(), (a, _m, e) in three_days(), designate in any::<bool>()) { fn prop_cf_financing_is_equity_plus_designated_debt(gens in arb_books(), (a, _m, e) in three_days(), designate in any::<bool>()) {
let b = build(&gens); let b = build(&gens);
@@ -2471,13 +2501,72 @@ proptest! {
cash_accounts: vec![b.ids[CASH]], cash_accounts: vec![b.ids[CASH]],
financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] }, financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] },
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}; };
let cf = cash_flow(&b.l, &params).unwrap(); let cf = cash_flow(&b.l, &params).unwrap();
let m = model(&gens, day(a), day(e), None); // Take up to `amount` (>= 0) from the parts of `qs` with sign
let credit = |code: &str| -m.get(code).copied().unwrap_or_default(); // `debits`, in order, moving them toward zero.
let mut fin = credit("3000") + credit("3001"); fn settle(qs: &mut [i64], mut amount: i64, debits: bool) {
if designate { fin += credit("2500"); } for q in qs.iter_mut() {
prop_assert_eq!(cf.cash_from_financing, fin); let room = if debits { (*q).max(0) } else { (-*q).max(0) };
let t = amount.min(room);
*q += if debits { -t } else { t };
amount -= t;
}
}
let credits = |qs: &[i64]| qs.iter().map(|q| (-q).max(0)).sum::<i64>();
let debits = |qs: &[i64]| qs.iter().map(|q| (*q).max(0)).sum::<i64>();
let mut fin = 0i64;
for g in gens.iter().filter(|g| g.date() >= day(a) && g.date() <= day(e)) {
// Kinds: cash; operating balance sheet, revenue and expense;
// capital A, capital B, the loan (financing when designated).
let mut cash = 0i64;
let mut op = [0i64; 2];
let mut fi = [0i64; 3];
for (i, side, cents) in g.legs(1) {
let m = if side == DR { cents } else { -cents };
match RCHART[i].0 {
"1000" => cash += m,
"3000" => fi[0] += m,
"3001" => fi[1] += m,
"2500" if designate => fi[2] += m,
"4000" | "4100" | "6000" | "6100" => op[1] += m,
_ => op[0] += m,
}
}
// Rule 3: operating, then financing (no investing here).
let take = |left: i64, m: i64| {
if left > 0 && m < 0 {
left.min(-m)
} else if left < 0 && m > 0 {
left.max(-m)
} else {
0
}
};
let to_op = take(cash, op[0] + op[1]);
let to_fin = take(cash - to_op, fi.iter().sum());
fin += to_fin;
if to_op > 0 { settle(&mut op, to_op, false) } else { settle(&mut op, -to_op, true) }
if to_fin > 0 { settle(&mut fi, to_fin, false) } else { settle(&mut fi, -to_fin, true) }
// Rule 11: the loan pays, then receives; then capital.
if designate {
let paid = credits(&fi[2..]).min(debits(&op));
fi[2] += paid;
settle(&mut op, paid, true);
let received = debits(&fi[2..]).min(credits(&op[1..]));
fi[2] -= received;
settle(&mut op[1..], received, false);
fin += paid - received;
}
let paid = credits(&fi[..2]).min(debits(&op[1..]));
settle(&mut fi[..2], paid, false);
settle(&mut op[1..], paid, true);
let received = debits(&fi[..2]).min(credits(&op[1..]));
settle(&mut fi[..2], received, true);
fin += paid - received;
}
prop_assert_eq!(cf.cash_from_financing, c(fin));
} }
/// Oracle: CF INVARIANT 5 — closing a period changes no cash flow /// Oracle: CF INVARIANT 5 — closing a period changes no cash flow
@@ -2487,10 +2576,10 @@ proptest! {
let open = build(&gens); let open = build(&gens);
let mut closed = build(&gens); let mut closed = build(&gens);
close_through(&mut closed, Some(month), &re_close()); 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 x = cash_flow(&open.l, &p(&open)).unwrap();
let y = cash_flow(&closed.l, &p(&closed)).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_operations, y.cash_from_operations);
prop_assert_eq!(x.cash_from_investing, y.cash_from_investing); prop_assert_eq!(x.cash_from_investing, y.cash_from_investing);
prop_assert_eq!(x.cash_from_financing, y.cash_from_financing); prop_assert_eq!(x.cash_from_financing, y.cash_from_financing);
@@ -2504,10 +2593,10 @@ proptest! {
let kk = Decimal::from(k); let kk = Decimal::from(k);
let one = build(&gens); let one = build(&gens);
let many = build_scaled(&gens, k); 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 x = cash_flow(&one.l, &p(&one)).unwrap();
let y = cash_flow(&many.l, &p(&many)).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_operations, x.cash_from_operations * kk);
prop_assert_eq!(y.cash_from_financing, x.cash_from_financing * kk); prop_assert_eq!(y.cash_from_financing, x.cash_from_financing * kk);
prop_assert_eq!(y.cash_beginning, x.cash_beginning * kk); prop_assert_eq!(y.cash_beginning, x.cash_beginning * kk);
+109 -57
View File
@@ -215,16 +215,17 @@ fn a_closed_period_still_reports_its_earnings() {
cash_accounts: vec![b.cash], cash_accounts: vec![b.cash],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}, },
) )
.unwrap(); .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.financing.len(), 1);
assert_eq!(cf.cash_from_financing, dec(10_000)); assert_eq!(cf.cash_from_financing, dec(10_000));
assert_eq!(cf.net_change_in_cash, dec(11_700)); 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.) /// iterates in hash order; the statement must not.)
#[test] #[test]
fn cash_flow_lines_are_in_code_order() { fn cash_flow_lines_are_in_code_order() {
@@ -251,14 +252,16 @@ fn cash_flow_lines_are_in_code_order() {
cash_accounts: vec![cash], cash_accounts: vec![cash],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}, },
) )
.unwrap(); .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 let codes: Vec<&str> = cf
.financing .financing
.iter() .iter()
.map(|f| f.label.rsplit(' ').next().unwrap()) .map(|f| f.kind.rsplit(' ').next().unwrap())
.collect(); .collect();
assert_eq!( assert_eq!(
codes, codes,
@@ -525,9 +528,11 @@ fn members_must_be_configured_correctly() {
)); ));
} }
/// Each line names the accounts it moved, and is listed under its
/// member's kind (D-35: "Contributions from members — <member>").
#[test] #[test]
fn cash_flow_lines_name_their_account() { fn cash_flow_lines_name_their_account() {
let (l, _) = partnership(false); let (l, members) = partnership(false);
let cf = cash_flow( let cf = cash_flow(
&l, &l,
&CashFlowParams { &CashFlowParams {
@@ -536,15 +541,23 @@ fn cash_flow_lines_name_their_account() {
cash_accounts: vec![1], cash_accounts: vec![1],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members,
}, },
) )
.unwrap(); .unwrap();
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect(); let accounts: Vec<&[String]> = cf.financing.iter().map(|f| &f.accounts[..]).collect();
// Gross (ASC 230-10-45-7): 3001 contributed and took a distribution, // Gross (ASC 230-10-45-7): 3001 contributed and took a distribution,
// so it has a line for each. // so it has a line for each.
assert_eq!(accounts, [Some("3000"), Some("3001"), Some("3001")]); assert_eq!(accounts, [["3000"], ["3001"], ["3001"]]);
assert!(cf.financing[1].label.starts_with("Owner contributions")); let kinds: Vec<&str> = cf.financing.iter().map(|f| f.kind.as_str()).collect();
assert!(cf.financing[2].label.starts_with("Owner distributions")); 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); 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], cash_accounts: vec![cash],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
} }
} }
fn reconciles(cf: &ledger_core::CashFlowStatement) { fn reconciles(cf: &ledger_core::CashFlowStatement) {
// B-14 / cash_flow INVARIANT 1: the three sections sum to the change in // B-14 / cash_flow INVARIANT 4: the three sections sum to the change in
// cash, exactly. // cash, exactly, and net income and its reconciling items to operating.
assert_eq!( assert_eq!(
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
cf.net_change_in_cash cf.net_change_in_cash
); );
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash); assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
assert_eq!(cf.reconciles.difference, Decimal::ZERO);
let r = &cf.operating_reconciliation;
assert_eq!(
r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>(),
cf.cash_from_operations
);
} }
#[test] #[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(); 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_beginning, dec(10_000));
assert_eq!(cf.cash_ending, dec(12_500)); 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); reconciles(&cf);
// Backwards is refused. // Backwards is refused.
assert!(matches!( 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, 5), "Repay", loan, cash, 300);
post(&mut l, d(3, 6), "Distribution", capital, cash, 400); post(&mut l, d(3, 6), "Distribution", capital, cash, 400);
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> { let rec = |cf: &ledger_core::CashFlowStatement| -> Vec<(String, Decimal)> {
v.iter().map(|l| (l.label.clone(), l.amount)).collect() 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 s = |x: &str| x.to_string();
let params = |start, end| CashFlowParams { let params = |start, end| CashFlowParams {
@@ -1103,24 +1131,27 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
cash_accounts: vec![cash], cash_accounts: vec![cash],
financing_accounts: vec![loan], financing_accounts: vec![loan],
investing_accounts: vec![], investing_accounts: vec![],
members: vec![],
}; };
// February: net income 100 - 50 = 50; receivable up 100 (cash out), // February: net income 100 - 50 = 50; receivable up 100 (cash out),
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400. // payable up 50 (cash in): operations 0. Borrowed 300, contributed 400
// (the kinds in the statement's order: contributions, then
// borrowing; ASC 230-10-45-14(a), (b)).
let feb = cash_flow(&l, &params(d(2, 1), d(2, 28))).unwrap(); let feb = cash_flow(&l, &params(d(2, 1), d(2, 28))).unwrap();
assert_eq!(feb.net_income, dec(50)); assert_eq!(feb.operating_reconciliation.net_income, dec(50));
assert_eq!( assert_eq!(
lines(&feb.working_capital), rec(&feb),
[ [
(s("Increase in 1100 Receivable"), dec(-100)), (s("Increase in 1100 Receivable"), dec(-100)),
(s("Increase in 2000 Payable"), dec(50)), (s("Increase in 2000 Payable"), dec(50)),
] ]
); );
assert_eq!( assert_eq!(
lines(&feb.financing), kinds(&feb.financing),
[ [
(s("Proceeds from 2500 Loan"), dec(300)), (s("Contributions from owners — 3000 Capital"), dec(400)),
(s("Owner contributions — 3000 Capital"), dec(400)), (s("Proceeds from borrowing — 2500 Loan"), dec(300)),
] ]
); );
assert_eq!(feb.cash_from_operations, dec(0)); assert_eq!(feb.cash_from_operations, dec(0));
@@ -1128,21 +1159,22 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
reconciles(&feb); reconciles(&feb);
// March: the mirror image. Collected 100, paid 50, repaid 300, // March: the mirror image. Collected 100, paid 50, repaid 300,
// distributed 400. // distributed 400 (45-15(a), (b)): an original's direction is its
// nature; only a reversal never takes a label from its sign.
let mar = cash_flow(&l, &params(d(3, 1), d(3, 31))).unwrap(); let mar = cash_flow(&l, &params(d(3, 1), d(3, 31))).unwrap();
assert_eq!(mar.net_income, dec(0)); assert_eq!(mar.operating_reconciliation.net_income, dec(0));
assert_eq!( assert_eq!(
lines(&mar.working_capital), rec(&mar),
[ [
(s("Decrease in 1100 Receivable"), dec(100)), (s("Decrease in 1100 Receivable"), dec(100)),
(s("Decrease in 2000 Payable"), dec(-50)), (s("Decrease in 2000 Payable"), dec(-50)),
] ]
); );
assert_eq!( assert_eq!(
lines(&mar.financing), kinds(&mar.financing),
[ [
(s("Repayment of 2500 Loan"), dec(-300)), (s("Distributions to owners — 3000 Capital"), dec(-400)),
(s("Owner distributions — 3000 Capital"), dec(-400)), (s("Repayments of borrowing — 2500 Loan"), dec(-300)),
] ]
); );
assert_eq!(mar.net_change_in_cash, dec(-650)); 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.add_line(e, equipment, Side::Credit, usd(1_000)).unwrap();
l.post_entry(e).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 // 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(); 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!( 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) dec(300)
)] )]
); );
assert_eq!(feb.cash_from_operations, dec(0)); 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!( assert_eq!(
lines(&feb.investing), inv,
[("Sale of fixed assets — 1000 Cash".to_string(), dec(600))] [("Proceeds from sales of property and equipment", dec(600))]
); );
assert_eq!(feb.net_change_in_cash, dec(600)); assert_eq!(feb.net_change_in_cash, dec(600));
reconciles(&feb); reconciles(&feb);
@@ -1324,21 +1365,27 @@ fn roles() -> (Ledger, CashFlowParams) {
cash_accounts: vec![cash], cash_accounts: vec![cash],
financing_accounts: vec![loan, due_owners], financing_accounts: vec![loan, due_owners],
investing_accounts: vec![member_loan], investing_accounts: vec![member_loan],
members: vec![],
}; };
(l, params) (l, params)
} }
fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> { fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> {
ls.iter() ls.iter()
.map(|l| (l.account.clone().unwrap_or_default(), l.amount)) .map(|l| (l.accounts.join(","), l.amount))
.collect() .collect()
} }
/// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash /// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash
/// receipts and cash payments during a period is more relevant than /// receipts and cash payments during a period is more relevant than
/// information about the net amounts"), 230-10-45-14(b) (proceeds from /// information about the net amounts"), 230-10-45-14(b) (proceeds from
/// "other short- or long-term borrowing") and 45-15(2) ("Repayments of /// "other short- or long-term borrowing") and 45-15(b) ("Repayments of
/// amounts borrowed"): borrowing and repayment are separate lines. /// 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] #[test]
fn financing_is_gross_borrowing_apart_from_repayment() { fn financing_is_gross_borrowing_apart_from_repayment() {
let (l, params) = roles(); let (l, params) = roles();
@@ -1347,12 +1394,13 @@ fn financing_is_gross_borrowing_apart_from_repayment() {
lines(&cf.financing), lines(&cf.financing),
[ [
("2100".into(), dec(100)), ("2100".into(), dec(100)),
("2100".into(), dec(-100)),
("2500".into(), dec(1000)), ("2500".into(), dec(1000)),
("2100".into(), dec(-100)),
("2500".into(), dec(-400)), ("2500".into(), dec(-400)),
] ]
); );
assert_eq!(cf.cash_from_financing, dec(600)); assert_eq!(cf.cash_from_financing, dec(600));
assert!(cf.noncash_disclosures.is_empty());
} }
/// Oracle: ASC 230-10-20 ("Investing activities include making and /// Oracle: ASC 230-10-20 ("Investing activities include making and
@@ -1365,37 +1413,39 @@ fn a_loan_the_entity_makes_is_investing_gross() {
let cf = cash_flow(&l, &params).unwrap(); let cf = cash_flow(&l, &params).unwrap();
assert_eq!( assert_eq!(
lines(&cf.investing), 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[0].kind.starts_with("Payments for"));
assert!(cf.investing[1].label.starts_with("Payments for")); assert!(cf.investing[1].kind.starts_with("Collections on"));
assert!( assert!(
cf.working_capital cf.operating_reconciliation
.lines
.iter() .iter()
.all(|w| w.account.as_deref() != Some("1400")) .all(|w| w.account.as_deref() != Some("1400"))
); );
assert_eq!(cf.cash_from_investing, dec(-300)); assert_eq!(cf.cash_from_investing, dec(-300));
} }
/// Oracle: PwC Viewpoint, Financial statement presentation 6.9.3.1 /// Oracle: D-42 (rules.md B-14 rule 11; ASC 230 is silent on a payment
/// (constructive receipts and disbursements: cash a third party pays on /// made on the entity's behalf, PwC FSP 6.9.3.1 and DART Roadmap 7.2
/// the entity's behalf is presented "as though the transaction took place /// present it as constructive receipt and disbursement). The owner paying
/// through the bank accounts of the reporting entity"). The owner paying /// software personally is cash the company borrowed (financing +100) and
/// software personally is a borrowing (financing in) and an operating /// spent (operating −100, 230-10-45-17(b)) on Mar 5; reimbursing the owner
/// payment (in net income); reimbursing the owner is a repayment. /// is a repayment (−100, 45-15(b)). Hand-computed: operating −100 (net
/// Hand-computed: operating -100, investing -300, financing +600 = +200, /// income −100, no reconciling item), investing −300, financing +600 =
/// the change in cash. /// +200, the change in cash. Replaces
/// `an_expense_the_owner_paid_is_disclosed_then_repaid` (D-35).
#[test] #[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 (l, params) = roles();
let cf = cash_flow(&l, &params).unwrap(); let cf = cash_flow(&l, &params).unwrap();
assert_eq!(cf.net_income, dec(-100)); assert_eq!(cf.operating_reconciliation.net_income, dec(-100));
assert_eq!(cf.cash_from_operations, dec(-100)); assert_eq!(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.net_change_in_cash, dec(200));
assert_eq!( reconciles(&cf);
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
cf.net_change_in_cash
);
} }
/// Gross lines net only within one entry: an entry that both debits and /// Gross lines net only within one entry: an entry that both debits and
@@ -1412,6 +1462,8 @@ fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() {
let cf = cash_flow(&l, &params).unwrap(); let cf = cash_flow(&l, &params).unwrap();
assert_eq!(cf.cash_from_financing, dec(600)); assert_eq!(cf.cash_from_financing, dec(600));
assert_eq!(cf.financing.len(), 4); 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 { mod chaining {
+131 -1
View File
@@ -76,7 +76,10 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Rou
/// engine makes from a schedule (period closes, depreciation, /// engine makes from a schedule (period closes, depreciation,
/// amortization: no counterparty by nature) (one item: Info under /// amortization: no counterparty by nature) (one item: Info under
/// suggest, Todo under require; its prompt names /// suggest, Todo under require; its prompt names
/// books.set_entry_party). /// books.set_entry_party); each warning of the fiscal year's
/// statement of cash flows (rules.md B-14 rule 12: equipment sold for
/// an IOU, Todo; bought on an operating payable, Info; one per
/// entry, its detail the warning's fix).
/// 3. Nothing else: books with none of those yield no items, except /// 3. Nothing else: books with none of those yield no items, except
/// that a figure behind an item that can't be computed (an /// that a figure behind an item that can't be computed (an
/// amortization plan past chrono's last day, an aging past exact /// amortization plan past chrono's last day, an aging past exact
@@ -657,6 +660,46 @@ pub fn find(b: &Books) -> Vec<Attention> {
prompt: verify_prompt, 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.sort_by_key(|a| a.severity);
out out
} }
@@ -681,6 +724,93 @@ mod tests {
.unwrap() .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 /// 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 /// whose prompt asks the agent to find the figure from the official
/// authority (named, never the repo's copy), confirm it with the human /// authority (named, never the repo's copy), confirm it with the human
+3 -3
View File
@@ -103,7 +103,7 @@ fn group(d: Decimal) -> String {
} }
/// The plain decimal behind a displayed amount, for the clipboard: /// 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. /// Exact (the display never rounds). None when the text isn't an amount.
pub fn plain(display: &str) -> Option<String> { pub fn plain(display: &str) -> Option<String> {
let t = display.trim(); let t = display.trim();
@@ -254,7 +254,7 @@ mod tests {
assert_eq!(money(dec("-12")), "\u{2212}12.00"); assert_eq!(money(dec("-12")), "\u{2212}12.00");
assert_eq!(money(dec("0")), "0.00"); assert_eq!(money(dec("0")), "0.00");
assert_eq!(money(dec("-0.00")), "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!(dollars(dec("999.999")), "$999.999");
assert_eq!(money(dec("0.125")), "0.125"); assert_eq!(money(dec("0.125")), "0.125");
assert_eq!(compact(dec("-0.4")), "$0"); assert_eq!(compact(dec("-0.4")), "$0");
@@ -266,7 +266,7 @@ mod tests {
assert_eq!(label("irs_name_control"), "IRS name control"); assert_eq!(label("irs_name_control"), "IRS name control");
assert_eq!(words("PrepaidExpense"), "Prepaid expense"); assert_eq!(words("PrepaidExpense"), "Prepaid expense");
assert_eq!(plain("$1,234.56").as_deref(), Some("1234.56")); 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("\u{2212}$12.00").as_deref(), Some("-12.00"));
assert_eq!(plain("+1,000.005").as_deref(), Some("1000.005")); assert_eq!(plain("+1,000.005").as_deref(), Some("1000.005"));
assert_eq!(plain("0.00").as_deref(), Some("0.00")); assert_eq!(plain("0.00").as_deref(), Some("0.00"));
+522 -74
View File
@@ -15,8 +15,8 @@ use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
use egui_phosphor::regular as icon; use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, intervals}; use ledger_core::analysis::{Interval, intervals};
use ledger_core::reports::{ use ledger_core::reports::{
CashFlowParams, balance_sheet_as_of, cash_flow, general_ledger, members_equity, balance_sheet_as_of, general_ledger, members_equity, profit_and_loss_for_period,
profit_and_loss_for_period, trial_balance_as_of, trial_balance_as_of,
}; };
use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_1099_report}; use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_1099_report};
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -138,6 +138,9 @@ impl PdfInfo {
enum Kind { enum Kind {
Section, Section,
Line, 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, Subtotal,
Grand, Grand,
Note, Note,
@@ -283,6 +286,7 @@ impl Table {
let kind = match r.kind { let kind = match r.kind {
Kind::Section => RowKind::Section, Kind::Section => RowKind::Section,
Kind::Line => RowKind::Line, Kind::Line => RowKind::Line,
Kind::Detail => RowKind::Detail,
Kind::Subtotal => RowKind::Subtotal, Kind::Subtotal => RowKind::Subtotal,
Kind::Grand => RowKind::Total, Kind::Grand => RowKind::Total,
Kind::Note => RowKind::Subheading, Kind::Note => RowKind::Subheading,
@@ -570,71 +574,67 @@ fn balance_sheet(b: &Books, dates: &[(NaiveDate, String)]) -> Table {
t t
} }
/// Financing accounts: from `[books] financing_accounts`, else liabilities /// One row of the cash flow table, keyed across its columns: (key, label,
/// whose names say they are debt. /// amount, the account it drills to).
fn financing_accounts(b: &Books) -> Vec<AccountId> { type KeyedRow = (String, String, Decimal, Option<String>);
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()
}
/// 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 { fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table {
use ledger_core::{CashFlowSection, ReconcilingKind};
let mut cols = cols.to_vec(); let mut cols = cols.to_vec();
if total && cols.len() > 1 { if total && cols.len() > 1 {
cols.push((cols[0].0, cols[cols.len() - 1].1, "Total".into())); cols.push((cols[0].0, cols[cols.len() - 1].1, "Total".into()));
} }
let fin = financing_accounts(b); let settings = ledger_package::Settings {
let stmts: Vec<_> = cols company: &b.settings.company,
cash_accounts: &b.settings.cash_accounts,
financing_accounts: &b.settings.financing_accounts,
investing_accounts: &b.settings.investing_accounts,
};
let stmts: Vec<Result<ledger_core::CashFlowStatement, String>> = cols
.iter() .iter()
.map(|(s, e, _)| { .map(|(s, e, _)| {
cash_flow( ledger_package::cash_flow_for(
&b.ledger, &b.ledger,
&CashFlowParams { &settings,
start: *s, Some(&s.to_string()),
end: *e, Some(&e.to_string()),
cash_accounts: b.cash_accounts.clone(), &[],
financing_accounts: fin.clone(), &[],
investing_accounts: b.investing_accounts.clone(), &[],
}, b.today,
) )
.ok()
}) })
.collect(); .collect();
let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect()); let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect());
// A cell per column; blank where the statement was refused.
let val = |f: &dyn Fn(&ledger_core::CashFlowStatement) -> Decimal| -> Vec<Cell> { let val = |f: &dyn Fn(&ledger_core::CashFlowStatement) -> Decimal| -> Vec<Cell> {
stmts stmts
.iter() .iter()
.map(|s| Cell { .map(|s| Cell {
value: s.as_ref().map(f), value: s.as_ref().ok().map(f),
drill: None, drill: None,
}) })
.collect() .collect()
}; };
let lines = |t: &mut Table, // Rows keyed across the columns, in first-seen order.
pick: &dyn Fn( let keyed = |t: &mut Table,
&ledger_core::CashFlowStatement, kind: Kind,
) -> &Vec<ledger_core::CashFlowLine>| { rows: &dyn Fn(&ledger_core::CashFlowStatement) -> Vec<KeyedRow>| {
let mut labels: Vec<(String, Option<String>)> = Vec::new(); let mut order: Vec<(String, String, Option<String>)> = Vec::new();
for s in stmts.iter().flatten() { for s in stmts.iter().flatten() {
for l in pick(s) { for (key, label, _, account) in rows(s) {
if !labels.iter().any(|(x, _)| x == &l.label) { if !order.iter().any(|(k, _, _)| *k == key) {
labels.push((l.label.clone(), l.account.clone())); order.push((key, label, account));
} }
} }
} }
for (label, account) in labels { for (key, label, account) in order {
let id = account let id = account
.as_deref() .as_deref()
.and_then(|c| b.ledger.chart().find_by_code(c)) .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() .iter()
.zip(&stmts) .zip(&stmts)
.map(|((s, e, _), st)| Cell { .map(|((s, e, _), st)| Cell {
value: Some( value: st.as_ref().ok().map(|st| {
st.as_ref() rows(st)
.and_then(|st| pick(st).iter().find(|l| l.label == label)) .into_iter()
.map(|l| l.amount) .filter(|r| r.0 == key)
.unwrap_or_default(), .map(|r| r.2)
), .sum()
}),
drill: id.map(|account| Drill { drill: id.map(|account| Drill {
account, account,
start: *s, start: *s,
@@ -656,36 +657,68 @@ fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table {
}), }),
}) })
.collect(); .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.section("Operating activities");
t.row(Kind::Line, "Net income", val(&|s| s.net_income)); keyed(&mut t, Kind::Line, &|s| {
t.row( s.operating_by_class()
Kind::Line, .into_iter()
"Depreciation (non-cash)", .map(|(c, a)| (c.label().to_string(), c.label().to_string(), a, None))
val(&|s| s.depreciation), .collect()
); });
lines(&mut t, &|s| &s.working_capital);
t.row( t.row(
Kind::Subtotal, Kind::Subtotal,
"Cash from operations", "Net cash from operating activities",
val(&|s| s.cash_from_operations), val(&|s| s.cash_from_operations),
); );
t.section("Investing activities"); for (sec, title, subtotal) in [
lines(&mut t, &|s| &s.investing); (
t.row( CashFlowSection::Investing,
Kind::Subtotal, "Investing activities",
"Cash from investing", "Net cash from investing activities",
val(&|s| s.cash_from_investing), ),
); (
t.section("Financing activities"); CashFlowSection::Financing,
lines(&mut t, &|s| &s.financing); "Financing activities",
t.row( "Net cash from financing activities",
Kind::Subtotal, ),
"Cash from financing", ] {
val(&|s| s.cash_from_financing), t.section(title);
); // Each kind with its total (D-35), then its transactions listed
// under it.
let mut kinds: Vec<String> = Vec::new();
for st in stmts.iter().flatten() {
for k in st.kinds(sec) {
if !kinds.contains(&k.kind) {
kinds.push(k.kind);
}
}
}
for kind in kinds {
keyed(&mut t, Kind::Line, &|s| {
s.kinds(sec)
.into_iter()
.filter(|k| k.kind == kind)
.map(|k| (k.kind.clone(), k.kind, k.amount, None))
.collect()
});
keyed(&mut t, Kind::Detail, &|s| {
s.lines(sec)
.iter()
.filter(|l| l.kind == kind)
.map(|l| (l.label.clone(), l.label.clone(), l.amount, one(&l.accounts)))
.collect()
});
}
t.row(Kind::Subtotal, subtotal, val(&|s| s.total(sec)));
}
t.row( t.row(
Kind::Grand, Kind::Grand,
"Net change in cash", "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 beginning", val(&|s| s.cash_beginning));
t.row(Kind::Line, "Cash at end", val(&|s| s.cash_ending)); t.row(Kind::Line, "Cash at end", val(&|s| s.cash_ending));
t.section("Reconciliation of net income to operating cash");
t.row(
Kind::Line,
"Net income (loss)",
val(&|s| s.operating_reconciliation.net_income),
);
// A balance's change is keyed by its account (review item 17): one
// row across the columns, whichever way it moved in each.
keyed(&mut t, Kind::Line, &|s| {
s.operating_reconciliation
.lines
.iter()
.map(|l| match (&l.kind, &l.account) {
(ReconcilingKind::OperatingBalanceChange, Some(code)) => {
let liability = b
.ledger
.chart()
.find_by_code(code)
.is_some_and(|a| a.account_type != AccountType::Asset);
let name = b
.ledger
.chart()
.find_by_code(code)
.map_or(code.clone(), |a| format!("{} {}", a.code, a.name));
let label = if liability {
format!("Increase (decrease) in {name}")
} else {
format!("(Increase) decrease in {name}")
};
(
format!("change {code}"),
label,
l.amount,
Some(code.clone()),
)
}
_ => (
l.label.clone(),
l.label.clone(),
l.amount,
l.account.clone(),
),
})
.collect()
});
t.row(
Kind::Subtotal,
"Net cash from operating activities",
val(&|s| s.operating_reconciliation.net_cash_from_operating),
);
t.section("Noncash investing and financing activities");
let any = stmts
.iter()
.flatten()
.any(|s| !s.noncash_disclosures.is_empty());
if any {
keyed(&mut t, Kind::Line, &|s| {
s.noncash_disclosures
.iter()
.map(|n| (n.label.clone(), n.label.clone(), n.amount, None))
.collect()
});
} else {
t.note("None");
}
let mut notes: Vec<String> = Vec::new();
for st in &stmts {
let note = match st {
Err(why) => format!("This statement can't be shown: {why}."),
Ok(s) => {
for w in &s.warnings {
let n = format!("Note: {}", w.message);
if !notes.contains(&n) {
notes.push(n);
}
}
continue;
}
};
if !notes.contains(&note) {
notes.push(note);
}
}
for n in notes {
t.note(n);
}
if cols.len() > 1 {
t.note(
"An entry and its reversal in the same column drop out of it; in different columns each shows, and the Total nets them.",
);
}
let names: Vec<String> = b let names: Vec<String> = b
.settings
.cash_accounts .cash_accounts
.iter() .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(); .collect();
t.note(format!("Cash accounts: {}.", names.join(", "))); if !names.is_empty() {
t.note(format!("Cash accounts: {}.", names.join(", ")));
}
t 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()); 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); ui.painter().rect_filled(rr, CornerRadius::same(4), p.hover);
} }
let (font, color, indent) = match r.kind { let (font, color, indent) = match r.kind {
Kind::Section => (theme::semibold(13.5), p.text, 0.0), Kind::Section => (theme::semibold(13.5), p.text, 0.0),
Kind::Line => (theme::regular(13.5), p.text, 18.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::Subtotal => (theme::semibold(13.5), p.text, 0.0),
Kind::Grand => (theme::bold(14.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), Kind::Note => (theme::regular(12.0), p.muted, 0.0),
@@ -2245,6 +2381,318 @@ mod tests {
); );
} }
/// A two-member LLC's first weeks (Member A and Member B, generic
/// vendors, every amount and date made up): cash contributions,
/// expenses the members paid personally (borrowed and spent, D-42) and
/// their reimbursement, property contributed (one contribution
/// reversed), a card, and a bank loan left out of the financing
/// setting. Settings journaled: cash 1000, financing 2100, the members
/// on 3000 and 3001.
fn two_member_llc_shape(tag: &str) -> (crate::test_support::TempDir, Books) {
use crate::test_support::{Build, d};
let mut b = Build::new(tag);
let mut add =
|code: &str, name: &str, ty| b.s.add_account(code, name, ty, None, false).unwrap();
let equipment = add("1500", "Equipment", AccountType::Asset);
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
let card = add("2050", "Business Card", AccountType::Liability);
let due = add("2100", "Due to Members", AccountType::Liability);
let loan = add("2500", "Bank Loan", AccountType::Liability);
let member_b = add("3001", "Capital — Member B", AccountType::Equity);
let depr = add("6500", "Depreciation Expense", AccountType::Expense);
let toml = "[company]\nname = \"Acme LLC\"\n\
[[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
[books]\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n";
let import =
ledger_config::codec::import(toml, None, b.s.ledger().settings(), "USD").unwrap();
b.s.adopt_settings(
&import.changes,
&ledger_core::settings::Source {
decided_by: "the test".into(),
decided_on: d(2026, 1, 1),
citation: None,
note: None,
},
)
.unwrap();
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post_as("Formation filing fee", d(2026, 3, 2), rent, due, 8_500);
b.post_as(
"Contribution — Member A",
d(2026, 3, 9),
cash,
capital,
120_000,
);
b.post_as(
"Contribution — Member B",
d(2026, 3, 10),
cash,
member_b,
80_000,
);
b.post_as("Subscription", d(2026, 3, 12), rent, due, 21_640);
b.post_as("Reimburse members", d(2026, 3, 23), due, cash, 30_140);
b.post_as("API credits", d(2026, 3, 25), rent, card, 1_420);
let laptop = b.post_as(
"Laptop contributed",
d(2026, 3, 27),
equipment,
capital,
148_000,
);
b.post_as(
"Tablet contributed",
d(2026, 3, 27),
equipment,
member_b,
63_000,
);
b.post_as("Bank loan", d(2026, 3, 27), cash, loan, 250_000);
b.s.register_asset(ledger_core::NewAsset {
description: "Tablet".into(),
purchase_date: d(2026, 3, 27),
cost: Money::new(Decimal::new(63_000, 2), ledger_core::Currency::USD),
salvage_value: Money::new(Decimal::ZERO, ledger_core::Currency::USD),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
b.s.reverse_entry(laptop, d(2026, 3, 27), "Contributed jointly instead")
.unwrap();
b.books()
}
use ledger_core::Money;
/// The viewer's cash flow is the server's statement (D1-f: no guess
/// from account names), laid out as printed: operating by class,
/// investing and financing by kind with each transaction under it,
/// the reconciliation and the noncash activities. The bank loan the
/// financing setting leaves out is operating in both.
#[test]
fn the_cash_flow_page_is_the_servers_statement() {
let (_dir, b) = two_member_llc_shape("cf-server");
let cols = vec![(
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 27).unwrap(),
"Total".to_string(),
)];
let t = cash_flow_table(&b, &cols, false);
let settings = ledger_package::Settings {
company: &b.settings.company,
cash_accounts: &b.settings.cash_accounts,
financing_accounts: &b.settings.financing_accounts,
investing_accounts: &b.settings.investing_accounts,
};
let cf = ledger_package::cash_flow_for(
&b.ledger,
&settings,
Some("2026-01-01"),
Some("2026-03-27"),
&[],
&[],
&[],
b.today,
)
.unwrap();
let rows: Vec<(String, Option<Decimal>)> = t
.rows
.iter()
.filter(|r| r.kind != Kind::Section)
.map(|r| (r.label.clone(), r.cells.first().and_then(|c| c.value)))
.collect();
let mut want: Vec<(String, Option<Decimal>)> = Vec::new();
for (c, a) in cf.operating_by_class() {
want.push((c.label().into(), Some(a)));
}
want.push((
"Net cash from operating activities".into(),
Some(cf.cash_from_operations),
));
for (sec, sub) in [
(
ledger_core::CashFlowSection::Investing,
"Net cash from investing activities",
),
(
ledger_core::CashFlowSection::Financing,
"Net cash from financing activities",
),
] {
for k in cf.kinds(sec) {
want.push((k.kind.clone(), Some(k.amount)));
for l in cf.lines(sec).iter().filter(|l| l.kind == k.kind) {
want.push((l.label.clone(), Some(l.amount)));
}
}
want.push((sub.into(), Some(cf.total(sec))));
}
want.push(("Net change in cash".into(), Some(cf.net_change_in_cash)));
want.push(("Cash at beginning".into(), Some(cf.cash_beginning)));
want.push(("Cash at end".into(), Some(cf.cash_ending)));
let r = &cf.operating_reconciliation;
want.push(("Net income (loss)".into(), Some(r.net_income)));
for l in &r.lines {
let label = match l.kind {
ledger_core::ReconcilingKind::OperatingBalanceChange => {
let a = b
.ledger
.chart()
.find_by_code(l.account.as_deref().unwrap())
.unwrap();
if a.account_type == AccountType::Asset {
format!("(Increase) decrease in {} {}", a.code, a.name)
} else {
format!("Increase (decrease) in {} {}", a.code, a.name)
}
}
_ => l.label.clone(),
};
want.push((label, Some(l.amount)));
}
want.push((
"Net cash from operating activities".into(),
Some(r.net_cash_from_operating),
));
for n in &cf.noncash_disclosures {
want.push((n.label.clone(), Some(n.amount)));
}
want.push(("Cash accounts: 1000 Cash.".into(), None));
assert_eq!(rows, want);
// Each investing and financing kind is a line with its total; its
// transactions are details under it, never summed into the section.
let kind_of = |label: &str| t.rows.iter().find(|r| r.label == label).map(|r| r.kind);
for sec in [
ledger_core::CashFlowSection::Investing,
ledger_core::CashFlowSection::Financing,
] {
for k in cf.kinds(sec) {
assert!(kind_of(&k.kind) == Some(Kind::Line), "{}", k.kind);
}
for l in cf.lines(sec) {
assert!(kind_of(&l.label) == Some(Kind::Detail), "{}", l.label);
}
}
// The figures, by hand: the loan (not a financing setting) is an
// operating receipt, 2,500.00; what the members paid personally
// (85.00 and 216.40, 301.40) is borrowed from them through 2100
// and spent (D-42, rules.md B-14 rule 11); the members'
// contributions are financing, each named (1,200.00 and 800.00);
// the reimbursement repays 2100 (301.40); the tablet is disclosed
// (630.00), the reversed laptop gone.
assert_eq!(
cf.operating_by_class(),
[
(
ledger_core::OperatingClass::OtherReceipts,
Decimal::new(250_000, 2)
),
(
ledger_core::OperatingClass::ToSuppliersAndEmployees,
Decimal::new(-30_140, 2)
),
]
);
let kinds: Vec<(String, Decimal)> = cf
.kinds(ledger_core::CashFlowSection::Financing)
.into_iter()
.map(|k| (k.kind, k.amount))
.collect();
assert_eq!(
kinds,
[
(
"Contributions from members — Member A".to_string(),
Decimal::new(120_000, 2)
),
(
"Contributions from members — Member B".to_string(),
Decimal::new(80_000, 2)
),
(
"Proceeds from borrowing — 2100 Due to Members".to_string(),
Decimal::new(30_140, 2)
),
(
"Repayments of borrowing — 2100 Due to Members".to_string(),
Decimal::new(-30_140, 2)
),
]
);
let disclosed: Vec<Decimal> = cf.noncash_disclosures.iter().map(|n| n.amount).collect();
assert_eq!(disclosed, [Decimal::new(63_000, 2)]);
assert!(
!t.rows
.iter()
.any(|r| r.label.contains("Bank Loan") && r.label.contains("borrowing"))
);
}
/// A statement the server refuses is refused here too: blank cells
/// and the reason, never a number.
#[test]
fn a_refused_cash_flow_shows_why_and_no_number() {
use crate::test_support::{Build, d};
let mut b = Build::new("cf-refused");
let (cash, capital) = (b.cash, b.capital);
b.post(d(2026, 10, 7), cash, capital, 50_000);
let (_dir, books) = b.books();
let cols = vec![(d(2026, 1, 1), d(2026, 10, 15), "Total".to_string())];
let t = cash_flow_table(&books, &cols, false);
assert!(
t.rows
.iter()
.flat_map(|r| &r.cells)
.all(|c| c.value.is_none())
);
assert!(
t.rows
.iter()
.any(|r| r.kind == Kind::Note && r.label.contains("cash_accounts is required")),
"{:?}",
t.rows.iter().map(|r| &r.label).collect::<Vec<_>>()
);
}
/// The page as painted (and, with `SHOT_DIR` set, as a PNG for eyes:
/// `cashflow-two-member-llc-shape.png`).
#[test]
fn the_cash_flow_page_paints_its_schedules() {
use crate::test_support::{find, harness, painted};
let (_dir, b) = two_member_llc_shape("cf-paint");
let mut h = harness(b, Route::Reports(ReportKind::CashFlow));
h.state_mut().state_mut().reports.range = Range::Custom(
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 27).unwrap(),
);
h.run_steps(4);
let texts = painted(h.output());
for text in [
"Operating activities",
"Other operating cash receipts",
"Cash paid to suppliers and employees",
"Contributions from members — Member A",
"Proceeds from borrowing — 2100 Due to Members",
"Repayments of borrowing — 2100 Due to Members",
"Reconciliation of net income to operating cash",
"Noncash investing and financing activities",
"Tablet contributed (#8)",
] {
find(&texts, text);
}
if let Ok(dir) = std::env::var("SHOT_DIR") {
let dir = std::path::PathBuf::from(dir);
std::fs::create_dir_all(&dir).unwrap();
let img = h.render().unwrap();
img.save(dir.join("cashflow-two-member-llc-shape.png"))
.unwrap();
}
}
/// Every table-backed report typesets as a PDF, in its plain, by-month, /// 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. /// and compared layouts. Set `PDF_DIR` to keep the files for eyes.
#[test] #[test]
+13 -12
View File
@@ -65,6 +65,7 @@ pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
RowKind::Section => "section", RowKind::Section => "section",
RowKind::Subheading => "subheading", RowKind::Subheading => "subheading",
RowKind::Line => "line", RowKind::Line => "line",
RowKind::Detail => "detail",
RowKind::Subtotal => "subtotal", RowKind::Subtotal => "subtotal",
RowKind::Total => "total", RowKind::Total => "total",
RowKind::Spacer => "spacer", RowKind::Spacer => "spacer",
@@ -146,7 +147,7 @@ mod tests {
ReportMeta { ReportMeta {
company: "Test Co".to_string(), company: "Test Co".to_string(),
basis: "Accrual".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, journal_seq: 42,
} }
} }
@@ -158,15 +159,15 @@ mod tests {
amount: Decimal::new(cents, 2), amount: Decimal::new(cents, 2),
}; };
ProfitAndLossStatement { ProfitAndLossStatement {
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(), start: NaiveDate::from_ymd_opt(2026, 3, 2).unwrap(),
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(), end: NaiveDate::from_ymd_opt(2026, 3, 18).unwrap(),
revenue: vec![], revenue: vec![],
expenses: vec![ expenses: vec![
line("6000", "Organizational Costs", 5900), line("6000", "Organizational Costs", 7200),
line("6400", "Memberships & Licenses", 10638), line("6400", "Memberships & Licenses", 11840),
], ],
total_revenue: Decimal::ZERO, 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!({ serde_json::json!({
"company": "Test Co", "company": "Test Co",
"title": "Profit and Loss", "title": "Profit and Loss",
"period": "October 4 – October 11, 2026", "period": "March 2 – March 18, 2026",
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42", "footer": "Accrual basis · Prepared Thursday, March 19, 2026 · Journal seq 42",
"columns": ["Total"], "columns": ["Total"],
"rows": [ "rows": [
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []}, {"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)}, {"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []}, {"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "section", "label": "Expenses", "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": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("72.00", false)},
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", 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("165.38", false)}, {"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("190.40", false)},
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []}, {"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)}, {"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("190.40", true)},
], ],
}) })
); );
+85 -62
View File
@@ -14,7 +14,7 @@
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
use ledger_core::RETAINED_EARNINGS_CODE; use ledger_core::RETAINED_EARNINGS_CODE;
use ledger_core::reports::{ use ledger_core::reports::{
BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement, BalanceSheetStatement, CashFlowSection, CashFlowStatement, MembersEquityStatement,
ProfitAndLossStatement, StatementLine, TrialBalance, ProfitAndLossStatement, StatementLine, TrialBalance,
}; };
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -67,6 +67,10 @@ pub enum RowKind {
Subheading, Subheading,
/// One account or one flow, indented under its section. /// One account or one flow, indented under its section.
Line, 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"). /// A section total ("Total revenue").
Subtotal, Subtotal,
/// A statement total ("Net income (loss)"); double rule beneath. /// A statement total ("Net income (loss)"); double rule beneath.
@@ -150,7 +154,10 @@ pub fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Ro
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total); r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
r.rule_above = match r.kind { r.rule_above = match r.kind {
RowKind::Total => true, RowKind::Total => true,
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)), RowKind::Subtotal => matches!(
above,
Some(RowKind::Line | RowKind::Detail | RowKind::Subtotal)
),
_ => false, _ => false,
}; };
if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) { 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. /// Lay out a statement of cash flows, direct method (rules.md B-14, D-35):
fn flow_lines<'a>( /// operating cash one row per class ASC 230-10-45-25 requires; investing
lines: &'a [CashFlowLine], /// and financing one row per kind ("Contributions from members — Name"),
members: &'a [Member], /// each with its total and its transactions listed under it; then the
) -> impl Iterator<Item = Row> + 'a { /// reconciliation of net income to operating cash, a separate schedule
lines.iter().map(move |l| { /// (45-30), and the noncash investing and financing activities (50-3).
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).
/// ///
/// ```text /// ```text
/// GOAL: the cash flow statement as printed, footing and reconciling. /// GOAL: the statement of cash flows as printed, footing and reconciling.
/// SOURCE: rules.md B-14 (ASC 230); footing. /// 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: /// INVARIANTS:
/// 1. Net income, depreciation, and the working-capital lines sum to /// 1. The operating class rows sum to "Net cash from operating
/// "Net cash from operating activities"; investing and financing lines /// activities"; each other section's kind rows to its own subtotal,
/// to theirs. /// and each kind's transaction rows (details, never summed into the
/// 2. The three section totals sum to "Net increase (decrease) in cash", /// section) to the kind's row.
/// and beginning cash plus it is "Cash at end of period". /// 2. The three subtotals sum to "Net increase (decrease) in cash", and
/// 3. A member's capital or distributions line names the member: /// beginning cash plus it is "Cash at end of period".
/// "Member contributions — Name" for cash in, "Member distributions /// 3. "Net income (loss)" and the reconciling items sum to the
/// — Name" for cash out. /// 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 { pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
let mut rows = vec![section("Operating activities")]; let mut rows = vec![section(CashFlowSection::Operating.label())];
rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income)); for (class, amount) in cf.operating_by_class() {
if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() { rows.push(one(RowKind::Line, class.label(), amount));
rows.push(subheading(
"Adjustments to reconcile net income (loss) to net cash from operating activities:",
));
} }
if !cf.depreciation.is_zero() {
rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
}
rows.extend(flow_lines(&cf.working_capital, members));
rows.push(one( rows.push(one(
RowKind::Subtotal, RowKind::Subtotal,
"Net cash from operating activities", "Net cash from operating activities",
cf.cash_from_operations, cf.cash_from_operations,
)); ));
rows.push(spacer()); for (sec, subtotal) in [
rows.push(section("Investing activities")); (
rows.extend(flow_lines(&cf.investing, members)); CashFlowSection::Investing,
rows.push(one( "Net cash from investing activities",
RowKind::Subtotal, ),
"Net cash from investing activities", (
cf.cash_from_investing, CashFlowSection::Financing,
)); "Net cash from financing activities",
rows.push(spacer()); ),
rows.push(section("Financing activities")); ] {
rows.extend(flow_lines(&cf.financing, members)); rows.push(spacer());
rows.push(one( rows.push(section(sec.label()));
RowKind::Subtotal, // Each kind with its total, its transactions listed under it (the
"Net cash from financing activities", // engine lists a kind's lines together, in the kinds' order).
cf.cash_from_financing, 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(spacer());
rows.push(one( rows.push(one(
RowKind::Subtotal, RowKind::Subtotal,
@@ -389,6 +387,31 @@ pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement {
cf.cash_beginning, cf.cash_beginning,
)); ));
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending)); 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(
"Statement of Cash Flows", "Statement of Cash Flows",
period_label(cf.start, cf.end), 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 /// 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 /// ```text
/// GOAL: an amount as an accountant writes it. /// 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("-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("5.8")), "5.80");
assert_eq!(format_amount(dec("207.68")), "207.68"); assert_eq!(format_amount(dec("318.42")), "318.42");
assert_eq!(format_amount(dec("-207.68")), "(207.68)"); assert_eq!(format_amount(dec("-318.42")), "(318.42)");
assert_eq!(format_amount(dec("1000")), "1,000.00"); 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("1234567.891")), "1,234,567.89");
assert_eq!(format_amount(dec("-100000")), "(100,000.00)"); assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
+11
View File
@@ -58,6 +58,8 @@ pub(crate) struct Formats {
pub(crate) dollars_total: Format, pub(crate) dollars_total: Format,
pub(crate) line_label: Format, pub(crate) line_label: Format,
pub(crate) sub_label: Format, pub(crate) sub_label: Format,
pub(crate) detail_label: Format,
pub(crate) detail_amount: Format,
pub(crate) note: Format, pub(crate) note: Format,
} }
@@ -85,6 +87,14 @@ impl Formats {
.set_border_bottom(FormatBorder::Double), .set_border_bottom(FormatBorder::Double),
line_label: Format::new().set_indent(1), line_label: Format::new().set_indent(1),
sub_label: Format::new().set_italic().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() note: Format::new()
.set_italic() .set_italic()
.set_font_size(9) .set_font_size(9)
@@ -225,6 +235,7 @@ fn statement(
RowKind::Section => (&f.bold, &f.plain), RowKind::Section => (&f.bold, &f.plain),
RowKind::Subheading => (&f.sub_label, &f.plain), RowKind::Subheading => (&f.sub_label, &f.plain),
RowKind::Line => (&f.line_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 if r.rule_above => (&f.bold, &f.dollars_sum),
RowKind::Subtotal => (&f.bold, &dollars_unruled), RowKind::Subtotal => (&f.bold, &dollars_unruled),
RowKind::Total => (&f.bold, &f.dollars_total), RowKind::Total => (&f.bold, &f.dollars_total),
+8 -4
View File
@@ -7,8 +7,8 @@
// parsed as markup. // parsed as markup.
// //
// Shape: { company, title, period, footer, columns: ["Total"] | [...], // Shape: { company, title, period, footer, columns: ["Total"] | [...],
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal" // rows: [{ kind: "section" | "subheading" | "line" | "detail"
// | "total" | "spacer", // | "subtotal" | "total" | "spacer",
// label, rule (rule above), currency ("$" on amounts), // label, rule (rule above), currency ("$" on amounts),
// amounts: [null | { digits: "1,234.56", negative }] }] } // amounts: [null | { digits: "1,234.56", negative }] }] }
// //
@@ -72,11 +72,15 @@
#let row-cells(r) = { #let row-cells(r) = {
let bold = r.kind == "subtotal" or r.kind == "total" 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 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 } let label = if bold { text(weight: "bold", label) } else { label }
( (
table.cell(stroke: stroke, label), table.cell(stroke: stroke, label),
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))), ..r.amounts.map(a => table.cell(stroke: stroke, quietly(amount(a, r.currency)))),
) )
} }
+11 -11
View File
@@ -116,9 +116,9 @@ fn sample() -> AssetRegister {
// A member's personal camera: worth less than its basis, so tax // 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. // basis is the fair market value and there's a built-in loss.
contributed( contributed(
row(4, "FL-0004", "Sony A7 IV camera", 165000, 0), row(4, "FL-0004", "Sony A7 IV camera", 175000, 0),
TaxBasisRule::PersonalUseConversion, TaxBasisRule::PersonalUseConversion,
191721, 200000,
), ),
// No tax basis recorded yet. // No tax basis recorded yet.
row(5, "FL-0005", "Standing desk", 80000, 0), row(5, "FL-0005", "Standing desk", 80000, 0),
@@ -172,9 +172,9 @@ fn pdf_shows_assets_totals_and_reconciliation() {
"Active", "Active",
"Disposed 2026-09-30, proceeds 45.00", "Disposed 2026-09-30, proceeds 45.00",
"Total, assets held", "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 "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 register",
"Cost per books", "Cost per books",
"Difference (unregistered cost)", "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. // The disposed asset's cost (400.00) is shown but in no total.
assert!(t.contains("400.00")); assert!(t.contains("400.00"));
let l = layout(&sample()); 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 /// 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", "Personal-use conversion",
"900.00", // MacBook: contributor's basis = tax basis "900.00", // MacBook: contributor's basis = tax basis
"399.00", // its built-in gain "399.00", // its built-in gain
"1,917.21", // camera: contributor's basis "2,000.00", // camera: contributor's basis
"1,650.00", // its tax basis: the fair market value "1,750.00", // its tax basis: the fair market value
"(267.21)", // its built-in loss "(250.00)", // its built-in loss: 1,750.00 - 2,000.00
"Not recorded", "Not recorded",
"Total tax basis, assets held", "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", "Tax basis not recorded for 1 asset",
"Notes", "Notes",
"Book value is the fair market value at contribution", "Book value is the fair market value at contribution",
@@ -227,7 +227,7 @@ fn pdf_shows_tax_basis() {
} }
let l = layout(&r); let l = layout(&r);
assert_eq!(l.tax_total, r.total_tax_basis); 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!( assert_eq!(
l.missing.as_deref(), l.missing.as_deref(),
Some("Tax basis not recorded for 1 asset") 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", "(Ryzen 7 7840U, 64 GB, 4 TB) Cost 1 2,500.00",
"MacBook Air Carryover 2 1,299.00", "MacBook Air Carryover 2 1,299.00",
"Old monitor Cost 1 400.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", "Standing desk Not recorded 800.00",
"1 Cost: property the company bought", "1 Cost: property the company bought",
"2 Carryover (IRC §723): property a member contributes", "2 Carryover (IRC §723): property a member contributes",
+119 -63
View File
@@ -245,7 +245,9 @@ fn assert_sections_foot(st: &Statement) -> Vec<(String, String)> {
} }
sums = vec![Decimal::ZERO; st.columns.len()]; 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 checked
@@ -315,7 +317,7 @@ fn assert_accounting_shape(s: &str) {
#[test] #[test]
fn format_amount_examples_from_the_spec() { fn format_amount_examples_from_the_spec() {
assert_eq!(format_amount(dec("1234.56")), "1,234.56"); 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"); assert_eq!(format_amount(Decimal::ZERO), "0.00");
// INVARIANT 2's own finding: -0.004 printed "(0.00)". // INVARIANT 2's own finding: -0.004 printed "(0.00)".
assert_eq!(format_amount(dec("-0.004")), "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, /// cash_flow INVARIANTS 1-5 (direct method, rules.md B-14, D-35): the
/// working capital) foot to "Net cash from operating activities", /// operating class rows foot to "Net cash from operating activities",
/// investing and financing to theirs; the three sum to "Net increase /// each other section's kind rows to theirs, and each kind's
/// (decrease) in cash"; beginning cash plus it is "Cash at end of /// transaction details to its kind's row; the three sum to
/// period"; a member's capital/distributions line is "Member /// "Net increase (decrease) in cash"; beginning cash plus it is "Cash
/// contributions — Name" (cash in) or "Member distributions — Name" /// at end of period"; net income and the reconciling items foot to the
/// (cash out). /// 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] #[test]
fn cash_flow_foots_and_reconciles((specs, oe, end_off) in books_strategy()) { fn cash_flow_foots_and_reconciles((specs, oe, end_off) in books_strategy()) {
let b = build_books(&specs, oe); let b = build_books(&specs, oe);
@@ -569,14 +573,13 @@ proptest! {
cash_accounts: vec![b.ids[0], b.ids[1]], cash_accounts: vec![b.ids[0], b.ids[1]],
financing_accounts: vec![b.ids[5]], financing_accounts: vec![b.ids[5]],
investing_accounts: vec![b.ids[3]], investing_accounts: vec![b.ids[3]],
members: member_capitals(&b),
}).unwrap(); }).unwrap();
let ms = members(); let st = report::cash_flow(&cf);
let st = report::cash_flow(&cf, &ms);
let checked = assert_sections_foot(&st); 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"); 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[..3].iter().map(|(_, t)| amt(st.rows.iter().find(|r| &r.label == t).unwrap(), 0)).sum();
let three: Decimal = checked.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"); 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); 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"); 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(op, 0), cf.cash_from_operations);
prop_assert_eq!(amt(begin, 0), cf.cash_beginning); prop_assert_eq!(amt(begin, 0), cf.cash_beginning);
prop_assert_eq!(amt(endrow, 0), cf.cash_ending); prop_assert_eq!(amt(endrow, 0), cf.cash_ending);
// INVARIANT 3: members named on their capital/distributions lines. // INVARIANT 3: the schedule closes on operating's figure.
let mut fin: Vec<(String, Decimal)> = section_lines(&st, "Financing").iter().map(|r| (r.label.clone(), amt(r, 0))).collect(); let (sched, sched_total) = &checked[3];
for line in &cf.financing { prop_assert!(sched.starts_with("Reconciliation of net income"));
let Some(code) = &line.account else { continue }; let sched_row = st.rows.iter().filter(|r| &r.label == sched_total).nth(1).expect("schedule subtotal");
let Some(m) = ms.iter().find(|m| &m.capital_code == code || m.distribution_code.as_ref() == Some(code)) else { continue }; prop_assert_eq!(amt(sched_row, 0), cf.cash_from_operations);
let want = if line.amount > Decimal::ZERO { // INVARIANT 4: every row is the engine's, in its order.
format!("Member contributions — {}", m.name) let ops: Vec<(String, Decimal)> = section_lines(&st, "Operating").iter().map(|r| (r.label.clone(), amt(r, 0))).collect();
} else { let want: Vec<(String, Decimal)> = cf.operating_by_class().into_iter().map(|(c, a)| (c.label().to_string(), a)).collect();
format!("Member distributions — {}", m.name) prop_assert_eq!(ops, want);
}; // Investing and financing: each kind a line with its total (D-35),
let pos = fin.iter().position(|(l, a)| *l == want && *a == line.amount); // its transactions listed under it as details that sum to it.
prop_assert!(pos.is_some(), "no {:?} {} among {:?}", want, line.amount, fin); for (sec, name) in [(ledger_core::CashFlowSection::Investing, "Investing"), (ledger_core::CashFlowSection::Financing, "Financing")] {
fin.remove(pos.unwrap()); 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("neg max exact", "-9999999999999.99"),
line("sixteen digits", "12345678901234.56"), line("sixteen digits", "12345678901234.56"),
line("neg sixteen digits", "-12345678901234.56"), line("neg sixteen digits", "-12345678901234.56"),
line("plain", "-207.68"), line("plain", "-318.42"),
], ],
); );
check_xlsx(&st); check_xlsx(&st);
@@ -3049,6 +3085,7 @@ fn xlsx_renders_every_statement_and_ledger_report() {
cash_accounts: vec![b.ids[0]], cash_accounts: vec![b.ids[0]],
financing_accounts: vec![], financing_accounts: vec![],
investing_accounts: vec![], investing_accounts: vec![],
members: member_capitals(&b),
}, },
) )
.unwrap(); .unwrap();
@@ -3056,7 +3093,7 @@ fn xlsx_renders_every_statement_and_ledger_report() {
for st in [ for st in [
report::profit_and_loss(&pl), report::profit_and_loss(&pl),
report::balance_sheet(&bs, ye, &members()), 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::trial_balance(&trial_balance_as_of(&b.ledger, e), e),
report::members_equity(&me), 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 /// The reconciliation's "Adjustments:" subheading appears only when there
/// only when there is something to adjust. /// 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] #[test]
fn cash_flow_adjustments_appear_only_when_there_are_some() { fn cash_flow_adjustments_appear_only_when_there_are_some() {
let mk = |depreciation: Decimal, wc: Vec<ledger_core::CashFlowLine>| { let mut l = Ledger::new(Currency::USD);
ledger_core::CashFlowStatement { let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
start: d(2026, 1, 1), let cash = add("1000", AccountType::Asset);
end: d(2026, 1, 31), let equip = add("1500", AccountType::Asset);
net_income: cents(1_000), let accum = add("1510", AccountType::Asset);
depreciation, let cap = add("3000", AccountType::Equity);
cash_from_operations: cents(1_000) let rev = add("4000", AccountType::Revenue);
+ depreciation let depr = add("6100", AccountType::Expense);
+ wc.iter().map(|l| l.amount).sum::<Decimal>(), let post = |l: &mut Ledger, day, desc: &str, dr, cr, n: i64| {
working_capital: wc, let e = l.create_entry(d(2026, 1, day), desc);
investing: vec![], l.add_line(e, dr, Side::Debit, usd(n)).unwrap();
cash_from_investing: Decimal::ZERO, l.add_line(e, cr, Side::Credit, usd(n)).unwrap();
financing: vec![], l.post_entry(e).unwrap();
cash_from_financing: Decimal::ZERO, };
net_change_in_cash: Decimal::ZERO, post(&mut l, 2, "Sale", cash, rev, 1_000);
cash_beginning: Decimal::ZERO, let st = |l: &Ledger| {
cash_ending: Decimal::ZERO, 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 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")); 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")); assert!(has(&dep, "Adjustments") && has(&dep, "Depreciation"));
let wc = report::cash_flow( let noncash = section_lines(&dep, "Noncash");
&mk( assert_eq!(noncash.len(), 1);
Decimal::ZERO, assert_eq!(noncash[0].label, "Laptop contributed (#2)");
vec![ledger_core::CashFlowLine { assert_eq!(noncash[0].amounts, vec![Some(cents(900))]);
label: "Increase in AR".into(),
amount: cents(-50),
account: None,
}],
),
&[],
);
assert!(has(&wc, "Adjustments") && !has(&wc, "Depreciation"));
} }
/// A template takes at most 90 owners. /// A template takes at most 90 owners.
+1
View File
@@ -4,3 +4,4 @@
- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart; add a missing one with books.add_account first, never guess one. - 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. - 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). - Name the entry's party when you draft it (books.draft_entry party), so the preview can show the split (the parties-and-tins section).
- An expense or purchase a member pays personally goes to the expense or asset against the members' financing account (Due to Members), the vendor named as the party; the cash flow statement shows it as borrowed from the member and spent. What the company owes a member for their own work or property (rent, a guaranteed payment) goes to a payable instead.
@@ -1,6 +1,7 @@
# Tracing figures # 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. - 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. - 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. - 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. - A member's odd cent: closes, the statement of members' equity, the K-1 worksheet and the viewer split earnings among the members one running way. Each close gives a member their share of everything closed to the same members and shares so far, to the cent (odd cents by largest remainder, ties to the member listed first when that count began), less what earlier closes gave them, so no member is ever a cent from their exact share. Earnings not yet closed are split the way their close will split them, so a member's capital on a day never changes when its month is closed. books.close_period's members shows each member's part, running total and exact share; the first close that posts may carry a one-time correction of the rounding left by closes made under an earlier rule (a cent or so per member; each member's this_close shows it) (books.list_periods' close_rule names each close's rule). A change of members or shares starts a new count.
+262 -122
View File
@@ -1245,6 +1245,46 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
} else { } else {
" in the settings" " in the settings"
}; };
// The cash flow roles: codes the chart lacks here; the rest is the
// engine's own check, the one the statement refuses on (rules.md B-14
// rule 2), so the two never disagree.
let lists = [
(
ledger_core::RoleList::Cash,
label("books.cash_accounts", "[books] cash_accounts"),
&cfg.roles.cash,
),
(
ledger_core::RoleList::Financing,
label("books.financing_accounts", "[books] financing_accounts"),
&cfg.roles.financing,
),
(
ledger_core::RoleList::Investing,
label("books.investing_accounts", "[books] investing_accounts"),
&cfg.roles.investing,
),
];
let mut ids: [Vec<ledger_core::AccountId>; 3] = Default::default();
for ((_, setting, codes), ids) in lists.iter().zip(&mut ids) {
for code in codes.iter() {
match ledger.chart().find_by_code(code) {
Some(a) => ids.push(a.id),
None => out.push(format!(
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
)),
}
}
}
let named = |l: ledger_core::RoleList| {
lists
.iter()
.find(|(list, _, _)| *list == l)
.map_or(String::new(), |(_, setting, _)| setting.to_string())
};
for p in ledger_core::role_problems(ledger, &ids[0], &ids[1], &ids[2]) {
out.push(format!("{}{how}", p.message(named)));
}
let mut check = |setting: &str, code: &str, want: &[AccountType]| { let mut check = |setting: &str, code: &str, want: &[AccountType]| {
match ledger.chart().find_by_code(code) { match ledger.chart().find_by_code(code) {
None => out.push(format!( None => out.push(format!(
@@ -1260,27 +1300,7 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
} }
}; };
use AccountType::*; 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; let c = &cfg.company;
for m in &c.members { for m in &c.members {
check( check(
@@ -2798,7 +2818,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.cash_flow", name = "books.cash_flow",
title = "Cash flow statement", 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) annotations(read_only_hint = true, open_world_hint = false)
)] )]
async fn cash_flow( async fn cash_flow(
@@ -2816,25 +2836,7 @@ impl BooksServer {
&p.financing_accounts, &p.financing_accounts,
&p.investing_accounts, &p.investing_accounts,
)?; )?;
let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut { Ok(Json(cash_flow_out(&stmt)))
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),
}))
} }
#[tool( #[tool(
@@ -5330,7 +5332,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.export", name = "books.export",
title = "Export a report", 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) annotations(read_only_hint = true, open_world_hint = false)
)] )]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> { async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -5431,17 +5433,14 @@ impl BooksServer {
let earnings = self.year_earnings(ledger, bs.as_of)?; let earnings = self.year_earnings(ledger, bs.as_of)?;
report::balance_sheet(&bs, earnings, &members) report::balance_sheet(&bs, earnings, &members)
} }
"cash_flow" => report::cash_flow( "cash_flow" => report::cash_flow(&self.cash_flow_for(
&self.cash_flow_for( ledger,
ledger, p.start.as_deref(),
p.start.as_deref(), p.end.as_deref(),
p.end.as_deref(), &p.cash_accounts,
&p.cash_accounts, &p.financing_accounts,
&p.financing_accounts, &p.investing_accounts,
&p.investing_accounts, )?),
)?,
&members,
),
"trial_balance" => { "trial_balance" => {
let as_of = match &p.as_of { let as_of = match &p.as_of {
Some(s) => parse_date(s)?, Some(s) => parse_date(s)?,
@@ -8098,7 +8097,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.export_cpa_package", name = "books.export_cpa_package",
title = "CPA handoff 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( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -8602,78 +8601,219 @@ impl BooksServer {
&p.investing_accounts, &p.investing_accounts,
)?; )?;
if format == "json" { if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({ // The tool's own answer, from the one function (they can't
"start": stmt.start.to_string(), "end": stmt.end.to_string(), // drift apart).
"net_income": money(stmt.net_income), return serde_json::to_string_pretty(&cash_flow_out(&stmt)).map_err(|e| e.to_string());
"depreciation": money(stmt.depreciation),
"working_capital": stmt.working_capital.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_operations": money(stmt.cash_from_operations),
"investing": stmt.investing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_investing": money(stmt.cash_from_investing),
"financing": stmt.financing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_financing": money(stmt.cash_from_financing),
"net_change_in_cash": money(stmt.net_change_in_cash),
"cash_beginning": money(stmt.cash_beginning),
"cash_ending": money(stmt.cash_ending),
}))
.map_err(|e| e.to_string());
} }
let mut csv = String::from("section,label,amount\n"); Ok(cash_flow_csv(&stmt))
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)
} }
} }
/// `books.cash_flow`'s answer, and `books.export`'s cash flow JSON.
fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut {
use ledger_core::CashFlowSection as S;
let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut {
entry: l.entry,
date: l.date.to_string(),
label: l.label.clone(),
kind: l.kind.clone(),
amount: money(l.amount),
on_behalf: (!l.on_behalf.is_zero()).then(|| money(l.on_behalf)),
accounts: l.accounts.clone(),
reverses: l.reverses,
class: l.class.map(|c| c.key().to_string()),
};
let kinds = |sec: S| {
stmt.kinds(sec)
.into_iter()
.map(|k| CashFlowKindOut {
kind: k.kind,
class: k.class.map(|c| c.key().to_string()),
amount: money(k.amount),
entries: k.entries,
})
.collect()
};
let r = &stmt.operating_reconciliation;
CashFlowOut {
method: "direct".to_string(),
start: stmt.start.to_string(),
end: stmt.end.to_string(),
operating_by_class: kinds(S::Operating),
operating: stmt.operating.iter().map(line).collect(),
cash_from_operations: money(stmt.cash_from_operations),
investing_by_kind: kinds(S::Investing),
investing: stmt.investing.iter().map(line).collect(),
cash_from_investing: money(stmt.cash_from_investing),
financing_by_kind: kinds(S::Financing),
financing: stmt.financing.iter().map(line).collect(),
cash_from_financing: money(stmt.cash_from_financing),
net_change_in_cash: money(stmt.net_change_in_cash),
cash_beginning: money(stmt.cash_beginning),
cash_ending: money(stmt.cash_ending),
noncash_disclosures: stmt
.noncash_disclosures
.iter()
.map(|n| NoncashDisclosureOut {
entry: n.entry,
date: n.date.to_string(),
label: n.label.clone(),
amount: money(n.amount),
operating: money(n.operating),
investing: money(n.investing),
financing: money(n.financing),
exchanged: money(n.exchanged),
reverses: n.reverses,
})
.collect(),
operating_reconciliation: OperatingReconciliationOut {
net_income: money(r.net_income),
lines: r
.lines
.iter()
.map(|l| ReconcilingLineOut {
kind: l.kind.key().to_string(),
label: l.label.clone(),
amount: money(l.amount),
account: l.account.clone(),
entries: l.entries.clone(),
})
.collect(),
net_cash_from_operating: money(r.net_cash_from_operating),
},
reconciles: CashFlowFootingOut {
sections_sum: money(stmt.reconciles.sections_sum),
net_change: money(stmt.reconciles.net_change),
difference: money(stmt.reconciles.difference),
},
warnings: stmt
.warnings
.iter()
.map(|w| CashFlowWarningOut {
entry: w.entry,
kind: w.kind.key().to_string(),
message: w.message.clone(),
})
.collect(),
}
}
/// `books.export`'s cash flow CSV:
/// `section,kind,class,entry,date,label,amount,on_behalf`, every line of
/// every section (`on_behalf`: of its amount, what a member or lender
/// paid or received for the company, D-42), the reconciliation, the
/// noncash disclosures, the footing and the warnings (`warning`, its
/// kind, its entry and its message; no amount). Only a section's lines
/// have an `on_behalf`.
fn cash_flow_csv(stmt: &ledger_core::CashFlowStatement) -> String {
use ledger_core::CashFlowSection as S;
let mut csv = String::from("section,kind,class,entry,date,label,amount,on_behalf\n");
let mut row = |section: &str,
kind: &str,
class: &str,
entry: Option<u64>,
date: Option<chrono::NaiveDate>,
label: &str,
amount: rust_decimal::Decimal,
on_behalf: Option<rust_decimal::Decimal>| {
csv.push_str(&format!(
"{section},{},{class},{},{},{},{},{}\n",
csv_escape(kind),
entry.map_or(String::new(), |e| e.to_string()),
date.map_or(String::new(), |d| d.to_string()),
csv_escape(label),
money(amount),
on_behalf.map_or(String::new(), money)
));
};
for (sec, total) in [
(S::Operating, "Net cash from operating activities"),
(S::Investing, "Net cash from investing activities"),
(S::Financing, "Net cash from financing activities"),
] {
for l in stmt.lines(sec) {
let class = l.class.map_or("", |c| c.key());
row(
sec.key(),
&l.kind,
class,
Some(l.entry),
Some(l.date),
&l.label,
l.amount,
Some(l.on_behalf),
);
}
row(sec.key(), "", "", None, None, total, stmt.total(sec), None);
}
let r = &stmt.operating_reconciliation;
row(
"reconciliation",
"",
"",
None,
None,
"Net income (loss)",
r.net_income,
None,
);
for l in &r.lines {
row(
"reconciliation",
l.kind.key(),
"",
None,
None,
&l.label,
l.amount,
None,
);
}
row(
"reconciliation",
"",
"",
None,
None,
"Net cash from operating activities",
r.net_cash_from_operating,
None,
);
for n in &stmt.noncash_disclosures {
row(
"noncash",
"",
"",
Some(n.entry),
Some(n.date),
&n.label,
n.amount,
None,
);
}
let f = &stmt.reconciles;
for (label, amount) in [
("Net increase (decrease) in cash", stmt.net_change_in_cash),
("Cash at beginning of period", stmt.cash_beginning),
("Cash at end of period", stmt.cash_ending),
("Sections sum", f.sections_sum),
("Difference", f.difference),
] {
row("footing", "", "", None, None, label, amount, None);
}
// The warnings, as the tool gives them: no amount (figures are
// unaffected).
for w in &stmt.warnings {
csv.push_str(&format!(
"warning,{},,{},,{},,\n",
w.kind.key(),
w.entry,
csv_escape(&w.message)
));
}
csv
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ServerHandler // ServerHandler
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+165 -26
View File
@@ -807,61 +807,200 @@ pub struct CashFlowParams {
pub start: Option<String>, pub start: Option<String>,
/// Period end, YYYY-MM-DD. Defaults to today. /// Period end, YYYY-MM-DD. Defaults to today.
pub end: Option<String>, pub end: Option<String>,
/// Account codes treated as cash (e.g. `["1000", "1010"]`): the /// Asset account codes treated as cash (e.g. `["1000", "1010"]`): the
/// statement reconciles to the net change in these accounts. Defaults /// statement explains the change in these accounts. Defaults to the
/// to `[books] cash_accounts`; one or the other is required. /// books' cash accounts setting (books.settings shows it); one or the
/// other is required.
#[serde(default)] #[serde(default)]
pub cash_accounts: Vec<String>, pub cash_accounts: Vec<String>,
/// Account codes for liabilities treated as financing (loans, and what /// Liability account codes treated as financing (loans, and what
/// owners advanced: ASC 230-10-45-14(b)). Defaults to `[books] /// members advanced: ASC 230-10-45-14(b)); equity is always financing.
/// financing_accounts`, else empty (liabilities are working capital). /// Defaults to the books' financing accounts setting, else empty
/// (other liabilities are operating).
#[serde(default)] #[serde(default)]
pub financing_accounts: Vec<String>, pub financing_accounts: Vec<String>,
/// Asset account codes treated as investing for cash_flow: loans the /// Asset account codes treated as investing: loans the company makes,
/// company makes, investments it buys (ASC 230-10-20). Defaults to /// investments it buys (ASC 230-10-20). Registered fixed assets are
/// `[books] investing_accounts`. /// investing unlisted. Defaults to the books' investing accounts
/// setting.
#[serde(default)] #[serde(default)]
pub investing_accounts: Vec<String>, pub investing_accounts: Vec<String>,
} }
/// One cash flow line. /// One entry's cash in one kind of one section of the cash flow
/// statement.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowLineOut { pub struct CashFlowLineOut {
/// Human label. /// The entry that moved the cash.
pub entry: u64,
/// Its date, YYYY-MM-DD.
pub date: String,
/// "<description> (#N)", "Reversal of #N — <N's description>", or
/// "Reinstatement of #A (reverses #B)".
pub label: String, pub label: String,
/// Signed amount, decimal string. Positive = inflow. /// The kind it is listed under: an operating class ("Cash paid to
/// suppliers and employees") or an investing or financing kind
/// ("Contributions from members — <member>").
pub kind: String,
/// The cash, decimal string; positive is cash in.
pub amount: String, pub amount: String,
/// Of `amount`, what a member or a lender paid or received for the
/// company with no cash account moving (a constructive receipt or
/// payment, D-42: an expense a member paid personally is borrowed from
/// the member and spent), decimal string; absent when none.
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf: Option<String>,
/// Codes of this kind's accounts the entry moved.
pub accounts: Vec<String>,
/// The entry this one reverses, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub reverses: Option<u64>,
/// Operating lines: the ASC 230-10-45-25 class key
/// (`from_customers`, `interest_and_dividends_received`,
/// `other_receipts`, `to_suppliers_and_employees`, `interest_paid`,
/// `income_taxes_paid`, `other_payments`).
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
} }
/// Result of `books.cash_flow`. /// A kind's total: how the printed statement and the viewer show a
/// section.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowKindOut {
/// The caption.
pub kind: String,
/// The operating class key, for an operating kind.
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// The kind's cash, decimal string.
pub amount: String,
/// Its entries.
pub entries: Vec<u64>,
}
/// One reconciling item between net income and operating cash.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ReconcilingLineOut {
/// `depreciation`, `investing_gain_loss`, `noncash_through`,
/// `other_noncash`, or `operating_balance_change`.
pub kind: String,
/// "Depreciation", "Noncash: through 2500 Bank loan", "Increase
/// in 1300 Prepaid Expenses", ...
pub label: String,
/// Added to net income, decimal string.
pub amount: String,
/// The account it is about, if one.
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
/// The entries behind it (an entry and its reversal in the period
/// left out).
pub entries: Vec<u64>,
}
/// Net income reconciled to net cash from operating activities (ASC
/// 230-10-45-30: a separate schedule with the direct method).
#[derive(Debug, Serialize, JsonSchema)]
pub struct OperatingReconciliationOut {
/// The period's net income, decimal string.
pub net_income: String,
/// The reconciling items.
pub lines: Vec<ReconcilingLineOut>,
/// Net income plus the items: equal to cash_from_operations.
pub net_cash_from_operating: String,
}
/// A noncash investing or financing activity (ASC 230-10-50-3).
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoncashDisclosureOut {
/// The entry.
pub entry: u64,
/// Its date, YYYY-MM-DD.
pub date: String,
/// Its label.
pub label: String,
/// The amount disclosed, decimal string.
pub amount: String,
/// The operating part no cash settled, debit-positive.
pub operating: String,
/// The investing part no cash settled, debit-positive.
pub investing: String,
/// The financing part no cash settled, debit-positive.
pub financing: String,
/// What was exchanged inside investing and financing (debt to equity,
/// one asset for another).
pub exchanged: String,
/// The entry it reverses, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub reverses: Option<u64>,
}
/// The footing check: always a zero difference (a nonzero one is an
/// engine error, never output).
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowFootingOut {
/// Operating + investing + financing.
pub sections_sum: String,
/// The cash accounts' ending balance less their beginning balance.
pub net_change: String,
/// sections_sum − net_change: "0.00".
pub difference: String,
}
/// An entry whose later settlement the books can't classify by
/// themselves, with what to change.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowWarningOut {
/// The entry.
pub entry: u64,
/// `asset_on_operating_payable` or `asset_sold_on_operating_receivable`.
pub kind: String,
/// What happened and the fix.
pub message: String,
}
/// Result of `books.cash_flow`; `books.export` writes the same JSON.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowOut { pub struct CashFlowOut {
/// "direct" (ASC 230-10-45-25).
pub method: String,
/// Period start, YYYY-MM-DD. /// Period start, YYYY-MM-DD.
pub start: String, pub start: String,
/// Period end, YYYY-MM-DD. /// Period end, YYYY-MM-DD.
pub end: String, pub end: String,
/// Period net income, decimal string. /// Operating cash by ASC 230-10-45-25 class, (a) to (g).
pub net_income: String, pub operating_by_class: Vec<CashFlowKindOut>,
/// Non-cash depreciation added back, decimal string. /// Every entry's operating cash, by class, then date.
pub depreciation: String, pub operating: Vec<CashFlowLineOut>,
/// Working-capital adjustments. /// Net cash from operating activities, decimal string.
pub working_capital: Vec<CashFlowLineOut>,
/// Cash from operations, decimal string.
pub cash_from_operations: String, pub cash_from_operations: String,
/// Investing flows (purchases, disposal proceeds). /// Investing cash by kind (purchases and sales of property and
/// equipment, loans made and collected).
pub investing_by_kind: Vec<CashFlowKindOut>,
/// Every entry's investing cash, by kind, then date.
pub investing: Vec<CashFlowLineOut>, pub investing: Vec<CashFlowLineOut>,
/// Cash from investing, decimal string. /// Net cash from investing activities, decimal string.
pub cash_from_investing: String, pub cash_from_investing: String,
/// Financing flows (equity, designated debt). /// Financing cash by kind (contributions and distributions per member,
/// borrowing and repayments per lender).
pub financing_by_kind: Vec<CashFlowKindOut>,
/// Every entry's financing cash, by kind, then date.
pub financing: Vec<CashFlowLineOut>, pub financing: Vec<CashFlowLineOut>,
/// Cash from financing, decimal string. /// Net cash from financing activities, decimal string.
pub cash_from_financing: String, pub cash_from_financing: String,
/// Net change in cash, decimal string. /// Net change in cash, decimal string.
pub net_change_in_cash: String, pub net_change_in_cash: String,
/// Cash balance at period start, decimal string. /// Cash at the start, decimal string.
pub cash_beginning: String, pub cash_beginning: String,
/// Cash balance at period end, decimal string. /// Cash at the end, decimal string.
pub cash_ending: String, pub cash_ending: String,
/// Noncash investing and financing activities.
pub noncash_disclosures: Vec<NoncashDisclosureOut>,
/// Net income reconciled to operating cash.
pub operating_reconciliation: OperatingReconciliationOut,
/// The footing check.
pub reconciles: CashFlowFootingOut,
/// Entries whose later settlement the books can't classify.
pub warnings: Vec<CashFlowWarningOut>,
} }
/// Parameters for `books.import_bank_csv`. /// Parameters for `books.import_bank_csv`.
+86 -4
View File
@@ -2379,8 +2379,11 @@ fn a_restarted_server_waits_for_the_old_one_to_let_go() {
} }
/// `books.cash_flow` with no accounts passed reads the roles from /// `books.cash_flow` with no accounts passed reads the roles from
/// `[books]` in wanderbooks.toml (rules.md B-14): the owners' advance is /// `[books]` in wanderbooks.toml (rules.md B-14): the expense the owners
/// financing, gross; a loan to a member is investing. /// 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] #[test]
fn cash_flow_reads_its_account_roles_from_the_books_settings() { fn cash_flow_reads_its_account_roles_from_the_books_settings() {
let dir = test_dir("cash-roles"); 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()) .map(|l| l["amount"].as_str().unwrap().to_string())
.collect() .collect()
}; };
// Financing, gross and sorted by code: 2100 in and out, then capital. // Financing by kind: the contribution, what the owners paid for the
assert_eq!(amounts("financing"), ["100.00", "-100.00", "1000.00"]); // 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("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["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 /// A chart CSV with a bad row anywhere is refused whole: nothing is
+253 -37
View File
@@ -782,12 +782,28 @@ fn pnl_and_balance_sheet_exports_equal_the_core_statements() {
} }
/// Cash flow CSV and JSON equal `ledger_core::cash_flow` for the same /// 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] #[test]
fn cash_flow_exports_equal_the_core_statement() { fn cash_flow_exports_equal_the_core_statement() {
let dir = test_dir("cf"); let dir = test_dir("cf");
let mut s = new_store(&dir); let mut s = new_store(&dir);
rich_books(&mut s); 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( let cf = ledger_core::cash_flow(
s.ledger(), s.ledger(),
&CashFlowParams { &CashFlowParams {
@@ -796,6 +812,7 @@ fn cash_flow_exports_equal_the_core_statement() {
cash_accounts: vec![id_of(&s, "1000")], cash_accounts: vec![id_of(&s, "1000")],
financing_accounts: vec![id_of(&s, "2100")], financing_accounts: vec![id_of(&s, "2100")],
investing_accounts: vec![], investing_accounts: vec![],
members,
}, },
) )
.unwrap(); .unwrap();
@@ -805,54 +822,173 @@ fn cash_flow_exports_equal_the_core_statement() {
json!({"format": format, "report": "cash_flow", "start": START, "end": END, json!({"format": format, "report": "cash_flow", "start": START, "end": END,
"cash_accounts": ["1000"], "financing_accounts": ["2100"]}) "cash_accounts": ["1000"], "financing_accounts": ["2100"]})
}; };
use ledger_core::CashFlowSection as S;
let csv = m.export_text(args("csv")); let csv = m.export_text(args("csv"));
let rows = csv_rows(&csv, &["section", "label", "amount"]); let rows = csv_rows(
let mut want: Vec<(String, String, Decimal)> = vec![ &csv,
("operating".into(), "Net income".into(), cf.net_income), &[
("operating".into(), "Depreciation".into(), cf.depreciation), "section",
]; "kind",
for l in &cf.working_capital { "class",
want.push(("operating".into(), l.label.clone(), l.amount)); "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(( let r = &cf.operating_reconciliation;
"operating".into(), want.push(row(
"Cash from operations".into(), "reconciliation",
cf.cash_from_operations, "",
"",
String::new(),
String::new(),
"Net income (loss)",
r.net_income,
)); ));
for l in &cf.investing { for l in &r.lines {
want.push(("investing".into(), l.label.clone(), l.amount)); want.push(row(
"reconciliation",
l.kind.key(),
"",
String::new(),
String::new(),
&l.label,
l.amount,
));
} }
want.push(( want.push(row(
"investing".into(), "reconciliation",
"Cash from investing".into(), "",
cf.cash_from_investing, "",
String::new(),
String::new(),
"Net cash from operating activities",
r.net_cash_from_operating,
)); ));
for l in &cf.financing { for n in &cf.noncash_disclosures {
want.push(("financing".into(), l.label.clone(), l.amount)); want.push(row(
"noncash",
"",
"",
n.entry.to_string(),
n.date.to_string(),
&n.label,
n.amount,
));
} }
want.push(( for (label, v) in [
"financing".into(), ("Net increase (decrease) in cash", cf.net_change_in_cash),
"Cash from financing".into(), ("Cash at beginning of period", cf.cash_beginning),
cf.cash_from_financing, ("Cash at end of period", cf.cash_ending),
)); ("Sections sum", cf.reconciles.sections_sum),
want.push(( ("Difference", cf.reconciles.difference),
"reconciliation".into(), ] {
"Net change in cash".into(), want.push(row(
cf.net_change_in_cash, "footing",
)); "",
"",
String::new(),
String::new(),
label,
v,
));
}
// The warnings close the CSV: kind, entry and message, no amount.
let (warned, rows): (Vec<_>, Vec<_>) = rows.into_iter().partition(|r| r[0] == "warning");
let want_warned: Vec<Vec<String>> = cf
.warnings
.iter()
.map(|w| {
vec![
"warning".to_string(),
w.kind.key().to_string(),
String::new(),
w.entry.to_string(),
String::new(),
w.message.clone(),
String::new(),
String::new(),
]
})
.collect();
assert_eq!(warned, want_warned, "cash flow CSV warnings");
assert_eq!(cf.warnings.len(), 1, "the monitor bought on account");
let got: Vec<_> = rows let got: Vec<_> = rows
.iter() .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(); .collect();
assert_eq!(got, want, "cash flow CSV"); 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(); 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["start"], START);
assert_eq!(j["end"], END); assert_eq!(j["end"], END);
for (k, v) in [ for (k, v) in [
("net_income", cf.net_income),
("depreciation", cf.depreciation),
("cash_from_operations", cf.cash_from_operations), ("cash_from_operations", cf.cash_from_operations),
("cash_from_investing", cf.cash_from_investing), ("cash_from_investing", cf.cash_from_investing),
("cash_from_financing", cf.cash_from_financing), ("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}"); assert_eq!(jamt(&j[k]), v, "cash flow {k}");
} }
for (k, lines) in [ for (k, sec) in [
("working_capital", &cf.working_capital), ("operating", S::Operating),
("investing", &cf.investing), ("investing", S::Investing),
("financing", &cf.financing), ("financing", S::Financing),
] { ] {
let lines = cf.lines(sec);
let got = j[k].as_array().unwrap(); let got = j[k].as_array().unwrap();
assert_eq!(got.len(), lines.len(), "{k}"); assert_eq!(got.len(), lines.len(), "{k}");
for (g, l) in got.iter().zip(lines) { 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["label"], l.label.as_str());
assert_eq!(g["kind"], l.kind.as_str());
assert_eq!(jamt(&g["amount"]), l.amount); 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 /// The journal export, CSV and JSON, is every posted entry line by line
+6 -2
View File
@@ -2510,7 +2510,11 @@ fn settings_that_disagree_with_the_chart_are_problems() {
.map(|p| p.as_str().unwrap().to_string()) .map(|p| p.as_str().unwrap().to_string())
.collect(); .collect();
assert_eq!(problems.len(), 1, "{problems:?}"); 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) let text = std::fs::read_to_string(&toml)
.unwrap() .unwrap()
.replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]") .replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]")
@@ -2527,7 +2531,7 @@ fn settings_that_disagree_with_the_chart_are_problems() {
"{problems}" "{problems}"
); );
assert!( assert!(
problems.contains("[books] financing_accounts names 6000"), problems.contains("[books] financing_accounts lists 6000"),
"{problems}" "{problems}"
); );
} }
+13 -13
View File
@@ -170,19 +170,19 @@ pub fn build_package<B: PackageBooks + ?Sized>(
), ),
( (
"cash-flow", "cash-flow",
report::cash_flow( // The package's one list of each (INVARIANT 7); investing
&cash_flow_for( // accounts are the cash flow's alone (the request's, else the
ledger, // settings').
settings, report::cash_flow(&cash_flow_for(
Some(&fy_start), ledger,
Some(&as_of_s), settings,
req.cash_accounts, Some(&fy_start),
req.financing_accounts, Some(&as_of_s),
req.investing_accounts, cash_codes,
today, financing_codes,
)?, req.investing_accounts,
&members, today,
), )?),
), ),
]; ];
let mut k1_csv: Option<(String, Vec<u8>)> = None; let mut k1_csv: Option<(String, Vec<u8>)> = None;
+28 -1
View File
@@ -13,7 +13,12 @@
//! refused. //! refused.
//! 2. The cash flow statement needs at least one cash account, and //! 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 //! 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 //! 3. The year's earnings split net income year to date from the part
//! not yet closed into equity (closes are cumulative). //! 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: /// 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 /// `start` defaults to the beginning of time, `end` to `today`, and at
/// least one cash account is required (the statement reconciles to them). /// 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( pub fn cash_flow_statement(
ledger: &Ledger, ledger: &Ledger,
start: Option<&str>, start: Option<&str>,
@@ -105,6 +113,7 @@ pub fn cash_flow_statement(
cash_codes: &[String], cash_codes: &[String],
financing_codes: &[String], financing_codes: &[String],
investing_codes: &[String], investing_codes: &[String],
members: &[Member],
today: NaiveDate, today: NaiveDate,
) -> Result<ledger_core::reports::CashFlowStatement, String> { ) -> Result<ledger_core::reports::CashFlowStatement, String> {
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> { let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> {
@@ -129,6 +138,22 @@ pub fn cash_flow_statement(
let financing_accounts = resolve(financing_codes, "financing")?; let financing_accounts = resolve(financing_codes, "financing")?;
let investing_accounts = resolve(investing_codes, "investing")?; let investing_accounts = resolve(investing_codes, "investing")?;
let (start, end) = statement_range(ledger, start, end, today)?; 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_core::cash_flow(
ledger, ledger,
&ledger_core::CashFlowParams { &ledger_core::CashFlowParams {
@@ -137,6 +162,7 @@ pub fn cash_flow_statement(
cash_accounts, cash_accounts,
financing_accounts, financing_accounts,
investing_accounts, investing_accounts,
members,
}, },
) )
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -169,6 +195,7 @@ pub fn cash_flow_for(
&or(cash, settings.cash_accounts), &or(cash, settings.cash_accounts),
&or(financing, settings.financing_accounts), &or(financing, settings.financing_accounts),
&or(investing, settings.investing_accounts), &or(investing, settings.investing_accounts),
&settings.company.members,
today, today,
) )
} }
+2 -2
View File
@@ -19,7 +19,7 @@ use chrono::NaiveDate;
use rust_decimal::Decimal; use rust_decimal::Decimal;
/// A money amount as a decimal string with at least two places: "0.00", /// 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". /// more places (an FX-converted line) keeps them. Never "-0".
pub fn money(n: Decimal) -> String { pub fn money(n: Decimal) -> String {
let mut n = if n.is_zero() { Decimal::ZERO } else { n }; 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("-0"), "0.00");
assert_eq!(m("1000"), "1000.00"); assert_eq!(m("1000"), "1000.00");
assert_eq!(m("5.8"), "5.80"); 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"); assert_eq!(m("12.3456"), "12.3456");
} }
+46 -7
View File
@@ -1734,16 +1734,23 @@ fn the_readme_names_the_entries_on_receivables_and_payables() {
assert!(!text(&p, "README.txt").contains("customer or vendor")); assert!(!text(&p, "README.txt").contains("customer or vendor"));
} }
/// An XLSX's shared strings, as text. /// Every text the package's XLSX `name` holds: its XML parts (the shared
/// strings and the sheets), concatenated, `&amp;` read as `&`.
fn xlsx_strings(p: &Package, name: &str) -> String { 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 x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = String::new(); let mut out = String::new();
x.by_name("xl/sharedStrings.xml") for i in 0..x.len() {
.unwrap() let mut part = x.by_index(i).unwrap();
.read_to_string(&mut out) if part.name().unwrap().ends_with(".xml") {
.unwrap(); part.read_to_string(&mut out).unwrap();
out }
}
out.replace("&amp;", "&")
} }
/// Random books for [`iif_ties_to_the_trial_balance_as_of`]: entries of /// 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); assert_eq!(checked, 2);
} }
/// The package's statement of cash flows is the direct method's (rules.md
/// B-14, D-35): operating by ASC 230-10-45-25 class, financing by kind
/// with each member named, and both schedules: the reconciliation of net
/// income to operating cash (45-30) and the noncash investing and
/// financing activities (50-3; "None" here: every entry moved cash).
#[test]
fn the_cash_flow_carries_both_schedules() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(true);
let cash = cash();
let p = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap();
let text = xlsx_strings(&p, "cash-flow.xlsx");
for m in &company.members {
let want = format!("Contributions from members — {}", m.name);
assert!(text.contains(&want), "{want:?} not in the cash flow");
}
for want in [
"Statement of Cash Flows",
"Cash received from customers",
"Cash paid to suppliers and employees",
"Members' contributions (#",
"Reconciliation of net income (loss) to net cash from operating activities",
"Noncash investing and financing activities",
"None",
] {
assert!(text.contains(want), "{want:?} not in the cash flow");
}
assert!(!text.contains("Member distributions"));
assert!(p.files.iter().any(|(n, _)| n == "cash-flow.pdf"));
}
+20 -12
View File
@@ -1821,19 +1821,27 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
tally(reports::profit_and_loss_for_period(l, start, end, None).map(|_| ())); tally(reports::profit_and_loss_for_period(l, start, end, None).map(|_| ()));
tally(reports::general_ledger(l, start, end).map(|_| ())); tally(reports::general_ledger(l, start, end).map(|_| ()));
tally(reports::members_equity(l, start, end, &members).map(|_| ())); tally(reports::members_equity(l, start, end, &members).map(|_| ()));
tally( // The statement of cash flows foots on every book, or is refused
reports::cash_flow( // for a reason: never an engine error (rules.md B-14 rule 9).
l, let cf = reports::cash_flow(
&reports::CashFlowParams { l,
start, &reports::CashFlowParams {
end, start,
cash_accounts: cash.clone(), end,
financing_accounts: vec![], cash_accounts: cash.clone(),
investing_accounts: vec![], financing_accounts: vec![],
}, investing_accounts: vec![],
) members: members.clone(),
.map(|_| ()), },
); );
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::k1::k1_worksheet(l, start, end, &members, &cash).map(|_| ()));
tally(ledger_core::tax_summary(l, start, end, None).map(|_| ())); tally(ledger_core::tax_summary(l, start, end, None).map(|_| ()));
let _ = reports::cancelled_in_span(l, start, end); let _ = reports::cancelled_in_span(l, start, end);
+1 -1
View File
@@ -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 | | 2 | Chart of accounts | engine + `books.chart` (with live balances) | covered |
| 3 | Live P&L tab | engine + `books.profit_and_loss` | covered | | 3 | Live P&L tab | engine + `books.profit_and_loss` | covered |
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | 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 | | 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 | | 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 | | 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
+65 -7
View File
@@ -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 `revenue_accounts` and `expense_accounts` for the range. `end` defaults to
today, `start` to the first posted entry. Closing entries are excluded, so today, `start` to the first posted entry. Closing entries are excluded, so
a closed period still shows what it earned. 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) - `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)
→ indirect-method cash flow statement. `cash_accounts` are the reconciling → the statement of cash flows, direct method (rules.md B-14, P3 defect
accounts (required); the three sections always sum to their net change. 1): `{ method: "direct", start, end, operating_by_class, operating,
Disposal gains/losses move from operating to investing automatically. 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 — <account>" 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 — <member>", "Repayments of borrowing — <account>", "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" }` - `books.digest { since: "2026-10-01" }`
→ `{ entries_posted: [...], totals_by_account: [...], stale_rules: [...] }`. → `{ entries_posted: [...], totals_by_account: [...], stale_rules: [...] }`.
The human review surface: "what did the AI do with my books lately." The 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?, - `books.export { format, report, start?, end?, as_of?, cash_accounts?,
financing_accounts?, investing_accounts? }` → `{ content, financing_accounts?, investing_accounts? }` → `{ content,
content_encoding?, warnings? }`. content_encoding?, warnings? }`.
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (direct
`cash_accounts`), `trial_balance`, `members_equity` (statement of 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 changes in members' equity; members from `wanderbooks.toml`; earnings
not yet closed are split the way the next members close will split 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 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, README says which. With TINs it adds `party_id, tin_last4, tin,
tin_type, tin_note`. A zip with the full set, each tin_type, tin_note`. A zip with the full set, each
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`, 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 (multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
`general-journal.xlsx`. Period reports cover the fiscal year to date. `general-journal.xlsx`. Period reports cover the fiscal year to date.
What the CPA needs; they never log into the system. What the CPA needs; they never log into the system.
+1 -1
View File
@@ -28,7 +28,7 @@ No other statuses.
| Reversing entries | Reverse transaction | done | linked via `reversal_of` | | Reversing entries | Reverse transaction | done | linked via `reversal_of` |
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | 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 | | 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 | | 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 | | Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
| Bank reconciliation (cleared flags + report) | Reconcile | done | — | | Bank reconciliation (cleared flags + report) | Reconcile | done | — |
+3 -1
View File
@@ -113,7 +113,9 @@ requirement conflict, the requirement wins.
## Promoted from future (done) ## 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`. `ledger-core::reports::cash_flow` + `books.cash_flow`.
- **R-? — QuickBooks-compatible import/export.** Was R-FUTURE-4. - **R-? — QuickBooks-compatible import/export.** Was R-FUTURE-4.
`ledger-import` crate: CSV bank/chart import, IIF and XLSX export. `ledger-import` crate: CSV bank/chart import, IIF and XLSX export.
+10 -1
View File
@@ -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, 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 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, (`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.
+305 -45
View File
@@ -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 `a_yen_members_page_splits_in_whole_yen`; the bookspec examples
`members-close.*`. `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 The P&L covers a period; a closing entry is not income or spending
determine and report the same amount for net cash flow from operating (B-11), so period statements exclude it.
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.
Investing and financing are gross (ASC 230-10-45-7, read directly The statement of cash flows (P3 defect 1, D-35, D-42; the paragraphs were read
2026-10-14, `sources/fasb-asc.md`: in public sources that reproduce the Codification, `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.
Money an owner advances, by paying a company expense personally (a 1. **Cash receipts and payments.** The statement reports "the cash
"Due to owners" liability), is borrowing: PwC Viewpoint, Financial effects" of operations, investing and financing (ASC 230-10-45-1; its
statement presentation 6.9.3.1 (constructive receipts and objective, 10-1: "cash receipts and cash payments"). An entry
disbursements) presents cash a third party pays on the entity's behalf contributes to the body only its net movement of the cash accounts,
"as though the transaction took place through the bank accounts of the and what a member or a lender paid or received for it (rule 11, which
reporting entity": a financing inflow and an operating outflow. Listing nets to zero within the entry); an entry that moves neither
that liability in `financing_accounts` gives exactly that; repaying the contributes nothing; a transfer between two cash accounts is no flow. The period is [start, end], inclusive. The
owner is then a repayment of amounts borrowed. "Judgment is required" cash accounts are every account holding cash, cash equivalents or
(PwC), so it is the company's setting, not a fixed rule. 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 — <N's description>", 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 <A>" (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
— <member>", 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 Scope: single-currency books (R-5). A foreign-currency cash account's
`statements_add_up`; `prop_period_pnl_partitions`; exchange effect would be a separate reconciling line (830-230-45-1); a
`cash_flow_reconciles_to_change_in_cash`; hand-posted revaluation of a cash account shows as an operating receipt.
`financing_is_gross_borrowing_apart_from_repayment`,
`a_loan_the_entity_makes_is_investing_gross`, **Enforced:** `profit_and_loss_for_period`; `cashflow::cash_flow` (with
`an_expense_the_owner_paid_is_borrowing_then_operating`. `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 ## 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 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, 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 B-33) counts the year's payments from January 1 through that day. The
payment sources on a vendor's tagged entries: `[books] cash_accounts` and engine counts credits minus debits to the payment sources on a vendor's
`financing_accounts` (a member or lender paying on the company's behalf; tagged entries: `[books] cash_accounts` and `financing_accounts` (a
the company is still the payer). A card charge leaves neither (1099-K); 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`, 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 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. 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` `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 `oracle_mcp.rs` (`cpa_package_holds_the_year_and_its_evidence`); the
bookspec examples `bookspec/examples/as-of-package-stops-at-as-of.toml` 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 ## B-34 The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out
+193 -1
View File
@@ -1,6 +1,7 @@
# FASB Accounting Standards Codification: paragraphs read directly # 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, The Codification is copyrighted by the Financial Accounting Foundation,
so this file keeps only short quotes and paraphrase, with the paragraph so this file keeps only short quotes and paraphrase, with the paragraph
numbers to look up. Earlier ASC paragraphs were read through Deloitte's 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 → B-14: money owners advance (Due to Shareholders) is borrowing, so
financing, gross; a loan the company makes is investing, gross. 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) ## 606-10-55-36 through 55-38 (Revenue: principal or agent)
An entity controlling the specified good or service before transfer is An entity controlling the specified good or service before transfer is
+159 -3
View File
@@ -23,7 +23,7 @@ promise.
| Model-based stateful | the Lean state machine, transcribed (3) | `tests/model.rs` | `just check` | ~3s | every change | | 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: 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 | | 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 <target> <secs>` | 10 min each | 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 <target> <secs>` | 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 | | 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 | | 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. 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 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 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 **Core** (`ledger-core` `tests/odd_cent.rs`): a true-up from inception
(`members_closes_true_up_from_inception`); a close that posts nothing (`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`, ones (`bookspec/examples/as-of-package-stops-at-as-of.toml`,
`1099-worksheet-through-as-of.toml`) and the IIF's `1099-worksheet-through-as-of.toml`) and the IIF's
(`iif-quickbooks-journal-file.toml`, formats appendix), each expected (`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 reads the IIF example and checks the engine gives exactly its file and
changes. changes.
- Review R18 and the README: `one_list_of_cash_accounts_per_package`, - 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 `parties.rs` `tin_separator`'s arms and `tin_invisible`, and the gap that
doesn't count them. 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) ## Benchmarks (baseline 2026-10-13)
No budget, by decision (the owner, 2026-10-13: the books do little work 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_...` | | 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 | | 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) | | 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) | | 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 | | 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 | | 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 |
+95 -14
View File
@@ -83,6 +83,58 @@ Each law, and what proves it. Kani harnesses are in
`Reachable.accounting_equation_through` (any number of accounts) `Reachable.accounting_equation_through` (any number of accounts)
9. **Period statements add up**: a year's P&L is the sum of its months'. 9. **Period statements add up**: a year's P&L is the sum of its months'.
- Lean `statements_add_up` - 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 ### 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 theorems hold in **every state reachable from empty books, by any
sequence of commands, of any length**: 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 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 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.) applying it: a post can never then fail on a closed period.)
- Lean `Step.inv`, `Reachable.inv` - Lean `Step.inv`, `Reachable.inv`
11. **The books always net to zero.** · Lean `Reachable.books_net_zero` 12. **The books always net to zero.** · Lean `Reachable.books_net_zero`
12. **Posted is forever**: posting only appends. After any sequence of 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, commands the old log is a prefix of the new one. Nothing is edited,
reordered, or removed. reordered, or removed.
- Lean `Step.extends`, `Steps.extends` - 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 commands adds, removes, or changes an entry dated in it, and it stays
closed. closed.
- Lean `Step.frozen`, `Steps.frozen` - 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 (a setting, convention, decision, open item or approval) leaves the
chart, every posted entry, every draft and every period exactly as they chart, every posted entry, every draft and every period exactly as they
were, and any number of them keeps a reachable state reachable. The were, and any number of them keeps a reachable state reachable. The
@@ -123,7 +175,7 @@ sequence of commands, of any length**:
### Closing ### 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 and zeroes every revenue and expense account** through the period's
last day, whether the net goes to one account or is split among last day, whether the net goes to one account or is split among
members. members.
@@ -131,14 +183,14 @@ sequence of commands, of any length**:
`closing_entry_balances` `closing_entry_balances`
- Lean `closingEntry_net`, `closingEntry_zeroes`, - Lean `closingEntry_net`, `closingEntry_zeroes`,
`closingEntry_accepted`, `close_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. expenses through the period's last day.
- Lean `net_closings_is_net_income`, `close_credits_net_income` - 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. other shares were rounded, and only the last share changes.
- Kani `plug_last_sums_exactly` · Lean `plugLast_total`, - Kani `plug_last_sums_exactly` · Lean `plugLast_total`,
`plugLast_keeps` `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 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 (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 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 - **The proofs are not vacuous.** `kani::cover!` statements make Kani
show that the interesting cases are reachable: an accepted four-line show that the interesting cases are reachable: an accepted four-line
entry, a reversal on one account with several lines, three distinct 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 ## 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 - **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 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, 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 and the bridge property test ties the `Decimal` instantiation to the
`i64` one Kani checked. `i64` one Kani checked.
@@ -276,6 +332,24 @@ Be precise about the claims:
structurally), A/R and A/P documents, payroll, depreciation schedules structurally), A/R and A/P documents, payroll, depreciation schedules
(those have their own property tests), and persistence (the journal (those have their own property tests), and persistence (the journal
store's hash chain is tested in `ledger-store`). 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) ### 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
``` ```
`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 and fails if any headline theorem depends on anything beyond Lean's
standard axioms (`propext`, `Quot.sound`, `Classical.choice`). In standard axioms (`propext`, `Quot.sound`, `Classical.choice`). In
particular, `sorryAx` fails it. Kani downloads its pinned nightly and particular, `sorryAx` fails it. Its time is the slowest harness's:
CBMC into `~/.kani` on first use. `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.
+1
View File
@@ -899,6 +899,7 @@ The first company keeps working between phases.
- **Description.** Tool text (S:2202) is corrected. - **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. - **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. - **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`. **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`.
+18
View File
@@ -49,3 +49,21 @@ open Wanderbooks
#print axioms Apportion.allocW_cumulative_total #print axioms Apportion.allocW_cumulative_total
#print axioms Step.record_changes_no_figure #print axioms Step.record_changes_no_figure
#print axioms Reachable.records #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
+74
View File
@@ -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 t (allLines (between b c es)) chart :=
typeTotal_split t _ _ _ (fun a => movement_split a b c h 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 -/ /-! ## How the books are kept changes no figure -/
/-- **A record changes nothing the books hold**: a settings change, a /-- **A record changes nothing the books hold**: a settings change, a
+580 -1
View File
@@ -4,7 +4,10 @@ import Wanderbooks.Entry
# Balances and statements # Balances and statements
Mirrors `kernel::normal_balance`, `kernel::columns`, 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 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 rw [sum_movements as hd ls hcov] at this
omega 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 end Wanderbooks