From 9ab003d0d178a0c2b56b2004ee1f00863b81a1fc Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 20 Oct 2026 19:59:31 -0600 Subject: [PATCH] P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8) books.rename_account; account names trimmed, bounded, refused when a sibling's or TIN-shaped; books.show refuses what its target ignores. A report asked for without dates covers this fiscal year so far, everywhere (one rule for tools, descriptions and the viewer; the IIF and CPA package keep their own dates). policy.require_tax_category warns or refuses; a Form 4797 gain is shown apart on every return. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- README.md | 2 +- bookspec/README.md | 68 +- .../default-period-fiscal-year-to-date.toml | 163 ++++ .../examples/rename-changes-no-amount.toml | 149 +++ bookspec/examples/tax-category-required.toml | 128 +++ crates/ledger-config/src/codec.rs | 28 + crates/ledger-core/src/account.rs | 18 + crates/ledger-core/src/calendar.rs | 385 +++++++- crates/ledger-core/src/error.rs | 81 +- crates/ledger-core/src/ledger.rs | 250 +++++- crates/ledger-core/src/lib.rs | 4 +- crates/ledger-core/src/settings.rs | 108 ++- crates/ledger-core/src/tax.rs | 100 +++ crates/ledger-core/tests/bookspec.rs | 761 ++++++++++++++-- crates/ledger-core/tests/invariants.rs | 10 +- crates/ledger-core/tests/model.rs | 93 +- .../tests/properties.proptest-regressions | 1 + crates/ledger-core/tests/properties.rs | 86 ++ crates/ledger-core/tests/spec_ledger.rs | 358 ++++++++ crates/ledger-core/tests/spec_roles.rs | 38 +- crates/ledger-core/tests/tax.rs | 333 +++++++ crates/ledger-gui/src/app.rs | 68 +- crates/ledger-gui/src/attention.rs | 70 +- crates/ledger-gui/src/books.rs | 61 +- crates/ledger-gui/src/pages/accounts.rs | 65 +- crates/ledger-gui/src/pages/assets.rs | 5 +- crates/ledger-gui/src/pages/cash.rs | 2 +- crates/ledger-gui/src/pages/journal.rs | 40 +- crates/ledger-gui/src/pages/profit.rs | 3 +- crates/ledger-gui/src/pages/reports.rs | 41 +- crates/ledger-gui/tests/old_books.rs | 3 +- crates/ledger-gui/tests/screens.rs | 9 +- crates/ledger-gui/tests/show.rs | 191 ++++ crates/ledger-gui/tests/spec_gui.rs | 12 +- crates/ledger-gui/tests/view_model.rs | 148 +++ crates/ledger-import/src/report.rs | 19 + crates/ledger-import/tests/spec_import.rs | 70 +- crates/ledger-mcp/handbook/control-roles.md | 2 +- .../handbook/settings-and-conventions.md | 1 + crates/ledger-mcp/handbook/tracing-figures.md | 1 + crates/ledger-mcp/handbook/viewer.md | 1 + crates/ledger-mcp/src/handbook.rs | 27 +- crates/ledger-mcp/src/scopes.rs | 3 +- crates/ledger-mcp/src/server.rs | 845 +++++++++++++++--- crates/ledger-mcp/src/types.rs | 170 +++- crates/ledger-mcp/tests/hostile_args.rs | 159 ++++ crates/ledger-mcp/tests/mcp_stdio.rs | 473 +++++++++- crates/ledger-mcp/tests/oracle_mcp.rs | 306 ++++++- crates/ledger-mcp/tests/spec_mcp.rs | 4 + crates/ledger-mcp/tests/spec_tax_policy.rs | 451 ++++++++++ crates/ledger-package/src/lib.rs | 4 +- crates/ledger-package/src/package.rs | 12 +- crates/ledger-package/src/reports.rs | 74 +- crates/ledger-store/src/format.rs | 43 +- crates/ledger-store/src/ops.rs | 39 +- crates/ledger-store/src/store.rs | 203 ++++- .../fixtures/old-books/p3_rename/chart.json | 107 +++ .../old-books/p3_rename/expected.json | 154 ++++ .../old-books/p3_rename/journal.jsonl | 30 + crates/ledger-store/tests/format.rs | 106 +++ crates/ledger-store/tests/store.rs | 806 ++++++++++++++++- docs/mcp-interface.md | 149 ++- docs/spec/rules.md | 212 ++++- docs/spec/sources/irs-tax-lines.md | 63 ++ docs/testing.md | 187 ++++ docs/verification.md | 14 +- fuzz/fuzz_targets/fuzz_store_replay.rs | 5 +- plan/served.md | 4 +- verify/lean/Wanderbooks/Ledger.lean | 7 +- 69 files changed, 8063 insertions(+), 570 deletions(-) create mode 100644 bookspec/examples/default-period-fiscal-year-to-date.toml create mode 100644 bookspec/examples/rename-changes-no-amount.toml create mode 100644 bookspec/examples/tax-category-required.toml create mode 100644 crates/ledger-mcp/tests/spec_tax_policy.rs create mode 100644 crates/ledger-store/tests/fixtures/old-books/p3_rename/chart.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/p3_rename/expected.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/p3_rename/journal.jsonl diff --git a/README.md b/README.md index d4aa4ed..53dbd99 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ just # lists the recipes: check, build, prove, demo, ... ``` crates/ledger-core/ # engine: money, accounts, journal, ledger, reports crates/ledger-store/ # journal + SQLite projection + blob store -crates/ledger-mcp/ # MCP server: 108 tools, resources, handbook, receipt policy +crates/ledger-mcp/ # MCP server: 109 tools, resources, handbook, receipt policy crates/ledger-import/ # QBO CSV import, IIF/XLSX export crates/ledger-config/ # wanderbooks.toml company settings crates/ledger-cli/ # `wanderbooks`: the one executable (every mode) diff --git a/bookspec/README.md b/bookspec/README.md index 42db595..0d36835 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -65,8 +65,10 @@ 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 +(`lines`), a reversal (`reverses`), a close (`close`, below) or a bill +(`bill`, below), exactly one of them, and posts entry #N. A change that +posts no entry (a rename, a policy) is a `[[changes]]` entry instead +(below), so the numbering holds. 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 @@ -89,6 +91,14 @@ the side gives the direction. 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. +- `header = true` on an account: a header, which groups other accounts + and takes no postings (it needs no tax category: rules.md B-32). +- `tax_category` on an account: its tax category, a key of the books' + catalog (`gross_receipts`, `office_expense`, ...; `ledger_core::tax`), + its link to a line of the return (B-32). +- `today` (in `[setup]`): the day the books are asked on, `YYYY-MM-DD`, + in the company's time zone: what an omitted date means (B-30). A query + that omits a date needs it. - `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 @@ -118,14 +128,30 @@ the side gives the direction. 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). +- `bill = { vendor, amount }` as an event: a bill from `vendor` (a name; + a payee's record if one has it) for `amount`, dated and due on the + event's date. It posts entry #N: General Expenses (6999) debited and + Accounts Payable (2000) credited, each account created with that fixed + name when first needed, as the books create a control role's account + (B-32). +- `[[changes]]` `{ after, rename?, require_tax_category? }`: a change + that posts no entry, exactly one of `rename = { code, name }` (rename + the account `code`, B-31) and `require_tax_category = "warn"` or + `"refuse"` (the policy, B-32; the books start at `warn`). It is made + right after event `after` (1 to the number of events), once the + expectations asked after that event are checked, in the order listed. +- `after = N` on an expectation: asked of the books after event N (before + the changes made after it), not after the last event and every change + (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. +`as_of`; the other is refused. A date left out is resolved by the one +rule (rules.md B-30) on `setup.today`: an omitted `to` is today, an +omitted `from` the first day of the fiscal year that holds `to`, an +omitted `as_of` today (`vendor_1099_paid` and the format queries give +theirs). A query no crate answers is refused, never skipped. | query | dates | values | checked by | |---|---|---|---| @@ -136,11 +162,39 @@ skipped. | `balances` | `as_of` | each account named by code, its balance on its normal side (a member's capital after a loss is negative); the example names the accounts it shows | `ledger-core` `tests/bookspec.rs` | | `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `".beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `vendor_1099_paid` | `from` (January 1 of `to`'s year), `to` | each payee's payments by name, out of the cash and financing accounts (`cash_accounts` required), every payee listed | `ledger-core` `tests/bookspec.rs` | +| `period` | `from`, `to`, either or both left out | the dates a period report asked with those dates covers, by the one rule (B-30): `from`, `to` (`YYYY-MM-DD`); or `refused` | `ledger-core` `tests/bookspec.rs` | +| `day` | `as_of`, or left out | the day a report as of a day is made as of: `as_of` | `ledger-core` `tests/bookspec.rs` | +| `calendar_year` | none | the year a calendar-year count (1099s, party totals) asked without one counts: `year` | `ledger-core` `tests/bookspec.rs` | +| `account_names` | none | each account's name by code, as the chart holds it and every report prints it, past periods included (B-31); every account listed, those the books created included | `ledger-core` `tests/bookspec.rs` | +| `uncategorized_accounts` | none | a list: every account that needs a tax category (revenue or expense, not a header) and has none, with activity or not, `" "`, by code (B-32) | `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 = [ ... ]`). +format query, and `uncategorized_accounts`, an array (`values = [ ... +]`). `period`, `day` and `calendar_year` give dates and a year, and +`account_names` names, not figures. + +A command is tried on a copy of the books, as the books' own command +does it, and changes nothing checked after it. It shows no values: it is +`accepted = true`, or `refused = ""`. + +| command | keys | | +|---|---|---| +| `rename_account` | `code`, `name` | rename the account (B-31); accepted, it must move no amount and keep the name trimmed | +| `add_account` | `account = { code, name, type, parent?, header?, tax_category? }` | add the account, under the policy in force (B-31, B-32) | +| `set_tax_category` | `code`, `category` (left out: clear it) | set or clear the account's tax category, under the policy in force (B-32) | + +A refusal (a command's, or a `period`'s) is named for what was refused, +never by an engine's message: `end_before_start` (both dates given, the +start after the end), `start_after_today` (only a start, after today, the +end an omitted end means), `blank`, `unchanged` (the name the account has, +trimmed), `sibling_has_it` (another account under the same parent has the +name, ignoring capitals and surrounding space), `holds_a_tin` (a run of +nine digits), `not_one_line` (a line break, tab or other control +character, or past 200 characters), `code_taken`, and +`tax_category_required` (an account that needs a category left without +one under `refuse`). `cash_flow` (rules.md B-14): the statement of cash flows for `[from, to]`. diff --git a/bookspec/examples/default-period-fiscal-year-to-date.toml b/bookspec/examples/default-period-fiscal-year-to-date.toml new file mode 100644 index 0000000..a2d8fbe --- /dev/null +++ b/bookspec/examples/default-period-fiscal-year-to-date.toml @@ -0,0 +1,163 @@ +# A report asked for without dates covers this fiscal year so far. +# +# wanderbooks: P3 defect 16 (the report tools, books.show and the viewer +# each read omitted dates their own way); rules.md B-30; regression tests +# `prop_period_defaults_resolve` (ledger-core tests/properties.rs), +# `one_default_period_everywhere_defect_16` (ledger-mcp +# tests/mcp_stdio.rs), `show_sends_the_dates_it_names_defect_16` and +# `show_refuses_start_after_end_defect_15` (ledger-mcp tests/oracle_mcp.rs). +# +# Expected values (bookspec/README.md): `period` gives the dates a period +# report asked with these dates covers, `day` the day a report as of a day +# is made as of, `calendar_year` the year a calendar-year count counts, +# each by the one rule on `today`; a query that omits a date resolves it +# by the same rule. The amounts fall one to a fiscal year (July to June), +# so each period's figures show which period it was. + +id = "default-period-fiscal-year-to-date" +title = "A report asked for without dates covers this fiscal year so far" +rule = "A period report whose end is omitted MUST end today, in the company's time zone, and one whose start is omitted MUST start on the first day of the fiscal year that holds its end; a start after the end, given or derived, MUST be refused, never swapped. A report as of a day whose day is omitted MUST be as of today, and a count by calendar year whose year is omitted MUST count this calendar year." +status = "CHOICE" +sources = [ + "IRS Publication 538 (Rev. January 2022), Tax year: \"A tax year is an annual accounting period for keeping records and reporting income and expenses.\" (the period: AUTHORITY; docs/spec/sources/irs-periods-and-methods.md)", + "26 U.S.C. §6041(a), \"in any calendar year\"; §6041A(a)(2), \"during such calendar year\" (the calendar-year counts: AUTHORITY; docs/spec/sources/irs-1099-thresholds.md)", + "DECISION, the owner, 2026-10-20 (wanderbooks plan/served.md P3, \"Settled in design\"): the fiscal year to date as the default, everywhere; no authority says what omitted dates mean (wanderbooks docs/spec/rules.md B-30)", +] +tags = ["periods", "reports", "defaults", "fiscal-year"] + +[setup] +currency = "USD" +fiscal_year_start = "07-01" +basis = "accrual" +rounding = "half-even" +today = "2026-10-20" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" + +[[setup.accounts]] +code = "6000" +name = "Software" +type = "expense" + +# Entry 1: in the fiscal year July 2024 to June 2025. +[[events]] +date = "2025-03-15" +description = "Capital contributed" +lines = [ + { account = "1000", side = "debit", amount = "500.00" }, + { account = "3000", side = "credit", amount = "500.00" }, +] + +# Entries 2 and 3: in the fiscal year July 2025 to June 2026. +[[events]] +date = "2025-09-10" +description = "Sale" +lines = [ + { account = "1000", side = "debit", amount = "120.00" }, + { account = "4000", side = "credit", amount = "120.00" }, +] + +[[events]] +date = "2026-02-01" +description = "Software" +lines = [ + { account = "6000", side = "debit", amount = "20.00" }, + { account = "1000", side = "credit", amount = "20.00" }, +] + +# Entry 4: in the fiscal year that holds today, July 2026 to June 2027. +[[events]] +date = "2026-08-03" +description = "Sale" +lines = [ + { account = "1000", side = "debit", amount = "7.00" }, + { account = "4000", side = "credit", amount = "7.00" }, +] + +# Both dates omitted: the fiscal year that holds today, through today. +[[expect]] +query = "period" +values = { from = "2026-07-01", to = "2026-10-20" } + +[[expect]] +query = "profit_and_loss" +values = { "4000" = "7.00", "6000" = "0.00", net_income = "7.00" } + +# Only an end: the fiscal year that holds the end, through it (never the +# first entry's day, never the fiscal year of today). +[[expect]] +query = "period" +to = "2026-03-31" +values = { from = "2025-07-01", to = "2026-03-31" } + +[[expect]] +query = "profit_and_loss" +to = "2026-03-31" +values = { "4000" = "120.00", "6000" = "20.00", net_income = "100.00" } + +# Only a start: through today. +[[expect]] +query = "period" +from = "2025-01-01" +values = { from = "2025-01-01", to = "2026-10-20" } + +[[expect]] +query = "profit_and_loss" +from = "2025-01-01" +values = { "4000" = "127.00", "6000" = "20.00", net_income = "107.00" } + +# A one-day period is a period. +[[expect]] +query = "period" +from = "2025-09-10" +to = "2025-09-10" +values = { from = "2025-09-10", to = "2025-09-10" } + +# A start after the end is refused, never swapped. +[[expect]] +query = "period" +from = "2026-12-31" +to = "2026-01-01" +refused = "end_before_start" + +# Only a start, after today: the end an omitted end means is today, so the +# period would end before it starts. Refused, never swapped. +[[expect]] +query = "period" +from = "2027-01-01" +refused = "start_after_today" + +# A report as of a day: today. +[[expect]] +query = "day" +values = { as_of = "2026-10-20" } + +[[expect]] +query = "trial_balance" +values = { "1000" = "607.00", "3000" = "-500.00", "4000" = "-127.00", "6000" = "20.00" } + +# A count by calendar year: this calendar year, not the fiscal one. +[[expect]] +query = "calendar_year" +values = { year = "2026" } + +# The calendar's first fiscal year: a July year holding a day early in +# year 1 began in year 0, outside the calendar; it starts on the +# calendar's first day. +[[expect]] +query = "period" +to = "0001-03-01" +values = { from = "0001-01-01", to = "0001-03-01" } diff --git a/bookspec/examples/rename-changes-no-amount.toml b/bookspec/examples/rename-changes-no-amount.toml new file mode 100644 index 0000000..6b00161 --- /dev/null +++ b/bookspec/examples/rename-changes-no-amount.toml @@ -0,0 +1,149 @@ +# Renaming an account changes its label, never an amount. +# +# wanderbooks: P3 defect 15 (an account could not be renamed); rules.md +# B-8, B-31; regression tests `rename_account_changes_only_the_name_defect_15` +# and `prop_rename_changes_no_figure` (ledger-store tests/store.rs), +# `rename_account_end_to_end_defect_15` (ledger-mcp tests/mcp_stdio.rs). +# The journal's record of the old name beside the new (`account.renamed`) +# is the store's, checked there. +# +# Expected values (bookspec/README.md): the same trial balance before the +# rename (`after = 2`) and after it; `account_names` gives each account's +# name as every report prints it, past periods included; each +# `rename_account` is tried on a copy of the books, after the rename. + +id = "rename-changes-no-amount" +title = "Renaming an account changes its label, never an amount" +rule = "Entries name accounts by identity, not by name, so renaming an account MUST change no posted entry, line, balance or statement amount, and every report, past periods included, MUST print the new name. A new name MUST be refused when it is blank, the name the account has, one another account under the same parent has (ignoring capitals and surrounding space), one that holds a run of nine digits, or not one line of at most 200 characters." +status = "AUTHORITY" +sources = [ + "Pacioli, Summa de arithmetica (1494), ch. 36: \"You should not write backward, but always forward\" (posted is forever: AUTHORITY; docs/spec/sources/pacioli-1494.md)", + "Rev. Proc. 98-25 §5.01: \"the relationship (i.e., audit trail): (a) between the total of the amounts in the taxpayer's machine-sensible records by account and the account totals in the taxpayer's books\" (an amount never moves with a name: AUTHORITY; docs/spec/sources/irs-records.md)", + "Intuit IIF Import Kit, !ACCNT: accounts are identified by NAME, so two siblings of one name would be one account in QuickBooks (CONVENTION; docs/spec/sources/intuit-iif.md)", + "DECISION, the owner, 2026-10-20 (wanderbooks plan/served.md P3, \"Settled in design\"): the name rules (wanderbooks docs/spec/rules.md B-31)", +] +tags = ["chart", "rename", "audit-trail", "posted-is-forever"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6000" +name = "Operating" +type = "expense" +header = true + +[[setup.accounts]] +code = "6150" +name = "Software" +type = "expense" +parent = "6000" + +[[setup.accounts]] +code = "6160" +name = "Hosting" +type = "expense" +parent = "6000" + +# Entry 1. +[[events]] +date = "2025-11-04" +description = "Capital contributed" +lines = [ + { account = "1000", side = "debit", amount = "800.00" }, + { account = "3000", side = "credit", amount = "800.00" }, +] + +# Entry 2: posted to 6150 under its old name. +[[events]] +date = "2025-11-20" +description = "Software" +lines = [ + { account = "6150", side = "debit", amount = "29.00" }, + { account = "1000", side = "credit", amount = "29.00" }, +] + +# After entry 2, the owner renames 6150. +[[changes]] +after = 2 +rename = { code = "6150", name = "Software subscriptions" } + +# Before the rename. +[[expect]] +query = "account_names" +after = 2 +values = { "1000" = "Cash", "3000" = "Capital", "6000" = "Operating", "6150" = "Software", "6160" = "Hosting" } + +[[expect]] +query = "trial_balance" +as_of = "2025-11-30" +after = 2 +values = { "1000" = "771.00", "3000" = "-800.00", "6000" = "0.00", "6150" = "29.00", "6160" = "0.00" } + +# After it: every amount as before; the new name, past periods included. +[[expect]] +query = "account_names" +values = { "1000" = "Cash", "3000" = "Capital", "6000" = "Operating", "6150" = "Software subscriptions", "6160" = "Hosting" } + +[[expect]] +query = "trial_balance" +as_of = "2025-11-30" +values = { "1000" = "771.00", "3000" = "-800.00", "6000" = "0.00", "6150" = "29.00", "6160" = "0.00" } + +[[expect]] +query = "profit_and_loss" +from = "2025-11-01" +to = "2025-11-30" +values = { "6000" = "0.00", "6150" = "29.00", "6160" = "0.00", net_income = "-29.00" } + +# Refused, changing nothing. +[[expect]] +query = "rename_account" +code = "6150" +name = " " +refused = "blank" + +[[expect]] +query = "rename_account" +code = "6150" +name = " Software subscriptions " +refused = "unchanged" + +[[expect]] +query = "rename_account" +code = "6150" +name = "HOSTING" +refused = "sibling_has_it" + +[[expect]] +query = "rename_account" +code = "6150" +name = "Card 123456789" +refused = "holds_a_tin" + +[[expect]] +query = "rename_account" +code = "6150" +name = "Software\nsubscriptions" +refused = "not_one_line" + +# A name an account under another parent has is no sibling's: accepted, +# moving no amount. +[[expect]] +query = "rename_account" +code = "6150" +name = "Cash" +accepted = true diff --git a/bookspec/examples/tax-category-required.toml b/bookspec/examples/tax-category-required.toml new file mode 100644 index 0000000..d58ab0d --- /dev/null +++ b/bookspec/examples/tax-category-required.toml @@ -0,0 +1,128 @@ +# Every revenue and expense account maps to a line of the return. +# +# wanderbooks: P3 defect 8 (an account made without a tax category went +# unnoticed); rules.md B-32; regression tests +# `add_account_warns_without_tax_category_defect_8` and +# `require_tax_category_refuses_defect_8` (ledger-mcp +# tests/spec_tax_policy.rs), `prop_tax_policy_refuses_exactly` (ledger-store +# tests/store.rs), `needs_attention_flags_every_uncategorized_pl_account_defect_8` +# (ledger-gui tests/view_model.rs). +# +# Expected values (bookspec/README.md): `uncategorized_accounts` lists +# every account that needs a category and has none, by code; each command +# is tried on a copy of the books under the policy in force when it is +# asked: `warn` (the default) after entry 1, `refuse` from then on. + +id = "tax-category-required" +title = "Every revenue and expense account maps to a line of the return" +rule = "A revenue or expense account that is not a header MUST have a tax category, its link to a line of the income tax return, and every such account without one MUST be listed for the human, with activity or not. By default an account created without one is created (and listed); a company MAY refuse to create such an account without one, or to clear one it has. The accounts the books create for a bill, an invoice or a payroll run MUST NOT be refused: they are listed instead." +status = "CHOICE" +sources = [ + "IRS Publication 583 (Rev. December 2024): \"Your books must show your gross income, as well as your deductions and credits.\" (why it matters: AUTHORITY; docs/spec/sources/irs-records.md)", + "Rev. Proc. 98-25 §5.01: the relationship \"between the total of the amounts in the taxpayer's machine-sensible records by account and the taxpayer's return\" (AUTHORITY; docs/spec/sources/irs-records.md)", + "IRS Publication 583: \"Except in a few cases, the law does not require any specific kind of records.\" (so warn or refuse is the company's choice; docs/spec/sources/irs-records.md)", + "DECISION, the owner, 2026-10-20 (wanderbooks plan/served.md P3, \"Settled in design\"): warn by default, header accounts exempt (wanderbooks docs/spec/rules.md B-32)", +] +tags = ["tax", "chart", "policy"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" +tax_category = "gross_receipts" + +[[setup.accounts]] +code = "6000" +name = "Operating" +type = "expense" +header = true + +[[setup.accounts]] +code = "6150" +name = "Software" +type = "expense" +parent = "6000" + +[[setup.accounts]] +code = "6160" +name = "Hosting" +type = "expense" +parent = "6000" + +# Entry 1: Hosting used in the year before; Software never. +[[events]] +date = "2025-06-01" +description = "Hosting" +lines = [ + { account = "6160", side = "debit", amount = "12.00" }, + { account = "1000", side = "credit", amount = "12.00" }, +] + +# Entry 2, under refuse: a bill, whose expense account (General Expenses, +# 6999) the books create without a category, never refused. +[[events]] +date = "2026-02-01" +description = "Hosting, billed" +bill = { vendor = "Example Hosting Co", amount = "42.00" } + +# After entry 1 (and its expectations), the company turns to refuse. +[[changes]] +after = 1 +require_tax_category = "refuse" + +# Under warn: every account without a category listed, active or not +# (the header needs none); one created without one is created. +[[expect]] +query = "uncategorized_accounts" +after = 1 +values = ["6150 Software", "6160 Hosting"] + +[[expect]] +query = "add_account" +after = 1 +account = { code = "6170", name = "Domains", type = "expense" } +accepted = true + +# Under refuse: refused without one, created with one; a header needs +# none; a needed category can't be cleared, but can be set; the accounts +# made earlier stay as they are, and the bill's is listed. +[[expect]] +query = "add_account" +account = { code = "6180", name = "Postage", type = "expense" } +refused = "tax_category_required" + +[[expect]] +query = "add_account" +account = { code = "6180", name = "Postage", type = "expense", tax_category = "office_expense" } +accepted = true + +[[expect]] +query = "add_account" +account = { code = "6900", name = "Other", type = "expense", header = true } +accepted = true + +[[expect]] +query = "set_tax_category" +code = "4000" +refused = "tax_category_required" + +[[expect]] +query = "set_tax_category" +code = "6150" +category = "other_deductions" +accepted = true + +[[expect]] +query = "uncategorized_accounts" +values = ["6150 Software", "6160 Hosting", "6999 General Expenses"] diff --git a/crates/ledger-config/src/codec.rs b/crates/ledger-config/src/codec.rs index 90d6d05..5905597 100644 --- a/crates/ledger-config/src/codec.rs +++ b/crates/ledger-config/src/codec.rs @@ -537,6 +537,34 @@ open_at_start = "ask" ); } + /// INVARIANT 4 for policy.require_tax_category (rules.md B-32): it is + /// `[books] require_tax_category` in a file, and round-trips; a file + /// without it clears it back to warn. + #[test] + fn the_tax_category_policy_round_trips() { + let file = FERNHILL.replace( + "parties = \"require\"\n", + "parties = \"require\"\nrequire_tax_category = \"required\"\n", + ); + let s = imported(&file); + assert_eq!( + s.require_tax_category(), + ledger_core::settings::TaxCategoryPolicy::Refuse + ); + let text = export(&s, "USD"); + assert!(text.contains("require_tax_category = \"refuse\""), "{text}"); + let back = import(&text, None, &s, "USD").unwrap(); + assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes); + let cleared = import(FERNHILL, None, &s, "USD").unwrap(); + assert_eq!( + cleared.changes, + [Change { + key: "policy.require_tax_category".into(), + value: None + }] + ); + } + /// INVARIANT 1: keys the file leaves out are cleared. #[test] fn what_the_file_leaves_out_is_cleared() { diff --git a/crates/ledger-core/src/account.rs b/crates/ledger-core/src/account.rs index 9f2f9b7..996ad60 100644 --- a/crates/ledger-core/src/account.rs +++ b/crates/ledger-core/src/account.rs @@ -56,6 +56,12 @@ impl AccountType { } } +/// The longest name a new account (or a rename) may take, in characters: +/// one line that every report prints, as long as the company's own name +/// may be ([`crate::settings::MAX_TEXT`]). The command's bound, never +/// replay's (rules.md B-28): a journaled name of any length replays. +pub const MAX_ACCOUNT_NAME: usize = crate::settings::MAX_TEXT; + /// A single account in the chart of accounts. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Account { @@ -143,6 +149,18 @@ impl ChartOfAccounts { Ok(()) } + /// Give an account a new name. Only the account must exist: what a + /// new name may be is the command's to check + /// ([`crate::Ledger::rename_account`]), never replay's (rules.md B-28). + pub fn set_name(&mut self, id: AccountId, name: &str) -> Result<(), LedgerError> { + let a = self + .accounts + .get_mut(&id) + .ok_or(LedgerError::UnknownAccount(id))?; + a.name = name.to_string(); + Ok(()) + } + /// Set (or clear) an account's tax category and deductible percent. pub fn set_tax( &mut self, diff --git a/crates/ledger-core/src/calendar.rs b/crates/ledger-core/src/calendar.rs index f447798..1c1c0be 100644 --- a/crates/ledger-core/src/calendar.rs +++ b/crates/ledger-core/src/calendar.rs @@ -18,7 +18,8 @@ //! ``` use crate::LedgerError; -use chrono::NaiveDate; +use crate::settings::Company; +use chrono::{Datelike, NaiveDate}; /// The first day the books keep. pub const FIRST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(1, 1, 1) { @@ -46,6 +47,207 @@ pub fn check(d: NaiveDate) -> Result { } } +/// The month and day a fiscal year starts on, as one every year has: the +/// books' `company.fiscal_year_start` always is (a new value is the first +/// of a month; a journaled one is a day 2001 has, and 2001 is no leap +/// year). Any other counts as January 1. +fn year_start(md: (u32, u32)) -> (u32, u32) { + if NaiveDate::from_ymd_opt(2001, md.0, md.1).is_some() { + md + } else { + (1, 1) + } +} + +/// The first day of the fiscal year that holds `d` (a fiscal year +/// starting on month-day `md`), before any clamp: in year 0 for a day +/// early in year 1, which chrono holds and the books' calendar doesn't. +pub(crate) fn fiscal_year_start_unclamped(md: (u32, u32), d: NaiveDate) -> NaiveDate { + let (m, day) = year_start(md); + // A day every year has (year_start), in d's year or the one before: + // chrono holds both for every date it holds but its very first year, + // where the year before falls back to d itself. + let on = |y: i32| NaiveDate::from_ymd_opt(y, m, day).unwrap_or(d); + let this = on(d.year()); + if this <= d { this } else { on(d.year() - 1) } +} + +/// The fiscal year that holds `d`, `(first day, last day)`, unclamped +/// (`Company::fiscal_year_containing`): the last day is the day before +/// the next start, so the two can't disagree. +pub(crate) fn fiscal_year_unclamped(md: (u32, u32), d: NaiveDate) -> (NaiveDate, NaiveDate) { + let start = fiscal_year_start_unclamped(md, d); + let (m, day) = year_start(md); + let end = NaiveDate::from_ymd_opt(start.year() + 1, m, day) + .and_then(|next| next.pred_opt()) + .unwrap_or(NaiveDate::MAX); + (start, end) +} + +/// The first day of the fiscal year that holds `d`, for a fiscal year +/// starting on month-day `md` (`company.fiscal_year_start`), clamped to +/// [`FIRST_DAY`]. +/// +/// ```text +/// GOAL: the books' annual period: the one fact the fiscal year to date, +/// the CPA package's year, and every default period derive from. +/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md): +/// "A tax year is an annual accounting period for keeping records +/// and reporting income and expenses"; rules.md B-28 (no date +/// outside the calendar). +/// INVARIANTS: +/// 1. For d in the calendar: FIRST_DAY <= result <= d, and d is before +/// the same month-day a year after the result (unless clamped). +/// 2. The result is md in d's year when that is on or before d, else md +/// in the year before; clamped to FIRST_DAY only when that is year +/// 0 (a non-January fiscal year, d early in year 1). +/// 3. NEVER panics, for any md and any chrono date. +/// ``` +pub fn fiscal_year_start_on(md: (u32, u32), d: NaiveDate) -> NaiveDate { + fiscal_year_start_unclamped(md, d).max(FIRST_DAY) +} + +/// What an omitted date means, by the kind of report (rules.md B-30): the +/// text every tool description and date parameter shows, so none can say +/// other than [`PeriodDefaults`] does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PeriodKind { + /// A report over a period (`start`, `end`): profit and loss, cash + /// flow, tax summary, members' equity, the general ledger and + /// journal, an account's register. + Statement, + /// A report at an instant (`as_of`): balance sheet, trial balance, + /// aging, the fixed asset register, the CPA package. + AsOf, + /// A count by calendar year (`year`): 1099s and party totals. + CalendarYear, +} + +impl PeriodKind { + /// The one sentence a tool's description gives for its dates. + pub const fn describe(self) -> &'static str { + match self { + PeriodKind::Statement => { + "Dates are YYYY-MM-DD. Omitted, the period is the fiscal year to date (company.fiscal_year_start): end defaults to today, start to the first day of the fiscal year that holds end. A start after the end is refused." + } + PeriodKind::AsOf => "as_of is YYYY-MM-DD; omitted, today in the company's time zone.", + PeriodKind::CalendarYear => { + "year defaults to this calendar year (1099s and party totals count by calendar year)." + } + } + } +} + +/// A period report's `start` parameter, as every schema documents it. +pub const START_DOC: &str = "First day, YYYY-MM-DD. Omitted: the first day of the fiscal year that holds end (company.fiscal_year_start). A start after the end is refused."; +/// A period report's `end` parameter, as every schema documents it. +pub const END_DOC: &str = "Last day, YYYY-MM-DD. Omitted: today in the company's time zone."; +/// A point-in-time report's `as_of` parameter, as every schema documents +/// it. +pub const AS_OF_DOC: &str = + "As of this day, YYYY-MM-DD. Omitted: today in the company's time zone."; +/// A calendar-year count's `year` parameter, as every schema documents it. +pub const YEAR_DOC: &str = + "Calendar year. Omitted: this calendar year (1099s and party totals count by calendar year)."; + +/// The defaults an omitted date takes, today, under the company's fiscal +/// year: the one rule (rules.md B-30) the report tools, the viewer and +/// the tool descriptions share. Built by [`period_defaults`]. +/// +/// ```text +/// GOAL: one meaning for "no dates", everywhere: a report asked for +/// without dates covers this fiscal year so far. +/// SOURCE: rules.md B-30; IRS Pub. 538 ("an annual accounting period for +/// keeping records and reporting income and expenses"); 26 U.S.C. +/// §6041(a) ("in any calendar year") and §6041A(a)(2) ("during +/// such calendar year") for the calendar-year counts; DECISION +/// (owner, 2026-10-20, plan/served.md P3 "Settled in design"): +/// the fiscal year to date, everywhere. +/// INVARIANTS: +/// 1. statement Ok implies start <= end, both in the calendar. +/// 2. An omitted end is today; an omitted as_of is today. +/// 3. An omitted start is the first day of the fiscal year that holds +/// the resolved end, clamped to FIRST_DAY. +/// 4. Never swapped or clamped to fit: Err iff the resolved start is +/// after the resolved end. +/// 5. statement_range() == statement(None, None). +/// SCENARIOS: +/// 1. IF both dates are given and start > end: SHOULD refuse with +/// InvalidDateRange. +/// 2. IF only start is given and it is after today: SHOULD refuse with +/// StartAfterToday, saying the end defaulted to today. +/// 3. IF a given date is outside the calendar: SHOULD refuse with +/// DateOutOfRange. +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PeriodDefaults { + fiscal_year_start: (u32, u32), + today: NaiveDate, +} + +/// The defaults an omitted date takes for `company`, on `today` (in the +/// company's time zone). +pub fn period_defaults(company: &Company, today: NaiveDate) -> PeriodDefaults { + PeriodDefaults { + fiscal_year_start: company.fiscal_year_start, + today, + } +} + +impl PeriodDefaults { + /// The day the defaults are for. + pub fn today(&self) -> NaiveDate { + self.today + } + + /// A period report with both dates omitted: the first day of the + /// fiscal year that holds today, through today (INVARIANT 5). + pub fn statement_range(&self) -> (NaiveDate, NaiveDate) { + ( + fiscal_year_start_on(self.fiscal_year_start, self.today), + self.today, + ) + } + + /// A period report's dates as given (`None`: omitted), resolved + /// (INVARIANTS 1-4). + pub fn statement( + &self, + start: Option, + end: Option, + ) -> Result<(NaiveDate, NaiveDate), LedgerError> { + let end_given = end.map(check).transpose()?; + let start_given = start.map(check).transpose()?; + let end = end_given.unwrap_or(self.today); + let start = + start_given.unwrap_or_else(|| fiscal_year_start_on(self.fiscal_year_start, end)); + if start > end { + return Err(if end_given.is_some() { + LedgerError::InvalidDateRange { + start: start.to_string(), + end: end.to_string(), + } + } else { + LedgerError::StartAfterToday { + start: start.to_string(), + today: end.to_string(), + } + }); + } + Ok((start, end)) + } + + /// A point-in-time report's day: the one given, else today. + pub fn as_of(&self, given: Option) -> NaiveDate { + given.unwrap_or(self.today) + } + + /// A calendar-year count's year when none is given: today's. + pub fn calendar_year(&self) -> i32 { + self.today.year() + } +} + #[cfg(test)] mod tests { use super::*; @@ -79,4 +281,185 @@ mod tests { assert!(d.pred_opt().is_some() && d.succ_opt().is_some()); } } + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + fn company(md: (u32, u32)) -> Company { + Company { + fiscal_year_start: md, + ..Company::default() + } + } + + /// fiscal_year_start_on INVARIANT 2: md in d's year when on or before + /// d, else the year before; for a calendar year, a July year and a + /// December year, on the start day and the day before it. + #[test] + fn the_fiscal_year_start_is_the_last_start_on_or_before() { + for (md, day, want) in [ + ((1, 1), d(2026, 10, 20), d(2026, 1, 1)), + ((1, 1), d(2026, 1, 1), d(2026, 1, 1)), + ((1, 1), d(2025, 12, 31), d(2025, 1, 1)), + ((7, 1), d(2026, 10, 20), d(2026, 7, 1)), + ((7, 1), d(2026, 7, 1), d(2026, 7, 1)), + ((7, 1), d(2026, 6, 30), d(2025, 7, 1)), + ((7, 1), d(2026, 2, 28), d(2025, 7, 1)), + ((12, 1), d(2026, 11, 30), d(2025, 12, 1)), + ((12, 1), d(2026, 12, 1), d(2026, 12, 1)), + ((12, 1), d(2026, 12, 31), d(2026, 12, 1)), + // A journaled start that isn't the first (replay is lenient). + ((7, 15), d(2026, 7, 14), d(2025, 7, 15)), + ((7, 15), d(2026, 7, 15), d(2026, 7, 15)), + // The calendar's last day. + ((7, 1), LAST_DAY, d(9999, 7, 1)), + ((1, 1), LAST_DAY, d(9999, 1, 1)), + ] { + assert_eq!(fiscal_year_start_on(md, day), want, "{md:?} {day}"); + } + } + + /// INVARIANT 2's clamp: a July year holding a day early in year 1 + /// began in year 0, which the books' calendar doesn't keep. + #[test] + fn a_fiscal_year_from_year_zero_starts_on_the_first_day() { + assert_eq!(fiscal_year_start_unclamped((7, 1), d(1, 3, 1)), d(0, 7, 1)); + assert_eq!(fiscal_year_start_on((7, 1), d(1, 3, 1)), FIRST_DAY); + assert_eq!(fiscal_year_start_on((7, 1), d(1, 7, 1)), d(1, 7, 1)); + assert_eq!(fiscal_year_start_on((1, 1), FIRST_DAY), FIRST_DAY); + } + + /// INVARIANT 3: no month-day and no chrono date panics; a month-day + /// no year has counts as January 1. + #[test] + fn no_month_day_or_date_panics() { + assert_eq!(fiscal_year_start_on((2, 30), d(2026, 5, 5)), d(2026, 1, 1)); + assert_eq!(fiscal_year_start_on((13, 1), d(2026, 5, 5)), d(2026, 1, 1)); + assert_eq!(fiscal_year_start_on((0, 0), d(2026, 5, 5)), d(2026, 1, 1)); + for md in [(1, 1), (7, 1), (12, 31), (2, 28)] { + for day in [NaiveDate::MIN, NaiveDate::MAX, FIRST_DAY, LAST_DAY] { + let _ = fiscal_year_start_on(md, day); + let _ = fiscal_year_start_unclamped(md, day); + } + } + } + + /// PeriodDefaults INVARIANTS 2, 3, 5: omitted dates. + #[test] + fn omitted_dates_are_the_fiscal_year_to_date() { + let today = d(2026, 10, 20); + let p = period_defaults(&company((1, 1)), today); + assert_eq!(p.statement_range(), (d(2026, 1, 1), today)); + assert_eq!(p.statement(None, None), Ok(p.statement_range())); + // Only end: that end's fiscal year, to it. + assert_eq!( + p.statement(None, Some(d(2025, 6, 30))), + Ok((d(2025, 1, 1), d(2025, 6, 30))) + ); + // Only start: through today. + assert_eq!( + p.statement(Some(d(2024, 3, 15)), None), + Ok((d(2024, 3, 15), today)) + ); + let july = period_defaults(&company((7, 1)), today); + assert_eq!(july.statement_range(), (d(2026, 7, 1), today)); + assert_eq!( + july.statement(None, Some(d(2026, 6, 30))), + Ok((d(2025, 7, 1), d(2026, 6, 30))) + ); + assert_eq!( + july.statement(None, Some(d(1, 3, 1))), + Ok((FIRST_DAY, d(1, 3, 1))) + ); + assert_eq!( + july.statement(None, Some(LAST_DAY)), + Ok((d(9999, 7, 1), LAST_DAY)) + ); + assert_eq!(p.as_of(None), today); + assert_eq!(p.as_of(Some(d(2025, 12, 31))), d(2025, 12, 31)); + assert_eq!(p.calendar_year(), 2026); + assert_eq!(july.calendar_year(), 2026); + assert_eq!(p.today(), today); + } + + /// PeriodDefaults INVARIANT 4 and SCENARIOS 1-3: never swapped. + #[test] + fn a_start_after_the_end_is_refused_never_swapped() { + let today = d(2026, 10, 20); + let p = period_defaults(&company((1, 1)), today); + // One day long is a period. + assert_eq!(p.statement(Some(today), Some(today)), Ok((today, today))); + assert_eq!( + p.statement(Some(d(2026, 1, 2)), Some(d(2026, 1, 1))), + Err(LedgerError::InvalidDateRange { + start: "2026-01-02".into(), + end: "2026-01-01".into() + }) + ); + assert_eq!( + p.statement(Some(d(2026, 12, 31)), Some(d(2026, 1, 1))), + Err(LedgerError::InvalidDateRange { + start: "2026-12-31".into(), + end: "2026-01-01".into() + }) + ); + // The end defaulted: the message says so. + let e = p.statement(Some(d(2027, 1, 1)), None).unwrap_err(); + assert_eq!( + e, + LedgerError::StartAfterToday { + start: "2027-01-01".into(), + today: "2026-10-20".into() + } + ); + assert!(e.to_string().contains("today"), "{e}"); + assert!(e.to_string().contains("pass end"), "{e}"); + assert_eq!( + p.statement(Some(d(2026, 10, 21)), None).map(|_| ()), + Err(LedgerError::StartAfterToday { + start: "2026-10-21".into(), + today: "2026-10-20".into() + }) + ); + assert_eq!(p.statement(Some(today), None), Ok((today, today))); + // Outside the calendar. + assert_eq!( + p.statement(None, Some(NaiveDate::MAX)), + Err(LedgerError::DateOutOfRange(NaiveDate::MAX)) + ); + assert_eq!( + p.statement(Some(NaiveDate::MIN), Some(today)), + Err(LedgerError::DateOutOfRange(NaiveDate::MIN)) + ); + } + + /// The descriptions say what the resolver does (B-30), in the words + /// every tool shows. + #[test] + fn the_descriptions_name_the_rule() { + let s = PeriodKind::Statement.describe(); + assert!(s.contains("fiscal year to date")); + assert!(s.contains("end defaults to today")); + assert!(s.contains("A start after the end is refused")); + assert!(PeriodKind::AsOf.describe().contains("today")); + assert!( + PeriodKind::CalendarYear + .describe() + .contains("calendar year") + ); + for text in [ + s, + PeriodKind::AsOf.describe(), + PeriodKind::CalendarYear.describe(), + START_DOC, + END_DOC, + AS_OF_DOC, + YEAR_DOC, + ] { + for stale in ["all time", "beginning of time", "first entry"] { + assert!(!text.contains(stale), "{text}"); + } + } + } } diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 89c74cc..6b60ae3 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -306,6 +306,59 @@ pub enum LedgerError { #[error("account name must not be empty")] EmptyAccountName, + /// A rename to the name the account already has: there is nothing to + /// journal. + #[error("account {code} is already named {name:?}: nothing to rename")] + AccountNameUnchanged { + /// The account's code. + code: String, + /// Its name. + name: String, + }, + + /// A new name (a new account's, or a rename's) that another account + /// under the same parent already has, ignoring capitals: QuickBooks + /// matches accounts by name (docs/spec/sources/intuit-iif.md), so the + /// CPA's import would merge the two. + #[error( + "{sibling} already has the name {name:?} under the same parent (names compare ignoring capitals): QuickBooks matches accounts by name and would merge the two, so choose another name" + )] + DuplicateAccountName { + /// The name refused. + name: String, + /// The account that has it, `"CODE Name"`. + sibling: String, + }, + + /// A new account name holding a run of nine digits: it reads as a full + /// SSN, EIN or account number, and every report prints it. + #[error( + "the account name holds nine digits in a row, which read as a full SSN, EIN or account number, and every report prints it: write only the last four (a full TIN goes only into the vault, books.set_tin)" + )] + AccountNameHoldsTin, + + /// A new account name (a new account's, or a rename's) that isn't one + /// line of text within the bound ([`crate::account::MAX_ACCOUNT_NAME`] + /// characters): every report, export and the IIF print it on one line. + #[error("account name {0}")] + InvalidAccountName(String), + + /// A rename's reason the journal can't keep: blank, too long, holding + /// a control character or a full TIN. + #[error("rename reason: {0}")] + InvalidRenameReason(String), + + /// `policy.require_tax_category = refuse`, and a revenue or expense + /// account (not a placeholder) would be created, or left, without a + /// tax category. + #[error("{}", tax_category_required_text(account, *clearing))] + TaxCategoryRequired { + /// The account, `"CODE Name"`. + account: String, + /// Whether the command would clear the category it has. + clearing: bool, + }, + /// The referenced parent account does not exist. #[error("unknown parent account id {0}")] UnknownParentAccount(u64), @@ -388,7 +441,7 @@ pub enum LedgerError { #[error("invalid members: {0}")] InvalidMembers(String), - /// Cash-flow period end is before period start. + /// A report period's end is before its start. #[error("invalid date range: end {end} is before start {start}")] InvalidDateRange { /// Period start, YYYY-MM-DD. @@ -420,6 +473,18 @@ pub enum LedgerError { #[error("cash flow account roles: {0}")] CashFlowRoles(String), + /// A report period given only a start, after today: an omitted end is + /// today (rules.md B-30), so the period would end before it starts. + #[error( + "invalid date range: start {start} is after the end, which defaults to today ({today}): pass end" + )] + StartAfterToday { + /// Period start, YYYY-MM-DD. + start: String, + /// Today, the end an omitted end means, YYYY-MM-DD. + today: String, + }, + /// The referenced accounting period does not exist. #[error("unknown period id {0}")] UnknownPeriod(u64), @@ -567,3 +632,17 @@ pub enum LedgerError { )] ApprovalNeedsDraft(String), } + +/// The refusal [`LedgerError::TaxCategoryRequired`] gives: what to pass, +/// and where the categories are listed. +fn tax_category_required_text(account: &str, clearing: bool) -> String { + if clearing { + format!( + "policy.require_tax_category is refuse: {account} keeps a tax category (give it another with books.set_tax_category; books.tax_categories lists them)" + ) + } else { + format!( + "policy.require_tax_category is refuse: give {account} a tax_category (books.tax_categories lists them)" + ) + } +} diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 1c38ee1..8d7caf4 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -749,9 +749,13 @@ impl Ledger { /// Add an account to the chart. Returns its id. /// /// ```text - /// GOAL: register an account under a unique code. + /// GOAL: register an account under a unique code and a name no + /// sibling has. /// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (owner, - /// 2026-10-13): a child has its parent's type. + /// 2026-10-13): a child has its parent's type; rules.md B-31 + /// (names: QuickBooks matches accounts by name, + /// docs/spec/sources/intuit-iif.md; no full TIN in what every + /// report prints, B-26; DECISION, owner, 2026-10-20). /// SCENARIOS: /// 1. IF the code is empty or taken: SHOULD refuse. /// 2. IF the parent doesn't exist: SHOULD refuse. @@ -759,10 +763,23 @@ impl Ledger { /// - SHOULD refuse with ParentTypeMismatch /// - NEVER add the account /// BECAUSE a child subdivides its parent. + /// 4. IF the name is blank: SHOULD refuse with EmptyAccountName. + /// 5. IF an account under the same parent (or, for a top-level + /// account, another top-level one) has the name, ignoring + /// capitals and surrounding space: SHOULD refuse with + /// DuplicateAccountName. + /// 6. IF the name holds a run of nine digits (a full SSN, EIN or + /// account number): SHOULD refuse with AccountNameHoldsTin. + /// 7. IF the name, trimmed, is longer than MAX_ACCOUNT_NAME + /// characters, or holds a line break, tab or other control + /// character: SHOULD refuse with InvalidAccountName + /// BECAUSE every report, export and the IIF print it on one line. + /// 8. The name is kept trimmed, as a rename keeps it. /// ``` /// - /// Replay of journals written before the parent rule uses - /// [`Self::replay_add_account`]. + /// Replay of journals written before these rules uses + /// [`Self::replay_add_account`]; the accounts the engine creates for a + /// control role use [`Self::add_control_account`]. pub fn add_account( &mut self, code: &str, @@ -777,7 +794,8 @@ impl Ledger { if code.trim().is_empty() { return Err(LedgerError::EmptyAccountCode); } - if name.trim().is_empty() { + let name = name.trim(); + if name.is_empty() { return Err(LedgerError::EmptyAccountName); } if let Some(p) = parent.and_then(|p| self.chart.get(p)) @@ -790,9 +808,66 @@ impl Ledger { found: account_type, }); } + if self.chart.find_by_code(code).is_some() { + return Err(LedgerError::DuplicateAccountCode(code.to_string())); + } + self.check_account_name(None, name, parent)?; self.replay_add_account(code, name, account_type, parent, placeholder) } + /// Create the account a control role posts to, at its code with its + /// name (invoices, bills, payroll and closes create them when first + /// needed). The engine's own names: never refused for a sibling's + /// name, and never for a missing tax category (rules.md B-32), so an + /// invoice is never refused over a chart label. + pub fn add_control_account(&mut self, c: &ControlAccount) -> Result { + self.replay_add_account(c.code, c.name, c.account_type, None, false) + } + + /// The name rules for a new name (a new account's, or a rename's), + /// given trimmed and not blank: one line of at most + /// [`crate::account::MAX_ACCOUNT_NAME`] characters, no run of nine + /// digits, and none of `account`'s siblings under `parent` has it, + /// ignoring capitals and surrounding space ([`Self::add_account`] + /// SCENARIOS 5-7). `account` is the one being renamed, never its own + /// sibling. + fn check_account_name( + &self, + account: Option, + name: &str, + parent: Option, + ) -> Result<(), LedgerError> { + let max = crate::account::MAX_ACCOUNT_NAME; + if name.chars().count() > max { + return Err(LedgerError::InvalidAccountName(format!( + "is longer than {max} characters: every report prints it on one line" + ))); + } + if name.chars().any(char::is_control) { + return Err(LedgerError::InvalidAccountName( + "holds a line break, tab or other control character: every report prints it on one line".into(), + )); + } + if crate::parties::has_full_tin(name) { + return Err(LedgerError::AccountNameHoldsTin); + } + let key = crate::parties::name_key(name); + let mut siblings: Vec<&Account> = self + .chart + .all() + .filter(|a| a.parent == parent && Some(a.id) != account) + .filter(|a| crate::parties::name_key(&a.name) == key) + .collect(); + siblings.sort_by_key(|a| a.id); + match siblings.first() { + Some(a) => Err(LedgerError::DuplicateAccountName { + name: name.trim().to_string(), + sibling: format!("{} {}", a.code, a.name), + }), + None => Ok(()), + } + } + /// [`Self::add_account`] without the rules added on 2026-10-13 (a /// child's type; a blank code or name), for replaying journals written before them. Never /// for new work. @@ -846,6 +921,141 @@ impl Ledger { self.chart.set_code(account, code) } + /// Give an account a new name: `name`, trimmed. + /// + /// ```text + /// GOAL: let the human rename an account without touching its history. + /// SOURCE: rules.md B-31; DECISION (owner, plan/served.md 4.9 and P3 + /// "Settled in design", 2026-10-20); rules.md B-8 (posted is + /// forever: entries name accounts by id, so a name is a label). + /// INVARIANTS: + /// 1. Only the name changes: the account's id, code, type, parent, + /// placeholder flag, tax category, and every entry posted to it + /// stay as they were, so no entry, balance or statement amount + /// changes; every report, past periods included, prints the new + /// name. + /// 2. A refusal MUST leave the books unchanged. + /// 3. The rules are the command's: replay takes any journaled name + /// for an account that exists (Self::replay_rename_account, + /// rules.md B-28). + /// SCENARIOS: + /// 1. IF the account is unknown: SHOULD refuse with UnknownAccount. + /// 2. IF the name is blank: SHOULD refuse with EmptyAccountName. + /// 3. IF the name, trimmed, is the one it has: SHOULD refuse with + /// AccountNameUnchanged (nothing to journal). + /// 4. IF another account under the same parent has it, ignoring + /// capitals and surrounding space: SHOULD refuse with + /// DuplicateAccountName. + /// 5. IF it holds a run of nine digits: SHOULD refuse with + /// AccountNameHoldsTin. + /// 6. IF it is longer than MAX_ACCOUNT_NAME characters or holds a + /// control character: SHOULD refuse with InvalidAccountName (the + /// rules a new account's name meets, Self::add_account). + /// 7. Approval rules, roles, settings and closed periods never + /// block it: they name accounts by code or id, and a name is no + /// posting. + /// ``` + pub fn rename_account(&mut self, account: AccountId, name: &str) -> Result<(), LedgerError> { + self.check_rename_account(account, name)?; + self.chart.set_name(account, name.trim()) + } + + /// The validation behind [`Self::rename_account`], for stores that + /// journal before mutating. + pub fn check_rename_account(&self, account: AccountId, name: &str) -> Result<(), LedgerError> { + let a = self + .chart + .get(account) + .ok_or(LedgerError::UnknownAccount(account))?; + let name = name.trim(); + if name.is_empty() { + return Err(LedgerError::EmptyAccountName); + } + if name == a.name { + return Err(LedgerError::AccountNameUnchanged { + code: a.code.clone(), + name: a.name.clone(), + }); + } + self.check_account_name(Some(account), name, a.parent) + } + + /// Replay a journaled rename: the account must exist; the name is + /// taken as journaled, whatever later versions refuse in a new one + /// (rules.md B-28). + pub fn replay_rename_account( + &mut self, + account: AccountId, + name: &str, + ) -> Result<(), LedgerError> { + self.chart.set_name(account, name) + } + + /// Check a rename's reason before it is journaled: one to + /// [`crate::settings::MAX_LONG_TEXT`] characters, line breaks and tabs + /// allowed but no other control character, and no full TIN (rules.md + /// B-26). Returns it trimmed. + pub fn check_rename_reason(reason: &str) -> Result { + let r = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT) + .map_err(LedgerError::InvalidRenameReason)?; + if crate::parties::has_full_tin(&r) { + return Err(LedgerError::InvalidRenameReason( + "reason holds nine digits in a row, which read as a full SSN or EIN: write only the last four".into(), + )); + } + Ok(r) + } + + /// Whether `policy.require_tax_category` lets an account be created + /// with `category` (rules.md B-32): under `refuse`, a revenue or + /// expense account that isn't a header needs one. The command's check + /// only, never replay's (rules.md B-28); the accounts the engine + /// creates for a control role never meet it. + pub fn check_tax_category_required( + &self, + code: &str, + name: &str, + account_type: AccountType, + placeholder: bool, + category: Option<&str>, + ) -> Result<(), LedgerError> { + if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse + && crate::tax::needs_category(account_type, placeholder) + && category.is_none() + { + return Err(LedgerError::TaxCategoryRequired { + account: format!("{} {}", code.trim(), name.trim()), + clearing: false, + }); + } + Ok(()) + } + + /// Whether `policy.require_tax_category` lets `account`'s category be + /// set to `category` (rules.md B-32): under `refuse`, an account that + /// needs one ([`crate::tax::account_needs_category`]) keeps one. The + /// command's check only, never replay's. + pub fn check_tax_category_kept( + &self, + account: AccountId, + category: Option<&str>, + ) -> Result<(), LedgerError> { + let a = self + .chart + .get(account) + .ok_or(LedgerError::UnknownAccount(account))?; + if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse + && category.is_none() + && crate::tax::account_needs_category(a) + { + return Err(LedgerError::TaxCategoryRequired { + account: format!("{} {}", a.code, a.name), + clearing: a.tax_category.is_some(), + }); + } + Ok(()) + } + /// The validation behind [`Self::renumber_account`], for stores that /// journal before mutating. pub fn check_renumber_account( @@ -2582,18 +2792,10 @@ impl Ledger { if let Some(&id) = self.roles.get(RETAINED_EARNINGS.key) { return Ok(id); } - Ok( - match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) { - Some(a) => a.id, - None => self.add_account( - periods::RETAINED_EARNINGS_CODE, - "Retained Earnings", - AccountType::Equity, - None, - false, - )?, - }, - ) + Ok(match self.chart.find_by_code(RETAINED_EARNINGS.code) { + Some(a) => a.id, + None => self.add_control_account(&RETAINED_EARNINGS)?, + }) } /// Validate a close target: every account an equity account, targets @@ -3777,18 +3979,18 @@ impl Ledger { fn ensure_control(&mut self, c: &ControlAccount) -> AccountId { match self.roles.get(c.key) { Some(&id) => id, - None => self.ensure_account(c.code, c.name, c.account_type), + None => self.ensure_account(c), } } - /// Ensure an account exists by code, creating it with the given type - /// and name if missing. Returns the account id. - fn ensure_account(&mut self, code: &str, name: &str, account_type: AccountType) -> AccountId { - if let Some(a) = self.chart.find_by_code(code) { + /// Ensure a control role's account exists at its code, creating it + /// (with the role's name and type: [`Self::add_control_account`]) if + /// missing. Returns the account id. + fn ensure_account(&mut self, c: &ControlAccount) -> AccountId { + if let Some(a) = self.chart.find_by_code(c.code) { return a.id; } - self.add_account(code, name, account_type, None, false) - .expect("fresh code cannot fail") + self.add_control_account(c).expect("fresh code cannot fail") } /// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable, diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index ccad369..f244766 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -92,4 +92,6 @@ pub use schedules::{ ScheduleId, ScheduleKind, TemplateLine, }; pub use shares::{PartyMember, PartyShare, Share}; -pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary}; +pub use tax::{ + BookOnlyLine, TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary, +}; diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index 01c4ce4..a16ab86 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -30,11 +30,12 @@ //! D-U-N-S number's own key aside (nine digits that are no TIN). //! 5. Unset keys mean the defaults the settings file had: "Unnamed //! company", a calendar year, accrual basis, receipts above 25.00, -//! parties suggested, approval rules off, the computer's time zone. +//! parties suggested, a missing tax category warned of (not +//! refused), approval rules off, the computer's time zone. //! ``` use crate::TaxForm; -use chrono::{Datelike, NaiveDate}; +use chrono::NaiveDate; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -150,6 +151,8 @@ const ACCRUAL_CASH: &[(&str, &[&str])] = &[("accrual", &[]), ("cash", &[])]; const PARTY_POLICY: &[(&str, &[&str])] = &[("off", &[]), ("suggest", &[]), ("require", &["required"])]; const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])]; +const TAX_CATEGORY_POLICY: &[(&str, &[&str])] = + &[("warn", &[]), ("refuse", &["required", "require"])]; /// A return's key as a setting keeps it. pub fn tax_form_key(f: TaxForm) -> &'static str { @@ -359,6 +362,13 @@ pub static KEYS: &[Key] = &[ sensitivity: Sensitivity::Public, doc: "Whether entries name their counterparty: off, suggest (a draft without one names the likely one), or require (posting is refused until an entry names a party or is marked as having none). Unset: suggest.", }, + Key { + name: "policy.require_tax_category", + toml: ("books", "require_tax_category"), + kind: Kind::Choice(TAX_CATEGORY_POLICY), + sensitivity: Sensitivity::Public, + doc: "Whether a revenue or expense account (not a header) must have a tax category, the line of the return it maps to: warn (it is created, the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; accounts invoices, bills and payroll create are never refused, and are listed). Existing accounts are untouched. Unset: warn.", + }, Key { name: "policy.approval_rules", toml: ("books", "approval_rules"), @@ -491,22 +501,29 @@ impl Company { .collect() } - /// The fiscal year containing `d`: `(first day, last day)`, inclusive. + /// The fiscal year containing `d`: `(first day, last day)`, inclusive, + /// as chrono counts them (unclamped: a July year holding 0001-03-01 + /// starts 0000-07-01). The first day is + /// [`crate::calendar::fiscal_year_start_on`]'s, before its clamp. pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) { - let (m, day) = self.fiscal_year_start; - let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day"); - let start = if on(d.year()) <= d { - on(d.year()) - } else { - on(d.year() - 1) - }; - let end = on(start.year() + 1) - .pred_opt() - .expect("not the minimum date"); - (start, end) + crate::calendar::fiscal_year_unclamped(self.fiscal_year_start, d) } } +/// `policy.require_tax_category`: whether a revenue or expense account +/// must map to a line of the return (rules.md B-32; DECISION, owner, +/// 2026-10-20: warn by default; a company may refuse). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TaxCategoryPolicy { + /// An account is created without one, and the result and Needs + /// attention say so. + #[default] + Warn, + /// The chart tools refuse to create or leave a revenue or expense + /// account (not a header) without one. + Refuse, +} + /// `policy.parties`: whether entries name their counterparty /// (docs/parties-and-attachments.md "Counterparties"; DECISION, owner, /// 2026-10-15: the default suggests, books may require or turn it off). @@ -1286,6 +1303,14 @@ impl Settings { .unwrap_or_default() } + /// `policy.require_tax_category` (unset: warn). + pub fn require_tax_category(&self) -> TaxCategoryPolicy { + match self.str("policy.require_tax_category") { + Some("refuse") => TaxCategoryPolicy::Refuse, + _ => TaxCategoryPolicy::Warn, + } + } + /// `policy.approval_rules`. pub fn approval_rules(&self) -> ApprovalMode { match self.str("policy.approval_rules") { @@ -1563,6 +1588,61 @@ mod tests { assert_eq!(s.get("company.name").unwrap().seq, 7); } + /// policy.require_tax_category (rules.md B-32): warn or refuse, one + /// spelling each ("required" and "require" are refuse), nothing else + /// ("off" is no value: the warning is the floor); unset is warn. + #[test] + fn the_tax_category_policy_is_warn_or_refuse() { + let s = Settings::default(); + assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn); + let one = |v: &str| set(&s, "policy.require_tax_category", json!(v)); + for (spelling, canon) in [ + ("warn", "warn"), + (" Warn ", "warn"), + ("refuse", "refuse"), + ("REFUSE", "refuse"), + ("required", "refuse"), + ("require", "refuse"), + ] { + assert_eq!( + one(spelling).unwrap()[0].value, + Some(json!(canon)), + "{spelling}" + ); + } + for bad in ["off", "none", "", "yes", "true"] { + assert!(one(bad).is_err(), "{bad}"); + } + let mut s = Settings::default(); + s.apply(&one("refuse").unwrap(), &src(), 3).unwrap(); + assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Refuse); + s.apply(&one("warn").unwrap(), &src(), 4).unwrap(); + assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn); + // Cleared: back to the default. + s.apply( + &[Change { + key: "policy.require_tax_category".into(), + value: None, + }], + &src(), + 5, + ) + .unwrap(); + assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn); + // A value only a later version writes reads as the default here + // (replay takes any string, INVARIANT 3). + s.apply( + &[Change { + key: "policy.require_tax_category".into(), + value: Some(json!("refuse_later")), + }], + &src(), + 6, + ) + .unwrap(); + assert_eq!(s.require_tax_category(), TaxCategoryPolicy::Warn); + } + /// INVARIANT 4: no full TIN outside company.ein. #[test] fn no_full_tin_but_the_companys_ein() { diff --git a/crates/ledger-core/src/tax.rs b/crates/ledger-core/src/tax.rs index 1e301bc..c2513c0 100644 --- a/crates/ledger-core/src/tax.rs +++ b/crates/ledger-core/src/tax.rs @@ -148,7 +148,23 @@ pub const CATEGORIES: &[TaxCategory] = &[ 100, "Portfolio interest: partnerships and S corps state it separately (Schedule K). Interest earned in the business itself (on customer accounts, say) is ordinary income: use other_income.", ), + cat( + "form_4797_gain_loss", + "Gain or loss on business property (Form 4797)", + Income, + ALL, + 100, + "Gain or loss on selling or disposing of business property (the gain or loss account of books.dispose_asset). Form 4797 figures the tax gain from the tax basis, not the book one, and splits it: the ordinary part goes on Form 1065 line 6, Form 1120-S line 4 or Form 1120 line 9, and a partnership or S corp states net section 1231 gain separately (Schedule K line 10 or 9). On Schedule C books it goes on Form 4797, never Schedule C. The tax summary lists it apart, at its book amount and in no total: the CPA figures it.", + ), cat("other_income", "Other income", Income, ALL, 100, ""), + cat( + "tax_exempt_income", + "Tax-exempt income", + Income, + ENTITIES, + 0, + "Tax-exempt interest and other tax-exempt income: on no line of page 1 (Form 1065 Schedule K lines 18a-18b, Form 1120-S Schedule K lines 16a-16b, Form 1120 Schedule K item 9 and Schedule M-1 line 7). None of it is taxable: a book-to-tax difference, the income twin of nondeductible.", + ), cat( "cost_of_goods_sold", "Cost of goods sold", @@ -379,6 +395,44 @@ pub fn check_category( Ok(c) } +/// Whether an account of `account_type` maps to a line of the return: +/// revenue and expense accounts do, unless a header (a placeholder takes +/// no postings, so nothing of its own reaches the return). +/// +/// ```text +/// GOAL: the accounts a tax category is for (rules.md B-32). +/// SOURCE: IRS Pub. 583, "Your books must show your gross income, as well +/// as your deductions and credits"; Rev. Proc. 98-25 §5.01, a +/// relationship between "the amounts ... by account and the +/// taxpayer's return" (docs/spec/sources/irs-records.md); DECISION +/// (owner, 2026-10-20): header accounts are exempt. +/// ``` +pub fn needs_category(account_type: AccountType, placeholder: bool) -> bool { + matches!(account_type, AccountType::Revenue | AccountType::Expense) && !placeholder +} + +/// Whether `account` maps to a line of the return ([`needs_category`] +/// of its type and header flag). A header never has a posting of its own +/// to report: no entry posts to one, now or on replay +/// (`JournalEntry::validate` refuses it), and an account's header flag +/// never changes. +pub fn account_needs_category(account: &crate::Account) -> bool { + needs_category(account.account_type, account.placeholder) +} + +/// Every account that maps to a line of the return and has no tax +/// category ([`account_needs_category`]), with or without activity, +/// sorted by code: what Needs attention and the digest list. +pub fn uncategorized_accounts(ledger: &Ledger) -> Vec<&crate::Account> { + let mut out: Vec<&crate::Account> = ledger + .chart() + .all() + .filter(|a| a.tax_category.is_none() && account_needs_category(a)) + .collect(); + out.sort_by(|a, b| a.code.cmp(&b.code).then(a.id.cmp(&b.id))); + out +} + /// One category's line on the tax summary. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct TaxSummaryLine { @@ -405,6 +459,20 @@ pub struct Uncategorized { pub book: Decimal, } +/// A category's book amount that the summary can't place on the return +/// or tax: see [`TaxSummary::form_4797`]. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct BookOnlyLine { + /// Category key. + pub category: String, + /// Category label. + pub label: String, + /// The accounts that roll into it, `"CODE Name"`. + pub accounts: Vec, + /// Book amount: a gain positive, a loss negative. + pub book: Decimal, +} + /// The tax summary for a period: book amounts by tax category, deductible /// amounts, and every account still needing a category. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -427,6 +495,17 @@ pub struct TaxSummary { /// irs-tax-lines.md). Not in [`TaxSummary::book_net`] or `tax_net`. #[serde(default)] pub separately_stated: Vec, + /// Gains and losses on business property ([`on_form_4797`]), at their + /// book amounts, with no tax amount and in no total + /// ([`TaxSummary::book_net`], `tax_net`): Form 4797 figures the tax + /// gain from the tax basis (book and tax depreciation differ) and + /// splits ordinary gain from net section 1231 gain, and the split + /// decides where it goes (Form 1065 line 6 or Schedule K line 10; Form + /// 1120-S line 4 or Schedule K line 9; Form 1120 line 9; on Schedule C + /// books Form 4797 itself, never Schedule C: docs/spec/sources/ + /// irs-tax-lines.md). The CPA figures it, on every return alike. + #[serde(default)] + pub form_4797: Vec, /// P&L accounts with activity and no category. pub uncategorized: Vec, } @@ -506,11 +585,21 @@ pub fn tax_summary( cost_of_goods: vec![], deductions: vec![], separately_stated: vec![], + form_4797: vec![], uncategorized, }; // Catalog order, so the summary reads like the return. for c in CATEGORIES { if let Some(line) = by_cat.remove(c.key) { + if on_form_4797(c.key) { + out.form_4797.push(BookOnlyLine { + category: line.category, + label: line.label, + accounts: line.accounts, + book: line.book, + }); + continue; + } if separately_stated(c.key, form) { out.separately_stated.push(line); continue; @@ -525,6 +614,17 @@ pub fn tax_summary( Ok(out) } +/// Whether this category is a gain or loss on business property, which +/// Form 4797 figures from the tax basis, on every return +/// ([`TaxSummary::form_4797`]). Form 1065 line 6 and Form 1120-S line 4: +/// "Include only ordinary gains or losses"; Schedule K: "Net section 1231 +/// gain (loss) (attach Form 4797)"; the Schedule C instructions: "Form +/// 4797 to report sales, exchanges, and involuntary conversions ... of +/// trade or business property". +pub fn on_form_4797(key: &str) -> bool { + key == "form_4797_gain_loss" +} + /// Whether a partnership or S corporation states this category separately /// to its owners (Schedule K) rather than in ordinary income. Form 1065 /// and 1120-S instructions: Schedule K takes "only taxable portfolio diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index b85e620..fa20e21 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -5,25 +5,31 @@ //! Each example is a setup, events and expected values worked by hand from //! its cited source (`bookspec/README.md` gives the shape). Every example //! must load, whatever it asks or whichever appendix it is in: its shape, -//! its accounts, fixed assets, members and payees, and every event, an -//! entry, a reversal or a close, posting entry #N as event N. The queries -//! the core answers (`cash_flow`, `trial_balance`, `profit_and_loss`, -//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`) are then -//! checked value by value, each after the event it names (`after`) or the -//! last; the formats appendix's (`iif_lines`, `iif_changes`) are checked by -//! ledger-package (`the_bookspec_iif_example_matches_the_engine`), and a -//! query no crate answers is refused. A key the shape doesn't have is -//! refused too, never ignored. +//! its accounts, fixed assets, members and payees, every event (an entry, +//! a reversal, a close or a bill, posting entry #N as event N) and every +//! change that posts no entry (a rename, a policy). The queries the core +//! answers (`cash_flow`, `trial_balance`, `profit_and_loss`, +//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`, `period`, +//! `day`, `calendar_year`, `account_names`, `uncategorized_accounts`) are +//! then checked value by value, and the commands it tries +//! (`rename_account`, `add_account`, `set_tax_category`) accepted or +//! refused on a copy of the books, each after the event it names (`after`) +//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are +//! checked by ledger-package (`the_bookspec_iif_example_matches_the_engine`), +//! and a query no crate answers is refused. A key the shape doesn't have +//! is refused too, never ignored. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use chrono::NaiveDate; +use ledger_core::calendar::{PeriodDefaults, period_defaults}; +use ledger_core::settings::Company; use ledger_core::{ - AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, EntryId, - Form1099, Ledger, MemberCapital, MemberClose, Money, NewAsset, PaymentAccounts, - ReconcilingKind, Side, VendorId, cash_flow, members_equity, profit_and_loss_for_period, - trial_balance_as_of, vendor_1099_report_with_members, + AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, + DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money, + NewAsset, PaymentAccounts, ReconcilingKind, Side, VendorId, cash_flow, members_equity, + profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members, }; use rust_decimal::Decimal; use serde::Deserialize; @@ -43,6 +49,9 @@ struct Example { appendix: Option, setup: Setup, events: Vec, + /// Changes that post no entry, each made after the event it names. + #[serde(default)] + changes: Vec, expect: Vec, } @@ -66,6 +75,11 @@ struct Setup { members: Vec, #[serde(default)] payees: Vec, + /// The day the books are asked on, in the company's time zone: what + /// an omitted date means (rules.md B-30). A query that omits a date + /// needs it. + #[serde(default)] + today: Option, } #[derive(Deserialize)] @@ -78,6 +92,25 @@ struct AccountSpec { /// The parent account's code, declared before it. #[serde(default)] parent: Option, + /// A header account: it groups others and takes no postings. + #[serde(default)] + header: bool, + /// Its tax category, a key of the catalog (rules.md B-32). + #[serde(default)] + tax_category: Option, +} + +impl AccountSpec { + fn account_type(&self, id: &str) -> AccountType { + match self.kind.as_str() { + "asset" => AccountType::Asset, + "liability" => AccountType::Liability, + "equity" => AccountType::Equity, + "revenue" => AccountType::Revenue, + "expense" => AccountType::Expense, + other => panic!("{id}: account type {other:?}"), + } + } } #[derive(Deserialize)] @@ -108,8 +141,8 @@ struct PayeeSpec { form: String, } -/// One event: an entry (`lines`), a reversal (`reverses`) or a close -/// (`close`), exactly one of them. +/// One event: an entry (`lines`), a reversal (`reverses`), a close +/// (`close`) or a bill (`bill`), exactly one of them. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Event { @@ -120,11 +153,48 @@ struct Event { reverses: Option, #[serde(default)] close: Option, + #[serde(default)] + bill: Option, /// The payee (in `setup.payees`) the entry paid. #[serde(default)] payee: Option, } +/// A bill from `vendor` for `amount`, dated and due on the event's date: +/// Dr General Expenses (6999), Cr Accounts Payable (2000), each created +/// with its fixed name when first needed, as the engine creates a control +/// role's account (rules.md B-32). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BillSpec { + vendor: String, + amount: String, +} + +/// A change to the books that posts no entry, made right after event +/// `after` (counting from 1) once the expectations asked after that event +/// are checked: a rename (`rename`) or the tax-category policy +/// (`require_tax_category`), exactly one of them. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ChangeSpec { + after: usize, + #[serde(default)] + rename: Option, + /// `policy.require_tax_category`: "warn" (the books' default) or + /// "refuse" (rules.md B-32). + #[serde(default)] + require_tax_category: Option, +} + +/// Rename the account `code` to `name` (rules.md B-31). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RenameSpec { + code: String, + name: String, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct LineSpec { @@ -158,7 +228,24 @@ struct Expect { entry: Option, /// The books after event N, not after the last. after: Option, - values: Values, + /// The account a `rename_account` or `set_tax_category` command + /// names, by code. + code: Option, + /// The new name a `rename_account` command gives. + name: Option, + /// The category a `set_tax_category` command sets (absent: it clears + /// the account's). + category: Option, + /// The account an `add_account` command adds. + account: Option, + /// A command the engine accepts: `true` (never `false`: a command is + /// accepted or `refused`). + accepted: Option, + /// A command, or a `period`, the engine refuses: why, in the words + /// of [`refusal`]. + refused: Option, + #[serde(default)] + values: Option, } /// What a query expects: figures by key (every figure an exact decimal @@ -182,53 +269,120 @@ impl Values { } impl Expect { + /// The values a query expects; panics when it gives none. + fn values(&self, id: &str) -> &Values { + self.values + .as_ref() + .unwrap_or_else(|| panic!("{id}: {} expects values", self.query)) + } + /// The figures a figure query expects; panics on lines or rows. fn figures(&self, id: &str) -> &BTreeMap { - match &self.values { + match self.values(id) { Values::Figures(f) => f, _ => panic!("{id}: {} expects figures (a table)", self.query), } } - /// The period `[from, to]` a period query covers; panics on `as_of`. - fn period(&self, id: &str) -> (NaiveDate, NaiveDate) { + /// The period `[from, to]` a period query covers, each date omitted + /// resolved by the one rule (rules.md B-30) on `setup.today`; panics + /// on `as_of`, on an omitted date without `setup.today`, and on a + /// period the rule refuses. + fn period(&self, ex: &Example) -> (NaiveDate, NaiveDate) { + self.resolved_period(ex) + .unwrap_or_else(|e| panic!("{}: {}: {e}", ex.id, self.query)) + } + + /// [`Self::period`], or the rule's refusal. + fn resolved_period(&self, ex: &Example) -> Result<(NaiveDate, NaiveDate), LedgerError> { + let id = &ex.id; assert!( self.as_of.is_none(), "{id}: {} covers a period (from, to), not as_of", self.query ); - let from = date( - self.from - .as_deref() - .unwrap_or_else(|| panic!("{id}: {} needs from", self.query)), - id, - ); - let to = date( - self.to - .as_deref() - .unwrap_or_else(|| panic!("{id}: {} needs to", self.query)), - id, - ); - assert!(from <= to, "{id}: {} from {from} after to {to}", self.query); - (from, to) + let from = self.from.as_deref().map(|s| date(s, id)); + let to = self.to.as_deref().map(|s| date(s, id)); + // A `period` query asks the rule itself, both dates given or not. + if let (Some(from), Some(to)) = (from, to) + && self.query != "period" + { + assert!(from <= to, "{id}: {} from {from} after to {to}", self.query); + return Ok((from, to)); + } + defaults(ex, &self.query).statement(from, to) } - /// The day an as-of query is made as of; panics on `from` or `to`. - fn day(&self, id: &str) -> NaiveDate { + /// The day an as-of query is made as of (omitted: `setup.today`, + /// rules.md B-30); panics on `from` or `to`. + fn day(&self, ex: &Example) -> NaiveDate { + let id = &ex.id; assert!( self.from.is_none() && self.to.is_none(), "{id}: {} is as of a day (as_of), not a period", self.query ); - date( - self.as_of - .as_deref() - .unwrap_or_else(|| panic!("{id}: {} needs as_of", self.query)), - id, - ) + match self.as_of.as_deref() { + Some(s) => date(s, id), + None => defaults(ex, &self.query).as_of(None), + } } } +/// What an omitted date means for `ex` (rules.md B-30): its fiscal year +/// on `setup.today`, which `query` needs. +fn defaults(ex: &Example, query: &str) -> PeriodDefaults { + let today = ex.setup.today.as_deref().unwrap_or_else(|| { + panic!( + "{}: {query} omits a date: setup.today says what it means (rules.md B-30)", + ex.id + ) + }); + let company = Company { + fiscal_year_start: fiscal_year_start(ex), + ..Company::default() + }; + period_defaults(&company, date(today, &format!("{} today", ex.id))) +} + +/// `setup.fiscal_year_start`, MM-DD, as (month, day). +fn fiscal_year_start(ex: &Example) -> (u32, u32) { + let s = &ex.setup.fiscal_year_start; + let (m, d) = s + .split_once('-') + .unwrap_or_else(|| panic!("{}: fiscal_year_start {s:?}", ex.id)); + let md = ( + m.parse() + .unwrap_or_else(|_| panic!("{}: fiscal_year_start {s:?}", ex.id)), + d.parse() + .unwrap_or_else(|_| panic!("{}: fiscal_year_start {s:?}", ex.id)), + ); + assert!( + NaiveDate::from_ymd_opt(2001, md.0, md.1).is_some(), + "{}: fiscal_year_start {s:?}", + ex.id + ); + md +} + +/// A refusal in the examples' words (bookspec/README.md), engine-neutral: +/// what was refused, never the engine's message. +fn refusal(e: &LedgerError) -> String { + match e { + LedgerError::InvalidDateRange { .. } => "end_before_start", + LedgerError::StartAfterToday { .. } => "start_after_today", + LedgerError::EmptyAccountName => "blank", + LedgerError::AccountNameUnchanged { .. } => "unchanged", + LedgerError::DuplicateAccountName { .. } => "sibling_has_it", + LedgerError::AccountNameHoldsTin => "holds_a_tin", + LedgerError::InvalidAccountName(_) => "not_one_line", + LedgerError::DuplicateAccountCode(_) => "code_taken", + LedgerError::TaxCategoryRequired { .. } => "tax_category_required", + other => return format!("another refusal: {other}"), + } + .to_string() +} + fn examples_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples") } @@ -260,6 +414,8 @@ struct Books<'a> { members: Vec, /// The payees' vendor records, by name. payees: BTreeMap, + /// The vendors bills came from, by name (a payee's record if one). + vendors: BTreeMap, } impl<'a> Books<'a> { @@ -281,15 +437,10 @@ impl<'a> Books<'a> { ex.id, s.basis ); - let (m, d) = s - .fiscal_year_start - .split_once('-') - .unwrap_or_else(|| panic!("{}: fiscal_year_start {:?}", ex.id, s.fiscal_year_start)); - assert!( - NaiveDate::from_ymd_opt(2001, m.parse().unwrap(), d.parse().unwrap()).is_some(), - "{}: fiscal_year_start", - ex.id - ); + fiscal_year_start(ex); + if let Some(today) = &s.today { + date(today, &format!("{} today", ex.id)); + } assert!( matches!( s.rounding.as_str(), @@ -318,18 +469,15 @@ impl<'a> Books<'a> { .id }; for a in &s.accounts { - let t = match a.kind.as_str() { - "asset" => AccountType::Asset, - "liability" => AccountType::Liability, - "equity" => AccountType::Equity, - "revenue" => AccountType::Revenue, - "expense" => AccountType::Expense, - other => panic!("{}: account type {other:?}", ex.id), - }; // A parent is declared before its subaccounts. let parent = a.parent.as_deref().map(|p| id(&l, p)); - l.add_account(&a.code, &a.name, t, parent, false) + let account = l + .add_account(&a.code, &a.name, a.account_type(&ex.id), parent, a.header) .unwrap_or_else(|e| panic!("{}: account {}: {e}", ex.id, a.code)); + if let Some(c) = &a.tax_category { + l.set_account_tax(account, Some(c), None) + .unwrap_or_else(|e| panic!("{}: account {}: {e}", ex.id, a.code)); + } } let first = ex .events @@ -392,6 +540,7 @@ impl<'a> Books<'a> { l, currency, members, + vendors: payees.clone(), payees, } } @@ -409,10 +558,45 @@ impl<'a> Books<'a> { let ex = &self.ex.id; let when = date(&e.date, &format!("{ex} event {n}")); assert!( - e.payee.is_none() || (e.reverses.is_none() && e.close.is_none()), - "{ex} event {n}: a reversal takes its original's payee, and a close has none" + e.payee.is_none() || (e.reverses.is_none() && e.close.is_none() && e.bill.is_none()), + "{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none" ); let posted = match (&e.close, e.reverses, e.lines.is_empty()) { + (None, None, true) if e.bill.is_some() => { + let b = e.bill.as_ref().unwrap(); + let amount = exact(&b.amount, ex); + assert!( + amount > Decimal::ZERO, + "{ex} event {n}: amounts are positive" + ); + let vendor = match self.vendors.get(&b.vendor) { + Some(&v) => v, + None => { + let v = self + .l + .add_vendor(&b.vendor) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + self.vendors.insert(b.vendor.clone(), v); + v + } + }; + let bill = self + .l + .create_bill( + vendor, + when, + when, + vec![DocumentLine { + description: e.description.clone(), + amount, + }], + ) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + self.l.get_bill(bill).expect("the bill just made").entry_id + } + (_, _, _) if e.bill.is_some() => { + panic!("{ex} event {n}: an event is one of lines, reverses, close or bill") + } (Some(c), None, true) => { let p = self .l @@ -487,7 +671,7 @@ impl<'a> Books<'a> { .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); entry } - _ => panic!("{ex} event {n}: an event is one of lines, reverses or close"), + _ => panic!("{ex} event {n}: an event is one of lines, reverses, close or bill"), }; assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}"); if let Some(p) = &e.payee { @@ -502,12 +686,60 @@ impl<'a> Books<'a> { } } -/// The books an example sets up, with every event applied and nothing -/// checked. +impl Books<'_> { + /// Make a change that posts no entry (after event `after`). + fn change(&mut self, c: &ChangeSpec) { + let ex = &self.ex.id; + let after = c.after; + match (&c.rename, &c.require_tax_category) { + (Some(r), None) => { + let account = self.id(&r.code); + self.l + .rename_account(account, &r.name) + .unwrap_or_else(|e| panic!("{ex} change after {after}: {e}")); + } + (None, Some(policy)) => { + assert!( + matches!(policy.as_str(), "warn" | "refuse"), + "{ex} change after {after}: require_tax_category {policy:?}" + ); + use ledger_core::settings::{Change, Source}; + let source = Source { + decided_by: "bookspec".into(), + decided_on: date("2026-01-01", ex), + citation: None, + note: None, + }; + let changes = self + .l + .check_settings( + &[Change { + key: "policy.require_tax_category".into(), + value: Some(serde_json::json!(policy)), + }], + &source, + ) + .unwrap_or_else(|e| panic!("{ex} change after {after}: {e}")); + self.l + .apply_settings(&changes, &source, 1) + .unwrap_or_else(|e| panic!("{ex} change after {after}: {e}")); + } + _ => panic!( + "{ex} change after {after}: a change is one of rename or require_tax_category" + ), + } + } +} + +/// The books an example sets up, with every event and change applied and +/// nothing checked. fn load(ex: &Example) -> Books<'_> { let mut b = Books::new(ex); for (i, e) in ex.events.iter().enumerate() { b.apply(i as EntryId + 1, e); + for c in ex.changes.iter().filter(|c| c.after == i + 1) { + b.change(c); + } } b } @@ -605,7 +837,7 @@ fn reconciliation_key(l: &ledger_core::ReconcilingLine) -> String { /// Check one `cash_flow` expectation, every value it gives. fn check_cash_flow(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { - let (start, end) = x.period(&ex.id); + let (start, end) = x.period(ex); let values = x.figures(&ex.id); let params = CashFlowParams { start, @@ -687,7 +919,7 @@ fn exactly(ex: &Example, x: &Expect, keys: &BTreeSet) { /// the day, debits less credits (a credit balance negative), every /// account listed (rules.md B-6, B-33). fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) { - let as_of = x.day(&ex.id); + let as_of = x.day(ex); let tb = trial_balance_as_of(l, as_of); assert!(tb.balanced(), "{}: the trial balance balances", ex.id); let keys = ex.setup.accounts.iter().map(|a| a.code.clone()).collect(); @@ -709,7 +941,7 @@ fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) { /// left out, and `net_income`, revenue less expenses; every revenue and /// expense account listed (rules.md B-14, B-33). fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) { - let (start, end) = x.period(&ex.id); + let (start, end) = x.period(ex); let pl = profit_and_loss_for_period(l, start, end, None) .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); let mut keys: BTreeSet = ex @@ -750,7 +982,14 @@ fn check_vendor_1099_paid( payees: &BTreeMap, x: &Expect, ) { - let (from, to) = x.period(&ex.id); + // A calendar-year count, never the fiscal default (rules.md B-30): + // both dates given. + assert!( + x.from.is_some() && x.to.is_some(), + "{}: vendor_1099_paid gives from and to", + ex.id + ); + let (from, to) = x.period(ex); let year = chrono::Datelike::year(&to); assert_eq!( Some(from), @@ -805,14 +1044,16 @@ fn check_format_shape(ex: &Example, x: &Expect) { ex.id, x.query ); - x.day(&ex.id); + // ledger-package reads the as_of itself: given, never defaulted. + assert!(x.as_of.is_some(), "{}: {} gives as_of", ex.id, x.query); + x.day(ex); assert!( x.after.is_none(), "{}: {}: a format query is of the books after every event", ex.id, x.query ); - match (x.query.as_str(), &x.values) { + match (x.query.as_str(), x.values(&ex.id)) { ("iif_lines", Values::Lines(_)) => {} ("iif_changes", Values::Rows(rows)) => { for r in rows { @@ -859,7 +1100,7 @@ fn check_entry(ex: &Example, l: &Ledger, x: &Expect) { /// of the day on its normal side (so a member's capital after a loss is /// negative); the accounts named are the example's choice. fn check_balances(ex: &Example, l: &Ledger, x: &Expect) { - let as_of = x.day(&ex.id); + let as_of = x.day(ex); let tb = trial_balance_as_of(l, as_of); for (code, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {code}", ex.id)); @@ -886,7 +1127,7 @@ fn check_balances(ex: &Example, l: &Ledger, x: &Expect) { /// and `ending`; the figures named are the example's choice (rules.md /// B-13). fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { - let (from, to) = x.period(&ex.id); + let (from, to) = x.period(ex); assert!( !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), "{}: members_equity needs the members and their shares", @@ -919,24 +1160,262 @@ fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x: } } +/// The commands an example may try: each on a copy of the books, so it +/// changes nothing that is checked after it. +const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"]; + +/// The account `code` names, for a command. +fn command_account(ex: &Example, l: &Ledger, x: &Expect) -> ledger_core::AccountId { + let code = x + .code + .as_deref() + .unwrap_or_else(|| panic!("{}: {} names its account (code)", ex.id, x.query)); + l.chart() + .find_by_code(code) + .unwrap_or_else(|| panic!("{}: no account {code}", ex.id)) + .id +} + +/// Every balance, by code: what a change that moves no amount leaves as +/// it was. +fn every_balance(l: &Ledger) -> Vec<(String, Decimal, Decimal)> { + trial_balance_as_of(l, ledger_core::calendar::LAST_DAY) + .lines + .into_iter() + .map(|t| (t.code, t.debit, t.credit)) + .collect() +} + +/// Try one command on a copy of the books, as the books' own command does +/// it (the engine's checks, then the policy's: rules.md B-31, B-32), and +/// check it is accepted or refused for the reason given. +fn check_command(ex: &Example, l: &Ledger, x: &Expect) { + let mut copy = l.clone(); + let result: Result<(), LedgerError> = match x.query.as_str() { + "rename_account" => { + let account = command_account(ex, l, x); + let name = x + .name + .as_deref() + .unwrap_or_else(|| panic!("{}: rename_account gives the name", ex.id)); + copy.rename_account(account, name).inspect(|()| { + // A rename changes the label, nothing else (rules.md B-31). + assert_eq!( + copy.chart().get(account).unwrap().name, + name.trim(), + "{}: rename_account keeps the name trimmed", + ex.id + ); + assert_eq!( + every_balance(©), + every_balance(l), + "{}: a rename moves no amount", + ex.id + ); + }) + } + "add_account" => { + let a = x + .account + .as_ref() + .unwrap_or_else(|| panic!("{}: add_account gives the account", ex.id)); + let kind = a.account_type(&ex.id); + let parent = a.parent.as_deref().map(|p| { + l.chart() + .find_by_code(p) + .unwrap_or_else(|| panic!("{}: no account {p}", ex.id)) + .id + }); + copy.add_account(&a.code, &a.name, kind, parent, a.header) + .and_then(|id| match &a.tax_category { + Some(c) => copy.set_account_tax(id, Some(c), None), + None => Ok(()), + }) + .and_then(|()| { + l.check_tax_category_required( + &a.code, + &a.name, + kind, + a.header, + a.tax_category.as_deref(), + ) + }) + } + "set_tax_category" => { + let account = command_account(ex, l, x); + let category = x.category.as_deref(); + copy.set_account_tax(account, category, None) + .and_then(|()| l.check_tax_category_kept(account, category)) + } + q => unreachable!("{q} is no command"), + }; + match (result, &x.refused) { + (Ok(()), None) => {} + (Err(e), Some(want)) => { + assert_eq!(refusal(&e), *want, "{}: {} refused ({e})", ex.id, x.query) + } + (Err(e), None) => panic!("{}: {} refused ({e}), expected accepted", ex.id, x.query), + (Ok(()), Some(want)) => panic!("{}: {} accepted, expected refused: {want}", ex.id, x.query), + } +} + +/// Check one `period` expectation: the period a period report asked with +/// these dates covers, by the one rule (rules.md B-30), as `from` and +/// `to`; or the rule's refusal. +fn check_period(ex: &Example, x: &Expect) { + match (x.resolved_period(ex), &x.refused) { + (Ok((from, to)), None) => { + exactly(ex, x, &["from".to_string(), "to".to_string()].into()); + let f = x.figures(&ex.id); + assert_eq!(from, date(&f["from"], &ex.id), "{}: period from", ex.id); + assert_eq!(to, date(&f["to"], &ex.id), "{}: period to", ex.id); + } + (Err(e), Some(want)) => assert_eq!(refusal(&e), *want, "{}: period refused ({e})", ex.id), + (Err(e), None) => panic!("{}: period refused ({e})", ex.id), + (Ok((from, to)), Some(want)) => { + panic!("{}: period {from} .. {to}, expected refused: {want}", ex.id) + } + } +} + +/// Check one `day` expectation: the day a report as of a day is made as +/// of (rules.md B-30), as `as_of`. +fn check_day(ex: &Example, x: &Expect) { + exactly(ex, x, &["as_of".to_string()].into()); + let want = date(&x.figures(&ex.id)["as_of"], &ex.id); + assert_eq!(x.day(ex), want, "{}: day", ex.id); +} + +/// Check one `calendar_year` expectation: the year a calendar-year count +/// (1099s, party totals) asked without one counts (rules.md B-30). +fn check_calendar_year(ex: &Example, x: &Expect) { + assert!( + x.from.is_none() && x.to.is_none() && x.as_of.is_none(), + "{}: calendar_year takes no dates", + ex.id + ); + exactly(ex, x, &["year".to_string()].into()); + let want: i32 = x.figures(&ex.id)["year"] + .parse() + .unwrap_or_else(|e| panic!("{}: calendar_year year: {e}", ex.id)); + assert_eq!( + defaults(ex, &x.query).calendar_year(), + want, + "{}: calendar_year", + ex.id + ); +} + +/// Check one `account_names` expectation: each account's name, by code, +/// as the chart holds it and every report prints it (the trial balance +/// and the profit and loss, over every date: past periods too, rules.md +/// B-31); every account listed. +fn check_account_names(ex: &Example, l: &Ledger, x: &Expect) { + assert!( + x.from.is_none() && x.to.is_none() && x.as_of.is_none(), + "{}: account_names takes no dates", + ex.id + ); + let keys = l.chart().all().map(|a| a.code.clone()).collect(); + exactly(ex, x, &keys); + let (first, last) = ( + ledger_core::calendar::FIRST_DAY, + ledger_core::calendar::LAST_DAY, + ); + let tb = trial_balance_as_of(l, last); + let pl = profit_and_loss_for_period(l, first, last, None) + .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); + for (code, want) in x.figures(&ex.id) { + let a = l.chart().find_by_code(code).unwrap(); + assert_eq!(&a.name, want, "{}: account {code}'s name", ex.id); + let printed = tb.lines.iter().map(|t| (&t.code, &t.name)).chain( + pl.revenue + .iter() + .chain(&pl.expenses) + .map(|s| (&s.code, &s.name)), + ); + for (c, name) in printed.filter(|(c, _)| *c == code) { + assert_eq!(name, want, "{}: a report prints {c} as {name:?}", ex.id); + } + } +} + +/// Check one `uncategorized_accounts` expectation: every account that +/// maps to a line of the return and has no tax category, with activity or +/// not, as `" "`, by code (rules.md B-32). +fn check_uncategorized_accounts(ex: &Example, l: &Ledger, x: &Expect) { + assert!( + x.from.is_none() && x.to.is_none() && x.as_of.is_none(), + "{}: uncategorized_accounts takes no dates", + ex.id + ); + let Values::Lines(want) = x.values(&ex.id) else { + panic!("{}: uncategorized_accounts expects a list", ex.id) + }; + let got: Vec = ledger_core::tax::uncategorized_accounts(l) + .into_iter() + .map(|a| format!("{} {}", a.code, a.name)) + .collect(); + assert_eq!(&got, want, "{}: uncategorized_accounts", ex.id); +} + /// Check one expectation against the books as they stand: a query the /// core answers, value by value; a format query's shape, its values left /// to the test [`FORMAT_QUERIES`] names (returned); a query no crate /// answers is refused. fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { let (ex, l) = (b.ex, &b.l); - assert!( - !x.values.is_empty(), - "{}: {} expects nothing", - ex.id, - x.query + let q = x.query.as_str(); + let only = |given: bool, key: &str, queries: &[&str]| { + assert!( + !given || queries.contains(&q), + "{}: {q}: {key} is for {} only", + ex.id, + queries.join(" and ") + ); + }; + only(x.entry.is_some(), "entry", &["entry"]); + only( + x.code.is_some(), + "code", + &["rename_account", "set_tax_category"], ); + only(x.name.is_some(), "name", &["rename_account"]); + only(x.category.is_some(), "category", &["set_tax_category"]); + only(x.account.is_some(), "account", &["add_account"]); + only(x.accepted.is_some(), "accepted", &COMMANDS); + only( + x.refused.is_some(), + "refused", + &[ + "rename_account", + "add_account", + "set_tax_category", + "period", + ], + ); + if COMMANDS.contains(&q) { + // A command is accepted or refused, and shows no values. + assert!( + x.values.is_none() && (x.accepted == Some(true)) != x.refused.is_some(), + "{}: {q} is a command: accepted = true, or refused, and no values", + ex.id + ); + check_command(ex, l, x); + return None; + } + // Anything else shows its values; a period may be refused instead. assert!( - x.entry.is_none() || x.query == "entry", - "{}: only an entry query names an entry", + x.refused.is_some() != x.values.as_ref().is_some_and(|v| !v.is_empty()), + "{}: {q} expects values (a period: values or refused)", ex.id ); - match x.query.as_str() { + match q { + "period" => check_period(ex, x), + "day" => check_day(ex, x), + "calendar_year" => check_calendar_year(ex, x), + "account_names" => check_account_names(ex, l, x), + "uncategorized_accounts" => check_uncategorized_accounts(ex, l, x), "cash_flow" => check_cash_flow(ex, l, &b.members, x), "trial_balance" => check_trial_balance(ex, l, x), "profit_and_loss" => check_profit_and_loss(ex, l, x), @@ -957,16 +1436,20 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { } /// Load an example and check every expectation: each one with `after = N` -/// on the books after event N, the rest after the last. Gives each query -/// checked, with the test of another crate that checks its values, if -/// any. +/// on the books after event N (before the changes made after it), the +/// rest after the last event and every change. Gives each query checked, +/// with the test of another crate that checks its values, if any. fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { - for x in &ex.expect { + let afters = ex + .expect + .iter() + .map(|x| x.after) + .chain(ex.changes.iter().map(|c| Some(c.after))); + for after in afters { assert!( - x.after.is_none_or(|n| n >= 1 && n <= ex.events.len()), - "{}: after = {:?}: events count from 1, to {}", + after.is_none_or(|n| n >= 1 && n <= ex.events.len()), + "{}: after = {after:?}: events count from 1, to {}", ex.id, - x.after, ex.events.len() ); } @@ -977,6 +1460,9 @@ fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { for x in ex.expect.iter().filter(|x| x.after == Some(i + 1)) { out.push((x.query.clone(), check(&b, x))); } + for c in ex.changes.iter().filter(|c| c.after == i + 1) { + b.change(c); + } } for x in ex.expect.iter().filter(|x| x.after.is_none()) { out.push((x.query.clone(), check(&b, x))); @@ -1021,8 +1507,9 @@ fn every_bookspec_example_holds() { } } // Each query the examples use ran: the cash flow's six examples at - // least, the as-of and 1099 examples', the members closes' and the - // formats appendix's. + // least, the as-of and 1099 examples', the members closes', the + // formats appendix's, and P3's default period, rename and tax + // category examples'. for (q, at_least) in [ ("cash_flow", 6), ("trial_balance", 2), @@ -1033,6 +1520,14 @@ fn every_bookspec_example_holds() { ("members_equity", 5), ("iif_lines", 1), ("iif_changes", 1), + ("period", 6), + ("day", 1), + ("calendar_year", 1), + ("account_names", 2), + ("uncategorized_accounts", 2), + ("rename_account", 5), + ("add_account", 4), + ("set_tax_category", 2), ] { let n = checked.get(q).copied().unwrap_or(0); assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}"); @@ -1259,3 +1754,91 @@ fn a_members_close_declared_with_another_rounding_is_refused() { ) }); } + +/// A period's dates are the rule's (rules.md B-30): an example saying the +/// fiscal year to date starts on January 1 under a July fiscal year fails. +#[test] +#[should_panic(expected = "period from")] +fn a_period_other_than_the_rules_is_refused() { + run_edited("default-period-fiscal-year-to-date.toml", |t| { + t.replacen( + "values = { from = \"2026-07-01\", to = \"2026-10-20\" }", + "values = { from = \"2026-01-01\", to = \"2026-10-20\" }", + 1, + ) + }); +} + +/// A refusal is checked for its reason: a start after today is not "the +/// end before the start". +#[test] +#[should_panic(expected = "period refused")] +fn a_refusal_for_another_reason_is_refused() { + run_edited("default-period-fiscal-year-to-date.toml", |t| { + t.replacen( + "refused = \"start_after_today\"", + "refused = \"end_before_start\"", + 1, + ) + }); +} + +/// A query that omits a date needs `setup.today`: without it, nothing +/// says what the omitted date means. +#[test] +#[should_panic(expected = "setup.today says what it means")] +fn an_omitted_date_without_today_is_refused() { + run_edited("default-period-fiscal-year-to-date.toml", |t| { + t.replacen("today = \"2026-10-20\"\n", "", 1) + }); +} + +/// `account_names` lists every account: one left out is refused. +#[test] +#[should_panic(expected = "lists exactly what it covers")] +fn account_names_leaving_an_account_out_is_refused() { + run_edited("rename-changes-no-amount.toml", |t| { + t.replacen(", \"6160\" = \"Hosting\" }", " }", 1) + }); +} + +/// A change is made after the event it names, once that event's +/// expectations are checked: asked after the rename, the old name fails. +#[test] +#[should_panic(expected = "account 6150's name")] +fn a_change_is_made_after_its_events_expectations() { + run_edited("rename-changes-no-amount.toml", |t| { + t.replacen( + "\"6150\" = \"Software subscriptions\", \"6160\" = \"Hosting\" }", + "\"6150\" = \"Software\", \"6160\" = \"Hosting\" }", + 1, + ) + }); +} + +/// A command is tried under the policy in force when it is asked: without +/// the change to refuse, the account without a category is accepted. +#[test] +#[should_panic(expected = "accepted, expected refused: tax_category_required")] +fn a_command_runs_under_the_policy_in_force() { + run_edited("tax-category-required.toml", |t| { + t.replacen( + "[[changes]]\nafter = 1\nrequire_tax_category = \"refuse\"\n", + "", + 1, + ) + }); +} + +/// A command is accepted or refused, never both, and shows no values. +#[test] +#[should_panic(expected = "is a command")] +fn a_command_with_values_is_refused() { + run_edited("tax-category-required.toml", |t| { + t.replacen( + "category = \"other_deductions\"\naccepted = true\n", + "category = \"other_deductions\"\naccepted = true\nvalues = { x = \"1\" }\n", + 1, + ) + }); +} diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index aa65759..2f43ecb 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -1423,8 +1423,14 @@ fn a_last_absorbs_close_gives_the_rounding_to_the_last_member() { let capital: Vec = ["3001", "3002", "3003"] .iter() .map(|code| { - l.add_account(code, "Capital", AccountType::Equity, None, false) - .unwrap() + l.add_account( + code, + &format!("Capital {code}"), + AccountType::Equity, + None, + false, + ) + .unwrap() }) .collect(); let e = l.create_entry(date(5), "sale"); diff --git a/crates/ledger-core/tests/model.rs b/crates/ledger-core/tests/model.rs index 695c8e5..c4dc279 100644 --- a/crates/ledger-core/tests/model.rs +++ b/crates/ledger-core/tests/model.rs @@ -53,8 +53,10 @@ struct MPeriod { #[derive(Clone, Debug, Default)] struct Model { - /// (id, code, type, placeholder) - accounts: Vec<(u64, String, AccountType, bool)>, + /// (id, code, type, placeholder, name). The name is outside the Lean + /// model (its chart is code and type): kept here so the two agree on + /// renames' refusals, and a rename moves nothing else (rules.md B-31). + accounts: Vec<(u64, String, AccountType, bool, String)>, drafts: Vec, posted: Vec, periods: Vec, @@ -79,7 +81,7 @@ impl Model { } } - fn account(&self, id: u64) -> Option<&(u64, String, AccountType, bool)> { + fn account(&self, id: u64) -> Option<&(u64, String, AccountType, bool, String)> { self.accounts.iter().find(|a| a.0 == id) } @@ -101,13 +103,53 @@ impl Model { .sum() } - fn add_account(&mut self, code: &str, t: AccountType, placeholder: bool) -> bool { - if code.is_empty() || self.accounts.iter().any(|a| a.1 == code) { + fn add_account(&mut self, code: &str, name: &str, t: AccountType, placeholder: bool) -> bool { + if code.is_empty() + || self.accounts.iter().any(|a| a.1 == code) + || !self.name_free(None, name) + { return false; } + self.add_engine_account(code, name, t, placeholder); + true + } + + /// An account the engine makes for a control role: its fixed name, + /// never refused for one. + fn add_engine_account(&mut self, code: &str, name: &str, t: AccountType, placeholder: bool) { self.accounts - .push((self.next_account, code.into(), t, placeholder)); + .push((self.next_account, code.into(), t, placeholder, name.into())); self.next_account += 1; + } + + /// rules.md B-31: a name is free for `account` (None: a new one) when + /// it isn't blank, holds no nine digits in a row, and no other account + /// (all are top-level here, so all are siblings) has it, ignoring + /// capitals and surrounding space. + fn name_free(&self, account: Option, name: &str) -> bool { + let key = name.trim().to_lowercase(); + let nine_digits = name + .as_bytes() + .windows(9) + .any(|w| w.iter().all(u8::is_ascii_digit)); + !key.is_empty() + && !nine_digits + && !self + .accounts + .iter() + .any(|a| Some(a.0) != account && a.4.trim().to_lowercase() == key) + } + + /// rules.md B-31: rename an account that exists to a free name other + /// than its own; only the name changes. + fn rename(&mut self, id: u64, name: &str) -> bool { + let Some(i) = self.accounts.iter().position(|a| a.0 == id) else { + return false; + }; + if name.trim() == self.accounts[i].4 || !self.name_free(Some(id), name) { + return false; + } + self.accounts[i].4 = name.trim().to_string(); true } @@ -278,7 +320,12 @@ impl Model { let re = match self.accounts.iter().find(|a| a.1 == "3999") { Some(a) => a.0, None => { - self.add_account("3999", AccountType::Equity, false); + self.add_engine_account( + "3999", + "Retained Earnings", + AccountType::Equity, + false, + ); self.next_account - 1 } }; @@ -314,6 +361,21 @@ impl Model { // --------------------------------------------------------------------------- const CODES: [&str; 7] = ["1000", "2000", "3000", "3999", "4000", "6000", "1900"]; +/// New names: a free one, its own in other capitals and spacing, a +/// sibling's (the accounts are named for their codes), blank, nine +/// digits, the engine's own name for retained earnings. +const NAMES: [&str; 10] = [ + "Software", + "SOFTWARE", + " software ", + "Hosting", + "", + " ", + "Acct 123456789", + "1000", + "Retained Earnings", + "Rent", +]; const TYPES: [AccountType; 5] = [ AccountType::Asset, AccountType::Liability, @@ -333,6 +395,9 @@ enum Cmd { Reverse(usize, u32), CreatePeriod(u32, u32), Close(usize), + /// Rename an account (rules.md B-31): `Step.record` in the Lean model, + /// whose chart has no names. + Rename(usize, usize), } fn day(n: u32) -> NaiveDate { @@ -353,6 +418,7 @@ fn cmd() -> impl Strategy { 2 => (0usize..8, 0u32..180).prop_map(|(e, d)| Cmd::Reverse(e, d)), 2 => (0u32..180, 0u32..60).prop_map(|(s, len)| Cmd::CreatePeriod(s, s + len)), 2 => (0usize..4).prop_map(Cmd::Close), + 2 => (0usize..8, 0..NAMES.len()).prop_map(|(a, n)| Cmd::Rename(a, n)), ] } @@ -368,7 +434,7 @@ fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) { Cmd::AddAccount(code, t, h) => ( l.add_account(CODES[code], CODES[code], TYPES[t], None, h) .is_ok(), - m.add_account(CODES[code], TYPES[t], h), + m.add_account(CODES[code], CODES[code], TYPES[t], h), ), Cmd::Draft(d) => { let got = l.try_create_entry(day(d), "x").ok(); @@ -437,6 +503,10 @@ fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) { let id = ids.get(i).copied().unwrap_or(999); (l.close_period(id).is_ok(), m.close(i)) } + Cmd::Rename(a, n) => { + let a = pick(&accounts, a); + (l.rename_account(a, NAMES[n]).is_ok(), m.rename(a, NAMES[n])) + } } } @@ -444,9 +514,12 @@ fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) { fn agree(l: &Ledger, m: &Model) -> Result<(), TestCaseError> { // Chart. prop_assert_eq!(l.chart().len(), m.accounts.len()); - for (id, code, t, h) in &m.accounts { + for (id, code, t, h, name) in &m.accounts { let a = l.chart().get(*id).expect("account"); - prop_assert_eq!((&a.code, a.account_type, a.placeholder), (code, *t, *h)); + prop_assert_eq!( + (&a.code, a.account_type, a.placeholder, &a.name), + (code, *t, *h, name) + ); } // The posted log, in order, line for line. let log: Vec<_> = l.posted_entries().collect(); diff --git a/crates/ledger-core/tests/properties.proptest-regressions b/crates/ledger-core/tests/properties.proptest-regressions index 14c8aaf..892c032 100644 --- a/crates/ledger-core/tests/properties.proptest-regressions +++ b/crates/ledger-core/tests/properties.proptest-regressions @@ -6,3 +6,4 @@ # everyone who runs the test benefits from these saved cases. cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200] cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842] +cc 92b493a8beea164b334d7fe6be1609fe029de4e35b22cadf9034736edc8703f5 # shrinks to md = (2, 5), today = 0001-01-01, start = None, end = None diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index 7dde157..f208dd4 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -385,3 +385,89 @@ proptest! { prop_assert_eq!(loss, gain.iter().map(|p| -*p).collect::>()); } } + +/// A fiscal year start as the books may hold one: any month and day 2001 +/// has (a new setting is the first of a month; replay takes any day every +/// year has, rules.md B-28). +fn arb_fiscal_start() -> impl Strategy { + (1u32..=12, 1u32..=31).prop_filter("a day 2001 has", |&(m, d)| { + NaiveDate::from_ymd_opt(2001, m, d).is_some() + }) +} + +/// A day in the books' calendar, 0001-01-01 through 9999-12-31. +fn arb_calendar_day() -> impl Strategy { + let span = (ledger_core::calendar::LAST_DAY - ledger_core::calendar::FIRST_DAY).num_days(); + prop_oneof![ + // Near the edges as often as anywhere: year 1 under a July year + // starts in year 0 (clamped), 9999-12-31 is the last day. + (0i64..800).prop_map(|n| ledger_core::calendar::FIRST_DAY + chrono::Days::new(n as u64)), + (0i64..800).prop_map(|n| ledger_core::calendar::LAST_DAY - chrono::Days::new(n as u64)), + (0i64..=span).prop_map(|n| ledger_core::calendar::FIRST_DAY + chrono::Days::new(n as u64)), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(5000))] + + /// rules.md B-30, PeriodDefaults INVARIANTS 1-5, for every fiscal + /// year start the books hold, every today in the calendar, and every + /// mix of given and omitted dates: the resolved dates are the given + /// ones or the defaults, never swapped; Err exactly when the resolved + /// start is after the resolved end, as InvalidDateRange when both + /// were given and StartAfterToday when the end was defaulted. + #[test] + fn prop_period_defaults_resolve( + md in arb_fiscal_start(), + today in arb_calendar_day(), + start in prop::option::of(arb_calendar_day()), + end in prop::option::of(arb_calendar_day()), + ) { + use ledger_core::calendar::{FIRST_DAY, fiscal_year_start_on, period_defaults}; + use chrono::Datelike; + let company = ledger_core::settings::Company { + fiscal_year_start: md, + ..Default::default() + }; + let p = period_defaults(&company, today); + let want_end = end.unwrap_or(today); + let fy = fiscal_year_start_on(md, want_end); + let want_start = start.unwrap_or(fy); + // The fiscal year start of the end: on or before it, md in its + // own year, and the next one after the end (unless year 0's, + // clamped to the calendar's first day). + prop_assert!(fy <= want_end && fy >= FIRST_DAY); + let next = NaiveDate::from_ymd_opt(fy.year() + 1, md.0, md.1).unwrap(); + if (fy.month(), fy.day()) == md { + prop_assert!(want_end < next, "{fy} {want_end}"); + let this = NaiveDate::from_ymd_opt(want_end.year(), md.0, md.1).unwrap(); + prop_assert_eq!(fy, if this <= want_end { this } else { + NaiveDate::from_ymd_opt(want_end.year() - 1, md.0, md.1).unwrap() + }); + } else { + // Clamped: the year began in year 0. + prop_assert_eq!(fy, FIRST_DAY); + prop_assert!(want_end < NaiveDate::from_ymd_opt(1, md.0, md.1).unwrap()); + } + match p.statement(start, end) { + Ok((s, e)) => { + prop_assert!(want_start <= want_end); + prop_assert_eq!((s, e), (want_start, want_end)); + } + Err(err) => { + prop_assert!(want_start > want_end); + let (s, e) = (want_start.to_string(), want_end.to_string()); + let expected = if end.is_some() { + ledger_core::LedgerError::InvalidDateRange { start: s, end: e } + } else { + ledger_core::LedgerError::StartAfterToday { start: s, today: e } + }; + prop_assert_eq!(err, expected); + } + } + prop_assert_eq!(p.statement(None, None), Ok(p.statement_range())); + prop_assert_eq!(p.as_of(None), today); + prop_assert_eq!(p.as_of(start), start.unwrap_or(today)); + prop_assert_eq!(p.calendar_year(), today.year()); + } +} diff --git a/crates/ledger-core/tests/spec_ledger.rs b/crates/ledger-core/tests/spec_ledger.rs index cdc6f29..1d6a64a 100644 --- a/crates/ledger-core/tests/spec_ledger.rs +++ b/crates/ledger-core/tests/spec_ledger.rs @@ -2539,3 +2539,361 @@ fn renumber_refusals_leave_the_books_unchanged() { l.renumber_account(hosting, "6999").unwrap(); l.renumber_account(cash, "1200").unwrap(); } + +// --------------------------------------------------------------------- +// Ledger::rename_account (P3, defect 15; rules.md B-31). +// --------------------------------------------------------------------- + +proptest! { + /// rename_account INVARIANT 1: only the name changes. Every other part + /// of the ledger is equal, every account keeps its other fields, and + /// the trial balance is the same line for line apart from that name. + #[test] + fn rename_account_changes_only_the_name( + postings in proptest::collection::vec((0usize..4, 0usize..4, 1i64..1_000_000), 0..12), + which in 0usize..4, + name in "[A-Za-z][A-Za-z &-]{0,30}", + ) { + let mut l = Ledger::new(Currency::USD); + let ids: Vec<_> = [ + ("1000", AccountType::Asset), + ("2000", AccountType::Liability), + ("4000", AccountType::Revenue), + ("6100", AccountType::Expense), + ] + .iter() + .map(|(c, t)| l.add_account(c, c, *t, None, false).unwrap()) + .collect(); + for (dr, cr, c) in postings { + if dr == cr { + continue; + } + let e = l.create_entry(d(2026, 1, 5), "x"); + let m = Money::new(Decimal::new(c, 2), Currency::USD); + l.add_line(e, ids[dr], Side::Debit, m).unwrap(); + l.add_line(e, ids[cr], Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + } + // A name the account doesn't have and no sibling has (the others + // are named for their codes: digits). + let new = format!("Renamed {}", name.trim()); + let before = l.clone(); + l.rename_account(ids[which], &new).unwrap(); + prop_assert_eq!(l.differs_from(&before), vec!["chart"]); + for id in &ids { + let (a, b) = (l.chart().get(*id).unwrap(), before.chart().get(*id).unwrap()); + let mut b = b.clone(); + if *id == ids[which] { + b.name = new.trim().to_string(); + } + prop_assert_eq!(a, &b); + } + let (t1, t0) = (trial_balance(&l), trial_balance(&before)); + prop_assert_eq!(t1.total_debits, t0.total_debits); + prop_assert_eq!(t1.total_credits, t0.total_credits); + let pick = |t: &ledger_core::TrialBalance| { + let mut v: Vec<_> = t.lines.iter().map(|x| (x.code.clone(), x.debit, x.credit)).collect(); + v.sort(); + v + }; + prop_assert_eq!(pick(&t1), pick(&t0)); + for e in l.posted_entries() { + prop_assert_eq!(Some(e), before.get_entry(e.id())); + } + } +} + +fn named_chart() -> (Ledger, AccountId, AccountId, AccountId) { + let mut l = Ledger::new(Currency::USD); + let ops = l + .add_account("6000", "Operations", AccountType::Expense, None, true) + .unwrap(); + let software = l + .add_account("6150", "Software", AccountType::Expense, Some(ops), false) + .unwrap(); + let hosting = l + .add_account("6160", "Hosting", AccountType::Expense, Some(ops), false) + .unwrap(); + l.add_account("6900", "Bank Fees", AccountType::Expense, None, false) + .unwrap(); + (l, ops, software, hosting) +} + +/// rename_account SCENARIOS 1-5 and INVARIANT 2: an unknown account, a +/// blank name, the name it has (trimmed), a sibling's name ignoring +/// capitals and surrounding space (top-level accounts are siblings too), +/// and a run of nine digits are refused, leaving the books as they were. +#[test] +fn rename_account_refusals_leave_the_books_unchanged() { + let (mut l, ops, software, hosting) = named_chart(); + let before = l.clone(); + for (id, name, want) in [ + (999, "Tools", LedgerError::UnknownAccount(999)), + (software, "", LedgerError::EmptyAccountName), + (software, " ", LedgerError::EmptyAccountName), + ( + software, + " Software ", + LedgerError::AccountNameUnchanged { + code: "6150".into(), + name: "Software".into(), + }, + ), + ( + software, + "hosting", + LedgerError::DuplicateAccountName { + name: "hosting".into(), + sibling: "6160 Hosting".into(), + }, + ), + ( + software, + " HOSTING ", + LedgerError::DuplicateAccountName { + name: "HOSTING".into(), + sibling: "6160 Hosting".into(), + }, + ), + ( + ops, + "bank fees", + LedgerError::DuplicateAccountName { + name: "bank fees".into(), + sibling: "6900 Bank Fees".into(), + }, + ), + (software, "Card 123456789", LedgerError::AccountNameHoldsTin), + ( + software, + "Acct 12-345-6789", + LedgerError::AccountNameHoldsTin, + ), + ] { + let checked = l.check_rename_account(id, name); + assert_eq!(l.rename_account(id, name), checked, "{name:?}"); + assert_eq!(checked, Err(want), "{name:?}"); + assert!(l.differs_from(&before).is_empty(), "{name:?}"); + } + // Its own name in other capitals is a rename; a name an account under + // another parent has is no sibling's; the last four of a number are + // kept; the name is kept trimmed. + l.rename_account(software, "SOFTWARE").unwrap(); + assert_eq!(l.chart().get(software).unwrap().name, "SOFTWARE"); + l.rename_account(hosting, " Bank Fees ").unwrap(); + assert_eq!(l.chart().get(hosting).unwrap().name, "Bank Fees"); + l.rename_account(software, "Card ending 6789").unwrap(); + assert_eq!(l.chart().get(software).unwrap().name, "Card ending 6789"); +} + +/// rename_account SCENARIO 6: a closed period, a role and a control code +/// never block a rename (they name accounts by code or id, and a name is +/// no posting). +#[test] +fn a_closed_period_or_a_role_never_blocks_a_rename() { + let b = books(); + let mut l = b.l; + post( + &mut l, + d(2026, 1, 10), + &[ + (b.exp, Side::Debit, "10.00"), + (b.cash, Side::Credit, "10.00"), + ], + ); + let p = l + .create_period("January", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + l.close_period(p).unwrap(); + l.assign_role("general_expense", Some(b.exp)).unwrap(); + l.rename_account(b.exp, "Software subscriptions").unwrap(); + assert_eq!(l.chart().get(b.exp).unwrap().name, "Software subscriptions"); + assert_eq!(l.role_account(&ledger_core::GENERAL_EXPENSES), Some(b.exp)); +} + +/// rename_account INVARIANT 3 (rules.md B-28's pair): replay takes a +/// journaled name the command now refuses (blank, a sibling's, nine +/// digits, unchanged) for an account that exists, and refuses only an +/// unknown account. +#[test] +fn a_journaled_rename_replays_past_the_command_rules() { + let (mut l, _, software, _) = named_chart(); + for name in ["", "Hosting", "Card 123456789", "Hosting"] { + l.replay_rename_account(software, name).unwrap(); + assert_eq!(l.chart().get(software).unwrap().name, name); + } + assert_eq!( + l.replay_rename_account(999, "x"), + Err(LedgerError::UnknownAccount(999)) + ); +} + +/// add_account SCENARIOS 5-6 (rules.md B-31): a new account named like a +/// sibling (ignoring capitals and surrounding space; top-level accounts +/// are siblings) or holding a run of nine digits is refused, using up no +/// id; under another parent the name is free. Replay takes them all, and +/// the accounts the engine creates for a control role are never refused +/// for a sibling's name. +#[test] +fn a_new_account_named_like_a_sibling_or_holding_a_tin_is_refused() { + let (mut l, ops, _, _) = named_chart(); + let before = l.clone(); + for (name, parent, want) in [ + ( + "software", + Some(ops), + LedgerError::DuplicateAccountName { + name: "software".into(), + sibling: "6150 Software".into(), + }, + ), + ( + " BANK FEES", + None, + LedgerError::DuplicateAccountName { + name: "BANK FEES".into(), + sibling: "6900 Bank Fees".into(), + }, + ), + ("Loan 123 45 6789", None, LedgerError::AccountNameHoldsTin), + ] { + assert_eq!( + l.add_account("6200", name, AccountType::Expense, parent, false), + Err(want), + "{name:?}" + ); + assert!(l.differs_from(&before).is_empty(), "{name:?}"); + } + // A code in use is still the first refusal. + assert_eq!( + l.add_account("6150", "Software", AccountType::Expense, Some(ops), false), + Err(LedgerError::DuplicateAccountCode("6150".into())) + ); + // Another parent: no sibling. + l.add_account("6200", "Software", AccountType::Expense, None, false) + .unwrap(); + // Replay takes what an earlier version wrote. + l.replay_add_account("6201", "software", AccountType::Expense, Some(ops), false) + .unwrap(); + l.replay_add_account("6202", "Loan 123456789", AccountType::Expense, None, false) + .unwrap(); + // A chart with its own top-level "Revenue" under another code: an + // invoice still creates the revenue role's account (4000 Revenue). + let mut l = Ledger::new(Currency::USD); + l.add_account("4100", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let c = l.add_customer("Pine Street Studio").unwrap(); + l.create_invoice( + c, + d(2026, 3, 1), + d(2026, 3, 31), + vec![DocumentLine { + description: "work".into(), + amount: dec("100.00"), + }], + ) + .unwrap(); + assert_eq!(l.chart().find_by_code("4000").unwrap().name, "Revenue"); +} + +/// check_rename_reason: one line or more of text, at most 2,000 +/// characters, no control character but line breaks and tabs, no run of +/// nine digits; kept trimmed. +#[test] +fn a_rename_reason_is_bounded_text_without_a_tin() { + assert_eq!( + Ledger::check_rename_reason(" owner asked\n2026-10-21 "), + Ok("owner asked\n2026-10-21".to_string()) + ); + for bad in [ + String::new(), + " ".into(), + "a\u{7}b".into(), + "x".repeat(2_001), + "their number is 123456789".into(), + "SSN 123-45-6789".into(), + ] { + assert!( + matches!( + Ledger::check_rename_reason(&bad), + Err(LedgerError::InvalidRenameReason(_)) + ), + "{bad:?}" + ); + } + assert!(Ledger::check_rename_reason(&"x".repeat(2_000)).is_ok()); +} + +/// add_account SCENARIOS 7-8 and rename_account SCENARIO 6 (rules.md +/// B-31): a new name is one line of at most MAX_ACCOUNT_NAME characters, +/// kept trimmed, by a new account and a rename alike: 200 characters are +/// taken, 201 refused, and so is a line break, a tab or another control +/// character; surrounding space is dropped the same way on both paths. +/// Replay takes any journaled name (B-28). +#[test] +fn a_new_name_is_one_trimmed_line_of_bounded_length() { + use ledger_core::account::MAX_ACCOUNT_NAME; + assert_eq!(MAX_ACCOUNT_NAME, 200); + let (mut l, ops, software, _) = named_chart(); + let before = l.clone(); + let long = "N".repeat(MAX_ACCOUNT_NAME + 1); + for name in [ + long.as_str(), + "Two\nlines", + "Tab\there", + "Bell\u{7}", + "Escape \u{1b}[31m", + "Next\u{85}line", + ] { + let add = l.add_account("6200", name, AccountType::Expense, Some(ops), false); + assert!( + matches!(add, Err(LedgerError::InvalidAccountName(_))), + "{name:?}: {add:?}" + ); + let rename = l.check_rename_account(software, name); + assert!( + matches!(rename, Err(LedgerError::InvalidAccountName(_))), + "{name:?}: {rename:?}" + ); + assert_eq!(l.rename_account(software, name), rename); + assert!(l.differs_from(&before).is_empty(), "{name:?}"); + } + let id = l + .add_account( + "6200", + &format!(" {} ", "N".repeat(MAX_ACCOUNT_NAME)), + AccountType::Expense, + Some(ops), + false, + ) + .unwrap(); + assert_eq!( + l.chart().get(id).unwrap().name, + "N".repeat(MAX_ACCOUNT_NAME) + ); + let id = l + .add_account( + "6210", + " Domains \t", + AccountType::Expense, + Some(ops), + false, + ) + .unwrap(); + assert_eq!(l.chart().get(id).unwrap().name, "Domains"); + l.rename_account(id, " Domain names\t").unwrap(); + assert_eq!(l.chart().get(id).unwrap().name, "Domain names"); + // Replay: as journaled. + let id = l + .replay_add_account( + "6220", + " Two\nlines ", + AccountType::Expense, + Some(ops), + false, + ) + .unwrap(); + assert_eq!(l.chart().get(id).unwrap().name, " Two\nlines "); + l.replay_rename_account(id, &long).unwrap(); + assert_eq!(l.chart().get(id).unwrap().name, long); +} diff --git a/crates/ledger-core/tests/spec_roles.rs b/crates/ledger-core/tests/spec_roles.rs index 48f1f8b..c4697b3 100644 --- a/crates/ledger-core/tests/spec_roles.rs +++ b/crates/ledger-core/tests/spec_roles.rs @@ -838,7 +838,12 @@ fn every_balance_sheet_role_holds_while_its_account_has_a_balance() { held.push((c, cur)); } for (i, (c, cur)) in held.iter().enumerate() { - let target = add(&mut l, &format!("{}", 9100 + i), "Target", c.account_type); + let target = add( + &mut l, + &format!("{}", 9100 + i), + &format!("Target {i}"), + c.account_type, + ); assert!( matches!( refuses(&mut l, c.key, Some(target)), @@ -1078,16 +1083,37 @@ fn pool_books() -> (Ledger, Vec) { .into_iter() .enumerate() { - ids.push(add(&mut l, &format!("{}", 8000 + 10 * i), "spare a", t)); - ids.push(add(&mut l, &format!("{}", 8001 + 10 * i), "spare b", t)); + ids.push(add( + &mut l, + &format!("{}", 8000 + 10 * i), + &format!("spare a {i}"), + t, + )); + ids.push(add( + &mut l, + &format!("{}", 8001 + 10 * i), + &format!("spare b {i}"), + t, + )); ids.push( - l.add_account(&format!("{}", 8002 + 10 * i), "group", t, None, true) - .unwrap(), + l.add_account( + &format!("{}", 8002 + 10 * i), + &format!("group {i}"), + t, + None, + true, + ) + .unwrap(), ); } while ids.len() < POOL { let n = ids.len(); - ids.push(add(&mut l, &format!("{}", 8500 + n), "extra", Expense)); + ids.push(add( + &mut l, + &format!("{}", 8500 + n), + &format!("extra {n}"), + Expense, + )); } assert_eq!(ids.len(), POOL); (l, ids) diff --git a/crates/ledger-core/tests/tax.rs b/crates/ledger-core/tests/tax.rs index 1cda2fa..10e398d 100644 --- a/crates/ledger-core/tests/tax.rs +++ b/crates/ledger-core/tests/tax.rs @@ -342,3 +342,336 @@ fn a_sole_proprietor_cannot_deduct_a_gift_on_schedule_c() { .is_ok() ); } + +// --------------------------------------------------------------------------- +// Which accounts need a category, and the policy (P3, defect 8; rules.md +// B-32). +// --------------------------------------------------------------------------- + +fn set_policy(l: &mut Ledger, value: &str) { + use ledger_core::settings::{Change, Source}; + let src = Source { + decided_by: "Owner".into(), + decided_on: d(10, 20), + citation: None, + note: None, + }; + let ch = l + .check_settings( + &[Change { + key: "policy.require_tax_category".into(), + value: Some(serde_json::json!(value)), + }], + &src, + ) + .unwrap(); + l.apply_settings(&ch, &src, 1).unwrap(); +} + +/// needs_category: revenue and expense accounts that aren't headers. A +/// header never reaches the return: no entry posts to one, a replayed one +/// included (review R13 supposed an old journal could; replay refuses it). +#[test] +fn revenue_and_expense_accounts_need_a_category_headers_dont() { + use ledger_core::tax::needs_category; + for (t, header, needs) in [ + (AccountType::Revenue, false, true), + (AccountType::Expense, false, true), + (AccountType::Revenue, true, false), + (AccountType::Expense, true, false), + (AccountType::Asset, false, false), + (AccountType::Liability, false, false), + (AccountType::Equity, false, false), + (AccountType::Asset, true, false), + ] { + assert_eq!(needs_category(t, header), needs, "{t:?} header {header}"); + } + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let group = l + .add_account("6000", "Operating", AccountType::Expense, None, true) + .unwrap(); + let a = l.chart().get(group).unwrap().clone(); + assert!(!ledger_core::tax::account_needs_category(&a)); + let e = l.create_entry(d(2, 1), "a posting to a header"); + let m = Money::new(dec("5.00"), Currency::USD); + l.replay_add_line(e, group, Side::Debit, m).unwrap(); + l.replay_add_line(e, cash, Side::Credit, m).unwrap(); + assert_eq!( + l.replay_post_entry(e), + Err(LedgerError::PlaceholderAccount("6000".into())) + ); +} + +/// uncategorized_accounts (repro 1.4.2-1.4.3): every revenue and expense +/// account without a category, active or not, last year's included, +/// sorted by code; headers without postings and categorized accounts +/// aren't listed. +#[test] +fn every_uncategorized_pl_account_is_listed_active_or_not() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + l.add_account("6000", "Operating", AccountType::Expense, None, true) + .unwrap(); + let software = l + .add_account("6150", "Software", AccountType::Expense, None, false) + .unwrap(); + let last_year = l + .add_account("6160", "Hosting", AccountType::Expense, None, false) + .unwrap(); + let sales = l + .add_account("4000", "Sales", AccountType::Revenue, None, false) + .unwrap(); + l.set_account_tax(sales, Some("gross_receipts"), None) + .unwrap(); + l.add_account("4900", "Other income", AccountType::Revenue, None, false) + .unwrap(); + let e = l.create_entry(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap(), "last year"); + let m = Money::new(dec("12.00"), Currency::USD); + l.add_line(e, last_year, Side::Debit, m).unwrap(); + l.add_line(e, cash, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + let listed: Vec = ledger_core::tax::uncategorized_accounts(&l) + .into_iter() + .map(|a| format!("{} {}", a.code, a.name)) + .collect(); + assert_eq!( + listed, + ["4900 Other income", "6150 Software", "6160 Hosting"] + ); + l.set_account_tax(software, Some("other_deductions"), None) + .unwrap(); + assert_eq!(ledger_core::tax::uncategorized_accounts(&l).len(), 2); +} + +/// check_tax_category_required's truth table: refused iff the policy is +/// refuse, the account needs a category (revenue or expense, not a +/// header) and none is given; the message names the account and where +/// the categories are. +#[test] +fn the_policy_refuses_exactly_a_needed_category_left_out() { + use ledger_core::settings::TaxCategoryPolicy; + let types = [ + AccountType::Asset, + AccountType::Liability, + AccountType::Equity, + AccountType::Revenue, + AccountType::Expense, + ]; + for policy in ["warn", "refuse"] { + let mut l = Ledger::new(Currency::USD); + set_policy(&mut l, policy); + let refuse = l.settings().require_tax_category() == TaxCategoryPolicy::Refuse; + assert_eq!(refuse, policy == "refuse"); + for t in types { + for header in [false, true] { + for category in [None, Some("other_income")] { + let got = + l.check_tax_category_required("6150", "Software", t, header, category); + let needs = matches!(t, AccountType::Revenue | AccountType::Expense) && !header; + if refuse && needs && category.is_none() { + let e = got.unwrap_err(); + assert_eq!( + e, + LedgerError::TaxCategoryRequired { + account: "6150 Software".into(), + clearing: false + } + ); + assert!(e.to_string().contains("books.tax_categories"), "{e}"); + } else { + assert_eq!(got, Ok(()), "{policy} {t:?} {header} {category:?}"); + } + } + } + } + } +} + +/// check_tax_category_kept: under refuse, an account that needs a +/// category can't be left without one (cleared, or "cleared" while it has +/// none); setting one, or any account under warn, passes. +#[test] +fn under_refuse_a_needed_category_is_never_cleared() { + let mut l = Ledger::new(Currency::USD); + let software = l + .add_account("6150", "Software", AccountType::Expense, None, false) + .unwrap(); + let bare = l + .add_account("6160", "Hosting", AccountType::Expense, None, false) + .unwrap(); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + l.set_account_tax(software, Some("other_deductions"), None) + .unwrap(); + for id in [software, bare, cash] { + assert_eq!(l.check_tax_category_kept(id, None), Ok(())); + } + set_policy(&mut l, "refuse"); + assert_eq!( + l.check_tax_category_kept(software, None), + Err(LedgerError::TaxCategoryRequired { + account: "6150 Software".into(), + clearing: true + }) + ); + assert_eq!( + l.check_tax_category_kept(bare, None), + Err(LedgerError::TaxCategoryRequired { + account: "6160 Hosting".into(), + clearing: false + }) + ); + assert_eq!(l.check_tax_category_kept(cash, None), Ok(())); + assert_eq!( + l.check_tax_category_kept(software, Some("supplies")), + Ok(()) + ); + assert_eq!( + l.check_tax_category_kept(999, None), + Err(LedgerError::UnknownAccount(999)) + ); + // The engine's own set_account_tax (which replay calls) never checks + // the policy (rules.md B-28): a journaled clear replays. + l.set_account_tax(software, None, None).unwrap(); +} + +/// rules.md B-32 (review R3): every account a tool can require has a +/// category that fits it on every return: a disposal's gain or loss +/// account (revenue or expense: Form 4797), tax-exempt income on the +/// entity returns, and each control role's account. +#[test] +fn every_account_a_tool_requires_has_a_fitting_category_on_every_return() { + for form in TaxForm::ALL { + for t in [AccountType::Revenue, AccountType::Expense] { + assert!( + check_category("form_4797_gain_loss", t, Some(form)).is_ok(), + "{form:?} {t:?}" + ); + } + for c in ledger_core::control_accounts() + .filter(|c| matches!(c.account_type, AccountType::Revenue | AccountType::Expense)) + { + let fits = ledger_core::tax::CATEGORIES + .iter() + .any(|cat| check_category(cat.key, c.account_type, Some(form)).is_ok()); + assert!(fits, "{} on {form:?}", c.code); + } + let exempt = check_category("tax_exempt_income", AccountType::Revenue, Some(form)); + assert_eq!(exempt.is_ok(), form != TaxForm::ScheduleC, "{form:?}"); + } + let c = ledger_core::tax::category("tax_exempt_income").unwrap(); + assert_eq!(c.default_deductible_percent, 0); + let c = ledger_core::tax::category("form_4797_gain_loss").unwrap(); + assert_eq!(c.default_deductible_percent, 100); +} + +/// Tax-exempt income (Form 1065 Schedule K 18a-18b) is book income with +/// no taxable amount: 0% of 300.00 is 0.00, so book net and tax net +/// differ by it, the income twin of nondeductible. +#[test] +fn tax_exempt_income_is_book_income_with_no_tax_amount() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let muni = l + .add_account( + "4800", + "Municipal bond interest", + AccountType::Revenue, + None, + false, + ) + .unwrap(); + l.set_account_tax(muni, Some("tax_exempt_income"), None) + .unwrap(); + let e = l.create_entry(d(3, 1), "coupon"); + let m = Money::new(dec("300.00"), Currency::USD); + l.add_line(e, cash, Side::Debit, m).unwrap(); + l.add_line(e, muni, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + let s = tax_summary(&l, d(1, 1), d(12, 31), Some(TaxForm::Form1065)).unwrap(); + assert_eq!(s.income.len(), 1); + assert_eq!(s.income[0].category, "tax_exempt_income"); + assert_eq!(s.income[0].book, dec("300.00")); + assert_eq!(s.income[0].tax, dec("0.00")); + assert_eq!(s.book_net(), dec("300.00")); + assert_eq!(s.tax_net(), dec("0.00")); +} + +/// Oracle: docs/spec/sources/irs-tax-lines.md. A gain or loss on business +/// property is never ordinary income the summary can place: Form 1065 +/// line 6 and Form 1120-S line 4 take "only ordinary gains or losses" +/// from Form 4797, net section 1231 gain is Schedule K line 10 (1065) or +/// line 9 (1120-S), and the Schedule C instructions send sales of +/// business property to "Form 4797", not Schedule C. Form 4797 figures +/// the gain from the tax basis, which the books don't keep. So on every +/// return (and with none set) the 150.00 gain and the 40.00 loss are +/// listed apart at their book amounts (110.00 net), outside income and +/// both nets: the nets are the 1,000.00 of sales alone. +#[test] +fn a_gain_on_business_property_is_figured_on_form_4797_on_every_return() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let mut acct = |code: &str, name: &str, t, cat: &str| { + let id = l.add_account(code, name, t, None, false).unwrap(); + l.set_account_tax(id, Some(cat), None).unwrap(); + id + }; + let sales = acct("4000", "Sales", AccountType::Revenue, "gross_receipts"); + let gain = acct( + "4700", + "Gain on disposal", + AccountType::Revenue, + "form_4797_gain_loss", + ); + let loss = acct( + "6700", + "Loss on disposal", + AccountType::Expense, + "form_4797_gain_loss", + ); + let usd = |s: &str| Money::new(dec(s), Currency::USD); + for (dr, cr, amt) in [ + (cash, sales, "1000.00"), + (cash, gain, "150.00"), + (loss, cash, "40.00"), + ] { + let e = l.create_entry(d(4, 1), "x"); + l.add_line(e, dr, Side::Debit, usd(amt)).unwrap(); + l.add_line(e, cr, Side::Credit, usd(amt)).unwrap(); + l.post_entry(e).unwrap(); + } + for form in [None].into_iter().chain(TaxForm::ALL.into_iter().map(Some)) { + let ts = tax_summary(&l, d(1, 1), d(12, 31), form).unwrap(); + let keys = |ls: &[ledger_core::TaxSummaryLine]| -> Vec { + ls.iter().map(|x| x.category.clone()).collect() + }; + assert_eq!(keys(&ts.income), ["gross_receipts"], "{form:?}"); + assert!(ts.deductions.is_empty(), "{form:?}"); + assert!(ts.separately_stated.is_empty(), "{form:?}"); + assert_eq!( + ts.form_4797, + [ledger_core::BookOnlyLine { + category: "form_4797_gain_loss".into(), + label: "Gain or loss on business property (Form 4797)".into(), + accounts: vec![ + "4700 Gain on disposal".into(), + "6700 Loss on disposal".into() + ], + book: dec("110.00"), + }], + "{form:?}" + ); + assert_eq!(ts.book_net(), dec("1000.00"), "{form:?}"); + assert_eq!(ts.tax_net(), dec("1000.00"), "{form:?}"); + } +} diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 7186227..6177e38 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -374,15 +374,33 @@ impl App { } /// Go where the agent asked (`books.show`). + /// + /// ```text + /// GOAL: the viewer shows what the agent was told it shows. + /// SOURCE: rules.md B-30 (one default period; a start after the end is + /// refused, never swapped); plan/served.md P3 defect 15. + /// INVARIANTS: + /// 1. A target's dates are taken as the server resolved them, an + /// omitted one (an earlier writer's) by the same rule + /// (Books::periods), never swapped, clamped or guessed. + /// 2. A target the viewer can't honour (an unknown page, report, + /// split or comparison; text that isn't a date; a start after + /// the end; an account, asset or party not in the books) is + /// ignored whole: the page, range and split stay as they were. + /// ``` pub fn show(&mut self, target: ledger_store::ops::ViewerTarget) { use crate::nav::ReportKind; use ledger_store::ops::ViewerTarget as T; let Some(b) = self.books.clone() else { return; }; - let date = |s: &Option| { - s.as_deref() - .and_then(|d| d.parse::().ok()) + // Some(None): omitted; None: text that isn't a date. + let date = |s: &Option| match s.as_deref() { + None => Some(None), + Some(d) => d.parse::().ok().map(Some), + }; + let dates = |start: &Option, end: &Option| { + b.periods().statement(date(start)?, date(end)?).ok() }; let route = match target { T::Page { page } => match page.as_str() { @@ -401,15 +419,17 @@ impl App { }, T::Entry { entry_id } => Route::Entry(entry_id), T::Account { code, start, end } => { + use crate::pages::accounts::RegisterRange; let Some(a) = b.ledger.chart().find_by_code(&code) else { return; }; - self.state.accounts.range = match (date(&start), date(&end)) { - (None, None) => None, - (s, e) => Some(( - s.unwrap_or_else(|| b.kpis.first_entry.unwrap_or(b.today)), - e.unwrap_or(b.today), - )), + let Some((s, e)) = dates(&start, &end) else { + return; + }; + self.state.accounts.range = if start.is_none() && end.is_none() { + RegisterRange::Default + } else { + RegisterRange::Dates(s, e) }; Route::Account(a.id) } @@ -420,17 +440,22 @@ impl App { columns, compare, } => { - use crate::pages::reports::{Columns, Compare}; - self.state.reports.columns = match columns.as_deref() { + use crate::pages::reports::{Columns, Compare, Range}; + let columns = match columns.as_deref() { + None | Some("total") => Columns::Total, Some("month") => Columns::Month, Some("quarter") => Columns::Quarter, Some("year") => Columns::Year, - _ => Columns::Total, + Some(_) => return, }; - self.state.reports.compare = match compare.as_deref() { + let compare = match compare.as_deref() { + None => Compare::None, Some("prior_period") => Compare::PriorPeriod, Some("prior_year") => Compare::PriorYear, - _ => Compare::None, + Some(_) => return, + }; + let Some((s, e)) = dates(&start, &end) else { + return; }; let kind = match report.as_str() { "profit_and_loss" => ReportKind::ProfitAndLoss, @@ -446,13 +471,14 @@ impl App { "fixed_asset_register" => ReportKind::FixedAssetRegister, _ => return, }; - if let (s, e) = (date(&start), date(&end)) - && (s.is_some() || e.is_some()) - { - let (ds, de) = crate::pages::reports::Range::YearToDate.dates(&b); - self.state.reports.range = - crate::pages::reports::Range::Custom(s.unwrap_or(ds), e.unwrap_or(de)); - } + self.state.reports.columns = columns; + self.state.reports.compare = compare; + // The dates as sent, by a preset's name when they are one + // (the default first: "Year to date"), else Custom. + self.state.reports.range = std::iter::once(Range::default()) + .chain(Range::PRESETS) + .find(|r| r.dates(&b) == (s, e)) + .unwrap_or(Range::Custom(s, e)); Route::Reports(kind) } T::Document { attachment_id } => Route::Document(attachment_id), diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index 4b7e8d4..a356f68 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -12,7 +12,6 @@ use crate::books::{Attention, Books, Severity}; use crate::fmt; use crate::nav::Route; use chrono::Datelike; -use ledger_core::reports::profit_and_loss_for_period; use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report}; use rust_decimal::Decimal; @@ -45,8 +44,11 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option Vec { } } - let (fy_start, _) = b.fiscal_ytd(); - if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) { - let uncategorized: Vec = pl - .revenue - .iter() - .chain(&pl.expenses) - .filter(|l| { - b.ledger - .chart() - .find_by_code(&l.code) - .is_some_and(|a| a.tax_category.is_none()) - }) - .map(|l| format!("{} {}", l.code, l.name)) - .collect(); - if !uncategorized.is_empty() { - out.push(Attention { - severity: Severity::Info, - title: format!( - "{} without a tax category", - fmt::count(uncategorized.len(), "account", "accounts") - ), - detail: format!( - "{}. The tax summary lists them as uncategorized.", - uncategorized.join(", ") - ), - target: Some(Route::Accounts), - prompt: format!( - "In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.", - uncategorized.join(", ") - ), - }); - } + // Every revenue and expense account (not a header) without a tax + // category, with activity or not (rules.md B-32): the one list the + // digest gives the agent too. + let uncategorized: Vec = ledger_core::tax::uncategorized_accounts(&b.ledger) + .into_iter() + .map(|a| format!("{} {}", a.code, a.name)) + .collect(); + if !uncategorized.is_empty() { + let refuse = b.ledger.settings().require_tax_category() + == ledger_core::settings::TaxCategoryPolicy::Refuse; + out.push(Attention { + severity: if refuse { + Severity::Todo + } else { + Severity::Info + }, + title: format!( + "{} without a tax category", + fmt::count(uncategorized.len(), "account", "accounts") + ), + detail: format!( + "{}. Each revenue and expense account maps to a line of the return (the tax summary and the CPA package list it as uncategorized until then).", + uncategorized.join(", ") + ), + target: Some(Route::Accounts), + prompt: format!( + "In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.", + uncategorized.join(", ") + ), + }); } // Rules set by law that wanderbooks may no longer know (rules.md; diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index d042bed..0f5a016 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -510,7 +510,8 @@ impl Integrity { /// minus revenue, divided by the months of those the books cover /// (from the first posted entry's month); Some only when positive, /// rounded to the cent half away from zero. -/// 7. first_entry: the earliest posted entry's date. +/// 7. first_entry, last_entry: the earliest and the latest posted +/// entry's date. /// ``` #[derive(Clone, Debug, Default)] pub struct Kpis { @@ -527,6 +528,9 @@ pub struct Kpis { /// Average monthly net loss over the last three full months, if losing. pub monthly_burn: Option, pub first_entry: Option, + /// The latest posted entry's date (an entry may be dated after + /// today). + pub last_entry: Option, } impl Books { @@ -846,12 +850,44 @@ impl Books { (start, self.today) } - /// The fiscal year to date. + /// The fiscal year to date: the fiscal year's first day through today + /// (the "Year to date" preset and the overview's figures). The + /// fiscal-year fact itself, not "what an omitted date means" + /// ([`Self::default_period`]): a label never follows a default. pub fn fiscal_ytd(&self) -> (NaiveDate, NaiveDate) { - let (start, _) = self.settings.company.fiscal_year_containing(self.today); + let start = ledger_core::calendar::fiscal_year_start_on( + self.settings.company.fiscal_year_start, + self.today, + ); (start, self.today) } + /// What omitted dates mean today, for these books: the one rule the + /// server's report tools, `books.show` and the viewer share (rules.md + /// B-30). + pub fn periods(&self) -> ledger_core::calendar::PeriodDefaults { + ledger_core::calendar::period_defaults(&self.settings.company, self.today) + } + + /// Every date the books hold: the first posted entry (today when + /// there is none, or when it is later) through the later of today and + /// the last posted entry, so no entry dated after today is left out + /// ("Show all dates", "All time"; as `party_span`'s all time ends). + pub fn all_dates(&self) -> (NaiveDate, NaiveDate) { + let t = self.today; + ( + self.kpis.first_entry.unwrap_or(t).min(t), + self.kpis.last_entry.unwrap_or(t).max(t), + ) + } + + /// The period a report or register opens on when nobody chose one: + /// the fiscal year to date (rules.md B-30), as the agent's report + /// tools give it for no dates. + pub fn default_period(&self) -> (NaiveDate, NaiveDate) { + self.periods().statement_range() + } + fn compute_kpis(&self) -> Kpis { let l = &self.ledger; let today = self.today; @@ -880,12 +916,14 @@ impl Books { let (fy_start, _) = self.fiscal_ytd(); let ytd = pl(fy_start, today); let three = pl(ms - chrono::Months::new(3), prev_end); - let first_entry = self - .entries - .iter() - .filter(|e| e.status == EntryStatus::Posted) - .map(|e| e.date) - .min(); + let posted = || { + self.entries + .iter() + .filter(|e| e.status == EntryStatus::Posted) + .map(|e| e.date) + }; + let first_entry = posted().min(); + let last_entry = posted().max(); let monthly_burn = three.and_then(|p| { let loss = p.total_expenses - p.total_revenue; // Only months the books cover count toward the average. @@ -918,6 +956,7 @@ impl Books { .unwrap_or_default(), monthly_burn, first_entry, + last_entry, } } @@ -968,7 +1007,7 @@ impl Books { /// Every party's [`PartyInfo`]. fn party_infos(&self) -> BTreeMap { let l = &self.ledger; - let year = self.today.year(); + let year = self.periods().calendar_year(); let in_year = |d: NaiveDate| d.year() == year; // A sum that can't be computed exactly stays None (INVARIANT 4a). let add = |sum: &mut Option, v: Result| { @@ -1386,7 +1425,7 @@ impl Books { ) }; match period { - PartyPeriod::ThisYear => year(self.today.year()), + PartyPeriod::ThisYear => year(self.periods().calendar_year()), PartyPeriod::LastYear => year(self.today.year() - 1), PartyPeriod::Year(y) => year(y), PartyPeriod::AllTime => { diff --git a/crates/ledger-gui/src/pages/accounts.rs b/crates/ledger-gui/src/pages/accounts.rs index 2b5da1f..99e7a24 100644 --- a/crates/ledger-gui/src/pages/accounts.rs +++ b/crates/ledger-gui/src/pages/accounts.rs @@ -22,11 +22,37 @@ use rust_decimal::Decimal; #[derive(Default)] pub struct State { - /// Limit the register to these dates (set by drill-downs). - pub range: Option<(NaiveDate, NaiveDate)>, + /// Which dates the register shows. + pub range: RegisterRange, pub show_empty: bool, } +/// Which dates an account's register shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RegisterRange { + /// What a register opens on when nobody chose dates: the fiscal year + /// to date (rules.md B-30, `Books::default_period`), its opening + /// balance carried in. + #[default] + Default, + /// Every date: the books' first entry through the later of today + /// and the last ("Show all dates", `Books::all_dates`). + All, + /// These dates: a drill-down's, or a `books.show` target's. + Dates(NaiveDate, NaiveDate), +} + +impl RegisterRange { + /// The first and last day shown. + pub fn dates(self, b: &Books) -> (NaiveDate, NaiveDate) { + match self { + RegisterRange::Default => b.default_period(), + RegisterRange::All => b.all_dates(), + RegisterRange::Dates(s, e) => (s, e), + } + } +} + pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); page(ui, "accounts", |ui| { @@ -152,7 +178,7 @@ pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }, ); if let Some(id) = go { - shell.state.accounts.range = None; + shell.state.accounts.range = RegisterRange::Default; shell.go(Route::Account(id)); } ui.add_space(12.0); @@ -165,12 +191,7 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { let Some(a) = b.ledger.chart().get(id) else { return; }; - let first = b.kpis.first_entry.unwrap_or(b.today); - let (s, e) = shell - .state - .accounts - .range - .unwrap_or((first.min(b.today), b.today)); + let (s, e) = shell.state.accounts.range.dates(b); page(ui, "account", |ui| { widgets::copyable( ui, @@ -226,8 +247,14 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { )) .font(theme::semibold(13.5)), ); - if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() { - shell.state.accounts.range = None; + // "Show all dates" one click away from any other range; from + // all dates, back to the fiscal year to date. + if shell.state.accounts.range == RegisterRange::All { + if ui.button("Fiscal year to date").clicked() { + shell.state.accounts.range = RegisterRange::Default; + } + } else if ui.button("Show all dates").clicked() { + shell.state.accounts.range = RegisterRange::All; } if ui .button(format!("{} Find in transactions", icon::LIST_BULLETS)) @@ -238,8 +265,20 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { } }); ui.add_space(8.0); - let Ok(gl) = general_ledger(&b.ledger, s, e) else { - return; + let gl = match general_ledger(&b.ledger, s, e) { + Ok(gl) => gl, + Err(why) => { + // Never a blank page: say why these dates can't be shown. + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::WARNING, + "These dates can't be shown", + &format!("{why}. Choose other dates."), + ) + }); + return; + } }; let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else { widgets::card(ui, |ui| { diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs index 262c146..d67e7dc 100644 --- a/crates/ledger-gui/src/pages/assets.rs +++ b/crates/ledger-gui/src/pages/assets.rs @@ -764,7 +764,7 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<' }, ); if let Some(id) = go { - shell.state.accounts.range = None; + shell.state.accounts.range = crate::pages::accounts::RegisterRange::Default; shell.go(Route::Account(id)); } } @@ -927,7 +927,8 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { ) .clicked() { - shell.state.accounts.range = None; + shell.state.accounts.range = + crate::pages::accounts::RegisterRange::Default; go = Some(Route::Account(acct)); } ui.end_row(); diff --git a/crates/ledger-gui/src/pages/cash.rs b/crates/ledger-gui/src/pages/cash.rs index 482f15a..d9f9702 100644 --- a/crates/ledger-gui/src/pages/cash.rs +++ b/crates/ledger-gui/src/pages/cash.rs @@ -105,7 +105,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }, ); if r.interact(Sense::click()).clicked() { - shell.state.accounts.range = None; + shell.state.accounts.range = crate::pages::accounts::RegisterRange::Default; shell.go(Route::Account(*id)); } }); diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index eb3fb3f..ccb6b75 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -368,6 +368,20 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { .and_then(|v| v.as_u64()) .map(Route::Account), ), + // Its code and both names as journaled: a later rename or + // renumber never rewrites what this line said. + "account.renamed" => ev( + icon::TAG, + format!( + "Renamed {} from \u{201c}{}\u{201d} to \u{201c}{}\u{201d}", + s(p, "code"), + s(p, "previous"), + s(p, "name") + ), + p.get("account_id") + .and_then(|v| v.as_u64()) + .map(Route::Account), + ), "role.assigned" => { let role = s(p, "role"); let control = ledger_core::control_account(role); @@ -792,11 +806,27 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { format!("Cut-off journal line set aside ({} bytes)", p["bytes"]), None, ), - "journal.format_set" => ev( - icon::FILE_TEXT, - format!("Journal format {} declared", p["format"]), - None, - ), + "journal.format_set" => { + // The features in force with it (P3: account_renamed, + // require_tax_category): what an earlier version can't read. + let features: Vec<&str> = p["features"] + .as_array() + .map(|a| a.iter().filter_map(|f| f.as_str()).collect()) + .unwrap_or_default(); + ev( + icon::FILE_TEXT, + if features.is_empty() { + format!("Journal format {} declared", p["format"]) + } else { + format!( + "Journal format {} declared, with {}", + p["format"], + features.join(", ") + ) + }, + None, + ) + } "recurring.generated" => ev( icon::REPEAT, "Recurring draft created".into(), diff --git a/crates/ledger-gui/src/pages/profit.rs b/crates/ledger-gui/src/pages/profit.rs index 7e4dd28..3fd08ba 100644 --- a/crates/ledger-gui/src/pages/profit.rs +++ b/crates/ledger-gui/src/pages/profit.rs @@ -281,7 +281,8 @@ fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }, ); if let Some(id) = go { - shell.state.accounts.range = Some((ms - Months::new(3), b.today)); + shell.state.accounts.range = + crate::pages::accounts::RegisterRange::Dates(ms - Months::new(3), b.today); shell.go(Route::Account(id)); } ui.add_space(16.0); diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 2e50a90..8f3d757 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -22,6 +22,10 @@ use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_109 use rust_decimal::Decimal; use std::collections::BTreeMap; +/// The range a report covers. The default (a report nobody chose dates +/// for) is the fiscal year to date: what an omitted date means +/// everywhere (rules.md B-30, `Books::default_period`; a test pins that +/// they agree). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Range { ThisMonth, @@ -52,7 +56,7 @@ impl Range { (s - Months::new(12), s - Days::new(1)) } Range::Last12Months => (ms - Months::new(11), t), - Range::AllTime => (b.kpis.first_entry.unwrap_or(t).min(t), t), + Range::AllTime => b.all_dates(), Range::Custom(s, e) => (s, e), } } @@ -70,7 +74,8 @@ impl Range { } } - const PRESETS: [Range; 7] = [ + /// The ranges the picker offers by name. + pub const PRESETS: [Range; 7] = [ Range::ThisMonth, Range::LastMonth, Range::ThisQuarter, @@ -354,7 +359,7 @@ fn col_title(s: NaiveDate, e: NaiveDate, columns: Columns) -> String { } /// The most columns a split report shows. -const MAX_COLUMNS: i64 = 240; +const MAX_COLUMNS: i64 = ledger_store::ops::MAX_REPORT_COLUMNS; /// How many columns splitting `s..=e` by `columns` makes, when that is /// more than [`MAX_COLUMNS`]; counted from the months, before building @@ -366,9 +371,8 @@ fn too_many_columns(s: NaiveDate, e: NaiveDate, columns: Columns) -> Option Columns::Quarter => 3, Columns::Year => 12, }; - let months = (i64::from(e.year()) - i64::from(s.year())) * 12 + i64::from(e.month()) - - i64::from(s.month()); - let n = months / per + 1; + // The count books.show refuses past, so the two never disagree. + let n = ledger_store::ops::report_columns(s, e, per); (n > MAX_COLUMNS).then_some(n) } @@ -1028,6 +1032,19 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { &ts.separately_stated, ); } + // Book amounts only: Form 4797 figures the tax gain from the tax + // basis, and its split decides the line (ledger_core::tax). + if !ts.form_4797.is_empty() { + t.section("Gains and losses on business property (Form 4797)"); + for l in &ts.form_4797 { + t.rows.push(Row { + kind: Kind::Line, + label: l.label.clone(), + detail: l.accounts.join(", "), + cells: vec![Cell::of(l.book), Cell::none()], + }); + } + } if !ts.uncategorized.is_empty() { t.section("Not categorized"); for u in &ts.uncategorized { @@ -1056,6 +1073,9 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { } let form = ts.form.map(fmt::tax_form).unwrap_or("not set"); t.note(format!("Return: {form}. Deductible percentages applied (meals 50%, penalties 0%). A worksheet for the CPA, not a filing.")); + if !ts.form_4797.is_empty() { + t.note("Gains and losses on business property are book amounts, in no total: the CPA figures them on Form 4797."); + } t } @@ -1460,6 +1480,11 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke let s = widgets::date_picker(ui, "from", s); ui.label(RichText::new("to").color(p.muted)); let e = widgets::date_picker(ui, "to", e); + // The human's own pickers: a "from" set past the "to" reads + // as the two in order. Never a books.show target's dates: + // App::show takes only a resolved range in order (rules.md + // B-30) and ignores any other, so this never changes what the + // agent was told the viewer shows. st.range = Range::Custom(s.min(e), e.max(s)); } if splittable { @@ -1987,7 +2012,7 @@ fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) { start_pdf(ui.ctx(), st, t, pdf); } if let Some(d) = go { - shell.state.accounts.range = Some((d.start, d.end)); + shell.state.accounts.range = crate::pages::accounts::RegisterRange::Dates(d.start, d.end); shell.go(Route::Account(d.account)); } } @@ -2162,7 +2187,7 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>) } if let Some(r) = go { if let Route::Account(_) = r { - shell.state.accounts.range = Some((s, e)); + shell.state.accounts.range = crate::pages::accounts::RegisterRange::Dates(s, e); } shell.go(r); } diff --git a/crates/ledger-gui/tests/old_books.rs b/crates/ledger-gui/tests/old_books.rs index 01c5fc8..1b08ecf 100644 --- a/crates/ledger-gui/tests/old_books.rs +++ b/crates/ledger-gui/tests/old_books.rs @@ -30,6 +30,7 @@ use chrono::NaiveDate; use egui_kittest::Harness; use ledger_gui::books::{Books, PartyPeriod}; use ledger_gui::nav::{ReportKind, Route}; +use ledger_gui::pages::accounts::RegisterRange; use ledger_gui::pages::reports::{Columns, Compare, Range}; use ledger_gui::{App, Source, data}; use serde_json::json; @@ -360,7 +361,7 @@ fn every_page_renders(shard: usize, of: usize) { for at in edges { h.state_mut().state_mut().assets.as_of = Some(at); show(&mut h, Route::Assets); - h.state_mut().state_mut().accounts.range = Some((NaiveDate::MIN, at)); + h.state_mut().state_mut().accounts.range = RegisterRange::Dates(NaiveDate::MIN, at); for &a in &accounts { show(&mut h, Route::Account(a)); } diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index b99d4eb..15574ac 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -1,6 +1,7 @@ //! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir //! (containing `books-data/`, its settings in its journal) and `SHOT_DIR` to -//! where the PNGs go; without them this test does nothing. +//! where the PNGs go; without them this test does nothing. `SHOT_REGISTER=all` +//! shows an account's register on every date instead of its default. use egui_kittest::Harness; use ledger_gui::nav::{ReportKind, Route}; @@ -153,6 +154,12 @@ fn screenshots() { if std::env::var("SHOT_LIVE").is_ok() { harness.state_mut().show_live_label(); } + // SHOT_REGISTER=all: a register showing every date (after "Show + // all dates"); otherwise it opens on the default period. + if std::env::var("SHOT_REGISTER").is_ok_and(|r| r == "all") { + harness.state_mut().state_mut().accounts.range = + ledger_gui::pages::accounts::RegisterRange::All; + } harness.run_steps(4); let ctx = harness.ctx.clone(); harness.state_mut().settle(&ctx); diff --git a/crates/ledger-gui/tests/show.rs b/crates/ledger-gui/tests/show.rs index 5f28b55..10671ab 100644 --- a/crates/ledger-gui/tests/show.rs +++ b/crates/ledger-gui/tests/show.rs @@ -118,3 +118,194 @@ fn show_requests_navigate_the_viewer() { }); assert_eq!(h.state_mut().route(), &Route::Transactions); } + +/// The demo books (calendar fiscal year) in a viewer, at `today`. +fn demo( + tag: &str, + today: chrono::NaiveDate, +) -> (TestDir, Harness<'static, App>, ledger_gui::books::Books) { + demo_sized(tag, today, 920.0) +} + +/// [`demo`] in a window `height` tall (the register's date row sits below +/// an account's cards: tall enough to click it). +fn demo_sized( + tag: &str, + today: chrono::NaiveDate, + height: f32, +) -> (TestDir, Harness<'static, App>, ledger_gui::books::Books) { + let dir = std::env::temp_dir().join(format!("ledger-gui-show-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let guard = TestDir(dir.clone()); + ledger_gui::demo::build(&dir, today).unwrap(); + let source = Source { + data_dir: dir.join("books-data"), + today: Some(today), + }; + let books = data::load_once(&source).unwrap(); + let copy = data::load_once(&source).unwrap(); + let h = Harness::builder() + .with_size(egui::vec2(1440.0, height)) + .build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, true, Route::Overview)); + (guard, h, copy) +} + +fn day(y: i32, m: u32, d: u32) -> chrono::NaiveDate { + chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// Defect 15, repros 1.1.1-1.1.2 (rules.md B-30): a target the viewer +/// can't honour is ignored whole, never swapped or guessed: a start after +/// the end (an earlier writer's, or a hand-edited ops/show.jsonl), text +/// that isn't a date, an unknown split or comparison. The page, the +/// report's range and split, and the register's range stay as they were. +#[test] +fn show_never_swaps_an_inverted_target_defect_15() { + use ledger_gui::pages::accounts::RegisterRange; + use ledger_gui::pages::reports::{Columns, Compare, Range}; + let (_dir, mut h, _) = demo("inverted", day(2026, 10, 20)); + h.state_mut().state_mut().reports.range = Range::LastMonth; + h.state_mut().state_mut().reports.columns = Columns::Quarter; + h.state_mut().state_mut().accounts.range = RegisterRange::All; + let report = + |start: Option<&str>, end: Option<&str>, columns: Option<&str>, compare: Option<&str>| { + ViewerTarget::Report { + report: "profit_and_loss".into(), + start: start.map(Into::into), + end: end.map(Into::into), + columns: columns.map(Into::into), + compare: compare.map(Into::into), + } + }; + for target in [ + report(Some("2026-12-31"), Some("2026-01-01"), None, None), + report(Some("2027-01-01"), None, None, None), + report(Some("January"), None, None, None), + report(None, Some("2026-13-01"), None, None), + report(None, None, Some("weekly"), None), + report(None, None, None, Some("prior_decade")), + ViewerTarget::Account { + code: "1000".into(), + start: Some("2026-12-31".into()), + end: Some("2026-01-01".into()), + }, + ViewerTarget::Account { + code: "1000".into(), + start: Some("2027-01-01".into()), + end: None, + }, + ] { + h.state_mut().show(target.clone()); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Overview, "{target:?}"); + let st = h.state_mut().state_mut(); + assert_eq!(st.reports.range, Range::LastMonth, "{target:?}"); + assert_eq!(st.reports.columns, Columns::Quarter, "{target:?}"); + assert_eq!(st.reports.compare, Compare::None, "{target:?}"); + assert_eq!(st.accounts.range, RegisterRange::All, "{target:?}"); + } +} + +/// Defect 16, repro 1.1.4 (rules.md B-30): a report target with only an +/// end covers that end's fiscal year to it (2025-01-01 to 2025-06-30, not +/// the swapped 2025-06-30 to 2026-01-01); with no dates, the default (the +/// fiscal year to date, named "Year to date"); dates that are a preset's +/// show under its name; an account target with no dates opens the +/// register on the default. +#[test] +fn show_report_with_only_end_uses_that_years_start_defect_16() { + use ledger_gui::pages::accounts::RegisterRange; + use ledger_gui::pages::reports::Range; + let today = day(2026, 10, 20); + let (_dir, mut h, books) = demo("only-end", today); + let report = |start: Option<&str>, end: Option<&str>| ViewerTarget::Report { + report: "profit_and_loss".into(), + start: start.map(Into::into), + end: end.map(Into::into), + columns: None, + compare: None, + }; + h.state_mut().show(report(None, Some("2025-06-30"))); + h.run_steps(2); + assert_eq!( + h.state_mut().state_mut().reports.range, + Range::Custom(day(2025, 1, 1), day(2025, 6, 30)) + ); + h.state_mut().state_mut().reports.range = Range::LastMonth; + h.state_mut().show(report(None, None)); + h.run_steps(2); + assert_eq!(h.state_mut().state_mut().reports.range, Range::YearToDate); + assert_eq!(Range::YearToDate.dates(&books), (day(2026, 1, 1), today)); + // What books.show sends for no dates: the same, by name. + h.state_mut() + .show(report(Some("2026-01-01"), Some("2026-10-20"))); + h.run_steps(2); + assert_eq!(h.state_mut().state_mut().reports.range, Range::YearToDate); + h.state_mut() + .show(report(Some("2026-09-01"), Some("2026-09-30"))); + h.run_steps(2); + assert_eq!(h.state_mut().state_mut().reports.range, Range::LastMonth); + // Only a start: through today. + h.state_mut().show(report(Some("2026-03-15"), None)); + h.run_steps(2); + assert_eq!( + h.state_mut().state_mut().reports.range, + Range::Custom(day(2026, 3, 15), today) + ); + // An account with no dates (an earlier writer's): the default. + h.state_mut().state_mut().accounts.range = RegisterRange::All; + h.state_mut().show(ViewerTarget::Account { + code: "1000".into(), + start: None, + end: None, + }); + h.run_steps(2); + assert_eq!( + h.state_mut().state_mut().accounts.range, + RegisterRange::Default + ); + assert_eq!( + RegisterRange::Default.dates(&books), + (day(2026, 1, 1), today) + ); +} + +/// Settled in design (P3, defect 16): an account's register opens on the +/// fiscal year to date with "Show all dates" one click away: after +/// books.show of an account (which sends the resolved dates), the button +/// shows every date, the books' first entry through today; from there, +/// "Fiscal year to date" goes back. +#[test] +fn show_all_dates_is_one_click_from_a_shown_register() { + use egui_kittest::kittest::Queryable; + use ledger_gui::pages::accounts::RegisterRange; + let today = day(2026, 10, 20); + let (_dir, mut h, books) = demo_sized("all-dates", today, 3000.0); + let id = books.ledger.chart().find_by_code("1000").unwrap().id; + h.state_mut().show(ViewerTarget::Account { + code: "1000".into(), + start: Some("2026-01-01".into()), + end: Some("2026-10-20".into()), + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Account(id)); + assert_eq!( + h.state_mut().state_mut().accounts.range, + RegisterRange::Dates(day(2026, 1, 1), today) + ); + h.get_by_label("Show all dates").click(); + h.run_steps(2); + assert_eq!(h.state_mut().state_mut().accounts.range, RegisterRange::All); + let first = books.kpis.first_entry.unwrap(); + assert_eq!(RegisterRange::All.dates(&books), (first.min(today), today)); + assert!( + first < day(2026, 1, 1), + "the demo's books began before this year" + ); + h.get_by_label("Fiscal year to date").click(); + h.run_steps(2); + assert_eq!( + h.state_mut().state_mut().accounts.range, + RegisterRange::Default + ); +} diff --git a/crates/ledger-gui/tests/spec_gui.rs b/crates/ledger-gui/tests/spec_gui.rs index 0275a5b..b05dfb6 100644 --- a/crates/ledger-gui/tests/spec_gui.rs +++ b/crates/ledger-gui/tests/spec_gui.rs @@ -1064,8 +1064,18 @@ fn small_books(tag: &str) -> Small { let cash = store .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); + // Mapped to the return, so books with nothing else to do have + // nothing in Needs attention (rules.md B-32 lists every expense + // account without a category, used or not). let rent = store - .add_account("6100", "Rent", AccountType::Expense, None, false) + .add_account_with_tax( + "6100", + "Rent", + AccountType::Expense, + None, + false, + Some(("rent_property", None)), + ) .unwrap(); Small { dir, diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index 22b538a..d557517 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -163,6 +163,22 @@ proptest! { fy = fy - Months::new(12); } prop_assert_eq!(b.fiscal_ytd(), (fy, t)); + // One default period everywhere (rules.md B-30): what a report or + // register opens on is the fiscal year to date, the server's + // resolver's own answer for no dates, and party totals count this + // calendar year. + prop_assert_eq!(b.default_period(), (fy, t)); + prop_assert_eq!(b.periods().statement(None, None), Ok((fy, t))); + prop_assert_eq!(ledger_gui::pages::reports::Range::default().dates(&b), (fy, t)); + prop_assert_eq!( + ledger_gui::pages::accounts::RegisterRange::default().dates(&b), + (fy, t) + ); + let jan1 = NaiveDate::from_ymd_opt(t.year(), 1, 1).unwrap(); + prop_assert_eq!( + b.party_span(ledger_gui::books::PartyPeriod::ThisYear).0, + jan1 + ); let ytd = pl(fy, t); prop_assert_eq!(k.net_ytd, ytd.total_revenue - ytd.total_expenses); let first = p.entries.iter().map(|e| t - Days::new(e.0)).min(); @@ -669,3 +685,135 @@ fn more_origins_and_parties() { assert_eq!(r.origin, "Payroll taxes"); assert!(!r.missing_receipt, "a remittance isn't a hand-made entry"); } + +/// Defect 8, repros 1.4.2 and 1.4.3 (rules.md B-32): Needs attention lists +/// every revenue and expense account without a tax category, with +/// activity or not: one never used, one used only last fiscal year; never +/// a header, an account with a category, or an asset. Info under warn, +/// Todo under refuse. +#[test] +fn needs_attention_flags_every_uncategorized_pl_account_defect_8() { + let today = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(); + let d = dir("attention-tax"); + let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let ops = s + .add_account("6000", "Operating", AccountType::Expense, None, true) + .unwrap(); + s.add_account("6150", "Software", AccountType::Expense, Some(ops), false) + .unwrap(); + let hosting = s + .add_account("6160", "Hosting", AccountType::Expense, Some(ops), false) + .unwrap(); + s.add_account_with_tax( + "4000", + "Sales", + AccountType::Revenue, + None, + false, + Some(("gross_receipts", None)), + ) + .unwrap(); + let e = s + .create_entry(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap(), "last year") + .unwrap(); + let m = Money::new(Decimal::new(1_200, 2), Currency::USD); + s.add_line(e, hosting, Side::Debit, m).unwrap(); + s.add_line(e, cash, Side::Credit, m).unwrap(); + s.post_entry(e).unwrap(); + journal_settings( + &mut s, + "[company]\nname = \"Fernhill Labs LLC\"\n[books]\ncash_accounts = [\"1000\"]\n", + ); + let source = Source { + data_dir: d.join("books-data"), + today: Some(today), + }; + let item = |b: &ledger_gui::books::Books| { + ledger_gui::attention::find(b) + .into_iter() + .find(|a| a.title.contains("without a tax category")) + }; + let b = data::load_once(&source).unwrap(); + let a = item(&b).expect("listed"); + assert_eq!(a.title, "2 accounts without a tax category"); + assert!( + a.detail.starts_with("6150 Software, 6160 Hosting. Each revenue and expense account maps to a line of the return"), + "{}", + a.detail + ); + assert_eq!(a.severity, ledger_gui::books::Severity::Info); + assert!( + a.prompt.contains("books.tax_categories") && a.prompt.contains("books.set_tax_category") + ); + // Under refuse, a to-do. + s.change_settings( + &[ledger_core::settings::Change { + key: "policy.require_tax_category".into(), + value: Some(serde_json::json!("refuse")), + }], + &ledger_core::settings::Source { + decided_by: "Tobin Harlow".into(), + decided_on: today, + citation: None, + note: None, + }, + ) + .unwrap(); + let b = data::load_once(&source).unwrap(); + assert_eq!( + item(&b).unwrap().severity, + ledger_gui::books::Severity::Todo + ); + // Categorized: no item at all. + for code in ["6150", "6160"] { + let id = s.ledger().chart().find_by_code(code).unwrap().id; + s.set_account_tax(id, Some("other_deductions"), None) + .unwrap(); + } + let b = data::load_once(&source).unwrap(); + assert!(item(&b).is_none()); +} + +/// "Show all dates" is every date (P3 settled in design, rules.md B-30): +/// the register's and the reports' all-dates range runs from the first +/// posted entry through the later of today and the last, so an entry +/// dated after today is never left out of it (as party totals' "All time" +/// already ends); with no such entry it ends today. +#[test] +fn all_dates_holds_an_entry_dated_after_today() { + use ledger_gui::pages::accounts::RegisterRange; + use ledger_gui::pages::reports::Range; + let today = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(); + let d = dir("all-dates-future"); + let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let source = Source { + data_dir: d.join("books-data"), + today: Some(today), + }; + let post = |s: &mut Store, day: NaiveDate| { + let e = s.create_entry(day, "x").unwrap(); + let m = Money::new(Decimal::new(500, 2), Currency::USD); + s.add_line(e, cash, Side::Debit, m).unwrap(); + s.add_line(e, capital, Side::Credit, m).unwrap(); + s.post_entry(e).unwrap(); + }; + let first = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + post(&mut s, first); + let b = data::load_once(&source).unwrap(); + assert_eq!(b.all_dates(), (first, today)); + let later = NaiveDate::from_ymd_opt(2026, 12, 1).unwrap(); + post(&mut s, later); + let b = data::load_once(&source).unwrap(); + assert_eq!(b.kpis.last_entry, Some(later)); + assert_eq!(RegisterRange::All.dates(&b), (first, later)); + assert_eq!(Range::AllTime.dates(&b), (first, later)); +} diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs index e83d7b4..761bd11 100644 --- a/crates/ledger-import/src/report.rs +++ b/crates/ledger-import/src/report.rs @@ -558,6 +558,25 @@ pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement { )); rows.extend(lines(&ts.separately_stated)); } + if !ts.form_4797.is_empty() { + rows.push(spacer()); + rows.push(section("Gains and losses on business property (Form 4797)")); + rows.push(subheading( + "Book amounts; the CPA figures the tax gain from the tax basis and where it goes; not included above:", + )); + rows.extend(ts.form_4797.iter().map(|l| { + let codes: Vec<&str> = l + .accounts + .iter() + .map(|a| a.split_whitespace().next().unwrap_or("")) + .collect(); + row( + RowKind::Line, + format!("{} ({})", l.label, codes.join(", ")), + vec![Some(l.book), None, None], + ) + })); + } if !ts.uncategorized.is_empty() { rows.push(spacer()); rows.push(section("Not yet categorized")); diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs index 9588bec..d6f447c 100644 --- a/crates/ledger-import/tests/spec_import.rs +++ b/crates/ledger-import/tests/spec_import.rs @@ -1630,7 +1630,10 @@ proptest! { // A blank name is refused by the books; it can't reach the file. let name = if name.trim().is_empty() { "x" } else { name.as_str() }; let parent = parent.map(|p| ids[p]); - ids.push(l.add_account(&code_of(i), name, AccountType::Expense, parent, false).unwrap()); + // Replayed, as books written before P3 hold them: a sibling's + // name, a tab, nine digits (rules.md B-31 refuses them in a + // new name, never in a journaled one). + ids.push(l.replay_add_account(&code_of(i), name, AccountType::Expense, parent, false).unwrap()); } let accounts = iif_accounts(&l, |_| QbAccountType::Expense); match journal_to_iif(&accounts, &[]) { @@ -1896,7 +1899,9 @@ fn iif_writes_plain_ascii_and_lists_every_change() { ("6050", "René 😀"), ("6070", "Wide number"), ] { - l.add_account(code, name, AccountType::Expense, None, false) + // Replayed: a tab in a name is refused in a new one (rules.md + // B-31), never in one books written before P3 hold. + l.replay_add_account(code, name, AccountType::Expense, None, false) .unwrap(); } let memos = [ @@ -2001,9 +2006,12 @@ fn iif_every_line_ends_cr_lf() { #[test] fn iif_distinct_accounts_never_share_a_name() { let mut l = Ledger::new(Currency::USD); + // Replayed, as books written before P3 hold them: siblings of one + // name (rules.md B-31 refuses one in a new name, never in a journaled + // one), which the file must still keep apart. let mut add = |code: &str, name: &str, parent: Option<&str>| { let p = parent.map(|c| l.chart().find_by_code(c).unwrap().id); - l.add_account(code, name, AccountType::Expense, p, false) + l.replay_add_account(code, name, AccountType::Expense, p, false) .unwrap(); }; add("6100", "Software", None); @@ -2069,7 +2077,7 @@ fn iif_distinct_accounts_never_share_a_name() { // tagged alike, so the match remains, and is refused naming both. let mut l = Ledger::new(Currency::USD); for code in ["6100", "6\u{FF11}00"] { - l.add_account(code, "Software", AccountType::Expense, None, false) + l.replay_add_account(code, "Software", AccountType::Expense, None, false) .unwrap(); } let accounts = iif_accounts(&l, |_| QbAccountType::Expense); @@ -2401,7 +2409,10 @@ fn iif_warnings_group_the_memos_by_what_is_written_differently() { #[test] fn iif_warnings_never_hold_a_tin() { let (mut l, cash, rev, _) = small_ledger(); - l.add_account( + // Replayed, as books written before P3 hold them: a name holding a + // TIN, or a tab, is refused in a new name (rules.md B-31), never in a + // journaled one, so the file must still mask it. + l.replay_add_account( "6300", "Loan from 123-45-6789 \u{2014} member", AccountType::Liability, @@ -2411,7 +2422,7 @@ fn iif_warnings_never_hold_a_tin() { .unwrap(); // A TIN only once written: its digits a tab apart, which the file // writes a space apart. - l.add_account( + l.replay_add_account( "6400", "Loan 123\t45\t6789", AccountType::Liability, @@ -3905,6 +3916,53 @@ fn tax_summary_difference_and_schedule_k() { ); } +/// A gain on business property (rules.md B-32; ledger_core::tax +/// `TaxSummary::form_4797`): on a Schedule C business's summary it is in +/// its own Form 4797 section, at its book amount with no tax amount, and +/// outside the net, never Schedule C income. +#[test] +fn tax_summary_lists_form_4797_apart() { + use ledger_core::tax::{TaxForm, tax_summary}; + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let mut acct = |code: &str, t, cat: &str| { + let id = l.add_account(code, code, t, None, false).unwrap(); + l.set_account_tax(id, Some(cat), None).unwrap(); + id + }; + let sales = acct("4000", AccountType::Revenue, "gross_receipts"); + let gain = acct("4700", AccountType::Revenue, "form_4797_gain_loss"); + for (cr, c) in [(sales, 100_000), (gain, 7_500)] { + let e = l.create_entry(d(2026, 2, 1), "x"); + l.add_line(e, cash, Side::Debit, usd(c)).unwrap(); + l.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + l.post_entry(e).unwrap(); + } + let ts = tax_summary(&l, d(2026, 1, 1), d(2026, 12, 31), Some(TaxForm::ScheduleC)).unwrap(); + let st = report::tax_summary(&ts); + let at = |label: &str| { + st.rows + .iter() + .position(|r| r.label.starts_with(label)) + .unwrap_or_else(|| panic!("no row {label}")) + }; + let section = at("Gains and losses on business property (Form 4797)"); + let line = at("Gain or loss on business property (Form 4797) (4700)"); + assert!(line > section && section > at("Income less deductions")); + assert_eq!(st.rows[line].amounts, vec![Some(cents(7_500)), None, None]); + assert_eq!( + st.rows[at("Total income")].amounts[0], + Some(cents(100_000)), + "the gain is no Schedule C income" + ); + assert_eq!( + st.rows[at("Income less deductions")].amounts, + vec![Some(cents(100_000)), Some(cents(100_000)), None] + ); +} + /// The reconciliation's "Adjustments:" subheading appears only when there /// is something to reconcile, and the noncash schedule says "None" when /// nothing was disclosed (cash_flow INVARIANT 5). A contribution of diff --git a/crates/ledger-mcp/handbook/control-roles.md b/crates/ledger-mcp/handbook/control-roles.md index 7f32d31..0118fa5 100644 --- a/crates/ledger-mcp/handbook/control-roles.md +++ b/crates/ledger-mcp/handbook/control-roles.md @@ -2,4 +2,4 @@ - Invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. - A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. -- Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays). +- Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays); books.rename_account changes only its name, never a role. diff --git a/crates/ledger-mcp/handbook/settings-and-conventions.md b/crates/ledger-mcp/handbook/settings-and-conventions.md index 45e2ba5..6b30d49 100644 --- a/crates/ledger-mcp/handbook/settings-and-conventions.md +++ b/crates/ledger-mcp/handbook/settings-and-conventions.md @@ -8,6 +8,7 @@ The books keep their own record of how they are kept. Every agent, anywhere, rea - Change settings with books.set_setting: one or more {key, value} together, with who decided (decided_by) and when (decided_on, default today). A value is checked and kept in one spelling; null clears a key back to its default. Change a setting when the human tells you a company fact or decides a policy, when a tool refuses naming a setting, when books.settings or books.digest lists a problem, and after books.renumber_account lists config_mentions. - The company's EIN is the one identity number the settings keep; it is shown masked. No other setting, note or convention ever holds a full TIN: those go only into the vault (the parties-and-tins section). - company.timezone sets what "today" means for the books. +- policy.require_tax_category is warn (unset: a revenue or expense account without a tax category is created, and the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; the accounts invoices, bills and payroll create are never refused, and their results name them). Before setting refuse, categorize with the human every account books.digest lists in uncategorized_accounts. - books.export_settings writes the settings out as a settings file (TOML), each with who decided it. It is a copy: editing it changes nothing. ## Books that still read a settings file diff --git a/crates/ledger-mcp/handbook/tracing-figures.md b/crates/ledger-mcp/handbook/tracing-figures.md index 9098b0a..fa757c8 100644 --- a/crates/ledger-mcp/handbook/tracing-figures.md +++ b/crates/ledger-mcp/handbook/tracing-figures.md @@ -1,6 +1,7 @@ # Tracing figures - Reports are pure functions of the posted entries. When the human questions a figure, trace it to its entries with the tools: the report (books.trial_balance, books.profit_and_loss, books.balance_sheet, books.cash_flow), then books.search_entries and books.get_entry for the entries behind it, and books.show to put them on the human's screen. +- A report without dates covers the fiscal year to date, everywhere (the report tools, the export, books.show and the viewer); one with only an end date covers that date's fiscal year to it. {period_rule} The IIF is never a period: every posted entry from the first through its as_of. Every result names the dates it covers: say them when you quote a figure, and pass dates whenever the human means another period. - A cash flow line names its entry (books.cash_flow: each line's entry, kind and accounts). An expense a member paid personally (or equipment a lender paid for) is cash borrowed and spent on its date: a financing line and an operating (or investing) line, each with on_behalf, the part no bank account moved; the reimbursement is a repayment. A noncash disclosure is an entry that moved no cash at all (property contributed, debt converted to capital). A warning names an entry whose later payment or collection the books can't classify, with the fix. A role refusal names the setting to change. - Never guess, and never look for the books' files: the tools are the evidence. - books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact. diff --git a/crates/ledger-mcp/handbook/viewer.md b/crates/ledger-mcp/handbook/viewer.md index 5113b63..5be87ec 100644 --- a/crates/ledger-mcp/handbook/viewer.md +++ b/crates/ledger-mcp/handbook/viewer.md @@ -1,4 +1,5 @@ # The viewer - The human usually has the read-only viewer open beside you. When they ask to see something, and after you post, call books.show so it's on their screen: an entry, an account register, a report for any dates (by month, quarter or year, against the prior period or year), a receipt or document, an asset, a party, a reconciliation, or a search. +- books.show says which dates it showed (without dates, the fiscal year to date; a register opens on it too, with "Show all dates" one click away for the human). It refuses a start after the end, and options its target ignores (dates for an entry, a split for the trial balance, a start for a report as of a day, a split into more than 240 columns): pass only what the target takes. - The viewer never changes the books; every change goes through these tools. diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index ad50d0e..0d421c5 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -43,7 +43,8 @@ pub const CONNECT_LEAD: usize = 512; /// The handbook's sections, in reading order: (id, markdown). Each starts /// with a `# Title` line; `{evidence_rule}` stands for the receipt rule in -/// force. +/// force, and `{period_rule}` for what omitted report dates mean +/// ([`period_rule`]). pub const SECTIONS: &[(&str, &str)] = &[ ("contract", include_str!("../handbook/contract.md")), ("entries", include_str!("../handbook/entries.md")), @@ -116,7 +117,10 @@ pub const ALL: &str = "all"; /// The section `id` (or [`ALL`]), rendered for these settings and the /// books' base currency (None: no such section). pub fn section(id: &str, settings: &Settings, currency: &str) -> Option { - let render = |md: &str| md.replace("{evidence_rule}", &evidence_rule(settings, currency)); + let render = |md: &str| { + md.replace("{evidence_rule}", &evidence_rule(settings, currency)) + .replace("{period_rule}", &period_rule()) + }; if id == ALL { let parts: Vec = SECTIONS.iter().map(|(_, md)| render(md)).collect(); return Some(parts.join("\n")); @@ -127,6 +131,17 @@ pub fn section(id: &str, settings: &Settings, currency: &str) -> Option .map(|(_, md)| render(md)) } +/// What omitted report dates mean (rules.md B-30), in the words every +/// report tool's description uses (`ledger_core::calendar::PeriodKind`). +pub fn period_rule() -> String { + use ledger_core::calendar::PeriodKind; + format!( + "{} For a report as of a day (balance sheet, trial balance, aging): {}", + PeriodKind::Statement.describe(), + PeriodKind::AsOf.describe() + ) +} + /// The receipt rule in force (INVARIANT 2). pub fn evidence_rule(settings: &Settings, currency: &str) -> String { format!( @@ -460,7 +475,14 @@ mod tests { assert!(!title(md).is_empty()); let text = section(id, &s, "USD").unwrap(); assert!(!text.contains("{evidence_rule}"), "{id}"); + assert!(!text.contains("{period_rule}"), "{id}"); } + // The default period, in the descriptions' own words (B-30). + assert!( + section("tracing-figures", &s, "USD") + .unwrap() + .contains(ledger_core::calendar::PeriodKind::Statement.describe()) + ); assert!( section("evidence-and-receipts", &s, "USD") .unwrap() @@ -497,6 +519,7 @@ mod tests { at += found + one.len(); } assert!(!all.contains("{evidence_rule}")); + assert!(!all.contains("{period_rule}")); assert!(SECTIONS.iter().all(|(id, _)| *id != ALL)); } diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 2025d9e..7503413 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -62,7 +62,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 111] = [ +pub const TABLE: [(&str, Needs); 112] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -123,6 +123,7 @@ pub const TABLE: [(&str, Needs); 111] = [ ("books.chart_template", R), ("books.tax_categories", R), ("books.renumber_account", P), + ("books.rename_account", P), ("books.label_asset", W), ("books.correct_asset", P), ("books.set_asset_tax_basis", W), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index b339601..73753cb 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -763,6 +763,56 @@ impl BooksServer { self.store.currency.code() } + /// What omitted dates mean today, for these books (rules.md B-30): the + /// one resolver every report tool, `books.show` and the viewer use. + fn periods(&self) -> ledger_core::calendar::PeriodDefaults { + ledger_core::calendar::period_defaults(&self.settings().company, today()) + } + + /// A period report's range: the dates as given, each omitted one the + /// default (rules.md B-30); a start after the end, given or derived, + /// is refused. + fn statement_range( + &self, + start: Option<&str>, + end: Option<&str>, + ) -> Result<(NaiveDate, NaiveDate), String> { + ledger_package::statement_range(&self.settings().company, start, end, today()) + } + + /// A point-in-time report's day: as given, else today (rules.md B-30). + fn as_of(&self, as_of: Option<&str>) -> Result { + ledger_package::as_of_date(&self.settings().company, as_of, today()) + } + + /// Profit and loss for a range (see [`Self::statement_range`]), + /// optionally one class. + fn pnl_statement( + &self, + ledger: &Ledger, + start: Option<&str>, + end: Option<&str>, + class_id: Option, + ) -> Result { + ledger_package::pnl_statement( + ledger, + &self.settings().company, + start, + end, + class_id, + today(), + ) + } + + /// Balance sheet as of a day (see [`Self::as_of`]). + fn bs_statement( + &self, + ledger: &Ledger, + as_of: Option<&str>, + ) -> Result { + ledger_package::bs_statement(ledger, &self.settings().company, as_of, today()) + } + /// The cash flow statement from account codes, each list defaulting to /// the `[books]` roles when the call passes none. fn cash_flow_for( @@ -1625,19 +1675,19 @@ fn resource_list() -> Vec { "books://reports/trial-balance", "trial-balance", "Trial balance", - "Current trial balance, debits equal credits.", + "The trial balance as of today (debits equal credits): books.trial_balance with its as_of omitted.", ), mk( "books://reports/balance-sheet", "balance-sheet", "Balance sheet", - "Assets, liabilities, equity.", + "Assets, liabilities, equity, as of today: books.balance_sheet with its as_of omitted.", ), mk( "books://reports/profit-and-loss", "profit-and-loss", "Profit and loss", - "Revenue, expenses, net income.", + "Revenue, expenses, net income for the fiscal year to date (company.fiscal_year_start through today): books.profit_and_loss with its dates omitted.", ), mk( "books://digest", @@ -2325,11 +2375,6 @@ fn party_bills<'a>(ledger: &'a Ledger, party: &ledger_core::Party) -> Vec<&'a le .collect() } -/// This calendar year. -fn this_year() -> i32 { - chrono::Datelike::year(&today()) -} - /// Resolve a class by id or name; a number that is one class's id and /// another's name is refused (INVARIANT 6). fn resolve_class(store: &Store, class: &str) -> Result { @@ -2495,15 +2540,89 @@ fn digest_since(query: &str, today: NaiveDate) -> String { }) } -/// Resolve a statement's date range: `end` defaults to today and `start` to -/// the first posted entry (where "from the beginning" really begins, so no -/// statement says "0001-01-01"), or to `end` for empty books. -fn statement_range( - ledger: &Ledger, - start: Option<&str>, - end: Option<&str>, -) -> Result<(NaiveDate, NaiveDate), String> { - ledger_package::statement_range(ledger, start, end, today()) +/// A tool description that ends with the one sentence for its kind of +/// dates (rules.md B-30), so no tool says other than the resolver does. +fn described(what: &str, kind: ledger_core::calendar::PeriodKind) -> String { + format!("{what} {}", kind.describe()) +} + +/// The warning for a revenue or expense account created without a tax +/// category (rules.md B-32). +fn uncategorized_warning(code: &str, name: &str) -> String { + format!( + "{} {} has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees.", + code.trim(), + name.trim() + ) +} + +/// The warnings for the revenue and expense accounts an operation created +/// (control accounts: Revenue, General Expenses, payroll's expenses) +/// without a tax category: every account from `first_new` on. +fn created_uncategorized(ledger: &Ledger, first_new: ledger_core::AccountId) -> Vec { + let mut new: Vec<&ledger_core::Account> = ledger + .chart() + .all() + .filter(|a| a.id >= first_new && a.tax_category.is_none()) + .filter(|a| ledger_core::tax::needs_category(a.account_type, a.placeholder)) + .collect(); + new.sort_by_key(|a| a.id); + new.into_iter() + .map(|a| uncategorized_warning(&a.code, &a.name)) + .collect() +} + +/// How the viewer reads a report's dates (ledger-gui pages/reports.rs): +/// over a period, as of its end, or for its end's calendar year. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShownDates { + /// From start through end. + Period, + /// As of end. + AsOf, + /// For end's calendar year. + Year, +} + +/// How the viewer reads `report`'s dates with these options, or why it +/// would ignore one of them (`books.show` refuses what its target +/// ignores). Splits are the P&L's, the balance sheet's and the cash +/// flow's; comparisons theirs and the tax summary's, in one total column; +/// a balance sheet in one total column is as of its end, unless compared +/// with the period before (which starts the day before start). +fn show_report_dates( + report: &str, + columns: Option<&str>, + compare: Option<&str>, +) -> Result { + let split = columns.is_some_and(|c| c != "total"); + if columns.is_some() && !matches!(report, "profit_and_loss" | "balance_sheet" | "cash_flow") { + return Err(format!( + "columns is for profit_and_loss, balance_sheet and cash_flow, not {report}" + )); + } + if compare.is_some() { + if !matches!( + report, + "profit_and_loss" | "balance_sheet" | "cash_flow" | "tax_summary" + ) { + return Err(format!( + "compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary, not {report}" + )); + } + if split { + return Err( + "compare is for a report in one total column: a report split into columns shows no comparison" + .into(), + ); + } + } + Ok(match report { + "trial_balance" | "ar_aging" | "ap_aging" | "fixed_asset_register" => ShownDates::AsOf, + "vendor_1099" => ShownDates::Year, + "balance_sheet" if !split && compare != Some("prior_period") => ShownDates::AsOf, + _ => ShownDates::Period, + }) } fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> ScheduleOut { @@ -2560,24 +2679,6 @@ fn recurring_out(r: &ledger_core::RecurringEntry) -> RecurringOut { } } -/// Profit and loss for a range (see [`statement_range`]), optionally one class. -fn pnl_statement( - ledger: &Ledger, - start: Option<&str>, - end: Option<&str>, - class_id: Option, -) -> Result { - ledger_package::pnl_statement(ledger, start, end, class_id, today()) -} - -/// Balance sheet as of a date (default today). -fn bs_statement( - ledger: &Ledger, - as_of: Option<&str>, -) -> Result { - ledger_package::bs_statement(ledger, as_of, today()) -} - fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec { lines .iter() @@ -2697,7 +2798,7 @@ impl BooksServer { #[tool( name = "books.add_account", title = "Add an account", - description = "Add an account to the chart. Codes must be unique; check books.chart first.", + description = "Add an account to the chart. Codes must be unique; check books.chart first. Give a revenue or expense account its tax_category (books.tax_categories): without one the result warns, or, under policy.require_tax_category = refuse, the account is refused. A name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or more than one line of 200 characters, is refused.", annotations( read_only_hint = false, destructive_hint = false, @@ -2709,23 +2810,50 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let p = params.0; + let account_type = parse_account_type(&p.account_type)?; + let placeholder = p.placeholder.unwrap_or(false); + let pct = match &p.deductible_percent { + Some(s) => Some(parse_amount(s)?), + None => None, + }; + let category = p.tax_category.as_deref(); + if pct.is_some() && category.is_none() { + return Err("deductible_percent needs a tax_category".into()); + } + if let Some(cat) = category { + ledger_core::tax::check_category(cat, account_type, self.settings().company.tax_form) + .map_err(|e| e.to_string())?; + } let mut store = self.lock()?; let id = store - .add_account( + .add_account_with_tax( &p.code, &p.name, - parse_account_type(&p.account_type)?, + account_type, p.parent, - p.placeholder.unwrap_or(false), + placeholder, + category.map(|c| (c, pct)), ) .map_err(|e| e.to_string())?; - Ok(Json(AddAccountOut { account_id: id })) + let warnings = + if category.is_none() && ledger_core::tax::needs_category(account_type, placeholder) { + vec![uncategorized_warning(&p.code, &p.name)] + } else { + Vec::new() + }; + Ok(Json(AddAccountOut { + account_id: id, + warnings, + })) } #[tool( name = "books.trial_balance", title = "Trial balance", - description = "The trial balance as of a date (default today): every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.", + description = described( + "The trial balance as of a date: every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.", + ledger_core::calendar::PeriodKind::AsOf + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn trial_balance( @@ -2733,10 +2861,7 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let store = self.lock()?; - let as_of = match ¶ms.0.as_of { - Some(s) => parse_date(s)?, - None => today(), - }; + let as_of = self.as_of(params.0.as_of.as_deref())?; let tb = reports::trial_balance_as_of(store.ledger(), as_of); Ok(Json(TrialBalanceOut { as_of: as_of.to_string(), @@ -2759,7 +2884,10 @@ impl BooksServer { #[tool( name = "books.profit_and_loss", title = "Profit and loss", - description = "Profit and loss for a period: revenue, expenses, net income. Dates are YYYY-MM-DD; omit for all time. Pass class (name or id) to filter to one division.", + description = described( + "Profit and loss for a period: revenue, expenses, net income. Pass class (name or id) to filter to one division.", + ledger_core::calendar::PeriodKind::Statement + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn profit_and_loss( @@ -2772,7 +2900,7 @@ impl BooksServer { Some(c) => Some(resolve_class(&store, c)?), None => None, }; - let pl = pnl_statement( + let pl = self.pnl_statement( store.ledger(), p.start.as_deref(), p.end.as_deref(), @@ -2792,7 +2920,10 @@ impl BooksServer { #[tool( name = "books.balance_sheet", title = "Balance sheet", - description = "The balance sheet as of a date: assets, liabilities, equity (including current earnings). Omit as_of for today.", + description = described( + "The balance sheet as of a date: assets, liabilities, equity (including current earnings).", + ledger_core::calendar::PeriodKind::AsOf + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn balance_sheet( @@ -2801,7 +2932,7 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let store = self.lock()?; - let bs = bs_statement(store.ledger(), p.as_of.as_deref())?; + let bs = self.bs_statement(store.ledger(), p.as_of.as_deref())?; Ok(Json(BalanceSheetOut { as_of: bs.as_of.to_string(), assets: money(bs.total_assets), @@ -2818,7 +2949,10 @@ impl BooksServer { #[tool( name = "books.cash_flow", title = "Cash flow statement", - 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.", + description = described( + "Cash flows, direct method (ASC 230): each entry that moved cash, gross, by section and kind (operating by ASC 230-10-45-25 class; investing and financing by kind, such as a member's contributions); an entry and its reversal in one period drop out. What a member or lender paid for the company is borrowed and spent that day (on_behalf). Also noncash activities, net income reconciled to operating cash, and warnings. Sections sum to the change in cash. Roles default to the settings.", + ledger_core::calendar::PeriodKind::Statement + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn cash_flow( @@ -3829,8 +3963,34 @@ impl BooksServer { .into(), ); } - for d in [&p.start, &p.end].into_iter().flatten() { - parse_date(d)?; + let start = p.start.as_deref().map(parse_date).transpose()?; + let end = p.end.as_deref().map(parse_date).transpose()?; + // Options only an account or a report takes are refused anywhere + // else (the viewer would ignore them, and the agent believe it + // showed what it didn't). + let dated = p.account.is_some() || p.report.is_some(); + let options: Vec<&str> = [ + ("start", p.start.is_some()), + ("end", p.end.is_some()), + ("columns", p.columns.is_some()), + ("compare", p.compare.is_some()), + ] + .into_iter() + .filter(|(_, given)| *given) + .map(|(name, _)| name) + .collect(); + if !dated && !options.is_empty() { + return Err(format!( + "{} {} for an account or a report; this target takes none", + options.join(" and "), + if options.len() == 1 { "is" } else { "are" } + )); + } + if p.account.is_some() && (p.columns.is_some() || p.compare.is_some()) { + return Err( + "columns and compare are for a report; an account's register takes only start and end" + .into(), + ); } const REPORTS: [&str; 11] = [ "profit_and_loss", @@ -3907,13 +4067,19 @@ impl BooksServer { .chart() .find_by_code(&code) .ok_or_else(|| format!("unknown account {code}"))?; + // The dates resolved (rules.md B-30), sent as resolved, so what + // the agent is told and what the viewer shows can't differ. + let (s, e) = self + .periods() + .statement(start, end) + .map_err(|e| e.to_string())?; ( ViewerTarget::Account { code: code.clone(), - start: p.start.clone(), - end: p.end.clone(), + start: Some(s.to_string()), + end: Some(e.to_string()), }, - format!("the register of {} {}", a.code, a.name), + format!("the register of {} {}, {s} to {e}", a.code, a.name), ) } else if let Some(report) = p.report { if !REPORTS.contains(&report.as_str()) { @@ -3936,10 +4102,55 @@ impl BooksServer { "compare {c:?}: expected prior_period or prior_year" )); } - let mut what = format!("the {} report", report.replace('_', " ")); - if let (Some(s), Some(e)) = (&p.start, &p.end) { - what.push_str(&format!(", {s} to {e}")); + let dates = show_report_dates(&report, p.columns.as_deref(), p.compare.as_deref())?; + if p.start.is_some() { + match dates { + ShownDates::Period => {} + ShownDates::AsOf => { + return Err(format!( + "{report}{} is as of a day: pass end (the day), not start", + if report == "balance_sheet" { + " in one total column" + } else { + "" + } + )); + } + ShownDates::Year => { + return Err(format!( + "{report} is for a calendar year: pass end (a day in that year), not start" + )); + } + } } + let (s, e) = self + .periods() + .statement(start, end) + .map_err(|e| e.to_string())?; + // A split past the viewer's columns is shown as one total (the + // viewer says so): a split it would ignore. + let per = match p.columns.as_deref() { + Some("month") => Some(1), + Some("quarter") => Some(3), + Some("year") => Some(12), + _ => None, + }; + if let Some(per) = per { + let n = ledger_store::ops::report_columns(s, e, per); + let max = ledger_store::ops::MAX_REPORT_COLUMNS; + if n > max { + return Err(format!( + "columns {} from {s} to {e} is {n} columns, and the viewer splits a report into at most {max} (past that it shows the total): pass a shorter range or a longer split", + p.columns.as_deref().unwrap_or_default() + )); + } + } + let mut what = format!("the {} report", report.replace('_', " ")); + what.push_str(&match dates { + ShownDates::Period => format!(", {s} to {e}"), + ShownDates::AsOf => format!(", as of {e}"), + ShownDates::Year => format!(", for {}", chrono::Datelike::year(&e)), + }); if let Some(c) = p.columns.as_deref().filter(|c| *c != "total") { what.push_str(&format!(", by {c}")); } @@ -3949,8 +4160,8 @@ impl BooksServer { ( ViewerTarget::Report { report: report.clone(), - start: p.start.clone(), - end: p.end.clone(), + start: Some(s.to_string()), + end: Some(e.to_string()), columns: p.columns.clone(), compare: p.compare.clone(), }, @@ -4051,7 +4262,7 @@ impl BooksServer { #[tool( name = "books.digest", title = "Activity digest", - description = "What changed since a date: entries posted, per-account movement, and receipt coverage; plus what needs doing: rules due for review, settings problems, and cash and card accounts not reconciled through the end of last month (unreconciled_accounts). Built for human review.", + description = "What changed since a date: entries posted, per-account movement, and receipt coverage; plus what needs doing: rules due for review, settings problems, cash and card accounts not reconciled through the end of last month (unreconciled_accounts), and revenue and expense accounts with no tax category (uncategorized_accounts). Built for human review.", annotations(read_only_hint = true, open_world_hint = false) )] async fn digest(&self, params: Parameters) -> Result, String> { @@ -4121,8 +4332,13 @@ impl BooksServer { }) .collect(); let unreconciled_accounts = self.unreconciled_accounts(&store, today)?; + let uncategorized_accounts = ledger_core::tax::uncategorized_accounts(ledger) + .into_iter() + .map(|a| format!("{} {}", a.code, a.name)) + .collect(); Ok(Json(DigestOut { unreconciled_accounts, + uncategorized_accounts, since: since.to_string(), entries, totals_by_account: totals, @@ -4883,6 +5099,111 @@ impl BooksServer { })) } + #[tool( + name = "books.rename_account", + title = "Rename an account", + description = "Give an account a new name. Only the name changes: its id, code, type, tax category and every entry posted to it stay, so no entry, balance or statement amount changes; every report, past periods included, prints the new name, and the journal keeps the old one. Refuses a blank name, the name it has, a name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or more than one line of 200 characters. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them. Never rename the human's accounts on your own: only when they ask.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn rename_account( + &self, + params: Parameters, + ) -> Result, String> { + use ledger_store::reconcilable; + let p = params.0; + let mut store = self.lock()?; + let id = resolve_account(&store, &p.account)?; + let reason = p.reason.as_deref().map(str::trim).filter(|r| !r.is_empty()); + // The sets the books pick by name, before and after (rules.md + // B-31: a rename moves no amount, but it can move an account into + // or out of one of these). + let configured_cash = self.settings().roles.cash.clone(); + let picked = |ledger: &Ledger| { + ( + configured_cash.is_empty() + && reconcilable::cash_accounts(ledger, &[]).contains(&id), + reconcilable::card_accounts(ledger).contains(&id), + ) + }; + let before = picked(store.ledger()); + let previous_name = store + .ledger() + .chart() + .get(id) + .expect("resolved") + .name + .clone(); + store + .rename_account(id, &p.name, reason) + .map_err(|e| e.to_string())?; + let ledger = store.ledger(); + let a = ledger.chart().get(id).expect("resolved"); + let after = picked(ledger); + let mut notes = vec![format!( + "Every report, past periods included, prints \u{201c}{}\u{201d} now; the journal keeps \u{201c}{previous_name}\u{201d}. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them.", + a.name + )]; + let children = ledger + .chart() + .all() + .filter(|c| c.parent == Some(id)) + .count(); + if children > 0 { + notes.push(format!( + "Its {children} sub-account{} carry the new name in their QuickBooks full names (IIF) too.", + if children == 1 { "" } else { "s" } + )); + } + for (was, is, what) in [ + ( + before.0, + after.0, + "a cash account (books.cash_accounts is unset, so cash accounts are the asset accounts whose names say cash, bank, checking, savings or money market): the reconciliation reminders and the viewer's cash figures, Cash page and cash flow statement follow it. Set books.cash_accounts to choose them by code", + ), + ( + before.1, + after.1, + "a card account (a liability whose name says card): the reconciliation reminders follow it", + ), + ] { + if was != is { + notes.push(format!( + "{} {} is {} {what}.", + a.code, + a.name, + if is { "now" } else { "no longer" } + )); + } + } + // A blank old name (an earlier version wrote one) is in every + // text: it names no convention. + let old = previous_name.trim().to_lowercase(); + for c in ledger + .conventions() + .conventions() + .filter(|c| c.retired.is_none() && !old.is_empty()) + .filter(|c| { + c.title.to_lowercase().contains(&old) || c.body_md.to_lowercase().contains(&old) + }) + { + notes.push(format!( + "Convention {} names \u{201c}{previous_name}\u{201d} in its text: if it means this account, revise it with books.set_convention.", + c.id + )); + } + Ok(Json(RenameAccountOut { + id, + code: a.code.clone(), + previous_name, + name: a.name.clone(), + notes, + })) + } + #[tool( name = "books.label_asset", title = "Label a fixed asset", @@ -5199,7 +5520,7 @@ impl BooksServer { #[tool( name = "books.set_tax_category", title = "Set an account's tax category", - description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it. Drives the tax_summary report.", + description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it (refused under policy.require_tax_category = refuse, for a revenue or expense account that isn't a header). Drives the tax_summary report.", annotations( read_only_hint = false, destructive_hint = false, @@ -5236,7 +5557,7 @@ impl BooksServer { #[tool( name = "books.import_chart_csv", title = "Import a chart of accounts", - description = "Import a chart of accounts from CSV (header: code,name,type,parent_code): csv_text, or upload_id (books.begin_upload purpose import). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported.", + description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional): csv_text, or upload_id (books.begin_upload purpose import). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.", annotations( read_only_hint = false, destructive_hint = false, @@ -5261,10 +5582,12 @@ impl BooksServer { }; // Rehearse the whole import on a copy of the ledger first, so a bad // row anywhere (a duplicate within the file, a parent listed after - // its child or of another type, a blank code) refuses the file and - // nothing is half-imported. The old up-front checks covered only - // clashes with the existing chart and tax categories. + // its child or of another type, a blank code, a name a sibling + // has, a missing category the policy requires) refuses the file + // and nothing is half-imported. The old up-front checks covered + // only clashes with the existing chart and tax categories. let mut scratch = store.ledger().clone(); + let mut warnings = Vec::new(); for row in &rows { let account_type = parse_type(&row.account_type)?; let parent = match &row.parent_code { @@ -5280,6 +5603,22 @@ impl BooksServer { let id = scratch .add_account(&row.code, &row.name, account_type, parent, false) .map_err(|e| format!("{}: {e}", row.code))?; + // A row is never a header account (the file can't say so), so + // a revenue or expense row needs a category under refuse. + scratch + .check_tax_category_required( + &row.code, + &row.name, + account_type, + false, + row.tax_category.as_deref(), + ) + .map_err(|e| { + format!( + "{}: {e}, as the file's fifth column (tax_category); a row of the file is never a header account", + row.code + ) + })?; if let Some(cat) = &row.tax_category { ledger_core::tax::check_category( cat, @@ -5290,6 +5629,8 @@ impl BooksServer { scratch .set_account_tax(id, Some(cat), None) .map_err(|e| format!("{}: {e}", row.code))?; + } else if ledger_core::tax::needs_category(account_type, false) { + warnings.push(uncategorized_warning(&row.code, &row.name)); } } // Rehearsed whole: the upload is used up as the writing begins. @@ -5315,24 +5656,30 @@ impl BooksServer { ), None => None, }; - let id = store - .add_account(&row.code, &row.name, account_type, parent, false) + store + .add_account_with_tax( + &row.code, + &row.name, + account_type, + parent, + false, + row.tax_category.as_deref().map(|c| (c, None)), + ) .map_err(|e| e.to_string())?; - if let Some(cat) = &row.tax_category { - store - .set_account_tax(id, Some(cat), None) - .map_err(|e| e.to_string())?; - } created.push(row.code.clone()); } let count = created.len(); - Ok(Json(ImportChartCsvOut { created, count })) + Ok(Json(ImportChartCsvOut { + created, + count, + warnings, + })) } #[tool( name = "books.export", title = "Export a report", - 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).", + 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 (each entry posted in the period; 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 or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), 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 (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).", annotations(read_only_hint = true, open_world_hint = false) )] async fn export(&self, params: Parameters) -> Result, String> { @@ -5355,11 +5702,10 @@ impl BooksServer { if p.start.is_some() || p.end.is_some() { return Err("iif takes as_of, not start or end: the file holds every posted entry from the first through as_of, so QuickBooks' balances match the books'".to_string()); } - // As of today by default, like every other as-of export. - let as_of = match p.as_of.as_deref() { - Some(s) => parse_date(s)?, - None => today(), - }; + // As of today by default, like every other as-of export + // (rules.md B-30); never a period, so the general journal's + // default period never shortens it (D-36, B-34). + let as_of = self.as_of(p.as_of.as_deref())?; let iif = self.export_iif(as_of, &p.cash_accounts)?; let warnings = iif.warnings(); return Ok(Json(ExportOut { @@ -5385,7 +5731,7 @@ impl BooksServer { })); } let content = match report.as_str() { - "journal" | "general_journal" => self.export_journal(&format)?, + "journal" | "general_journal" => self.export_journal(&format, &p)?, "trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?, "general_ledger" => self.export_general_ledger(&format, &p)?, "members_equity" => self.export_members_equity(&format, &p)?, @@ -5422,14 +5768,14 @@ impl BooksServer { use ledger_import::report; let members = self.report_members(); Ok(match report { - "profit_and_loss" => report::profit_and_loss(&pnl_statement( + "profit_and_loss" => report::profit_and_loss(&self.pnl_statement( ledger, p.start.as_deref(), p.end.as_deref(), None, )?), "balance_sheet" => { - let bs = bs_statement(ledger, p.as_of.as_deref())?; + let bs = self.bs_statement(ledger, p.as_of.as_deref())?; let earnings = self.year_earnings(ledger, bs.as_of)?; report::balance_sheet(&bs, earnings, &members) } @@ -5442,21 +5788,18 @@ impl BooksServer { &p.investing_accounts, )?), "trial_balance" => { - let as_of = match &p.as_of { - Some(s) => parse_date(s)?, - None => today(), - }; + let as_of = self.as_of(p.as_of.as_deref())?; report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of) } "tax_summary" => { - let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; report::tax_summary( &ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form) .map_err(|e| e.to_string())?, ) } "members_equity" => { - let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) .map_err(|e| e.to_string())?; @@ -5477,23 +5820,20 @@ impl BooksServer { let meta = self.report_meta(&store); let ledger = store.ledger(); if report == "fixed_asset_register" { - let as_of = match p.as_of.as_deref() { - Some(s) => parse_date(s)?, - None => today(), - }; + let as_of = self.as_of(p.as_of.as_deref())?; let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?; return ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string()); } let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); match report { "general_ledger" => { - let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; xlsx(XlsxReport::GeneralLedger(&gl)) } "general_journal" | "journal" => { - let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; xlsx(XlsxReport::GeneralJournal(ledger, start, end)) } _ => xlsx(XlsxReport::Statement( @@ -5510,10 +5850,7 @@ impl BooksServer { let store = self.lock()?; let meta = self.report_meta(&store); if report == "fixed_asset_register" { - let as_of = match p.as_of.as_deref() { - Some(s) => parse_date(s)?, - None => today(), - }; + let as_of = self.as_of(p.as_of.as_deref())?; let r = ledger_core::fixed_asset_register(store.ledger(), as_of) .map_err(|e| e.to_string())?; return ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string()); @@ -7247,7 +7584,10 @@ impl BooksServer { #[tool( name = "books.parties", title = "List parties", - description = "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 facts (with the TIN's last four) and W-4 facts are on file, archived, and what was paid to and received from it in the year (default this year; defined as in books.party). Filter by role; archived parties only with include_archived.", + description = described( + "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 facts (with the TIN's last four) and W-4 facts are on file, archived, and what was paid to and received from it in the year (defined as in books.party). Filter by role; archived parties only with include_archived.", + ledger_core::calendar::PeriodKind::CalendarYear + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn parties( @@ -7260,7 +7600,7 @@ impl BooksServer { .as_deref() .map(|r| parse_roles(&[r.to_string()]).map(|v| v[0])) .transpose()?; - let year = p.year.unwrap_or_else(this_year); + let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); let settings = self.settings(); let store = self.lock()?; let mut parties = Vec::new(); @@ -7293,12 +7633,15 @@ impl BooksServer { #[tool( name = "books.party", title = "Get a party", - description = "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (default this year: paid to it, received from it, invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", + description = described( + "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it, received from it, invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", + ledger_core::calendar::PeriodKind::CalendarYear + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn party(&self, params: Parameters) -> Result, String> { let p = params.0; - let year = p.year.unwrap_or_else(this_year); + let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); let settings = self.settings(); let store = self.lock()?; let ledger = store.ledger(); @@ -7534,6 +7877,7 @@ impl BooksServer { }) }) .collect::>()?; + let first_new = store.ledger().next_account_id(); let id = store .create_invoice( p.party_id, @@ -7550,6 +7894,7 @@ impl BooksServer { id, total: inv.total().map_err(|e| e.to_string())?.to_string(), entry_id: inv.entry_id, + warnings: created_uncategorized(store.ledger(), first_new), })) } @@ -7579,6 +7924,7 @@ impl BooksServer { }) }) .collect::>()?; + let first_new = store.ledger().next_account_id(); let id = store .create_bill( p.party_id, @@ -7595,6 +7941,7 @@ impl BooksServer { id, total: bill.total().map_err(|e| e.to_string())?.to_string(), entry_id: bill.entry_id, + warnings: created_uncategorized(store.ledger(), first_new), })) } @@ -7681,15 +8028,19 @@ impl BooksServer { #[tool( name = "books.ar_aging", title = "Receivables aging", - description = "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).", + description = described( + "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).", + ledger_core::calendar::PeriodKind::AsOf + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn ar_aging(&self, params: Parameters) -> Result, String> { let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; let store = self.lock()?; let rows = store .ledger() - .ar_aging(parse_date(&p.as_of)?) + .ar_aging(as_of) .map_err(|e| e.to_string())? .into_iter() .map(|r| { @@ -7705,21 +8056,28 @@ impl BooksServer { }) }) .collect::>()?; - Ok(Json(AgingOut { rows })) + Ok(Json(AgingOut { + as_of: as_of.to_string(), + rows, + })) } #[tool( name = "books.ap_aging", title = "Payables aging", - description = "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).", + description = described( + "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).", + ledger_core::calendar::PeriodKind::AsOf + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn ap_aging(&self, params: Parameters) -> Result, String> { let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; let store = self.lock()?; let rows = store .ledger() - .ap_aging(parse_date(&p.as_of)?) + .ap_aging(as_of) .map_err(|e| e.to_string())? .into_iter() .map(|r| { @@ -7735,7 +8093,10 @@ impl BooksServer { }) }) .collect::>()?; - Ok(Json(AgingOut { rows })) + Ok(Json(AgingOut { + as_of: as_of.to_string(), + rows, + })) } // ------------------------------------------------------------------ @@ -7832,6 +8193,7 @@ impl BooksServer { "name each payroll line's employee party (employee), so the entry names who was paid", )); } + let first_new = store.ledger().next_account_id(); let run_id = store .run_payroll(parse_date(&p.date)?, lines, cash) .map_err(|e| e.to_string())?; @@ -7846,6 +8208,7 @@ impl BooksServer { entry_id: run.entry_id, total_gross: money(total_gross), total_net: money(total_net), + warnings: created_uncategorized(store.ledger(), first_new), })) } @@ -7969,7 +8332,10 @@ impl BooksServer { #[tool( name = "books.profit_and_loss_by_class", title = "Profit and loss by class", - description = "P&L broken down by tracking class for a period, plus an unclassified row for untagged lines. Dates are YYYY-MM-DD; omit for all time.", + description = described( + "P&L broken down by tracking class for a period, plus an unclassified row for untagged lines.", + ledger_core::calendar::PeriodKind::Statement + ), annotations(read_only_hint = true, open_world_hint = false) )] async fn profit_and_loss_by_class( @@ -7979,17 +8345,7 @@ impl BooksServer { let p = params.0; let store = self.lock()?; let ledger = store.ledger(); - let start = match p.start { - Some(s) => parse_date(&s)?, - None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(), - }; - let end = match p.end { - Some(s) => parse_date(&s)?, - None => today(), - }; - if end < start { - return Err("end is before start".to_string()); - } + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; // One class, if asked (validated like every reference). let only = match &p.class { Some(c) => Some(resolve_class(&store, c)?), @@ -8109,10 +8465,7 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let p = params.0; - let as_of = match &p.as_of { - Some(s) => parse_date(s)?, - None => today(), - }; + let as_of = self.as_of(p.as_of.as_deref())?; let store = self.lock()?; let book = book_name(&store); // The vault, opened once and never creating a key: locked, each @@ -8187,12 +8540,17 @@ impl BooksServer { // --------------------------------------------------------------------------- impl BooksServer { - fn export_journal(&self, format: &str) -> Result { + /// The general journal as JSON or CSV: each entry posted in the + /// period (rules.md B-30, as the XLSX journal), in posting order. + fn export_journal(&self, format: &str, p: &ExportParams) -> Result { + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; let store = self.lock()?; let ledger = store.ledger(); + let in_period = |e: &&ledger_core::JournalEntry| (start..=end).contains(&e.date()); if format == "json" { let rows: Vec = ledger .posted_entries() + .filter(in_period) .map(|e| { serde_json::json!({ "entry_id": e.id(), @@ -8217,7 +8575,7 @@ impl BooksServer { let mut csv = String::from( "entry_id,date,description,reversal_of,account_code,account_name,side,amount,currency\n", ); - for e in ledger.posted_entries() { + for e in ledger.posted_entries().filter(in_period) { for l in e.lines() { let a = ledger.chart().get(l.account).unwrap(); csv.push_str(&format!( @@ -8242,10 +8600,7 @@ impl BooksServer { fn export_trial_balance(&self, format: &str, as_of: Option<&str>) -> Result { let store = self.lock()?; - let as_of = match as_of { - Some(s) => parse_date(s)?, - None => today(), - }; + let as_of = self.as_of(as_of)?; let tb = reports::trial_balance_as_of(store.ledger(), as_of); if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ @@ -8286,10 +8641,7 @@ impl BooksServer { as_of: Option<&str>, ) -> Result { let store = self.lock()?; - let as_of = match as_of { - Some(s) => parse_date(s)?, - None => today(), - }; + let as_of = self.as_of(as_of)?; let r = ledger_core::fixed_asset_register(store.ledger(), as_of).map_err(|e| e.to_string())?; // The same words as the XLSX and PDF register. @@ -8366,7 +8718,7 @@ impl BooksServer { fn export_general_ledger(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); - let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ @@ -8419,7 +8771,7 @@ impl BooksServer { fn export_tax_summary(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); - let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; let ts = ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form) .map_err(|e| e.to_string())?; let line = |l: &ledger_core::TaxSummaryLine| { @@ -8436,6 +8788,11 @@ impl BooksServer { "cost_of_goods": ts.cost_of_goods.iter().map(line).collect::>(), "deductions": ts.deductions.iter().map(line).collect::>(), "separately_stated": ts.separately_stated.iter().map(line).collect::>(), + // Book amounts only: Form 4797 figures the tax gain. + "form_4797": ts.form_4797.iter().map(|l| serde_json::json!({ + "category": l.category, "label": l.label, "accounts": l.accounts, + "book": money(l.book), + })).collect::>(), "uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({ "account": u.account, "book": money(u.book), })).collect::>(), @@ -8462,6 +8819,15 @@ impl BooksServer { )); } } + for l in &ts.form_4797 { + csv.push_str(&format!( + "form_4797,{},{},{},{},\n", + l.category, + csv_escape(&l.label), + csv_escape(&l.accounts.join("; ")), + money(l.book) + )); + } for u in &ts.uncategorized { csv.push_str(&format!( "uncategorized,,,{},{},\n", @@ -8475,7 +8841,7 @@ impl BooksServer { fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); - let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) .map_err(|e| e.to_string())?; let col = |c: &ledger_core::MemberEquityColumn| { @@ -8518,7 +8884,7 @@ impl BooksServer { end: Option<&str>, ) -> Result { let store = self.lock()?; - let pl = pnl_statement(store.ledger(), start, end, None)?; + let pl = self.pnl_statement(store.ledger(), start, end, None)?; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "start": pl.start.to_string(), "end": pl.end.to_string(), @@ -8551,7 +8917,7 @@ impl BooksServer { fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result { let store = self.lock()?; - let bs = bs_statement(store.ledger(), as_of)?; + let bs = self.bs_statement(store.ledger(), as_of)?; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "as_of": bs.as_of.to_string(), @@ -9643,7 +10009,7 @@ mod tests { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 111] = [ + let table: [(&str, Hints); 112] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -9699,6 +10065,7 @@ mod tests { ("books.chart_template", R), ("books.tax_categories", R), ("books.renumber_account", D), + ("books.rename_account", D), ("books.label_asset", W), ("books.correct_asset", D), ("books.set_asset_tax_basis", W), @@ -9794,8 +10161,8 @@ mod tests { "{name}: openWorldHint" ); } - // docs/mcp-interface.md names the 28 destructive tools. - assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 28); + // docs/mcp-interface.md names the 29 destructive tools. + assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); } /// Every refusal an agent can be given whose text is fixed here, or @@ -10283,6 +10650,198 @@ mod tests { assert_eq!(over, OVER, "tool descriptions over 700 characters"); } + /// rules.md B-30 (defect 16): every report tool says what its omitted + /// dates mean in the one sentence the resolver's kind gives + /// (PeriodKind::describe), and each date parameter in the schema's + /// own words (START_DOC, END_DOC, AS_OF_DOC, YEAR_DOC); the two tools + /// over the description budget (books.export, books.show) and the CPA + /// package say it in their parameters. No tool or schema says "all + /// time", "beginning of time" or "first entry" any more. The resources + /// read with dates omitted say which period they cover. + #[test] + fn period_tools_describe_the_one_default() { + use ledger_core::calendar::{AS_OF_DOC, END_DOC, PeriodKind, START_DOC, YEAR_DOC}; + let tools = BooksServer::tool_router().list_all(); + let tool = |name: &str| { + tools + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("no tool {name}")) + }; + let field = |name: &str, f: &str| -> String { + tool(name).input_schema["properties"][f]["description"] + .as_str() + .unwrap_or_else(|| panic!("{name}.{f} has no description")) + .to_string() + }; + for (name, kind, fields) in [ + ( + "books.profit_and_loss", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), + ( + "books.profit_and_loss_by_class", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), + ( + "books.cash_flow", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), + ( + "books.trial_balance", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.balance_sheet", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.ar_aging", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.ap_aging", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.parties", + PeriodKind::CalendarYear, + &[("year", YEAR_DOC)][..], + ), + ( + "books.party", + PeriodKind::CalendarYear, + &[("year", YEAR_DOC)][..], + ), + ] { + let d = tool(name).description.as_deref().unwrap_or_default(); + assert!(d.ends_with(kind.describe()), "{name}: {d}"); + for (f, doc) in fields { + assert_eq!(field(name, f), *doc, "{name}.{f}"); + } + } + // Over the budget, or the package: in the parameters' words. + for (name, f, doc) in [ + ("books.export", "start", START_DOC), + ("books.export", "end", END_DOC), + ("books.export", "as_of", AS_OF_DOC), + ("books.show", "start", START_DOC), + ("books.show", "end", END_DOC), + ("books.export_cpa_package", "as_of", AS_OF_DOC), + ] { + assert!( + field(name, f).ends_with(doc), + "{name}.{f}: {}", + field(name, f) + ); + } + assert!( + tool("books.export") + .description + .as_deref() + .unwrap() + .contains("omitted: the fiscal year to date") + ); + // Nothing left of the old defaults, anywhere. + for t in tools.iter() { + let text = format!( + "{} {}", + t.description.as_deref().unwrap_or_default(), + serde_json::Value::Object((*t.input_schema).clone()) + ) + .to_lowercase(); + for stale in [ + "all time", + "beginning of time", + "first entry", + "first posted entry", + ] { + assert!(!text.contains(stale), "{} says {stale:?}", t.name); + } + } + let resources = resource_list(); + let resource = |uri: &str| { + resources + .iter() + .find(|r| r.uri == uri) + .and_then(|r| r.description.clone()) + .unwrap() + }; + assert!(resource("books://reports/profit-and-loss").contains("fiscal year to date")); + assert!(resource("books://reports/trial-balance").contains("as of today")); + assert!(resource("books://reports/balance-sheet").contains("as of today")); + } + + /// books.show (defect 15): how the viewer reads each report's dates, + /// and the options a report ignores, refused (one match, so each arm + /// is pinned). + #[test] + fn show_reads_each_reports_dates_as_the_viewer_does() { + for (report, columns, compare, want) in [ + ("profit_and_loss", None, None, Ok(ShownDates::Period)), + ("cash_flow", Some("month"), None, Ok(ShownDates::Period)), + ("members_equity", None, None, Ok(ShownDates::Period)), + ("general_ledger", None, None, Ok(ShownDates::Period)), + ( + "tax_summary", + None, + Some("prior_year"), + Ok(ShownDates::Period), + ), + ("trial_balance", None, None, Ok(ShownDates::AsOf)), + ("ar_aging", None, None, Ok(ShownDates::AsOf)), + ("ap_aging", None, None, Ok(ShownDates::AsOf)), + ("fixed_asset_register", None, None, Ok(ShownDates::AsOf)), + ("vendor_1099", None, None, Ok(ShownDates::Year)), + ("balance_sheet", None, None, Ok(ShownDates::AsOf)), + ("balance_sheet", Some("total"), None, Ok(ShownDates::AsOf)), + ( + "balance_sheet", + None, + Some("prior_year"), + Ok(ShownDates::AsOf), + ), + ( + "balance_sheet", + None, + Some("prior_period"), + Ok(ShownDates::Period), + ), + ( + "balance_sheet", + Some("quarter"), + None, + Ok(ShownDates::Period), + ), + ("trial_balance", Some("total"), None, Err("columns")), + ("tax_summary", Some("month"), None, Err("columns")), + ("vendor_1099", None, Some("prior_year"), Err("compare")), + ("general_ledger", None, Some("prior_period"), Err("compare")), + ( + "profit_and_loss", + Some("year"), + Some("prior_year"), + Err("split"), + ), + ] { + let got = show_report_dates(report, columns, compare); + match want { + Ok(dates) => assert_eq!(got, Ok(dates), "{report} {columns:?} {compare:?}"), + Err(word) => { + let e = got.unwrap_err(); + assert!(e.contains(word), "{report} {columns:?} {compare:?}: {e}"); + } + } + } + } + /// How many masked TINs a text holds. fn data_masked(text: &str) -> usize { text.matches("•••-••-••••").count() + text.matches("•••••••••").count() diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 4019ccf..ac14469 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1,6 +1,7 @@ //! Tool parameter and output types. Amounts cross MCP as decimal strings, //! never JSON floats. +use ledger_core::calendar::{AS_OF_DOC, END_DOC, START_DOC, YEAR_DOC}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -13,7 +14,10 @@ use serde::{Deserialize, Serialize}; pub struct AddAccountParams { /// Account code, e.g. "1000". Must be unique. pub code: String, - /// Human name, e.g. "Business Checking". + /// Human name, e.g. "Business Checking": one line of at most 200 + /// characters, kept trimmed; no other account under the same parent + /// has it (ignoring capitals), and it holds no run of nine digits + /// (write an account number's last four). pub name: String, /// One of: asset, liability, equity, revenue, expense. pub account_type: String, @@ -21,6 +25,14 @@ pub struct AddAccountParams { pub parent: Option, /// True for header accounts that never take postings. pub placeholder: Option, + /// The tax category of a revenue or expense account (a key from + /// `books.tax_categories`): the line of the return it maps to. + #[serde(default)] + pub tax_category: Option, + /// Percent deductible, overriding the category's default (0 to 100), + /// as a decimal string. Needs tax_category. + #[serde(default)] + pub deductible_percent: Option, } /// Result of `books.add_account`. @@ -28,6 +40,45 @@ pub struct AddAccountParams { pub struct AddAccountOut { /// The new account's id. pub account_id: u64, + /// What to tell the human: a revenue or expense account created + /// without a tax category (the tax summary lists it as uncategorized + /// until it has one). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// Parameters for `books.rename_account`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RenameAccountParams { + /// The account's code (or id). + pub account: String, + /// Its new name, kept trimmed: one line of at most 200 characters, + /// not the one it has, not one another account under the same parent + /// has (ignoring capitals), and no run of nine digits. + pub name: String, + /// Why, in a line or two, for the journal (the human's words, or what + /// they asked). Optional. + #[serde(default)] + pub reason: Option, +} + +/// Result of `books.rename_account`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct RenameAccountOut { + /// The account's id (unchanged). + pub id: u64, + /// Its code (unchanged). + pub code: String, + /// The name it had: the journal keeps it. + pub previous_name: String, + /// The name it has now. + pub name: String, + /// What else the new name changes: a set of accounts picked by name + /// it joins or leaves (cash and card accounts, when the settings name + /// none), conventions whose text names the old name, and the + /// QuickBooks import (IIF matches accounts by name). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, } /// One account in the chart. @@ -474,17 +525,38 @@ pub struct ShowParams { /// A page: overview, cash, profit, transactions, reports, accounts, /// assets, parties, documents, company, journal. pub page: Option, - /// For account and report: first date, YYYY-MM-DD. + /// For an account or a report over a period: its first day (see + /// [`show_start_doc`]). + #[schemars(description = show_start_doc())] pub start: Option, - /// For account and report: last date, YYYY-MM-DD. + /// For an account or a report: its last day, or the day a report as + /// of a day is as of (see [`show_end_doc`]). + #[schemars(description = show_end_doc())] pub end: Option, /// For a report: split into columns, "total" (default), "month", /// "quarter", or "year" (P&L, balance sheet, cash flow). pub columns: Option, - /// For a report: compare with "prior_period" or "prior_year". + /// For a report: compare with "prior_period" or "prior_year" (P&L, + /// balance sheet, cash flow, tax summary; not with a split). pub compare: Option, } +/// `books.show`'s `start`, in the one default's words +/// ([`ledger_core::calendar::START_DOC`]). +pub fn show_start_doc() -> String { + format!( + "For an account, or a report over a period (a balance sheet only when split or compared with the prior period). {START_DOC}" + ) +} + +/// `books.show`'s `end`, in the one default's words +/// ([`ledger_core::calendar::END_DOC`]). +pub fn show_end_doc() -> String { + format!( + "For an account or a report: the last day, or the day a report as of a day is as of (vendor_1099: a day in its year). {END_DOC}" + ) +} + /// Result of `books.show`. #[derive(Debug, Serialize, JsonSchema)] pub struct ShowOut { @@ -738,8 +810,9 @@ pub struct TrialBalanceLineOut { /// Parameters for `books.trial_balance`. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct TrialBalanceParams { - /// As of this date, YYYY-MM-DD. Defaults to today. + /// As of this day ([`AS_OF_DOC`]). #[serde(default)] + #[schemars(description = AS_OF_DOC)] pub as_of: Option, } @@ -761,9 +834,11 @@ pub struct TrialBalanceOut { /// Parameters for `books.profit_and_loss`. #[derive(Debug, Deserialize, JsonSchema)] pub struct ProfitAndLossParams { - /// Period start, YYYY-MM-DD. Defaults to the beginning of time. + /// Period start ([`START_DOC`]). + #[schemars(description = START_DOC)] pub start: Option, - /// Period end, YYYY-MM-DD. Defaults to today. + /// Period end ([`END_DOC`]). + #[schemars(description = END_DOC)] pub end: Option, /// Optional class name or id to filter to one division. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -803,9 +878,11 @@ pub struct StatementLineOut { /// Parameters for `books.cash_flow`. #[derive(Debug, Deserialize, JsonSchema)] pub struct CashFlowParams { - /// Period start, YYYY-MM-DD. Defaults to the beginning of time. + /// Period start ([`START_DOC`]). + #[schemars(description = START_DOC)] pub start: Option, - /// Period end, YYYY-MM-DD. Defaults to today. + /// Period end ([`END_DOC`]). + #[schemars(description = END_DOC)] pub end: Option, /// Asset account codes treated as cash (e.g. `["1000", "1010"]`): the /// statement explains the change in these accounts. Defaults to the @@ -1081,12 +1158,17 @@ pub struct ImportChartCsvOut { pub created: Vec, /// Number of accounts created. pub count: usize, + /// What to tell the human: each revenue or expense account created + /// without a tax category. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } /// Parameters for `books.balance_sheet`. #[derive(Debug, Deserialize, JsonSchema)] pub struct BalanceSheetParams { - /// As of this date, YYYY-MM-DD. Defaults to today. + /// As of this day ([`AS_OF_DOC`]). + #[schemars(description = AS_OF_DOC)] pub as_of: Option, } @@ -1773,6 +1855,11 @@ pub struct DigestOut { /// with a recorded statement) with activity not reconciled through the /// end of last month: record each statement and reconcile it. pub unreconciled_accounts: Vec, + /// Revenue and expense accounts (not headers) with no tax category, + /// active or not, "CODE Name": the list the viewer's Needs attention + /// shows. Propose a category for each from books.tax_categories and + /// set it with books.set_tax_category once the human agrees. + pub uncategorized_accounts: Vec, } /// An account due for reconciliation (`books.digest`). @@ -1827,13 +1914,14 @@ pub struct ExportParams { /// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or /// "cash_flow". pub report: String, - /// Period start for profit_and_loss and cash_flow, YYYY-MM-DD. + /// Period start for the period reports (see [`export_start_doc`]). + #[schemars(description = export_start_doc())] pub start: Option, - /// Period end for profit_and_loss and cash_flow, YYYY-MM-DD. + /// Period end for the period reports (see [`export_end_doc`]). + #[schemars(description = export_end_doc())] pub end: Option, - /// As-of date for balance_sheet, trial_balance, fixed_asset_register, - /// and iif (every posted entry dated on or before it), YYYY-MM-DD. - /// Defaults to today. + /// As-of day for the reports as of a day (see [`export_as_of_doc`]). + #[schemars(description = export_as_of_doc())] pub as_of: Option, /// Cash account codes for cash_flow, and for iif its BANK accounts. /// Defaults to `[books] cash_accounts`; one or the other is required @@ -1851,6 +1939,28 @@ pub struct ExportParams { pub investing_accounts: Vec, } +/// `books.export`'s `start`, in the one default's words +/// ([`ledger_core::calendar::START_DOC`]). +pub fn export_start_doc() -> String { + format!( + "For the period reports: profit_and_loss, cash_flow, members_equity, tax_summary, general_ledger, general_journal (csv, json, xlsx; iif takes as_of instead). {START_DOC}" + ) +} + +/// `books.export`'s `end`, in the one default's words +/// ([`ledger_core::calendar::END_DOC`]). +pub fn export_end_doc() -> String { + format!("For the period reports, as for start. {END_DOC}") +} + +/// `books.export`'s `as_of`, in the one default's words +/// ([`ledger_core::calendar::AS_OF_DOC`]). +pub fn export_as_of_doc() -> String { + format!( + "For balance_sheet, trial_balance, fixed_asset_register and iif (every posted entry dated on or before it). {AS_OF_DOC}" + ) +} + /// Result of `books.export`. #[derive(Debug, Serialize, JsonSchema)] pub struct ExportOut { @@ -2534,6 +2644,10 @@ pub struct CreateDocumentOut { pub total: String, /// The auto-posted journal entry's id. pub entry_id: u64, + /// What to tell the human: a revenue or expense account this created + /// (Revenue 4000, General Expenses 6999) has no tax category yet. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } /// Parameters for `books.receive_payment` / `books.pay_bill`. @@ -2563,8 +2677,10 @@ pub struct PaymentOut { /// Parameters for `books.ar_aging` / `books.ap_aging`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AgingParams { - /// As-of date, YYYY-MM-DD. - pub as_of: String, + /// As of this day ([`AS_OF_DOC`]). + #[serde(default)] + #[schemars(description = AS_OF_DOC)] + pub as_of: Option, } /// One row of an aging report. @@ -2589,6 +2705,8 @@ pub struct AgingRowOut { /// Output of `books.ar_aging` / `books.ap_aging`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AgingOut { + /// The day it is as of, YYYY-MM-DD. + pub as_of: String, /// Rows, one per party with an outstanding balance. pub rows: Vec, } @@ -2669,6 +2787,11 @@ pub struct RunPayrollOut { pub total_gross: String, /// Total net pay, decimal string. pub total_net: String, + /// What to tell the human: an expense account this created (Salary & + /// Wage Expense 6100, Payroll Tax Expense 6110) has no tax category + /// yet. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } /// Parameters for `books.remit_payroll_taxes`. @@ -2780,7 +2903,9 @@ pub struct ProfitAndLossByClassOut { /// Parameters for `books.export_cpa_package`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ExportCpaPackageParams { - /// As-of date, YYYY-MM-DD. Defaults to today. + /// As of this day ([`AS_OF_DOC`]); the period reports cover the + /// fiscal year that holds it, through it. + #[schemars(description = AS_OF_DOC)] pub as_of: Option, /// Cash account codes the cash flow statement reconciles to, e.g. /// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other @@ -3114,8 +3239,9 @@ pub struct ListPartiesParams { /// Include archived parties (default false). #[serde(default)] pub include_archived: bool, - /// Calendar year for `paid` and `received` (default: this year, so - /// year to date). + /// Calendar year for `paid` and `received` ([`YEAR_DOC`]: this year, + /// so year to date). + #[schemars(description = YEAR_DOC)] pub year: Option, } @@ -3124,7 +3250,9 @@ pub struct ListPartiesParams { pub struct PartyParams { /// The party: id or name. pub party: String, - /// Calendar year for the totals (default: this year, so year to date). + /// Calendar year for the totals ([`YEAR_DOC`]: this year, so year to + /// date). + #[schemars(description = YEAR_DOC)] pub year: Option, } diff --git a/crates/ledger-mcp/tests/hostile_args.rs b/crates/ledger-mcp/tests/hostile_args.rs index 7b1556d..098e069 100644 --- a/crates/ledger-mcp/tests/hostile_args.rs +++ b/crates/ledger-mcp/tests/hostile_args.rs @@ -1553,3 +1553,162 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec { let _ = std::fs::remove_dir_all(&p.base); failures } + +/// P3 (defects 8, 15, 16): renames, refused report ranges and tax +/// categories at creation, at their edges, are each answered (a result or +/// a refusal, never a stop), and the books reopen after them. A new name +/// is one line of at most 200 characters (ledger_core::account:: +/// MAX_ACCOUNT_NAME): 200 is taken and read back whole; 201, a +/// 100,000-character one, a line break or a tab is refused. +#[test] +fn hostile_renames_ranges_and_categories_are_answered() { + let p = places("p3"); + let mut m = Mcp::start(&p); + fixture(&mut m, &p); + let refused = |r: &Value| r["isError"] == json!(true); + for (tool, args, refuse) in [ + ( + "books.rename_account", + json!({"account": "6300", "name": ""}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": " "}), + true, + ), + ( + "books.rename_account", + json!({"account": "9999", "name": "X"}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "insurance"}), + false, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "Contract labor"}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "Policy 123456789"}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "Policy ٠١٢٣٤٥٦٧٨"}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "Risk", "reason": "x".repeat(2_001)}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "Risk", "reason": "\u{0}"}), + true, + ), + ( + "books.show", + json!({"account": "1000", "start": "9999-12-31"}), + true, + ), + ( + "books.show", + json!({"report": "profit_and_loss", "start": "9999-12-31"}), + true, + ), + ( + "books.show", + json!({"report": "profit_and_loss", "end": "0001-01-01"}), + false, + ), + ( + "books.show", + json!({"report": "trial_balance", "end": "9999-12-31"}), + false, + ), + ( + "books.profit_and_loss", + json!({"start": "9999-12-31"}), + true, + ), + ("books.profit_and_loss", json!({"end": "0001-01-01"}), false), + ( + "books.profit_and_loss_by_class", + json!({"start": "2026-12-31", "end": "2026-01-01"}), + true, + ), + ( + "books.cash_flow", + json!({"start": "9999-12-31", "cash_accounts": ["1000"]}), + true, + ), + ("books.ar_aging", json!({}), false), + ("books.ap_aging", json!({"as_of": "9999-12-31"}), false), + ( + "books.add_account", + json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}), + true, + ), + ( + "books.add_account", + json!({"code": "4101", "name": "Fees", "account_type": "revenue", "deductible_percent": "1e9"}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "N".repeat(201)}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "N".repeat(100_000)}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "Risk\nPolicy"}), + true, + ), + ( + "books.rename_account", + json!({"account": "6300", "name": "Risk\tPolicy"}), + true, + ), + ( + "books.add_account", + json!({"code": "6310", "name": "X".repeat(201), "account_type": "expense"}), + true, + ), + ( + "books.add_account", + json!({"code": "6311", "name": "Two\nlines", "account_type": "expense"}), + true, + ), + ] { + let r = pinned(&mut m, tool, args.clone()); + assert_eq!(refused(&r), refuse, "{tool} {}: {r}", truncated(&args)); + } + // A name of 200 characters: taken, and read back whole. + let long = "N".repeat(200); + let r = pinned( + &mut m, + "books.rename_account", + json!({"account": "1000", "name": long}), + ); + assert!(!refused(&r), "{}", truncated(&r)); + let chart = m.ok("books.chart", json!({})); + assert!( + chart["accounts"] + .as_array() + .unwrap() + .iter() + .any(|a| a["code"] == "1000" && a["name"].as_str().unwrap() == long) + ); + reopens(m, &p, "P3's hostile inputs"); +} diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index fa231a6..11daf8b 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len()); + assert_eq!(tools.len(), 109, "expected 109 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); @@ -309,7 +309,7 @@ fn mcp_end_to_end_over_stdio() { // Export works. let export = mcp.call( "books.export", - json!({"format": "csv", "report": "journal"}), + json!({"format": "csv", "report": "journal", "start": "2026-01-01", "end": "2026-12-31"}), ); assert!(export["content"].as_str().unwrap().contains("January rent")); @@ -1445,10 +1445,15 @@ fn mcp_period_statements_over_stdio() { assert_eq!(feb["net_income"], "-0.50"); assert_eq!(feb["expense_accounts"][0]["code"], "6000"); assert_eq!(feb["expense_accounts"][0]["amount"], "3.00"); - // No start: the statement begins at the first entry, not year 1. - let all = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"})); - assert_eq!(all["start"], "2026-01-02"); - assert_eq!(all["revenue"], "6.50"); + // No start (rules.md B-30): the fiscal year that holds the end, to + // it. The fiscal year starts 02-01, so January's 4.00 of sales is + // last year's, and February's 2.50 is this one's. + let ytd = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"})); + assert_eq!(ytd["start"], "2026-02-01"); + assert_eq!(ytd["revenue"], "2.50"); + let last = mcp.call("books.profit_and_loss", json!({"end": "2026-01-31"})); + assert_eq!(last["start"], "2025-02-01"); + assert_eq!(last["revenue"], "4.00"); // As of Jan 31: cash 1,004.00 = capital 1,000.00 + earnings 4.00. let jan = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"})); @@ -2775,3 +2780,459 @@ fn renumbering_an_account_keeps_its_history() { "{codes:?}" ); } + +/// A server on books whose settings come from `toml`, initialized. +fn served(dir: &std::path::Path, toml: &str) -> Mcp { + let path = dir.join("wanderbooks.toml"); + std::fs::write(&path, toml).unwrap(); + let mut mcp = Mcp::spawn_with( + &dir.join("books-data"), + &[std::ffi::OsStr::new("--books-toml"), path.as_os_str()], + ); + init(&mut mcp); + mcp +} + +/// Post one balanced two-line entry over stdio. +fn post_pair(mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str) -> u64 { + let e = mcp.call( + "books.draft_entry", + json!({"date": date, "description": "x", "no_party_reason": "test"}), + ); + let id = e["entry_id"].as_u64().unwrap(); + for (account, side) in [(dr, "debit"), (cr, "credit")] { + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ); + } + mcp.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + id +} + +/// Defect 16 (rules.md B-30): one default period everywhere. On books with +/// a July fiscal year and a first entry in the fiscal year before, every +/// period report given only an end covers the fiscal year that holds it +/// (2025-07-01 through 2026-03-31, never the first entry's day, never +/// 0001-01-01): profit and loss, by class, cash flow, and the exported tax +/// summary, members' equity, general ledger and general journal (csv and +/// json, whose entries are the period's); the CPA package as of that day +/// covers the same. With no dates at all, every one of them returns the +/// same pair, today's fiscal year to date (the clock read before and +/// after, so midnight can't fail it). +#[test] +fn one_default_period_everywhere_defect_16() { + let dir = test_dir("one-default"); + let mut mcp = served( + &dir, + "[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", + ); + mcp.call( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital A,equity,,\n3001,Capital B,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}), + ); + let first = post_pair(&mut mcp, "2025-03-15", "1000", "3000", "500.00"); + let mid = post_pair(&mut mcp, "2025-09-10", "1000", "4000", "120.00"); + let late = post_pair(&mut mcp, "2026-02-01", "6000", "1000", "20.00"); + let _after = post_pair(&mut mcp, "2026-05-01", "1000", "4000", "7.00"); + let end = json!({"end": "2026-03-31"}); + let pair = |v: &Value| { + ( + v["start"].as_str().unwrap().to_string(), + v["end"].as_str().unwrap().to_string(), + ) + }; + let want = ("2025-07-01".to_string(), "2026-03-31".to_string()); + let pl = mcp.call("books.profit_and_loss", end.clone()); + assert_eq!(pair(&pl), want); + assert_eq!(pl["revenue"], "120.00"); + assert_eq!(pl["expenses"], "20.00"); + assert_eq!( + pair(&mcp.call("books.profit_and_loss_by_class", end.clone())), + want + ); + assert_eq!(pair(&mcp.call("books.cash_flow", end.clone())), want); + for report in ["tax_summary", "members_equity", "general_ledger"] { + let out = mcp.call( + "books.export", + json!({"report": report, "format": "json", "end": "2026-03-31"}), + ); + let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); + assert_eq!(pair(&v), want, "{report}"); + } + // The general journal: the period's entries, in json and csv alike. + let out = mcp.call( + "books.export", + json!({"report": "general_journal", "format": "json", "end": "2026-03-31"}), + ); + let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); + let ids: Vec = v + .as_array() + .unwrap() + .iter() + .map(|e| e["entry_id"].as_u64().unwrap()) + .collect(); + assert_eq!(ids, [mid, late], "not {first}, not after the end"); + let csv = mcp.call( + "books.export", + json!({"report": "journal", "format": "csv", "end": "2026-03-31"}), + ); + let rows = csv["content"].as_str().unwrap().lines().count(); + assert_eq!(rows, 1 + 4, "header and two entries of two lines"); + // An inverted range is refused by the journal too (review R1). + let err = mcp.call_err( + "books.export", + json!({"report": "general_journal", "format": "json", "start": "2026-12-31", "end": "2026-01-01"}), + ); + assert!(err.contains("is before start"), "{err}"); + // The CPA package as of that day: the same fiscal year to date. + let pkg = mcp.call( + "books.export_cpa_package", + json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}), + ); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap(); + let mut readme = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("README.txt").unwrap(), &mut readme).unwrap(); + assert!( + readme.contains("Fiscal year to date: 2025-07-01 through 2026-03-31."), + "{readme}" + ); + // No dates: the same pair from every period report. + let today = || ledger_core::settings::today_in(None); + let before = today(); + let mut got = vec![ + pair(&mcp.call("books.profit_and_loss", json!({}))), + pair(&mcp.call("books.profit_and_loss_by_class", json!({}))), + pair(&mcp.call("books.cash_flow", json!({}))), + ]; + for report in ["tax_summary", "members_equity", "general_ledger"] { + let out = mcp.call("books.export", json!({"report": report, "format": "json"})); + let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); + got.push(pair(&v)); + } + let after = today(); + let fy = |t: chrono::NaiveDate| { + let c = ledger_core::settings::Company { + fiscal_year_start: (7, 1), + ..Default::default() + }; + let (s, _) = ledger_core::calendar::period_defaults(&c, t).statement_range(); + (s.to_string(), t.to_string()) + }; + for p in &got { + assert!(*p == fy(before) || *p == fy(after), "{p:?}"); + } + assert!( + got.iter().all(|p| p == &got[0]) || before != after, + "{got:?}" + ); +} + +/// The entry ids (DOCNUM) of an IIF file's transactions, in file order. +fn iif_docnums(iif: &str) -> Vec { + let mut docnum = None; + let mut ids = Vec::new(); + for line in iif.split("\r\n") { + let f: Vec<&str> = line.split('\t').collect(); + match f[0] { + "!TRNS" => docnum = f.iter().position(|c| *c == "DOCNUM"), + "TRNS" => ids.push(f[docnum.expect("!TRNS before TRNS")].parse().unwrap()), + _ => {} + } + } + ids +} + +/// The default period (rules.md B-30) against the IIF (B-34, D-36) and +/// the CPA package (B-33): a general journal asked for without dates is +/// the fiscal year to date, but the IIF is never a period. Without dates +/// it holds every posted entry from the first through today, under +/// either name of the report, and a start or end is refused; the +/// package's IIF holds every entry from the first through its as_of, the +/// one before its fiscal year included. +#[test] +fn the_default_period_never_shortens_the_iif_or_the_package() { + let dir = test_dir("default-vs-iif"); + let mut mcp = served( + &dir, + "[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", + ); + mcp.call( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}), + ); + let entries = [ + ("2025-03-15", "1000", "3000", "500.00"), + ("2025-09-10", "1000", "4000", "120.00"), + ("2026-02-01", "6000", "1000", "20.00"), + ("2026-05-01", "1000", "4000", "7.00"), + ] + .map(|(date, dr, cr, amount)| (date, post_pair(&mut mcp, date, dr, cr, amount))); + let every: Vec = entries.iter().map(|(_, id)| *id).collect(); + let today = || ledger_core::settings::today_in(None); + let before = today(); + // The general journal without dates: the fiscal year to date. + let out = mcp.call( + "books.export", + json!({"report": "general_journal", "format": "json"}), + ); + let after = today(); + let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); + let journal: Vec = v + .as_array() + .unwrap() + .iter() + .map(|e| e["entry_id"].as_u64().unwrap()) + .collect(); + let fytd = |t: chrono::NaiveDate| -> Vec { + let c = ledger_core::settings::Company { + fiscal_year_start: (7, 1), + ..Default::default() + }; + let (start, end) = ledger_core::calendar::period_defaults(&c, t).statement_range(); + entries + .iter() + .filter(|(date, _)| { + let d = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(); + start <= d && d <= end + }) + .map(|(_, id)| *id) + .collect() + }; + assert!( + journal == fytd(before) || journal == fytd(after), + "{journal:?}" + ); + assert_ne!(journal, every, "the journal's default is a period"); + // The IIF without dates: every posted entry through today, by either + // name of the report. + for report in ["general_journal", "journal", "GENERAL_JOURNAL"] { + let iif = mcp.call("books.export", json!({"report": report, "format": "iif"})); + assert_eq!(iif["report"], report.to_lowercase()); + assert_eq!( + iif_docnums(iif["content"].as_str().unwrap()), + every, + "{report}" + ); + } + for dates in [json!({"start": "2025-07-01"}), json!({"end": "2026-03-31"})] { + let mut args = json!({"report": "general_journal", "format": "iif"}); + args.as_object_mut() + .unwrap() + .extend(dates.as_object().unwrap().clone()); + let err = mcp.call_err("books.export", args); + assert!(err.contains("iif takes as_of, not start or end"), "{err}"); + } + // The package as of 2026-03-31: its statements' fiscal year starts + // 2025-07-01, and its IIF still holds the entry before it. + let pkg = mcp.call( + "books.export_cpa_package", + json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}), + ); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap(); + let mut read = |name: &str| { + let mut text = String::new(); + std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut text).unwrap(); + text + }; + let readme = read("README.txt"); + assert!( + readme.contains("Fiscal year to date: 2025-07-01 through 2026-03-31."), + "{readme}" + ); + assert_eq!(iif_docnums(&read("general-journal.iif")), every[..3]); +} + +/// Defect 15 (rules.md B-31) over the real binary: books.rename_account +/// gives an account a new name and nothing else. The chart shows it; a +/// past period's P&L export prints it; the general journal's IIF declares +/// the account by it; the books verify, the journal grew by the rename +/// and its feature's declaration only; refusals (a blank name, the name +/// it has, a sibling's ignoring capitals, nine digits) write nothing; and +/// a restarted server reads the new name back. +#[test] +fn rename_account_end_to_end_defect_15() { + let dir = test_dir("rename-e2e"); + let mut mcp = served( + &dir, + "[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", + ); + mcp.call( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n3000,Capital,equity,,\n6000,Operating,expense,,other_deductions\n6150,Software,expense,6000,other_deductions\n6160,Hosting,expense,6000,other_deductions\n"}), + ); + post_pair(&mut mcp, "2025-11-04", "1000", "3000", "800.00"); + post_pair(&mut mcp, "2025-11-20", "6150", "1000", "29.00"); + let verified = mcp.call("books.verify", json!({"blobs": false})); + let events = verified["events"].as_u64().unwrap(); + for (name, says) in [ + ("", "must not be empty"), + (" Software ", "already named"), + ("HOSTING", "same parent"), + ("Card 1234 5678 9", "nine digits"), + ] { + let err = mcp.call_err( + "books.rename_account", + json!({"account": "6150", "name": name}), + ); + assert!(err.contains(says), "{name:?}: {err}"); + } + assert_eq!( + mcp.call("books.verify", json!({"blobs": false}))["events"], + json!(events), + "refusals write nothing" + ); + let out = mcp.call( + "books.rename_account", + json!({"account": "6150", "name": "Software subscriptions", "reason": "owner asked"}), + ); + assert!(out["id"].as_u64().unwrap() > 0); + assert_eq!(out["code"], "6150"); + assert_eq!(out["previous_name"], "Software"); + assert_eq!(out["name"], "Software subscriptions"); + let notes = out["notes"].to_string(); + assert!( + notes.contains("QuickBooks matches accounts by name"), + "{notes}" + ); + let after = mcp.call("books.verify", json!({"blobs": false})); + assert_eq!(after["status"], "ok"); + assert_eq!( + after["events"], + json!(events + 2), + "the feature and the rename" + ); + let chart = mcp.call("books.chart", json!({})); + let acct = chart["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["code"] == "6150") + .unwrap() + .clone(); + assert_eq!(acct["name"], "Software subscriptions"); + assert_eq!(acct["balance"], "29.00"); + assert_eq!(acct["tax_category"], "other_deductions"); + // A past period prints the new name; the amounts are the same. + let out = mcp.call( + "books.export", + json!({"report": "profit_and_loss", "format": "json", "start": "2025-11-01", "end": "2025-11-30"}), + ); + let pl: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); + assert_eq!(pl["expense_accounts"][0]["name"], "Software subscriptions"); + assert_eq!(pl["expense_accounts"][0]["amount"], "29.00"); + let iif = mcp.call( + "books.export", + json!({"report": "journal", "format": "iif"}), + ); + let iif = iif["content"].as_str().unwrap(); + assert!(iif.contains("Operating:Software subscriptions"), "{iif}"); + // A renamed parent: its children's full names follow, and the note + // says so. + let out = mcp.call( + "books.rename_account", + json!({"account": "6000", "name": "Operating expenses"}), + ); + assert!(out["notes"].to_string().contains("2 sub-accounts"), "{out}"); + // Renaming the bank account to a name without "checking" or "bank" + // moves nothing: books.cash_accounts names it by code. + let out = mcp.call( + "books.rename_account", + json!({"account": "1000", "name": "Treasury"}), + ); + assert!(!out["notes"].to_string().contains("cash account"), "{out}"); + drop(mcp); + let mut mcp = Mcp::spawn(&dir.join("books-data")); + init(&mut mcp); + let chart = mcp.call("books.chart", json!({})); + let names: Vec<(String, String)> = chart["accounts"] + .as_array() + .unwrap() + .iter() + .map(|a| { + ( + a["code"].as_str().unwrap().to_string(), + a["name"].as_str().unwrap().to_string(), + ) + }) + .collect(); + for want in [ + ("6150", "Software subscriptions"), + ("6000", "Operating expenses"), + ("1000", "Treasury"), + ] { + assert!( + names.contains(&(want.0.to_string(), want.1.to_string())), + "{names:?}" + ); + } + assert_eq!( + mcp.call("books.verify", json!({"blobs": false}))["status"], + "ok" + ); +} + +/// Review R10 (rules.md B-31): on books that name no cash accounts +/// (books.cash_accounts unset, so they are picked by name), a rename that +/// moves an account into or out of the cash or card accounts says so; one +/// that moves nothing says nothing of them. A convention naming the old +/// name is listed, and one that doesn't isn't. +#[test] +fn rename_says_what_the_new_name_moves_defect_15() { + let dir = test_dir("rename-sets"); + let mut mcp = served( + &dir, + "[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n", + ); + mcp.call( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n1010,Petty Fund,asset,,\n2100,Visa,liability,,\n2200,Credit Card,liability,,\n6000,Software,expense,,other_deductions\n"}), + ); + mcp.call( + "books.set_convention", + json!({"id": "software-note", "kind": "note", "title": "Subscriptions", + "body_md": "Every SaaS bill goes to Software.", "decided_by": "A"}), + ); + let notes = |out: &Value| out["notes"].to_string(); + for (account, name, says) in [ + ( + "1000", + "Treasury", + "1000 Treasury is no longer a cash account", + ), + ( + "1010", + "Petty Cash", + "1010 Petty Cash is now a cash account", + ), + ("2100", "Visa Card", "2100 Visa Card is now a card account"), + ("2200", "Amex", "2200 Amex is no longer a card account"), + ] { + let out = mcp.call( + "books.rename_account", + json!({"account": account, "name": name}), + ); + assert!(notes(&out).contains(says), "{name}: {out}"); + } + let out = mcp.call( + "books.rename_account", + json!({"account": "1010", "name": "Petty Cash Box"}), + ); + assert!(!notes(&out).contains("cash account"), "{out}"); + let out = mcp.call( + "books.rename_account", + json!({"account": "6000", "name": "Software subscriptions"}), + ); + assert!( + notes(&out).contains("Convention software-note names \u{201c}Software\u{201d}"), + "{out}" + ); + let out = mcp.call( + "books.rename_account", + json!({"account": "6000", "name": "Tools"}), + ); + assert!(!notes(&out).contains("Convention"), "{out}"); +} diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index b886760..c2b2001 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -1092,8 +1092,8 @@ fn cash_flow_exports_equal_the_core_statement() { assert_eq!(tool, j); } -/// The journal export, CSV and JSON, is every posted entry line by line -/// (no drafts), as the ledger holds them. Kills: export_journal's +/// The journal export, CSV and JSON, over every date, is every posted +/// entry line by line (no drafts), as the ledger holds them. Kills: export_journal's /// json/csv switch. #[test] fn journal_exports_hold_every_posted_line() { @@ -1106,7 +1106,10 @@ fn journal_exports_hold_every_posted_line() { let mut m = Mcp::start(&dir, "1000000.00"); for report in ["journal", "general_journal"] { - let csv = m.export_text(json!({"format": "csv", "report": report})); + // Every date: the journal covers a period (rules.md B-30). + let csv = m.export_text( + json!({"format": "csv", "report": report, "start": "0001-01-01", "end": "9999-12-31"}), + ); let rows = csv_rows( &csv, &[ @@ -1148,9 +1151,10 @@ fn journal_exports_hold_every_posted_line() { } } - let j: Value = - serde_json::from_str(&m.export_text(json!({"format": "json", "report": "journal"}))) - .unwrap(); + let j: Value = serde_json::from_str(&m.export_text( + json!({"format": "json", "report": "journal", "start": "0001-01-01", "end": "9999-12-31"}), + )) + .unwrap(); let got = j.as_array().unwrap(); assert_eq!(got.len(), posted.len(), "a JSON entry per posted entry"); for (g, e) in got.iter().zip(&posted) { @@ -1292,6 +1296,17 @@ fn ledger_tax_and_equity_exports_equal_the_core_reports() { ]); } } + // Book amounts only: Form 4797 figures the tax gain. + for l in &ts.form_4797 { + want.push(vec![ + "form_4797".into(), + l.category.clone(), + l.label.clone(), + l.accounts.join("; "), + l.book.to_string(), + String::new(), + ]); + } for u in &ts.uncategorized { want.push(vec![ "uncategorized".into(), @@ -2287,11 +2302,15 @@ fn show_writes_the_viewer_command_for_each_target() { } ); + // A split, or one total column compared (the viewer compares only a + // report in one total column: defect 15, options a target ignores are + // refused). for (columns, compare) in [ - ("month", "prior_year"), - ("quarter", "prior_period"), - ("year", "prior_year"), - ("total", "prior_period"), + (Some("month"), None), + (Some("quarter"), None), + (Some("year"), None), + (Some("total"), Some("prior_period")), + (None, Some("prior_year")), ] { let out = m.ok( "books.show", @@ -2304,16 +2323,25 @@ fn show_writes_the_viewer_command_for_each_target() { report: "profit_and_loss".into(), start: Some("2025-01-01".into()), end: Some("2025-12-31".into()), - columns: Some(columns.into()), - compare: Some(compare.into()), + columns: columns.map(Into::into), + compare: compare.map(Into::into), } ); - if columns != "total" { - assert!( - out["shown"].as_str().unwrap().contains(columns), - "shown names the {columns} columns: {out}" - ); + let shown = out["shown"].as_str().unwrap(); + assert!(shown.contains("2025-01-01 to 2025-12-31"), "{out}"); + if let Some(c) = columns.filter(|c| *c != "total") { + assert!(shown.contains(&format!("by {c}")), "{out}"); } + if let Some(c) = compare { + assert!(shown.contains(&c.replace('_', " ")), "{out}"); + } + } + for (columns, compare) in [("month", "prior_year"), ("quarter", "prior_period")] { + let e = m.refuse( + "books.show", + json!({"report": "profit_and_loss", "columns": columns, "compare": compare}), + ); + assert!(e.contains("one total column"), "{e}"); } m.refuse( "books.show", @@ -2370,6 +2398,250 @@ fn show_writes_the_viewer_command_for_each_target() { m.refuse("books.show", json!({})); } +/// The command `books.show` last wrote, or None before any. +fn last_show(m: &Mcp) -> Option { + let books = m.run.parent().unwrap().to_path_buf(); + ledger_store::ops::read(&data_dir(&books)) +} + +/// Defect 15, repros 1.1.1 and 1.1.2: a start after the end, given, is +/// refused for a report and for an account, and nothing reaches the +/// viewer (the last command it was sent stays the last). A start equal to +/// the end is a one-day period. +#[test] +fn show_refuses_start_after_end_defect_15() { + let dir = test_dir("show-inverted"); + drop(new_store(&dir)); + let mut m = Mcp::start(&dir, "1000000.00"); + m.ok("books.show", json!({"page": "overview"})); + let before = last_show(&m).unwrap(); + for args in [ + json!({"report": "profit_and_loss", "start": "2026-12-31", "end": "2026-01-01"}), + json!({"account": "1000", "start": "2026-12-31", "end": "2026-01-01"}), + json!({"report": "cash_flow", "start": "2026-01-02", "end": "2026-01-01"}), + json!({"report": "balance_sheet", "columns": "month", "start": "2026-03-01", "end": "2026-02-28"}), + ] { + let e = m.refuse("books.show", args.clone()); + assert!(e.contains("is before start"), "{args}: {e}"); + assert_eq!(last_show(&m), Some(before.clone()), "{args}"); + } + let out = m.ok( + "books.show", + json!({"report": "profit_and_loss", "start": "2026-03-01", "end": "2026-03-01"}), + ); + assert!( + out["shown"] + .as_str() + .unwrap() + .contains("2026-03-01 to 2026-03-01"), + "{out}" + ); +} + +/// Defect 15, repro 1.1.3: a start after today with no end (the end an +/// omitted end means) is refused, saying the end was defaulted; nothing +/// reaches the viewer. +#[test] +fn show_refuses_a_derived_inverted_range_defect_15() { + let dir = test_dir("show-derived"); + drop(new_store(&dir)); + let mut m = Mcp::start(&dir, "1000000.00"); + m.ok("books.show", json!({"page": "journal"})); + let before = last_show(&m).unwrap(); + for args in [ + json!({"account": "1000", "start": "9999-12-31"}), + json!({"report": "general_ledger", "start": "9999-01-01"}), + ] { + let e = m.refuse("books.show", args.clone()); + assert!( + e.contains("defaults to today") && e.contains("pass end"), + "{args}: {e}" + ); + assert_eq!(last_show(&m), Some(before.clone()), "{args}"); + } +} + +/// Defect 16, repro 1.1.4: books.show sends the dates it names, resolved +/// by the one default (rules.md B-30): only an end covers that end's +/// fiscal year to it (2025-01-01 to 2025-06-30, never today's year's +/// start); no dates cover today's fiscal year to date (checked against +/// the clock read before and after, R14); a report as of a day says "as +/// of", vendor_1099 "for" its year; and the viewer gets the same pair. +#[test] +fn show_sends_the_dates_it_names_defect_16() { + let dir = test_dir("show-dates"); + drop(new_store(&dir)); + let mut m = Mcp::start(&dir, "1000000.00"); + let target = |m: &Mcp| match last_show(m).unwrap().target { + ViewerTarget::Report { start, end, .. } | ViewerTarget::Account { start, end, .. } => { + (start.unwrap(), end.unwrap()) + } + other => panic!("not dated: {other:?}"), + }; + for (args, shown, dates) in [ + ( + json!({"report": "profit_and_loss", "end": "2025-06-30"}), + "the profit and loss report, 2025-01-01 to 2025-06-30", + ("2025-01-01", "2025-06-30"), + ), + ( + json!({"account": "1000", "end": "2025-06-30"}), + "the register of 1000 Bank, 2025-01-01 to 2025-06-30", + ("2025-01-01", "2025-06-30"), + ), + ( + json!({"report": "profit_and_loss", "start": "2025-03-01", "end": "2025-03-31"}), + "the profit and loss report, 2025-03-01 to 2025-03-31", + ("2025-03-01", "2025-03-31"), + ), + ( + json!({"report": "trial_balance", "end": "2025-06-30"}), + "the trial balance report, as of 2025-06-30", + ("2025-01-01", "2025-06-30"), + ), + ( + json!({"report": "balance_sheet", "end": "2025-06-30"}), + "the balance sheet report, as of 2025-06-30", + ("2025-01-01", "2025-06-30"), + ), + ( + json!({"report": "balance_sheet", "end": "2025-06-30", "columns": "quarter"}), + "the balance sheet report, 2025-01-01 to 2025-06-30, by quarter", + ("2025-01-01", "2025-06-30"), + ), + ( + json!({"report": "fixed_asset_register", "end": "2024-12-31"}), + "the fixed asset register report, as of 2024-12-31", + ("2024-01-01", "2024-12-31"), + ), + ( + json!({"report": "ar_aging", "end": "2025-02-15"}), + "the ar aging report, as of 2025-02-15", + ("2025-01-01", "2025-02-15"), + ), + ( + json!({"report": "vendor_1099", "end": "2025-12-31"}), + "the vendor 1099 report, for 2025", + ("2025-01-01", "2025-12-31"), + ), + ] { + let out = m.ok("books.show", args.clone()); + assert_eq!(out["shown"], shown, "{args}"); + assert_eq!( + target(&m), + (dates.0.to_string(), dates.1.to_string()), + "{args}" + ); + } + // No dates: today's fiscal year to date, whichever day the call saw. + let before = ledger_core::settings::today_in(None); + let out = m.ok("books.show", json!({"report": "profit_and_loss"})); + let after = ledger_core::settings::today_in(None); + let (s, e) = target(&m); + let ok = [before, after].iter().any(|t| { + let (fy, _) = ledger_core::settings::Company::default().fiscal_year_containing(*t); + (s.clone(), e.clone()) == (fy.to_string(), t.to_string()) + && out["shown"] == format!("the profit and loss report, {fy} to {t}") + }); + assert!(ok, "{s} to {e}: {out}"); +} + +/// Defect 15 (settled: books.show refuses options its target ignores): +/// dates, columns or a comparison for an entry, a page or any other +/// non-dated target; columns or a comparison for an account; columns for +/// a report that doesn't split; a comparison for one that doesn't compare; +/// a start for a report as of a day, or for the 1099 worksheet. Each names +/// what takes the option, and nothing reaches the viewer. +#[test] +fn show_refuses_options_its_target_ignores_defect_15() { + let dir = test_dir("show-options"); + let mut s = new_store(&dir); + let e = post( + &mut s, + "2025-01-02", + "Seed", + &[("1000", Dr, "1.00"), ("4000", Cr, "1.00")], + ); + drop(s); + let mut m = Mcp::start(&dir, "1000000.00"); + m.ok("books.show", json!({"page": "overview"})); + let before = last_show(&m).unwrap(); + for (args, says) in [ + ( + json!({"entry_id": e, "start": "2025-01-01"}), + "for an account or a report", + ), + ( + json!({"page": "reports", "end": "2025-01-31"}), + "for an account or a report", + ), + ( + json!({"search": "x", "columns": "month"}), + "for an account or a report", + ), + ( + json!({"entry_id": e, "start": "2025-01-01", "compare": "prior_year"}), + "start and compare are", + ), + ( + json!({"account": "1000", "columns": "month"}), + "takes only start and end", + ), + ( + json!({"account": "1000", "compare": "prior_year"}), + "takes only start and end", + ), + ( + json!({"report": "trial_balance", "columns": "total"}), + "columns is for profit_and_loss, balance_sheet and cash_flow", + ), + ( + json!({"report": "tax_summary", "columns": "month"}), + "columns is for", + ), + ( + json!({"report": "general_ledger", "compare": "prior_year"}), + "compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary", + ), + ( + json!({"report": "trial_balance", "start": "2025-01-01", "end": "2025-03-31"}), + "is as of a day: pass end", + ), + ( + json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31"}), + "in one total column is as of a day", + ), + ( + json!({"report": "vendor_1099", "start": "2025-01-01"}), + "is for a calendar year", + ), + // A split past the viewer's 240 columns, which it shows as the + // total: 312 months. + ( + json!({"report": "profit_and_loss", "start": "2000-01-01", "end": "2025-12-31", "columns": "month"}), + "is 312 columns, and the viewer splits a report into at most 240", + ), + ( + json!({"report": "balance_sheet", "start": "0001-01-01", "end": "2025-12-31", "columns": "year"}), + "at most 240", + ), + ] { + let err = m.refuse("books.show", args.clone()); + assert!(err.contains(says), "{args}: {err}"); + assert_eq!(last_show(&m), Some(before.clone()), "{args}"); + } + // What a balance sheet takes a start for: a split, or the prior period; + // and 240 months is a split the viewer shows. + for args in [ + json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "columns": "month"}), + json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "compare": "prior_period"}), + json!({"report": "profit_and_loss", "start": "2006-01-01", "end": "2025-12-31", "columns": "month"}), + json!({"report": "profit_and_loss", "start": "0001-01-01", "end": "2025-12-31", "columns": "total"}), + ] { + m.ok("books.show", args); + } +} + // ------------------------------------------------- resources /// Each advertised resource reads back as the matching tool's result, and diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 32e41dc..a78c7c2 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -746,6 +746,10 @@ fn happy_path(name: &str) -> Mcp { "books.renumber_account", json!({"account": "7000", "code": "7100"}), ); + m.ok( + "books.rename_account", + json!({"account": "7100", "name": "Gain or Loss on Sale", "reason": "clearer"}), + ); m.ok("books.settings", json!({})); m.ok("books.roles", json!({})); m.ok( diff --git a/crates/ledger-mcp/tests/spec_tax_policy.rs b/crates/ledger-mcp/tests/spec_tax_policy.rs new file mode 100644 index 0000000..f2bdca4 --- /dev/null +++ b/crates/ledger-mcp/tests/spec_tax_policy.rs @@ -0,0 +1,451 @@ +//! Tax categories at account creation, and `policy.require_tax_category` +//! (P3, defect 8; rules.md B-32), through the real server over stdio. +//! +//! ```text +//! GOAL: every revenue and expense account maps to a line of the return, +//! or the human hears that it doesn't; a company that refuses +//! uncategorized accounts gets exactly that, never a refused +//! invoice. +//! SOURCE: rules.md B-32; plan/served.md P3 defect 8 and "Settled in +//! design" (warn by default, headers exempt). +//! ``` +//! +//! Fixtures are fictional (Fernhill Labs LLC). + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +struct Mcp { + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, + child: Child, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Mcp { + /// A server on books that keep their own settings, initialized. + fn spawn(data_dir: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) + .arg("--data-dir") + .arg(data_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ledger-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut m = Self { + stdin, + stdout, + next_id: 1, + child, + }; + m.request( + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "spec-tax-policy", "version": "0"}}), + ); + let msg = json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}); + writeln!(m.stdin, "{msg}").unwrap(); + m.stdin.flush().unwrap(); + m + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + writeln!(self.stdin, "{msg}").unwrap(); + self.stdin.flush().unwrap(); + let mut line = String::new(); + self.stdout + .read_line(&mut line) + .expect("read response line"); + let resp: Value = serde_json::from_str(&line).expect("parse response"); + assert_eq!(resp["id"], id, "id mismatch in {resp}"); + assert!(resp.get("error").is_none(), "{method}: {}", resp["error"]); + resp + } + + fn result(&mut self, tool: &str, args: Value) -> Value { + let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); + resp["result"].clone() + } + + /// Call a tool; panic on a refusal. Returns structuredContent. + fn call(&mut self, tool: &str, args: Value) -> Value { + let result = self.result(tool, args); + assert_ne!( + result.get("isError"), + Some(&json!(true)), + "{tool}: {}", + result["content"] + ); + result.get("structuredContent").cloned().unwrap_or_else(|| { + serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap() + }) + } + + /// Call a tool expecting a refusal; returns its text. + fn call_err(&mut self, tool: &str, args: Value) -> String { + let result = self.result(tool, args); + assert_eq!( + result.get("isError"), + Some(&json!(true)), + "{tool}: {result}" + ); + result["content"][0]["text"].as_str().unwrap().to_string() + } + + fn events(&mut self) -> u64 { + self.call("books.verify", json!({"blobs": false}))["events"] + .as_u64() + .unwrap() + } + + fn set_policy(&mut self, value: Value) { + self.call( + "books.set_setting", + json!({"changes": [{"key": "policy.require_tax_category", "value": value}], + "decided_by": "Tobin Harlow", "decided_on": "2026-10-20"}), + ); + } +} + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let dir = std::env::temp_dir().join(format!("ledger-mcp-tax-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn add(m: &mut Mcp, args: Value) -> Value { + m.call("books.add_account", args) +} + +fn chart_codes(m: &mut Mcp) -> Vec { + m.call("books.chart", json!({}))["accounts"] + .as_array() + .unwrap() + .iter() + .map(|a| a["code"].as_str().unwrap().to_string()) + .collect() +} + +/// Defect 8, repro 1.4.1: books.add_account of a revenue or expense +/// account without a tax category creates it and warns, naming it and +/// the tools to call; an asset, liability, equity or header account, or +/// one given its category, has no warning. A category that doesn't fit +/// the account or the return is refused, and a deductible percent needs +/// a category. +#[test] +fn add_account_warns_without_tax_category_defect_8() { + let dir = test_dir("warn"); + let mut m = Mcp::spawn(&dir.0.join("books-data")); + let out = add( + &mut m, + json!({"code": "6150", "name": "Software", "account_type": "expense"}), + ); + assert!(out["account_id"].as_u64().is_some()); + assert_eq!( + out["warnings"], + json!([ + "6150 Software has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees." + ]) + ); + let out = add( + &mut m, + json!({"code": "4000", "name": "Sales", "account_type": "revenue"}), + ); + assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}"); + for args in [ + json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}), + json!({"code": "2000", "name": "Card", "account_type": "liability"}), + json!({"code": "3000", "name": "Capital", "account_type": "equity"}), + json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}), + json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}), + json!({"code": "6170", "name": "Meals", "account_type": "expense", "tax_category": "meals", "deductible_percent": "50"}), + ] { + let out = add(&mut m, args.clone()); + assert!(out.get("warnings").is_none(), "{args}: {out}"); + } + let chart = m.call("books.chart", json!({})); + let meals = chart["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["code"] == "6170") + .unwrap() + .clone(); + assert_eq!(meals["tax_category"], "meals"); + assert_eq!(meals["deductible_percent"], "50"); + let before = m.events(); + for (args, says) in [ + ( + json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}), + "expense", + ), + ( + json!({"code": "6180", "name": "X", "account_type": "expense", "tax_category": "no_such"}), + "unknown tax category", + ), + ( + json!({"code": "6190", "name": "Y", "account_type": "expense", "deductible_percent": "50"}), + "needs a tax_category", + ), + ( + json!({"code": "6195", "name": "Z", "account_type": "expense", "tax_category": "other_deductions", "deductible_percent": "150"}), + "between 0 and 100", + ), + ] { + let err = m.call_err("books.add_account", args.clone()); + assert!(err.contains(says), "{args}: {err}"); + } + assert_eq!(m.events(), before, "refusals write nothing"); + // Needs attention's list, for the agent too. + let digest = m.call("books.digest", json!({"since": "2026-01-01"})); + assert_eq!( + digest["uncategorized_accounts"], + json!(["4000 Sales", "6150 Software"]) + ); +} + +/// Defect 8 under `policy.require_tax_category = refuse`: books.add_account +/// without a category is refused and the chart is unchanged; with one it +/// is created (account.added and account.tax_set); import_chart_csv with +/// one uncategorized expense row creates nothing and says why; clearing a +/// needed category is refused; a bill on books without 6999 still posts, +/// its result naming the account it created, which Needs attention (the +/// digest) lists. Back to warn, the same account is created with a +/// warning. +#[test] +fn require_tax_category_refuses_defect_8() { + let dir = test_dir("refuse"); + let mut m = Mcp::spawn(&dir.0.join("books-data")); + add( + &mut m, + json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}), + ); + add( + &mut m, + json!({"code": "6150", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}), + ); + m.set_policy(json!("refuse")); + let settings = m.call("books.settings", json!({})); + assert!( + settings.to_string().contains("policy.require_tax_category"), + "{settings}" + ); + let before = m.events(); + let codes = chart_codes(&mut m); + let err = m.call_err( + "books.add_account", + json!({"code": "6160", "name": "Hosting", "account_type": "expense"}), + ); + assert!( + err.contains("policy.require_tax_category is refuse") + && err.contains("6160 Hosting") + && err.contains("books.tax_categories"), + "{err}" + ); + assert_eq!(chart_codes(&mut m), codes); + assert_eq!(m.events(), before); + let out = add( + &mut m, + json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}), + ); + assert!(out.get("warnings").is_none(), "{out}"); + assert_eq!(m.events(), before + 2, "account.added and account.tax_set"); + // A header is exempt. + add( + &mut m, + json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}), + ); + // The import: refused whole, saying why. + let before = m.events(); + let err = m.call_err( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n"}), + ); + assert!( + err.contains("6210") && err.contains("fifth column") && err.contains("never a header"), + "{err}" + ); + assert!(!chart_codes(&mut m).contains(&"6200".to_string())); + assert_eq!(m.events(), before); + // Clearing a needed category. + let err = m.call_err( + "books.set_tax_category", + json!({"account": "6150", "category": null}), + ); + assert!(err.contains("keeps a tax category"), "{err}"); + m.call( + "books.set_tax_category", + json!({"account": "6150", "category": "supplies"}), + ); + // A bill creates General Expenses 6999 without a category: never + // refused, named in the result's warnings and the digest. + let vendor = m.call("books.add_vendor", json!({"name": "Coastline Hosting"})); + let bill = m.call( + "books.create_bill", + json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01", + "lines": [{"description": "hosting", "amount": "42.00"}]}), + ); + assert_eq!( + bill["warnings"], + json!([ + "6999 General Expenses has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees." + ]) + ); + let digest = m.call("books.digest", json!({"since": "2026-01-01"})); + assert_eq!( + digest["uncategorized_accounts"], + json!(["6999 General Expenses"]) + ); + // A second bill creates nothing, and warns of nothing. + let again = m.call( + "books.create_bill", + json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02", + "lines": [{"description": "hosting", "amount": "1.00"}]}), + ); + assert!(again.get("warnings").is_none(), "{again}"); + // Back to warn (cleared): created, with the warning. + m.set_policy(Value::Null); + let out = add( + &mut m, + json!({"code": "6210", "name": "Misc", "account_type": "expense"}), + ); + assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}"); + let out = m.call( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n"}), + ); + assert_eq!(out["count"], 3); + assert_eq!(out["warnings"].as_array().unwrap().len(), 2, "{out}"); +} + +/// rules.md B-31 for new accounts: a name another account under the same +/// parent has, ignoring capitals (top-level accounts are siblings too), +/// or one holding nine digits in a row, is refused, for books.add_account +/// and books.import_chart_csv alike; under another parent the name is +/// free. +#[test] +fn a_new_account_named_like_a_sibling_is_refused() { + let dir = test_dir("names"); + let mut m = Mcp::spawn(&dir.0.join("books-data")); + add( + &mut m, + json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}), + ); + let ops = m.call("books.chart", json!({}))["accounts"][0]["id"].clone(); + add( + &mut m, + json!({"code": "6150", "name": "Software", "account_type": "expense", "parent": ops, "tax_category": "other_deductions"}), + ); + let before = m.events(); + for (args, says) in [ + ( + json!({"code": "6151", "name": "SOFTWARE", "account_type": "expense", "parent": ops}), + "6150 Software already has the name", + ), + ( + json!({"code": "6001", "name": " operating ", "account_type": "expense"}), + "6000 Operating already has the name", + ), + ( + json!({"code": "1000", "name": "Checking 123456789", "account_type": "asset"}), + "nine digits", + ), + ] { + let err = m.call_err("books.add_account", args.clone()); + assert!(err.contains(says), "{args}: {err}"); + } + let err = m.call_err( + "books.import_chart_csv", + json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n"}), + ); + assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}"); + assert_eq!(m.events(), before); + // Top level: no sibling of the child. + add( + &mut m, + json!({"code": "6200", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}), + ); +} + +/// rules.md B-32: a gain on business property (`form_4797_gain_loss`, the +/// disposal account a refusing company must categorize) is exported apart +/// in the tax summary, JSON and CSV alike: at its book amount, with no tax +/// amount, and outside income and both nets (Form 4797 figures it from +/// the tax basis; never Schedule C income, never ordinary income the +/// summary could place). +#[test] +fn a_gain_on_business_property_is_exported_apart() { + let dir = test_dir("form4797"); + let mut m = Mcp::spawn(&dir.0.join("books-data")); + for args in [ + json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}), + json!({"code": "4000", "name": "Sales", "account_type": "revenue", "tax_category": "gross_receipts"}), + json!({"code": "4700", "name": "Gain on disposal", "account_type": "revenue", "tax_category": "form_4797_gain_loss"}), + ] { + add(&mut m, args); + } + for (cr, amount) in [("4000", "10.00"), ("4700", "7.50")] { + let e = m.call( + "books.draft_entry", + json!({"date": "2026-03-02", "description": "x", "no_party_reason": "test"}), + ); + let id = e["entry_id"].clone(); + for (account, side) in [("1000", "debit"), (cr, "credit")] { + m.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ); + } + m.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + } + let range = |format: &str| json!({"report": "tax_summary", "format": format, "start": "2026-01-01", "end": "2026-12-31"}); + let out = m.call("books.export", range("json")); + let j: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); + assert_eq!( + j["form_4797"], + json!([{"category": "form_4797_gain_loss", "label": "Gain or loss on business property (Form 4797)", + "accounts": ["4700 Gain on disposal"], "book": "7.50"}]) + ); + assert_eq!(j["income"].as_array().unwrap().len(), 1, "{j}"); + assert_eq!(j["income"][0]["category"], "gross_receipts"); + assert_eq!(j["book_net"], "10.00"); + assert_eq!(j["tax_net"], "10.00"); + let out = m.call("books.export", range("csv")); + let csv = out["content"].as_str().unwrap(); + assert!( + csv.lines().any(|l| l + == "form_4797,form_4797_gain_loss,Gain or loss on business property (Form 4797),4700 Gain on disposal,7.50,"), + "{csv}" + ); + assert!( + !csv.lines().any(|l| l.starts_with("income,form_4797")), + "{csv}" + ); +} diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index 2f39a6f..671ed64 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -35,8 +35,8 @@ pub use books::PackageBooks; pub use iif::export_iif; pub use package::{Package, PackageRequest, build_package, package_file_name}; pub use reports::{ - Settings, bs_statement, cash_flow_for, cash_flow_statement, member_capitals, pnl_statement, - report_members, report_meta, statement_range, year_earnings, + Settings, as_of_date, bs_statement, cash_flow_for, cash_flow_statement, member_capitals, + pnl_statement, report_members, report_meta, statement_range, year_earnings, }; pub use text::{csv_escape, money, parse_date, share_text}; pub use tins::{TinSource, tin_kind, tin_type_key, vault_tin}; diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index ba362af..72d1104 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -129,12 +129,13 @@ pub fn build_package( let tins = req.tins; let mut tin_gaps: Vec = Vec::new(); - // Fiscal year to date, per the company's fiscal_year_start. - let (fy_start, _) = company.fiscal_year_containing(as_of); - let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string()); + // The fiscal year to date: the tax year holding as_of (IRS Pub. 538), + // the fiscal-year fact itself, not "what omitted dates mean" (rules.md + // B-30), so the package's year never follows a default. + let fy = ledger_core::calendar::fiscal_year_start_on(company.fiscal_year_start, as_of); + let (fy_start, as_of_s) = (fy.to_string(), as_of.to_string()); let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string()); - let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?; let members = report_members(company); // One list of each per package (INVARIANT 7). let cash_codes = if req.cash_accounts.is_empty() { @@ -154,6 +155,7 @@ pub fn build_package( "profit-and-loss", report::profit_and_loss(&pnl_statement( ledger, + company, Some(&fy_start), Some(&as_of_s), None, @@ -163,7 +165,7 @@ pub fn build_package( ( "balance-sheet", report::balance_sheet( - &bs_statement(ledger, Some(&as_of_s), today)?, + &bs_statement(ledger, company, Some(&as_of_s), today)?, year_earnings(ledger, company, as_of)?, &members, ), diff --git a/crates/ledger-package/src/reports.rs b/crates/ledger-package/src/reports.rs index 50962a3..2cd3422 100644 --- a/crates/ledger-package/src/reports.rs +++ b/crates/ledger-package/src/reports.rs @@ -8,9 +8,11 @@ //! books.cash_flow, books.export_cpa_package); ASC 230-10-20 //! (financing and investing activities). //! INVARIANTS: -//! 1. A range defaults its end to `today` and its start to the first -//! posted entry (never after the end); an end before its start is -//! refused. +//! 1. A range's omitted dates are the one default (rules.md B-30, +//! `ledger_core::calendar::PeriodDefaults`): an omitted end is +//! `today`, an omitted start the first day of the fiscal year that +//! holds the end; a start after the end, given or derived, is +//! refused, never swapped. An omitted as_of is `today`. //! 2. The cash flow statement needs at least one cash account, and //! every code it is given must be in the chart; account lists a call //! leaves empty default to the settings' roles. It foots, or it is @@ -46,68 +48,67 @@ pub struct Settings<'a> { pub investing_accounts: &'a [String], } -/// Resolve a statement's date range: `end` defaults to `today` and `start` -/// to the first posted entry (where "from the beginning" really begins, so -/// no statement says "0001-01-01"), or to `end` for empty books. An end -/// before its start is refused (INVARIANT 1). +/// Resolve a statement's date range (INVARIANT 1): the dates as given, +/// each omitted one the books' default for `company` on `today` +/// (rules.md B-30). pub fn statement_range( - ledger: &Ledger, + company: &Company, start: Option<&str>, end: Option<&str>, today: NaiveDate, ) -> Result<(NaiveDate, NaiveDate), String> { - let end = match end { - Some(s) => parse_date(s)?, - None => today, - }; - let start = match start { - Some(s) => parse_date(s)?, - None => ledger - .posted_entries() - .map(|e| e.date()) - .min() - .map_or(end, |first| first.min(end)), - }; - if end < start { - return Err("end is before start".to_string()); - } - Ok((start, end)) + let start = start.map(parse_date).transpose()?; + let end = end.map(parse_date).transpose()?; + ledger_core::calendar::period_defaults(company, today) + .statement(start, end) + .map_err(|e| e.to_string()) +} + +/// Resolve a point-in-time report's day (INVARIANT 1): as given, else +/// `today`. +pub fn as_of_date( + company: &Company, + as_of: Option<&str>, + today: NaiveDate, +) -> Result { + let given = as_of.map(parse_date).transpose()?; + Ok(ledger_core::calendar::period_defaults(company, today).as_of(given)) } /// Profit and loss for a range (see [`statement_range`]), optionally one /// class. pub fn pnl_statement( ledger: &Ledger, + company: &Company, start: Option<&str>, end: Option<&str>, class_id: Option, today: NaiveDate, ) -> Result { - let (start, end) = statement_range(ledger, start, end, today)?; + let (start, end) = statement_range(company, start, end, today)?; ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string()) } -/// Balance sheet as of a date (default `today`). +/// Balance sheet as of a day (see [`as_of_date`]). pub fn bs_statement( ledger: &Ledger, + company: &Company, as_of: Option<&str>, today: NaiveDate, ) -> Result { - let as_of = match as_of { - Some(s) => parse_date(s)?, - None => today, - }; + let as_of = as_of_date(company, as_of, today)?; ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string()) } /// Build the cash flow statement from account codes, as the exports need it: -/// `start` defaults to the beginning of time, `end` to `today`, and at -/// least one cash account is required (the statement reconciles to them). -/// A member whose capital account the chart lacks is left unnamed (equity -/// is then named by account), never a refusal of the statement. +/// the range as [`statement_range`] resolves it, and at least one cash +/// account (the statement reconciles to them). A member whose capital +/// account the chart lacks is left unnamed (equity is then named by +/// account), never a refusal of the statement. #[allow(clippy::too_many_arguments)] pub fn cash_flow_statement( ledger: &Ledger, + company: &Company, start: Option<&str>, end: Option<&str>, cash_codes: &[String], @@ -137,7 +138,7 @@ pub fn cash_flow_statement( } let financing_accounts = resolve(financing_codes, "financing")?; let investing_accounts = resolve(investing_codes, "investing")?; - let (start, end) = statement_range(ledger, start, end, today)?; + let (start, end) = statement_range(company, start, end, today)?; let chart = ledger.chart(); let members = members .iter() @@ -190,6 +191,7 @@ pub fn cash_flow_for( }; cash_flow_statement( ledger, + settings.company, start, end, &or(cash, settings.cash_accounts), @@ -274,7 +276,7 @@ pub fn year_earnings( company: &Company, as_of: NaiveDate, ) -> Result { - let (fy_start, _) = company.fiscal_year_containing(as_of); + let fy_start = ledger_core::calendar::fiscal_year_start_on(company.fiscal_year_start, as_of); let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None) .map_err(|e| e.to_string())? .net_income(); diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 0223db5..c0b207d 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -18,6 +18,12 @@ //! whitespace). A newer format adds its fields inside `payload`. So this //! version can always follow the chain one line past the first line it //! can't read, and say whether that line is intact. +//! +//! A line an earlier version can't read is announced by a feature +//! ([`KNOWN_FEATURES`]): the writer declares it (`journal.format_set`, +//! the features in force and the new one) just before the first line that +//! needs it, so the earlier reader stops at the declaration, and books +//! that never use the feature stay readable by it. use crate::error::StoreError; use crate::journal::{JournalLine, ReadLine}; @@ -26,13 +32,18 @@ use serde_json::{Value, json}; /// The newest journal format this version reads and writes. pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2; -/// Optional journal features this version reads. Each is a field or value -/// on an event type an older version knows, which that version would -/// misread silently; a writer declares the feature (`journal.format_set`) -/// before its first such line, so the older version stops there instead -/// (rules.md B-27). Books that never use one stay readable by versions -/// before it. -pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE]; +/// Optional journal features this version reads. Each marks lines an +/// older version can't read: a new event type (which it would refuse as +/// unknown), or a field or value on an event type it knows (which it +/// would misread silently). A writer declares the feature +/// (`journal.format_set`) before its first such line, so the older +/// version stops there instead (rules.md B-27). Books that never use one +/// stay readable by versions before it. +pub const KNOWN_FEATURES: &[&str] = &[ + MEMBERS_CUMULATIVE, + FEATURE_ACCOUNT_RENAMED, + FEATURE_REQUIRE_TAX_CATEGORY, +]; /// Feature: `period.closed` closes to members with `"rounding": /// "cumulative_since"` and `since_seq`, the count of members closes @@ -42,6 +53,23 @@ pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE]; /// rule, which can split it differently. pub const MEMBERS_CUMULATIVE: &str = "members_cumulative"; +/// The feature declared before the first `account.renamed` (P3, defect +/// 15). +pub const FEATURE_ACCOUNT_RENAMED: &str = "account_renamed"; + +/// The feature declared before the first `settings.set` naming +/// `policy.require_tax_category` (P3, defect 8). +pub const FEATURE_REQUIRE_TAX_CATEGORY: &str = "require_tax_category"; + +/// The feature a `settings.set` naming `key` needs declared first: a key +/// an earlier version doesn't know. +pub(crate) fn setting_feature(key: &str) -> Option<&'static str> { + match key { + "policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY), + _ => None, + } +} + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -49,6 +77,7 @@ pub const FORMAT_SET: &str = "journal.format_set"; /// projection arm in `store.rs` (`tests/format.rs` checks both ways). pub const EVENT_TYPES: &[&str] = &[ "account.added", + "account.renamed", "account.renumbered", "account.tax_set", "asset.acquisition_linked", diff --git a/crates/ledger-store/src/ops.rs b/crates/ledger-store/src/ops.rs index 3c32112..d7345a3 100644 --- a/crates/ledger-store/src/ops.rs +++ b/crates/ledger-store/src/ops.rs @@ -36,6 +36,28 @@ pub const VIEWER_LOCK: &str = "viewer.lock"; /// The most show requests kept. pub const KEPT: usize = 32; +/// The most columns the viewer splits a report into: past it, the +/// viewer shows the report in one total column and says why, so +/// `books.show` refuses such a split (it would be an option the viewer +/// ignores). +pub const MAX_REPORT_COLUMNS: i64 = 240; + +/// How many columns splitting `start..=end` into periods of +/// `months_per_column` months makes (each started period a column), +/// counted from the months, before building any: a range across chrono's +/// calendar is millions of months. The one count `books.show` and the +/// viewer share. +pub fn report_columns( + start: chrono::NaiveDate, + end: chrono::NaiveDate, + months_per_column: u32, +) -> i64 { + use chrono::Datelike; + let months = (i64::from(end.year()) - i64::from(start.year())) * 12 + i64::from(end.month()) + - i64::from(start.month()); + months / i64::from(months_per_column.max(1)) + 1 +} + /// Where to go. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "show", rename_all = "snake_case")] @@ -51,24 +73,29 @@ pub enum ViewerTarget { /// Entry id. entry_id: u64, }, - /// An account's register, optionally for a date range. + /// An account's register, for a date range. Account { /// Account code. code: String, - /// First date, YYYY-MM-DD. + /// First date, YYYY-MM-DD. `books.show` always sends both dates, + /// resolved (rules.md B-30); `None` only from an earlier writer, + /// which the viewer resolves by the same rule. start: Option, - /// Last date, YYYY-MM-DD. + /// Last date, YYYY-MM-DD (see `start`). end: Option, }, - /// A report, optionally for a date range. + /// A report, for a date range. Report { /// profit_and_loss, balance_sheet, cash_flow, members_equity, /// trial_balance, general_ledger, tax_summary, vendor_1099, /// ar_aging, ap_aging, fixed_asset_register. report: String, - /// First date, YYYY-MM-DD. + /// First date, YYYY-MM-DD. `books.show` always sends both dates, + /// resolved (rules.md B-30); `None` only from an earlier writer, + /// which the viewer resolves by the same rule. start: Option, - /// Last date, YYYY-MM-DD. + /// Last date, YYYY-MM-DD (see `start`): the as-of day of a report + /// as of a day. end: Option, /// Split into columns: total, month, quarter, or year. #[serde(default)] diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index c5fadcd..2921ccd 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -584,24 +584,36 @@ impl Store { } /// Declare a journal feature (crate::format::KNOWN_FEATURES) before the - /// first event that needs it: a field or value on a known event type - /// that an older version would otherwise misread silently. Declared - /// with the format in force (at least 2) and every feature already in - /// force; nothing is journaled when it is already declared. Books that - /// never use the feature stay readable by older versions. + /// first line that needs it: a new event type, or a field or value on + /// a known event type that an older version would otherwise misread + /// silently. Declared with the format in force (at least format 2, + /// crate::meta::FORMAT) and every feature already in force; nothing is + /// journaled when it is already declared. Books that never use the + /// feature stay readable by older versions. /// /// ```text /// GOAL: an older wanderbooks stops at the declaration (NewerJournal) - /// instead of misreading a line it thinks it knows. + /// instead of misreading, or failing on, a line it can't read. /// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate). /// ``` fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> { - if self.format.features.iter().any(|f| f == feature) { + self.ensure_features(&[feature]) + } + + /// [`Self::ensure_feature`] for several at once: one declaration + /// naming every one not yet in force, or none when all are. + fn ensure_features(&mut self, needed: &[&str]) -> Result<(), StoreError> { + let mut features: Vec = self.format.features.clone(); + let before = features.len(); + for f in needed { + if !features.iter().any(|g| g == f) { + features.push(f.to_string()); + } + } + if features.len() == before { return Ok(()); } let format = self.format.format.max(crate::meta::FORMAT); - let mut features = self.format.features.clone(); - features.push(feature.to_string()); let features: Vec<&str> = features.iter().map(String::as_str).collect(); self.declare_journal_format(format, &features) } @@ -642,6 +654,14 @@ impl Store { if changes.is_empty() && !(adopt && !self.ledger.settings().is_journaled()) { return Ok(changes); } + // A key an older version doesn't know needs its feature declared + // first (crate::format::setting_feature), in the one declaration + // that also brings the books to format 2. + let needed: Vec<&str> = changes + .iter() + .filter_map(|c| crate::format::setting_feature(&c.key)) + .collect(); + self.ensure_features(&needed)?; self.ensure_format()?; let line = self.append_journal("settings.set", crate::meta::settings_set(&changes, source))?; @@ -827,7 +847,8 @@ impl Store { self.journal.append(line_type, payload) } - /// Add an account to the chart. + /// Add an account to the chart, with no tax category (see + /// [`Self::add_account_with_tax`]). pub fn add_account( &mut self, code: &str, @@ -836,7 +857,73 @@ impl Store { parent: Option, placeholder: bool, ) -> Result { - self.rehearse(|l| l.add_account(code, name, account_type, parent, placeholder))?; + self.add_account_with_tax(code, name, account_type, parent, placeholder, None) + } + + /// Add an account to the chart (`Ledger::add_account`) with, when + /// `tax` is given, its tax category and deductible percent: + /// `account.added`, then `account.tax_set`, the two events + /// `books.import_chart_csv` has always written. + /// + /// ```text + /// GOAL: an account created with the line of the return it maps to. + /// SOURCE: rules.md B-32 (policy.require_tax_category). + /// INVARIANTS: + /// 1. Rehearsed whole first: a refusal (of the account, or of its + /// category) journals nothing. + /// 2. Under policy.require_tax_category = refuse, a revenue or + /// expense account that isn't a header and has no category is + /// refused (TaxCategoryRequired), journaling nothing. + /// 3. No new event and no new field: a power cut between the two + /// appends leaves an account without a category, the warn + /// state, which Needs attention lists; never a damaged journal. + /// 4. The name is journaled trimmed, as `Ledger::add_account` keeps + /// it and a rename journals one, so live equals replay. + /// ``` + pub fn add_account_with_tax( + &mut self, + code: &str, + name: &str, + account_type: AccountType, + parent: Option, + placeholder: bool, + tax: Option<(&str, Option)>, + ) -> Result { + let name = name.trim(); + let (category, pct) = match tax { + Some((c, p)) => (Some(c), p), + None => (None, None), + }; + self.rehearse(|l| { + let id = l.add_account(code, name, account_type, parent, placeholder)?; + if category.is_some() { + l.set_account_tax(id, category, pct)?; + } + Ok(id) + })?; + self.ledger + .check_tax_category_required(code, name, account_type, placeholder, category)?; + let id = + self.journal_account_added(code, name, account_type, parent, placeholder, |l| { + l.add_account(code, name, account_type, parent, placeholder) + })?; + if category.is_some() { + self.journal_account_tax(id, category, pct)?; + } + Ok(id) + } + + /// Journal `account.added` and apply it with `add` (the command the + /// caller rehearsed). + fn journal_account_added( + &mut self, + code: &str, + name: &str, + account_type: AccountType, + parent: Option, + placeholder: bool, + add: impl FnOnce(&mut Ledger) -> Result, + ) -> Result { // Pure pre-validation replicating ChartOfAccounts::add, so the // journal write below cannot be followed by a failed mutation. let chart = self.ledger.chart(); @@ -864,9 +951,7 @@ impl Store { "currency": self.base_currency.code(), }), )?; - let live = self - .ledger - .add_account(code, name, account_type, parent, placeholder)?; + let live = add(&mut self.ledger)?; debug_assert_eq!(live, id, "id allocator diverged from journal"); apply_line_to_sqlite(&self.conn, &line)?; Ok(live) @@ -1970,7 +2055,10 @@ impl Store { /// code), creating it at its code through the journaled /// account path when missing. The ledger's internal ensure_account /// then no-ops, which keeps replay deterministic: every account - /// creation is an `account.added` event. + /// creation is an `account.added` event. The engine's account + /// (`Ledger::add_control_account`): never refused for its name or a + /// missing tax category (rules.md B-32), so an invoice, a bill or a + /// payroll run is never refused over a chart label. fn ensure_control_account( &mut self, c: &ledger_core::ControlAccount, @@ -1978,7 +2066,10 @@ impl Store { if let Some(id) = self.ledger.role_account(c) { return Ok(id); } - self.add_account(c.code, c.name, c.account_type, None, false) + self.rehearse(|l| l.add_control_account(c))?; + self.journal_account_added(c.code, c.name, c.account_type, None, false, |l| { + l.add_control_account(c) + }) } /// Journal an auto-posted two-line entry (debit one account, credit @@ -2854,6 +2945,58 @@ impl Store { Ok(()) } + /// Give an account a new name (see `Ledger::rename_account`), with an + /// optional reason. Journaled as `account.renamed`, keyed by the + /// account's id (a code can change; an id can't), with its code, the + /// new name, the name it had and the reason. The first rename declares + /// the journal feature `account_renamed` + /// ([`crate::format::FEATURE_ACCOUNT_RENAMED`]), so an earlier version + /// stops at the declaration. + /// + /// ```text + /// GOAL: rename an account, keeping the name it had in the journal. + /// SOURCE: rules.md B-31; Rev. Proc. 98-25 (docs/spec/sources/ + /// irs-records.md): a report printed under the old name can + /// be tied to the new one. + /// INVARIANTS: + /// 1. Only the account's name changes (Ledger::rename_account + /// INVARIANT 1): every entry, line, hash and earlier journal line + /// stays as it was. + /// 2. Rehearsed and checked first: a refusal journals nothing, not + /// even the feature's declaration. + /// ``` + pub fn rename_account( + &mut self, + account: AccountId, + name: &str, + reason: Option<&str>, + ) -> Result<(), StoreError> { + self.rehearse(|l| l.rename_account(account, name))?; + self.ledger.check_rename_account(account, name)?; + let reason = reason.map(Ledger::check_rename_reason).transpose()?; + let a = self + .ledger + .chart() + .get(account) + .ok_or(LedgerError::UnknownAccount(account))?; + let (code, previous) = (a.code.clone(), a.name.clone()); + let name = name.trim(); + self.ensure_feature(crate::format::FEATURE_ACCOUNT_RENAMED)?; + let line = self.append_journal( + "account.renamed", + json!({ + "account_id": account, + "code": code, + "name": name, + "previous": previous, + "reason": reason, + }), + )?; + self.ledger.rename_account(account, name)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Assign a control role an account, or (`None`) return it to its code /// (see `Ledger::assign_role`). Journaled as `role.assigned`, with the /// account that filled the role before. @@ -2874,7 +3017,10 @@ impl Store { } /// Set (or clear) an account's tax category and deductible percent. - /// Journaled as `account.tax_set`. + /// Journaled as `account.tax_set`. Under + /// `policy.require_tax_category = refuse`, an account that needs a + /// category is never left without one (rules.md B-32; + /// `Ledger::check_tax_category_kept`). pub fn set_account_tax( &mut self, account: AccountId, @@ -2882,6 +3028,17 @@ impl Store { deductible_percent: Option, ) -> Result<(), StoreError> { self.rehearse(|l| l.set_account_tax(account, category, deductible_percent))?; + self.ledger.check_tax_category_kept(account, category)?; + self.journal_account_tax(account, category, deductible_percent) + } + + /// Journal `account.tax_set` and apply it (the policy is the caller's). + fn journal_account_tax( + &mut self, + account: AccountId, + category: Option<&str>, + deductible_percent: Option, + ) -> Result<(), StoreError> { self.ledger .check_account_tax(account, category, deductible_percent)?; let line = self.append_journal( @@ -4585,6 +4742,12 @@ pub(crate) fn replay_line_into_ledger( "account.renumbered" => { ledger.renumber_account(get_u64(p, "account_id")?, get_str(p, "code")?)?; } + "account.renamed" => { + // Only that the account exists: the name rules are the + // command's (rules.md B-28). `code`, `previous` and `reason` + // are the record, not state. + ledger.replay_rename_account(get_u64(p, "account_id")?, get_str(p, "name")?)?; + } "role.assigned" => { let account = match p.get("account_id") { None | Some(Value::Null) => None, @@ -5535,6 +5698,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr params![get_str(p, "code")?, get_u64(p, "account_id")? as i64], )?; } + "account.renamed" => { + conn.execute( + "UPDATE accounts SET name = ? WHERE id = ?", + params![get_str(p, "name")?, get_u64(p, "account_id")? as i64], + )?; + } "account.tax_set" => { conn.execute( "UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?", diff --git a/crates/ledger-store/tests/fixtures/old-books/p3_rename/chart.json b/crates/ledger-store/tests/fixtures/old-books/p3_rename/chart.json new file mode 100644 index 0000000..4508031 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p3_rename/chart.json @@ -0,0 +1,107 @@ +[ + { + "account_type": "Asset", + "code": "1000", + "currency": [ + 85, + 83, + 68 + ], + "deductible_percent": null, + "id": 1, + "name": "Bank", + "parent": null, + "placeholder": false, + "tax_category": null + }, + { + "account_type": "Equity", + "code": "3000", + "currency": [ + 85, + 83, + 68 + ], + "deductible_percent": null, + "id": 2, + "name": "Capital", + "parent": null, + "placeholder": false, + "tax_category": null + }, + { + "account_type": "Expense", + "code": "6000", + "currency": [ + 85, + 83, + 68 + ], + "deductible_percent": null, + "id": 3, + "name": "Operating expenses", + "parent": null, + "placeholder": true, + "tax_category": null + }, + { + "account_type": "Expense", + "code": "6150", + "currency": [ + 85, + 83, + 68 + ], + "deductible_percent": null, + "id": 4, + "name": "Software", + "parent": 3, + "placeholder": false, + "tax_category": "other_deductions" + }, + { + "account_type": "Expense", + "code": "6160", + "currency": [ + 85, + 83, + 68 + ], + "deductible_percent": null, + "id": 5, + "name": "Hosting", + "parent": 3, + "placeholder": false, + "tax_category": null + }, + { + "account_type": "Revenue", + "code": "4000", + "currency": [ + 85, + 83, + 68 + ], + "deductible_percent": null, + "id": 6, + "name": "Sales", + "parent": null, + "placeholder": false, + "tax_category": "gross_receipts" + }, + { + "account_type": "Expense", + "code": "6170", + "currency": [ + 85, + 83, + 68 + ], + "deductible_percent": null, + "id": 7, + "name": "Domains", + "parent": 3, + "placeholder": false, + "tax_category": "other_deductions" + } +] diff --git a/crates/ledger-store/tests/fixtures/old-books/p3_rename/expected.json b/crates/ledger-store/tests/fixtures/old-books/p3_rename/expected.json new file mode 100644 index 0000000..d56ea3b --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p3_rename/expected.json @@ -0,0 +1,154 @@ +{ + "assets": [], + "bills": [], + "entries": [ + { + "date": "2026-01-05", + "description": "fixture", + "id": 1, + "lines": [ + { + "account": 1, + "amount": { + "amount": "5000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "5000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-03", + "description": "fixture", + "id": 2, + "lines": [ + { + "account": 4, + "amount": { + "amount": "49.99", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "49.99", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-03-09", + "description": "fixture", + "id": 3, + "lines": [ + { + "account": 4, + "amount": { + "amount": "49.99", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "49.99", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [], + "invoices": [], + "payroll_runs": [], + "periods": [], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "4900.02", + "balanced": true, + "current_earnings": "-99.98", + "equity": "4900.02", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "99.98", + "net_income": "-99.98", + "revenue": "0" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "0", + "debit": "4900.02", + "name": "Bank" + }, + { + "code": "3000", + "credit": "5000", + "debit": "0", + "name": "Capital" + }, + { + "code": "6150", + "credit": "0", + "debit": "99.98", + "name": "Software" + } + ], + "total_credits": "5000", + "total_debits": "5000" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p3_rename/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p3_rename/journal.jsonl new file mode 100644 index 0000000..6fbcfc4 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p3_rename/journal.jsonl @@ -0,0 +1,30 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T02:31:59Z","type":"store.created"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"b94228016ddcf3ce47c1682e75a6d7e78b63aa5894a2503701d990d55f7b99e1","seq":2,"ts":"2026-10-21T02:31:59Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"41e78ca2067b2531e40aa27b89632ed1d29ea2f4c47ab05f4ea0fe7ed448358a","seq":3,"ts":"2026-10-21T02:31:59Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Operating","parent":null,"placeholder":true},"prev":"7144fbc2d1ddca0f84b1b40c4be7c2ed20dfd3eaa5e85d82d21ee8e76f03d568","seq":4,"ts":"2026-10-21T02:31:59Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6150","currency":"USD","id":4,"name":"Software","parent":3,"placeholder":false},"prev":"e35fee15325262d429dea1bb6dd0c2f462ac3fc476714d2a93cd328c506f0dca","seq":5,"ts":"2026-10-21T02:31:59Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6160","currency":"USD","id":5,"name":"Hosting","parent":3,"placeholder":false},"prev":"48dbed424f0aa33a85bb9b9b1b9c2904e7c2f853c6d261bd14624b178cdf14f3","seq":6,"ts":"2026-10-21T02:31:59Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":6,"name":"Sales","parent":null,"placeholder":false},"prev":"3565bf2bd6033efa6addb4bc757e21612b154ba5aac19a675977238d713f522f","seq":7,"ts":"2026-10-21T02:31:59Z","type":"account.added"} +{"payload":{"account_id":6,"deductible_percent":null,"tax_category":"gross_receipts"},"prev":"c668734a18c316009542e1b0f81c6f41b0b1bb67acc80a224931bd0f5f22b255","seq":8,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"} +{"payload":{"features":["require_tax_category"],"format":2},"prev":"fe53bfcaeb90665ff374ce22173e9719786601649aabfd527d4a1cd080386dc3","seq":9,"ts":"2026-10-21T02:31:59Z","type":"journal.format_set"} +{"payload":{"changes":[{"key":"policy.require_tax_category","value":"refuse"}],"source":{"decided_by":"Owner","decided_on":"2026-10-20"}},"prev":"3fd65f0c9fceecf686ae855e8a36565c701b65029a48a9919623e79156dfc19f","seq":10,"ts":"2026-10-21T02:31:59Z","type":"settings.set"} +{"payload":{"account_type":"Expense","code":"6170","currency":"USD","id":7,"name":"Domains","parent":3,"placeholder":false},"prev":"8349d624d6c4e5d5cb6e68c7e457be09c95e094f51660acdc8c9911552df2caf","seq":11,"ts":"2026-10-21T02:31:59Z","type":"account.added"} +{"payload":{"account_id":7,"deductible_percent":null,"tax_category":"other_deductions"},"prev":"6f0a5105551325ff8c46a0e6ae5db60a74a813396c2021b5b42c2162d39c9deb","seq":12,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"} +{"payload":{"date":"2026-01-05","description":"fixture","id":1},"prev":"11ada13dda80e0a91a1bfa8b87b96fd1e818b5e3e5c52960e098ae8c3cd32675","seq":13,"ts":"2026-10-21T02:31:59Z","type":"entry.created"} +{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":1,"side":"debit"},"prev":"83871fcd54722182038b10357f628f37fb2d2562b9eb9f793f7a36d865c60f73","seq":14,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"5000.00","currency":"USD","id":1,"side":"credit"},"prev":"5cbc319bf98bc3b490572af050aa42e5f36e9eb21ea02d333cdcdaedbdd61b53","seq":15,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-05","description":"fixture","id":1,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":2,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"dfec8a0b1b77bdd9de9993d8f5cd5408275c49480d7ba562d5033e606d0cab0f","seq":16,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"} +{"payload":{"date":"2026-02-03","description":"fixture","id":2},"prev":"94895f542d8b6eff47ab2e78b4b1983205e601b6dfafa3e47e87cce869876c94","seq":17,"ts":"2026-10-21T02:31:59Z","type":"entry.created"} +{"payload":{"account":4,"amount":"49.99","currency":"USD","id":2,"side":"debit"},"prev":"70b7d2bd0ba00224ce8bad014c6ecbc0b9921b45dfc48cdb1c44a16fe722951c","seq":18,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"49.99","currency":"USD","id":2,"side":"credit"},"prev":"7145687d599c654a7402f37a189cb96aaf2aac03463a48519a4c8b1f5b8ec0cc","seq":19,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-03","description":"fixture","id":2,"lines":[{"account":4,"amount":"49.99","currency":"USD","side":"debit"},{"account":1,"amount":"49.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"104f0938e13f9811c8c4963145a415947740dc3e4efc9dfc2120661494a82fee","seq":20,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"} +{"payload":{"date":"2026-03-09","description":"fixture","id":3},"prev":"6dd47d7450a5e451545e8fbca6b9a13d4d898f8571e9ba93dfa5e4b6b6ccd473","seq":21,"ts":"2026-10-21T02:31:59Z","type":"entry.created"} +{"payload":{"account":4,"amount":"49.99","currency":"USD","id":3,"side":"debit"},"prev":"dc00c733f073a6c1ef74b2461df69cb2642981c73f86efdc2d5513e4674766ca","seq":22,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"49.99","currency":"USD","id":3,"side":"credit"},"prev":"5065fceb3a44ccb628ea44b509c4c98642445b2fa99585761e0dfc38816cffdd","seq":23,"ts":"2026-10-21T02:31:59Z","type":"entry.line_added"} +{"payload":{"date":"2026-03-09","description":"fixture","id":3,"lines":[{"account":4,"amount":"49.99","currency":"USD","side":"debit"},{"account":1,"amount":"49.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"79d9e4751f740203c4a14cc25d7a352ac7e576e6c31723d82844e7b3c7f1e192","seq":24,"ts":"2026-10-21T02:31:59Z","type":"entry.posted"} +{"payload":{"features":["account_renamed","require_tax_category"],"format":2},"prev":"561f49d94956a7e4c6192116a22e9152e516f1cbe01591ec6f0b7e674cd3d7c9","seq":25,"ts":"2026-10-21T02:31:59Z","type":"journal.format_set"} +{"payload":{"account_id":4,"code":"6150","name":"Software subscriptions","previous":"Software","reason":"owner asked 2026-10-20"},"prev":"f8cc150e9ce15a0cfb8ecf52c4dcfb72703d0e93fb3d9c4eb08ee3ad54f4a9b3","seq":26,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"} +{"payload":{"account_id":3,"code":"6000","name":"Operating expenses","previous":"Operating","reason":null},"prev":"1311f98abb25afb140df36046a53d146d5a800f5bd54605f15a904b26794a61c","seq":27,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"} +{"payload":{"account_id":4,"deductible_percent":null,"tax_category":"other_deductions"},"prev":"b2a4b494604d991a0ebb99b0d69a14f2bdd11876b693810a4896ff55eecb0145","seq":28,"ts":"2026-10-21T02:31:59Z","type":"account.tax_set"} +{"payload":{"account_id":4,"code":"6150","name":"Software","previous":"Software subscriptions","reason":"back to the short name"},"prev":"3058d2fb9b9b0d94253126e083e23ded7f51ffb6b5ffc64fedeb42a0ca753749","seq":29,"ts":"2026-10-21T02:31:59Z","type":"account.renamed"} +{"payload":{"changes":[{"key":"policy.require_tax_category","value":null}],"source":{"decided_by":"Owner","decided_on":"2026-10-20"}},"prev":"f86c03b68da3205f73d131c2032ea918614b99e2ae016cebc86af7e614112bb7","seq":30,"ts":"2026-10-21T02:31:59Z","type":"settings.set"} diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index a620620..958e571 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -883,3 +883,109 @@ fn read_journal_and_format_gives_every_line_and_the_format() { } ); } + +/// P3's journal extensions (rules.md B-27; plan D-39): each is announced +/// by a feature this version knows, declared once, just before the first +/// line that needs it, with the features in force kept: `account_renamed` +/// before the first `account.renamed`, `require_tax_category` before the +/// first `settings.set` naming `policy.require_tax_category`. A reader +/// whose features don't include it (every earlier version: none) stops at +/// the declaration as newer, never at the line, and never calls it +/// corrupt; books that never use either stay format 1 or 2 with no +/// feature. +#[test] +fn p3_features_are_declared_once_before_the_first_line_that_needs_them() { + for f in ["account_renamed", "require_tax_category"] { + assert!(KNOWN_FEATURES.contains(&f), "{f}"); + } + let dir = test_dir("p3-features"); + books(&dir); + let mut store = Store::open(&dir).unwrap(); + assert!(store.journal_format().features.is_empty()); + store.rename_account(1, "Business Checking", None).unwrap(); + store.rename_account(1, "Checking", None).unwrap(); + store + .change_settings( + &[ledger_core::settings::Change { + key: "policy.require_tax_category".into(), + value: Some(json!("refuse")), + }], + &ledger_core::settings::Source { + decided_by: "Owner".into(), + decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(), + citation: None, + note: None, + }, + ) + .unwrap(); + assert_eq!( + store.journal_format().features, + ["account_renamed", "require_tax_category"] + ); + drop(store); + let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap(); + let lines: Vec = text + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let kinds: Vec<(&str, serde_json::Value)> = lines + .iter() + .map(|l| (l["type"].as_str().unwrap(), l["payload"].clone())) + .filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set")) + .collect(); + assert_eq!(kinds[0].0, FORMAT_SET); + assert_eq!( + kinds[0].1, + json!({"format": 2, "features": ["account_renamed"]}) + ); + assert_eq!(kinds[1].0, "account.renamed"); + assert_eq!(kinds[2].0, "account.renamed"); + assert_eq!(kinds[3].0, FORMAT_SET); + assert_eq!( + kinds[3].1, + json!({"format": 2, "features": ["account_renamed", "require_tax_category"]}) + ); + assert_eq!(kinds[4].0, "settings.set"); + assert_eq!(kinds.len(), 5); + // An earlier reader knows no feature (P2: KNOWN_FEATURES was empty), + // so the gate stops it at the first declaration, before the rename: + // the gate's refusal of a feature it doesn't know is pinned by + // an_unknown_feature_before_a_rename_is_newer_at_the_declaration. + // This version opens them, in the writer and the viewer. + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert_eq!(store.ledger().chart().get(1).unwrap().name, "Checking"); + drop(store); + let snap = Snapshot::load(&dir).unwrap(); + assert_eq!(snap.ledger().chart().get(1).unwrap().name, "Checking"); +} + +/// A feature this version doesn't know, declared before an account.renamed +/// (as a later version would), is newer at the declaration: the line after +/// it is never read. +#[test] +fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() { + let dir = test_dir("p3-unknown-feature"); + books(&dir); + let seqs = append( + &dir, + &[ + ( + FORMAT_SET, + json!({"format": 2, "features": ["account_renamed", "later"]}), + ), + ( + "account.renamed", + json!({"account_id": 1, "code": "1000", "name": "x", "previous": "Cash", "reason": null}), + ), + ], + ); + let _ = std::fs::remove_file(dir.join(DB_FILE)); + match Store::open(&dir) { + Err(StoreError::NewerJournal { seq, need, .. }) => { + assert_eq!(seq, seqs[0]); + assert_eq!(need, "journal features later"); + } + other => panic!("expected NewerJournal, got {:?}", other.map(|_| ())), + } +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index de1f046..2db92c8 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -975,6 +975,11 @@ mod refusals { SetVendor1099(u64, bool), SetAccountTax(u64, u8), Renumber(u64, u8), + // P3 (defects 8, 15): renames, accounts added with a category, + // and the tax category policy turned on and off. + Rename(u64, u8), + AddAccountTax(u8, u8, u8, bool), + SetTaxPolicy(bool), // The rest of the command surface (2026-10-13). AttachReceipt(Option, u8), FileDocument(u8), @@ -1049,6 +1054,10 @@ mod refusals { (0u64..4, any::()).prop_map(|(v, t)| Op::SetVendor1099(v, t)), (0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)), (0u64..10, 0u8..9).prop_map(|(a, c)| Op::Renumber(a, c)), + (0u64..12, 0u8..8).prop_map(|(a, n)| Op::Rename(a, n)), + (0u8..8, 0u8..5, 0u8..4, any::()) + .prop_map(|(c, t, k, h)| Op::AddAccountTax(c, t, k, h)), + any::().prop_map(Op::SetTaxPolicy), (prop::option::of(id.clone()), 0u8..4).prop_map(|(e, b)| Op::AttachReceipt(e, b)), (0u8..4).prop_map(Op::FileDocument), (0u64..6, id.clone()).prop_map(|(a, e)| Op::LinkReceipt(a, e)), @@ -1138,8 +1147,56 @@ mod refusals { }] }; match *op { + // Named for the code: a sibling's name is refused (B-31), and + // a code is the first refusal. Op::AddAccount(c, t, p, h) => store - .add_account(CODES[c as usize], "acct", TYPES[t as usize], p, h) + .add_account( + CODES[c as usize], + &format!("Account {}", CODES[c as usize]), + TYPES[t as usize], + p, + h, + ) + .map(drop), + // A setup account's own name, another's (in other capitals), + // a blank, nine digits, and fresh names. + Op::Rename(a, n) => store.rename_account( + a, + [ + "Sales", + "sales", + "CASH", + "", + "Card 123456789", + "Renamed", + "Office", + "Rent ", + ][n as usize], + (n % 2 == 0).then_some("owner asked"), + ), + Op::AddAccountTax(c, t, k, h) => store + .add_account_with_tax( + CODES[c as usize], + &format!("Taxed {}", CODES[c as usize]), + TYPES[t as usize], + None, + h, + TAX[k as usize].map(|cat| (cat, None)), + ) + .map(drop), + Op::SetTaxPolicy(refuse) => store + .change_settings( + &[ledger_core::settings::Change { + key: "policy.require_tax_category".into(), + value: Some(serde_json::json!(if refuse { "refuse" } else { "warn" })), + }], + &ledger_core::settings::Source { + decided_by: "Owner".into(), + decided_on: day(0), + citation: None, + note: None, + }, + ) .map(drop), Op::CreateEntry(d) => store.create_entry(day(d), "e").map(drop), Op::AddLine(e, a, d, c) => { @@ -1468,8 +1525,14 @@ mod refusals { } } // The converse law: an accepted command is on the record. Only - // a batch run may find nothing to do. - if result.is_ok() && !matches!(op, Op::RunDepreciation(..) | Op::RunRecurring(..)) { + // a batch run may find nothing to do, and a setting changed to + // the value it has changes nothing. + if result.is_ok() + && !matches!( + op, + Op::RunDepreciation(..) | Op::RunRecurring(..) | Op::SetTaxPolicy(..) + ) + { let after = std::fs::read_to_string(&journal).unwrap(); prop_assert!( after.len() > before.len(), @@ -2776,3 +2839,740 @@ fn a_database_with_damaged_pages_is_rebuilt() { .count(); assert_eq!(kept, 1); } + +// --------------------------------------------------------------------------- +// P3: renaming an account, and the tax category policy (defects 15 and 8; +// rules.md B-31, B-32). +// --------------------------------------------------------------------------- + +/// Every row of every projection table but the journal's own (`events`, +/// `meta`), as text, with `accounts.name` blanked: what a rename may +/// change is that one column. +fn projection_but_names(dir: &std::path::Path) -> Vec<(String, Vec)> { + let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap(); + let mut tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(Result::unwrap) + .collect(); + // The journal's own rows (every line, and its head) grow with every + // command: what a rename adds there is checked from the journal. + tables.retain(|t| !t.starts_with("sqlite_") && t != "events" && t != "meta"); + tables + .into_iter() + .map(|t| { + let mut stmt = conn.prepare(&format!("SELECT * FROM \"{t}\"")).unwrap(); + let names: Vec = stmt.column_names().iter().map(|c| c.to_string()).collect(); + let mut rows: Vec = stmt + .query_map([], |r| { + Ok((0..names.len()) + .map(|i| { + if t == "accounts" && names[i] == "name" { + "-".to_string() + } else { + format!("{:?}", r.get_ref(i).unwrap()) + } + }) + .collect::>() + .join("|")) + }) + .unwrap() + .map(Result::unwrap) + .collect(); + rows.sort(); + (t, rows) + }) + .collect() +} + +/// Every figure of the books with account names left out: the trial +/// balance, the P&L and balance sheet (whole and for January), and every +/// entry's lines. +fn figures_but_names(l: &ledger_core::Ledger) -> serde_json::Value { + use ledger_core::reports; + let jan = |d: u32| NaiveDate::from_ymd_opt(2026, 1, d).unwrap(); + let lines = |v: &[ledger_core::StatementLine]| { + v.iter() + .map(|x| (x.code.clone(), x.amount.normalize().to_string())) + .collect::>() + }; + let tb = reports::trial_balance(l); + let pl = reports::profit_and_loss_for_period(l, jan(1), jan(31), None).unwrap(); + let bs = reports::balance_sheet_as_of(l, jan(31)).unwrap(); + serde_json::json!({ + "tb": tb.lines.iter().map(|x| (x.code.clone(), x.debit.normalize().to_string(), x.credit.normalize().to_string())).collect::>(), + "tb_totals": (tb.total_debits.normalize().to_string(), tb.total_credits.normalize().to_string()), + "pl": (lines(&pl.revenue), lines(&pl.expenses), pl.net_income().normalize().to_string()), + "bs": (lines(&bs.assets), lines(&bs.liabilities), lines(&bs.equity), bs.total_assets.normalize().to_string()), + "entries": (1..l.next_entry_id()).filter_map(|id| l.get_entry(id)).collect::>(), + }) +} + +/// rename_account (repro 1.2; rules.md B-31): a rename changes the +/// account's name and nothing else: no figure, entry or line, no earlier +/// journal line, no projection row but `accounts.name`; the journal gains +/// one `account.renamed` line (keyed by id, with the old name and the +/// reason) after the feature's declaration; a refused rename (unknown, +/// blank, unchanged, a sibling's name, nine digits, a bad reason) appends +/// nothing; the name survives a reopen, a rebuild and the viewer's read. +#[test] +fn rename_account_changes_only_the_name_defect_15() { + let dir = test_dir("rename-only-name"); + let mut store = build_books(&dir); + let journal = dir.join(ledger_store::JOURNAL_FILE); + let text = std::fs::read_to_string(&journal).unwrap(); + let figures = figures_but_names(store.ledger()); + let rows = projection_but_names(&dir); + let rent = store.ledger().chart().find_by_code("6000").unwrap().id; + for (id, name, reason) in [ + (999, "Office Rent", None), + (rent, "", None), + (rent, " Rent Expense ", None), + (rent, "cash", None), + (rent, "Lease 123456789", None), + (rent, "Office Rent", Some("")), + (rent, "Office Rent", Some("lease no. 123-45-6789")), + ] { + assert!( + store.rename_account(id, name, reason).is_err(), + "{name:?} {reason:?}" + ); + assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{name:?}"); + assert_eq!(projection_but_names(&dir), rows, "{name:?}"); + } + let (seq, _) = store.head(); + store + .rename_account(rent, " Office Rent ", Some("owner asked 2026-10-21")) + .unwrap(); + let after = std::fs::read_to_string(&journal).unwrap(); + // Every earlier line kept, byte for byte; two lines appended: the + // feature's declaration, then the rename. + assert!(after.starts_with(&text)); + let added: Vec = after[text.len()..] + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!(added.len(), 2); + assert_eq!(added[0]["type"], "journal.format_set"); + assert_eq!( + added[0]["payload"], + serde_json::json!({"format": 2, "features": ["account_renamed"]}) + ); + assert_eq!(added[1]["type"], "account.renamed"); + assert_eq!( + added[1]["payload"], + serde_json::json!({ + "account_id": rent, "code": "6000", "name": "Office Rent", + "previous": "Rent Expense", "reason": "owner asked 2026-10-21" + }) + ); + assert_eq!(store.head().0, seq + 2); + assert_eq!( + store.ledger().chart().get(rent).unwrap().name, + "Office Rent" + ); + assert_eq!(figures_but_names(store.ledger()), figures); + assert_eq!(projection_but_names(&dir), rows); + // The one column a rename changes, read from the projection itself + // (projection_but_names blanks it). + let db_name = |dir: &std::path::Path| -> String { + rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)) + .unwrap() + .query_row( + "SELECT name FROM accounts WHERE id = ?", + [rent as i64], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!(db_name(&dir), "Office Rent"); + store.verify().unwrap(); + // A second rename declares nothing more. + store.rename_account(rent, "Rent", None).unwrap(); + let again = std::fs::read_to_string(&journal).unwrap(); + let last: serde_json::Value = serde_json::from_str(again.lines().last().unwrap()).unwrap(); + assert_eq!(again.lines().count(), after.lines().count() + 1); + assert_eq!(last["payload"]["previous"], "Office Rent"); + assert_eq!(last["payload"]["reason"], serde_json::Value::Null); + drop(store); + let reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + assert_eq!(reopened.ledger().chart().get(rent).unwrap().name, "Rent"); + assert_eq!(figures_but_names(reopened.ledger()), figures); + drop(reopened); + let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + let rebuilt = Store::open(&dir).unwrap(); + assert_eq!(rebuilt.ledger().chart().get(rent).unwrap().name, "Rent"); + assert_eq!(projection_but_names(&dir), rows); + assert_eq!(db_name(&dir), "Rent"); + drop(rebuilt); + let snap = ledger_store::Snapshot::load(&dir).unwrap(); + assert_eq!(snap.ledger().chart().get(rent).unwrap().name, "Rent"); +} + +/// rules.md B-28's pair for renames (review R5): the command refuses a +/// blank name, a sibling's or nine digits; replay takes a journaled line +/// holding any of them (a later version may tighten the rules, never lock +/// out books it wrote), and refuses only an account that doesn't exist. +#[test] +fn a_journaled_rename_replays_past_the_command_rules() { + use serde_json::json; + for (name, opens) in [ + ("", true), + ("Cash", true), + ("Lease 123456789", true), + ("Rent Expense", true), + ] { + let dir = test_dir("rename-replay"); + let store = build_books(&dir); + let rent = store.ledger().chart().find_by_code("6000").unwrap().id; + drop(store); + let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap(); + w.append( + "journal.format_set", + json!({"format": 2, "features": ["account_renamed"]}), + ) + .unwrap(); + w.append( + "account.renamed", + json!({"account_id": rent, "code": "6000", "name": name, "previous": "Rent Expense", "reason": null}), + ) + .unwrap(); + drop(w); + let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + let store = Store::open(&dir); + assert_eq!(store.is_ok(), opens, "{name:?}"); + let store = store.unwrap(); + store.verify().unwrap(); + assert_eq!(store.ledger().chart().get(rent).unwrap().name, name); + } + // An account that doesn't exist: the line can't be replayed. + let dir = test_dir("rename-replay-unknown"); + drop(build_books(&dir)); + let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap(); + w.append( + "journal.format_set", + json!({"format": 2, "features": ["account_renamed"]}), + ) + .unwrap(); + w.append( + "account.renamed", + json!({"account_id": 99, "code": "9", "name": "x", "previous": "y", "reason": null}), + ) + .unwrap(); + drop(w); + let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + assert!(Store::open(&dir).is_err()); +} + +fn tax_policy(store: &mut Store, value: Option<&str>) { + store + .change_settings( + &[ledger_core::settings::Change { + key: "policy.require_tax_category".into(), + value: value.map(|v| serde_json::json!(v)), + }], + &ledger_core::settings::Source { + decided_by: "Owner".into(), + decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(), + citation: None, + note: None, + }, + ) + .unwrap(); +} + +/// add_account_with_tax and the policy (rules.md B-32): under warn an +/// account is created without a category; under refuse a revenue or +/// expense account that isn't a header is refused without one, journaling +/// nothing, and created with one as account.added then account.tax_set; +/// a needed category can't be cleared; the accounts the engine creates +/// for a control role are never refused. The first setting of the key +/// declares the feature require_tax_category, once. +#[test] +fn the_tax_category_policy_refuses_only_what_it_says() { + let dir = test_dir("tax-policy"); + let mut store = build_books(&dir); + let journal = dir.join(ledger_store::JOURNAL_FILE); + store + .add_account("6150", "Software", AccountType::Expense, None, false) + .unwrap(); + let before = std::fs::read_to_string(&journal).unwrap(); + tax_policy(&mut store, Some("refuse")); + let declared = std::fs::read_to_string(&journal).unwrap(); + let new: Vec = declared[before.len()..] + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!(new[0]["type"], "journal.format_set"); + assert_eq!( + new[0]["payload"], + serde_json::json!({"format": 2, "features": ["require_tax_category"]}) + ); + assert_eq!(new.last().unwrap()["type"], "settings.set"); + for (code, t, header, tax, refused) in [ + ("6160", AccountType::Expense, false, None, true), + ("4100", AccountType::Revenue, false, None, true), + ("6170", AccountType::Expense, true, None, false), + ("1100", AccountType::Asset, false, None, false), + ("2100", AccountType::Liability, false, None, false), + ("3100", AccountType::Equity, false, None, false), + ( + "6180", + AccountType::Expense, + false, + Some(("bogus", None)), + true, + ), + ( + "4200", + AccountType::Revenue, + false, + Some(("supplies", None)), + true, + ), + ] { + let text = std::fs::read_to_string(&journal).unwrap(); + let got = store.add_account_with_tax(code, &format!("Acct {code}"), t, None, header, tax); + assert_eq!(got.is_err(), refused, "{code} {got:?}"); + if refused { + assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{code}"); + assert!(store.ledger().chart().find_by_code(code).is_none()); + } + } + let err = store + .add_account("6160", "Hosting", AccountType::Expense, None, false) + .unwrap_err() + .to_string(); + assert!( + err.contains("policy.require_tax_category is refuse"), + "{err}" + ); + assert!(err.contains("6160 Hosting"), "{err}"); + // With a category: two lines, account.added then account.tax_set. + let text = std::fs::read_to_string(&journal).unwrap(); + let id = store + .add_account_with_tax( + "6160", + "Hosting", + AccountType::Expense, + None, + false, + Some(("other_deductions", Some(Decimal::from(80)))), + ) + .unwrap(); + let added: Vec = std::fs::read_to_string(&journal).unwrap()[text.len()..] + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!( + added + .iter() + .map(|l| l["type"].as_str().unwrap()) + .collect::>(), + ["account.added", "account.tax_set"] + ); + let a = store.ledger().chart().get(id).unwrap(); + assert_eq!(a.tax_category.as_deref(), Some("other_deductions")); + assert_eq!(a.deductible_percent, Some(Decimal::from(80))); + // A needed category is never cleared; a header's or an asset's is no + // concern; an existing uncategorized account is left as it was. + let text = std::fs::read_to_string(&journal).unwrap(); + assert!(store.set_account_tax(id, None, None).is_err()); + let software = store.ledger().chart().find_by_code("6150").unwrap().id; + assert!(store.set_account_tax(software, None, None).is_err()); + assert_eq!(std::fs::read_to_string(&journal).unwrap(), text); + assert!( + store + .ledger() + .chart() + .get(software) + .unwrap() + .tax_category + .is_none() + ); + store + .set_account_tax(software, Some("other_deductions"), None) + .unwrap(); + // The engine's own accounts: a bill makes 6999 General Expenses with + // no category, under refuse too. + let v = store.add_vendor("Coastline Hosting").unwrap(); + store + .create_bill( + v, + NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + vec![ledger_core::DocumentLine { + description: "hosting".into(), + amount: Decimal::new(4_200, 2), + }], + ) + .unwrap(); + let general = store.ledger().chart().find_by_code("6999").unwrap(); + assert!(general.tax_category.is_none()); + // Back to warn: no second declaration, and the account is created. + let text = std::fs::read_to_string(&journal).unwrap(); + tax_policy(&mut store, Some("warn")); + let lines: Vec = std::fs::read_to_string(&journal).unwrap()[text.len()..] + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "settings.set"); + store + .add_account("6190", "Domains", AccountType::Expense, None, false) + .unwrap(); + store.verify().unwrap(); + drop(store); + Store::open(&dir).unwrap().verify().unwrap(); +} + +/// rules.md B-28's pair for the policy (review R5): the policy is the +/// commands', never replay's. A journal that sets refuse and then clears +/// a category, or adds an uncategorized expense account, replays. +#[test] +fn a_journaled_clear_replays_under_refuse() { + use serde_json::json; + let dir = test_dir("tax-policy-replay"); + let mut store = build_books(&dir); + let rent = store.ledger().chart().find_by_code("6000").unwrap().id; + store + .set_account_tax(rent, Some("rent_property"), None) + .unwrap(); + tax_policy(&mut store, Some("refuse")); + let next = store.ledger().next_account_id(); + drop(store); + let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap(); + w.append( + "account.tax_set", + json!({"account_id": rent, "tax_category": null, "deductible_percent": null}), + ) + .unwrap(); + w.append( + "account.added", + json!({"id": next, "code": "6150", "name": "Software", "account_type": "Expense", + "parent": null, "placeholder": false, "currency": "USD"}), + ) + .unwrap(); + drop(w); + let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert!( + store + .ledger() + .chart() + .get(rent) + .unwrap() + .tax_category + .is_none() + ); + assert!(store.ledger().chart().find_by_code("6150").is_some()); + assert_eq!( + store.ledger().settings().require_tax_category(), + ledger_core::settings::TaxCategoryPolicy::Refuse + ); +} + +mod p3_properties { + use super::*; + use proptest::prelude::*; + + const NAMES: [&str; 8] = [ + "Office", + "office", + "Cash", + "", + "Lease 123456789", + "Rent Expense", + "Studio", + "Tools", + ]; + + proptest! { + #![proptest_config(ProptestConfig { + cases: std::env::var("STORE_CASES").ok().and_then(|v| v.parse().ok()).unwrap_or(64), + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// rules.md B-31 (the engine-side counterpart to the Lean note, + /// docs/verification.md 13a): on books with postings, any sequence + /// of renames changes no figure (the ledger's statements, without + /// names), no entry and no earlier journal line; live equals + /// replay; the projection equals a fresh one. The sets picked by + /// name (cash, card accounts) are presentation, outside this law. + #[test] + fn prop_rename_changes_no_figure( + renames in prop::collection::vec((1u64..6, 0usize..NAMES.len()), 1..12), + seed in any::(), + ) { + let dir = test_dir(&format!("prop-rename-{seed}")); + let mut store = build_books(&dir); + let journal = dir.join(ledger_store::JOURNAL_FILE); + let figures = figures_but_names(store.ledger()); + let rows = projection_but_names(&dir); + let mut text = std::fs::read_to_string(&journal).unwrap(); + for (account, n) in renames { + let ok = store.rename_account(account, NAMES[n], None).is_ok(); + let now = std::fs::read_to_string(&journal).unwrap(); + prop_assert!(now.starts_with(&text)); + prop_assert_eq!(ok, now.len() > text.len()); + text = now; + prop_assert_eq!(&figures_but_names(store.ledger()), &figures); + prop_assert_eq!(&projection_but_names(&dir), &rows); + } + prop_assert!(store.verify().is_ok()); + let live = store.ledger().clone(); + drop(store); + let reopened = Store::open(&dir).unwrap(); + prop_assert!(live.differs_from(reopened.ledger()).is_empty()); + } + + /// rules.md B-32: add_account_with_tax is refused exactly when the + /// policy is refuse, the account needs a category (revenue or + /// expense, not a header) and none is given, or when the category + /// doesn't fit; a refusal journals nothing. + #[test] + fn prop_tax_policy_refuses_exactly( + refuse in any::(), + t in 0usize..5, + header in any::(), + cat in 0usize..4, + seed in any::(), + ) { + let types = [ + AccountType::Asset, + AccountType::Liability, + AccountType::Equity, + AccountType::Revenue, + AccountType::Expense, + ]; + let cats = [None, Some("other_income"), Some("supplies"), Some("bogus")]; + let dir = test_dir(&format!("prop-tax-{seed}")); + let mut store = build_books(&dir); + tax_policy(&mut store, Some(if refuse { "refuse" } else { "warn" })); + let journal = dir.join(ledger_store::JOURNAL_FILE); + let text = std::fs::read_to_string(&journal).unwrap(); + let t = types[t]; + let needs = matches!(t, AccountType::Revenue | AccountType::Expense) && !header; + let fits = match cats[cat] { + None => true, + Some("other_income") => matches!(t, AccountType::Revenue | AccountType::Expense), + Some("supplies") => t == AccountType::Expense, + _ => false, + }; + let want_refused = !fits || (refuse && needs && cats[cat].is_none()); + let got = store.add_account_with_tax( + "7000", "Prop", t, None, header, cats[cat].map(|c| (c, None)), + ); + prop_assert_eq!(got.is_err(), want_refused, "{:?}", got); + let after = std::fs::read_to_string(&journal).unwrap(); + if want_refused { + prop_assert_eq!(after, text); + } else { + let lines = after[text.len()..].lines().count(); + prop_assert_eq!(lines, if cats[cat].is_some() { 2 } else { 1 }); + } + } + } +} + +/// Each account as the books hold it, by id: what chart.json in the +/// p3_rename fixture records (expected.json's figures carry no names). +fn chart_of(l: &ledger_core::Ledger) -> serde_json::Value { + let mut accounts: Vec<_> = l.chart().all().collect(); + accounts.sort_by_key(|a| a.id); + serde_json::json!(accounts) +} + +fn p3_fixture() -> PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p3_rename") +} + +/// How the p3_rename fixture was written (2026-10-20, P3): through the +/// Store API of the version that introduced `account.renamed` and +/// `policy.require_tax_category`, with their journal features, a header +/// and its children, refused commands between them. Run once with +/// `--ignored`; the journal it wrote is frozen in the fixture, and later +/// versions must open it to the same figures +/// (books_an_earlier_version_wrote_open_to_its_figures) and names +/// (books_written_under_p3_keep_their_names). +#[test] +#[ignore] +fn write_p3_rename_fixture() { + let dir = test_dir("p3-fixture"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let ops = s + .add_account("6000", "Operating", AccountType::Expense, None, true) + .unwrap(); + let software = s + .add_account("6150", "Software", AccountType::Expense, Some(ops), false) + .unwrap(); + s.add_account("6160", "Hosting", AccountType::Expense, Some(ops), false) + .unwrap(); + s.add_account_with_tax( + "4000", + "Sales", + AccountType::Revenue, + None, + false, + Some(("gross_receipts", None)), + ) + .unwrap(); + tax_policy(&mut s, Some("refuse")); + assert!( + s.add_account("6170", "Domains", AccountType::Expense, Some(ops), false) + .is_err() + ); + s.add_account_with_tax( + "6170", + "Domains", + AccountType::Expense, + Some(ops), + false, + Some(("other_deductions", None)), + ) + .unwrap(); + for (m, d, dr, cr, cents) in [ + (1u32, 5u32, bank, capital, 500_000i64), + (2, 3, software, bank, 4_999), + (3, 9, software, bank, 4_999), + ] { + let e = s.create_entry(day(m, d), "fixture").unwrap(); + let money = Money::new(Decimal::new(cents, 2), Currency::USD); + s.add_line(e, dr, Side::Debit, money).unwrap(); + s.add_line(e, cr, Side::Credit, money).unwrap(); + s.post_entry(e).unwrap(); + } + assert!(s.rename_account(software, "hosting", None).is_err()); + s.rename_account( + software, + "Software subscriptions", + Some("owner asked 2026-10-20"), + ) + .unwrap(); + s.rename_account(ops, "Operating expenses", None).unwrap(); + assert!(s.set_account_tax(software, None, None).is_err()); + s.set_account_tax(software, Some("other_deductions"), None) + .unwrap(); + s.rename_account(software, "Software", Some("back to the short name")) + .unwrap(); + tax_policy(&mut s, None); + s.verify().unwrap(); + let out = p3_fixture(); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + std::fs::copy( + dir.join(ledger_store::JOURNAL_FILE), + out.join(ledger_store::JOURNAL_FILE), + ) + .unwrap(); + let mut figures = old_books_figures(s.ledger()); + figures["verify"] = serde_json::json!({"Ok": null}); + std::fs::write( + out.join("expected.json"), + serde_json::to_string_pretty(&figures).unwrap() + "\n", + ) + .unwrap(); + std::fs::write( + out.join("chart.json"), + serde_json::to_string_pretty(&chart_of(s.ledger())).unwrap() + "\n", + ) + .unwrap(); +} + +/// Books written under P3 (fixture p3_rename): every account's name, +/// code, parent and tax category, as journaled through renames and the +/// tax category policy, read back the same by the writer, a rebuild and +/// the viewer; and the features it declared are the ones this version +/// knows. +#[test] +fn books_written_under_p3_keep_their_names() { + let fixture = p3_fixture(); + let want: serde_json::Value = + serde_json::from_slice(&std::fs::read(fixture.join("chart.json")).unwrap()).unwrap(); + let dir = old_books_copy(&fixture, "p3-names"); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert_eq!(chart_of(store.ledger()), want); + assert_eq!( + store.journal_format().features, + ["account_renamed", "require_tax_category"] + ); + let names: Vec<&str> = want + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].as_str().unwrap()) + .collect(); + assert_eq!( + names, + [ + "Bank", + "Capital", + "Operating expenses", + "Software", + "Hosting", + "Sales", + "Domains" + ] + ); + drop(store); + let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + let rebuilt = Store::open(&dir).unwrap(); + assert_eq!(chart_of(rebuilt.ledger()), want); + drop(rebuilt); + let snap = ledger_store::Snapshot::load(&dir).unwrap(); + assert_eq!(chart_of(snap.ledger()), want); +} + +/// rules.md B-31: a new account's name is journaled trimmed, as a rename +/// journals one, so the live books, a reopen and the projection all read +/// the same name; the command's text rules refuse a name a report can't +/// print on one line, journaling nothing. +#[test] +fn a_new_accounts_name_is_journaled_trimmed() { + let dir = test_dir("add-trimmed"); + let mut store = build_books(&dir); + let journal = dir.join(ledger_store::JOURNAL_FILE); + let text = std::fs::read_to_string(&journal).unwrap(); + for name in ["Two\nlines", &"N".repeat(201)] { + assert!( + store + .add_account("6150", name, AccountType::Expense, None, false) + .is_err(), + "{name:?}" + ); + assert_eq!(std::fs::read_to_string(&journal).unwrap(), text); + } + let id = store + .add_account("6150", " Hosting\t", AccountType::Expense, None, false) + .unwrap(); + let after = std::fs::read_to_string(&journal).unwrap(); + let line: serde_json::Value = serde_json::from_str(after.lines().last().unwrap()).unwrap(); + assert_eq!(line["type"], "account.added"); + assert_eq!(line["payload"]["name"], "Hosting"); + assert_eq!(store.ledger().chart().get(id).unwrap().name, "Hosting"); + let live = store.ledger().clone(); + drop(store); + let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + let reopened = Store::open(&dir).unwrap(); + assert!(live.differs_from(reopened.ledger()).is_empty()); + let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap(); + let name: String = conn + .query_row("SELECT name FROM accounts WHERE id = ?", [id as i64], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(name, "Hosting"); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 49e2eed..a217e3c 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -334,12 +334,13 @@ Tool descriptions are at most 700 characters; eight longer ones are listed in `tool_descriptions_fit_700_characters`, to be shortened in P5. **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool -that changes anything also has a `destructiveHint`, true for these 28: +that changes anything also has a `destructiveHint`, true for these 29: `books.post_entry`, `books.reverse_entry`, `books.import_settings`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.unlink_attachment`, `books.withdraw_attachment`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.renumber_account`, +`books.rename_account` (the label every past report prints), `books.assign_role`, `books.close_period`, `books.reconcile`, `books.void_statement`, `books.void_reconciliation`, `books.set_w9`, `books.set_tin` (both overwrite a vault record), `books.purge_tin`, @@ -411,6 +412,20 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. - `books.handbook { section? }`: the guide (see "Server instructions"). - `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder, balance }] }`. Balances included: one call answers "what do we have." +- `books.add_account { code, name, account_type, parent?, placeholder?, + tax_category?, deductible_percent? }` → `{ account_id, warnings? }`. + Codes are unique; a name another account under the same parent has + (top-level accounts are siblings too; ignoring capitals and surrounding + space), one holding a run of nine digits, or one that isn't one line of + at most 200 characters, is refused (rules.md B-31); the name is kept + trimmed. A revenue or expense account (not a header) without `tax_category` is + created with a warning ("6150 Software has no tax category: the tax + summary will list it as uncategorized. Propose one from + books.tax_categories and set it with books.set_tax_category once the + human agrees."), or, under `policy.require_tax_category = refuse`, + refused (rules.md B-32). The category is checked against the account's + type and the return, as `books.set_tax_category` checks it; journaled as + `account.added` then `account.tax_set`. - `books.draft_entry { date: "2026-10-10", description: "...", vendor?, party?, parties?, no_party_reason? }` → `{ entry_id, status: "draft" }`. `party` tags the counterparty (see "Tools — parties"); given with @@ -443,6 +458,16 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. (older books hold such drafts) shows an empty code and "(no account N)". - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` → entries with lines, newest first. `limit` caps at 100. +- **Omitted dates (rules.md B-30).** One rule everywhere + (`ledger_core::calendar::PeriodDefaults`), stated in every report + tool's description and date parameters in the same words: a period + report with no dates covers the fiscal year to date + (`company.fiscal_year_start`): `end` defaults to today (the company's + time zone), `start` to the first day of the fiscal year that holds + `end`; a `start` after the `end`, given or derived (`start` after today + with no `end`), is refused, never swapped. An `as_of` defaults to + today; a party's `year` to this calendar year. Every result names the + dates it covers. - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced` (the trial balance as of the date, default today). - `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`, @@ -450,13 +475,14 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. `liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of the date (default today). - `books.profit_and_loss { start?, end?, class? }` → totals plus - `revenue_accounts` and `expense_accounts` for the range. `end` defaults to - today, `start` to the first posted entry. Closing entries are excluded, so - a closed period still shows what it earned. + `revenue_accounts` and `expense_accounts` for the range (omitted dates: + the fiscal year to date). Closing entries are excluded, so a closed + period still shows what it earned. - `books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }` (each defaults to the books' settings, `books.cash_accounts` and so on; cash is required one way or the other) → the statement of cash flows, direct method (rules.md B-14, P3 defect - 1): `{ method: "direct", start, end, operating_by_class, operating, - cash_from_operations, investing_by_kind, investing, cash_from_investing, + 1; omitted dates: the fiscal year to date): `{ method: "direct", start, + end, operating_by_class, operating, cash_from_operations, + investing_by_kind, investing, cash_from_investing, financing_by_kind, financing, cash_from_financing, net_change_in_cash, cash_beginning, cash_ending, noncash_disclosures, operating_reconciliation, reconciles, warnings }`. @@ -521,6 +547,9 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. with a recorded statement, that have posted activity through the end of last month but no reconciliation (not voided) through it. The viewer's Needs attention uses the same rule (`ledger_store::reconcilable`). + `uncategorized_accounts` lists every revenue and expense account (not a + header) with no tax category, active or not, `"CODE Name"`: the list + Needs attention shows (rules.md B-32). ## Tools — fixed assets @@ -605,8 +634,9 @@ through the policy. ## Tools — the viewer - `books.show { entry_id | account | report | attachment_id | asset | - party | reconciliation | search | page, start?, end? }` → `{ shown, - viewer_open, note }`. Asks the human's viewer (`ledger-gui`) to open an + party | reconciliation | search | page, start?, end?, columns?, compare? }` + → `{ shown, viewer_open, note }`. Asks the human's viewer (`ledger-gui`) + to open an entry, an account register or a report (for start/end dates if given), a stored file, an asset, a party (by id or name, resolved as in "Tools — parties"), a reconciliation (by id: its account's page, with its @@ -623,6 +653,30 @@ through the policy. `viewer_open` says whether one does. The note says "The viewer shows …", or "No viewer has these books open: ask the human to open it (opened within a couple of minutes, it shows …)". + - **Dates (rules.md B-30).** For an account or a report, the dates are + resolved by the one default (omitted: the fiscal year to date; only + `end`: its fiscal year to it) and sent to the viewer resolved, so + `shown` and the screen can't differ: "the register of 1000 Business + Checking, 2026-01-01 to 2026-10-20", "the profit and loss report, + 2026-01-01 to 2026-10-20", "the trial balance report, as of + 2026-10-20" (trial balance, aging, fixed asset register, a balance + sheet in one total column), "the vendor 1099 report, for 2026". A + start after the end, given or derived, is refused and nothing is + sent. + - **Options a target ignores are refused**, naming what takes them: + `start`, `end`, `columns` and `compare` for any target but an account + or a report; `columns` and `compare` for an account; `columns` but for + `profit_and_loss`, `balance_sheet` and `cash_flow`; `compare` but for + those and `tax_summary`, and never with a split; `start` for a report + as of a day (the trial balance, the agings, the fixed asset register, + a balance sheet in one total column unless compared with the prior + period) or for `vendor_1099` (its year is `end`'s); and a split into + more columns than the viewer shows (`ops::MAX_REPORT_COLUMNS`, 240: + past it the viewer shows the total). + - The viewer takes a target's dates as sent; one it can't honour (text + that isn't a date, a start after the end from an earlier writer or a + hand-edited `ops/show.jsonl`, an unknown split) is ignored whole, as + an unknown page is. ## Tools — integrity @@ -1196,13 +1250,17 @@ posting. closed: rules.md B-13, D-38), `general_ledger` (by account: beginning balance, postings with running balance and split, ending balance), `general_journal` (alias - `journal`: every entry, line by line, in date order). + `journal`: each entry posted in the period, line by line; `iif`: + every posted entry from the first through `as_of`, never a period). - Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`, `csv`, `json` for every report; `iif` for `general_journal` (alias `journal`), for QuickBooks Desktop. - - Dates: period reports take `start` (default: first entry) and `end` - (default: today, local time); `balance_sheet`, `trial_balance`, - `fixed_asset_register` and `iif` take `as_of` (default today). + - Dates (rules.md B-30): the period reports (`profit_and_loss`, + `cash_flow`, `members_equity`, `tax_summary`, `general_ledger`, and + `general_journal` as csv, json and xlsx) take `start` and `end` + (omitted: the fiscal year to date; a start after the end is + refused); `balance_sheet`, `trial_balance`, `fixed_asset_register` + and `iif` take `as_of` (omitted: today, the company's time zone). - IIF (`docs/spec/rules.md` B-34; plan D-36): plain ASCII text, every line ending CR LF, inline (no `content_encoding`): every posted entry from the first through `as_of`, period-closing entries left out @@ -1267,16 +1325,49 @@ posting. code for an account of the wrong type. `config_mentions` lists the wanderbooks.toml settings still naming the old code (edit them; the server reloads the file). Journaled as `account.renumbered`. +- `books.rename_account { account, name, reason? }` → `{ id, code, + previous_name, name, notes }` (scope `books:post`, destructive: a + rename changes the label every past report prints): give an account a + new name (rules.md B-31). Only the name changes (entries name accounts + by id): no entry, balance or statement amount moves, and every report, + past periods included, prints the new name. Refuses an unknown account, + a blank name, the name it has, a name another account under the same + parent has (ignoring capitals), one holding a run of nine digits, or + one that isn't one line of at most 200 characters (kept trimmed); a + `reason` is one to 2,000 characters with no full TIN. Journaled as + `account.renamed { account_id, code, name, previous, reason }`, keyed + by id (a code can change; an id can't); the first rename declares the + journal feature `account_renamed` (rules.md B-27), so an earlier + version stops at the declaration. `notes` always says that QuickBooks + matches accounts by name (tell a CPA who imported an earlier IIF), says + when sub-accounts' full names change with it, when the new name moves + the account into or out of the cash accounts (`books.cash_accounts` + unset: picked by name) or the card accounts (named "card"), and lists + conventions whose text names the old name. - `books.set_tax_category { account, category?, deductible_percent? }`: map a revenue or expense account to a category (null clears), - optionally overriding its deductible percent. Journaled as + optionally overriding its deductible percent. Under + `policy.require_tax_category = refuse` a revenue or expense account + that isn't a header is never left without one. Journaled as `account.tax_set`. - `books.export { report: "tax_summary" }`: book vs. tax amounts by category, the difference where they disagree (meals 50%, penalties 0%), - and every P&L account still uncategorized. In the CPA package for the - fiscal year to date. + and every P&L account still uncategorized. Gains and losses on business + property (`form_4797_gain_loss`) are listed apart in `form_4797`, at + their book amounts with no tax amount and outside income and both nets, + on every return: Form 4797 figures them from the tax basis and splits + ordinary from section 1231 gain, which decides the line (never Schedule + C). In the CPA package for the fiscal year to date. - `books.import_chart_csv` accepts an optional fifth `tax_category` column, - validated before anything is created. + validated before anything is created. A revenue or expense row without + one is listed in `warnings`, or, under `policy.require_tax_category = + refuse`, refuses the whole file (a row is never a header account: the + file can't say so). +- `policy.require_tax_category` (`books.set_setting`): `warn` (unset) or + `refuse` (rules.md B-32). The accounts invoices, bills and payroll + create (Revenue 4000, General Expenses 6999, 6100, 6110) are never + refused; their results' `warnings` name them. The first change naming + the key declares the journal feature `require_tax_category`. ## Tools — import @@ -1287,8 +1378,10 @@ posting. `import_source`) is checked before any draft and linked to the bank account (`source_linked`; false when it was already), never to the drafts: a bank export is not a receipt. -- `books.import_chart_csv { csv_text? | upload_id? }` → creates accounts - from `code,name,type,parent_code` CSV. All-or-nothing. +- `books.import_chart_csv { csv_text? | upload_id? }` → `{ created, + count, warnings? }`: creates accounts from + `code,name,type,parent_code[,tax_category]` CSV. All-or-nothing; the + name and tax category rules are `books.add_account`'s. - Each takes the CSV as `csv_text` or as an upload (`books.begin_upload` purpose `import`; UTF-8), never both. The CSV itself is not filed: file it with `books.file_document` (category `import_source`) for @@ -1434,17 +1527,18 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. - `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to a party ("Tools — parties"). - `books.create_invoice { party_id, date, due_date, lines: [{ description, - amount }] }` → `{ id, total, entry_id }`. Auto-posts Dr `1200` Accounts - Receivable / Cr `4000` Revenue. + amount }] }` → `{ id, total, entry_id, warnings? }`. Auto-posts Dr + `1200` Accounts Receivable / Cr `4000` Revenue. `warnings` names a + revenue account it created with no tax category (rules.md B-32). - `books.create_bill { party_id, date, due_date, lines }` → `{ id, total, - entry_id }`. Auto-posts Dr `6999` General Expenses / Cr `2000` Accounts - Payable. + entry_id, warnings? }`. Auto-posts Dr `6999` General Expenses / Cr + `2000` Accounts Payable; `warnings` as for invoices. - `books.receive_payment { document_id, date, amount, cash_account }` → `{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R. - `books.pay_bill { document_id, date, amount, cash_account }` → `{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash. -- `books.ar_aging { as_of }` → `{ rows: [{ name, current, days_30, days_60, - days_90, days_90_plus, total }] }`. +- `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30, + days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today). - `books.ap_aging { as_of }` → same, per vendor. ## Tools — foreign exchange @@ -1464,7 +1558,9 @@ entries. - `books.run_payroll { date, cash_account, lines: [{ employee?, employee_name?, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions }] }` - → `{ run_id, entry_id, total_gross, total_net }`. One balanced entry: + → `{ run_id, entry_id, total_gross, total_net, warnings? }` (`warnings` + names an expense account it created with no tax category). One + balanced entry: Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense (employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash (net). Auto-creates the 61xx/21xx accounts. Respects closed periods. @@ -1490,7 +1586,8 @@ entries. - `books.add_line` gains optional `class` (name or id): tags the new line. Shared costs are split explicitly across lines at entry time. - `books.profit_and_loss` gains optional `class` filter. -- `books.profit_and_loss_by_class { start, end, class? }` → per-class revenue, +- `books.profit_and_loss_by_class { start?, end?, class? }` (omitted dates: + the fiscal year to date, as every period report) → per-class revenue, expenses, net income, plus an unclassified row (only that class's row when `class` is given; an unknown or ambiguous class is refused). Reversals carry their class so divisional P&L nets correctly. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 3119602..f25b0de 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1080,12 +1080,19 @@ lowers the format in force. Opening the books checks every complete line before it changes anything: a cut-off last line is set aside only when the lines before it pass, so an older version never alters newer books. -A feature (`ledger_store::format::KNOWN_FEATURES`) is a field or value on -an event type an older version knows but would misread; the writer declares -it with the format in force (at least 2) just before the first line that -needs it, so books that never use it stay readable by versions before it: -`members_cumulative` (P3, B-13: a members close counted from its count's -first close, `"rounding": "cumulative_since"`). +A line an earlier version can't read is announced by a journal feature +(`ledger_store::format::KNOWN_FEATURES`): a new event type, or a field or +value on an event type an earlier version knows but would misread. The +writer declares it once, in the `journal.format_set` just before the +first line that needs it, with the format in force (at least 2) and the +features already in force (`Store::ensure_feature`): an earlier reader +stops at the declaration, and books that never use the feature stay +readable by it. P3's: `members_cumulative` (B-13: a members close counted +from its count's first close, `"rounding": "cumulative_since"`), +`account_renamed` (before the first `account.renamed`, B-31) and +`require_tax_category` (before the first `settings.set` naming +`policy.require_tax_category`, B-32). A later piece asks the owner's +go-ahead before the first such declaration (plan D-39). **Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`, `gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`, @@ -1281,6 +1288,196 @@ checked to change no figure or entry; `approval_rules_off_by_default`, `tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`, `Reachable.records`; fuzz `fuzz_settings_replay`. +## B-30 A report asked for without dates covers this fiscal year so far + +**Status:** AUTHORITY for the period (IRS Pub. 538, +`sources/irs-periods-and-methods.md`: "A tax year is an annual accounting +period for keeping records and reporting income and expenses"; 26 U.S.C. +§6041(a), "in any calendar year", and §6041A(a)(2), "during such calendar +year", `sources/irs-1099-thresholds.md`, for the calendar-year counts); +DECISION for using it as the default (no authority says what a tool's +omitted dates mean; the owner, 2026-10-20, `plan/served.md` P3 "Settled in +design": "a report asked for without dates covers this fiscal year so +far, everywhere, and an account's register opens on it with 'Show all +dates' one click away"). + +The books' annual period is the fiscal year (`company.fiscal_year_start`, +B-29). Every place a date may be omitted resolves it by one rule +(`ledger_core::calendar::PeriodDefaults`), so the agent's report tools, +`books.show`, the viewer and the tool descriptions can't say different +things: + +| Kind | Used for | Both dates omitted | Only `end` given | Only `start` given | +|---|---|---|---|---| +| A period | profit and loss (and by class), cash flow, tax summary, members' equity, general ledger, general journal (csv, json, xlsx), an account's register, `books.show` of an account or a report | the first day of the fiscal year that holds today, through today | the first day of the fiscal year that holds `end`, through `end` | `start` through today | +| An instant | balance sheet, trial balance, A/R and A/P aging, fixed asset register, the IIF's `as_of` (every posted entry from the first through it, B-34), the CPA package's `as_of` | today | | | +| A calendar year | party totals (`books.parties`, `books.party`, the viewer's party pages), the viewer's 1099 worksheet | today's calendar year | | | + +- A resolved start after the resolved end is refused, whether the caller + gave both dates (`InvalidDateRange`) or only a start after today, the + end an omitted end means (`StartAfterToday`, whose message says the end + was defaulted: "pass end"). It is never swapped or clamped to fit. +- A derived start before 0001-01-01 is the calendar's first day (only an + end early in year 1 under a non-January fiscal year, B-28). +- The first year: when the company was formed after its fiscal year's + first day, the default start comes before formation. The amounts are + the same, and the start is not moved to `company.formed`: start-up and + organizational costs (IRC §195, §709) can be dated before formation. +- "Today" is the company's (`company.timezone`, B-29). +- The CPA package's period and the viewer's "Year to date" preset are + the fiscal year that holds their day (`calendar::fiscal_year_start_on`), + the fact itself, never "what an omitted date means": they don't follow + this default if it ever changes. +- Not covered (no default period): search filters, the activity digest + (its `since` is required; the `books://digest` resource reads this + month so far), the `through` of `books.run_schedules` and + `books.run_recurring` (today: an operation, not a period), and the + viewer's drill-downs from a balance, which run from the books' first + entry because a balance is cumulative. The IIF is never a period (B-34): + it refuses `start` and `end`, so the general journal's default period + never shortens it, nor anything in the CPA package, which gives its + statements and ledgers its own fiscal year to date. + +**Enforced:** `ledger_core::calendar` (`period_defaults`, +`PeriodDefaults::statement`, `statement_range`, `as_of`, +`calendar_year`, `fiscal_year_start_on`, `PeriodKind::describe`, +`START_DOC`, `END_DOC`, `AS_OF_DOC`, `YEAR_DOC`); `ledger_package` +`statement_range`, `as_of_date`, `pnl_statement`, `bs_statement`, +`cash_flow_statement`, `build_package`; `ledger-mcp` `BooksServer::periods`, +`statement_range`, `as_of`, every report tool, `books.show`, the +generated descriptions (`described`) and parameter docs; `ledger-gui` +`Books::periods`, `default_period`, `fiscal_ytd`, `App::show`, +`RegisterRange`. **Checked:** `ledger-core` `calendar.rs` unit tests and +`tests/properties.rs` `prop_period_defaults_resolve`; `ledger-mcp` +`server.rs` `period_tools_describe_the_one_default`, `tests/mcp_stdio.rs` +`one_default_period_everywhere_defect_16` and +`the_default_period_never_shortens_the_iif_or_the_package`, +`tests/oracle_mcp.rs` +`show_sends_the_dates_it_names_defect_16`, +`show_refuses_start_after_end_defect_15`, +`show_refuses_a_derived_inverted_range_defect_15`; `ledger-gui` +`tests/show.rs`; bookspec `examples/default-period-fiscal-year-to-date.toml`. + +## B-31 Renaming an account changes its label, nothing else + +**Status:** DECISION (the owner, `plan/served.md` 4.9 and P3 "Settled in +design", 2026-10-20: "a new account named like a sibling (ignoring +capitals), or holding a nine-digit number, is refused"); B-8 (posted is +forever); AUTHORITY for keeping the old name (Rev. Proc. 98-25 §5.01, +`sources/irs-records.md`: "the relationship (i.e., audit trail): (a) +between the total of the amounts in the taxpayer's machine-sensible +records by account and the account totals in the taxpayer's books"); +Intuit IIF (`sources/intuit-iif.md`: "Accounts are identified by NAME") +for refusing a sibling's name. + +Entries name accounts by id, so a rename changes no entry, line, hash, +balance or statement amount: every report, past periods included, prints +the new name. The journal keeps the old one (`account.renamed {account_id, +code, name, previous, reason}`), so a report printed under the old name +can be tied to the new. A rename is refused when the account is unknown, +the name is blank, the name (trimmed) is the one it has, another account +under the same parent (top-level accounts are siblings too) has it +ignoring capitals and surrounding space (QuickBooks would merge the two), +it holds a run of nine digits (a full SSN, EIN or account number, B-26: +every report prints a name), or it is not one line of at most 200 +characters (`account::MAX_ACCOUNT_NAME`, the bound of the company's own +name: no line break, tab or other control character, which no report, CSV +or IIF line can hold). The name is kept trimmed. The same name rules hold +for a new account (`books.add_account`, `books.import_chart_csv`), which +is journaled with its name trimmed too; accounts the engine +creates for a control role keep their fixed names and are never refused +for one. Approval rules, roles, settings and closed periods name accounts +by code or id, and a name is no posting, so none of them blocks a rename. +Existing charts are untouched: the rules are the commands', never replay's +(B-28). + +A rename can still change what the books pick by name (no amount): with +`books.cash_accounts` unset, the cash accounts are the asset accounts +whose names say they hold cash, and the card accounts are the +liabilities named "card" (`ledger_store::reconcilable`); the result says +when the new name moves the account into or out of either. It also lists +conventions whose text names the old name. QuickBooks matches accounts +by name, so a CPA who imported an earlier IIF is told. + +**Enforced:** `Ledger::rename_account` / `check_rename_account`, +`check_account_name` (also in `add_account`), `account::MAX_ACCOUNT_NAME`, +`check_rename_reason`, +`replay_rename_account` (only that the account exists), +`ChartOfAccounts::set_name`, `Ledger::add_control_account`; +`Store::rename_account` (journal feature `account_renamed`, declared +before the first rename, B-27); `books.rename_account` (scope +`books:post`, like renumbering). **Checked:** `ledger-core` +`tests/spec_ledger.rs` (`rename_account_*`), `tests/model.rs` +(`Cmd::Rename`); `ledger-store` `tests/store.rs` +(`rename_account_changes_only_the_name_defect_15`, +`prop_rename_changes_no_figure`, `a_journaled_rename_replays_past_the_command_rules`), +`tests/format.rs`; `ledger-mcp` `tests/mcp_stdio.rs` +(`rename_account_end_to_end_defect_15`), `tests/hostile_args.rs`; +`ledger-core` `tests/spec_ledger.rs` +(`a_new_name_is_one_trimmed_line_of_bounded_length`), `ledger-store` +`tests/store.rs` (`a_new_accounts_name_is_journaled_trimmed`); Lean +`Step.record` (a name is outside the model: `docs/verification.md` 13a); +bookspec `examples/rename-changes-no-amount.toml`. + +## B-32 Every revenue and expense account maps to a line of the return + +**Status:** AUTHORITY for why it matters (IRS Pub. 583, +`sources/irs-records.md`: "Your books must show your gross income, as +well as your deductions and credits"; IRS Pub. 538: "you must maintain +records that will enable you to file a correct return"; Rev. Proc. 98-25 +§5.01: the relationship "between the total of the amounts in the +taxpayer's machine-sensible records by account and the taxpayer's +return"); DECISION for warn versus refuse (Pub. 583: "the law does not +require any specific kind of records"; the owner, 2026-10-20: warn by +default, header accounts exempt, the first company set to refuse at the +P3 cutover). + +A tax category is the account's link to the return (`ledger_core::tax`, +`sources/irs-tax-lines.md`). An account needs one when it is a revenue or +expense account and not a header (a header takes no postings, so nothing +of its own reaches the return: no entry posts to one, a replayed one +included, and an account's header flag never changes). + +- Needs attention and the digest (`uncategorized_accounts`) list every + account that needs a category and has none, with activity or not. +- `policy.require_tax_category`: `warn` (unset) or `refuse`. Under `warn`, + `books.add_account` and `books.import_chart_csv` create the account and + their results say so. Under `refuse`, they refuse it (the file whole), + naming the account and where the categories are listed; both take the + category at creation, so the refusal is always met in one call; and + `books.set_tax_category` won't clear a category an account needs. +- The accounts invoices, bills and payroll create for a control role + (Revenue 4000, General Expenses 6999, Salary & Wage Expense 6100, + Payroll Tax Expense 6110) are never refused, so an invoice is never + refused over a chart label: their results warn, and Needs attention + lists them. The policy is "no uncategorized account made through the + chart tools", not "never any". +- Turning the policy to `refuse` changes no existing account: it applies + to new commands (B-28). Needs attention keeps listing the gaps. +- Every account a tool can require has a category that fits it on every + return: the gain or loss account of a disposal (`form_4797_gain_loss`, + Form 4797), tax-exempt income (`tax_exempt_income`, Schedule K), and + each control role's. A gain or loss on business property is figured on + Form 4797 from the tax basis, and its split (ordinary gain, net section + 1231 gain) decides the line, never Schedule C: the tax summary lists it + apart (`TaxSummary::form_4797`), at its book amount, with no tax amount + and in no total, on every return. + +**Enforced:** `tax::needs_category`, `account_needs_category`, +`uncategorized_accounts`, `CATEGORIES`; `Ledger::check_tax_category_required`, +`check_tax_category_kept` (commands only, never `set_account_tax`, which +replay calls); `settings::TaxCategoryPolicy`, `Settings::require_tax_category`; +`Store::add_account_with_tax`, `add_account`, `set_account_tax` +(journal feature `require_tax_category`, declared before the first +`settings.set` naming the key); `ledger-mcp` `books.add_account`, +`books.import_chart_csv`, `books.digest`; `ledger-gui` Needs attention +(Info under warn, Todo under refuse). **Checked:** `ledger-core` +`tests/tax.rs`; `ledger-store` `tests/store.rs` +(`prop_tax_policy_refuses_exactly`, `a_journaled_clear_replays_under_refuse`); +`ledger-mcp` `tests/spec_tax_policy.rs`; `ledger-gui` `tests/view_model.rs` +(`needs_attention_flags_every_uncategorized_pl_account_defect_8`); +bookspec `examples/tax-category-required.toml`. + ## B-33 A package as of a day shows the books through that day **Status:** CONVENTION (a report as of a day reports the books on that @@ -1447,4 +1644,5 @@ appendix: the file line by line and its changes, derived by hand); `ledger-mcp` `oracle_mcp.rs` (`iif_declares_accounts_by_role_and_balances_every_transaction`, `iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance`), -`mcp_stdio.rs` (`mcp_import_export_over_stdio`). +`mcp_stdio.rs` (`mcp_import_export_over_stdio`, +`the_default_period_never_shortens_the_iif_or_the_package`). diff --git a/docs/spec/sources/irs-tax-lines.md b/docs/spec/sources/irs-tax-lines.md index 13c23b7..fccda57 100644 --- a/docs/spec/sources/irs-tax-lines.md +++ b/docs/spec/sources/irs-tax-lines.md @@ -350,6 +350,65 @@ SHA-256 of the retrieved file (`i1120s.pdf`): `eab655cff9226dbc6d80213350bac0500 > Enter only taxable portfolio interest on this line. Taxable interest is interest from all sources except interest exempt from tax and interest on tax-free covenant bonds. +--- + +## Gains and losses on business property (Form 4797), and tax-exempt income +Governs `form_4797_gain_loss` and `tax_exempt_income` (added in P3, defect 8, 2026-10-20: under `policy.require_tax_category = refuse` every revenue and expense account needs a category that fits it, and the gain or loss account `books.dispose_asset` takes had none). Each file below was retrieved again on 2026-10-20 and is byte for byte the one quoted above (its SHA-256 is the same); only the passages are new. Captions as above. + +**Form 1065 (2025), Schedule K** (Source: ; Retrieved: 2026-10-20; SHA-256 `0f19f556e12ef53c41ba27e5930b4373103f3abcf64693c4ea686451b2a8f56f`) + +> 10 Net section 1231 gain (loss) (attach Form 4797) +> +> 18a Tax-exempt interest income +> +> b Other tax-exempt income +> +> c Nondeductible expenses + +**Form 1120-S (2025), Schedule K** (Source: ; Retrieved: 2026-10-20; SHA-256 `c4b7cae7f2283a2e3225523ea001a710109fee34aad909461421025b287cd18d`) + +> 9 Net section 1231 gain (loss) (attach Form 4797) +> +> 16a Tax-exempt interest income +> +> b Other tax-exempt income +> +> c Nondeductible expenses + +**Form 1120 (2025), Schedule K item 9 and Schedule M-1 line 7** (Source: ; Retrieved: 2026-10-20; SHA-256 `016c7fb4042a565c23a98014b17019af7ddc89e3260dd3f926b84d6688b7be6e`) + +> 9 Enter the amount of tax-exempt interest received or accrued during this tax year +> +> 7 Income recorded on books this year not included on this return (itemize): Tax-exempt interest $ + +**Instructions for Form 1065 (2025), Line 6 and Tax-exempt income, in part** (Source: ; Retrieved: 2026-10-20; SHA-256 `f4a35a24c7abac486908d3610754397a66b2130f0eb425dda897d78a9d91b786`) + +> Line 6. Net Gain (Loss) From Form 4797 +> +> Caution: Include only ordinary gains or losses from the sale, exchange, or involuntary conversion of assets used in a trade or business activity. + +> Tax-exempt income. Don’t include any tax-exempt income on lines 1a through 8. A partnership that receives any tax-exempt income other than interest, or holds any property or engages in any activity that produces tax-exempt income, reports this income on Schedule K, line 18b, and in box 18 of Schedule K-1 using code B. +> +> Report tax-exempt interest income, including exempt-interest dividends received as a shareholder in a mutual fund or other RIC, on Schedule K, line 18a, and in box 18 of Schedule K-1 using code A. + +**Instructions for Form 1120-S (2025), Line 4 and Tax-exempt income, in part** (Source: ; Retrieved: 2026-10-20; SHA-256 `eab655cff9226dbc6d80213350bac050009ba90daf621c182d31c541bc62c3f2`) + +> Line 4. Net Gain (Loss) From Form 4797 +> +> Caution: Include only ordinary gains or losses from the sale, exchange, or involuntary conversion of assets used in a trade or business activity. + +> Tax-exempt income. Don’t include any tax-exempt income on lines 1a through 5. A corporation that receives any tax-exempt income other than interest or holds any property or engages in any activity that produces tax-exempt income reports this income on Schedule K, line 16b, and in box 16 of Schedule K-1 using code B. +> +> Report tax-exempt interest income, including exempt-interest dividends received as a shareholder in a mutual fund or other regulated investment company, on Schedule K, line 16a, and in box 16 of Schedule K-1 using code A. + +**Instructions for Form 1120 (2025), Line 5, in part** (Source: ; Retrieved: 2026-10-20; SHA-256 `49476a6f69a38a0fdb96fea1d66ddc936404e9b2eebb249da8e20830b0743358`) + +> Report tax-exempt interest on Schedule K, item 9. Also, if required, include the same amount on Schedule M-1, line 7, or Schedule M-3 (Form 1120), Part II, line 13, if applicable. + +**Instructions for Schedule C (2025), Other Schedules and Forms You May Have To File, in part** (Source: ; Retrieved: 2026-10-20; SHA-256 `b5536db91498a450572e37078d7d5ee8fdc400e385d4fee3a26c230a33e399b6`) + +> • Form 4797 to report sales, exchanges, and involuntary conversions (not from a casualty or theft) of trade or business property. + ## Mapping (wanderbooks, derived) **This section is derived, not quoted.** It is wanderbooks' reading of the 2025 forms and instructions above, written 2026-10-13, for the categories in `crates/ledger-core/src/tax.rs`. It is not tax advice; the CPA decides the return. "—" means the category does not apply to that form (per `TaxCategory::forms`). "Not page 1" means the quoted page-1 lines have no place for it; where it goes instead (for example Schedule K) is not quoted in this file and is left to the CPA. @@ -359,7 +418,9 @@ SHA-256 of the retrieved file (`i1120s.pdf`): `eab655cff9226dbc6d80213350bac0500 | `gross_receipts` | 1 | 1a | 1a | 1a | | `returns_allowances` | 2 | 1b | 1b | 1b | | `interest_income` | 6 for interest on notes and accounts receivable (instructions, line 6); other interest not determined here | Not page 1: Schedule K line 5, portfolio interest (quoted above) | Not page 1: Schedule K line 4, portfolio interest (quoted above) | 5 | +| `form_4797_gain_loss` | Not on Schedule C: Form 4797 (instructions, quoted above) | 6 (the ordinary part, from Form 4797); net section 1231 gain on Schedule K line 10 (quoted above) | 4 (the ordinary part); net section 1231 gain on Schedule K line 9 (quoted above) | 9 | | `other_income` | 6 | 7 | 5 | 10 | +| `tax_exempt_income` | — | Not page 1: Schedule K line 18a (interest) or 18b (other) (quoted above) | Not page 1: Schedule K line 16a or 16b (quoted above) | Not page 1: Schedule K item 9 and Schedule M-1 line 7, tax-exempt interest (quoted above) | | `cost_of_goods_sold` | 4 (from Part III, line 42) | 2 | 2 | 2 | | `advertising` | 8 | 21 (no advertising line) | 16 | 22 | | `bad_debts` | 27b via Part V (no line in Part II; instructions list bad debts under Part V) | 12 | 10 | 15 | @@ -395,3 +456,5 @@ Notes on the derivation: - Form 1065 line 13 "Rent" and Forms 1120-S line 11 / 1120 line 16 "Rents" do not split equipment from property; Schedule C line 20 does. - Schedule C has no line for bad debts or for organizational and start-up costs; the Part V instructions quoted above put them in Other expenses (line 48 to line 27b). - Schedule C line 30 (business use of home) and Form 1065 line 20 / 1120-S line 19 / 1120 line 25 (energy efficient commercial buildings) have no wanderbooks category. +- `form_4797_gain_loss` (2026-10-20) is the book gain or loss on business property. Form 4797 figures the tax gain from the tax basis (book and tax depreciation differ), and splits ordinary gain (recapture) from section 1231 gain; the CPA does both, and the split decides the line (the row above). So, unlike `depreciation` (on page 1 of every return, only its amount the CPA's), the tax summary can't place it: it lists it apart (`form_4797`), at its book amount, with no tax amount and outside income and both nets, on every return alike. A Form 4797 gain or loss never goes on Schedule C (the Schedule C instructions send sales of business property to Form 4797). +- `tax_exempt_income` (2026-10-20) is income at 0%: none of it is taxable, so it appears in the summary's book amounts and not in its tax amounts, the income twin of `nondeductible`. It is for the entity returns; a sole proprietor's tax-exempt interest is not business income. diff --git a/docs/testing.md b/docs/testing.md index 7b23aa2..495eeb1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1050,6 +1050,193 @@ the end. (plan P3 accept). Likely equivalent, to adjudicate: `min` against `max` in `take` where both bounds coincide, and the kind order's tie-breaks. +## P3: rename, defaults and tax categories (2026-10-20) + +Defects 15, 16 and 8 (`plan/served.md` P3; rules.md B-30, B-31, B-32). +Every fixture is fictional (Fernhill Labs LLC, its members and vendors); +no mutation run (D-33): the targets are listed for P12 below. + +**Named regressions**, from the brief's reproductions: +- `ledger-mcp` `tests/oracle_mcp.rs`: `show_refuses_start_after_end_defect_15` + (1.1.1-1.1.2: refused, nothing reaches the viewer), + `show_refuses_a_derived_inverted_range_defect_15` (1.1.3: a start after + today with no end, the message saying the end was defaulted), + `show_sends_the_dates_it_names_defect_16` (1.1.4: only an end covers its + fiscal year to it; "as of" and "for" wording; the viewer gets the pair + `shown` names), `show_refuses_options_its_target_ignores_defect_15`, and + `show_writes_the_viewer_command_for_each_target` (resolved dates; a + split never compared). +- `ledger-mcp` `tests/mcp_stdio.rs`: `one_default_period_everywhere_defect_16` + (a July fiscal year, a first entry the year before: P&L, by class, cash + flow, the exported tax summary, members' equity, general ledger and + journal (csv, json: the period's entries only) and the CPA package all + give 2025-07-01 through 2026-03-31 for that end; with no dates every one + gives today's fiscal year to date, the clock read before and after, R14), + `rename_account_end_to_end_defect_15` (the chart, a past period's P&L, + the IIF's full names, verify, two journal lines, refusals write nothing, + a restart keeps the name); `mcp_period_statements_over_stdio`'s old pin + (no start = the first entry) now pins the fiscal year (02-01) that holds + the end. +- `ledger-mcp` `tests/spec_tax_policy.rs`: `add_account_warns_without_tax_category_defect_8` + (1.4.1), `require_tax_category_refuses_defect_8` (refused without, + created with, the import refused whole, clearing refused, a bill's 6999 + created and named in its warnings and the digest), + `a_new_account_named_like_a_sibling_is_refused`. +- `ledger-gui` `tests/view_model.rs`: `needs_attention_flags_every_uncategorized_pl_account_defect_8` + (1.4.2-1.4.3: unused and last-year-only accounts listed, headers and + categorized ones not; Info under warn, Todo under refuse), and + `the_overview_numbers_are_the_books_numbers` now pins that the viewer's + default report range, its register's default and the server's resolver + agree on random books and fiscal years. +- `ledger-gui` `tests/show.rs`: `show_never_swaps_an_inverted_target_defect_15`, + `show_report_with_only_end_uses_that_years_start_defect_16`, + `show_all_dates_is_one_click_from_a_shown_register` (review R4). +- `ledger-store` `tests/store.rs`: `rename_account_changes_only_the_name_defect_15` + (every figure, entry and earlier journal line, every projection row but + `accounts.name`; refusals append nothing; reopen, rebuild and viewer), + `a_journaled_rename_replays_past_the_command_rules` and + `a_journaled_clear_replays_under_refuse` (B-28's pairs, review R5), + `the_tax_category_policy_refuses_only_what_it_says`, + `books_written_under_p3_keep_their_names` (fixture `old-books/p3_rename`, + written once by `write_p3_rename_fixture`, `--ignored`; its figures are + also in `books_an_earlier_version_wrote_open_to_its_figures`). +- `ledger-store` `tests/format.rs`: `p3_features_are_declared_once_before_the_first_line_that_needs_them`, + `an_unknown_feature_before_a_rename_is_newer_at_the_declaration`. + +**Properties:** `prop_period_defaults_resolve` (`ledger-core`, 5,000 +cases: every fiscal year start replay admits, every today and every mix of +given and omitted dates, the calendar's edges weighted); +`prop_rename_changes_no_figure` and `prop_tax_policy_refuses_exactly` +(`ledger-store`, `STORE_CASES`, 64 by default); the refusal law +(`refused_commands_never_reach_the_journal`) gains renames, accounts added +with a category, and the policy turned on and off; the core model +(`tests/model.rs`) gains `Cmd::Rename`, its model keeping a name per +account. + +**Unit tests:** `calendar.rs` (each `PeriodDefaults` invariant and scenario: +fiscal years 01-01, 07-01, 12-01 and a lenient 07-15; the start day and +the day before; year 1 under a July year, clamped; 9999-12-31; a one-day +period; a start one day late; no month-day or chrono date panics); +`settings.rs` (`the_tax_category_policy_is_warn_or_refuse`); `ledger-config` +`codec.rs` (`the_tax_category_policy_round_trips`); `ledger-core` +`tests/spec_ledger.rs` (`rename_account_changes_only_the_name`, +`rename_account_refusals_leave_the_books_unchanged`, +`a_closed_period_or_a_role_never_blocks_a_rename`, +`a_journaled_rename_replays_past_the_command_rules`, +`a_new_account_named_like_a_sibling_or_holding_a_tin_is_refused`, +`a_rename_reason_is_bounded_text_without_a_tin`) and `tests/tax.rs` +(`needs_category`'s and the policy's truth tables, the catalog fitting +every account a tool requires on every return, tax-exempt income at 0%); +`ledger-mcp` `server.rs` `period_tools_describe_the_one_default` (every +report tool's description ends with its kind's sentence and each date +parameter is the shared constant; "all time", "beginning of time" and +"first entry" are gone from every tool and schema; the resources say +their period) and `show_reads_each_reports_dates_as_the_viewer_does`; +`handbook.rs` (the period rule rendered from the same constant); +`hostile_args.rs` `hostile_renames_ranges_and_categories_are_answered`. + +Test fixtures that gave two accounts one name under one parent +(`ledger-core` `invariants.rs` and `spec_roles.rs`, the store's refusal +law) now name each for its code: the sibling rule is the commands', and +existing charts and journals are untouched (replay takes any name). The +viewer's `spec_gui.rs` small books give their one expense account a +category, so "empty books have nothing" still holds now that an unused +uncategorized expense account is listed. + +**Adversarial review (2026-10-21).** Each finding has a test that failed +before its fix (checked by breaking the code and watching it fail): +- `ledger-core` `tests/tax.rs` `a_gain_on_business_property_is_figured_on_form_4797_on_every_return`, + `ledger-import` `tests/spec_import.rs` `tax_summary_lists_form_4797_apart`, + `ledger-mcp` `tests/spec_tax_policy.rs` `a_gain_on_business_property_is_exported_apart`: + a `form_4797_gain_loss` amount was ordinary income on every return + (Schedule C included, whose instructions send it to Form 4797; on a + partnership net section 1231 gain is Schedule K line 10), its Tax column + the book gain; it is now `TaxSummary::form_4797`, book only, in no + total. +- `ledger-core` `tests/spec_ledger.rs` `a_new_name_is_one_trimmed_line_of_bounded_length`, + `ledger-store` `tests/store.rs` `a_new_accounts_name_is_journaled_trimmed`, + `hostile_args.rs` (the 100,000-character name now refused): a new + account's name was journaled untrimmed while a rename's was trimmed, + and names had no bound and could hold a line break or tab. +- `ledger-gui` `tests/view_model.rs` `all_dates_holds_an_entry_dated_after_today`: + "Show all dates" and "All time" ended today, leaving out entries dated + after it. +- `ledger-mcp` `tests/oracle_mcp.rs` `show_refuses_options_its_target_ignores_defect_15` + (a split past 240 columns): `books.show` sent a split the viewer shows + as one total. +- `ledger-mcp` `tests/mcp_stdio.rs` `rename_says_what_the_new_name_moves_defect_15`: + the notes for the name-picked cash and card sets and conventions had no + test of their own. +- `ledger-store` `tests/store.rs` `rename_account_changes_only_the_name_defect_15` + now reads the projection's renamed name itself (its comparison blanked + that column); `tests/format.rs` lost a check that recomputed the gate's + answer itself. + +**Landing on main (2026-10-21),** beside odd cents, the IIF and the +cash flow statement: +- `ledger-mcp` `tests/mcp_stdio.rs` `the_default_period_never_shortens_the_iif_or_the_package`: + on a July fiscal year with entries before it, the general journal asked + without dates is the fiscal year to date, while the IIF without dates + holds every posted entry from the first through today under + `general_journal`, `journal` and any capitals, refuses `start` and + `end`, and the CPA package's IIF holds the entry before the package's + fiscal year. +- `ledger-core` `tests/bookspec.rs`: the three examples are in the + loader's one shape, and the loader gained what they need (bookspec + `README.md`): `setup.today` and omitted dates resolved by B-30, + `header` and `tax_category` on an account, a `bill` event, `[[changes]]` + (a rename, the policy) made after an event's expectations, the queries + `period`, `day`, `calendar_year`, `account_names` and + `uncategorized_accounts`, and the commands `rename_account`, + `add_account` and `set_tax_category`, tried on a copy and accepted or + refused for a named reason. Each new key has a refusal test + (`a_period_other_than_the_rules_is_refused`, + `a_refusal_for_another_reason_is_refused`, + `an_omitted_date_without_today_is_refused`, + `account_names_leaving_an_account_out_is_refused`, + `a_change_is_made_after_its_events_expectations`, + `a_command_runs_under_the_policy_in_force`, + `a_command_with_values_is_refused`). +- `ledger-import` `tests/spec_import.rs`: the IIF's tests + (`iif_names_never_clash_and_every_change_is_listed`, + `iif_distinct_accounts_never_share_a_name`, + `iif_writes_plain_ascii_and_lists_every_change`, + `iif_warnings_never_hold_a_tin`) make their siblings of one name, names + with a tab and names holding a TIN by replay (`replay_add_account`), as + books written before P3 hold them: B-31 refuses them in a new name, and + the file must still keep them apart, write them on one line and mask + them. +- `books.cash_flow`'s description is the direct method's, shortened to + fit the 700-character cap with its dates sentence + (`tool_descriptions_fit_700_characters`, + `period_tools_describe_the_one_default`). + +**For P12** (D-33; none run): `ledger-core` `calendar::{fiscal_year_start_unclamped, +fiscal_year_unclamped, fiscal_year_start_on, period_defaults}`, +`PeriodDefaults::{statement, statement_range, as_of, calendar_year}`, +`PeriodKind::describe`, `Company::fiscal_year_containing`, +`ChartOfAccounts::set_name`, `Ledger::{add_account (its new name checks), +add_control_account, check_account_name, rename_account, +check_rename_account, replay_rename_account, check_rename_reason, +check_tax_category_required, check_tax_category_kept}`, +`tax::{needs_category, account_needs_category, uncategorized_accounts, +on_form_4797}` and `tax_summary`'s Form 4797 section, +`Settings::require_tax_category`; `ledger-store` `Store::{ensure_feature, +ensure_features, journal_settings (its feature), add_account, +add_account_with_tax, journal_account_added, ensure_control_account, +rename_account, set_account_tax, journal_account_tax}`, the +`account.renamed` replay and projection arms, `format::setting_feature`, +`ops::report_columns`; +`ledger-package` `statement_range`, `as_of_date`, `pnl_statement`, +`bs_statement`, `cash_flow_statement`, `year_earnings`, `build_package`'s +fiscal year; `ledger-mcp` `show` (the option refusals, the resolve, the +`shown` wording), `show_report_dates`, `rename_account` (its notes), +`add_account`, `import_chart_csv` (both loops), `profit_and_loss_by_class`'s +range, `export_journal`'s period, `described`, `uncategorized_warning`, +`created_uncategorized`, the digest's `uncategorized_accounts`, +`handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18): +`view_model.rs` and `show.rs` cover it. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/docs/verification.md b/docs/verification.md index f805993..7924e54 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -169,9 +169,19 @@ sequence of commands, of any length**: checked by `settings_replay_is_pure`, which snapshots every figure and entry around each record (rules.md B-29). Settings that change how the same entries are presented (the fiscal year, cash flow roles, members) - are outside this law by design. + are outside this law by design. A rename (`rename_account`, P3, + rules.md B-31) is a record too: the model's chart is `(code, type)` + (`Ledger.lean`), names are not modelled, so a rename is the identity + on the model and needs no new `Step` (plan I-13 is met as P1 met it + for records). The engine's side is the property + `prop_rename_changes_no_figure` (ledger-store `tests/store.rs`: every + statement amount, entry and earlier journal line unchanged, live + equals replay, the projection unchanged but `accounts.name`), and the + stateful model `tests/model.rs` keeps a name per account so engine + and model agree on every rename's refusal and nothing else moves. - Lean `Step.record_changes_no_figure`, `Reachable.records` (no - axioms at all) · property `settings_replay_is_pure` + axioms at all) · property `settings_replay_is_pure` · + `prop_rename_changes_no_figure` · model `Cmd::Rename` ### Closing diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index f923218..e51d511 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -22,7 +22,8 @@ use libfuzzer_sys::fuzz_target; use serde_json::{Map, Value, json}; const TYPES: &[&str] = &[ - "account.added", "account.tax_set", "asset.depreciation_posted", "asset.disposed", + "account.added", "account.renamed", "account.tax_set", "asset.depreciation_posted", + "asset.disposed", "asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added", "customer.added", "document.filed", "entry.created", "entry.line_added", "entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set", @@ -47,7 +48,7 @@ const FIELDS: &[&str] = &[ "total", "useful_life_months", "vendor_id", "type", "account_type", "rounding", "members", "distributions", "sweep", "format", "features", "changes", "key", "value", "source", "decided_by", "decided_on", "kind", "title", "body_md", "structured", "subject", - "decision_md", "supersedes", "resolution", "approver", + "decision_md", "supersedes", "resolution", "approver", "previous", ]; /// A value replay might plausibly see: small ids, amounts as decimal diff --git a/plan/served.md b/plan/served.md index 99abbcf..5d39621 100644 --- a/plan/served.md +++ b/plan/served.md @@ -556,7 +556,7 @@ Every event may carry an additive `actor {principal, client_id, kind}`, which re | `convention.set {id, kind: posting_template\|approval_rule\|policy\|close_step\|calendar\|mapping\|note, title, body_md, structured?, source, supersedes?}` / `convention.retired` | P1 | `Conventions` | | `decision.recorded {id, subject, decision_md, decided_by[party], decided_on, evidence_attachment?, supersedes?}` | P1 | `Conventions` | | `open_item.raised` / `open_item.resolved` | P1 | `Conventions` | -| `account.renamed {code, name, previous, reason}` | P3 | chart | +| `account.renamed {account_id, code, name, previous, reason}` (keyed by id, as `account.renumbered`: a code can change, an id can't; `code` is informational; the journal feature `account_renamed` is declared before the first) | P3 | chart | | `instrument.added {id, kind: bank\|business_card\|member_card\|member_cash_or_check\|platform, label, last4?, owner_party?, account?}` / `entry.instrument_set` | P3 | `Ledger` (1099) | | `asset.basis_set {asset, rule: purchase\|carryover_723\|personal_use_conversion, fmv_at_contribution?, contributor_adjusted_basis?, tax_basis_723, depreciable_basis, built_in_gain_704c, sec704c_method?}` (legacy `asset.tax_basis_set` still replays) | P3 | assets | | `schedule.registered` + `convention` (absent = `monthly_even`) | P3 | schedules | @@ -591,7 +591,7 @@ Each key has a validator, a docstring (which becomes the `books.settings` schema |---|---| | company | `company.name`, `.entity_type`, `.tax_classification`, `.tax_form`, `.fiscal_year_start`, `.timezone` (drives `today()`, replacing `chrono::Local` at S:1943), `.legal{formation_state, formed, state_entity_number, address, registered_agent, partnership_representative, irs_name_control, duns}`, `.ein` (identity; D-5, decided), `.cpa` (party), `.members` (effective-dated `[{party, share, capital_account, distribution_account, files_jointly_with}]`) | | books | `books.accounting_basis`, `.cash_accounts`, `.financing_accounts`, `.investing_accounts`, `.asset_tag_prefix`, `.close_into`, `.close_sweep`, `.schedule_convention` | -| policy | `policy.evidence{threshold, required_accounts, exempt_accounts}` (replaces config.json `receipt_policy`), `policy.parties`, `policy.capitalization{de_minimis, twelve_month_rule, sources}`, `policy.require_tax_category`, `policy.attestation_bar` (default per D-9), `policy.cpa_package_requires_audit: refuse\|warn` (default `refuse`, overridable per export with a reason; D-9), `policy.audit_cadence`, `policy.approval_rules: off\|enforce` (default `off`: `approval_rule` conventions are recorded but never block a post) | +| policy | `policy.evidence{threshold, required_accounts, exempt_accounts}` (replaces config.json `receipt_policy`), `policy.parties`, `policy.capitalization{de_minimis, twelve_month_rule, sources}`, `policy.require_tax_category: warn\|refuse` (default `warn`; the journal feature `require_tax_category` is declared before the first change naming it), `policy.attestation_bar` (default per D-9), `policy.cpa_package_requires_audit: refuse\|warn` (default `refuse`, overridable per export with a reason; D-9), `policy.audit_cadence`, `policy.approval_rules: off\|enforce` (default `off`: `approval_rule` conventions are recorded but never block a post) | `base_currency` stays in `store.created`. `[books] data_dir`, `[vault] key_file` and `[viewer]` are dropped on import with a note. Unknown keys are reported as problems instead of being silently ignored. diff --git a/verify/lean/Wanderbooks/Ledger.lean b/verify/lean/Wanderbooks/Ledger.lean index 754dbda..21a9885 100644 --- a/verify/lean/Wanderbooks/Ledger.lean +++ b/verify/lean/Wanderbooks/Ledger.lean @@ -15,7 +15,9 @@ Mirrors `Ledger::add_account`, `try_create_entry`, `post_entry`, conventions, decisions, open items and approvals (`apply_settings`, `apply_convention`, `apply_decision`, `apply_open_item`, `apply_approval`, and their retirements and resolutions), each a record -the step adds and nothing else. +the step adds and nothing else; and an account's rename +(`rename_account`, `account.renamed`, rules.md B-31), since the model's +chart is codes and types and a name is no part of it. -/ namespace Wanderbooks @@ -129,7 +131,8 @@ inductive Step : Ledger → Ledger → Prop Step L { L with posted := L.posted ++ closingFor L p ts id, periods := closeIn p L.periods } /-- `apply_settings`, `apply_convention`, `apply_decision`, `apply_open_item`, `apply_approval` and their retirements and - resolutions: a record of how the books are kept, and nothing else. -/ + resolutions: a record of how the books are kept, and nothing else. + `rename_account` too: a name is outside the chart the model keeps. -/ | record (L : Ledger) (r : Nat) : Step L { L with records := r :: L.records } /-- States reachable from empty books. -/