From 06347159f0d86bec3b7636b71ae57c5ab66fa719 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 | 68 +++++++- .../default-period-fiscal-year-to-date.toml | 163 ++++++++++++++++++ examples/rename-changes-no-amount.toml | 149 ++++++++++++++++ examples/tax-category-required.toml | 128 ++++++++++++++ 4 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 examples/default-period-fiscal-year-to-date.toml create mode 100644 examples/rename-changes-no-amount.toml create mode 100644 examples/tax-category-required.toml diff --git a/README.md b/README.md index 42db595..0d36835 100644 --- a/README.md +++ b/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/examples/default-period-fiscal-year-to-date.toml b/examples/default-period-fiscal-year-to-date.toml new file mode 100644 index 0000000..a2d8fbe --- /dev/null +++ b/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/examples/rename-changes-no-amount.toml b/examples/rename-changes-no-amount.toml new file mode 100644 index 0000000..6b00161 --- /dev/null +++ b/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/examples/tax-category-required.toml b/examples/tax-category-required.toml new file mode 100644 index 0000000..d58ab0d --- /dev/null +++ b/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"]