From 528c07060533b6a14d1f4d6dc42e4cfb8064562f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 22 Oct 2026 09:14:07 -0600 Subject: [PATCH] feat: invoices and bills take a project, line by line (B-37 item 8) A client project's invoiced revenue and a contractor's billed cost could never count toward the project: books.create_invoice and books.create_bill posted at once with untagged lines, and only a draft's line takes a class. Job costing (B-37 item 3) collects a job's billings and the supplier invoices for its direct costs on its cost sheet, so each document line now takes an optional project. The owner decided it on 2026-10-22 (plan D-43, rules.md B-37 item 8): each line takes one project or none; a document spanning N projects is N lines, each with its own amount; no percentage split, so every figure is exact; a cost truly shared among projects names none on the document and reaches projects only as entries of their own under the company's monthly allocation convention, when books.conventions has one (otherwise it stays untagged and the agent asks the human). The reasoning, stated in B-37: job costing charges a job the costs traceable to it, whole, and reaches shared costs only by allocation on a stated basis. ASC 340-40-25-7 counts as directly related both the costs traceable to a contract (25-7(a), (b), (d), (e)) and allocations of costs that relate directly to it (25-7(c)); 25-8 expenses general and administrative cost. The books carry the first whole on the document's line and the second as the allocation convention's own entries; neither is a percentage on the document (sources/fasb-asc.md, 25-7 and 25-8 added). The handbook's projects rule and the line's class parameter say the same, and no handbook sentence lets a shared cost be split across a document's lines. The accounting: the document's entry posts one revenue (an invoice's) or expense (a bill's) line per project its lines name, in the order each is first named, each the sum of that project's lines, and the untagged lines' sum as one more line (arap::document_parts, conservation checked by a proptest). A document naming no project therefore posts exactly the one line every document posted before. The receivable or payable line is never tagged: it is a balance-sheet line, which item 3 counts nothing; the payments (receive_payment, pay_bill) move cash against it and take no project either. Nothing but the projects moves: the P&L, the 1099 worksheet (payments made, B-19) and party totals (what moved the money, B-35) read the same with or without the tags (twin-books test); the projects with the untagged lines still partition the P&L. - ledger-core: DocumentLine.class_id (serde-omitted when none, so old documents serialize as before); create_invoice / create_bill split the revenue or expense side by project and tag it; an unknown project is refused whole (atomically). - ledger-store: a document line's project is journaled as class_id on invoice.created / bill.created, and the entry's line as entry.line_class_set right after its entry.line_added (the entry.posted lines carry class_id, as a hand-built entry's do). Behind a NEW journal feature, document_line_projects (KNOWN_FEATURES, declared by new books, asked for before the first journaled line, not even the control accounts written on refusal); not `projects`, which the live books already declared: the owner consents again at the next upgrade. A malformed class_id is corrupt; null or absent is none. Untagged documents journal byte for byte as before; old journals replay unchanged. - ledger-mcp: each line takes class (id or name, resolved like add_line's, every one before anything is written); the result gains notes for a finished or abandoned project, and on an invoice for an on-spec project (is there now an agreement? on spec a hoped-for payment is no receivable, B-37 item 4), tagged anyway; books lacking the feature refuse with format_upgrade_pending. books.get_entry shows a line's class_id and project. The handbook's "an invoice or bill takes no project yet" caveat now depends on the books: gone where the feature is declared, an "until their format upgrade" note where it isn't. - ledger-gui: an invoice's or bill's page (its entry's) lists the document's lines, each project a link that opens it; the demo's client invoices are tagged to "Client work", whose snapshot is now its revenue less its contractors. Looking at the page in a narrow window found a tagged line's tag pushing the amounts past the pane (any tagged entry): the name is now cut to leave the tag room, the tag itself at most half the cell. - bookspec: an `invoice` event, and `lines` with `project` on a bill or an invoice; example projects-invoice-and-bill-lines-count-by-project, worked by hand, both projects client work (Zeta 3,500.00 - 1,200.00 = 2,300.00; Harbor 200.00; untagged -1,750.00; together the P&L's 750.00; Dana's 1099 payments 1,250.00). - fuzz: fuzz_store_replay can declare journal features. - Tests: core, store, MCP, viewer, the P2-served cutover fixture and a viewer/server parity test (its month lists the same length, not empty); a tagged document's reversal nets its projects to zero, live and replayed; books.get_entry's class_id; the handbook served as a resource both ways; each fix broken to confirm its test fails (docs/testing.md). Docs: rules.md B-27 and B-37 items 3 and 8, sources/fasb-asc.md, plan/served.md D-43, mcp-interface.md, testing.md, parity.md, plan/STATUS.md. No proof changed (no kernel arithmetic: document_parts sums with the existing document_total). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- bookspec/README.md | 42 +- ...voice-and-bill-lines-count-by-project.toml | 182 +++++++ crates/ledger-cli/tests/format_upgrade.rs | 18 +- crates/ledger-cli/tests/projects_parity.rs | 137 +++++ crates/ledger-core/src/arap.rs | 51 ++ crates/ledger-core/src/ledger.rs | 65 ++- crates/ledger-core/tests/bookspec.rs | 156 +++++- crates/ledger-core/tests/form1099.rs | 1 + crates/ledger-core/tests/invariants.rs | 1 + crates/ledger-core/tests/spec_arap.rs | 4 +- crates/ledger-core/tests/spec_bounds.rs | 5 + crates/ledger-core/tests/spec_cash_flow.rs | 2 + ...pec_document_projects.proptest-regressions | 7 + .../tests/spec_document_projects.rs | 465 ++++++++++++++++ crates/ledger-core/tests/spec_ledger.rs | 2 + crates/ledger-core/tests/spec_party_totals.rs | 2 + .../tests/spec_replayed_extremes.rs | 1 + crates/ledger-core/tests/spec_roles.rs | 1 + crates/ledger-gui/src/demo.rs | 13 +- crates/ledger-gui/src/pages/cash.rs | 1 + crates/ledger-gui/src/pages/company.rs | 1 + crates/ledger-gui/src/pages/entry.rs | 234 +++++++- crates/ledger-gui/src/pages/parties.rs | 1 + crates/ledger-gui/src/pages/profit.rs | 1 + crates/ledger-gui/src/test_support.rs | 13 + crates/ledger-gui/tests/parties.rs | 1 + crates/ledger-gui/tests/projects.rs | 163 ++++++ crates/ledger-gui/tests/screens.rs | 13 +- crates/ledger-gui/tests/view_model.rs | 2 + crates/ledger-mcp/handbook/projects.md | 2 +- crates/ledger-mcp/src/handbook.rs | 86 ++- crates/ledger-mcp/src/server.rs | 116 +++- crates/ledger-mcp/src/tools/projects.rs | 15 + crates/ledger-mcp/src/types.rs | 21 + crates/ledger-mcp/tests/spec_projects.rs | 387 +++++++++++++ crates/ledger-store/src/format.rs | 46 ++ crates/ledger-store/src/store.rs | 110 +++- crates/ledger-store/tests/attachments.rs | 2 + .../ledger-store/tests/document_projects.rs | 514 ++++++++++++++++++ crates/ledger-store/tests/roles.rs | 1 + crates/ledger-store/tests/settings.rs | 1 + crates/ledger-store/tests/store.rs | 4 + docs/mcp-interface.md | 64 ++- docs/parity.md | 2 +- docs/spec/rules.md | 95 +++- docs/spec/sources/fasb-asc.md | 29 + docs/testing.md | 110 ++++ fuzz/Cargo.lock | 135 ++++- fuzz/fuzz_targets/fuzz_store_replay.rs | 18 +- plan/STATUS.md | 23 +- plan/served.md | 1 + 51 files changed, 3201 insertions(+), 166 deletions(-) create mode 100644 bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml create mode 100644 crates/ledger-core/tests/spec_document_projects.proptest-regressions create mode 100644 crates/ledger-core/tests/spec_document_projects.rs create mode 100644 crates/ledger-store/tests/document_projects.rs diff --git a/bookspec/README.md b/bookspec/README.md index bcb4d65..5f3537c 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -64,9 +64,9 @@ operating = "0.00" ``` Events count from 1: the first `[[events]]` is event 1. An event is an -entry (`lines`), a reversal (`reverses`), a close (`close`, below) or a -bill (`bill`, below), each posting one entry; or one of the books' -commands that post what is due (`run_depreciation`, `dispose_asset`, +entry (`lines`), a reversal (`reverses`), a close (`close`, below), a +bill or an invoice (`bill`, `invoice`, below), each posting one entry; +or one of the books' commands that post what is due (`run_depreciation`, `dispose_asset`, `run_schedules`, below), which post as many entries as are due, none included; or a schedule's registration (`register_schedule`), which posts none: exactly one of them. A key that names an event (`reverses = @@ -77,10 +77,11 @@ posts one entry, event N's is entry #N. A change that posts no entry (a rename, a policy) is a `[[changes]]` entry instead (below). A key the shape doesn't have is refused too, never ignored. -An entry, a reversal, a close, a bill and a disposal are dated (`date`); -the other commands date their own entries (a run its month's end) and -take no date. An entry, a reversal, a close and a bill are described -(`description`); a command describes its own entries and takes none. +An entry, a reversal, a close, a bill, an invoice and a disposal are +dated (`date`); the other commands date their own entries (a run its +month's end) and take no date. An entry, a reversal, a close, a bill and +an invoice are described (`description`); a command describes its own +entries and takes none. The books are the engine's own (`ledger_store::Store`, in a directory of their own): each event is the command the books take it by, journaled as @@ -238,7 +239,8 @@ the side gives the direction. original's tags. - `payee` on an event: the payee (by name, one of `setup.payees`) the entry paid. Only on an entry (`lines`): a reversal takes its - original's, a bill names its vendor, a close and a command have none. + original's, a bill names its vendor, an invoice its customer, a close + and a command have none. - `parties = [{ party, amount? }, { party, percent? }, ...]` on an event: the parties (by name, in `setup.parties`) the entry is with, each with its share: an `amount` (an exact decimal string), a `percent`, or @@ -258,12 +260,24 @@ 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 one entry like any other event. -- `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 one entry: 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). +- `bill = { vendor, amount }` or `bill = { vendor, lines = [{ amount, + description?, project? }, ...] }` as an event: a bill from `vendor` (a + name; a payee's record if one has it) for `amount` (one line, described + as the event) or its `lines` (exactly one of them; a line's description + is the event's when left out), dated and due on the event's date. It + posts one entry: 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). A line's + `project` (a `setup.projects` name) puts its cost on that project: the + entry's expense side is one line per project named, the untagged lines' + as one, the payable line never tagged (B-37 item 8). +- `invoice = { customer, amount }` or `invoice = { customer, lines = [...] + }` as an event: an invoice to `customer` (a name, its record added when + first named), as a bill is (the same `lines`), dated and due on the + event's date. It posts one entry: Accounts Receivable (1200) debited and + Revenue (4000) credited, each created with that fixed name when first + needed; a line's `project` puts its revenue on that project, the + receivable line never tagged (B-37 item 8). - `run_depreciation = { asset, period }` as an event: the asset's run for `period` (`"YYYY-MM"`), which posts what the schedule says is due through the month's end and the books don't carry, dated then: a diff --git a/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml b/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml new file mode 100644 index 0000000..2cc9b85 --- /dev/null +++ b/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml @@ -0,0 +1,182 @@ +# An invoice's or bill's lines count toward the projects they name. +# +# wanderbooks: rules.md B-37 item 8 (journal feature +# `document_line_projects`); regression tests in ledger-core +# `tests/spec_document_projects.rs`, ledger-store +# `tests/document_projects.rs`, ledger-mcp `tests/spec_projects.rs` +# (`invoice_and_bill_lines_count_toward_their_projects`). +# +# Expected values worked by hand. Invoice 1 bills 3,000.00 + 500.00 of +# Zeta's design (3,500.00), 200.00 of Harbor's hosting and an untagged +# 100.00 rush fee: 3,800.00, all Revenue (4000), the receivable untagged. +# Bill 1 is 1,200.00 of Zeta's illustration and 50.00 of untagged +# postage: 1,250.00 of General Expenses (6999). Bill 2, 300.00, names no +# project. The customer's payment and the bill's payment move cash and +# the receivable or payable only: no project figure. So Zeta nets +# 3,500.00 - 1,200.00 = 2,300.00; Harbor 200.00 (both are client work, +# so neither has an unrecovered cost: on spec, a hoped-for payment is no +# receivable, B-37 item 4); the untagged lines 100.00 - 50.00 - 300.00 +# - 1,500.00 (rent) = -1,750.00; together 750.00, the P&L's net +# (3,800.00 - 1,550.00 - 1,500.00). Dana was paid 1,250.00 from the bank, +# whatever the bill's projects; the second bill is still owed. + +id = "projects-invoice-and-bill-lines-count-by-project" +title = "An invoice's or bill's lines count toward the projects they name" +rule = "Each line of an invoice or bill MAY name a project; its revenue (an invoice's) or cost (a bill's) MUST then count toward that project's figures exactly as a tagged line of a hand-built entry would, the receivable, the payable and their payments MUST count toward none, and every figure that is not a project's (the P&L, a payee's 1099 payments) MUST be what it would be with no project named." +status = "CONVENTION" +sources = [ + "Job-order costing: a job cost sheet accumulates the revenue and the direct costs identified with one job, a supplier's invoice and the job's billing among them (management-accounting convention; wanderbooks docs/spec/rules.md B-37 items 3 and 8)", + "wanderbooks docs/spec/rules.md B-11 and B-14: a period's net income is its revenue less its expenses", + "Treas. Reg. §1.6041-1(a)(1)(iv) (docs/spec/sources/irs-1099-payment-method.md): a 1099 counts payments made, not how the books classify them", +] +tags = ["projects", "classes", "job-costing", "accrual", "invoices", "bills"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" +[[setup.accounts]] +code = "1200" +name = "Accounts Receivable" +type = "asset" +[[setup.accounts]] +code = "2000" +name = "Accounts Payable" +type = "liability" +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" +[[setup.accounts]] +code = "6200" +name = "Rent" +type = "expense" +[[setup.accounts]] +code = "6999" +name = "General Expenses" +type = "expense" + +[[setup.projects]] +name = "Zeta" +kind = "client" +[[setup.projects]] +name = "Harbor" +kind = "client" + +[[setup.payees]] +name = "Dana Illustration" +form = "1099-NEC" + +[[events]] +date = "2026-03-02" +description = "Invoice to Cedar" +invoice = { customer = "Cedar Client", lines = [ + { description = "Design", amount = "3000.00", project = "Zeta" }, + { description = "Hosting", amount = "200.00", project = "Harbor" }, + { description = "Rush fee", amount = "100.00" }, + { description = "Revisions", amount = "500.00", project = "Zeta" }, +] } + +[[events]] +date = "2026-03-09" +description = "Dana's bill" +bill = { vendor = "Dana Illustration", lines = [ + { description = "Illustration", amount = "1200.00", project = "Zeta" }, + { description = "Postage", amount = "50.00" }, +] } + +[[events]] +date = "2026-03-20" +description = "Cedar pays invoice 1" +lines = [ + { account = "1000", side = "debit", amount = "3800.00" }, + { account = "1200", side = "credit", amount = "3800.00" }, +] + +[[events]] +date = "2026-03-30" +description = "Pay Dana's bill" +payee = "Dana Illustration" +lines = [ + { account = "2000", side = "debit", amount = "1250.00" }, + { account = "1000", side = "credit", amount = "1250.00" }, +] + +[[events]] +date = "2026-03-31" +description = "Studio rent" +lines = [ + { account = "6200", side = "debit", amount = "1500.00" }, + { account = "1000", side = "credit", amount = "1500.00" }, +] + +[[events]] +date = "2026-04-02" +description = "Dana's second bill" +bill = { vendor = "Dana Illustration", amount = "300.00" } + +[[expect]] +query = "entry" +entry = 1 +[expect.values] +"1200.debit" = "3800.00" +"4000.credit" = "3800.00" + +[[expect]] +query = "entry" +entry = 2 +[expect.values] +"6999.debit" = "1250.00" +"2000.credit" = "1250.00" + +[[expect]] +query = "project" +project = "Zeta" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +revenue = "3500.00" +expenses = "1200.00" +net = "2300.00" +"to_date.revenue" = "3500.00" +"to_date.expenses" = "1200.00" +"to_date.net" = "2300.00" +unrecovered_cost = "none" + +[[expect]] +query = "project" +project = "Harbor" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +revenue = "200.00" +expenses = "0.00" +net = "200.00" +"to_date.revenue" = "200.00" +"to_date.expenses" = "0.00" +"to_date.net" = "200.00" +unrecovered_cost = "none" + +[[expect]] +query = "profit_and_loss" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +"4000" = "3800.00" +"6200" = "1500.00" +"6999" = "1550.00" +net_income = "750.00" + +[[expect]] +query = "vendor_1099_paid" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +"Dana Illustration" = "1250.00" diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index 5065765..f00056a 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -643,7 +643,8 @@ fn copy_private(from: &Path, to: &Path) { /// database is never written; what P2 can read is written, and every /// write P2 would refuse or misread is refused, journaling nothing /// (a setting key, a tax category, a depreciation catch-up, a members -/// close counted from the count's start). +/// close counted from the count's start, an invoice's or bill's line +/// naming its project). /// 2. The consent backup holds the books exactly as they were, keys and /// vault too; the upgrade is one line after P2's, byte for byte. /// 3. After it: the same figures, and every refused write is made; the @@ -699,8 +700,23 @@ fn books_p2_served_cut_over_whole() { ) .unwrap()["period_id"] .clone(); + // A project by name alone is a plain class, which P2 reads. + a.call("books.add_project", json!({"name": "Lighthouse"})) + .unwrap(); let waiting = journal(&dir); let refused: Vec<(&str, Value)> = vec![ + // An invoice's or bill's line naming its project + // (document_line_projects, rules.md B-37 item 8). + ( + "books.create_invoice", + json!({"party_id": 1, "date": "2026-02-10", "due_date": "2026-03-12", + "lines": [{"description": "Design", "amount": "500.00", "class": "Lighthouse"}]}), + ), + ( + "books.create_bill", + json!({"party_id": 1, "date": "2026-02-11", "due_date": "2026-03-13", + "lines": [{"description": "Drafting", "amount": "120.00", "class": "Lighthouse"}]}), + ), ( "books.set_setting", json!({"decided_by": "Avery Quill", diff --git a/crates/ledger-cli/tests/projects_parity.rs b/crates/ledger-cli/tests/projects_parity.rs index eb31361..7ed9b32 100644 --- a/crates/ledger-cli/tests/projects_parity.rs +++ b/crates/ledger-cli/tests/projects_parity.rs @@ -257,3 +257,140 @@ fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() { ); assert_eq!(period["net"], "-722.22", "a refund and costs, no sale"); } + +/// rules.md B-37 item 8: a project's invoiced revenue and billed costs, +/// made through the books server's own tools, read the same in the viewer +/// (its snapshot, and the invoice's and bill's pages: each line's +/// project) and from the server (books.project, books.get_entry). +#[test] +fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server() { + let dir = tempfile::Builder::new() + .prefix("wb-projects-parity-docs-") + .tempdir() + .unwrap(); + let data = dir.path().join("books-data"); + std::fs::write( + dir.path().join("wanderbooks.toml"), + "[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", + ) + .unwrap(); + let today = d(2026, 10, 15); + { + let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap(); + st.add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + } + let mut server = Server::start(&data); + server.tool( + "books.add_project", + json!({"name": "Kestrel", "kind": "client", "started_on": "2026-01-05"}), + ); + server.tool("books.add_project", json!({"name": "Heron"})); + let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone(); + let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone(); + let doc = |party: &Value, date: &str, lines: Value| json!({"party_id": party, "date": date, "due_date": date, "lines": lines}); + let inv = server.tool( + "books.create_invoice", + doc( + &cedar, + "2026-02-02", + json!([ + {"description": "Discovery", "amount": "1500.00", "class": "Kestrel"}, + {"description": "Heron sketches", "amount": "250.00", "class": "Heron"}, + {"description": "Travel", "amount": "80.00"}, + ]), + ), + ); + let inv2 = server.tool( + "books.create_invoice", + doc( + &cedar, + "2026-05-04", + json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]), + ), + ); + let bill = server.tool( + "books.create_bill", + doc( + &dana, + "2026-03-09", + json!([ + {"description": "Illustration", "amount": "987.65", "class": "Kestrel"}, + {"description": "Postage", "amount": "12.00"}, + ]), + ), + ); + server.tool( + "books.receive_payment", + json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}), + ); + let b = data::load_once(&Source { + data_dir: data.clone(), + today: Some(today), + }) + .unwrap(); + let id = b.ledger.find_class_by_name("Kestrel").unwrap().id; + let page = page_figures(&b, id, Some(d(2026, 1, 5)), false); + let t = page.to_date.as_ref().unwrap(); + let snap = Snap::to_date(t); + let p = server.tool( + "books.project", + json!({"project": "Kestrel", "start": "2026-01-01", "end": today.to_string()}), + ); + let to = &p["to_date"]; + assert_eq!(to["revenue"], s(snap.revenue)); + assert_eq!(to["expenses"], s(snap.expenses)); + assert_eq!(to["net"], s(snap.contribution)); + assert_eq!( + to["margin_pct"], + snap.margin_pct + .map_or(Value::Null, |m| json!(m.to_string())) + ); + // By hand: 1,500.00 + 4,321.09 invoiced, 987.65 billed. + assert_eq!( + ( + to["revenue"].as_str(), + to["expenses"].as_str(), + to["net"].as_str() + ), + (Some("5821.09"), Some("987.65"), Some("4833.44")) + ); + let ((_, _), months) = page.months.as_ref().unwrap(); + let by_month = p["period"]["by_month"].as_array().unwrap(); + assert!(!by_month.is_empty(), "{p}"); + assert_eq!(by_month.len(), months.months.len()); + for (m, ours) in by_month.iter().zip(&months.months) { + assert_eq!(m["month"], ours.start.format("%Y-%m").to_string()); + assert_eq!(m["revenue"], s(ours.revenue), "{m}"); + assert_eq!(m["expenses"], s(ours.expenses), "{m}"); + } + // Each document's lines: the viewer's page names the project the + // server's entry line carries. + for out in [&inv, &inv2, &bill] { + let entry = out["entry_id"].as_u64().unwrap(); + let doc = ledger_gui::pages::entry::document_of(&b, entry).unwrap(); + let viewer: Vec> = { + let mut seen: Vec> = Vec::new(); + for l in doc.lines { + let name = l + .class_id + .and_then(|c| b.ledger.get_class(c)) + .map(|c| c.name.clone()); + if !seen.contains(&name) { + seen.push(name); + } + } + seen + }; + let served = server.tool("books.get_entry", json!({"entry_id": entry})); + let served: Vec> = served["lines"] + .as_array() + .unwrap() + .iter() + .filter(|l| l["code"] == "4000" || l["code"] == "6999") + .map(|l| l["project"].as_str().map(str::to_string)) + .collect(); + assert_eq!(viewer, served, "entry {entry}"); + } +} diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index 52ac1b5..f00c018 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -89,6 +89,13 @@ pub struct DocumentLine { pub description: String, /// Line amount (positive). pub amount: Decimal, + /// The project (a class) the line's revenue or cost belongs to, if + /// any (rules.md B-37): the posted entry's revenue or expense line for + /// it carries the tag; the receivable or payable line never does. + /// Documents journaled before the `document_line_projects` feature + /// have none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class_id: Option, } /// A sales invoice: we billed a customer. @@ -123,6 +130,44 @@ pub fn document_total(lines: &[DocumentLine], what: &str) -> Result Result, Decimal)>, LedgerError> { + let mut keys: Vec> = Vec::new(); + for l in lines { + if !keys.contains(&l.class_id) { + keys.push(l.class_id); + } + } + keys.into_iter() + .map(|k| { + let part: Vec = + lines.iter().filter(|l| l.class_id == k).cloned().collect(); + Ok((k, document_total(&part, what)?)) + }) + .collect() +} + /// What is still owed on a document: its total less what was paid. fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result { total @@ -350,6 +395,7 @@ mod tests { vec![DocumentLine { description: "Widgets".to_string(), amount: "1000.00".parse().unwrap(), + class_id: None, }], ) .unwrap(); @@ -399,6 +445,7 @@ mod tests { vec![DocumentLine { description: "Paper".to_string(), amount: "250.00".parse().unwrap(), + class_id: None, }], ) .unwrap(); @@ -440,6 +487,7 @@ mod tests { vec![DocumentLine { description: "X".to_string(), amount: "100.00".parse().unwrap(), + class_id: None, }], ) .unwrap(); @@ -517,6 +565,7 @@ mod tests { vec![DocumentLine { description: "A".to_string(), amount: "100.00".parse().unwrap(), + class_id: None, }], ) .unwrap(); @@ -528,6 +577,7 @@ mod tests { vec![DocumentLine { description: "B".to_string(), amount: "200.00".parse().unwrap(), + class_id: None, }], ) .unwrap(); @@ -557,6 +607,7 @@ mod tests { vec![DocumentLine { description: "X".to_string(), amount: "100.00".parse().unwrap(), + class_id: None, }], ); assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _)))); diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index c02f851..e84b3c3 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -4900,6 +4900,30 @@ impl Ledger { self.add_control_account(c).expect("fresh code cannot fail") } + /// A document's revenue or expense side on its draft `entry`: one line + /// on `account` per part ([`arap::document_parts`]), each tagged with + /// its project (a project the books don't have: UnknownClass, and the + /// caller's `atomically` leaves the books unchanged). + fn add_document_parts( + &mut self, + entry: EntryId, + account: AccountId, + side: Side, + parts: &[(Option, Decimal)], + ) -> Result<(), LedgerError> { + for &(class, amount) in parts { + self.add_line(entry, account, side, Money::new(amount, self.base_currency))?; + if class.is_some() { + let last = self + .get_entry(entry) + .map_or(0, |e| e.lines().len()) + .saturating_sub(1); + self.set_line_class(entry, last, class)?; + } + } + Ok(()) + } + /// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable, /// Cr `4000` Revenue. The invoice date must not fall in a closed period. /// @@ -4910,13 +4934,17 @@ impl Ledger { /// control accounts: Ledger::check_controls. /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 1200 receivables, Cr 4000 revenue, - /// for the sum of the lines, dated the invoice date. + /// for the sum of the lines, dated the invoice date: one revenue + /// line per project the lines name, the untagged lines' as one + /// (arap::document_parts), each tagged with its project; the + /// receivable line never (rules.md B-37 item 8). /// 2. The invoice MUST start open, with nothing paid. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the customer is unknown, a line amount isn't positive, the due - /// date is before the invoice date, or the date is in a closed - /// period: SHOULD refuse. + /// date is before the invoice date, a line names a project the + /// books don't have, or the date is in a closed period: SHOULD + /// refuse. /// ``` pub fn create_invoice( &mut self, @@ -4945,13 +4973,18 @@ impl Ledger { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let parts = arap::document_parts(&lines, "the invoice")?; self.check_controls(&[RECEIVABLES, REVENUE])?; let ar = self.ensure_control(&RECEIVABLES); let revenue = self.ensure_control(&REVENUE); let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id)); - let money = Money::new(total, self.base_currency); - self.add_line(entry, ar, Side::Debit, money)?; - self.add_line(entry, revenue, Side::Credit, money)?; + self.add_line( + entry, + ar, + Side::Debit, + Money::new(total, self.base_currency), + )?; + self.add_document_parts(entry, revenue, Side::Credit, &parts)?; self.post_entry(entry)?; let id = self.next_invoice_id; self.next_invoice_id += 1; @@ -4983,12 +5016,15 @@ impl Ledger { /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000 /// payables, for the sum of the lines, dated the bill date, tagged - /// with the vendor (1099s). + /// with the vendor (1099s): one expense line per project the lines + /// name, the untagged lines' as one (arap::document_parts), each + /// tagged with its project; the payable line never (rules.md B-37 + /// item 8). /// 2. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the vendor is unknown, a line amount isn't positive, the due date - /// is before the bill date, or the date is in a closed period: SHOULD - /// refuse. + /// is before the bill date, a line names a project the books don't + /// have, or the date is in a closed period: SHOULD refuse. /// ``` pub fn create_bill( &mut self, @@ -5017,13 +5053,18 @@ impl Ledger { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let parts = arap::document_parts(&lines, "the bill")?; self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?; let expense = self.ensure_control(&GENERAL_EXPENSES); let ap = self.ensure_control(&PAYABLES); let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id)); - let money = Money::new(total, self.base_currency); - self.add_line(entry, expense, Side::Debit, money)?; - self.add_line(entry, ap, Side::Credit, money)?; + self.add_document_parts(entry, expense, Side::Debit, &parts)?; + self.add_line( + entry, + ap, + Side::Credit, + Money::new(total, self.base_currency), + )?; self.post_entry(entry)?; let id = self.next_bill_id; self.next_bill_id += 1; diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index 53bba77..eb2483f 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -358,6 +358,8 @@ struct Event { #[serde(default)] bill: Option, #[serde(default)] + invoice: Option, + #[serde(default)] run_depreciation: Option, #[serde(default)] dispose_asset: Option, @@ -411,15 +413,48 @@ struct RunSchedulesSpec { through: String, } -/// 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). +/// A bill from `vendor`, dated and due on the event's date, for `amount` +/// (one line, the event's description) or its `lines`, exactly one of +/// them: 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); a line naming a project puts +/// its cost on that project's expense line (B-37 item 8). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct BillSpec { vendor: String, + #[serde(default)] + amount: Option, + #[serde(default)] + lines: Vec, +} + +/// An invoice to `customer` (a name; added when first named), dated and +/// due on the event's date, for `amount` or its `lines`, exactly one of +/// them: Dr Accounts Receivable (1200), Cr Revenue (4000), each created +/// with its fixed name when first needed (B-32); a line naming a project +/// puts its revenue on that project's revenue line (B-37 item 8). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct InvoiceSpec { + customer: String, + #[serde(default)] + amount: Option, + #[serde(default)] + lines: Vec, +} + +/// One line of an invoice or a bill: its amount, what it is (the event's +/// description when left out) and the project (a `setup.projects` name) +/// it belongs to. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DocumentLineSpec { amount: String, + #[serde(default)] + description: Option, + #[serde(default)] + project: Option, } /// A change to the books that posts no entry, made right after event @@ -731,6 +766,8 @@ struct Books<'a> { payees: BTreeMap, /// The vendors bills came from, by name (a payee's record if one). vendors: BTreeMap, + /// The customers invoices went to, by name. + customers: BTreeMap, /// The payment methods, by label. instruments: BTreeMap, /// The fixed assets of `setup.assets`, by id. @@ -1181,6 +1218,7 @@ impl<'a> Books<'a> { party_members, parties, vendors: payees.clone(), + customers: BTreeMap::new(), payees, instruments, assets, @@ -1241,6 +1279,7 @@ impl<'a> Books<'a> { ("reverses", e.reverses.is_some()), ("close", e.close.is_some()), ("bill", e.bill.is_some()), + ("invoice", e.invoice.is_some()), ("run_depreciation", e.run_depreciation.is_some()), ("dispose_asset", e.dispose_asset.is_some()), ("run_schedules", e.run_schedules.is_some()), @@ -1249,13 +1288,13 @@ impl<'a> Books<'a> { let kind = match kinds.iter().filter(|(_, given)| *given).collect::>()[..] { [(kind, _)] => *kind, _ => panic!( - "{ex} event {n}: an event is one of lines, reverses, close, bill, run_depreciation, dispose_asset, run_schedules or register_schedule" + "{ex} event {n}: an event is one of lines, reverses, close, bill, invoice, run_depreciation, dispose_asset, run_schedules or register_schedule" ), }; // An entry of the example's own words: dated and described. A // disposal is dated; the other commands date their own entries, // and every command describes its own. - let own = matches!(kind, "lines" | "reverses" | "close" | "bill"); + let own = matches!(kind, "lines" | "reverses" | "close" | "bill" | "invoice"); assert_eq!( e.date.is_some(), own || kind == "dispose_asset", @@ -1278,7 +1317,7 @@ impl<'a> Books<'a> { ); assert!( e.payee.is_none() || kind == "lines", - "{ex} event {n}: a payee is an entry's: a reversal takes its original's, a bill names its vendor, a close and a command have none" + "{ex} event {n}: a payee is an entry's: a reversal takes its original's, a bill names its vendor, an invoice its customer, a close and a command have none" ); assert!( (e.parties.is_empty() && e.instrument.is_none()) || kind != "reverses", @@ -1289,7 +1328,7 @@ impl<'a> Books<'a> { "{ex} event {n}: {kind} is tagged with no parties or payment method" ); assert!( - e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill"), + e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill" | "invoice"), "{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all" ); let what = format!("{ex} event {n}"); @@ -1376,6 +1415,43 @@ impl<'a> Books<'a> { } } + /// An invoice's or bill's lines: `amount` (one line, described as the + /// event) or `lines`, exactly one; each project a `setup.projects` + /// name. + fn document_lines( + &self, + amount: &Option, + lines: &[DocumentLineSpec], + description: &str, + what: &str, + ) -> Vec { + let positive = |s: &str| { + let a = exact(s, what); + assert!(a > Decimal::ZERO, "{what}: amounts are positive"); + a + }; + match (amount, lines) { + (Some(a), []) => vec![DocumentLine { + description: description.to_string(), + amount: positive(a), + class_id: None, + }], + (None, [_, ..]) => lines + .iter() + .map(|l| DocumentLine { + description: l.description.clone().unwrap_or_else(|| description.into()), + amount: positive(&l.amount), + class_id: l.project.as_ref().map(|p| { + *self.projects.get(p).unwrap_or_else(|| { + panic!("{what}: project {p:?} not in setup.projects") + }) + }), + }) + .collect(), + _ => panic!("{what}: a document has an amount or lines, exactly one"), + } + } + /// Event `n`'s command, given to the books (`kind`, as [`Self::apply`] /// found it): whatever it posts is posted, or the books refuse it. fn take( @@ -1459,7 +1535,7 @@ impl<'a> Books<'a> { } "bill" => { let b = e.bill.as_ref().expect("checked: a bill"); - let amount = money(&b.amount); + let lines = self.document_lines(&b.amount, &b.lines, description, what); let vendor = match self.vendors.get(&b.vendor) { Some(&v) => v, None => { @@ -1468,14 +1544,25 @@ impl<'a> Books<'a> { v } }; - let lines = vec![DocumentLine { - description: description.to_string(), - amount, - }]; self.store .create_bill(vendor, day(), day(), lines) .map(|_| ()) } + "invoice" => { + let i = e.invoice.as_ref().expect("checked: an invoice"); + let lines = self.document_lines(&i.amount, &i.lines, description, what); + let customer = match self.customers.get(&i.customer) { + Some(&c) => c, + None => { + let c = self.store.add_customer(&i.customer)?; + self.customers.insert(i.customer.clone(), c); + c + } + }; + self.store + .create_invoice(customer, day(), day(), lines) + .map(|_| ()) + } "run_depreciation" => { let r = e.run_depreciation.as_ref().expect("checked: a run"); let asset = self.asset(&r.asset, what); @@ -3751,3 +3838,46 @@ fn a_project_query_leaving_a_value_out_is_refused() { t.replacen("unrecovered_cost = \"1460.00\"\n", "", 1) }); } + +/// An invoice's line naming a project the setup doesn't have is refused. +#[test] +#[should_panic(expected = "project \"Gamma\" not in setup.projects")] +fn a_document_line_naming_an_unknown_project_is_refused() { + run_edited( + "projects-invoice-and-bill-lines-count-by-project.toml", + |t| t.replacen("project = \"Harbor\" }", "project = \"Gamma\" }", 1), + ); +} + +/// A document has an amount or lines, exactly one. +#[test] +#[should_panic(expected = "a document has an amount or lines, exactly one")] +fn a_document_with_an_amount_and_lines_is_refused() { + run_edited( + "projects-invoice-and-bill-lines-count-by-project.toml", + |t| { + t.replacen( + "invoice = { customer = \"Cedar Client\", lines", + "invoice = { customer = \"Cedar Client\", amount = \"1.00\", lines", + 1, + ) + }, + ); +} + +/// An invoice's projects count: the example fails when a line's project +/// moves (Zeta's figures are no longer the ones worked by hand). +#[test] +#[should_panic(expected = "projects-invoice-and-bill-lines-count-by-project project \"Zeta\"")] +fn an_invoice_lines_project_is_what_the_project_counts() { + run_edited( + "projects-invoice-and-bill-lines-count-by-project.toml", + |t| { + t.replacen( + "{ description = \"Revisions\", amount = \"500.00\", project = \"Zeta\" }", + "{ description = \"Revisions\", amount = \"500.00\", project = \"Harbor\" }", + 1, + ) + }, + ); +} diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index 8a31f35..d381978 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -154,6 +154,7 @@ fn books() -> Ledger { vec![DocumentLine { description: "Design work".into(), amount: dec("1500"), + class_id: None, }], ) .unwrap(); diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index fc94bf9..fe6ea9f 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -978,6 +978,7 @@ fn doc(amount: u64) -> Vec { vec![ledger_core::DocumentLine { description: "work".into(), amount: Decimal::from(amount), + class_id: None, }] } diff --git a/crates/ledger-core/tests/spec_arap.rs b/crates/ledger-core/tests/spec_arap.rs index fc9ca99..093497c 100644 --- a/crates/ledger-core/tests/spec_arap.rs +++ b/crates/ledger-core/tests/spec_arap.rs @@ -36,6 +36,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine { DocumentLine { description: desc.to_string(), amount: dec(amount), + class_id: None, } } @@ -380,7 +381,7 @@ proptest! { ) { let lines: Vec = amts .iter() - .map(|c| DocumentLine { description: "l".into(), amount: cents(*c) }) + .map(|c| DocumentLine { description: "l".into(), amount: cents(*c), class_id: None }) .collect(); let ok = !amts.is_empty() && amts.iter().all(|c| *c > 0); prop_assert_eq!(validate_lines(&lines).is_ok(), ok); @@ -1083,6 +1084,7 @@ fn doc_lines(doc: &Doc) -> Vec { .map(|c| DocumentLine { description: "item".into(), amount: cents(*c), + class_id: None, }) .collect() } diff --git a/crates/ledger-core/tests/spec_bounds.rs b/crates/ledger-core/tests/spec_bounds.rs index d626f22..9394644 100644 --- a/crates/ledger-core/tests/spec_bounds.rs +++ b/crates/ledger-core/tests/spec_bounds.rs @@ -141,6 +141,7 @@ fn dates_outside_the_calendar_are_refused_everywhere() { let line = vec![DocumentLine { description: "work".into(), amount: Decimal::ONE, + class_id: None, }]; assert_eq!( b.ledger @@ -219,6 +220,7 @@ fn replayed_dates_outside_the_calendar_are_accepted() { let line = vec![DocumentLine { description: "work".into(), amount: Decimal::ONE, + class_id: None, }]; b.ledger .replay_invoice(1, customer, NaiveDate::MIN, NaiveDate::MAX, line.clone(), 1) @@ -326,10 +328,12 @@ fn amounts_larger_than_a_line_are_refused_everywhere() { DocumentLine { description: "a".into(), amount: a, + class_id: None, }, DocumentLine { description: "b".into(), amount: Decimal::ONE, + class_id: None, }, ] }; @@ -617,6 +621,7 @@ fn replayed_payments_are_as_journaled() { vec![DocumentLine { description: "work".into(), amount: Decimal::new(10000, 2), + class_id: None, }], ) .unwrap(); diff --git a/crates/ledger-core/tests/spec_cash_flow.rs b/crates/ledger-core/tests/spec_cash_flow.rs index c79654d..a19fe7f 100644 --- a/crates/ledger-core/tests/spec_cash_flow.rs +++ b/crates/ledger-core/tests/spec_cash_flow.rs @@ -2085,6 +2085,7 @@ fn a_bill_payment_carries_its_bills_split() { vec![ledger_core::DocumentLine { description: "Equipment and supplies".into(), amount: x("1700.00"), + class_id: None, }], entry, ) @@ -2509,6 +2510,7 @@ fn a_reversed_bill_payment_leaves_the_purchase_unpaid() { vec![ledger_core::DocumentLine { description: "Equipment".into(), amount: x("1000.00"), + class_id: None, }], entry, ) diff --git a/crates/ledger-core/tests/spec_document_projects.proptest-regressions b/crates/ledger-core/tests/spec_document_projects.proptest-regressions new file mode 100644 index 0000000..ce4317d --- /dev/null +++ b/crates/ledger-core/tests/spec_document_projects.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc cdb5206c6d8016c327396d8037b466e9bccd1d04e86be907372108cbbf668f88 # shrinks to raw = [(1, Some(0)), (1, Some(0))] diff --git a/crates/ledger-core/tests/spec_document_projects.rs b/crates/ledger-core/tests/spec_document_projects.rs new file mode 100644 index 0000000..fae39b8 --- /dev/null +++ b/crates/ledger-core/tests/spec_document_projects.rs @@ -0,0 +1,465 @@ +//! An invoice's or bill's lines name their projects (rules.md B-37 item +//! 8): the posted entry's revenue or expense side is one line per project, +//! the untagged lines' as one; the receivable or payable line, and the +//! payments, never take one. +//! +//! ```text +//! GOAL: a client project's invoiced revenue and a contractor's billed +//! cost count toward the project, every other figure as before. +//! SOURCE: docs/spec/rules.md B-37 items 3 and 8 (job costing), B-14 (the +//! projects with the untagged lines partition the P&L), B-19 and +//! B-35 (1099 and party figures follow the money, not the tags). +//! ``` + +use chrono::NaiveDate; +use ledger_core::arap::{DocumentLine, document_parts}; +use ledger_core::form1099::{PaymentAccounts, vendor_1099_report}; +use ledger_core::parties::{MoneyAccounts, activity}; +use ledger_core::reports::profit_and_loss_by_class; +use ledger_core::{ + AccountType, ClassId, Currency, Ledger, LedgerError, ProjectFacts, ProjectStatus, Side, + project_entries, project_to_date, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn line(what: &str, c: i64, class: Option) -> DocumentLine { + DocumentLine { + description: what.into(), + amount: cents(c), + class_id: class, + } +} + +/// Cash 1000 and two projects, Zeta (client) and Harbor. +struct Books { + l: Ledger, + cash: u64, + zeta: ClassId, + harbor: ClassId, +} + +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let zeta = l.add_class("Zeta").unwrap(); + let harbor = l.add_class("Harbor").unwrap(); + Books { + l, + cash, + zeta, + harbor, + } +} + +/// Each line of `entry`: (code, side, amount, class). +fn lines_of(l: &Ledger, entry: u64) -> Vec<(String, Side, Decimal, Option)> { + l.get_entry(entry) + .unwrap() + .lines() + .iter() + .map(|x| { + ( + l.chart().get(x.account).unwrap().code.clone(), + x.side, + x.amount.amount, + x.class_id, + ) + }) + .collect() +} + +#[test] +fn an_invoices_revenue_is_one_line_per_project_and_the_receivable_none() { + let mut b = books(); + let (z, h) = (b.zeta, b.harbor); + let c = b.l.add_customer("Cedar").unwrap(); + let inv = + b.l.create_invoice( + c, + d(2026, 3, 2), + d(2026, 4, 1), + vec![ + line("Design", 300_000, Some(z)), + line("Hosting", 20_000, Some(h)), + line("Rush fee", 10_000, None), + line("Revisions", 50_000, Some(z)), + ], + ) + .unwrap(); + let e = b.l.get_invoice(inv).unwrap().entry_id; + assert_eq!( + lines_of(&b.l, e), + vec![ + ("1200".into(), Side::Debit, cents(380_000), None), + ("4000".into(), Side::Credit, cents(350_000), Some(z)), + ("4000".into(), Side::Credit, cents(20_000), Some(h)), + ("4000".into(), Side::Credit, cents(10_000), None), + ] + ); + // The invoice keeps its lines as given, projects and all. + let kept = &b.l.get_invoice(inv).unwrap().lines; + assert_eq!(kept[3].class_id, Some(z)); + assert_eq!( + b.l.get_invoice(inv).unwrap().total().unwrap(), + cents(380_000) + ); + // Its revenue counts toward each project, to the cent. + let zt = project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(); + assert_eq!((zt.revenue, zt.expenses), (cents(350_000), Decimal::ZERO)); + assert_eq!( + project_to_date(&b.l, h, d(2026, 12, 31)).unwrap().revenue, + cents(20_000) + ); + assert_eq!(project_entries(&b.l, z), vec![(e, cents(350_000))]); + // The payment is balance-sheet only: no project, no project figure. + let paid = + b.l.receive_payment(inv, d(2026, 3, 20), cents(380_000), b.cash) + .unwrap(); + assert!(lines_of(&b.l, paid).iter().all(|l| l.3.is_none())); + assert_eq!(project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(), zt); +} + +#[test] +fn a_bills_cost_is_one_line_per_project_and_the_payable_none() { + let mut b = books(); + let (z, h) = (b.zeta, b.harbor); + let v = b.l.add_vendor("Dana Illustration").unwrap(); + let bill = + b.l.create_bill( + v, + d(2026, 3, 9), + d(2026, 4, 8), + vec![ + line("Office supplies", 5_000, None), + line("Illustration", 120_000, Some(z)), + line("Icons", 30_000, Some(h)), + ], + ) + .unwrap(); + let e = b.l.get_bill(bill).unwrap().entry_id; + assert_eq!( + lines_of(&b.l, e), + vec![ + ("6999".into(), Side::Debit, cents(5_000), None), + ("6999".into(), Side::Debit, cents(120_000), Some(z)), + ("6999".into(), Side::Debit, cents(30_000), Some(h)), + ("2000".into(), Side::Credit, cents(155_000), None), + ] + ); + let zt = project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(); + assert_eq!((zt.revenue, zt.expenses), (Decimal::ZERO, cents(120_000))); + let paid = + b.l.pay_bill(bill, d(2026, 3, 30), cents(155_000), b.cash) + .unwrap(); + assert!(lines_of(&b.l, paid).iter().all(|l| l.3.is_none())); + assert_eq!(project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(), zt); +} + +/// Reversing a tagged invoice's or bill's entry carries each line's +/// project to the reversal (B-37 item 3: each line on its normal side), so +/// every project nets back to zero; a partial payment, cash against the +/// receivable, moves no project figure. +#[test] +fn reversing_a_tagged_document_takes_its_projects_to_zero() { + let mut b = books(); + let (z, h) = (b.zeta, b.harbor); + let c = b.l.add_customer("Cedar").unwrap(); + let v = b.l.add_vendor("Dana").unwrap(); + let inv = + b.l.create_invoice( + c, + d(2026, 3, 2), + d(2026, 4, 1), + vec![ + line("Design", 300_000, Some(z)), + line("Hosting", 20_000, Some(h)), + line("Fee", 10_000, None), + ], + ) + .unwrap(); + let bill = + b.l.create_bill( + v, + d(2026, 3, 9), + d(2026, 4, 8), + vec![line("Illustration", 120_000, Some(z))], + ) + .unwrap(); + let to_date = |l: &Ledger, p| { + let t = project_to_date(l, p, d(2026, 12, 31)).unwrap(); + (t.revenue, t.expenses) + }; + assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000))); + b.l.receive_payment(inv, d(2026, 3, 20), cents(100_000), b.cash) + .unwrap(); + assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000))); + assert_eq!(to_date(&b.l, h), (cents(20_000), Decimal::ZERO)); + for e in [ + b.l.get_invoice(inv).unwrap().entry_id, + b.l.get_bill(bill).unwrap().entry_id, + ] { + let r = b.l.reverse_entry(e, d(2026, 3, 31), "Reverse").unwrap(); + let original: Vec<_> = lines_of(&b.l, e).into_iter().map(|l| l.3).collect(); + let reversed: Vec<_> = lines_of(&b.l, r).into_iter().map(|l| l.3).collect(); + assert_eq!(reversed, original, "entry {e}"); + } + // Each line on its normal side: a reversed revenue line is a debit to + // revenue, so the project's revenue and expenses are both zero. + assert_eq!(to_date(&b.l, z), (Decimal::ZERO, Decimal::ZERO)); + assert_eq!(to_date(&b.l, h), (Decimal::ZERO, Decimal::ZERO)); + let by_class = profit_and_loss_by_class(&b.l).unwrap(); + assert!(!by_class.is_empty()); + for r in &by_class { + assert_eq!(r.profit_and_loss.net_income(), Decimal::ZERO, "{r:?}"); + } +} + +/// A document naming no project posts exactly what every document posted +/// before projects: one revenue (or expense) line for its total. +#[test] +fn an_untagged_document_posts_as_before() { + let mut b = books(); + let c = b.l.add_customer("Cedar").unwrap(); + let v = b.l.add_vendor("Dana").unwrap(); + let inv = + b.l.create_invoice( + c, + d(2026, 1, 5), + d(2026, 2, 4), + vec![line("A", 10_000, None), line("B", 2_550, None)], + ) + .unwrap(); + let bill = + b.l.create_bill(v, d(2026, 1, 6), d(2026, 2, 5), vec![line("C", 999, None)]) + .unwrap(); + assert_eq!( + lines_of(&b.l, b.l.get_invoice(inv).unwrap().entry_id), + vec![ + ("1200".into(), Side::Debit, cents(12_550), None), + ("4000".into(), Side::Credit, cents(12_550), None), + ] + ); + assert_eq!( + lines_of(&b.l, b.l.get_bill(bill).unwrap().entry_id), + vec![ + ("6999".into(), Side::Debit, cents(999), None), + ("2000".into(), Side::Credit, cents(999), None), + ] + ); +} + +#[test] +fn a_line_naming_an_unknown_project_is_refused_whole() { + let mut b = books(); + let c = b.l.add_customer("Cedar").unwrap(); + let v = b.l.add_vendor("Dana").unwrap(); + let before = b.l.clone(); + assert_eq!( + b.l.create_invoice( + c, + d(2026, 1, 5), + d(2026, 2, 4), + vec![line("A", 100, Some(b.zeta)), line("B", 100, Some(77))], + ), + Err(LedgerError::UnknownClass(77)) + ); + assert_eq!( + b.l.create_bill( + v, + d(2026, 1, 5), + d(2026, 2, 4), + vec![line("A", 100, Some(78))] + ), + Err(LedgerError::UnknownClass(78)) + ); + assert!(b.l.differs_from(&before).is_empty(), "nothing changed"); +} + +/// Finished and abandoned projects still take a document's lines (B-37 +/// item 5: a late cost, a final invoice). +#[test] +fn a_finished_project_still_takes_an_invoice() { + let mut b = books(); + b.l.set_project( + b.zeta, + "Zeta", + ProjectFacts { + status: ProjectStatus::Finished, + started_on: Some(d(2026, 1, 1)), + ended_on: Some(d(2026, 2, 28)), + ..Default::default() + }, + ) + .unwrap(); + let c = b.l.add_customer("Cedar").unwrap(); + b.l.create_invoice( + c, + d(2026, 3, 15), + d(2026, 4, 14), + vec![line("Final", 40_000, Some(b.zeta))], + ) + .unwrap(); + assert_eq!( + project_to_date(&b.l, b.zeta, d(2026, 12, 31)) + .unwrap() + .revenue, + cents(40_000) + ); +} + +/// The same books twice, one with its documents' lines tagged: every +/// figure that isn't a project's is the same (the P&L, the 1099 +/// worksheet, a party's totals), and the tagged books' projects with the +/// untagged lines partition the P&L. +#[test] +fn tags_move_only_the_project_figures() { + let build = |tagged: bool| { + let mut b = books(); + let tag = |c: ClassId| tagged.then_some(c); + let (z, h, cash) = (b.zeta, b.harbor, b.cash); + let c = b.l.add_customer("Cedar").unwrap(); + let v = b.l.add_vendor("Dana").unwrap(); + b.l.set_vendor_1099( + v, + ledger_core::form1099::Dated1099::from_beginning( + ledger_core::form1099::Decision1099::Files(ledger_core::form1099::Form1099::Nec), + None, + ), + false, + ) + .unwrap(); + let inv = + b.l.create_invoice( + c, + d(2026, 2, 1), + d(2026, 3, 3), + vec![ + line("Design", 500_000, tag(z)), + line("App", 80_000, tag(h)), + line("Fee", 1_000, None), + ], + ) + .unwrap(); + b.l.receive_payment(inv, d(2026, 2, 20), cents(581_000), cash) + .unwrap(); + let bill = + b.l.create_bill( + v, + d(2026, 2, 5), + d(2026, 3, 7), + vec![ + line("Illustration", 180_000, tag(z)), + line("Misc", 2_500, None), + ], + ) + .unwrap(); + b.l.pay_bill(bill, d(2026, 2, 25), cents(182_500), cash) + .unwrap(); + (b, c, v) + }; + let (plain, _, v) = build(false); + let (tagged, _, _) = build(true); + let year = (d(2026, 1, 1), d(2026, 12, 31)); + let pnl = |l: &Ledger| ledger_core::profit_and_loss_for_period(l, year.0, year.1, None); + assert_eq!( + format!("{:?}", pnl(&plain.l).unwrap()), + format!("{:?}", pnl(&tagged.l).unwrap()) + ); + let sources = PaymentAccounts { + cash: vec![plain.cash], + financing: vec![], + }; + let w = |l: &Ledger| { + format!( + "{:?}", + vendor_1099_report(l, 2026, None, &sources, &[], None) + ) + }; + assert_eq!(w(&plain.l), w(&tagged.l)); + assert!(w(&tagged.l).contains("1825.00"), "{}", w(&tagged.l)); + let money = MoneyAccounts { + cash: [plain.cash].into_iter().collect(), + liabilities: Default::default(), + }; + let party = plain.l.party_of_vendor(v).unwrap(); + let a = |l: &Ledger| format!("{:?}", activity(l, party, year, &money)); + assert_eq!(a(&plain.l), a(&tagged.l)); + // The tagged books: Zeta 5,000.00 - 1,800.00, Harbor 800.00, and the + // untagged lines 10.00 - 25.00; together the P&L's net. + let rows = profit_and_loss_by_class(&tagged.l).unwrap(); + let net = |name: Option<&str>| { + let r = rows + .iter() + .find(|r| r.class.as_ref().map(|c| c.name.as_str()) == name) + .unwrap(); + r.profit_and_loss.net_income() + }; + assert_eq!(net(Some("Zeta")), cents(320_000)); + assert_eq!(net(Some("Harbor")), cents(80_000)); + assert_eq!(net(None), cents(-1_500)); + let whole = pnl(&tagged.l).unwrap(); + assert_eq!(cents(320_000 + 80_000 - 1_500), whole.net_income()); + let z = project_to_date(&tagged.l, tagged.zeta, year.1).unwrap(); + assert_eq!((z.revenue, z.expenses), (cents(500_000), cents(180_000))); + let none = project_to_date(&plain.l, plain.zeta, year.1).unwrap(); + assert!(!none.any_tagged()); +} + +proptest! { + /// arap::document_parts INVARIANTS 1-3: the parts sum to the total, + /// each project once and in the order first named, the untagged lines + /// one part, and a document naming none one part. + #[test] + fn prop_document_parts_partition_the_total( + raw in prop::collection::vec((1i64..=10_000_000, prop::option::of(0u32..4)), 1..12) + ) { + let lines: Vec = raw + .iter() + .map(|&(c, k)| line("x", c, k)) + .collect(); + let parts = document_parts(&lines, "doc").unwrap(); + let total: Decimal = lines.iter().map(|l| l.amount).sum(); + prop_assert_eq!(parts.iter().map(|p| p.1).sum::(), total); + let mut first: Vec> = Vec::new(); + for l in &lines { + if !first.contains(&l.class_id) { + first.push(l.class_id); + } + } + prop_assert_eq!(parts.iter().map(|p| p.0).collect::>(), first); + for (k, amount) in &parts { + let sum: Decimal = lines.iter().filter(|l| l.class_id == *k).map(|l| l.amount).sum(); + prop_assert_eq!(*amount, sum); + prop_assert!(*amount > Decimal::ZERO); + } + if lines.iter().all(|l| l.class_id.is_none()) { + prop_assert_eq!(parts, vec![(None, total)]); + } + } +} + +/// document_parts INVARIANT 4: a sum past exact arithmetic is refused, +/// never a panic. +#[test] +fn document_parts_refuse_past_exact_arithmetic() { + let big = DocumentLine { + description: "x".into(), + amount: Decimal::MAX, + class_id: Some(1), + }; + assert!(matches!( + document_parts(&[big.clone(), big], "doc"), + Err(LedgerError::TooLargeToCompute(_)) + )); +} diff --git a/crates/ledger-core/tests/spec_ledger.rs b/crates/ledger-core/tests/spec_ledger.rs index 5e74ca2..58f6795 100644 --- a/crates/ledger-core/tests/spec_ledger.rs +++ b/crates/ledger-core/tests/spec_ledger.rs @@ -1913,6 +1913,7 @@ fn one_line(amount: &str) -> Vec { vec![DocumentLine { description: "consulting".into(), amount: dec(amount), + class_id: None, }] } @@ -2796,6 +2797,7 @@ fn a_new_account_named_like_a_sibling_or_holding_a_tin_is_refused() { vec![DocumentLine { description: "work".into(), amount: dec("100.00"), + class_id: None, }], ) .unwrap(); diff --git a/crates/ledger-core/tests/spec_party_totals.rs b/crates/ledger-core/tests/spec_party_totals.rs index 446b464..a88dd1f 100644 --- a/crates/ledger-core/tests/spec_party_totals.rs +++ b/crates/ledger-core/tests/spec_party_totals.rs @@ -465,6 +465,7 @@ fn audit() -> Audit { vec![DocumentLine { description: "filing".into(), amount: d("80.00"), + class_id: None, }], ) .unwrap(); @@ -482,6 +483,7 @@ fn audit() -> Audit { vec![DocumentLine { description: "consulting".into(), amount: d("120.00"), + class_id: None, }], ) .unwrap(); diff --git a/crates/ledger-core/tests/spec_replayed_extremes.rs b/crates/ledger-core/tests/spec_replayed_extremes.rs index ae8342a..4b1b472 100644 --- a/crates/ledger-core/tests/spec_replayed_extremes.rs +++ b/crates/ledger-core/tests/spec_replayed_extremes.rs @@ -292,6 +292,7 @@ fn documents_and_aging_are_exact_or_refused() { let line = |a: Decimal| DocumentLine { description: "x".into(), amount: a, + class_id: None, }; let fine = vec![ line(Decimal::new(1, 11)), diff --git a/crates/ledger-core/tests/spec_roles.rs b/crates/ledger-core/tests/spec_roles.rs index c4697b3..a927431 100644 --- a/crates/ledger-core/tests/spec_roles.rs +++ b/crates/ledger-core/tests/spec_roles.rs @@ -41,6 +41,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine { DocumentLine { description: desc.to_string(), amount: dec(amount), + class_id: None, } } diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index f3c8007..b7dc75c 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -246,11 +246,11 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { let client_class = s.add_project( "Client work", &ledger_core::ProjectFacts { - // Its invoices can't be tagged to it: an invoice posts one - // untagged revenue line (Ledger::create_invoice takes no - // class), so only the contractors it pays show here, said so. + // Its invoices' lines and the contractors it pays are tagged + // to it (rules.md B-37 item 8), so its snapshot is its + // revenue less its direct costs. description: Some( - "Design work for the studio's clients. Only its contractors are tagged to it: an invoice can't carry a project yet, so what the clients pay shows on the profit and loss, not here." + "Design work for the studio's clients: every client invoice, and the contractors who work on it." .into(), ), kind: Some(ledger_core::ProjectKind::Client), @@ -763,12 +763,9 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { ][rng.pick(5) as usize] .into(), amount: amt, + class_id: Some(client_class), }], )?; - let entry = s.ledger().get_invoice(inv).map(|i| i.entry_id); - if let Some(e) = entry { - let _ = e; - } open_invoices.push((inv, date, amt)); } // Collect invoices 20-50 days out; leave the latest ones open and one overdue. diff --git a/crates/ledger-gui/src/pages/cash.rs b/crates/ledger-gui/src/pages/cash.rs index 76547fd..3351fb9 100644 --- a/crates/ledger-gui/src/pages/cash.rs +++ b/crates/ledger-gui/src/pages/cash.rs @@ -663,6 +663,7 @@ mod tests { vec![DocumentLine { description: "work".into(), amount: cents(c), + class_id: None, }] }; let acme = b.s.add_customer("Acme").unwrap(); diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 121c6fd..5deaaa7 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -802,6 +802,7 @@ mod tests { vec![DocumentLine { description: "work".into(), amount: cents(c), + class_id: None, }] }; let acme = b.s.add_customer("Acme").unwrap(); diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index 53327b6..0e22e3f 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -10,6 +10,7 @@ use crate::theme::{self, pal}; use crate::widgets; use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2}; use egui_phosphor::regular as icon; +use ledger_core::arap::DocumentLine; use ledger_core::{EntryStatus, Side}; use ledger_store::Attachment; use rust_decimal::Decimal; @@ -231,26 +232,18 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { ui.allocate_ui(vec2(w, 22.0), |ui| { ui.set_width(w); ui.horizontal(|ui| { - let r = widgets::link_truncated( - ui, - RichText::new(b.account_label(line.account)) - .font(theme::regular(13.5)), - ); + let tag = project_tag(line.class_id, &class_names); + let (r, project) = name_and_tag(ui, tag.as_ref(), |ui| { + widgets::link_truncated( + ui, + RichText::new(b.account_label(line.account)) + .font(theme::regular(13.5)), + ) + }); if r.clicked() { shell.go(Route::Account(line.account)); } - if let Some((cid, c)) = line - .class_id - .and_then(|c| class_names.get(&c).map(|n| (c, n))) - && widgets::link( - ui, - RichText::new(format!("{} {c}", icon::TAG)) - .font(theme::regular(11.5)) - .color(p.muted), - ) - .on_hover_text("Open this project") - .clicked() - { + if let Some(cid) = project { shell.go(Route::Project(cid)); } if let (Some(cur), Some(fa)) = @@ -308,6 +301,14 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { }); ui.add_space(18.0); + // The invoice or bill it records: its lines as the document + // gives them, each project a link (rules.md B-37 item 8). + if let Some(doc) = document_of(b, e.id) { + section(ui, &doc.title); + document_lines(ui, &doc, &class_names, total_w, amt_w, shell); + ui.add_space(18.0); + } + // Receipts and documents: filed on it, linked to it, or linked // to the invoice, bill, or payroll run it records; a withdrawn // receipt filed on it stays, marked. @@ -406,6 +407,144 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { }); } +/// The invoice or bill an entry records: its name and due date, and its +/// lines. +pub struct DocumentOf<'a> { + /// "Invoice 3, due Oct 1, 2026". + pub title: String, + /// Its lines, as journaled. + pub lines: &'a [DocumentLine], +} + +/// The invoice or bill whose entry is `entry`, if it records one. +pub fn document_of(b: &Books, entry: ledger_core::EntryId) -> Option> { + let due = |what: &str, id: u64, due: chrono::NaiveDate| { + format!("{what} {id}, due {}", fmt::date(due)) + }; + b.ledger + .invoices() + .into_iter() + .find(|i| i.entry_id == entry) + .map(|i| DocumentOf { + title: due("Invoice", i.id, i.due_date), + lines: &i.lines, + }) + .or_else(|| { + b.ledger + .bills() + .into_iter() + .find(|x| x.entry_id == entry) + .map(|x| DocumentOf { + title: due("Bill", x.id, x.due_date), + lines: &x.lines, + }) + }) +} + +/// A document's lines: each description, its project (a link that opens +/// it), and its amount. +fn document_lines( + ui: &mut Ui, + doc: &DocumentOf<'_>, + class_names: &std::collections::HashMap, + total_w: f32, + amt_w: f32, + shell: &mut Shell<'_>, +) { + let p = pal(ui.ctx()); + egui::Frame::new() + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(10)) + .inner_margin(Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.set_width(total_w - 26.0); + ui.horizontal(|ui| { + let w = ui.available_width() - amt_w - 8.0; + let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover()); + ui.painter().text( + r.left_center(), + egui::Align2::LEFT_CENTER, + "Line", + theme::regular(12.0), + p.muted, + ); + widgets::amount_cell(ui, "Amount", amt_w, p.muted, false, false); + }); + for line in doc.lines { + ui.horizontal(|ui| { + let w = ui.available_width() - amt_w - 8.0; + ui.allocate_ui(vec2(w, 22.0), |ui| { + ui.set_width(w); + ui.horizontal(|ui| { + let tag = project_tag(line.class_id, class_names); + let (_, project) = name_and_tag(ui, tag.as_ref(), |ui| { + ui.add( + egui::Label::new( + RichText::new(&line.description).font(theme::regular(13.5)), + ) + .truncate(), + ) + }); + if let Some(cid) = project { + shell.go(Route::Project(cid)); + } + }); + }); + widgets::amount_cell(ui, &fmt::money(line.amount), amt_w, p.text, false, false); + }); + } + }); +} + +/// A line's project tag: its id and the text it shows. +fn project_tag( + class: Option, + class_names: &std::collections::HashMap, +) -> Option<(u32, String)> { + let c = class?; + class_names + .get(&c) + .map(|n| (c, format!("{} {n}", icon::TAG))) +} + +/// `name` (a truncating label), then `tag` as a link that opens its +/// project: the project, when clicked. The tag's width (at most half the +/// cell, cut with an ellipsis past it) is kept back from the name, so a +/// tagged line never grows past its cell. +fn name_and_tag( + ui: &mut Ui, + tag: Option<&(u32, String)>, + name: impl FnOnce(&mut Ui) -> egui::Response, +) -> (egui::Response, Option) { + let p = pal(ui.ctx()); + let avail = ui.available_width(); + let gap = ui.spacing().item_spacing.x; + let tag_w = tag.map_or(0.0, |(_, t)| { + ui.painter() + .layout_no_wrap(t.clone(), theme::regular(11.5), p.muted) + .size() + .x + .min(avail / 2.0) + }); + let room = if tag.is_some() { tag_w + gap } else { 0.0 }; + let r = ui + .allocate_ui(vec2((avail - room).max(0.0), 22.0), name) + .inner; + let go = tag.and_then(|(cid, t)| { + ui.allocate_ui(vec2(tag_w, 22.0), |ui| { + widgets::link_truncated( + ui, + RichText::new(t).font(theme::regular(11.5)).color(p.muted), + ) + }) + .inner + .on_hover_text("Open this project") + .clicked() + .then_some(*cid) + }); + (r, go) +} + pub fn section(ui: &mut Ui, title: &str) { let p = pal(ui.ctx()); ui.label( @@ -588,7 +727,66 @@ pub fn share_text( #[cfg(test)] mod tests { use super::*; - use crate::test_support::{Build, d, find, old_books, render}; + use crate::test_support::{Build, d, find, old_books, render, render_at}; + + /// A tagged line keeps its project tag inside its cell, in a narrow + /// pane too: the name is cut to leave the tag room, so the amounts + /// stay in their columns (an invoice's revenue line and its document + /// line, each tagged; rules.md B-37 item 8). + #[test] + fn a_tagged_line_keeps_its_amounts_in_their_column() { + use ledger_core::arap::DocumentLine; + let mut b = Build::new("entry-tag-room"); + let project = + b.s.add_project( + "A project with a rather long name indeed", + &ledger_core::ProjectFacts::default(), + ) + .unwrap(); + b.s.add_account( + "4000", + "Revenue from design services and consulting", + ledger_core::AccountType::Revenue, + None, + false, + ) + .unwrap(); + let c = b.s.add_customer("Cedar").unwrap(); + let inv = + b.s.create_invoice( + c, + d(2026, 3, 2), + d(2026, 4, 1), + vec![DocumentLine { + description: "Brand identity, website and a long tail of revisions".into(), + amount: Decimal::new(123_456, 2), + class_id: Some(project), + }], + ) + .unwrap(); + let entry = b.s.ledger().get_invoice(inv).unwrap().entry_id; + let (_dir, books) = b.books(); + let texts = render_at(books, Route::Entry(entry), 1000.0); + // The amount: the receivable's debit, the revenue's credit, both + // totals, the document line's; every one ends at a column's edge, + // the credits at one, the debits at another. + let pane = find(&texts, "LINES").pos.x; + let mut edges: Vec = texts + .iter() + .filter(|t| t.text == fmt::money(Decimal::new(123_456, 2)) && t.pos.x > pane) + .map(|t| (t.pos.x + t.width).round() as i32) + .collect(); + assert_eq!(edges.len(), 5, "{edges:?}"); + edges.sort_unstable(); + edges.dedup(); + assert_eq!( + edges.len(), + 2, + "the debit column and the credit column: {edges:?}" + ); + let tag = format!("{} A project with a rather long name indeed", icon::TAG); + assert_eq!(texts.iter().filter(|t| t.text == tag).count(), 2); + } /// An entry's totals line: "Balanced" when its debits equal its /// credits, "Out of balance" when they don't, "Too large to total" diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 3792185..d993704 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -1709,6 +1709,7 @@ mod tests { vec![DocumentLine { description: "work".into(), amount: cents(c), + class_id: None, }] }; // Owed, not yet due; owed, due today; paid, past due; owed, past diff --git a/crates/ledger-gui/src/pages/profit.rs b/crates/ledger-gui/src/pages/profit.rs index d8d7e67..c2a74f6 100644 --- a/crates/ledger-gui/src/pages/profit.rs +++ b/crates/ledger-gui/src/pages/profit.rs @@ -448,6 +448,7 @@ mod tests { vec![DocumentLine { description: "work".into(), amount: cents(c), + class_id: None, }] }; let acme = b.s.add_customer("Acme").unwrap(); diff --git a/crates/ledger-gui/src/test_support.rs b/crates/ledger-gui/src/test_support.rs index 8c753d4..16f7872 100644 --- a/crates/ledger-gui/src/test_support.rs +++ b/crates/ledger-gui/src/test_support.rs @@ -197,6 +197,19 @@ pub(crate) fn render( (texts, p) } +/// [`render`] in a window `width` points wide (a narrow one squeezes the +/// detail pane). +pub(crate) fn render_at(books: Books, route: Route, width: f32) -> Vec { + let mut h = Harness::builder() + .with_size(egui::vec2(width, 2400.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route)); + h.run_steps(4); + let ctx = h.ctx.clone(); + h.state_mut().settle(&ctx); + h.run_steps(4); + painted(h.output()) +} + /// [`render`], with the filled rectangles too. pub(crate) fn render_all( books: Books, diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs index c09e512..3f53b4c 100644 --- a/crates/ledger-gui/tests/parties.rs +++ b/crates/ledger-gui/tests/parties.rs @@ -74,6 +74,7 @@ fn doc(c: i64) -> Vec { vec![DocumentLine { description: "work".into(), amount: cents(c), + class_id: None, }] } diff --git a/crates/ledger-gui/tests/projects.rs b/crates/ledger-gui/tests/projects.rs index 4638fc6..177df28 100644 --- a/crates/ledger-gui/tests/projects.rs +++ b/crates/ledger-gui/tests/projects.rs @@ -1252,3 +1252,166 @@ proptest::proptest! { proptest::prop_assert_eq!(s.standing, want); } } + +/// rules.md B-37 item 8: an invoice's or bill's page (its entry's) lists +/// the document's lines, each project a link that opens it; an untagged +/// line has none. The entry's own lines show the tagged revenue or cost +/// line too. +#[test] +fn an_invoice_and_a_bill_show_each_lines_project() { + use ledger_core::arap::DocumentLine; + let dir = TempDir(std::env::temp_dir().join(format!( + "ledger-gui-projects-documents-{}", + std::process::id() + ))); + let _ = std::fs::remove_dir_all(&dir.0); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + s.add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let zeta = s + .add_project( + "Zeta site", + &ProjectFacts { + kind: Some(ProjectKind::Client), + ..Default::default() + }, + ) + .unwrap(); + let harbor = s.add_class("Harbor app").unwrap(); + let c = s.add_customer("Cedar Client").unwrap(); + let v = s.add_vendor("Dana Illustration").unwrap(); + let line = |what: &str, c: i64, class| DocumentLine { + description: what.into(), + amount: cents(c), + class_id: class, + }; + let inv = s + .create_invoice( + c, + d(2026, 3, 2), + d(2026, 4, 1), + vec![ + line("Design", 300_000, Some(zeta)), + line("Rush fee", 10_000, None), + ], + ) + .unwrap(); + let bill = s + .create_bill( + v, + d(2026, 3, 9), + d(2026, 4, 8), + vec![ + line("Illustration", 120_000, Some(harbor)), + line("Postage", 5_000, None), + ], + ) + .unwrap(); + let inv_entry = s.ledger().get_invoice(inv).unwrap().entry_id; + let bill_entry = s.ledger().get_bill(bill).unwrap().entry_id; + drop(s); + let tag = |name: &str| format!("{} {name}", egui_phosphor::regular::TAG); + for (entry, title, kept, untagged, project, id, shot_name) in [ + ( + inv_entry, + "INVOICE 1, DUE APR 1, 2026", + "Design", + "Rush fee", + "Zeta site", + zeta, + "invoice-lines", + ), + ( + bill_entry, + "BILL 1, DUE APR 8, 2026", + "Illustration", + "Postage", + "Harbor app", + harbor, + "bill-lines", + ), + ] { + let books = load(&dir); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 1400.0)) + .build_eframe(move |cc| { + App::with_books(&cc.egui_ctx, books, false, Route::Entry(entry)) + }); + h.run_steps(3); + shot(&mut h, shot_name); + h.get_by_label(title); + h.get_by_label(kept); + h.get_by_label(untagged); + // The entry's tagged line and the document's line: two links, one + // project; the untagged lines, none. + assert_eq!(h.query_all_by_label(&tag(project)).count(), 2, "{title}"); + // The two links and the history's "tagged project" event: nothing + // else wears a tag, so the untagged lines have none. + assert_eq!( + h.query_all_by_label_contains(egui_phosphor::regular::TAG) + .count(), + 3, + "{title}" + ); + assert_eq!( + h.query_all_by_label_contains(&format!("tagged project {project}")) + .count(), + 1, + "{title}" + ); + // The document's line, the lower of the two, opens the project. + h.query_all_by_label(&tag(project)) + .max_by(|a, b| a.rect().top().total_cmp(&b.rect().top())) + .unwrap() + .click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Project(id), "{title}"); + } +} + +/// The demo's "Client work" project carries every client invoice (rules.md +/// B-37 item 8): its revenue to date is the invoices' total, its direct +/// costs the contractors tagged to it. +#[test] +fn the_demos_client_work_counts_every_client_invoice() { + let dir = TempDir( + std::env::temp_dir().join(format!("ledger-gui-projects-demo-{}", std::process::id())), + ); + let _ = std::fs::remove_dir_all(&dir.0); + let today = d(2026, 10, 12); + ledger_gui::demo::build(&dir.0, today).unwrap(); + let b = data::load_once(&Source { + data_dir: dir.0.join("books-data"), + today: Some(today), + }) + .unwrap(); + let l = &b.ledger; + let client = l.find_class_by_name("Client work").unwrap().id; + let invoices = l.invoices(); + assert!(!invoices.is_empty()); + assert!( + invoices + .iter() + .all(|i| i.lines.iter().all(|x| x.class_id == Some(client))), + "every client invoice is tagged" + ); + let invoiced: Decimal = invoices.iter().map(|i| i.total().unwrap()).sum(); + let t = ledger_core::project_to_date(l, client, today).unwrap(); + assert_eq!(t.revenue, invoiced); + let tagged_costs: Decimal = l + .posted_entries() + .flat_map(|e| e.lines().iter()) + .filter(|x| x.class_id == Some(client)) + .filter(|x| { + l.chart() + .get(x.account) + .is_some_and(|a| a.account_type == AccountType::Expense) + }) + .map(|x| match x.side { + Side::Debit => x.amount.amount, + Side::Credit => -x.amount.amount, + }) + .sum(); + assert_eq!(t.expenses, tagged_costs); + assert!(t.expenses > Decimal::ZERO && t.net() > Decimal::ZERO); +} diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index 681bf7f..7307a40 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -106,8 +106,17 @@ fn screenshots() { .unwrap_or_else(|| project_named("Tidepool app")); let project_client = project_named("Client work"); let project_templates = project_named("Templates"); + // An invoice whose lines name a project (else the first), and the + // first bill: their entries' pages show the document's lines. + let invoices = loaded.ledger.invoices(); + let invoice = invoices + .iter() + .find(|i| i.lines.iter().any(|l| l.class_id.is_some())) + .or(invoices.first()) + .map(|i| i.entry_id); + let bill = loaded.ledger.bills().first().map(|b| b.entry_id); drop(loaded); - let routes: Vec<(&str, Route)> = vec![ + let mut routes: Vec<(&str, Route)> = vec![ ("overview", Route::Overview), ("transactions", Route::Transactions), ( @@ -152,6 +161,8 @@ fn screenshots() { ("company", Route::Company), ("journal", Route::Journal), ]; + routes.extend(invoice.map(|e| ("invoice", Route::Entry(e)))); + routes.extend(bill.map(|e| ("bill", Route::Entry(e)))); for (name, route) in routes { if only .as_deref() diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index d557517..3ff5cae 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -256,6 +256,7 @@ fn origins_and_parties() { vec![DocumentLine { description: "x".into(), amount: Decimal::new(a, 2), + class_id: None, }] }; let inv = s @@ -637,6 +638,7 @@ fn more_origins_and_parties() { vec![DocumentLine { description: "x".into(), amount: Decimal::new(a, 2), + class_id: None, }] }; s.create_invoice(cust, today, today + Days::new(30), line(10_000)) diff --git a/crates/ledger-mcp/handbook/projects.md b/crates/ledger-mcp/handbook/projects.md index b8b030a..032c386 100644 --- a/crates/ledger-mcp/handbook/projects.md +++ b/crates/ledger-mcp/handbook/projects.md @@ -1,7 +1,7 @@ # Projects - A project is a class with its facts: books.add_project, books.update_project, books.projects, books.project (its figures, entries, documents, decisions). books.start lists the ones under way (active and paused). -- When the company's conventions ask for it (books.conventions: a rule to link things to projects), link whatever belongs to a project as you record it: tag each of its revenue and expense lines (books.add_line class), link its documents (books.link_attachment subject kind project, or link_to when filing), and name it in decisions about it (books.record_decision projects). An invoice or a bill takes no project yet (books.create_invoice and books.create_bill post at once, untagged): link its document to the project, and when its lines must count, book it as a hand-built entry instead (books.draft_entry, books.add_line class), which then is no invoice or bill in the agings: ask the human which matters more. Ask the human when it isn't clear which project; leave company-wide costs (rent, insurance, bookkeeping) untagged, and split a shared cost across lines only by a basis the human gives. +- When the company's conventions ask for it (books.conventions: a rule to link things to projects), link whatever belongs to a project as you record it: tag each of its revenue and expense lines (books.add_line class), link its documents (books.link_attachment subject kind project, or link_to when filing), and name it in decisions about it (books.record_decision projects). {document_projects} Ask the human when it isn't clear which project; leave company-wide costs (rent, insurance, bookkeeping) untagged. A cost shared among projects is never split on an invoice, a bill or an entry by guesswork: it reaches projects only as the allocation convention's own entries, on its stated basis; when books.conventions has none, leave it untagged and ask the human. - Keep the facts current: status when it changes, ended_on when it is finished or abandoned, the party it is for. - On spec: its costs are expenses when incurred. A hoped-for payment is not recorded (no receivable, no income) until it is received; then book it as revenue tagged to the project. books.project shows the unrecovered cost. - Only revenue and expense lines make a project's figures; a tagged asset purchase is listed with its entries but counts nothing until it is depreciated or expensed. Show a project to the human with books.show project. diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index 449bd53..190eb46 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -30,6 +30,10 @@ //! control or format characters, no line breaks), cut on a //! character; a company with no name (empty, or the settings' //! default name) is "a new company" (plan/served.md 4.1). +//! 6. A section says an invoice's or bill's lines take a project only +//! for books that declared the journal feature +//! `document_line_projects`; for the rest, that they wait for their +//! format upgrade (rules.md B-27, B-37 item 8). //! ``` use crate::settings::Settings; @@ -119,12 +123,22 @@ pub fn title(markdown: &str) -> &str { /// reading order. 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 { +/// The section `id` (or [`ALL`]), rendered for these settings, the +/// books' base currency, and whether they declared the journal feature +/// `document_line_projects` (None: no such section). +pub fn section( + id: &str, + settings: &Settings, + currency: &str, + document_line_projects: bool, +) -> Option { let render = |md: &str| { md.replace("{evidence_rule}", &evidence_rule(settings, currency)) .replace("{period_rule}", &period_rule()) + .replace( + "{document_projects}", + document_projects_rule(document_line_projects), + ) }; if id == ALL { let parts: Vec = SECTIONS.iter().map(|(_, md)| render(md)).collect(); @@ -147,6 +161,17 @@ pub fn period_rule() -> String { ) } +/// How an invoice's or bill's lines reach a project (rules.md B-37 item +/// 8), for books that declared the journal feature +/// `document_line_projects` or not yet (INVARIANT 6). +pub fn document_projects_rule(declared: bool) -> &'static str { + if declared { + "Tag an invoice's or bill's lines the same way (books.create_invoice and books.create_bill take class on each line): its revenue or cost counts toward the project, and it stays an invoice or bill in the agings; its payments take no project. Each line names one project or none: a document spanning several projects is one line per project, each with its own amount, never a percentage; a cost truly shared among projects names none on the document and reaches them only as entries of their own under the company's monthly allocation convention, when books.conventions has one. An invoice line naming an on-spec project returns a note: ask the human whether there is now an agreement, since a hoped-for payment is no receivable." + } else { + "An invoice or a bill takes no project in these books until their format upgrade (books.start's format_upgrade): link its document to the project, and when its lines must count, book it as a hand-built entry instead (books.draft_entry, books.add_line class), which then is no invoice or bill in the agings: ask the human which matters more." + } +} + /// The receipt rule in force (INVARIANT 2). pub fn evidence_rule(settings: &Settings, currency: &str) -> String { format!( @@ -478,22 +503,65 @@ mod tests { assert!(ids.insert(*id), "duplicate section {id}"); assert!(md.starts_with("# "), "{id} has no title"); assert!(!title(md).is_empty()); - let text = section(id, &s, "USD").unwrap(); + let text = section(id, &s, "USD", true).unwrap(); assert!(!text.contains("{evidence_rule}"), "{id}"); assert!(!text.contains("{period_rule}"), "{id}"); + assert!(!text.contains("{document_projects}"), "{id}"); } // The default period, in the descriptions' own words (B-30). assert!( - section("tracing-figures", &s, "USD") + section("tracing-figures", &s, "USD", true) .unwrap() .contains(ledger_core::calendar::PeriodKind::Statement.describe()) ); assert!( - section("evidence-and-receipts", &s, "USD") + section("evidence-and-receipts", &s, "USD", true) .unwrap() .contains("every entry needs at least one receipt") ); - assert!(section("nope", &s, "USD").is_none()); + assert!(section("nope", &s, "USD", true).is_none()); + } + + /// INVARIANT 6: the projects section tells books that declared + /// document_line_projects to tag an invoice's or bill's lines, and the + /// rest that it waits for their format upgrade; no other section + /// changes. + #[test] + fn document_projects_follow_the_books_feature() { + let s = settings("Co", Decimal::ZERO); + let with = section("projects", &s, "USD", true).unwrap(); + let without = section("projects", &s, "USD", false).unwrap(); + assert!( + with.contains("books.create_invoice and books.create_bill take class on each line") + ); + assert!(!with.contains("takes no project")); + // The owner's decision (B-37 item 8, D-43): one project or none a + // line, no percentage split, a shared cost by the allocation + // convention. + assert!(with.contains("Each line names one project or none")); + assert!(with.contains("never a percentage")); + assert!(with.contains("monthly allocation convention, when books.conventions has one")); + assert!(with.contains("ask the human whether there is now an agreement")); + // No sentence may let the agent split a shared cost across a + // document's lines (D-43). + for text in [&with, &without] { + assert!(!text.contains("split a shared cost across lines"), "{text}"); + assert!(text.contains("is never split on an invoice, a bill or an entry")); + assert!( + text.contains( + "when books.conventions has none, leave it untagged and ask the human" + ) + ); + } + assert!(without.contains("takes no project in these books until their format upgrade")); + assert!(!without.contains("take class on each line")); + for (id, _) in SECTIONS.iter().filter(|(id, _)| *id != "projects") { + assert_eq!( + section(id, &s, "USD", true), + section(id, &s, "USD", false), + "{id}" + ); + } } /// A section's title is its heading's text, as the index and the @@ -514,10 +582,10 @@ mod tests { #[test] fn the_whole_handbook_is_every_section_in_order() { let s = settings("Co", Decimal::new(2500, 2)); - let all = section(ALL, &s, "USD").unwrap(); + let all = section(ALL, &s, "USD", true).unwrap(); let mut at = 0; for (id, _) in SECTIONS { - let one = section(id, &s, "USD").unwrap(); + let one = section(id, &s, "USD", true).unwrap(); let found = all[at..] .find(&one) .unwrap_or_else(|| panic!("{id} missing or out of order")); diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 82faaa9..6b902df 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -794,6 +794,16 @@ impl BooksServer { self.live().current() } + /// Whether the books declared the journal feature + /// `document_line_projects` (the handbook's projects section says how + /// an invoice's or bill's lines reach a project by it). + fn document_line_projects(&self) -> bool { + self.lock().is_ok_and(|s| { + s.journal_format() + .has_feature(ledger_store::format::DOCUMENT_LINE_PROJECTS) + }) + } + /// The books' base currency code, e.g. "USD". fn currency(&self) -> &str { self.store.currency.code() @@ -2636,6 +2646,54 @@ fn resolve_class(store: &Store, class: &str) -> Result { } } +/// An invoice's or bill's lines as the tool takes them: each amount +/// parsed and each project resolved ([`resolve_class`]) before anything is +/// written. +fn document_lines( + store: &Store, + lines: Vec, +) -> Result, String> { + lines + .into_iter() + .map(|l| { + Ok(DocumentLine { + description: l.description, + amount: parse_amount(&l.amount)?, + class_id: l + .class + .as_deref() + .map(|c| resolve_class(store, c)) + .transpose()?, + }) + }) + .collect() +} + +/// A document's notes, each project once, in the order first named: its +/// late-tag note ([`projects::late_tag_note`]) for a finished or abandoned +/// project, and on an invoice ([`projects::on_spec_invoice_note`]) one for +/// an on-spec project (rules.md B-37 items 4, 5 and 8). +fn document_notes( + ledger: &ledger_core::Ledger, + lines: &[DocumentLine], + invoice: bool, +) -> Vec { + let mut named: Vec = Vec::new(); + for c in lines.iter().filter_map(|l| l.class_id) { + if !named.contains(&c) { + named.push(c); + } + } + let mut notes = Vec::new(); + for c in named { + notes.extend(projects::late_tag_note(ledger, c)); + if invoice { + notes.extend(projects::on_spec_invoice_note(ledger, c)); + } + } + notes +} + /// Preview lines with resulting balances for raw (account, side, amount) lines. fn preview_lines_for( store: &Store, @@ -2967,10 +3025,12 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let settings = self.settings(); + let declared = self.document_line_projects(); let sections = handbook::SECTIONS .iter() .map(|(id, _)| { - let text = handbook::section(id, &settings, self.currency()).unwrap_or_default(); + let text = + handbook::section(id, &settings, self.currency(), declared).unwrap_or_default(); HandbookSectionOut { id: id.to_string(), title: handbook::title(&text).to_string(), @@ -2982,7 +3042,7 @@ impl BooksServer { let section = match params.0.section.as_deref().map(str::trim) { None => None, Some(id) => { - let text = handbook::section(id, &settings, self.currency()).ok_or_else(|| { + let text = handbook::section(id, &settings, self.currency(), declared).ok_or_else(|| { let ids: Vec<&str> = handbook::SECTIONS.iter().map(|(id, _)| *id).collect(); format!( "no handbook section {id:?}: the sections are {}, or \"{}\" for all of them", @@ -4119,6 +4179,11 @@ impl BooksServer { Side::Credit => "credit".to_string(), }, amount: money(l.amount.amount), + class_id: l.class_id, + project: l + .class_id + .and_then(|c| store.ledger().get_class(c)) + .map(|c| c.name.clone()), } }) .collect(); @@ -8489,7 +8554,7 @@ impl BooksServer { #[tool( name = "books.create_invoice", title = "Create and post an invoice", - description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount} with decimal-string amounts.", + description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's revenue: one revenue line per project, the untagged lines' as one; the receivable line is never tagged. Its result gains notes for a finished, abandoned or on-spec project (on spec: is there now an agreement?), tagged anyway.", annotations( read_only_hint = false, destructive_hint = true, @@ -8502,16 +8567,7 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; - let lines: Vec = p - .lines - .into_iter() - .map(|l| { - Ok(DocumentLine { - description: l.description, - amount: parse_amount(&l.amount)?, - }) - }) - .collect::>()?; + let lines = document_lines(&store, p.lines)?; let first_new = store.ledger().next_account_id(); let id = store .create_invoice( @@ -8530,13 +8586,14 @@ impl BooksServer { total: inv.total().map_err(|e| e.to_string())?.to_string(), entry_id: inv.entry_id, warnings: created_uncategorized(store.ledger(), first_new), + notes: document_notes(store.ledger(), &inv.lines, true), })) } #[tool( name = "books.create_bill", title = "Create and post a bill", - description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount} with decimal-string amounts.", + description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's cost: one expense line per project, the untagged lines' as one; the payable line is never tagged. Its result gains notes for a finished or abandoned project, tagged anyway.", annotations( read_only_hint = false, destructive_hint = true, @@ -8549,16 +8606,7 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; - let lines: Vec = p - .lines - .into_iter() - .map(|l| { - Ok(DocumentLine { - description: l.description, - amount: parse_amount(&l.amount)?, - }) - }) - .collect::>()?; + let lines = document_lines(&store, p.lines)?; let first_new = store.ledger().next_account_id(); let id = store .create_bill( @@ -8577,6 +8625,7 @@ impl BooksServer { total: bill.total().map_err(|e| e.to_string())?.to_string(), entry_id: bill.entry_id, warnings: created_uncategorized(store.ledger(), first_new), + notes: document_notes(store.ledger(), &bill.lines, false), })) } @@ -9926,8 +9975,13 @@ impl BooksServer { } _ if path.starts_with(&format!("{}/", handbook::URI)) => { let id = &path[handbook::URI.len() + 1..]; - handbook::section(id, &self.settings(), self.currency()) - .ok_or_else(|| format!("unknown handbook section: {id}")) + handbook::section( + id, + &self.settings(), + self.currency(), + self.document_line_projects(), + ) + .ok_or_else(|| format!("unknown handbook section: {id}")) } _ if path.starts_with("books://receipts/") => { let id: u64 = path @@ -11212,10 +11266,12 @@ mod tests { handbook::connect_text(&settings, "USD"), )); for (id, _) in handbook::SECTIONS { - texts.push(( - format!("handbook {id}"), - handbook::section(id, &settings, "USD").unwrap(), - )); + for declared in [true, false] { + texts.push(( + format!("handbook {id}"), + handbook::section(id, &settings, "USD", declared).unwrap(), + )); + } } } // Every tool the text names is one there is. diff --git a/crates/ledger-mcp/src/tools/projects.rs b/crates/ledger-mcp/src/tools/projects.rs index 89937e7..c4918b4 100644 --- a/crates/ledger-mcp/src/tools/projects.rs +++ b/crates/ledger-mcp/src/tools/projects.rs @@ -161,6 +161,21 @@ pub(super) fn late_tag_note(ledger: &Ledger, id: ClassId) -> Option { )) } +/// The note `books.create_invoice` gives for a line naming an on-spec +/// project (rules.md B-37 items 4 and 8): on spec, a hoped-for payment is +/// no receivable and no income until received, so an invoice for one +/// means there is now an agreement, or it shouldn't be an invoice. +pub(super) fn on_spec_invoice_note(ledger: &Ledger, id: ClassId) -> Option { + let f = ledger.project_facts(id)?; + if f.kind != Some(ProjectKind::OnSpec) { + return None; + } + let name = &ledger.get_class(id)?.name; + Some(format!( + "project {name} is on spec: an invoice books a receivable and income, so is there now an agreement to be paid? If so, update its kind (books.update_project); if not, tell the human: on spec, a hoped-for payment is recorded only when received" + )) +} + #[tool_router(router = projects_router, vis = "pub(super)")] impl BooksServer { #[tool( diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 40bae99..6a30cb7 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1931,6 +1931,13 @@ pub struct EntryLineOut { pub side: String, /// Amount, decimal string. pub amount: String, + /// The project (class) id the line is tagged with, if any (books.add_line + /// class, an invoice's or bill's line class). + #[serde(skip_serializing_if = "Option::is_none")] + pub class_id: Option, + /// That project's name. + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, } /// Result of `books.get_entry`: the full entry with lines and receipts. @@ -3063,6 +3070,15 @@ pub struct DocumentLineParam { pub description: String, /// Line amount, decimal string. pub amount: String, + /// The one project (name or id, as books.projects lists them) the + /// line's revenue (an invoice's) or cost (a bill's) belongs to, or + /// none. Several projects are several lines, each with its own amount, + /// never a percentage; a shared cost names none (the allocation + /// convention's own entries reach the projects). The posted + /// entry's revenue or expense line for it carries the tag; the + /// receivable or payable line never does. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class: Option, } /// Parameters for `books.create_invoice` / `books.create_bill`. @@ -3091,6 +3107,11 @@ pub struct CreateDocumentOut { /// (Revenue 4000, General Expenses 6999) has no tax category yet. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, + /// One per finished or abandoned project a line names, as + /// `books.add_line`'s `note`, and on an invoice one per on-spec + /// project a line names (is there now an agreement?): tagged anyway. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, } /// Parameters for `books.receive_payment` / `books.pay_bill`. diff --git a/crates/ledger-mcp/tests/spec_projects.rs b/crates/ledger-mcp/tests/spec_projects.rs index 12a1bef..1a3b17c 100644 --- a/crates/ledger-mcp/tests/spec_projects.rs +++ b/crates/ledger-mcp/tests/spec_projects.rs @@ -1200,3 +1200,390 @@ fn a_digit_name_that_shadows_an_id_says_what_to_pass() { ); assert_eq!(files["subject"]["label"], "project 1 Éclair"); } + +// ------------------------------------------------- invoices and bills + +/// [`books`] as the live books stand until their owner consents again: +/// this version's format without `document_line_projects` (they declared +/// `projects`). +fn books_before_document_projects(name: &str) -> Mcp { + let dir = test_dir(name); + let mut format = ledger_store::format::current(); + format + .features + .retain(|f| f != ledger_store::format::DOCUMENT_LINE_PROJECTS); + ledger_store::Store::create_in_format( + &dir.join("books-data"), + ledger_core::Currency::USD, + &format, + ) + .unwrap(); + let toml = books_toml(&dir, "Spec Co", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m.books_dir = Some(dir); + m +} + +impl Mcp { + /// Each line of the entry as books.get_entry shows it: (code, side, + /// amount, project). + fn entry_lines(&mut self, entry: u64) -> Vec<(String, String, String, Option)> { + self.read_only("books.get_entry", json!({"entry_id": entry}))["lines"] + .as_array() + .unwrap() + .iter() + .map(|l| { + ( + l["code"].as_str().unwrap().to_string(), + l["side"].as_str().unwrap().to_string(), + l["amount"].as_str().unwrap().to_string(), + l["project"].as_str().map(str::to_string), + ) + }) + .collect() + } + + /// Each line's `class_id` as books.get_entry shows it (absent: None). + fn entry_class_ids(&mut self, entry: u64) -> Vec> { + let lines = self.read_only("books.get_entry", json!({"entry_id": entry}))["lines"].clone(); + lines + .as_array() + .unwrap() + .iter() + .map(|l| { + assert!( + l.get("class_id").is_none_or(|c| !c.is_null()), + "an untagged line omits class_id: {l}" + ); + l["class_id"].as_u64() + }) + .collect() + } + + /// The projects handbook section served as a resource. + fn handbook_resource(&mut self) -> String { + let r = self.rpc( + "resources/read", + json!({"uri": "books://handbook/projects"}), + ); + r["result"]["contents"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("{r}")) + .to_string() + } +} + +fn l4( + code: &str, + side: &str, + amount: &str, + project: Option<&str>, +) -> (String, String, String, Option) { + ( + code.into(), + side.into(), + amount.into(), + project.map(str::to_string), + ) +} + +/// rules.md B-37 item 8: each invoice and bill line takes a project (by +/// name or id); the entry's revenue or expense side is one line per +/// project, the untagged lines' as one, the receivable or payable line +/// and the payments untagged; books.project, profit_and_loss_by_class, +/// the 1099 worksheet and the party's totals all read them. +#[test] +fn invoice_and_bill_lines_count_toward_their_projects() { + let mut m = books("doc-lines"); + let zeta = m.ok( + "books.add_project", + json!({"name": "Zeta site", "kind": "client"}), + )["id"] + .as_u64() + .unwrap(); + let harbor = m.ok( + "books.add_project", + json!({"name": "Harbor app", "kind": "client"}), + )["id"] + .as_u64() + .unwrap(); + let cedar = m.ok("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone(); + let dana = m.ok("books.add_vendor", json!({"name": "Dana Illustration"})); + let (dana_vendor, dana_party) = (dana["id"].clone(), dana["party_id"].clone()); + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Dana Illustration", "form_1099": "nec", "decided_by": "Tobin Harlow", "decided_on": "2026-01-01"}), + ); + let inv = m.ok( + "books.create_invoice", + json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + {"description": "Design", "amount": "3000.00", "class": "Zeta site"}, + {"description": "Hosting", "amount": "200.00", "class": harbor.to_string()}, + {"description": "Rush fee", "amount": "100.00"}, + {"description": "Revisions", "amount": "500.00", "class": " zeta SITE "}, + ]}), + ); + assert_eq!(inv["total"], "3800.00"); + assert!(inv.get("notes").is_none(), "{inv}"); + let inv_entry = inv["entry_id"].as_u64().unwrap(); + assert_eq!( + m.entry_lines(inv_entry), + vec![ + l4("1200", "debit", "3800.00", None), + l4("4000", "credit", "3500.00", Some("Zeta site")), + l4("4000", "credit", "200.00", Some("Harbor app")), + l4("4000", "credit", "100.00", None), + ] + ); + // books.get_entry's class_id is the project's id on a tagged line and + // absent on the others. + assert_eq!( + m.entry_class_ids(inv_entry), + vec![None, Some(zeta), Some(harbor), None] + ); + let bill = m.ok( + "books.create_bill", + json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08", "lines": [ + {"description": "Illustration", "amount": "1200.00", "class": "Zeta site"}, + {"description": "Postage", "amount": "50.00"}, + ]}), + ); + let bill_entry = bill["entry_id"].as_u64().unwrap(); + assert_eq!( + m.entry_lines(bill_entry), + vec![ + l4("6999", "debit", "1200.00", Some("Zeta site")), + l4("6999", "debit", "50.00", None), + l4("2000", "credit", "1250.00", None), + ] + ); + assert_eq!(m.entry_class_ids(bill_entry), vec![Some(zeta), None, None]); + // Payments are balance-sheet only: no project. + let got = m.ok( + "books.receive_payment", + json!({"document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}), + ); + let paid = m.ok( + "books.pay_bill", + json!({"document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}), + ); + for e in [&got["entry_id"], &paid["entry_id"]] { + let e = e.as_u64().unwrap(); + assert!(m.entry_lines(e).iter().all(|l| l.3.is_none()), "entry {e}"); + } + // The project's figures, and the class P&L, to the cent. + let year = json!({"start": "2026-01-01", "end": "2026-12-31"}); + let mut a = year.clone(); + a["project"] = json!("Zeta site"); + let z = m.read_only("books.project", a); + assert_eq!( + ( + &z["period"]["revenue"], + &z["period"]["expenses"], + &z["period"]["net"] + ), + (&json!("3500.00"), &json!("1200.00"), &json!("2300.00")) + ); + assert_eq!(z["to_date"]["net"], "2300.00"); + assert_eq!(z["period"]["by_month"][2]["net"], "2300.00"); + let entries: Vec = z["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["entry_id"].as_u64().unwrap()) + .collect(); + assert_eq!(entries, vec![bill_entry, inv_entry]); + let by_class = m.read_only("books.profit_and_loss_by_class", year.clone()); + let row = |name: &str| { + by_class["classes"] + .as_array() + .unwrap() + .iter() + .find(|r| r["class"] == name) + .unwrap_or_else(|| panic!("{name}: {by_class}")) + .clone() + }; + assert_eq!(row("Zeta site")["net_income"], "2300.00"); + assert_eq!(row("Harbor app")["net_income"], "200.00"); + // The 1099 worksheet and the party's totals follow the money: the + // bill's payment, 1,250.00, whatever its lines' projects. + let w = m.read_only("books.vendor_1099", json!({"year": 2026})); + let dana_line = w["vendors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["name"] == "Dana Illustration") + .unwrap() + .clone(); + assert_eq!(dana_line["paid"], "1250.00"); + let p = m.read_only( + "books.party", + json!({"party": dana_party.to_string(), "year": 2026}), + ); + assert_eq!(p["totals"]["paid"], "1250.00", "{p}"); + assert_eq!(p["totals"]["amount_1099"], "1250.00", "{p}"); +} + +#[test] +fn a_document_naming_an_unknown_project_is_refused_whole() { + let mut m = books("doc-unknown"); + m.ok("books.add_project", json!({"name": "Zeta site"})); + let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone(); + let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone(); + for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] { + let why = m.refuse( + tool, + json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + {"description": "a", "amount": "10.00", "class": "Zeta site"}, + {"description": "b", "amount": "10.00", "class": "Nowhere"}, + ]}), + ); + assert!(why.contains("unknown project \"Nowhere\""), "{tool}: {why}"); + } +} + +#[test] +fn a_document_for_a_finished_project_says_so() { + let mut m = books("doc-late"); + m.ok( + "books.add_project", + json!({"name": "Harbor", "status": "finished", "ended_on": "2026-09-30"}), + ); + m.ok( + "books.add_project", + json!({"name": "Dock", "status": "abandoned"}), + ); + let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone(); + let out = m.ok( + "books.create_invoice", + json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [ + {"description": "Final", "amount": "10.00", "class": "Harbor"}, + {"description": "Again", "amount": "10.00", "class": "harbor"}, + {"description": "Old", "amount": "10.00", "class": "Dock"}, + ]}), + ); + let notes: Vec<&str> = out["notes"] + .as_array() + .unwrap() + .iter() + .map(|n| n.as_str().unwrap()) + .collect(); + assert_eq!(notes.len(), 2, "{notes:?}"); + assert!( + notes[0].contains("Harbor is finished (ended 2026-09-30)"), + "{notes:?}" + ); + assert!(notes[1].contains("Dock is abandoned"), "{notes:?}"); + // Tagged anyway. + let e = out["entry_id"].as_u64().unwrap(); + assert_eq!( + m.entry_lines(e)[1], + l4("4000", "credit", "20.00", Some("Harbor")) + ); +} + +/// B-37 items 4 and 8: an invoice line naming an on-spec project is taken +/// and its result asks whether there is now an agreement (a hoped-for +/// payment is no receivable); a bill's cost for one is an expense like any +/// other, so a bill gets no such note. +#[test] +fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() { + let mut m = books("doc-on-spec"); + m.ok( + "books.add_project", + json!({"name": "Kestrel", "kind": "on_spec"}), + ); + m.ok( + "books.add_project", + json!({"name": "Zeta", "kind": "client"}), + ); + let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone(); + let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone(); + let out = m.ok( + "books.create_invoice", + json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [ + {"description": "Build", "amount": "10.00", "class": "Kestrel"}, + {"description": "More", "amount": "5.00", "class": "kestrel"}, + {"description": "Site", "amount": "7.00", "class": "Zeta"}, + ]}), + ); + let notes = out["notes"].as_array().unwrap(); + assert_eq!(notes.len(), 1, "{out}"); + let note = notes[0].as_str().unwrap(); + assert!( + note.contains("project Kestrel is on spec") && note.contains("is there now an agreement"), + "{note}" + ); + let e = out["entry_id"].as_u64().unwrap(); + assert_eq!( + m.entry_lines(e)[1], + l4("4000", "credit", "15.00", Some("Kestrel")) + ); + let bill = m.ok( + "books.create_bill", + json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [ + {"description": "Icons", "amount": "10.00", "class": "Kestrel"}, + ]}), + ); + assert!(bill.get("notes").is_none(), "{bill}"); +} + +/// D-39: books that declared `projects` but not +/// `document_line_projects` refuse a document naming a project, journaling +/// nothing, and take one naming none; the handbook tells them so. +#[test] +fn books_without_the_feature_refuse_a_tagged_document() { + let mut m = books_before_document_projects("doc-gate"); + m.ok( + "books.add_project", + json!({"name": "Zeta site", "kind": "client"}), + ); + let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone(); + let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone(); + let head = m.journal(); + for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] { + let why = m.refuse( + tool, + json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + {"description": "a", "amount": "10.00", "class": "Zeta site"}, + ]}), + ); + assert!( + why.starts_with("format_upgrade_pending:") && why.contains("document_line_projects"), + "{tool}: {why}" + ); + } + assert_eq!(m.journal(), head, "nothing journaled"); + m.ok( + "books.create_invoice", + json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + {"description": "a", "amount": "10.00"}, + ]}), + ); + let text = m.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"] + .as_str() + .unwrap() + .to_string(); + assert!( + text.contains("takes no project in these books until their format upgrade"), + "{text}" + ); + // The same section read as a resource tells the same books the same. + let served = m.handbook_resource(); + assert!( + served.contains("takes no project in these books until their format upgrade"), + "{served}" + ); + assert!(!served.contains("take class on each line"), "{served}"); + let mut fresh = books("doc-gate-fresh"); + let served = fresh.handbook_resource(); + assert!(served.contains("take class on each line"), "{served}"); + assert!(!served.contains("takes no project"), "{served}"); + let text = fresh.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"] + .as_str() + .unwrap() + .to_string(); + assert!(text.contains("take class on each line"), "{text}"); + assert!(!text.contains("takes no project"), "{text}"); +} diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 241fa4d..c39ccfc 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -60,6 +60,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ DISPOSAL_BEFORE_SERVICE, DEPRECIATION_CATCH_UP, PROJECTS, + DOCUMENT_LINE_PROJECTS, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -155,6 +156,13 @@ pub const ASSET_BASIS: &str = "asset_basis"; /// silently). pub const PROJECTS: &str = "projects"; +/// Feature: an `invoice.created` or `bill.created` line naming its project +/// (`class_id`, rules.md B-37 item 8). An older version would restore the +/// document without its lines' projects, silently. Its own feature, not +/// `projects`': books that consented to projects before it existed must +/// consent again before a document names one. +pub const DOCUMENT_LINE_PROJECTS: &str = "document_line_projects"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -742,6 +750,44 @@ mod tests { ); } + /// The version the live books run (every feature through `projects`) + /// stops at a declaration of `document_line_projects` as newer, never + /// restoring an invoice or bill without its lines' projects; and books + /// that declared `projects` alone still wait for it (B-27, D-39). + #[test] + fn document_line_projects_is_its_own_feature() { + assert!(KNOWN_FEATURES.contains(&DOCUMENT_LINE_PROJECTS)); + assert_ne!(DOCUMENT_LINE_PROJECTS, PROJECTS); + let before: Vec<&str> = KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != DOCUMENT_LINE_PROJECTS) + .collect(); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [PROJECTS, DOCUMENT_LINE_PROJECTS]}), + ); + match declared_knowing(&l, &before) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features document_line_projects") + } + other => panic!("expected NewerJournal, got {other:?}"), + } + let with_projects = JournalFormat { + format: 2, + features: before.iter().map(|f| f.to_string()).collect(), + }; + assert!(matches!( + with_projects.require_feature(DOCUMENT_LINE_PROJECTS), + Err(StoreError::FormatUpgradePending { .. }) + )); + let up = with_projects.upgrade().expect("waits for it"); + assert_eq!( + up.adds(), + vec!["the journal feature document_line_projects"] + ); + } + /// This version's format: the newest it reads, every feature it /// knows, each once, sorted (what new books and an upgrade declare). #[test] diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 56a5c79..c0bd0a2 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2816,10 +2816,7 @@ impl Store { } /// Journal an auto-posted two-line entry (debit one account, credit - /// another for the same amount) through the standard entry.created / - /// entry.line_added / entry.posted flow. Returns the predicted entry id - /// and the journaled lines; the caller mutates the ledger afterwards - /// and applies the lines to SQLite. + /// another for the same amount): [`Self::journal_auto_lines`]. fn journal_auto_entry( &mut self, date: NaiveDate, @@ -2827,10 +2824,32 @@ impl Store { debit_account: AccountId, credit_account: AccountId, amount: Decimal, + ) -> Result<(EntryId, Vec), StoreError> { + self.journal_auto_lines( + date, + description, + &[ + (debit_account, Side::Debit, amount, None), + (credit_account, Side::Credit, amount, None), + ], + ) + } + + /// Journal an auto-posted entry of `lines` (account, side, amount, + /// project) through the standard entry.created / entry.line_added / + /// entry.posted flow, a tagged line's `entry.line_class_set` right + /// after its `entry.line_added` (as `books.add_line` journals one). + /// Returns the predicted entry id and the journaled lines; the caller + /// mutates the ledger afterwards and applies the lines to SQLite. + fn journal_auto_lines( + &mut self, + date: NaiveDate, + description: &str, + lines: &[(AccountId, Side, Decimal, Option)], ) -> Result<(EntryId, Vec), StoreError> { let entry_id = self.ledger.next_entry_id(); let base = self.base_currency; - let mut journaled = Vec::with_capacity(4); + let mut journaled = Vec::with_capacity(lines.len() * 2 + 2); journaled.push(self.append_journal( "entry.created", json!({ @@ -2839,7 +2858,7 @@ impl Store { "description": description, }), )?); - for (account, side) in [(debit_account, Side::Debit), (credit_account, Side::Credit)] { + for (index, &(account, side, amount, class)) in lines.iter().enumerate() { journaled.push(self.append_journal( "entry.line_added", json!({ @@ -2853,7 +2872,30 @@ impl Store { "currency": base.code(), }), )?); + if let Some(class) = class { + journaled.push(self.append_journal( + "entry.line_class_set", + json!({ + "entry_id": entry_id, + "line_index": index as u64, + "class_id": class, + }), + )?); + } } + let posted: Vec = lines + .iter() + .map(|&(account, side, amount, class_id)| { + journal_line_json(&ledger_core::JournalLine { + account, + side, + amount: Money::new(amount, base), + foreign_currency: None, + foreign_amount: None, + class_id, + }) + }) + .collect(); journaled.push(self.append_journal( "entry.posted", json!({ @@ -2861,15 +2903,22 @@ impl Store { "date": date.to_string(), "description": description, "reversal_of": Option::::None, - "lines": [ - plain_line_json(debit_account, Side::Debit, Money::new(amount, base)), - plain_line_json(credit_account, Side::Credit, Money::new(amount, base)), - ], + "lines": posted, }), )?); Ok((entry_id, journaled)) } + /// The feature a document's lines need (rules.md B-27): a line naming + /// a project is `document_line_projects`'s; a document naming none is + /// written as every version before it wrote one. + fn ensure_document_lines(&self, lines: &[DocumentLine]) -> Result<(), StoreError> { + if lines.iter().any(|l| l.class_id.is_some()) { + self.ensure_feature(crate::format::DOCUMENT_LINE_PROJECTS)?; + } + Ok(()) + } + /// Add a customer. Journaled as `customer.added`. pub fn add_customer(&mut self, name: &str) -> Result { self.rehearse(|l| l.add_customer(name))?; @@ -2927,13 +2976,19 @@ impl Store { if let Some(pid) = self.ledger.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); } + self.ensure_document_lines(&lines)?; let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; let revenue = self.ensure_control_account(&ledger_core::REVENUE)?; let invoice_id = self.ledger.next_invoice_id(); let total: Decimal = lines.iter().map(|l| l.amount).sum(); let description = format!("Invoice {invoice_id}"); - let (entry_id, mut journaled) = - self.journal_auto_entry(date, &description, ar, revenue, total)?; + let mut posting = vec![(ar, Side::Debit, total, None)]; + posting.extend( + arap::document_parts(&lines, "the invoice")? + .into_iter() + .map(|(class, amount)| (revenue, Side::Credit, amount, class)), + ); + let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?; let doc_lines: Vec = lines.iter().map(document_line_json).collect(); journaled.push(self.append_journal( "invoice.created", @@ -2987,13 +3042,18 @@ impl Store { if let Some(pid) = self.ledger.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); } + self.ensure_document_lines(&lines)?; let expense = self.ensure_control_account(&ledger_core::GENERAL_EXPENSES)?; let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; let bill_id = self.ledger.next_bill_id(); let total: Decimal = lines.iter().map(|l| l.amount).sum(); let description = format!("Bill {bill_id}"); - let (entry_id, mut journaled) = - self.journal_auto_entry(date, &description, expense, ap, total)?; + let mut posting: Vec<_> = arap::document_parts(&lines, "the bill")? + .into_iter() + .map(|(class, amount)| (expense, Side::Debit, amount, class)) + .collect(); + posting.push((ap, Side::Credit, total, None)); + let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?; let doc_lines: Vec = lines.iter().map(document_line_json).collect(); journaled.push(self.append_journal( "bill.created", @@ -5422,10 +5482,16 @@ fn plain_line_json(account: AccountId, side: Side, amount: Money) -> Value { /// One invoice/bill document line as journal JSON. Amounts are decimal /// strings, never floats. fn document_line_json(l: &DocumentLine) -> Value { - json!({ + let mut v = json!({ "description": l.description, "amount": l.amount.to_string(), - }) + }); + // Its project, behind the feature document_line_projects (rules.md + // B-27, B-37 item 8); a line naming none is written as before it. + if let Some(c) = l.class_id { + v["class_id"] = json!(c); + } + v } /// Parse the `lines` array of an invoice/bill journal payload back into @@ -5442,6 +5508,18 @@ fn parse_document_lines(p: &Value) -> Result, StoreError> { amount: get_str(l, "amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad line amount".into()))?, + // Absent (every document before document_line_projects) + // or null: no project. + class_id: match l.get("class_id") { + None | Some(Value::Null) => None, + Some(c) => Some( + c.as_u64() + .and_then(|c| ClassId::try_from(c).ok()) + .ok_or_else(|| { + StoreError::JournalCorrupt("bad document line class_id".into()) + })?, + ), + }, }) }) .collect() diff --git a/crates/ledger-store/tests/attachments.rs b/crates/ledger-store/tests/attachments.rs index 44b5a08..8370705 100644 --- a/crates/ledger-store/tests/attachments.rs +++ b/crates/ledger-store/tests/attachments.rs @@ -321,6 +321,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec { vec![DocumentLine { description: "App".into(), amount: dec("100.00"), + class_id: None, }], ) .unwrap(); @@ -333,6 +334,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec { vec![DocumentLine { description: "Design".into(), amount: dec("200.00"), + class_id: None, }], ) .unwrap(); diff --git a/crates/ledger-store/tests/document_projects.rs b/crates/ledger-store/tests/document_projects.rs new file mode 100644 index 0000000..7de8558 --- /dev/null +++ b/crates/ledger-store/tests/document_projects.rs @@ -0,0 +1,514 @@ +//! An invoice's or bill's lines in the journal (rules.md B-37 item 8, +//! B-27): a line naming its project carries `class_id` (journal feature +//! `document_line_projects`), and the entry's revenue or expense line for +//! that project is tagged by `entry.line_class_set` right after its +//! `entry.line_added`. A document naming no project is journaled exactly +//! as before, and books that haven't declared the feature refuse one that +//! names a project, journaling nothing. +//! +//! ```text +//! GOAL: a document's projects are journaled once, replay exactly, and +//! never reach books whose owner hasn't consented to the format. +//! SOURCE: docs/spec/rules.md B-37 item 8, B-27, B-28; plan/served.md +//! D-39 (the owner's upgrade). +//! ``` + +use chrono::NaiveDate; +use ledger_core::arap::DocumentLine; +use ledger_core::{ClassId, Currency, ProjectFacts}; +use ledger_store::format::{DOCUMENT_LINE_PROJECTS, PROJECTS, current}; +use ledger_store::{JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +struct TestDir(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!( + "wanderbooks-document-projects-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn line(what: &str, c: i64, class: Option) -> DocumentLine { + DocumentLine { + description: what.into(), + amount: cents(c), + class_id: class, + } +} + +/// Every journal line, as (type, payload). +fn lines(dir: &Path) -> Vec<(String, Value)> { + std::fs::read_to_string(dir.join(JOURNAL_FILE)) + .unwrap() + .lines() + .map(|l| { + let v: Value = serde_json::from_str(l).unwrap(); + ( + v["type"].as_str().unwrap().to_string(), + v["payload"].clone(), + ) + }) + .collect() +} + +/// The lines journaled from `from` on, as (type, payload). +fn since(dir: &Path, from: usize) -> Vec<(String, Value)> { + lines(dir).split_off(from) +} + +fn project(s: &mut Store, name: &str) -> ClassId { + s.add_project(name, &ProjectFacts::default()).unwrap() +} + +#[test] +fn a_tagged_invoice_journals_each_projects_line_and_replays() { + let dir = test_dir("invoice"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let zeta = project(&mut s, "Zeta"); + let harbor = project(&mut s, "Harbor"); + let c = s.add_customer("Cedar").unwrap(); + // The control accounts first, so what follows is the invoice alone. + s.create_invoice(c, d(2026, 1, 2), d(2026, 2, 1), vec![line("x", 100, None)]) + .unwrap(); + let from = lines(&dir.0).len(); + let inv = s + .create_invoice( + c, + d(2026, 3, 2), + d(2026, 4, 1), + vec![ + line("Design", 300_000, Some(zeta)), + line("Hosting", 20_000, Some(harbor)), + line("Fee", 10_000, None), + ], + ) + .unwrap(); + let e = s.ledger().get_invoice(inv).unwrap().entry_id; + let (ar, rev) = ( + s.ledger().chart().find_by_code("1200").unwrap().id, + s.ledger().chart().find_by_code("4000").unwrap().id, + ); + let added = |account: u64, side: &str, amount: &str| { + ( + "entry.line_added".to_string(), + json!({"id": e, "account": account, "side": side, "amount": amount, "currency": "USD"}), + ) + }; + let class_set = |index: u64, class: ClassId| { + ( + "entry.line_class_set".to_string(), + json!({"entry_id": e, "line_index": index, "class_id": class}), + ) + }; + let got = since(&dir.0, from); + assert_eq!( + got[..7], + [ + ( + "entry.created".to_string(), + json!({"id": e, "date": "2026-03-02", "description": format!("Invoice {inv}")}) + ), + added(ar, "debit", "3300.00"), + added(rev, "credit", "3000.00"), + class_set(1, zeta), + added(rev, "credit", "200.00"), + class_set(2, harbor), + added(rev, "credit", "100.00"), + ] + ); + assert_eq!(got[7].0, "entry.posted"); + assert_eq!( + got[7].1["lines"], + json!([ + {"account": ar, "side": "debit", "amount": "3300.00", "currency": "USD"}, + {"account": rev, "side": "credit", "amount": "3000.00", "currency": "USD", "class_id": zeta}, + {"account": rev, "side": "credit", "amount": "200.00", "currency": "USD", "class_id": harbor}, + {"account": rev, "side": "credit", "amount": "100.00", "currency": "USD"}, + ]) + ); + assert_eq!(got[8].0, "invoice.created"); + assert_eq!( + got[8].1["lines"], + json!([ + {"description": "Design", "amount": "3000.00", "class_id": zeta}, + {"description": "Hosting", "amount": "200.00", "class_id": harbor}, + {"description": "Fee", "amount": "100.00"}, + ]) + ); + assert_eq!(got.len(), 9); + // Live, replayed, projected and the viewer's snapshot all agree. + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!(s.ledger().differs_from(&live).is_empty()); + assert_eq!( + s.ledger().get_invoice(inv).unwrap().lines[1].class_id, + Some(harbor) + ); + let snap = Snapshot::load(&dir.0).unwrap(); + assert!(snap.ledger().differs_from(&live).is_empty()); +} + +#[test] +fn a_tagged_bill_tags_its_expense_lines_never_the_payable() { + let dir = test_dir("bill"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let zeta = project(&mut s, "Zeta"); + let v = s.add_vendor("Dana").unwrap(); + let bill = s + .create_bill( + v, + d(2026, 3, 9), + d(2026, 4, 8), + vec![ + line("Misc", 2_500, None), + line("Illustration", 180_000, Some(zeta)), + ], + ) + .unwrap(); + let e = s.ledger().get_bill(bill).unwrap().entry_id; + let tags: Vec> = s + .ledger() + .get_entry(e) + .unwrap() + .lines() + .iter() + .map(|l| l.class_id) + .collect(); + assert_eq!(tags, vec![None, Some(zeta), None]); + let set: Vec = lines(&dir.0) + .into_iter() + .filter(|(t, _)| t == "entry.line_class_set") + .map(|(_, p)| p) + .collect(); + assert_eq!( + set, + vec![json!({"entry_id": e, "line_index": 1, "class_id": zeta})] + ); + // Its payment is balance-sheet only: no tag journaled. + let cash = s + .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false) + .unwrap(); + let before = lines(&dir.0).len(); + s.pay_bill(bill, d(2026, 4, 1), cents(182_500), cash) + .unwrap(); + assert!(since(&dir.0, before).iter().all(|(t, p)| { + t != "entry.line_class_set" + && p.get("class_id").is_none() + && p["lines"] + .as_array() + .is_none_or(|ls| ls.iter().all(|l| l.get("class_id").is_none())) + })); + s.verify().unwrap(); +} + +/// A document naming no project is journaled exactly as every version +/// before the feature journaled one: no `class_id` anywhere, no +/// `entry.line_class_set`, one revenue (expense) line. +/// Reversing a tagged invoice's entry journals one `entry.reversed`, whose +/// replay (Ledger::reverse_entry) carries each line's project; live and +/// replayed books agree that its projects net to zero. +#[test] +fn a_reversed_tagged_invoice_replays_with_its_projects_at_zero() { + let dir = test_dir("reversed"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let zeta = project(&mut s, "Zeta"); + let harbor = project(&mut s, "Harbor"); + let c = s.add_customer("Cedar").unwrap(); + let inv = s + .create_invoice( + c, + d(2026, 3, 2), + d(2026, 4, 1), + vec![ + line("Design", 300_000, Some(zeta)), + line("Hosting", 20_000, Some(harbor)), + ], + ) + .unwrap(); + let e = s.ledger().get_invoice(inv).unwrap().entry_id; + let from = lines(&dir.0).len(); + let r = s.reverse_entry(e, d(2026, 3, 31), "Reverse").unwrap(); + let types: Vec = since(&dir.0, from).into_iter().map(|(t, _)| t).collect(); + assert_eq!(types, ["entry.reversed"]); + let tags = |s: &Store| -> Vec> { + s.ledger() + .get_entry(r) + .unwrap() + .lines() + .iter() + .map(|l| l.class_id) + .collect() + }; + assert_eq!(tags(&s), [None, Some(zeta), Some(harbor)]); + let zero = |s: &Store, p: ClassId| { + let t = ledger_core::project_to_date(s.ledger(), p, d(2026, 12, 31)).unwrap(); + (t.revenue, t.expenses) == (Decimal::ZERO, Decimal::ZERO) + }; + assert!(zero(&s, zeta) && zero(&s, harbor)); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!(s.ledger().differs_from(&live).is_empty()); + assert_eq!(tags(&s), [None, Some(zeta), Some(harbor)]); + assert!(zero(&s, zeta) && zero(&s, harbor)); +} + +#[test] +fn an_untagged_document_journals_as_before() { + let dir = test_dir("untagged"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + project(&mut s, "Zeta"); + let c = s.add_customer("Cedar").unwrap(); + let v = s.add_vendor("Dana").unwrap(); + let from = lines(&dir.0).len(); + s.create_invoice( + c, + d(2026, 1, 5), + d(2026, 2, 4), + vec![line("A", 10_000, None), line("B", 2_550, None)], + ) + .unwrap(); + s.create_bill(v, d(2026, 1, 6), d(2026, 2, 5), vec![line("C", 999, None)]) + .unwrap(); + let got = since(&dir.0, from); + let types: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!( + types, + [ + "account.added", + "account.added", + "entry.created", + "entry.line_added", + "entry.line_added", + "entry.posted", + "invoice.created", + "account.added", + "account.added", + "entry.created", + "entry.line_added", + "entry.line_added", + "entry.posted", + "bill.created", + ] + ); + let text = got + .iter() + .map(|(_, p)| p.to_string()) + .collect::>() + .join("\n"); + assert!(!text.contains("class_id"), "{text}"); + assert_eq!( + got[6].1["lines"], + json!([{"description": "A", "amount": "100.00"}, {"description": "B", "amount": "25.50"}]) + ); +} + +/// The books the live server keeps until the owner consents again: this +/// version's format without `document_line_projects` (they declared +/// `projects`). A document naming a project is refused, journaling +/// nothing (not even its control accounts); one naming none is taken; +/// after the upgrade both are. +#[test] +fn a_document_naming_a_project_waits_for_the_feature() { + let dir = test_dir("gate"); + let mut format = current(); + format.features.retain(|f| f != DOCUMENT_LINE_PROJECTS); + assert!(format.features.iter().any(|f| f == PROJECTS)); + let mut s = Store::create_in_format(&dir.0, Currency::USD, &format).unwrap(); + let zeta = project(&mut s, "Zeta"); + let c = s.add_customer("Cedar").unwrap(); + let v = s.add_vendor("Dana").unwrap(); + let head = s.head(); + let waits = |r: Result| match r { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature document_line_projects") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + }; + let tagged = || { + vec![ + line("Design", 50_000, None), + line("Build", 1_000, Some(zeta)), + ] + }; + waits(s.create_invoice(c, d(2026, 3, 2), d(2026, 4, 1), tagged())); + waits(s.create_bill(v, d(2026, 3, 2), d(2026, 4, 1), tagged())); + assert_eq!(s.head(), head, "nothing journaled"); + assert!(s.ledger().chart().find_by_code("1200").is_none()); + // Untagged: as before, accepted. + s.create_invoice(c, d(2026, 3, 2), d(2026, 4, 1), vec![line("A", 100, None)]) + .unwrap(); + assert!(s.upgrade_format().unwrap().is_some()); + let inv = s + .create_invoice(c, d(2026, 3, 3), d(2026, 4, 2), tagged()) + .unwrap(); + s.create_bill(v, d(2026, 3, 3), d(2026, 4, 2), tagged()) + .unwrap(); + assert_eq!( + s.ledger().get_invoice(inv).unwrap().lines[1].class_id, + Some(zeta) + ); + s.verify().unwrap(); +} + +/// Invoices and bills an earlier version journaled replay unchanged: no +/// project on their lines, the entry as it was, and opening the books +/// writes nothing. +#[test] +fn old_invoices_and_bills_replay_unchanged() { + let dir = test_dir("old"); + let mut w = JournalWriter::open(&dir.0.join(JOURNAL_FILE)).unwrap(); + for (t, p) in [ + ("store.created", json!({"base_currency": "USD"})), + ( + "account.added", + json!({"account_type": "Asset", "code": "1200", "currency": "USD", "id": 1, "name": "Accounts Receivable", "parent": null, "placeholder": false}), + ), + ( + "account.added", + json!({"account_type": "Revenue", "code": "4000", "currency": "USD", "id": 2, "name": "Revenue", "parent": null, "placeholder": false}), + ), + ( + "account.added", + json!({"account_type": "Expense", "code": "6999", "currency": "USD", "id": 3, "name": "General Expenses", "parent": null, "placeholder": false}), + ), + ( + "account.added", + json!({"account_type": "Liability", "code": "2000", "currency": "USD", "id": 4, "name": "Accounts Payable", "parent": null, "placeholder": false}), + ), + ("class.added", json!({"id": 1, "name": "Zeta"})), + ("customer.added", json!({"id": 1, "name": "Cedar"})), + ("vendor.added", json!({"id": 1, "name": "Dana"})), + ( + "entry.created", + json!({"date": "2026-03-01", "description": "Invoice 1", "id": 1}), + ), + ( + "entry.line_added", + json!({"account": 1, "amount": "125.50", "currency": "USD", "id": 1, "side": "debit"}), + ), + ( + "entry.line_added", + json!({"account": 2, "amount": "125.50", "currency": "USD", "id": 1, "side": "credit"}), + ), + ( + "entry.posted", + json!({"date": "2026-03-01", "description": "Invoice 1", "id": 1, "reversal_of": null, + "lines": [{"account": 1, "amount": "125.50", "currency": "USD", "side": "debit"}, + {"account": 2, "amount": "125.50", "currency": "USD", "side": "credit"}]}), + ), + ( + "invoice.created", + json!({"customer_id": 1, "date": "2026-03-01", "due_date": "2026-03-31", "entry_id": 1, "id": 1, + "lines": [{"amount": "100.00", "description": "a"}, {"amount": "25.50", "description": "b"}]}), + ), + ( + "entry.created", + json!({"date": "2026-03-02", "description": "Bill 1", "id": 2}), + ), + ( + "entry.line_added", + json!({"account": 3, "amount": "40.00", "currency": "USD", "id": 2, "side": "debit"}), + ), + ( + "entry.line_added", + json!({"account": 4, "amount": "40.00", "currency": "USD", "id": 2, "side": "credit"}), + ), + ( + "entry.posted", + json!({"date": "2026-03-02", "description": "Bill 1", "id": 2, "reversal_of": null, + "lines": [{"account": 3, "amount": "40.00", "currency": "USD", "side": "debit"}, + {"account": 4, "amount": "40.00", "currency": "USD", "side": "credit"}]}), + ), + ( + "bill.created", + json!({"vendor_id": 1, "date": "2026-03-02", "due_date": "2026-04-01", "entry_id": 2, "id": 1, + "lines": [{"amount": "40.00", "description": "c"}]}), + ), + ] { + w.append(t, p).unwrap(); + } + drop(w); + let bytes = std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap(); + let s = Store::open(&dir.0).unwrap(); + s.verify().unwrap(); + let inv = s.ledger().get_invoice(1).unwrap(); + assert_eq!( + inv.lines, + vec![line("a", 10_000, None), line("b", 2_550, None)] + ); + assert_eq!( + s.ledger().get_bill(1).unwrap().lines, + vec![line("c", 4_000, None)] + ); + for e in [1, 2] { + let entry = s.ledger().get_entry(e).unwrap(); + assert_eq!(entry.lines().len(), 2); + assert!(entry.lines().iter().all(|l| l.class_id.is_none())); + } + assert!(ledger_core::project_entries(s.ledger(), 1).is_empty()); + drop(s); + assert_eq!(std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap(), bytes); +} + +/// A document line's `class_id` that isn't a class id is damage, never +/// silently no project; null is none, and an id is taken as journaled +/// (replay checks no line, B-28). +#[test] +fn a_document_lines_class_id_that_isnt_an_id_is_corrupt() { + let journal = |dir: &Path, class: Value| { + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append("store.created", json!({"base_currency": "USD"})) + .unwrap(); + w.append("customer.added", json!({"id": 1, "name": "Cedar"})) + .unwrap(); + w.append( + "invoice.created", + json!({"customer_id": 1, "date": "2026-03-01", "due_date": "2026-03-31", "entry_id": 1, "id": 1, + "lines": [{"amount": "1.00", "description": "a", "class_id": class}]}), + ) + .unwrap(); + }; + for (fine, want) in [(json!(null), None), (json!(7), Some(7))] { + let dir = test_dir("fine-class"); + journal(&dir.0, fine); + let s = Store::open(&dir.0).unwrap(); + assert_eq!(s.ledger().get_invoice(1).unwrap().lines[0].class_id, want); + } + for bad in [ + json!("1"), + json!(-1), + json!(1.5), + json!(u64::from(u32::MAX) + 1), + ] { + let dir = test_dir("bad-class"); + journal(&dir.0, bad.clone()); + assert!( + matches!(Store::open(&dir.0), Err(StoreError::JournalCorrupt(_))), + "{bad}" + ); + } +} diff --git a/crates/ledger-store/tests/roles.rs b/crates/ledger-store/tests/roles.rs index 628863e..08523ec 100644 --- a/crates/ledger-store/tests/roles.rs +++ b/crates/ledger-store/tests/roles.rs @@ -59,6 +59,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine { DocumentLine { description: desc.to_string(), amount: dec(amount), + class_id: None, } } diff --git a/crates/ledger-store/tests/settings.rs b/crates/ledger-store/tests/settings.rs index 643046b..da4e8af 100644 --- a/crates/ledger-store/tests/settings.rs +++ b/crates/ledger-store/tests/settings.rs @@ -349,6 +349,7 @@ fn approvals_cover_every_post() { let line = |n: i64| ledger_core::DocumentLine { description: "work".into(), amount: Decimal::from(n), + class_id: None, }; assert!(matches!( s.create_bill(v, d(2026, 10, 1), d(2026, 10, 31), vec![line(600)]), diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index dcb4eb2..fd8ae35 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -815,6 +815,7 @@ fn vendor_1099_tags_replay_and_project() { vec![DocumentLine { description: "Design".into(), amount: Decimal::from(1500), + class_id: None, }], ) .unwrap(); @@ -1209,6 +1210,7 @@ mod refusals { vec![DocumentLine { description: "work".into(), amount: Decimal::new(c, 2), + class_id: None, }] }; match *op { @@ -2778,6 +2780,7 @@ fn the_database_tracks_what_an_invoice_has_been_paid() { vec![DocumentLine { description: "work".into(), amount: Decimal::from(100), + class_id: None, }], ) .unwrap(); @@ -3325,6 +3328,7 @@ fn the_tax_category_policy_refuses_only_what_it_says() { vec![ledger_core::DocumentLine { description: "hosting".into(), amount: Decimal::new(4_200, 2), + class_id: None, }], ) .unwrap(); diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 5bbaa2b..31e78ab 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -505,7 +505,9 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. schedule); and a reversal of a depreciation run or a schedule month's posting (or of a reversal in its chain) dated before that entry's own date (a correction comes after what it corrects, B-9). -- `books.get_entry { entry_id }` → full entry with lines, receipts, and +- `books.get_entry { entry_id }` → full entry with lines (each `{ + account_id, code, name, side, amount, class_id?, project? }`: a tagged + line's project, its id and name), receipts, and its `vendor`, `party_id`, and `party` (the party's label, when it names exactly one) tags, `parties` / `parties_error` as in the `post_entry` preview, and `no_party_reason` when it is marked as having no @@ -1980,19 +1982,46 @@ accounting only: the caller supplies every figure), tax filing. - `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, 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). - `party_id` is the customer's `id` from `books.add_customer`, not its - `party_id`. + amount, class? }] }` → `{ id, total, entry_id, warnings?, notes? }`. + Auto-posts Dr `1200` Accounts Receivable / Cr `4000` Revenue. `warnings` + names a revenue account it created with no tax category (rules.md + B-32). `party_id` is the customer's `id` from `books.add_customer`, not + its `party_id`. A line's `class` (a project: name or id, resolved as + `books.add_line`'s) puts its revenue on that project (rules.md B-37 item + 8, the owner's decision D-43): one project or none per line, several + projects as several lines each with its own amount, never a percentage + (a cost truly shared names none: it reaches the projects only as + entries of their own under the company's monthly allocation convention, + when `books.conventions` has one, and otherwise stays untagged while the + agent asks the human). The entry's revenue side is one line per project named, in the + order each is first named, the untagged lines' as one (a document + naming none posts the one revenue line it always did); the receivable + line is never tagged. Every project is resolved before anything is + written (an unknown one is refused, the document whole). `notes`: one + per finished or abandoned project named, as `books.add_line`'s `note`, + and one per on-spec project named, asking whether there is now an + agreement (on spec, a hoped-for payment is no receivable until + received: B-37 item 4) (tagged anyway). Books that haven't declared the journal feature + `document_line_projects` (B-27; the live books until their owner's next + upgrade, D-39) refuse a line naming a project with + `format_upgrade_pending`, journaling nothing; a document naming none is + taken as before. - `books.create_bill { party_id, date, due_date, lines }` → `{ id, total, - entry_id, warnings? }`. Auto-posts Dr `6999` General Expenses / Cr - `2000` Accounts Payable; `warnings` as for invoices. `party_id` is the - vendor's `id` from `books.add_vendor`, not its `party_id`. + entry_id, warnings?, notes? }`. Auto-posts Dr `6999` General Expenses / + Cr `2000` Accounts Payable; `warnings` as for invoices. `party_id` is + the vendor's `id` from `books.add_vendor`, not its `party_id`. A line's + `class` puts its cost on that project, as an invoice's its revenue: one + expense line per project, the untagged lines' as one, the payable line + never tagged; `notes` as for invoices, but none for an on-spec project + (a cost of on-spec work is an expense like any other); the feature as + for invoices. - `books.receive_payment { document_id, date, amount, cash_account }` → - `{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R. + `{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R, neither line + tagged with a project (balance-sheet only: the invoice's lines carry + its projects). - `books.pay_bill { document_id, date, amount, cash_account }` → - `{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash. + `{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash, neither line + tagged with a project. - `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 (`as_of` omitted: today). @@ -2121,14 +2150,11 @@ until the owner upgrades them. - `books.record_payout` lines and adjustments take optional `class` (a project); its result gains `notes`, one per finished or abandoned project tagged, as `books.add_line`'s `note`. -- An invoice or a bill takes no project yet: `books.create_invoice` and - `books.create_bill` post their entry at once with untagged lines, and - only a draft's lines take a class (`books.add_line`'s `class`). Link the document to the - project (`books.link_attachment` subject kind `project`); when its lines - must count in the project's figures, book it as a hand-built entry - (`books.draft_entry` + `books.add_line` class) instead, which then is - no invoice or bill in the agings (an open item: tagged document - lines). +- An invoice's or bill's lines take a project too (`books.create_invoice` + and `books.create_bill`, `class` on a line, rules.md B-37 item 8), in + books that declared the journal feature `document_line_projects`; the + handbook's projects section says which these books are. Their payments + take none. - `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 diff --git a/docs/parity.md b/docs/parity.md index 0dfdb80..915c181 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -37,7 +37,7 @@ No other statuses. | A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5 | | A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5 | | Payroll runs (accounting only, no tax tables) + tax remittance | Payroll | done | — | -| Tracking classes (divisional P&L) | Class tracking | done | — | +| Tracking classes (divisional P&L) | Class tracking | done | invoice and bill lines take a class too (projects, rules.md B-37 item 8; QBO/Xero class or tracking per line) | | CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — | | Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) | | Scheduled transactions (recurring entries → drafts) | Scheduled txns / Recurring transactions | partial | hand-verified fixture incl. month-end dates; drafts only, by design | diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 9ba1c22..060dffe 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1585,7 +1585,12 @@ value at contribution and the §704(c) method, B-25) and `projects` (a `project.set`, a new event type: a class's project facts; an `attachment.linked` to the subject kind `project`, which an older version would call corrupt; a `decision.recorded` with `projects`, which an older -version would drop silently: B-37). A command asks for +version would drop silently: B-37). After P3: `document_line_projects` +(an `invoice.created` or `bill.created` line carrying `class_id`, its +project, B-37 item 8; an older version would restore the document without +it, silently). It is a feature of its own, not part of `projects`: books +that declared `projects` before it existed wait for their owner's next +upgrade before a document's line names a project. A command asks for the feature it needs (`Store::ensure_feature`) before its first journaled line, and only when the line it is about to write uses it, so a write refused for waiting on the upgrade journals nothing, never an entry @@ -2545,8 +2550,12 @@ package). **Checked:** `ledger-core` `tests/tax_capital.rs`; a class as a project ... Projects are entities too and can have name, description, whatever other metadata you think they should have"; the convention `link-to-projects` in the books: "whenever applicable, we link -things to [projects]"); CONVENTION (job costing, item 3; the snapshot, item 7: a presentation choice from management accounting, not GAAP); AUTHORITY (on -spec, item 4: ASC 450-30-25-1; ASC 340-40-25-5 with B-22; +things to [projects]"); DECISION (the owner, 2026-10-22, plan D-43: an invoice's or bill's +lines take a project, each line one or none, item 8); CONVENTION (job +costing, items 3 and 8; the snapshot, item 7: a presentation choice from +management accounting, not GAAP); AUTHORITY (on +spec, item 4: ASC 450-30-25-1; ASC 340-40-25-5 with B-22; directly +related costs, item 8: ASC 340-40-25-7; `sources/fasb-asc.md`, both quoted through Deloitte's DART roadmaps and not yet re-read in the ASC basic view: an open item) @@ -2582,8 +2591,10 @@ not yet re-read in the ASC basic view: an open item) such line through the day, from the first day the books keep, whatever the project's dates. A tagged balance-sheet line counts nothing (an asset bought for it counts as it is depreciated or expensed, tagged - then). No cost is allocated: a shared cost counts only as split across - lines when it is entered. The projects with the untagged lines + then). The books allocate no cost: a shared cost counts toward + projects only as allocation entries of their own, made by the + company's allocation convention (item 8). The projects with the + untagged lines partition the P&L. By month, the period's last 240 months at most. 4. **On spec.** Work done hoping to be paid: its costs are expenses when incurred (B-22; ASC 340-40-25-5 makes a cost of an anticipated @@ -2595,8 +2606,9 @@ not yet re-read in the ASC basic view: an open item) same). Its unrecovered cost, "if repaid", is expenses less revenue to date, never below zero: a figure, never an asset. 5. **Status.** Finished and abandoned projects still take lines (a late - cost, a final invoice); `books.add_line` and `books.record_payout` say - so when one is tagged. + cost, a final invoice); `books.add_line`, `books.record_payout`, + `books.create_invoice` and `books.create_bill` say so when one is + tagged. 6. Omitted dates mean what B-30 says (`books.project`'s period); to date is always stated as such, beside it. 7. **Snapshot (a presentation choice, not GAAP).** A project's net is @@ -2625,6 +2637,52 @@ not yet re-read in the ASC basic view: an open item) standard defines or requires these figures (ASC 280's segment reporting is for public entities' operating segments, not projects); they come from item 3's, one function the tools and the viewer share. +8. **Invoices and bills (the owner's decision, 2026-10-22, D-43).** Each + line of an invoice or bill names one project or none. A document that + spans N projects is N lines, each with its own amount; there is no + percentage split, so every figure is exact, as entered, with nothing + to round. A cost truly shared among projects is not split on the + document: its line names none, and it reaches the projects, if at + all, only as allocation entries of their own, on the stated basis of + the company's monthly allocation convention when `books.conventions` + has one; without one the line stays untagged and the agent asks the + human. The reasoning: job costing (item 3) charges a job the costs + traceable to it, its direct costs, whole, and reaches a shared + (indirect) cost only by an allocation on a stated basis, never by + guessing at the document. ASC 340-40-25-7 counts as relating + directly to a contract (25-5(a)) both the costs traceable to it + (direct labor and materials, costs explicitly chargeable to the + customer, costs incurred only because of the contract, such as + subcontractors: 25-7(a), (b), (d), (e)) and allocations of costs that + relate directly to the contract or its activities (25-7(c)); 25-8 + sends general and administrative costs to expense unless the contract + makes them chargeable to the customer (`sources/fasb-asc.md`). The + books carry the first kind whole on the document's line and the + second as entries under the allocation convention on its basis; + neither is a percentage on the document, and 25-7(c) is what the + allocation convention models. An invoice line naming an on-spec + project (item 4) is taken, and the tools ask whether there is now an + agreement: on spec, a hoped-for payment is no receivable until it is + received. Its revenue (an + invoice's) or cost (a bill's) then counts toward the project exactly + as a tagged line of a hand-built entry does: the document's entry + posts one revenue (expense) line per project its lines name, in the + order each is first named, each the sum of that project's lines, and + the untagged lines' sum as one line (`arap::document_parts`), so a + document naming none posts exactly the one line every document posted + before. The receivable (payable) line is never tagged: it is a + balance-sheet line (item 3 counts it nothing), and so are the + payments (`books.receive_payment`, `books.pay_bill`: cash against the + receivable or payable), which take no project. Nothing else moves: the + P&L, the 1099 worksheet (B-19: payments made) and a party's totals + (B-35: what moved the money) read the same with or without the + projects; only the projects' figures and the P&L by class differ. A + line naming a finished or abandoned project is taken, and the tools + say so (item 5); one naming a project the books don't have is refused, + the document whole. Journaled as the line's `class_id` in + `invoice.created` or `bill.created` and an `entry.line_class_set` right + after its revenue or expense line's `entry.line_added`, behind the + journal feature `document_line_projects` (B-27). **Enforced:** `ledger_core::classes::{ProjectFacts, ProjectStatus, ProjectKind}`, `Ledger::{check_set_project, set_project, @@ -2632,13 +2690,22 @@ replay_set_project, projects_of_party}`, `reports::{project_figures, project_to_date, project_entries, contribution_margin_pct}`, `ProjectToDate::{net, margin_pct, any_tagged}`, `ProjectFigures::{months_net_to_date, period_margin_pct, period_lines}`, `kernel::unrecovered_cost`; -`Store::{add_project, set_project, subject_feature}` (feature -`projects`); `books.add_project`, `books.update_project`, -`books.projects`, `books.project`; the viewer's Projects pages. -**Checked:** `ledger-core` `tests/spec_projects.rs`, `ledger-store` -`tests/projects.rs`, `ledger-mcp` `tests/spec_projects.rs`, `ledger-gui` -`tests/projects.rs`; bookspec `projects-net-is-its-tagged-lines` and -`projects-on-spec-costs-are-expenses`; Kani `unrecovered_cost_laws`; +`arap::document_parts`, `Ledger::{create_invoice, create_bill}` (item +8); `Store::{add_project, set_project, subject_feature}` (feature +`projects`), `Store::{create_invoice, create_bill}` (feature +`document_line_projects`); `books.add_project`, `books.update_project`, +`books.projects`, `books.project`, `books.create_invoice` and +`books.create_bill` (`class` on a line); the viewer's Projects pages and +an invoice's or bill's page (each line's project). +**Checked:** `ledger-core` `tests/spec_projects.rs` and +`tests/spec_document_projects.rs`, `ledger-store` `tests/projects.rs` and +`tests/document_projects.rs`, `ledger-mcp` `tests/spec_projects.rs`, +`ledger-gui` `tests/projects.rs`, `ledger-cli` `tests/projects_parity.rs` +and `tests/format_upgrade.rs` (`books_p2_served_cut_over_whole`); bookspec +`projects-net-is-its-tagged-lines`, +`projects-on-spec-costs-are-expenses` and +`projects-invoice-and-bill-lines-count-by-project`; proptest +`prop_document_parts_partition_the_total`; Kani `unrecovered_cost_laws`; proptests `prop_project_nets_partition_the_pnl`, `prop_project_set_round_trips`, `prop_project_contribution_is_the_net_to_date` (core) and `prop_snapshot_contribution_is_the_net_to_date` (viewer). diff --git a/docs/spec/sources/fasb-asc.md b/docs/spec/sources/fasb-asc.md index f46641f..6eccb0c 100644 --- a/docs/spec/sources/fasb-asc.md +++ b/docs/spec/sources/fasb-asc.md @@ -274,3 +274,32 @@ performance obligations in the future; and they "are expected to be recovered." → B-37 item 4 (with B-22): on-spec work has no contract it is expected to recover its costs from, so its costs are expensed as incurred. + +## 340-40-25-7 (Costs that relate directly to a contract) + +Added 2026-10-22 for B-37 item 8 as the DART revenue roadmap sets it +out; not re-read in the ASC basic view: check it there at the next +reading. The costs that "relate directly to a contract" (25-5(a)) are +(a) direct labor, (b) direct materials, (c) allocations of costs that +relate directly to the contract or its activities (supervision, +insurance, depreciation of the tools used on it), (d) costs explicitly +chargeable to the customer, and (e) other costs incurred only because +the entity entered into the contract (payments to subcontractors, for +one). Allocations are among the directly related costs, not set apart +from them. + +## 340-40-25-8 (Costs expensed when incurred) + +Added 2026-10-22 with 25-7, from the same DART chapter (13.3), likewise +to check in the basic view. Expensed when incurred: general and +administrative costs (unless explicitly chargeable to the customer under +the contract), costs of wasted materials, labor or other resources not +reflected in the contract's price, costs that relate to satisfied (or +partly satisfied) performance obligations, and costs the entity cannot +tell apart between satisfied and unsatisfied obligations. +→ B-37 item 8 (with 25-7): a cost traceable to one job (25-7(a), (b), +(d), (e)) is that job's, whole, on the document's line; a cost that +relates to several jobs reaches them only by an allocation on a stated +basis (25-7(c)), which the books record as the allocation convention's +own entries, never as a percentage on the document; general and +administrative cost (25-8) is company overhead, no project's. diff --git a/docs/testing.md b/docs/testing.md index 7a4b4f8..390fa0b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2028,6 +2028,116 @@ fail. rounding strategy; `project_figures`' opening net and running sum; `profit_and_loss_counted`'s counts; `Standing::of`'s order. +## Invoices and bills take a project (2026-10-22) + +rules.md B-37 item 8: each invoice and bill line takes an optional project +(`books.create_invoice` / `books.create_bill` `class`); the entry's revenue +or expense side is one line per project (`arap::document_parts`), the +receivable or payable and the payments untagged; journaled as the line's +`class_id` behind the new feature `document_line_projects` (B-27). + +- **ledger-core:** `tests/spec_document_projects.rs` + (`an_invoices_revenue_is_one_line_per_project_and_the_receivable_none`, + `a_bills_cost_is_one_line_per_project_and_the_payable_none`, + `an_untagged_document_posts_as_before`, + `a_line_naming_an_unknown_project_is_refused_whole`, + `a_finished_project_still_takes_an_invoice`, + `tags_move_only_the_project_figures` (twin books, tagged and not: the + P&L, the 1099 worksheet and a party's activity identical; the projects + with the untagged lines partition the P&L), + `document_parts_refuse_past_exact_arithmetic`, + `reversing_a_tagged_document_takes_its_projects_to_zero` (a partial + payment moves no project figure; the reversal carries each line's + project, so every project nets to zero), and the proptest + `prop_document_parts_partition_the_total`); bookspec + `projects-invoice-and-bill-lines-count-by-project` (the loader gains an + `invoice` event and a document's `lines` with `project`; loader tests + `a_document_line_naming_an_unknown_project_is_refused`, + `a_document_with_an_amount_and_lines_is_refused`, + `an_invoice_lines_project_is_what_the_project_counts`). +- **ledger-store:** `tests/document_projects.rs` + (`a_tagged_invoice_journals_each_projects_line_and_replays`: the exact + lines, then verify, reopen and the viewer's snapshot; + `a_tagged_bill_tags_its_expense_lines_never_the_payable`; + `an_untagged_document_journals_as_before`: no `class_id` anywhere; + `a_document_naming_a_project_waits_for_the_feature`: books with + `projects` but not this feature refuse, journaling nothing, not even + the control accounts; `old_invoices_and_bills_replay_unchanged`: an + earlier version's journal opens, verifies, writes nothing; + `a_document_lines_class_id_that_isnt_an_id_is_corrupt`; + `a_reversed_tagged_invoice_replays_with_its_projects_at_zero`: one + `entry.reversed`, its replay tagged as live); `format.rs` + `document_line_projects_is_its_own_feature`. +- **ledger-mcp:** `tests/spec_projects.rs` + (`invoice_and_bill_lines_count_toward_their_projects`: by name, id and + any capitals; `books.get_entry`'s line `project` and `class_id`; + payments untagged; + `books.project`, `profit_and_loss_by_class`, `vendor_1099` and + `books.party` totals; `a_document_naming_an_unknown_project_is_refused_whole`, + `a_document_for_a_finished_project_says_so`, + `an_invoice_for_an_on_spec_project_asks_about_an_agreement` (an + invoice's note, a bill's none), + `books_without_the_feature_refuse_a_tagged_document` with the + handbook's text both ways, by `books.handbook` and as the + `books://handbook/projects` resource); `handbook.rs` + `document_projects_follow_the_books_feature` (no sentence lets a shared + cost be split on a document; the allocation convention only when + `books.conventions` has one). +- **ledger-gui:** `tests/projects.rs` + (`an_invoice_and_a_bill_show_each_lines_project`: each document line's + project a link that opens it, untagged lines bare; + `the_demos_client_work_counts_every_client_invoice`); + `pages::entry::tests::a_tagged_line_keeps_its_amounts_in_their_column` + (in a narrow pane a tagged line's name is cut to leave its tag room: + looking at the demo's invoice page at 1,100 points found the tag pushing + the credit column past the pane, a fault every tagged entry had); + `tests/screens.rs` shoots an invoice's and a bill's page. +- **ledger-cli:** `tests/projects_parity.rs` + (`invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server`: + made through the server's tools, read by both, the month lists the same + length and not empty); `tests/format_upgrade.rs` + `books_p2_served_cut_over_whole` refuses a tagged invoice and bill + before consent and takes them after. +- **fuzz:** `fuzz_store_replay` can now declare features (this one + among them), so a document line's `class_id` reaches replay under a + declared format. + +**Broken to confirm (each restored):** the core's revenue/expense line +left untagged (core 4 and store 3 tests fail); `document_parts` one part +per line (4 core, the proptest among them); the store's feature check +dropped (store gate test, MCP `books_without_the_feature_...`, CLI +`books_p2_served_cut_over_whole`); `document_line_json` without +`class_id` (3 store); `parse_document_lines` ignoring it (4 store, the +corrupt-id test among them); the `entry.line_class_set` not journaled (3 +store, by verify); the MCP ignoring a line's `class` (4 MCP); no late-tag +notes (1 MCP); the handbook ungated (the handbook unit test and 1 MCP); +the handbook's rule without the owner's decision of D-43 (one project or +none a line, no percentage, a shared cost by the allocation convention: +the handbook unit test); +`books.get_entry` without `project` (2 MCP, the CLI parity test); the +viewer's document lines dropped, or their project not a link (the GUI +tests); no room kept back for the tag (the narrow-pane test); the demo's invoices untagged (the demo test); the bookspec loader +dropping a line's project (`every_bookspec_example_holds`). After +review: the projects handbook's old "split a shared cost across lines" +sentence restored, or the convention made unconditional (the handbook +unit test); no on-spec note, or one on a bill too (the on-spec MCP test); +the handbook resource served as if the feature were declared +(`books_without_the_feature_...`); `books.get_entry`'s `class_id` +dropped (`invoice_and_bill_lines_...`); the server's months cut short +(the parity test, which `zip` alone let pass); a reversal's line tags +dropped (the core and store reversal tests); the bookspec's Harbor made +on spec again (`every_bookspec_example_holds`). A separate +up-front unknown-class check in the core was removed: equivalent to +`set_line_class`'s own refusal inside `atomically`. + +**For P12 (mutation targets):** `document_parts` (the first-appearance +order, the `contains` test); `add_document_parts`' tag index; +`ensure_document_lines`' `any`; `document_line_json`'s `if let`; +`parse_document_lines`' arms (null, absent, out of `u32`); +`document_notes`' dedup and its `invoice` flag; `on_spec_invoice_note`'s +kind test; `document_projects_rule`'s branch; the viewer's +`document_of` (invoice before bill). + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b2fa419..467bb25 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -65,6 +65,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -101,6 +113,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "biblatex" version = "0.12.0" @@ -154,6 +172,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -163,6 +190,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borsh" version = "1.8.1" @@ -325,6 +361,12 @@ dependencies = [ "serde_path_to_error", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -406,6 +448,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -456,6 +507,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -477,6 +537,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "data-url" version = "0.3.2" @@ -531,14 +600,31 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "deunicode" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1055,6 +1141,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hypher" version = "0.1.8" @@ -1541,6 +1636,7 @@ version = "0.1.0" dependencies = [ "chrono", "csv", + "deunicode", "ledger-core", "qrcode", "rust_decimal", @@ -1558,14 +1654,18 @@ dependencies = [ name = "ledger-store" version = "0.1.0" dependencies = [ + "argon2", "chrono", + "getrandom 0.2.17", "hex", "ledger-core", + "libc", "rusqlite", "rust_decimal", "serde", "serde_json", "sha2", + "subtle", "thiserror", ] @@ -1819,6 +1919,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -1856,6 +1966,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -2478,8 +2599,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2607,6 +2728,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.16.1" diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index 94878f9..3175d3a 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -63,10 +63,16 @@ const FIELDS: &[&str] = &[ "status", "party_id", "started_on", "ended_on", "links", "notes", "projects", ]; +/// Journal features a `journal.format_set` may declare. +const FEATURES: &[&str] = &[ + "projects", "document_line_projects", "payment_instruments", "month_reposting", + "a_feature_from_the_future", +]; + /// A value replay might plausibly see: small ids, amounts as decimal /// strings, dates, sides, currencies, account types, nested objects. fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result { - Ok(match u.int_in_range(0..=12)? { + Ok(match u.int_in_range(0..=13)? { 0 => json!(u.int_in_range(0u64..=6)?), 1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=5000)?, u.int_in_range(0u32..=99)?)), 2 => json!(format!("2026-{:02}-{:02}", u.int_in_range(1u32..=12)?, u.int_in_range(1u32..=28)?)), @@ -83,6 +89,16 @@ fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result { 10 if depth < 2 => object(u, depth + 1)?, // 1099 decisions and payment methods (plan P3). 11 => json!(*u.choose(&["nec", "misc", "not_reportable", "corporation", "business_card", "member_card", "0042"])?), + // A declaration's features: some this version knows (projects, + // the invoice and bill lines' projects) and one it doesn't. + 12 => { + let n = u.int_in_range(0..=3)?; + Value::Array( + (0..n) + .map(|_| u.choose(FEATURES).map(|f| json!(*f))) + .collect::>()?, + ) + } _ => json!(String::arbitrary(u)?), }) } diff --git a/plan/STATUS.md b/plan/STATUS.md index 5ea5c4b..d2a831d 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -167,10 +167,27 @@ the net to date, and costs by category. The sidebar measures its footer. made; the bridge says a locked keyring instead of "make a token"; `token check` tells a kept token live, revoked, missing or duplicated. +**Invoices and bills take a project (2026-10-22).** Each line of +`books.create_invoice` and `books.create_bill` takes an optional `class` +(a project, by id or name): the entry's revenue or expense side is one +line per project, the untagged lines' as one, the receivable or payable +line and the payments never tagged (rules.md B-37 item 8), so a client +project's invoiced revenue and a contractor's billed cost count in its +figures, the P&L by class, and its snapshot; the P&L, the 1099 worksheet +and party totals don't move. Journaled as the line's `class_id` behind a +new feature, `document_line_projects` (not `projects`, which the live +books already declared); a document naming none is journaled byte for +byte as before. Late-tag notes as `books.add_line`'s; `books.get_entry` +shows a line's project; the viewer's invoice and bill pages list the +document's lines, each project a link; the demo's "Client work" carries +its invoices. The handbook's caveat is gone for books with the feature. + **Still open after P3 (each for the phase named, or the lead):** -- Invoices and bills take no project: a client project's invoiced revenue - can't count toward it (a format-gated field on `invoice.created` and - `bill.created`; the owner to decide). +- Invoices and bills take a project now (above), behind the new journal + feature `document_line_projects`: the first company's books declared + `projects` but not it, so until the owner's next format upgrade + (`wanderbooks books upgrade`, D-39) their invoices and bills still take + none (the handbook says so). - Raised by the cutover review (private change log, "Open points"): the IIF leaves its CLASS field empty although entries carry projects; a state filing fee is classed as cash paid to suppliers where ASC diff --git a/plan/served.md b/plan/served.md index bb15261..aed3911 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1336,6 +1336,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-40 | 1099s (P3, defects 23, 12 and 18; owner, 2026-10-20) | **A bank payment with no method recorded counts toward 1099s** (presumed check, ACH or transfer; debit-card payments are tagged as card). **Anthropic, PBC is recorded "not reportable: a corporation (Delaware public benefit corporation, from the public record)"**; Namecheap, Apple and OpenRouter "a corporation by name" (Treas. Reg. §1.6041-3(p)(1)), USPS "the United States" (§1.6041-3(p)(3)); Google LLC stays undecided until a W-9 shows what it is. **Needs attention asks only about vendors paid in a way that could need a 1099.** **A vendor's 1099 status is dated** (owner: "I think legally this has to be 2 dated statuses, right? If someone incorporates on Dec 30 …"): each status carries the date it takes effect (the W-9's date, or a tax election's effective date), and the worksheet applies the status in force on each payment's date. A payee that incorporates is a new legal person (a new vendor record with its own W-9); an LLC electing corporate taxation keeps its EIN and changes status on the election's date: dated statuses cover both. A status changed for a closed year warns. | | D-41 | The contributed devices' tax basis (P3, defect 13; owner, 2026-10-20) | **Depreciable basis is the lower of fair market value and the member's adjusted basis at conversion** (Treas. Reg. §1.168(i)-4(b); Pub. 946, property changed from personal use): for the four contributed devices, the CPA to confirm at year end. **§704(c) method: traditional**, recorded now and confirmed with the CPA before the first Form 1065 (for members filing jointly every method gives the household the same tax). **The four devices' values on 2026-10-15 are the books' amounts**, citing the entries that recorded them (figures in the company's private change log). **New P3 item: the K-1 worksheet reports tax-basis capital** (Form 1065 instructions, Item L, since 2020), the contributed property at its §723 carryover basis, beside the book figures. | | D-42 | A member paying for the company, on the cash flow statement (P3, defect 1; owner, 2026-10-20, revising D-35's first point after the review found ASC 230 silent on a payment a third party makes on the entity's behalf) | **Constructive receipt and disbursement:** when a member (or a lender) pays a company expense or purchase directly, the statement shows a financing inflow (borrowed from the member) and the operating or investing outflow, on that date; paying the member back is a financing outflow. Source: ASC 230 does not address it; practice fills the gap by substance (Deloitte DART Roadmap 7.2 with AICPA TQA 1300.16 and the SEC staff's view; PwC, BDO and EY guides alike), the way a CPA would present it; KPMG accepts noncash only for equipment a lender pays for. Property a member contributes that it already owned stays noncash (ASC 230-10-50-4: no cash moved). Total cash is the same either way; the first company's figures are in its private P3 change log. | +| D-43 | Invoices and bills take a project, line by line (owner, 2026-10-22) | **Each line of an invoice or bill takes one project or none.** A document that spans N projects is N lines, each with its own amount; no percentage split, so every figure is exact. A cost truly shared among projects names none on the document and reaches projects only as allocation entries of their own under the company's monthly allocation convention, when the books have one (otherwise it stays untagged and the agent asks the human). Reasoning: job costing charges a job its traceable (direct) costs whole and reaches shared costs only by allocation on a stated basis. ASC 340-40-25-7 counts as directly related both the costs traceable to a contract (25-7(a), (b), (d), (e)) and allocations of costs that relate directly to it (25-7(c)); 25-8 expenses general and administrative costs unless chargeable to the customer. The books carry a traceable cost whole on the document's line and an allocated cost as entries under the allocation convention on its basis; neither is a percentage on the document. Behind its own journal feature, `document_line_projects`, so books that declared `projects` wait for the owner's next upgrade (rules.md B-37 item 8, B-27). | ### 10.2 Deferred