P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Direct method with the operating reconciliation and noncash schedules; it foots by construction (Lean and Kani). Every non-cash account belongs to one section; reversals net; a member or lender paying for the company is cash borrowed and spent (D-42, constructive receipt and disbursement); property a member contributes stays noncash. The bookspec seed: one loader checks every example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
64 files changed
+12128
-1567
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 ¶ms.cash_accounts {
|
|
||||||
cash_beginning += normal(id, begin_movement(id));
|
|
||||||
cash_ending += normal(id, begin_movement(id) + period_movement(id));
|
|
||||||
}
|
|
||||||
let net_change_in_cash = cash_ending - cash_beginning;
|
|
||||||
|
|
||||||
Ok(CashFlowStatement {
|
|
||||||
start: params.start,
|
|
||||||
end: params.end,
|
|
||||||
net_income,
|
|
||||||
depreciation,
|
|
||||||
working_capital,
|
|
||||||
cash_from_operations,
|
|
||||||
investing,
|
|
||||||
cash_from_investing,
|
|
||||||
financing,
|
|
||||||
cash_from_financing,
|
|
||||||
net_change_in_cash,
|
|
||||||
cash_beginning,
|
|
||||||
cash_ending,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)]
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = []
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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, ¶ms).unwrap();
|
let cf = cash_flow(&co.l, ¶ms).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, ¶ms).unwrap();
|
let cf = cash_flow(&co.l, ¶ms).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, ¶ms).unwrap();
|
let before = cash_flow(&co.l, ¶ms).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, ¶ms).unwrap();
|
let after = cash_flow(&co.l, ¶ms).unwrap();
|
||||||
assert_eq!(after.net_income, c(40000));
|
assert_eq!(after.operating_reconciliation.net_income, c(40000));
|
||||||
assert_eq!(cf_key(&after), cf_key(&before));
|
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, ¶ms).unwrap();
|
let cf = cash_flow(&b.l, ¶ms).unwrap();
|
||||||
prop_assert_eq!(cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash);
|
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, ¶ms).unwrap();
|
let cf = cash_flow(&b.l, ¶ms).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);
|
||||||
|
|||||||
@@ -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, ¶ms(d(2, 1), d(2, 28))).unwrap();
|
let feb = cash_flow(&l, ¶ms(d(2, 1), d(2, 28))).unwrap();
|
||||||
assert_eq!(feb.net_income, dec(50));
|
assert_eq!(feb.operating_reconciliation.net_income, dec(50));
|
||||||
assert_eq!(
|
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, ¶ms(d(3, 1), d(3, 31))).unwrap();
|
let mar = cash_flow(&l, ¶ms(d(3, 1), d(3, 31))).unwrap();
|
||||||
assert_eq!(mar.net_income, dec(0));
|
assert_eq!(mar.operating_reconciliation.net_income, dec(0));
|
||||||
assert_eq!(
|
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, ¶ms).unwrap();
|
let cf = cash_flow(&l, ¶ms).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, ¶ms).unwrap();
|
let cf = cash_flow(&l, ¶ms).unwrap();
|
||||||
assert_eq!(cf.net_income, dec(-100));
|
assert_eq!(cf.operating_reconciliation.net_income, dec(-100));
|
||||||
assert_eq!(cf.cash_from_operations, dec(-100));
|
assert_eq!(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, ¶ms).unwrap();
|
let cf = cash_flow(&l, ¶ms).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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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(¬e) {
|
||||||
|
notes.push(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for n in notes {
|
||||||
|
t.note(n);
|
||||||
|
}
|
||||||
|
if cols.len() > 1 {
|
||||||
|
t.note(
|
||||||
|
"An entry and its reversal in the same column drop out of it; in different columns each shows, and the Total nets them.",
|
||||||
|
);
|
||||||
|
}
|
||||||
let names: Vec<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]
|
||||||
|
|||||||
@@ -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)},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, `&` 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("&", "&")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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"));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 | — |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user