From 321b94562ac47454074409db44ecbe9684139716 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 21 Oct 2026 16:07:55 -0600 Subject: [PATCH] Projects: classes as entities, and the sidebar fits short windows A project is a class with facts (description, status, kind, party, dates, links, notes) in one project.set event behind the format feature projects. Four tools (books.add_project, update_project, projects, project); documents and decisions link to projects; B-37: on-spec costs are expensed as incurred, repayment is revenue, and the unrecovered cost is never below zero (Kani). Viewer: a Projects list and a project page with its figures, by month and by account. The sidebar measures its footer instead of reserving a fixed 172 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- README.md | 2 +- bookspec/README.md | 13 +- .../projects-net-is-its-tagged-lines.toml | 162 +++ .../projects-on-spec-costs-are-expenses.toml | 138 ++ crates/ledger-core/src/analysis.rs | 14 +- crates/ledger-core/src/classes.rs | 253 ++++ crates/ledger-core/src/conventions.rs | 4 + crates/ledger-core/src/error.rs | 13 +- crates/ledger-core/src/kernel.rs | 21 + crates/ledger-core/src/ledger.rs | 213 +++- crates/ledger-core/src/lib.rs | 9 +- crates/ledger-core/src/proofs.rs | 22 + crates/ledger-core/src/reports.rs | 157 +++ crates/ledger-core/src/settings.rs | 29 +- crates/ledger-core/tests/bookspec.rs | 142 ++- crates/ledger-core/tests/properties.rs | 119 ++ crates/ledger-core/tests/spec_projects.rs | 633 ++++++++++ crates/ledger-gui/src/app.rs | 73 +- crates/ledger-gui/src/books.rs | 15 + crates/ledger-gui/src/charts.rs | 156 ++- crates/ledger-gui/src/demo.rs | 70 +- crates/ledger-gui/src/nav.rs | 12 +- crates/ledger-gui/src/pages/assets.rs | 10 + crates/ledger-gui/src/pages/cash.rs | 1 + crates/ledger-gui/src/pages/company.rs | 29 +- crates/ledger-gui/src/pages/documents.rs | 1 + crates/ledger-gui/src/pages/entry.rs | 14 +- crates/ledger-gui/src/pages/journal.rs | 25 +- crates/ledger-gui/src/pages/mod.rs | 3 + crates/ledger-gui/src/pages/overview.rs | 1 + crates/ledger-gui/src/pages/parties.rs | 100 +- crates/ledger-gui/src/pages/profit.rs | 51 +- crates/ledger-gui/src/pages/projects.rs | 915 ++++++++++++++ crates/ledger-gui/src/palette.rs | 21 + crates/ledger-gui/src/schemes.rs | 1 + crates/ledger-gui/src/widgets.rs | 53 +- crates/ledger-gui/tests/breadcrumbs.rs | 7 +- crates/ledger-gui/tests/edges.rs | 120 ++ crates/ledger-gui/tests/kept.rs | 2 + crates/ledger-gui/tests/old_books.rs | 96 ++ crates/ledger-gui/tests/projects.rs | 590 +++++++++ crates/ledger-gui/tests/read_only.rs | 7 + crates/ledger-gui/tests/screens.rs | 19 + crates/ledger-mcp/handbook/projects.md | 7 + crates/ledger-mcp/src/handbook.rs | 1 + crates/ledger-mcp/src/scopes.rs | 6 +- crates/ledger-mcp/src/server.rs | 105 +- crates/ledger-mcp/src/tools/kept.rs | 119 +- crates/ledger-mcp/src/tools/projects.rs | 390 ++++++ crates/ledger-mcp/src/types.rs | 284 ++++- crates/ledger-mcp/tests/hostile_args.rs | 18 + crates/ledger-mcp/tests/mcp_stdio.rs | 2 +- crates/ledger-mcp/tests/oracle_mcp.rs | 26 +- crates/ledger-mcp/tests/spec_mcp.rs | 40 +- crates/ledger-mcp/tests/spec_projects.rs | 1119 +++++++++++++++++ crates/ledger-mcp/tests/spec_vault.rs | 5 + crates/ledger-store/src/format.rs | 9 + crates/ledger-store/src/meta.rs | 88 +- crates/ledger-store/src/ops.rs | 8 +- crates/ledger-store/src/store.rs | 137 +- crates/ledger-store/tests/format.rs | 21 +- crates/ledger-store/tests/projects.rs | 513 ++++++++ crates/ledger-store/tests/settings.rs | 4 + docs/capability-parity.md | 5 + docs/glossary.md | 11 + docs/mcp-interface.md | 129 +- docs/spec/rules.md | 94 +- docs/spec/sources/fasb-asc.md | 25 + docs/testing.md | 141 ++- docs/verification.md | 18 +- fuzz/fuzz_targets/fuzz_store_replay.rs | 2 + 71 files changed, 7446 insertions(+), 217 deletions(-) create mode 100644 bookspec/examples/projects-net-is-its-tagged-lines.toml create mode 100644 bookspec/examples/projects-on-spec-costs-are-expenses.toml create mode 100644 crates/ledger-core/tests/spec_projects.rs create mode 100644 crates/ledger-gui/src/pages/projects.rs create mode 100644 crates/ledger-gui/tests/projects.rs create mode 100644 crates/ledger-mcp/handbook/projects.md create mode 100644 crates/ledger-mcp/src/tools/projects.rs create mode 100644 crates/ledger-mcp/tests/spec_projects.rs create mode 100644 crates/ledger-store/tests/projects.rs diff --git a/README.md b/README.md index 0be4e40..bdbebfb 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ just # lists the recipes: check, build, prove, demo, ... ``` crates/ledger-core/ # engine: money, accounts, journal, ledger, reports crates/ledger-store/ # journal + SQLite projection + blob store -crates/ledger-mcp/ # MCP server: 115 tools, resources, handbook, receipt policy +crates/ledger-mcp/ # MCP server: 119 tools, resources, handbook, receipt policy crates/ledger-import/ # QBO CSV import, IIF/XLSX export crates/ledger-config/ # wanderbooks.toml company settings crates/ledger-cli/ # `wanderbooks`: the one executable (every mode) diff --git a/bookspec/README.md b/bookspec/README.md index 694ce53..bcb4d65 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -227,6 +227,15 @@ the side gives the direction. once. A business card's liability is a card account (B-35 item 5): while none is recorded, a liability whose name says "card", not "gift card", is. +- `[[setup.projects]]` `{ name, kind?, status?, party? }`: a project + (rules.md B-37), added with its facts, each name once: `kind` + `internal`, `client`, `on_spec` or `other`; `status` `active` (the + default), `paused`, `finished` or `abandoned`; `party` a + `setup.parties` name. +- `project = ""` on a line of `lines`: the line is tagged with + that project (a `setup.projects` name), as the books' command tags it, + after the line is added. Not on a reversal, which carries its + 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. @@ -329,6 +338,7 @@ answers is refused, never skipped. | `register_row` | `asset`, `as_of` | the asset's row of the fixed asset register (B-15 rules 7 and 8, B-24): `acquired`, `in_service` (`YYYY-MM-DD`), `cost`, `accumulated_scheduled` (the schedule's), `accumulated_posted` (what the books carry), `unposted` (the one less the other), `book_value` (cost less posted), each `"0.00"` once disposed; the example names the figures it shows; an asset acquired after the day is refused | `ledger-core` `tests/bookspec.rs` | | `register_totals` | `as_of` | the register's totals over the assets held (B-24, B-25): `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`, `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`, `missing_basis` (a count), and how it ties to the books: `unregistered_cost`, `unposted_depreciation`, `unattributed_accumulated`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `asset_basis` | `asset`, no dates | the asset's basis facts and the tax figures they give (B-25): `rule`, `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`, `depreciable_basis`, `built_in_gain_704c` (a loss negative), each `"none"` where it has none, and `sec704c_1c` (`"true"` for a built-in loss, which counts only for the contributor); the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | +| `project` | `project`, `from`, `to` | the project's figures (B-37), every one given: `revenue`, `expenses` and `net` for the period (the revenue and expense lines tagged to it, closes left out), `to_date.revenue`, `to_date.expenses` and `to_date.net` (every tagged line through `to`), and `unrecovered_cost` (expenses less revenue to date, never below zero; `"none"` unless it is on spec) | `ledger-core` `tests/bookspec.rs`; `ledger-mcp` `project_figures_match_profit_and_loss_by_class` | | `schedule_plan` | `schedule`, no dates | the schedule's plan (B-16): what it recognizes at each month end named (`"YYYY-MM-DD"`), `"0.00"` at one it doesn't post; `total`, all it recognizes; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `iif_lines` | `as_of` | formats appendix: the file's lines in order, each its fields joined by tabs, without the CR LF that ends each | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | | `iif_changes` | `as_of` | formats appendix: each text the file writes differently from the books, in file order, as `[what, books' text, file's text]` (the example's header gives `what`) | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | @@ -338,7 +348,8 @@ format query, and `uncategorized_accounts`, an array (`values = [ ... ]`). `period`, `day` and `calendar_year` give dates and a year, `account_names` names, `party_part`'s `direction`, `vendor_1099`'s `status` and `party_tin_last4` words, not figures; `register_row` gives -dates beside its figures, `asset_basis` a rule's name, `"none"` and +dates beside its figures, `project` gives `"none"` as the unrecovered cost of a +project that isn't on spec, `asset_basis` a rule's name, `"none"` and `"true"` or `"false"`, and a `k1_tax_basis` line `"open"`, beside them. A command is tried on a copy of the books, as the books' own command diff --git a/bookspec/examples/projects-net-is-its-tagged-lines.toml b/bookspec/examples/projects-net-is-its-tagged-lines.toml new file mode 100644 index 0000000..1acb435 --- /dev/null +++ b/bookspec/examples/projects-net-is-its-tagged-lines.toml @@ -0,0 +1,162 @@ +# A project's figures are the revenue and expense lines tagged to it. +# +# wanderbooks: rules.md B-37 (projects); regression tests +# `project_figures_are_its_tagged_lines` (ledger-core tests/spec_projects.rs), +# `project_figures_match_profit_and_loss_by_class` (ledger-mcp +# tests/spec_projects.rs). +# +# Expected values worked by hand: Alpha earns 5,000.00 and costs 1,800.00 +# (contractor) + 120.00 (its part of the tool) = 1,920.00; the contractor +# billed twice in March is reversed, so March nets to zero. Beta costs +# 80.00; its test devices (an asset) count nothing. Rent is no project's. +# The projects and the untagged rent add up to the books' net income: +# 3,080.00 - 80.00 - 1,500.00 = 1,500.00. + +id = "projects-net-is-its-tagged-lines" +title = "A project's net is its tagged revenue less its tagged expenses" +rule = "A project's revenue and expenses for a period MUST be the revenue and expense lines tagged to it, dated in the period, closing entries left out; a tagged balance-sheet line MUST count nothing; a shared cost counts only as split across lines; and the projects with the untagged lines MUST add up to the period's net income." +status = "CONVENTION" +sources = [ + "Job-order costing: a job cost sheet accumulates the revenue and the direct costs identified with one job (management-accounting convention; no overhead is allocated here: wanderbooks docs/spec/rules.md B-37)", + "wanderbooks docs/spec/rules.md B-11 and B-14: a period's net income is its revenue less its expenses, closing entries left out", +] +tags = ["projects", "classes", "job-costing", "accrual"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" +[[setup.accounts]] +code = "1200" +name = "Equipment" +type = "asset" +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" +[[setup.accounts]] +code = "6000" +name = "Contractors" +type = "expense" +[[setup.accounts]] +code = "6100" +name = "Software" +type = "expense" +[[setup.accounts]] +code = "6200" +name = "Rent" +type = "expense" + +[[setup.projects]] +name = "Alpha" +kind = "client" +[[setup.projects]] +name = "Beta" +kind = "internal" + +[[events]] +date = "2026-02-03" +description = "Alpha milestone paid" +lines = [ + { account = "1000", side = "debit", amount = "5000.00" }, + { account = "4000", side = "credit", amount = "5000.00", project = "Alpha" }, +] +[[events]] +date = "2026-02-10" +description = "Contractor for Alpha" +lines = [ + { account = "6000", side = "debit", amount = "1800.00", project = "Alpha" }, + { account = "1000", side = "credit", amount = "1800.00" }, +] +[[events]] +date = "2026-02-15" +description = "Design tool, split by use" +lines = [ + { account = "6100", side = "debit", amount = "120.00", project = "Alpha" }, + { account = "6100", side = "debit", amount = "80.00", project = "Beta" }, + { account = "1000", side = "credit", amount = "200.00" }, +] +[[events]] +date = "2026-02-28" +description = "Studio rent" +lines = [ + { account = "6200", side = "debit", amount = "1500.00" }, + { account = "1000", side = "credit", amount = "1500.00" }, +] +[[events]] +date = "2026-03-05" +description = "Test devices for Beta" +lines = [ + { account = "1200", side = "debit", amount = "900.00", project = "Beta" }, + { account = "1000", side = "credit", amount = "900.00" }, +] +[[events]] +date = "2026-03-12" +description = "Contractor for Alpha, billed twice" +lines = [ + { account = "6000", side = "debit", amount = "1800.00", project = "Alpha" }, + { account = "1000", side = "credit", amount = "1800.00" }, +] +[[events]] +date = "2026-03-13" +description = "Reverse entry 6" +reverses = 6 + +[[expect]] +query = "project" +project = "Alpha" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +revenue = "5000.00" +expenses = "1920.00" +net = "3080.00" +"to_date.revenue" = "5000.00" +"to_date.expenses" = "1920.00" +"to_date.net" = "3080.00" +unrecovered_cost = "none" + +[[expect]] +query = "project" +project = "Alpha" +from = "2026-03-01" +to = "2026-03-31" +[expect.values] +revenue = "0.00" +expenses = "0.00" +net = "0.00" +"to_date.revenue" = "5000.00" +"to_date.expenses" = "1920.00" +"to_date.net" = "3080.00" +unrecovered_cost = "none" + +[[expect]] +query = "project" +project = "Beta" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +revenue = "0.00" +expenses = "80.00" +net = "-80.00" +"to_date.revenue" = "0.00" +"to_date.expenses" = "80.00" +"to_date.net" = "-80.00" +unrecovered_cost = "none" + +[[expect]] +query = "profit_and_loss" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +"4000" = "5000.00" +"6000" = "1800.00" +"6100" = "200.00" +"6200" = "1500.00" +net_income = "1500.00" diff --git a/bookspec/examples/projects-on-spec-costs-are-expenses.toml b/bookspec/examples/projects-on-spec-costs-are-expenses.toml new file mode 100644 index 0000000..cf11224 --- /dev/null +++ b/bookspec/examples/projects-on-spec-costs-are-expenses.toml @@ -0,0 +1,138 @@ +# Work on spec: its costs are expenses when incurred, and the hoped-for +# payment is income only when received; until then it is no receivable. +# +# wanderbooks: rules.md B-37 item 4; regression tests +# `unrecovered_cost_only_for_on_spec_and_never_negative` (ledger-core), +# `on_spec_project_shows_unrecovered_cost_and_others_null` (ledger-mcp). +# +# Expected values worked by hand: 1,400.00 + 60.00 = 1,460.00 of costs, +# nothing receivable (1100 stays 0.00). Repaid 1,000.00: 460.00 still +# unrecovered. Repaid 700.00 more: 1,700.00 in, 240.00 net, nothing +# unrecovered (never below zero). +# +# Both sources are quoted through Deloitte's DART roadmaps and not yet +# re-read in the ASC basic view (wanderbooks docs/spec/sources/fasb-asc.md): +# an open item. + +id = "projects-on-spec-costs-are-expenses" +title = "On-spec costs are expenses; a repayment is income when received" +rule = "The costs of work done on spec MUST be expensed as incurred, and a payment hoped for MUST NOT be recorded, as a receivable or as income, until it is received; a project's unrecovered cost MUST be its tagged expenses less its tagged revenue to date, never below zero." +status = "AUTHORITY" +sources = [ + "ASC 450-30-25-1: a contingency that might result in a gain usually is not reflected in the financial statements, since that might recognize revenue before its realization (originally FAS 5 ¶17(a))", + "ASC 340-40-25-5: costs to fulfill an anticipated contract are an asset only if, among other criteria, they are expected to be recovered (here recovery is uncertain: expensed; wanderbooks docs/spec/rules.md B-22)", +] +tags = ["projects", "on-spec", "gain-contingency", "accrual"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" +[[setup.accounts]] +code = "1100" +name = "Accounts Receivable" +type = "asset" +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" +[[setup.accounts]] +code = "6000" +name = "Contractors" +type = "expense" +[[setup.accounts]] +code = "6100" +name = "Hosting" +type = "expense" + +[[setup.projects]] +name = "Harbor app" +kind = "on_spec" + +[[events]] +date = "2026-04-02" +description = "Prototype build" +lines = [ + { account = "6000", side = "debit", amount = "1400.00", project = "Harbor app" }, + { account = "1000", side = "credit", amount = "1400.00" }, +] +[[events]] +date = "2026-04-20" +description = "Hosting for the prototype" +lines = [ + { account = "6100", side = "debit", amount = "60.00", project = "Harbor app" }, + { account = "1000", side = "credit", amount = "60.00" }, +] +[[events]] +date = "2026-06-15" +description = "Harbor repays part of the prototype" +lines = [ + { account = "1000", side = "debit", amount = "1000.00" }, + { account = "4000", side = "credit", amount = "1000.00", project = "Harbor app" }, +] +[[events]] +date = "2026-07-01" +description = "Harbor pays the rest, and more" +lines = [ + { account = "1000", side = "debit", amount = "700.00" }, + { account = "4000", side = "credit", amount = "700.00", project = "Harbor app" }, +] + +[[expect]] +query = "project" +after = 2 +project = "Harbor app" +from = "2026-01-01" +to = "2026-04-30" +[expect.values] +revenue = "0.00" +expenses = "1460.00" +net = "-1460.00" +"to_date.revenue" = "0.00" +"to_date.expenses" = "1460.00" +"to_date.net" = "-1460.00" +unrecovered_cost = "1460.00" + +[[expect]] +query = "balances" +after = 2 +as_of = "2026-04-30" +[expect.values] +"1100" = "0.00" +"6000" = "1400.00" +"6100" = "60.00" + +[[expect]] +query = "project" +after = 3 +project = "Harbor app" +from = "2026-01-01" +to = "2026-06-30" +[expect.values] +revenue = "1000.00" +expenses = "1460.00" +net = "-460.00" +"to_date.revenue" = "1000.00" +"to_date.expenses" = "1460.00" +"to_date.net" = "-460.00" +unrecovered_cost = "460.00" + +[[expect]] +query = "project" +project = "Harbor app" +from = "2026-01-01" +to = "2026-12-31" +[expect.values] +revenue = "1700.00" +expenses = "1460.00" +net = "240.00" +"to_date.revenue" = "1700.00" +"to_date.expenses" = "1460.00" +"to_date.net" = "240.00" +unrecovered_cost = "0.00" diff --git a/crates/ledger-core/src/analysis.rs b/crates/ledger-core/src/analysis.rs index 39c679d..27953a8 100644 --- a/crates/ledger-core/src/analysis.rs +++ b/crates/ledger-core/src/analysis.rs @@ -148,11 +148,23 @@ pub fn pnl_by_interval( start: NaiveDate, end: NaiveDate, interval: Interval, +) -> Result, LedgerError> { + pnl_by_interval_for(ledger, start, end, interval, None) +} + +/// [`pnl_by_interval`] for one class (project): only the lines tagged +/// with it count (`None`: every line). +pub fn pnl_by_interval_for( + ledger: &Ledger, + start: NaiveDate, + end: NaiveDate, + interval: Interval, + class: Option, ) -> Result, LedgerError> { intervals(start, end, interval) .into_iter() .map(|(s, e)| { - let pl = profit_and_loss_for_period(ledger, s, e, None)?; + let pl = profit_and_loss_for_period(ledger, s, e, class)?; Ok(IntervalPnl { start: s, end: e, diff --git a/crates/ledger-core/src/classes.rs b/crates/ledger-core/src/classes.rs index 57c14f5..b23b254 100644 --- a/crates/ledger-core/src/classes.rs +++ b/crates/ledger-core/src/classes.rs @@ -8,7 +8,12 @@ //! //! Classes are master data, journaled like accounts. Tagging a line happens //! on drafts only; posted entries are immutable, so their classes are frozen. +//! +//! A class is also a project: the class with its facts ([`ProjectFacts`]: +//! what it is, where it stands, who it is for; rules.md B-37). Every class +//! is one; a class an earlier version wrote has the default facts. +use chrono::NaiveDate; use serde::{Deserialize, Serialize}; /// Opaque class handle. Created by [`crate::Ledger`]; never forged. @@ -23,6 +28,254 @@ pub struct Class { pub name: String, } +/// Where a project stands (rules.md B-37). +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, +)] +pub enum ProjectStatus { + /// Under way (the default: every class an earlier version wrote). + #[default] + Active, + /// Set aside for now, expected to resume. + Paused, + /// Done: its work is delivered or shipped. + Finished, + /// Dropped before it was done. + Abandoned, +} + +impl ProjectStatus { + /// Every status, in display order. + pub const ALL: [ProjectStatus; 4] = [ + ProjectStatus::Active, + ProjectStatus::Paused, + ProjectStatus::Finished, + ProjectStatus::Abandoned, + ]; + + /// The key the journal and the tools use. + pub fn key(self) -> &'static str { + match self { + ProjectStatus::Active => "active", + ProjectStatus::Paused => "paused", + ProjectStatus::Finished => "finished", + ProjectStatus::Abandoned => "abandoned", + } + } + + /// The status a key names, if any. + pub fn parse(key: &str) -> Option { + Self::ALL.into_iter().find(|s| s.key() == key) + } + + /// Whether the project is over (finished or abandoned): only then may + /// it have an end date. + pub fn is_over(self) -> bool { + matches!(self, ProjectStatus::Finished | ProjectStatus::Abandoned) + } + + /// The status in words, for the viewer. + pub fn words(self) -> &'static str { + match self { + ProjectStatus::Active => "Active", + ProjectStatus::Paused => "Paused", + ProjectStatus::Finished => "Finished", + ProjectStatus::Abandoned => "Abandoned", + } + } +} + +/// What kind of work a project is (rules.md B-37): it decides the +/// accounting questions its costs raise. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ProjectKind { + /// The company's own product. + Internal, + /// Work for a customer. + Client, + /// Work done hoping to be paid: its costs are expenses, and a + /// repayment is income only when received (ASC 450-30-25-1). + OnSpec, + /// Anything else. + Other, +} + +impl ProjectKind { + /// Every kind, in display order. + pub const ALL: [ProjectKind; 4] = [ + ProjectKind::Internal, + ProjectKind::Client, + ProjectKind::OnSpec, + ProjectKind::Other, + ]; + + /// The key the journal and the tools use. + pub fn key(self) -> &'static str { + match self { + ProjectKind::Internal => "internal", + ProjectKind::Client => "client", + ProjectKind::OnSpec => "on_spec", + ProjectKind::Other => "other", + } + } + + /// The kind a key names, if any. + pub fn parse(key: &str) -> Option { + Self::ALL.into_iter().find(|k| k.key() == key) + } + + /// The kind in words, for the viewer. + pub fn words(self) -> &'static str { + match self { + ProjectKind::Internal => "Internal product", + ProjectKind::Client => "Client work", + ProjectKind::OnSpec => "On spec", + ProjectKind::Other => "Other", + } + } +} + +/// The most links a project keeps. +pub const MAX_LINKS: usize = 8; + +/// One link: one line of text ([`crate::settings::line_text`]'s length +/// and control-character rules) with no full TIN as a web address holds +/// one ([`crate::settings::holds_printed_tin_in_link`]: a printed SSN or +/// EIN is refused, a store listing's `id284882215` is not; rules.md B-26). +fn link_text(l: &str) -> Result { + use crate::settings::{MAX_TEXT, holds_printed_tin_in_link, line_text}; + // line_text's TIN rule is a plain text's, so it sees the link with + // its digits masked; the link's own rule runs on the link itself. + let masked: String = l + .chars() + .map(|c| if c.is_numeric() { 'x' } else { c }) + .collect(); + line_text("link", &masked, MAX_TEXT)?; + let t = l.trim(); + if holds_printed_tin_in_link(t) { + return Err( + "link holds a number that reads as a full SSN or EIN (printed 3-2-4 or 2-7, or nine digits standing alone): a full TIN goes only into the vault (books.set_tin)" + .to_string(), + ); + } + Ok(t.to_string()) +} + +/// A project's facts: what a class is, beyond its name. +/// +/// ```text +/// GOAL: say what a project is, where it stands and who it is for, in +/// the few facts a job cost sheet carries, without a project +/// manager's sprawl (no budgets, estimates or percent complete). +/// SOURCE: docs/spec/rules.md B-37; DECISION (owner, 2026-10-21: "Projects +/// are entities too and can have name, description, whatever +/// other metadata"); job-order costing (a job cost sheet: the +/// job, its customer, its dates). +/// INVARIANTS: +/// 1. No field holds a full taxpayer identification number +/// (settings::line_text / long_text, rules.md B-26). +/// 2. ended_on only when the project is finished or abandoned, and +/// never before started_on. +/// 3. The party, if any, is in the directory (archived allowed). +/// 4. Links: at most MAX_LINKS, each one line without whitespace, none +/// twice, in the order given. +/// 5. The dates are informational: no figure filters by them. +/// ``` +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectFacts { + /// The scope of work: what the project is. + #[serde(default)] + pub description: Option, + /// Where it stands. + #[serde(default)] + pub status: ProjectStatus, + /// What kind of work it is; `None`: not recorded. + #[serde(default)] + pub kind: Option, + /// Who it is for or with, if anyone. + #[serde(default)] + pub party: Option, + /// When it started. + #[serde(default)] + pub started_on: Option, + /// When it ended (finished or abandoned only). + #[serde(default)] + pub ended_on: Option, + /// Web addresses: its site, its store listing, its repository. + #[serde(default)] + pub links: Vec, + /// Anything else. + #[serde(default)] + pub notes: Option, +} + +impl ProjectFacts { + /// Check the facts' form (INVARIANTS 1, 2 and 4; the party is the + /// ledger's to check) and return them normalized: text trimmed. + pub fn check(&self) -> Result { + use crate::settings::{MAX_LONG_TEXT, long_text}; + let description = self + .description + .as_deref() + .map(|t| long_text("description", t, MAX_LONG_TEXT)) + .transpose()?; + let notes = self + .notes + .as_deref() + .map(|t| long_text("notes", t, MAX_LONG_TEXT)) + .transpose()?; + for (what, d) in [("started_on", self.started_on), ("ended_on", self.ended_on)] { + if let Some(d) = d { + crate::calendar::check(d).map_err(|e| format!("{what}: {e}"))?; + } + } + if let Some(end) = self.ended_on { + if !self.status.is_over() { + return Err(format!( + "ended_on is for a finished or abandoned project; this one is {}: set status to finished or abandoned, or clear ended_on", + self.status.key() + )); + } + if let Some(start) = self.started_on + && end < start + { + return Err(format!("ended_on {end} is before started_on {start}")); + } + } + if self.links.len() > MAX_LINKS { + return Err(format!("a project keeps at most {MAX_LINKS} links")); + } + let mut links: Vec = Vec::with_capacity(self.links.len()); + for l in &self.links { + let l = link_text(l)?; + if l.chars().any(char::is_whitespace) { + return Err(format!( + "link {l:?} holds a space: one web address per link" + )); + } + if links.iter().any(|o| o == &l) { + return Err(format!("link {l:?} is given twice")); + } + links.push(l); + } + Ok(ProjectFacts { + description, + status: self.status, + kind: self.kind, + party: self.party, + started_on: self.started_on, + ended_on: self.ended_on, + links, + notes, + }) + } + + /// Whether these are the defaults: a project with only its name. + pub fn is_default(&self) -> bool { + *self == ProjectFacts::default() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-core/src/conventions.rs b/crates/ledger-core/src/conventions.rs index c2fc0f1..15a24b6 100644 --- a/crates/ledger-core/src/conventions.rs +++ b/crates/ledger-core/src/conventions.rs @@ -273,6 +273,8 @@ pub struct NewDecision { pub supersedes: Option, /// The rule or document it rests on, if any. pub citation: Option, + /// The projects (classes) it is about, if any (rules.md B-37). + pub projects: Vec, } /// A decision as the journal holds it. @@ -794,6 +796,7 @@ pub fn check_decision(d: &NewDecision) -> Result { .as_deref() .map(|c| long_text("citation", c, MAX_LONG_TEXT)) .transpose()?, + projects: d.projects.clone(), }) } @@ -982,6 +985,7 @@ mod tests { evidence_attachment: None, supersedes: None, citation: None, + projects: vec![], }; assert!(c.apply_decision(2, d.clone(), 1).is_err()); c.apply_decision(1, d.clone(), 1).unwrap(); diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index a634fb1..c2d63f8 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -654,14 +654,23 @@ pub enum LedgerError { #[error("unknown payroll run id {0}")] UnknownPayrollRun(u64), - /// Class names must not be empty. - #[error("class name must not be empty")] + /// Class (project) names must not be blank. + #[error("a project (class) name is blank")] EmptyClassName, /// The referenced class does not exist. #[error("unknown class id {0}")] UnknownClass(u32), + /// Another class (project) already has this name, ignoring capitals + /// (rules.md B-37). + #[error("a project named {0:?} already exists: names are unique, ignoring capitals")] + DuplicateClassName(String), + + /// A project's facts the books can't take (rules.md B-37): the reason. + #[error("{0}")] + InvalidProject(String), + /// A setting change the books can't honor (crate::settings): an /// unknown key, a value its key refuses, or settings that wouldn't /// hold together. diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index f566477..b671e97 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -1491,6 +1491,27 @@ pub fn basis_figures( }) } +/// A project's unrecovered cost from its tagged revenue and expenses to +/// date: what it would take to be repaid in full (rules.md B-37 item 4). +/// +/// ```text +/// GOAL: the "if repaid" figure of work done on spec, from one function +/// the tools, the viewer and bookspec share. +/// SOURCE: docs/spec/rules.md B-37 item 4 (ASC 450-30-25-1: a hoped-for +/// payment is a gain contingency, never recorded before it is +/// received; so this is a figure, never a receivable). +/// INVARIANTS: +/// 1. Never below zero. +/// 2. expenses - revenue when expenses >= revenue; zero otherwise. +/// 3. Never above max(expenses, 0) when revenue >= 0 (expenses can be +/// negative: a reimbursement credited to the expense above its cost). +/// 4. NEVER wraps: None when the difference can't be represented. +/// ``` +pub fn unrecovered_cost(revenue: T, expenses: T) -> Option { + let gap = expenses.checked_sub(revenue)?; + Some(if gap > T::ZERO { gap } else { T::ZERO }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 7d1dae5..c02f851 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -237,6 +237,9 @@ pub struct Ledger { payroll_runs: HashMap, /// Classes for divisional P&L tracking. classes: HashMap, + /// The project facts set for classes (rules.md B-37); a class not here + /// has the defaults. + projects: std::collections::BTreeMap, next_account_id: AccountId, next_entry_id: EntryId, next_asset_id: AssetId, @@ -307,6 +310,7 @@ impl Ledger { fx_rates: HashMap::new(), payroll_runs: HashMap::new(), classes: HashMap::new(), + projects: std::collections::BTreeMap::new(), next_account_id: 1, next_entry_id: 1, next_asset_id: 1, @@ -670,6 +674,7 @@ impl Ledger { ("fx_rates", self.fx_rates == other.fx_rates), ("payroll_runs", self.payroll_runs == other.payroll_runs), ("classes", self.classes == other.classes), + ("projects", self.projects == other.projects), ( "next_account_id", self.next_account_id == other.next_account_id, @@ -6170,6 +6175,14 @@ impl Ledger { ) -> Result<(u64, crate::conventions::NewDecision), LedgerError> { let bad = LedgerError::InvalidConvention; let d = crate::conventions::check_decision(d).map_err(bad)?; + for (i, p) in d.projects.iter().enumerate() { + if !self.classes.contains_key(p) { + return Err(LedgerError::UnknownClass(*p)); + } + if d.projects[..i].contains(p) { + return Err(bad(format!("project {p} is named twice"))); + } + } if let Some(old) = d.supersedes { match self.conventions.decision(old) { None => { @@ -6478,21 +6491,77 @@ impl Ledger { // Classes // ------------------------------------------------------------------ - /// Add a class (division) for line-level P&L tracking. Returns its id. + /// Add a class (division) for line-level P&L tracking: a project with + /// only its name (rules.md B-37). Returns its id. + /// + /// ```text + /// GOAL: a new class under a name no other class has. + /// SOURCE: docs/spec/rules.md B-37 item 1. + /// SCENARIOS: + /// 1. IF the name is blank: SHOULD refuse (EmptyClassName). + /// 2. IF it is longer than a line, holds a control character or a + /// full TIN: SHOULD refuse. + /// 3. IF another class has it, ignoring capitals and surrounding + /// space: SHOULD refuse (DuplicateClassName). Replay never checks + /// (replay_class), so old books holding a duplicate still open. + /// 4. IF it is digits only: SHOULD refuse (it would read as an id). + /// ``` pub fn add_class(&mut self, name: &str) -> Result { + let name = self.check_class_name(name, None)?; + let id = self.next_class_id; + self.next_class_id += 1; + self.classes.insert(id, Class { id, name }); + Ok(id) + } + + /// A class name, trimmed, if a class (other than `except`) may take it. + /// + /// ```text + /// GOAL: one rule for every name a command gives a class. + /// SOURCE: docs/spec/rules.md B-37 item 1; B-26 (no full TIN). + /// SCENARIOS: + /// 1. IF `except` is a class and the name is its own, exactly: SHOULD + /// keep it as stored, unchecked, so a fact-only change never + /// renames (an earlier version stored names untrimmed) and never + /// refuses a name the books already hold. + /// 2. IF it is the class's own name up to capitals and surrounding + /// space: SHOULD check its text but not its uniqueness, so old + /// books holding a duplicate (replay never checks) can still + /// change either class. + /// 3. IF it is digits only: SHOULD refuse, since a project is named + /// by id or name and such a name would read as an id. + /// 4. IF another class has it, ignoring capitals and surrounding + /// space: SHOULD refuse (DuplicateClassName). + /// ``` + fn check_class_name(&self, name: &str, except: Option) -> Result { + let current = except.and_then(|id| self.classes.get(&id)); + if let Some(c) = current + && c.name == name + { + return Ok(c.name.clone()); + } if name.trim().is_empty() { return Err(LedgerError::EmptyClassName); } - let id = self.next_class_id; - self.next_class_id += 1; - self.classes.insert( - id, - Class { - id, - name: name.to_string(), - }, - ); - Ok(id) + let name = crate::settings::line_text("project name", name, crate::settings::MAX_TEXT) + .map_err(LedgerError::InvalidProject)?; + let folded = fold_class_name(&name); + if current.is_some_and(|c| fold_class_name(&c.name) == folded) { + return Ok(name); + } + if name.chars().all(|c| c.is_ascii_digit()) { + return Err(LedgerError::InvalidProject(format!( + "project name {name:?} is digits only, so it would read as a project id: add a word (\"Job {name}\")" + ))); + } + if self + .classes + .values() + .any(|c| Some(c.id) != except && fold_class_name(&c.name) == folded) + { + return Err(LedgerError::DuplicateClassName(name)); + } + Ok(name) } /// Look up a class. @@ -6509,11 +6578,15 @@ impl Ledger { .collect() } - /// Find a class by name, case-insensitive. + /// Find a class by name, ignoring capitals (in any script) and + /// surrounding space: the fold the duplicate check uses. Among old + /// books' duplicates (replay never checks), the lowest id. pub fn find_class_by_name(&self, name: &str) -> Option<&Class> { + let folded = fold_class_name(name); self.classes .values() - .find(|c| c.name.eq_ignore_ascii_case(name)) + .filter(|c| fold_class_name(&c.name) == folded) + .min_by_key(|c| c.id) } /// Tag a draft entry's line with a class, or clear it with `None`. @@ -6563,6 +6636,120 @@ impl Ledger { self.classes.insert(id, Class { id, name }); Ok(()) } + + /// A class's project facts: the ones set, or the defaults for a class + /// that has none (every class an earlier version wrote). `None` for no + /// such class. + pub fn project_facts(&self, id: ClassId) -> Option { + self.classes.get(&id)?; + Some(self.projects.get(&id).cloned().unwrap_or_default()) + } + + /// The projects for or with a party, by name (ignoring capitals). + pub fn projects_of_party(&self, party: crate::PartyId) -> Vec { + let mut ids: Vec = self + .projects + .iter() + .filter(|(_, f)| f.party == Some(party)) + .map(|(id, _)| *id) + .filter(|id| self.classes.contains_key(id)) + .collect(); + ids.sort_by_key(|id| { + let c = &self.classes[id]; + (c.name.to_lowercase(), c.id) + }); + ids + } + + /// Check a whole project record before it is journaled: returns the + /// trimmed name and the normalized facts. Changes nothing. + /// + /// ```text + /// GOAL: refuse any project record the books can't take, before a + /// line is written. + /// SOURCE: docs/spec/rules.md B-37 item 1 (and B-26: no full TIN). + /// SCENARIOS: + /// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass). + /// 2. IF the name is blank, too long, or another class has it + /// (ignoring capitals; the class itself excepted): SHOULD refuse. + /// 3. IF a fact breaks its rule (ProjectFacts::check): SHOULD refuse. + /// 4. IF the party is not in the directory: SHOULD refuse. + /// ``` + pub fn check_set_project( + &self, + id: ClassId, + name: &str, + facts: &crate::classes::ProjectFacts, + ) -> Result<(String, crate::classes::ProjectFacts), LedgerError> { + if !self.classes.contains_key(&id) { + return Err(LedgerError::UnknownClass(id)); + } + let name = self.check_class_name(name, Some(id))?; + let facts = facts.check().map_err(LedgerError::InvalidProject)?; + if let Some(p) = facts.party + && self.get_party(p).is_none() + { + return Err(LedgerError::InvalidProject(format!( + "party {p} is not in the directory: books.parties lists them" + ))); + } + Ok((name, facts)) + } + + /// Apply a project record after [`Ledger::check_set_project`]: rename + /// the class, keep the facts. + pub fn set_project( + &mut self, + id: ClassId, + name: &str, + facts: crate::classes::ProjectFacts, + ) -> Result<(), LedgerError> { + let (name, facts) = self.check_set_project(id, name, &facts)?; + self.put_project(id, name, facts); + Ok(()) + } + + /// Replay a journaled project record: only what replay can't do + /// without (the class and the party exist), never the text rules, so a + /// rule tightened later never breaks old books (as + /// `AssetBasis::check_journaled`, rules.md B-25). + #[doc(hidden)] + pub fn replay_set_project( + &mut self, + id: ClassId, + name: String, + facts: crate::classes::ProjectFacts, + ) -> Result<(), LedgerError> { + if !self.classes.contains_key(&id) { + return Err(LedgerError::UnknownClass(id)); + } + if let Some(p) = facts.party + && self.get_party(p).is_none() + { + return Err(LedgerError::InvalidProject(format!( + "party {p} is not in the directory" + ))); + } + self.put_project(id, name, facts); + Ok(()) + } + + fn put_project(&mut self, id: ClassId, name: String, facts: crate::classes::ProjectFacts) { + if let Some(c) = self.classes.get_mut(&id) { + c.name = name; + } + if facts.is_default() { + self.projects.remove(&id); + } else { + self.projects.insert(id, facts); + } + } +} + +/// A class name as names compare (rules.md B-37 item 1): trimmed, in +/// lower case (Unicode's, so `Éclair` and `éCLAIR` are one name). +fn fold_class_name(name: &str) -> String { + name.trim().to_lowercase() } /// Refuse an amount finer than its currency's minor unit (rules.md B-3). diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 1883e97..94abfe7 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -64,7 +64,7 @@ pub use cashflow::{ OperatingReconciliation, ReconcilingKind, ReconcilingLine, RoleList, RoleProblem, cash_flow, role_problems, }; -pub use classes::{Class, ClassId}; +pub use classes::{Class, ClassId, ProjectFacts, ProjectKind, ProjectStatus}; pub use error::LedgerError; pub use form1099::{ Dated1099, Decision1099, ExemptReason, Form1099, Payment1099, PaymentAccounts, Status1099, @@ -88,9 +88,10 @@ pub use periods::{ pub use reports::{ AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, MemberEquityColumn, - MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance, - TrialBalanceLine, balance_sheet_as_of, fixed_asset_register, general_ledger, members_equity, - profit_and_loss_for_period, trial_balance_as_of, + MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, ProjectFigures, ProjectToDate, + StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, fixed_asset_register, + general_ledger, members_equity, profit_and_loss_for_period, project_entries, project_figures, + project_to_date, trial_balance_as_of, }; pub use schedules::{ AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId, diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index 3b9777d..b0233c1 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -1785,6 +1785,28 @@ fn basis_figures_laws() { ); } +/// `kernel::unrecovered_cost` (rules.md B-37 item 4), for every revenue +/// and expense a line could carry: never below zero; the shortfall when +/// expenses exceed revenue, else zero; never above max(expenses, 0) when +/// revenue isn't negative (expenses may be negative: a reimbursement +/// credited to the expense above its cost). +#[kani::proof] +fn unrecovered_cost_laws() { + let (revenue, expenses) = (any_amount(), any_amount()); + let u = kernel::unrecovered_cost(revenue, expenses).expect("no overflow"); + assert!(u >= 0); + if expenses >= revenue { + assert_eq!(u, expenses - revenue); + } else { + assert_eq!(u, 0); + } + if revenue >= 0 { + assert!(u <= expenses.max(0)); + } + kani::cover!(u > 0, "costs not yet repaid"); + kani::cover!(u == 0 && revenue > expenses, "repaid, and more"); +} + // --------------------------------------------------------------------------- // The checker itself // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 7f592e7..daf89b3 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -504,6 +504,163 @@ pub fn profit_and_loss_for_period( }) } +/// A project's figures (rules.md B-37 item 3): the period's statement and +/// months, and its figures to date. +#[derive(Clone, Debug)] +pub struct ProjectFigures { + /// The period's profit and loss, only the lines tagged to it. + pub period: ProfitAndLossStatement, + /// The period by calendar month: its last [`PROJECT_MONTHS`] at most. + pub months: Vec, + /// Every tagged line through the period's end. + pub to_date: ProjectToDate, +} + +/// A project's figures to date: every posted revenue and expense line +/// tagged to it through `through`, whatever its dates (rules.md B-37). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectToDate { + /// The last day counted. + pub through: NaiveDate, + /// Its tagged revenue. + pub revenue: Decimal, + /// Its tagged expenses. + pub expenses: Decimal, + /// On spec only: expenses less revenue, never below zero (the "if + /// repaid" figure, never a receivable). `None` for any other kind. + pub unrecovered_cost: Option, +} + +impl ProjectToDate { + /// Revenue less expenses. + pub fn net(&self) -> Decimal { + self.revenue - self.expenses + } +} + +/// The most months [`ProjectFigures::months`] holds: the period's last. +pub const PROJECT_MONTHS: u32 = 240; + +/// A project's figures for `[start, end]`, by month, and to date. +/// +/// ```text +/// GOAL: what a project earned and cost, from one function the tools, the +/// viewer and bookspec share, so their figures never drift. +/// SOURCE: docs/spec/rules.md B-37 item 3 (job costing), B-11, B-14. +/// INVARIANTS: +/// 1. MUST be profit_and_loss_for_period with the project's class: only +/// revenue and expense lines tagged to it, closing entries left out. +/// 2. The months partition the period (their nets sum to its net) +/// when it spans at most PROJECT_MONTHS months; a longer one's +/// months are its last PROJECT_MONTHS. +/// 3. to_date counts every tagged line through `end`, from the first +/// day the books keep, whatever the project's own dates. +/// SCENARIOS: +/// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass). +/// 2. IF `start` is after `end`: SHOULD refuse. +/// ``` +pub fn project_figures( + ledger: &Ledger, + id: ClassId, + start: NaiveDate, + end: NaiveDate, +) -> Result { + if ledger.get_class(id).is_none() { + return Err(LedgerError::UnknownClass(id)); + } + let period = profit_and_loss_for_period(ledger, start, end, Some(id))?; + // The last PROJECT_MONTHS months at most: a span across the calendar + // is a hundred thousand months, each a statement. + let months_from = chrono::Datelike::with_day(&end, 1) + .and_then(|m| m.checked_sub_months(chrono::Months::new(PROJECT_MONTHS - 1))) + .map_or(start, |m| m.max(start)); + let months = crate::analysis::pnl_by_interval_for( + ledger, + months_from, + end, + crate::analysis::Interval::Month, + Some(id), + )?; + let to_date = project_to_date(ledger, id, end)?; + Ok(ProjectFigures { + period, + months, + to_date, + }) +} + +/// A project's figures to date: every posted revenue and expense line +/// tagged to it through `through` (closing entries left out), and for an +/// on-spec project its unrecovered cost (kernel::unrecovered_cost). +pub fn project_to_date( + ledger: &Ledger, + id: ClassId, + through: NaiveDate, +) -> Result { + let facts = ledger + .project_facts(id) + .ok_or(LedgerError::UnknownClass(id))?; + let start = crate::calendar::FIRST_DAY.min(through); + let pl = profit_and_loss_for_period(ledger, start, through, Some(id))?; + let unrecovered_cost = match facts.kind { + Some(crate::classes::ProjectKind::OnSpec) => Some( + crate::kernel::unrecovered_cost(pl.total_revenue, pl.total_expenses) + .ok_or(LedgerError::AmountOutOfRange)?, + ), + _ => None, + }; + Ok(ProjectToDate { + through, + revenue: pl.total_revenue, + expenses: pl.total_expenses, + unrecovered_cost, + }) +} + +/// Every entry with a line tagged to the project (drafts included), +/// newest first (by date, then id), with its project amount: its tagged +/// revenue lines less its tagged expense lines, each on its normal side. +/// A tagged balance-sheet line counts zero (rules.md B-37 item 3). +pub fn project_entries(ledger: &Ledger, id: ClassId) -> Vec<(crate::EntryId, Decimal)> { + let mut out: Vec<(NaiveDate, crate::EntryId, Decimal)> = Vec::new(); + for eid in 1..ledger.next_entry_id() { + let Some(entry) = ledger.get_entry(eid) else { + continue; + }; + let mut tagged = false; + let mut amount = Decimal::ZERO; + for line in entry.lines().iter().filter(|l| l.class_id == Some(id)) { + tagged = true; + let Some(account) = ledger.chart().get(line.account) else { + continue; + }; + let debit = matches!(line.side, crate::Side::Debit); + match account.account_type { + AccountType::Revenue => { + amount += if debit { + -line.amount.amount + } else { + line.amount.amount + } + } + AccountType::Expense => { + amount -= if debit { + line.amount.amount + } else { + -line.amount.amount + } + } + _ => {} + } + } + if tagged { + out.push((entry.date(), eid, amount)); + } + } + out.sort_by_key(|a| std::cmp::Reverse((a.0, a.1))); + out.into_iter().map(|(_, e, a)| (e, a)).collect() +} + /// Balance sheet as of `as_of`: every posted entry dated on or before it. /// Revenue and expense not yet closed to retained earnings appear as /// current earnings inside equity, so the accounting equation holds on diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index bb09145..0293b16 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -602,6 +602,20 @@ pub fn decimal(s: &str) -> Option { /// a hyphen or any other dash, a dot, or a space (or a dash with spaces /// around it); full-width digits count as digits. pub fn holds_printed_tin(text: &str) -> bool { + printed_tin(text, false) +} + +/// [`holds_printed_tin`] for a web address: the printed SSN (3-2-4) and +/// EIN (2-7) forms count as there, but a bare run of nine digits joined +/// to a letter or to the URL punctuation `/ = ? & #` does not: it is an +/// id in a path or query (an app store listing's `id284882215`), not a +/// TIN someone wrote down (rules.md B-26). +pub fn holds_printed_tin_in_link(text: &str) -> bool { + printed_tin(text, true) +} + +/// [`holds_printed_tin`], with a link's bare runs let through when `link`. +fn printed_tin(text: &str, link: bool) -> bool { // Full-width digits read as the digits they are. let c: Vec = text .chars() @@ -645,11 +659,20 @@ pub fn holds_printed_tin(text: &str) -> bool { } Some(i) }; + let url_joined = |i: Option| { + i.and_then(|i| c.get(i)) + .is_some_and(|ch| ch.is_alphabetic() || matches!(ch, '/' | '=' | '?' | '&' | '#')) + }; (0..c.len()).any(|at| { (at == 0 || !digit(at - 1)) - && [&[9][..], &[3, 2, 4][..], &[2, 7][..]] - .iter() - .any(|g| groups_at(at, g).is_some_and(|end| !digit(end))) + && [&[9][..], &[3, 2, 4][..], &[2, 7][..]].iter().any(|g| { + groups_at(at, g).is_some_and(|end| { + !digit(end) + && !(link + && g.len() == 1 + && (url_joined(at.checked_sub(1)) || url_joined(Some(end)))) + }) + }) }) } diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index 9ab099e..53bba77 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -111,6 +111,9 @@ struct Setup { /// The ways the company (or a member, for it) pays (rules.md B-19). #[serde(default)] instruments: Vec, + /// The projects (rules.md B-37), each added with its facts. + #[serde(default)] + projects: Vec, /// The day the books are asked on, in the company's time zone: what /// an omitted date means (rules.md B-30). A query that omits a date /// needs it. @@ -118,6 +121,21 @@ struct Setup { today: Option, } +/// A project (rules.md B-37): its name (once each), and its kind +/// (`internal`, `client`, `on_spec`, `other`), status and party (a +/// `setup.parties` name), if given. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ProjectSpec { + name: String, + #[serde(default)] + kind: Option, + #[serde(default)] + status: Option, + #[serde(default)] + party: Option, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct AccountSpec { @@ -454,6 +472,9 @@ struct LineSpec { account: String, side: String, amount: String, + /// The project (a `setup.projects` name) the line is tagged with. + #[serde(default)] + project: Option, } /// A close of the period from `start` through the event's date, to @@ -490,6 +511,9 @@ struct Expect { /// The party a `party_part`, `party_totals` or `party_tin_last4` /// query asks about (in `setup.parties`). party: Option, + /// The project (in `setup.projects`) a `project` query asks about. + #[serde(default)] + project: Option, /// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4). basis: Option, /// The payee a `vendor_1099` query asks about (in `setup.payees`). @@ -711,6 +735,8 @@ struct Books<'a> { instruments: BTreeMap, /// The fixed assets of `setup.assets`, by id. assets: BTreeMap, + /// The projects of `setup.projects`, by name. + projects: BTreeMap, /// The schedules of `setup.schedules` and `register_schedule` events, /// by id. schedules: BTreeMap, @@ -1100,6 +1126,31 @@ impl<'a> Books<'a> { }; took(store.set_vendor_1099(v, &dated, Some(false)), &what); } + let mut projects = BTreeMap::new(); + for p in &s.projects { + let what = format!("{}: project {}", ex.id, p.name); + let facts = ledger_core::ProjectFacts { + kind: p.kind.as_deref().map(|k| { + ledger_core::ProjectKind::parse(k) + .unwrap_or_else(|| panic!("{what}: kind {k:?}")) + }), + status: p.status.as_deref().map_or(Default::default(), |k| { + ledger_core::ProjectStatus::parse(k) + .unwrap_or_else(|| panic!("{what}: status {k:?}")) + }), + party: p.party.as_ref().map(|n| { + *parties + .get(n) + .unwrap_or_else(|| panic!("{what}: party {n:?} not in setup.parties")) + }), + ..Default::default() + }; + let id = took(store.add_project(&p.name, &facts), &what); + assert!( + projects.insert(p.name.clone(), id).is_none(), + "{what}: named twice" + ); + } let mut instruments = BTreeMap::new(); for i in &s.instruments { let what = format!("{} instrument {}", ex.id, i.label); @@ -1133,6 +1184,7 @@ impl<'a> Books<'a> { payees, instruments, assets, + projects, schedules, posted: Vec::new(), runs: BTreeMap::new(), @@ -1344,7 +1396,7 @@ impl<'a> Books<'a> { match kind { "lines" => { let entry = self.store.create_entry(day(), description)?; - for line in &e.lines { + for (i, line) in e.lines.iter().enumerate() { let side = match line.side.as_str() { "debit" => Side::Debit, "credit" => Side::Credit, @@ -1353,6 +1405,13 @@ impl<'a> Books<'a> { let amount = Money::new(money(&line.amount), self.currency); let account = self.id(&line.account); self.store.add_line(entry, account, side, amount)?; + // Tagged as books.add_line tags it: after the line. + if let Some(p) = &line.project { + let class = *self.projects.get(p).unwrap_or_else(|| { + panic!("{what}: project {p:?} not in setup.projects") + }); + self.store.set_line_class(entry, i, Some(class))?; + } } self.store.post_entry(entry) } @@ -2006,6 +2065,57 @@ fn check_party_totals(b: &Books<'_>, x: &Expect) { ); } +/// Check one `project` expectation (rules.md B-37): the project's +/// `revenue`, `expenses` and `net` for the period (B-30 resolving omitted +/// dates), the same `to_date` (every tagged line through `to`), and its +/// `unrecovered_cost` (`"none"` unless it is on spec); every one given. +/// Through ledger_core::project_figures, as the tools and the viewer. +fn check_project(b: &Books<'_>, x: &Expect) { + let (ex, l) = (b.ex, b.l()); + let name = x + .project + .as_ref() + .unwrap_or_else(|| panic!("{}: a project query names its project", ex.id)); + let id = *b + .projects + .get(name) + .unwrap_or_else(|| panic!("{}: project {name:?} not in setup.projects", ex.id)); + let (from, to) = x.period(ex); + let f = + ledger_core::project_figures(l, id, from, to).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); + let keys: BTreeSet = [ + "revenue", + "expenses", + "net", + "to_date.revenue", + "to_date.expenses", + "to_date.net", + "unrecovered_cost", + ] + .iter() + .map(|k| k.to_string()) + .collect(); + exactly(ex, x, &keys); + let what = |k: &str| format!("{} project {name:?} [{from} .. {to}]: {k}", ex.id); + for (key, want) in x.figures(&ex.id) { + let got = match key.as_str() { + "revenue" => Some(f.period.total_revenue), + "expenses" => Some(f.period.total_expenses), + "net" => Some(f.period.net_income()), + "to_date.revenue" => Some(f.to_date.revenue), + "to_date.expenses" => Some(f.to_date.expenses), + "to_date.net" => Some(f.to_date.net()), + _ => f.to_date.unrecovered_cost, + }; + match (want.as_str(), got) { + ("none", None) => {} + ("none", Some(g)) => panic!("{}: {g}, expected none", what(key)), + (w, Some(g)) => assert_eq!(g, exact(w, &what(key)), "{}", what(key)), + (w, None) => panic!("{}: none, expected {w}", what(key)), + } + } +} + /// Check one `party_tin_last4` expectation: the last four of the party's /// TIN the books keep (its TIN facts', else its W-9's; `""` for none: /// rules.md B-26). @@ -2919,6 +3029,7 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { "party_part" => check_party_part(b, x), "party_totals" => check_party_totals(b, x), "party_tin_last4" => check_party_tin_last4(b, x), + "project" => check_project(b, x), q => { let &(_, by) = FORMAT_QUERIES .iter() @@ -3044,6 +3155,7 @@ fn every_bookspec_example_holds() { ("asset_basis", 6), ("k1_tax_basis", 2), ("k1_reconciliation", 1), + ("project", 6), ] { let n = checked.get(q).copied().unwrap_or(0); assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}"); @@ -3611,3 +3723,31 @@ fn a_members_reconciliation_shows_every_cause() { t.replacen(", \"Tobin Harlow.property_distributed\" = \"0.00\"", "", 1) }); } + +/// A project named twice in `setup.projects` is refused (names are +/// unique, rules.md B-37). +#[test] +#[should_panic(expected = "a project named")] +fn a_project_named_twice_is_refused() { + run_edited("projects-net-is-its-tagged-lines.toml", |t| { + t.replacen("name = \"Beta\"", "name = \"alpha\"", 1) + }); +} + +/// A line naming a project the setup doesn't have is refused. +#[test] +#[should_panic(expected = "not in setup.projects")] +fn a_line_naming_an_unknown_project_is_refused() { + run_edited("projects-net-is-its-tagged-lines.toml", |t| { + t.replacen("project = \"Beta\" }", "project = \"Gamma\" }", 1) + }); +} + +/// A project query lists every value it covers. +#[test] +#[should_panic(expected = "lists exactly what it covers")] +fn a_project_query_leaving_a_value_out_is_refused() { + run_edited("projects-on-spec-costs-are-expenses.toml", |t| { + t.replacen("unrecovered_cost = \"1460.00\"\n", "", 1) + }); +} diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index f75d70b..0388417 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -933,3 +933,122 @@ proptest! { } } } + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// rules.md B-37 item 3: for any tags and any period, the projects' + /// figures plus the untagged lines' are the period's profit and loss + /// (no line counted twice or lost), each through + /// ledger_core::project_figures. + #[test] + fn prop_project_nets_partition_the_pnl( + entries in prop::collection::vec( + (0u32..120, 1i64..1_000_000, any::(), 0u32..4, 0u32..4), + 1..20, + ), + from in 0u32..120, + len in 0u32..120, + ) { + let base = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let mut l = Ledger::new(Currency::USD); + let cash = l.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap(); + let rev = l.add_account("4000", "Revenue", AccountType::Revenue, None, false).unwrap(); + let exp = l.add_account("6000", "Costs", AccountType::Expense, None, false).unwrap(); + let classes: Vec = (0..3).map(|i| l.add_class(&format!("P{i}")).unwrap()).collect(); + for (day, c, income, tag_a, tag_b) in entries { + let e = l.create_entry(base + chrono::Days::new(u64::from(day)), "e"); + let amt = Money::new(Decimal::new(c, 2), Currency::USD); + let (dr, cr) = if income { (cash, rev) } else { (exp, cash) }; + l.add_line(e, dr, Side::Debit, amt).unwrap(); + l.add_line(e, cr, Side::Credit, amt).unwrap(); + // 3 means untagged; both lines may carry any project. + for (i, t) in [tag_a, tag_b].into_iter().enumerate() { + if let Some(&k) = classes.get(t as usize) { + l.set_line_class(e, i, Some(k)).unwrap(); + } + } + l.post_entry(e).unwrap(); + } + let start = base + chrono::Days::new(u64::from(from)); + let end = start + chrono::Days::new(u64::from(len)); + let all = profit_and_loss_for_period(&l, start, end, None).unwrap(); + let mut rev_sum = Decimal::ZERO; + let mut exp_sum = Decimal::ZERO; + for &k in &classes { + let f = ledger_core::project_figures(&l, k, start, end).unwrap(); + rev_sum += f.period.total_revenue; + exp_sum += f.period.total_expenses; + let months: Decimal = f.months.iter().map(|m| m.net()).sum(); + prop_assert_eq!(months, f.period.net_income()); + } + // The untagged lines: every line with no class. + let mut untagged_rev = Decimal::ZERO; + let mut untagged_exp = Decimal::ZERO; + for e in l.posted_entries() { + if e.date() < start || e.date() > end { + continue; + } + for line in e.lines().iter().filter(|x| x.class_id.is_none()) { + let a = line.amount.amount; + if line.account == rev { + untagged_rev += if line.side == Side::Credit { a } else { -a }; + } else if line.account == exp { + untagged_exp += if line.side == Side::Debit { a } else { -a }; + } + } + } + prop_assert_eq!(rev_sum + untagged_rev, all.total_revenue); + prop_assert_eq!(exp_sum + untagged_exp, all.total_expenses); + } + + /// rules.md B-37 item 3: project_entries codes its own signs, so the + /// posted entries' project amounts must sum to project_to_date's net + /// (with no entry dated after the day), whatever the tags, the sides, + /// a tagged balance-sheet line, or drafts beside them. + #[test] + fn prop_project_entries_sum_to_the_net_to_date( + entries in prop::collection::vec( + (0u32..120, 1i64..1_000_000, 0u32..3, any::(), 0u32..3, 0u32..3, any::()), + 1..20, + ), + ) { + let base = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let mut l = Ledger::new(Currency::USD); + let cash = l.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap(); + let rev = l.add_account("4000", "Revenue", AccountType::Revenue, None, false).unwrap(); + let exp = l.add_account("6000", "Costs", AccountType::Expense, None, false).unwrap(); + let classes: Vec = (0..2).map(|i| l.add_class(&format!("P{i}")).unwrap()).collect(); + let accounts = [cash, rev, exp]; + for (day, c, other, debit_first, tag_a, tag_b, post) in entries { + let e = l.create_entry(base + chrono::Days::new(u64::from(day)), "e"); + let amt = Money::new(Decimal::new(c, 2), Currency::USD); + // Cash against any account, on either side: a refund debits + // revenue, a reimbursement credits an expense. + let other = accounts[other as usize]; + let (dr, cr) = if debit_first { (other, cash) } else { (cash, other) }; + l.add_line(e, dr, Side::Debit, amt).unwrap(); + l.add_line(e, cr, Side::Credit, amt).unwrap(); + for (i, t) in [tag_a, tag_b].into_iter().enumerate() { + if let Some(&k) = classes.get(t as usize) { + l.set_line_class(e, i, Some(k)).unwrap(); + } + } + if post { + l.post_entry(e).unwrap(); + } + } + let through = base + chrono::Days::new(200); + for &k in &classes { + let posted: Decimal = ledger_core::project_entries(&l, k) + .into_iter() + .filter(|(e, _)| { + l.get_entry(*e).unwrap().status() == ledger_core::EntryStatus::Posted + }) + .map(|(_, a)| a) + .sum(); + let t = ledger_core::project_to_date(&l, k, through).unwrap(); + prop_assert_eq!(posted, t.net()); + } + } +} diff --git a/crates/ledger-core/tests/spec_projects.rs b/crates/ledger-core/tests/spec_projects.rs new file mode 100644 index 0000000..a210e08 --- /dev/null +++ b/crates/ledger-core/tests/spec_projects.rs @@ -0,0 +1,633 @@ +//! Projects (rules.md B-37): a class with its facts; its figures are the +//! revenue and expense lines tagged to it. +//! +//! ```text +//! GOAL: a project's record holds only what its rules allow, and its +//! figures are exactly its tagged lines, from one function. +//! SOURCE: docs/spec/rules.md B-37 (job costing; ASC 450-30-25-1 and +//! 340-40-25-5 for on-spec work), B-11 (closes left out). +//! ``` + +use chrono::NaiveDate; +use ledger_core::conventions::NewDecision; +use ledger_core::{ + AccountType, ClassId, Currency, Ledger, LedgerError, Money, ProjectFacts, ProjectKind, + ProjectStatus, Side, project_entries, project_figures, project_to_date, +}; +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 usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +/// Cash 1000, equipment 1200, revenue 4000, contractors 6000, rent 6200. +struct Books { + l: Ledger, + cash: u64, + equipment: u64, + revenue: u64, + contractors: u64, + rent: u64, +} + +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let cash = add("1000", AccountType::Asset); + let equipment = add("1200", AccountType::Asset); + let revenue = add("4000", AccountType::Revenue); + let contractors = add("6000", AccountType::Expense); + let rent = add("6200", AccountType::Expense); + Books { + l, + cash, + equipment, + revenue, + contractors, + rent, + } +} + +impl Books { + /// Post `dr`/`cr` for `c` cents on `date`, tagging the debit line + /// (`on_debit`) or the credit line with `class`. + fn post( + &mut self, + date: NaiveDate, + dr: u64, + cr: u64, + c: i64, + class: Option<(ClassId, bool)>, + ) -> u64 { + let e = self.l.create_entry(date, "e"); + self.l.add_line(e, dr, Side::Debit, usd(c)).unwrap(); + self.l.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + if let Some((k, on_debit)) = class { + self.l + .set_line_class(e, if on_debit { 0 } else { 1 }, Some(k)) + .unwrap(); + } + self.l.post_entry(e).unwrap(); + e + } +} + +fn on_spec() -> ProjectFacts { + ProjectFacts { + kind: Some(ProjectKind::OnSpec), + ..Default::default() + } +} + +#[test] +fn project_facts_default_for_a_bare_class() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + let f = b.l.project_facts(c).unwrap(); + assert_eq!(f, ProjectFacts::default()); + assert_eq!(f.status, ProjectStatus::Active); + assert_eq!(f.kind, None); + assert!(b.l.project_facts(99).is_none()); +} + +#[test] +fn set_project_checks_every_field() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + b.l.add_class("Other").unwrap(); + let ok = ProjectFacts::default(); + let refused = + |l: &Ledger, name: &str, f: ProjectFacts| l.check_set_project(c, name, &f).is_err(); + // The class exists. + assert_eq!( + b.l.check_set_project(99, "x", &ok), + Err(LedgerError::UnknownClass(99)) + ); + // Names: not blank, one line, not another's (ignoring capitals), the + // project's own name fine. + assert!(refused(&b.l, " ", ok.clone())); + assert!(refused(&b.l, &"x".repeat(201), ok.clone())); + assert!(refused(&b.l, "two\nlines", ok.clone())); + assert_eq!( + b.l.check_set_project(c, " other ", &ok), + Err(LedgerError::DuplicateClassName("other".into())) + ); + assert_eq!(b.l.check_set_project(c, "HARBOR", &ok).unwrap().0, "HARBOR"); + // No full TIN in any text. + for f in [ + ProjectFacts { + description: Some("EIN 12-3456789".into()), + ..ok.clone() + }, + ProjectFacts { + notes: Some("SSN 123-45-6789".into()), + ..ok.clone() + }, + ] { + assert!(refused(&b.l, "Harbor", f)); + } + assert!(refused(&b.l, "123-45-6789", ok.clone())); + // ended_on only when over, never before started_on. + let ended = |status, start: Option, end| ProjectFacts { + status, + started_on: start, + ended_on: Some(end), + ..Default::default() + }; + assert!(refused( + &b.l, + "Harbor", + ended(ProjectStatus::Active, None, d(2026, 1, 1)) + )); + assert!(refused( + &b.l, + "Harbor", + ended(ProjectStatus::Paused, None, d(2026, 1, 1)) + )); + assert!(refused( + &b.l, + "Harbor", + ended(ProjectStatus::Finished, Some(d(2026, 2, 1)), d(2026, 1, 31)) + )); + assert!(!refused( + &b.l, + "Harbor", + ended(ProjectStatus::Finished, Some(d(2026, 2, 1)), d(2026, 2, 1)) + )); + assert!(!refused( + &b.l, + "Harbor", + ended(ProjectStatus::Abandoned, None, d(2026, 2, 1)) + )); + // Links: at most eight, one address each, none twice. + let links = |v: Vec<&str>| ProjectFacts { + links: v.into_iter().map(String::from).collect(), + ..Default::default() + }; + assert!(refused(&b.l, "Harbor", links(vec!["https://a.example"; 2]))); + assert!(refused(&b.l, "Harbor", links(vec!["a b"]))); + assert!(refused(&b.l, "Harbor", links(vec![""]))); + let nine: Vec = (0..9).map(|i| format!("https://{i}.example")).collect(); + assert!(refused( + &b.l, + "Harbor", + links(nine.iter().map(String::as_str).collect()) + )); + assert!(!refused( + &b.l, + "Harbor", + links(nine[..8].iter().map(String::as_str).collect()) + )); + // The party is in the directory. + assert!(refused( + &b.l, + "Harbor", + ProjectFacts { + party: Some(7), + ..Default::default() + } + )); + let p = b.l.add_party("Harbor Co", &[]).unwrap(); + let (name, facts) = + b.l.check_set_project( + c, + " Harbor ", + &ProjectFacts { + party: Some(p), + description: Some(" the app ".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + (name.as_str(), facts.description.as_deref()), + ("Harbor", Some("the app")) + ); +} + +#[test] +fn set_project_renames_the_class_and_moves_no_figure() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + b.post(d(2026, 3, 1), b.cash, b.revenue, 10_000, Some((c, false))); + let before = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap(); + b.l.set_project(c, "Harbor app", on_spec()).unwrap(); + assert_eq!(b.l.get_class(c).unwrap().name, "Harbor app"); + let after = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap(); + assert_eq!( + (before.revenue, before.expenses), + (after.revenue, after.expenses) + ); + assert_eq!( + b.l.project_facts(c).unwrap().kind, + Some(ProjectKind::OnSpec) + ); +} + +#[test] +fn add_class_refuses_a_duplicate_name() { + let mut b = books(); + b.l.add_class("Harbor").unwrap(); + let next = b.l.next_class_id(); + assert_eq!( + b.l.add_class(" harbor "), + Err(LedgerError::DuplicateClassName("harbor".into())) + ); + assert_eq!(b.l.next_class_id(), next, "a refusal allocates nothing"); + // Trimmed as kept. + let c = b.l.add_class(" Dock ").unwrap(); + assert_eq!(b.l.get_class(c).unwrap().name, "Dock"); +} + +#[test] +fn names_compare_one_way_for_the_duplicate_check_and_the_lookup() { + let mut b = books(); + let c = b.l.add_class("Éclair").unwrap(); + // The duplicate check and the lookup fold the same: capitals in any + // script, surrounding space. + assert_eq!( + b.l.add_class("éCLAIR"), + Err(LedgerError::DuplicateClassName("éCLAIR".into())) + ); + for name in ["éclair", "ÉCLAIR", " Éclair ", "\téclair"] { + assert_eq!( + b.l.find_class_by_name(name).map(|x| x.id), + Some(c), + "{name:?}" + ); + } + assert!(b.l.find_class_by_name("eclair").is_none()); +} + +#[test] +fn old_books_duplicates_resolve_to_the_lowest_id_and_change_freely() { + let mut l = Ledger::new(Currency::USD); + // Replay never checks names, so an earlier version's duplicates load. + l.replay_class(1, "Alpha".into()).unwrap(); + l.replay_class(2, "alpha".into()).unwrap(); + l.replay_class(3, "Harbor".into()).unwrap(); + for _ in 0..20 { + assert_eq!(l.find_class_by_name("ALPHA").map(|c| c.id), Some(1)); + } + let paused = ProjectFacts { + status: ProjectStatus::Paused, + ..Default::default() + }; + // Each takes a fact-only change under its own name. + assert_eq!(l.check_set_project(1, "Alpha", &paused).unwrap().0, "Alpha"); + assert_eq!(l.check_set_project(2, "alpha", &paused).unwrap().0, "alpha"); + // Its own name up to capitals and space is still its own. + assert_eq!( + l.check_set_project(2, " Alpha ", &paused).unwrap().0, + "Alpha" + ); + // A rename onto another project's name is still refused. + assert_eq!( + l.check_set_project(2, "harbor", &paused), + Err(LedgerError::DuplicateClassName("harbor".into())) + ); +} + +#[test] +fn an_unchanged_name_is_kept_exactly_and_not_checked_again() { + let mut l = Ledger::new(Currency::USD); + // An earlier version kept names untrimmed, and wrote rules since + // tightened (a nine-digit job number) without checking them. + l.replay_class(1, "Retail ".into()).unwrap(); + l.replay_class(2, "Job 123456789".into()).unwrap(); + let ok = ProjectFacts::default(); + assert_eq!(l.check_set_project(1, "Retail ", &ok).unwrap().0, "Retail "); + assert_eq!( + l.check_set_project(2, "Job 123456789", &ok).unwrap().0, + "Job 123456789" + ); + // A new name gets every rule. + assert!(l.check_set_project(2, "Job 223456789", &ok).is_err()); + assert_eq!(l.check_set_project(1, "Retail", &ok).unwrap().0, "Retail"); +} + +#[test] +fn a_name_of_digits_only_is_refused() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + for name in ["1", " 42 ", "0007"] { + assert!( + matches!(b.l.add_class(name), Err(LedgerError::InvalidProject(_))), + "{name:?}" + ); + assert!( + b.l.check_set_project(c, name, &ProjectFacts::default()) + .is_err(), + "{name:?}" + ); + } + assert!(b.l.add_class("Job 42").is_ok()); +} + +#[test] +fn a_link_is_read_as_a_web_address_for_a_full_tin() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + let link = |l: &str| { + b.l.check_set_project( + c, + "Harbor", + &ProjectFacts { + links: vec![l.to_string()], + ..Default::default() + }, + ) + }; + // An id in a path or query is no TIN. + for ok in [ + "https://apps.apple.com/us/app/harbor/id284882215", + "https://store.example/listing/284882215", + "https://store.example/app?id=284882215&x=1", + "https://drive.example/file/d/284882215abc/view", + ] { + assert!(link(ok).is_ok(), "{ok}"); + } + // A printed SSN or EIN, or nine digits standing alone, is. + for bad in [ + "https://files.example/123-45-6789", + "https://files.example/ein-12-3456789.pdf", + "https://files.example/a.123456789.pdf", + "123456789", + ] { + assert!( + matches!(link(bad), Err(LedgerError::InvalidProject(_))), + "{bad}" + ); + } +} + +#[test] +fn replay_set_project_takes_what_the_text_rules_would_refuse() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + // An end with no end status, and a nine-link list: what a later rule + // might refuse, an earlier version may have journaled. + let odd = ProjectFacts { + ended_on: Some(d(2026, 1, 1)), + links: (0..9).map(|i| format!("l{i}")).collect(), + ..Default::default() + }; + assert!(b.l.check_set_project(c, "Harbor", &odd).is_err()); + b.l.replay_set_project(c, "Harbor".into(), odd.clone()) + .unwrap(); + assert_eq!(b.l.project_facts(c), Some(odd)); + // What replay can't do without is still refused. + assert!( + b.l.replay_set_project(9, "x".into(), ProjectFacts::default()) + .is_err() + ); + assert!( + b.l.replay_set_project( + c, + "x".into(), + ProjectFacts { + party: Some(42), + ..Default::default() + } + ) + .is_err() + ); +} + +#[test] +fn project_figures_are_its_tagged_lines() { + let mut b = books(); + let alpha = b.l.add_class("Alpha").unwrap(); + let beta = b.l.add_class("Beta").unwrap(); + b.post( + d(2026, 2, 3), + b.cash, + b.revenue, + 500_000, + Some((alpha, false)), + ); + b.post( + d(2026, 2, 10), + b.contractors, + b.cash, + 180_000, + Some((alpha, true)), + ); + b.post( + d(2026, 3, 10), + b.contractors, + b.cash, + 8_000, + Some((beta, true)), + ); + b.post(d(2026, 3, 28), b.rent, b.cash, 150_000, None); + let f = project_figures(&b.l, alpha, d(2026, 1, 1), d(2026, 3, 31)).unwrap(); + assert_eq!(f.period.total_revenue, cents(500_000)); + assert_eq!(f.period.total_expenses, cents(180_000)); + assert_eq!(f.period.revenue.len(), 1); + assert_eq!(f.period.expenses[0].code, "6000"); + assert_eq!(f.months.len(), 3); + assert_eq!(f.months[1].net(), cents(320_000)); + assert!( + f.months[2].net().is_zero(), + "March is Beta's and the rent's" + ); + let total: Decimal = f.months.iter().map(|m| m.net()).sum(); + assert_eq!(total, f.period.net_income()); + assert_eq!( + project_figures(&b.l, 99, d(2026, 1, 1), d(2026, 3, 31)).err(), + Some(LedgerError::UnknownClass(99)) + ); +} + +#[test] +fn project_to_date_ignores_the_start_and_the_projects_dates() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + b.l.set_project( + c, + "Harbor", + ProjectFacts { + started_on: Some(d(2026, 6, 1)), + ..Default::default() + }, + ) + .unwrap(); + // A cost before kickoff is still the project's. + b.post( + d(2025, 11, 5), + b.contractors, + b.cash, + 20_000, + Some((c, true)), + ); + b.post( + d(2026, 7, 5), + b.contractors, + b.cash, + 10_000, + Some((c, true)), + ); + let f = project_figures(&b.l, c, d(2026, 7, 1), d(2026, 7, 31)).unwrap(); + assert_eq!(f.period.total_expenses, cents(10_000)); + assert_eq!(f.to_date.expenses, cents(30_000)); + assert_eq!(f.to_date.through, d(2026, 7, 31)); + assert_eq!( + project_to_date(&b.l, c, d(2025, 12, 31)).unwrap().expenses, + cents(20_000) + ); +} + +#[test] +fn a_tagged_balance_sheet_line_counts_nothing() { + let mut b = books(); + let c = b.l.add_class("Beta").unwrap(); + let e = b.post(d(2026, 3, 5), b.equipment, b.cash, 90_000, Some((c, true))); + let t = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap(); + assert_eq!((t.revenue, t.expenses), (Decimal::ZERO, Decimal::ZERO)); + // Listed among its entries, with nothing of the project's. + assert_eq!(project_entries(&b.l, c), vec![(e, Decimal::ZERO)]); +} + +#[test] +fn closing_entries_are_left_out_of_project_figures() { + let mut b = books(); + let c = b.l.add_class("Alpha").unwrap(); + b.post(d(2025, 6, 1), b.cash, b.revenue, 40_000, Some((c, false))); + let p = + b.l.create_period("2025", d(2025, 1, 1), d(2025, 12, 31)) + .unwrap(); + let close = b.l.close_period(p).unwrap(); + assert!(close.is_some()); + let f = project_figures(&b.l, c, d(2025, 1, 1), d(2025, 12, 31)).unwrap(); + assert_eq!(f.period.total_revenue, cents(40_000)); + assert_eq!(f.to_date.revenue, cents(40_000)); +} + +#[test] +fn a_reversal_nets_a_project_to_zero() { + let mut b = books(); + let c = b.l.add_class("Alpha").unwrap(); + let e = b.post( + d(2026, 3, 12), + b.contractors, + b.cash, + 180_000, + Some((c, true)), + ); + let r = + b.l.reverse_entry(e, d(2026, 3, 13), "billed twice") + .unwrap(); + let t = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap(); + assert!(t.expenses.is_zero()); + let entries = project_entries(&b.l, c); + assert_eq!(entries, vec![(r, cents(180_000)), (e, cents(-180_000))]); +} + +#[test] +fn unrecovered_cost_only_for_on_spec_and_never_negative() { + let mut b = books(); + let spec = b.l.add_class("Harbor app").unwrap(); + b.l.set_project(spec, "Harbor app", on_spec()).unwrap(); + let client = b.l.add_class("Client").unwrap(); + b.post( + d(2026, 4, 2), + b.contractors, + b.cash, + 146_000, + Some((spec, true)), + ); + b.post( + d(2026, 4, 2), + b.contractors, + b.cash, + 5_000, + Some((client, true)), + ); + let at = |b: &Books, k| project_to_date(&b.l, k, d(2026, 12, 31)).unwrap(); + assert_eq!(at(&b, spec).unrecovered_cost, Some(cents(146_000))); + assert_eq!(at(&b, client).unrecovered_cost, None); + b.post( + d(2026, 6, 15), + b.cash, + b.revenue, + 100_000, + Some((spec, false)), + ); + assert_eq!(at(&b, spec).unrecovered_cost, Some(cents(46_000))); + b.post( + d(2026, 7, 1), + b.cash, + b.revenue, + 70_000, + Some((spec, false)), + ); + assert_eq!(at(&b, spec).unrecovered_cost, Some(Decimal::ZERO)); + assert_eq!(at(&b, spec).net(), cents(24_000)); +} + +#[test] +fn projects_of_party_lists_them_by_name() { + let mut b = books(); + let p = b.l.add_party("Riley Chen", &[]).unwrap(); + let with = |l: &mut Ledger, name: &str| { + let c = l.add_class(name).unwrap(); + l.set_project( + c, + name, + ProjectFacts { + party: Some(p), + ..Default::default() + }, + ) + .unwrap(); + c + }; + let zeta = with(&mut b.l, "Zeta"); + let alpha = with(&mut b.l, "alpha"); + b.l.add_class("Unrelated").unwrap(); + assert_eq!(b.l.projects_of_party(p), vec![alpha, zeta]); + assert!(b.l.projects_of_party(p + 1).is_empty()); +} + +#[test] +fn check_decision_refuses_an_unknown_or_repeated_project() { + let mut b = books(); + let c = b.l.add_class("Harbor").unwrap(); + let decision = |projects| NewDecision { + subject: "Harbor".into(), + decision_md: "Build it.".into(), + decided_by: vec!["Tobin Harlow".into()], + decided_on: d(2026, 4, 1), + evidence_attachment: None, + supersedes: None, + citation: None, + projects, + }; + assert_eq!( + b.l.check_decision(&decision(vec![9])).err(), + Some(LedgerError::UnknownClass(9)) + ); + assert!(b.l.check_decision(&decision(vec![c, c])).is_err()); + assert_eq!( + b.l.check_decision(&decision(vec![c])).unwrap().1.projects, + vec![c] + ); +} + +#[test] +fn differs_from_names_projects() { + let mut a = books(); + let c = a.l.add_class("Harbor").unwrap(); + let before = a.l.clone(); + a.l.set_project(c, "Harbor", on_spec()).unwrap(); + assert_eq!(a.l.differs_from(&before), vec!["projects"]); +} diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 16a6e73..26bb452 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -22,6 +22,7 @@ pub struct PageState { pub accounts: pages::accounts::State, pub assets: pages::assets::State, pub parties: pages::parties::State, + pub projects: pages::projects::State, pub documents: pages::documents::State, pub journal: pages::journal::State, } @@ -412,6 +413,7 @@ impl App { "accounts" => Route::Accounts, "assets" => Route::Assets, "parties" => Route::Parties, + "projects" => Route::Projects, "documents" => Route::Documents, "company" => Route::Company, "journal" => Route::Journal, @@ -494,6 +496,12 @@ impl App { } Route::Party(party_id) } + T::Project { class_id } => { + if b.ledger.get_class(class_id).is_none() { + return; + } + Route::Project(class_id) + } T::Reconciliation { id } => { // Its own page: statement, balance, the lines it cleared. if b.reconciliation(id).is_none() { @@ -601,8 +609,16 @@ impl App { let current = self.nav.current.section(); let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0); // Short windows: the links scroll above the status box - // instead of the box drawing over them. - let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0); + // instead of the box drawing over them. The box's height + // is the one it was drawn at last frame. + let footer_id = egui::Id::new("sidebar_footer_h"); + let footer_h = ui + .ctx() + .data(|d| d.get_temp::(footer_id)) + .unwrap_or(SIDEBAR_FOOTER); + // At least one link always shows; below that the footer + // gives way (footer_layout). + let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(32.0); egui::ScrollArea::vertical() .id_salt("sidebar_links") .max_height(links_h) @@ -613,6 +629,10 @@ impl App { vec2(ui.available_width(), 32.0), Sense::click(), ); + // Named for assistive tech (and the tests). + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title()) + }); if on { ui.painter() .rect_filled(rect, CornerRadius::same(8), p.surface); @@ -668,7 +688,7 @@ impl App { } } }); - ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + let footer = footer_layout(ui, footer_h, Align::Min, |ui| { self.scheme_menu(ui, false); if let Some(b) = &books { ui.add_space(2.0); @@ -685,6 +705,7 @@ impl App { ); } }); + remember_footer(ui, footer_id, footer.response.rect.height(), footer_h); }); } @@ -722,7 +743,12 @@ impl App { ui.add_space(18.0); let current = self.nav.current.section(); let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0); - let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0); + let footer_id = egui::Id::new("rail_footer_h"); + let footer_h = ui + .ctx() + .data(|d| d.get_temp::(footer_id)) + .unwrap_or(RAIL_FOOTER); + let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(36.0); egui::ScrollArea::vertical() .id_salt("rail_links") .max_height(links_h) @@ -733,6 +759,9 @@ impl App { vec2(ui.available_width(), 36.0), Sense::click(), ); + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title()) + }); if on { ui.painter() .rect_filled(rect, CornerRadius::same(8), p.surface); @@ -768,7 +797,7 @@ impl App { } } }); - ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + let footer = footer_layout(ui, footer_h, Align::Center, |ui| { self.scheme_menu(ui, true); if let Some(b) = &books { self.menu.ui(ui, b, true); @@ -814,6 +843,7 @@ impl App { resp.on_hover_text(tip); } }); + remember_footer(ui, footer_id, footer.response.rect.height(), footer_h); }); } @@ -948,10 +978,41 @@ impl App { /// Width of the icon rail. const RAIL_W: f32 = 60.0; /// Height kept clear under the sidebar's links for the status box and the -/// theme toggle; the rail's equivalent. +/// theme toggle before the footer is first measured; the rail's +/// equivalent. After the first frame the footer's drawn height is used +/// ([`remember_footer`]). const SIDEBAR_FOOTER: f32 = 172.0; const RAIL_FOOTER: f32 = 84.0; +/// Space kept between the last link and the footer. +const FOOTER_GAP: f32 = 8.0; + +/// Keep the footer's drawn height for the next frame's links, and draw +/// again at once when it changed (the first frame, a check appearing). +/// The sidebar's footer, bottom up: on the panel's floor when it fits +/// under the links, else right under them, running off the bottom of the +/// window, so it gives way and never draws over a link. +fn footer_layout( + ui: &mut Ui, + footer_h: f32, + align: Align, + add: impl FnOnce(&mut Ui) -> R, +) -> egui::InnerResponse { + if ui.available_height() >= footer_h { + ui.with_layout(Layout::bottom_up(align), add) + } else { + let w = ui.available_width(); + ui.allocate_ui_with_layout(vec2(w, footer_h), Layout::bottom_up(align), add) + } +} + +fn remember_footer(ui: &Ui, id: egui::Id, drawn: f32, used: f32) { + if (drawn - used).abs() > 0.5 { + ui.ctx().data_mut(|d| d.insert_temp(id, drawn)); + ui.ctx().request_repaint(); + } +} + fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { let p = pal(ui.ctx()); Frame::new() diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 7f9a4bf..3b29e33 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -1056,6 +1056,14 @@ impl Books { .unwrap_or_else(|| format!("party {id}")) } + /// A project's name ("project 3" for none such). + pub fn project_label(&self, id: ledger_core::ClassId) -> String { + self.ledger + .get_class(id) + .map(|c| c.name.clone()) + .unwrap_or_else(|| format!("project {id}")) + } + /// Whether a party files a 1099: it has a 1099 role (contractor, /// landlord), or its vendor record has been decided for one (from any /// date: its W-9 matters for those payments). @@ -1352,6 +1360,12 @@ impl Books { Subject::Account(a) => self.account_label(a), Subject::Asset(a) => self.asset_label(a), Subject::Party(p) => self.party_label(p), + Subject::Project(c) => format!( + "Project {}", + u32::try_from(c) + .ok() + .map_or_else(|| c.to_string(), |c| self.project_label(c)) + ), Subject::Invoice(i) => format!("Invoice {i}"), Subject::Bill(i) => format!("Bill {i}"), Subject::Period(i) => l @@ -1390,6 +1404,7 @@ impl Books { Subject::Account(a) => Route::Account(a), Subject::Asset(a) => Route::Asset(a), Subject::Party(p) => Route::Party(p), + Subject::Project(c) => Route::Project(u32::try_from(c).ok()?), Subject::Invoice(i) => Route::Entry(l.get_invoice(i)?.entry_id), Subject::Bill(i) => Route::Entry(l.get_bill(i)?.entry_id), Subject::PayrollRun(i) => Route::Entry(l.get_payroll_run(i)?.entry_id), diff --git a/crates/ledger-gui/src/charts.rs b/crates/ledger-gui/src/charts.rs index b976d2b..8f70220 100644 --- a/crates/ledger-gui/src/charts.rs +++ b/crates/ledger-gui/src/charts.rs @@ -236,6 +236,10 @@ fn tooltip( pub struct BarGroup { pub label: String, pub values: Vec, + /// The group's year, when the chart spans more than one: the first + /// label drawn, and each drawn in a new year, gains it ("Jan 26"), + /// whichever labels a crowded chart leaves out. + pub year: Option, } /// Grouped bars (e.g. income and spending per month), with a legend. @@ -245,6 +249,22 @@ pub fn bar_chart( groups: &[BarGroup], series: &[(&str, Color32)], height: f32, +) -> Option { + bars(ui, groups, series, height, true) +} + +/// [`bar_chart`] that opens nothing: hovering shows each group's figures, +/// and the pointer stays an arrow, since no page shows those figures. +pub fn bar_chart_view(ui: &mut Ui, groups: &[BarGroup], series: &[(&str, Color32)], height: f32) { + bars(ui, groups, series, height, false); +} + +fn bars( + ui: &mut Ui, + groups: &[BarGroup], + series: &[(&str, Color32)], + height: f32, + clickable: bool, ) -> Option { let p = pal(ui.ctx()); ui.horizontal(|ui| { @@ -259,7 +279,12 @@ pub fn bar_chart( ui.add_space(8.0); } }); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click()); + let sense = if clickable { + Sense::click() + } else { + Sense::hover() + }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), sense); let painter = ui.painter_at(rect); if groups.is_empty() { return None; @@ -293,7 +318,12 @@ pub fn bar_chart( let bw = ((gw * 0.7) / n).min(28.0); let mut clicked = None; let hover = resp.hover_pos(); + let mut drawn_year: Option = None; for (gi, g) in groups.iter().enumerate() { + let full = match g.year { + Some(y) => format!("{} {:02}", g.label, y.rem_euclid(100)), + None => g.label.clone(), + }; let gx = plot.left() + gw * gi as f32; let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom())); let hovered = hover.is_some_and(|h| grect.contains(h)); @@ -325,10 +355,12 @@ pub fn bar_chart( // the latest so it's always labeled. let every = (34.0 / gw).ceil().max(1.0) as usize; if (groups.len() - 1 - gi).is_multiple_of(every) { + let new_year = g.year.is_some() && g.year != drawn_year; + drawn_year = g.year.or(drawn_year); painter.text( pos2(gx + gw / 2.0, plot.bottom() + 6.0), Align2::CENTER_TOP, - &g.label, + if new_year { &full } else { &g.label }, theme::regular(11.0), p.faint, ); @@ -342,7 +374,7 @@ pub fn bar_chart( tooltip( &painter, pos2(gx + gw / 2.0, plot.top()), - &g.label, + &full, &lines.join("\n"), p.surface, p.border, @@ -350,10 +382,12 @@ pub fn bar_chart( p.muted, rect, ); - if resp.clicked() { - clicked = Some(gi); + if clickable { + if resp.clicked() { + clicked = Some(gi); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } } clicked @@ -362,6 +396,17 @@ pub fn bar_chart( /// Ranked horizontal bars with labels and exact amounts. Returns the /// clicked row. pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option { + hbars_signed(ui, rows, color, color) +} + +/// [`hbars`] with a row below zero (a loss) drawn in `negative`, its +/// amount too, so the longest bar can't read as the best. +pub fn hbars_signed( + ui: &mut Ui, + rows: &[(String, Decimal)], + color: Color32, + negative: Color32, +) -> Option { let p = pal(ui.ctx()); let max = rows .iter() @@ -371,13 +416,21 @@ pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option< let mut clicked = None; for (i, (label, v)) in rows.iter().enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); let painter = ui.painter_at(rect); if resp.hovered() { painter.rect_filled(rect, CornerRadius::same(6), p.hover); ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } + let below = *v < Decimal::ZERO; + let fill = if below { negative } else { color }; + let amount_color = if below && negative != color { + negative + } else { + p.text + }; let amount = fmt::dollars(*v); - let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text); + let ag = painter.layout_no_wrap(amount, theme::regular(13.0), amount_color); let label_w = (rect.width() * 0.38).min(220.0); let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w); painter.galley( @@ -392,14 +445,14 @@ pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option< pos2(bar_left, rect.center().y - 6.0), vec2(w.max(2.0), 12.0), ); - painter.rect_filled(bar, CornerRadius::same(3), color); + painter.rect_filled(bar, CornerRadius::same(3), fill); painter.galley( pos2( rect.right() - ag.size().x - 6.0, rect.center().y - ag.size().y / 2.0, ), ag, - p.text, + amount_color, ); if resp.clicked() { clicked = Some(i); @@ -491,4 +544,89 @@ mod tests { ); } } + + /// Months from November 2024 through October 2026, as the project and + /// party charts give them. + fn two_years() -> Vec { + (0..24) + .map(|i| { + let m = d(2024, 11, 1) + chrono::Months::new(i); + BarGroup { + label: m.format("%b").to_string(), + values: vec![Decimal::new(i64::from(i) * 100, 2)], + year: Some(chrono::Datelike::year(&m)), + } + }) + .collect() + } + + /// A crowded chart over two years drops every other label, January's + /// too, yet the first label drawn and the first drawn in each new year + /// carry the year, so no month name stands for two months unread. + #[test] + fn a_crowded_chart_keeps_its_years() { + for width in [380.0, 800.0] { + let groups = two_years(); + let mut h = Harness::builder() + .with_size(egui::vec2(width, 300.0)) + .build_ui(move |ui| { + bar_chart_view(ui, &groups, &[("Net", Color32::GRAY)], 200.0); + }); + crate::theme::install_fonts(&h.ctx); + crate::theme::apply(&h.ctx, false); + h.run(); + let mut labels: Vec<(String, f32)> = painted(h.output()) + .into_iter() + .filter(|(t, _, _)| { + t.len() <= 6 && t.chars().next().is_some_and(char::is_alphabetic) + }) + .filter(|(t, _, _)| t != "Net") + .map(|(t, p, _)| (t, p.x)) + .collect(); + labels.sort_by(|a, b| a.1.total_cmp(&b.1)); + let names: Vec<&str> = labels.iter().map(|l| l.0.as_str()).collect(); + assert!(names.len() < 24, "{width}: crowded, so thinned: {names:?}"); + let tagged = |n: &&str| n.ends_with(char::is_numeric); + assert!(tagged(&names[0]), "{width}: {names:?}"); + for year in [" 25", " 26"] { + assert_eq!( + names.iter().filter(|n| n.ends_with(year)).count(), + 1, + "{width}: {names:?}" + ); + } + // 2026's tag sits on the first label drawn in 2026. + let at = names.iter().position(|n| n.ends_with(" 26")).unwrap(); + assert!( + ["Jan", "Feb", "Mar"].contains(&&names[at][..3]) && !names[at - 1].ends_with(" 26"), + "{width}: {names:?}" + ); + } + } + + /// A chart with nothing to open takes no click: hovering shows its + /// figures, and the pointer stays an arrow. + #[test] + fn a_view_only_chart_shows_figures_and_takes_no_click() { + let groups = two_years(); + let mut h = Harness::builder() + .with_size(egui::vec2(800.0, 300.0)) + .build_ui(move |ui| { + bar_chart_view(ui, &groups, &[("Net", Color32::GRAY)], 200.0); + }); + crate::theme::install_fonts(&h.ctx); + crate::theme::apply(&h.ctx, false); + h.run(); + h.hover_at(egui::pos2(780.0, 120.0)); + h.run(); + let texts = painted(h.output()); + assert!( + texts.iter().any(|(t, _, _)| t == "Oct 26"), + "the tooltip names its month and year" + ); + assert_ne!( + h.output().platform_output.cursor_icon, + egui::CursorIcon::PointingHand + ); + } } diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index 106b484..ef3a13a 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -242,8 +242,25 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { Some("depreciation"), )?; let bank_fees = acct(&mut s, "6900", "Bank Fees", Expense, None)?; - let client_class = s.add_class("Client work")?; - let template_class = s.add_class("Templates")?; + // The projects (classes with their facts, rules.md B-37). + let client_class = s.add_project( + "Client work", + &ledger_core::ProjectFacts { + description: Some("Design work for the studio's clients".into()), + kind: Some(ledger_core::ProjectKind::Client), + ..Default::default() + }, + )?; + let template_class = s.add_project( + "Templates", + &ledger_core::ProjectFacts { + description: Some("Notion and Figma templates sold on Gumroad".into()), + kind: Some(ledger_core::ProjectKind::Internal), + started_on: Some(start), + links: vec!["https://juniper.example/templates".into()], + ..Default::default() + }, + )?; // Each vendor's 1099 decision, as the owner made it when they were // first paid; the card is a payment method, so what was charged to it @@ -282,6 +299,21 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { .iter() .map(|n| s.add_customer(n)) .collect::>()?; + // Work on spec for a customer, hoping they adopt it: its costs are + // expenses, and nothing is receivable (rules.md B-37 item 4). + let tidepool_started = start + Months::new(13); + let tidepool = s.add_project( + "Tidepool app", + &ledger_core::ProjectFacts { + description: Some( + "An events app built for Tidepool Records ahead of a contract".into(), + ), + kind: Some(ledger_core::ProjectKind::OnSpec), + party: s.ledger().party_of_customer(customers[3]), + started_on: Some(tidepool_started), + ..Default::default() + }, + )?; // The party directory: who they are, beyond the vendor and customer // records (roles, W-9 facts with only the TIN's last four, sites). @@ -827,7 +859,12 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { ), )), Some(riley), - Some(client_class), + // The second milestone builds the Tidepool app. + Some(if month_index == 13 { + tidepool + } else { + client_class + }), )?; } } @@ -962,6 +999,33 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { )?; s.link_attachment(contract_doc, Subject::Party(sam_party))?; s.link_attachment(contract_doc, Subject::Company)?; + // The Tidepool app's pitch, linked to it, and the decision to build it + // on spec. + let (pitch, _, _) = s.file_document( + "other", + "Tidepool app pitch.pdf", + "application/pdf", + &receipt_pdf( + "Tidepool app pitch", + &[ + "Juniper Studio LLC for Tidepool Records".into(), + "An events app: shows, tickets, a mailing list".into(), + format!("Sent {}", tidepool_started.format("%B %-d, %Y")), + ], + ), + Some("The pitch"), + )?; + s.link_attachment(pitch, Subject::Project(u64::from(tidepool)))?; + s.record_decision(&ledger_core::conventions::NewDecision { + subject: "The Tidepool app".into(), + decision_md: "Build the Tidepool app on spec; bill only if they adopt it.".into(), + decided_by: vec!["Alex Rivera".into()], + decided_on: tidepool_started, + evidence_attachment: Some(pitch), + supersedes: None, + citation: None, + projects: vec![tidepool], + })?; // Statements and reconciliations: savings reconciled through last // month; checking through the month before (its first reconciliation diff --git a/crates/ledger-gui/src/nav.rs b/crates/ledger-gui/src/nav.rs index dff10d3..ca6e2c3 100644 --- a/crates/ledger-gui/src/nav.rs +++ b/crates/ledger-gui/src/nav.rs @@ -70,6 +70,10 @@ pub enum Route { Parties, /// One party. Party(PartyId), + /// The projects (classes with their facts). + Projects, + /// One project. + Project(ledger_core::ClassId), Documents, Document(u64), Company, @@ -89,6 +93,7 @@ impl Route { Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts, Route::Assets | Route::Asset(_) => Section::Assets, Route::Parties | Route::Party(_) => Section::Parties, + Route::Projects | Route::Project(_) => Section::Projects, Route::Documents | Route::Document(_) => Section::Documents, Route::Company => Section::Company, Route::Journal | Route::JournalEvent(_) => Section::Journal, @@ -106,13 +111,14 @@ pub enum Section { Accounts, Assets, Parties, + Projects, Documents, Company, Journal, } impl Section { - pub const ALL: [Section; 11] = [ + pub const ALL: [Section; 12] = [ Section::Overview, Section::Cash, Section::Profit, @@ -121,6 +127,7 @@ impl Section { Section::Accounts, Section::Assets, Section::Parties, + Section::Projects, Section::Documents, Section::Company, Section::Journal, @@ -136,6 +143,7 @@ impl Section { Section::Accounts => "Accounts", Section::Assets => "Assets", Section::Parties => "Parties", + Section::Projects => "Projects", Section::Documents => "Documents", Section::Company => "Company", Section::Journal => "Journal", @@ -153,6 +161,7 @@ impl Section { Section::Accounts => i::TREE_STRUCTURE, Section::Assets => i::PACKAGE, Section::Parties => i::ADDRESS_BOOK, + Section::Projects => i::BRIEFCASE, Section::Documents => i::FILES, Section::Company => i::BUILDINGS, Section::Journal => i::SCROLL, @@ -169,6 +178,7 @@ impl Section { Section::Accounts => Route::Accounts, Section::Assets => Route::Assets, Section::Parties => Route::Parties, + Section::Projects => Route::Projects, Section::Documents => Route::Documents, Section::Company => Route::Company, Section::Journal => Route::Journal, diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs index ff79e06..bb195be 100644 --- a/crates/ledger-gui/src/pages/assets.rs +++ b/crates/ledger-gui/src/pages/assets.rs @@ -385,6 +385,16 @@ pub(crate) fn row( Sense::hover() }, ); + if click { + // Named by its main cell, for assistive tech (and the tests). + let name = cells + .iter() + .find(|c| c.1 == 0.0) + .or(cells.first()) + .map(|c| c.0.clone()) + .unwrap_or_default(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + } if click && resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); diff --git a/crates/ledger-gui/src/pages/cash.rs b/crates/ledger-gui/src/pages/cash.rs index d9f9702..76547fd 100644 --- a/crates/ledger-gui/src/pages/cash.rs +++ b/crates/ledger-gui/src/pages/cash.rs @@ -135,6 +135,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { .map(|(s, _, i, o)| BarGroup { label: s.format("%b").to_string(), values: vec![*i, *o], + year: None, }) .collect(); charts::bar_chart( diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 0b1abbe..121c6fd 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -50,7 +50,7 @@ fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) { /// How the books are kept, as the owners decided: conventions, decisions, /// and what is open (each only when there is something to show). -fn kept_cards(ui: &mut Ui, b: &Books) { +fn kept_cards(ui: &mut Ui, b: &Books, go: &mut Option) { let p = pal(ui.ctx()); let kept = b.ledger.conventions(); let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect(); @@ -95,6 +95,27 @@ fn kept_cards(ui: &mut Ui, b: &Books) { RichText::new(&d.new.decision_md).font(theme::regular(12.5)), &d.new.decision_md, ); + if !d.new.projects.is_empty() { + ui.horizontal_wrapped(|ui| { + for &c in &d.new.projects { + if widgets::link( + ui, + RichText::new(format!( + "{} {}", + icon::BRIEFCASE, + b.project_label(c) + )) + .font(theme::regular(12.0)) + .color(p.accent), + ) + .on_hover_text("Open this project") + .clicked() + { + *go = Some(Route::Project(c)); + } + } + }); + } provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on); ui.add_space(6.0); } @@ -132,6 +153,7 @@ fn kept_cards(ui: &mut Ui, b: &Books) { pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); + let mut kept_go: Option = None; page(ui, "company", |ui| { let c = &b.settings.company; widgets::page_header( @@ -192,7 +214,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ); }, ); - kept_cards(ui, b); + kept_cards(ui, b, &mut kept_go); } _ => { widgets::titled_card( @@ -332,6 +354,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { recurring(ui, b); parties(ui, b, shell); }); + if let Some(r) = kept_go { + shell.go(r); + } } fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs index b921cdb..92d24b1 100644 --- a/crates/ledger-gui/src/pages/documents.rs +++ b/crates/ledger-gui/src/pages/documents.rs @@ -371,6 +371,7 @@ pub fn subject_icon(s: Subject) -> &'static str { Subject::Account(_) => icon::TREE_STRUCTURE, Subject::Asset(_) => icon::PACKAGE, Subject::Party(_) => icon::ADDRESS_BOOK, + Subject::Project(_) => icon::BRIEFCASE, Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT, Subject::Period(_) => icon::CALENDAR_BLANK, Subject::PayrollRun(_) => icon::USERS, diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index c7d0aca..53327b6 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -239,13 +239,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { if r.clicked() { shell.go(Route::Account(line.account)); } - if let Some(c) = line.class_id.and_then(|c| class_names.get(&c)) - { - ui.label( + 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() + { + shell.go(Route::Project(cid)); } if let (Some(cur), Some(fa)) = (&line.foreign_currency, line.foreign_amount) diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 413c0d6..67654c5 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -479,7 +479,26 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { .map_or(Route::Company, Route::Party), ), ), - "class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None), + "class.added" => ev( + icon::BRIEFCASE, + format!("Added project {}", s(p, "name")), + u32::try_from(id).ok().map(Route::Project), + ), + "project.set" => { + let status = ledger_core::ProjectStatus::parse(s(p, "status")) + .map_or("", |x| x.words()) + .to_lowercase(); + let kind = match p.get("kind").and_then(|v| v.as_str()) { + Some(k) => ledger_core::ProjectKind::parse(k) + .map_or(k.to_string(), |x| x.words().to_lowercase()), + None => "kind not recorded".into(), + }; + ev( + icon::BRIEFCASE, + format!("Project {}: {status}, {kind}", s(p, "name")), + u32::try_from(id).ok().map(Route::Project), + ) + } "party.added" => { let roles: Vec = p .get("roles") @@ -1114,8 +1133,8 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event { .get("class_id") .and_then(|v| v.as_u64()) .and_then(|c| b.ledger.get_class(c as u32)) - .map(|c| c.name.clone()) - .unwrap_or_else(|| "no class".into()); + .map(|c| format!("project {}", c.name)) + .unwrap_or_else(|| "no project".into()); Event { icon: icon::TAG, text: format!( diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs index 39dd76e..ca18e4d 100644 --- a/crates/ledger-gui/src/pages/mod.rs +++ b/crates/ledger-gui/src/pages/mod.rs @@ -11,6 +11,7 @@ pub mod journal; pub mod overview; pub mod parties; pub mod profit; +pub mod projects; pub mod reconciliation; pub mod reports; pub mod transactions; @@ -61,6 +62,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) { Route::Asset(id) => assets::show_asset(ui, b, *id, shell), Route::Parties => parties::show_list(ui, b, shell), Route::Party(id) => parties::show_party(ui, b, *id, shell), + Route::Projects => projects::show_list(ui, b, shell), + Route::Project(id) => projects::show_project(ui, b, *id, shell), Route::Documents => documents::show(ui, b, None, shell), Route::Document(id) => documents::show(ui, b, Some(*id), shell), Route::Company => company::show(ui, b, shell), diff --git a/crates/ledger-gui/src/pages/overview.rs b/crates/ledger-gui/src/pages/overview.rs index 1bb7f0b..c0ab1ee 100644 --- a/crates/ledger-gui/src/pages/overview.rs +++ b/crates/ledger-gui/src/pages/overview.rs @@ -397,6 +397,7 @@ fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { .map(|m| BarGroup { label: m.start.format("%b").to_string(), values: vec![m.revenue, m.expenses, m.net()], + year: None, }) .collect(); let clicked = charts::bar_chart( diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 4c3f733..3792185 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -322,7 +322,7 @@ fn paid_cell(paid: Decimal, p: &theme::Palette) -> (String, egui::Color32) { } /// A filter chip. -fn chip(ui: &mut Ui, text: &str, on: bool) -> bool { +pub(crate) fn chip(ui: &mut Ui, text: &str, on: bool) -> bool { let p = pal(ui.ctx()); ui.add( egui::Button::new( @@ -642,7 +642,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { ); }); ui.add_space(4.0); - widgets::copyable( + widgets::copyable_truncated( ui, RichText::new(party.label()).font(theme::bold(26.0)), party.label(), @@ -654,7 +654,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { .font(theme::regular(12.5)) .color(p.muted), ); - widgets::copyable(ui, RichText::new(&party.name), &party.name); + widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name); }); } ui.add_space(6.0); @@ -841,6 +841,11 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { } }); + let projects = b.ledger.projects_of_party(id); + if !projects.is_empty() { + projects_card(ui, b, &projects, &mut go); + ui.add_space(14.0); + } entries_card(ui, b, &info, &mut go); ui.add_space(14.0); if !info.invoices.is_empty() || !info.bills.is_empty() { @@ -879,26 +884,9 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { for &i in &info.events { let line = &b.journal[i]; let ev = super::journal::describe_any(b, line); - ui.horizontal(|ui| { - ui.label(RichText::new(ev.icon).color(p.muted)); - if widgets::link_truncated( - ui, - RichText::new(&ev.text).font(theme::regular(12.5)), - ) - .clicked() - { - go = Some(Route::JournalEvent(line.seq)); - } - ui.label( - RichText::new(format!( - "event {} · {}", - line.seq, - fmt::timestamp(&line.ts) - )) - .font(theme::regular(11.0)) - .color(p.faint), - ); - }); + if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() { + go = Some(Route::JournalEvent(line.seq)); + } } }, ); @@ -919,14 +907,14 @@ fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option) { facts(ui, |ui, k| { let text = |ui: &mut Ui, v: &Option| match v { Some(t) => { - widgets::copyable(ui, RichText::new(t), t); + widgets::copyable_truncated(ui, RichText::new(t), t); } None => { ui.label(RichText::new("not recorded").color(p.faint)); } }; k(ui, "Name"); - widgets::copyable(ui, RichText::new(&party.name), &party.name); + widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name); ui.end_row(); k(ui, "Display name"); text(ui, &party.display_name); @@ -1223,6 +1211,59 @@ fn records_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu ); } +/// The projects for or with the party (rules.md B-37), by name: status +/// and net to date; a row opens its project. +fn projects_card(ui: &mut Ui, b: &Books, ids: &[ledger_core::ClassId], go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Projects", + |ui| { + widgets::pill(ui, &ids.len().to_string(), p.muted, p.hover); + }, + |ui| { + let amt = widgets::AMOUNT_MIN + 24.0; + let w = ui.available_width().max(100.0 + amt + 180.0); + widgets::min_width(ui, "party_projects", w, |ui| { + for &id in ids { + let name = b.project_label(id); + let f = b.ledger.project_facts(id).unwrap_or_default(); + let net = ledger_core::project_to_date(&b.ledger, id, b.today) + .ok() + .map(|t| t.net()); + let (net_t, net_c) = match net { + Some(n) if n.is_zero() => ("—".to_string(), p.faint), + Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative), + Some(n) => (fmt::accounting(n), p.text), + None => (fmt::CANT_COMPUTE.to_string(), p.faint), + }; + let sub = format!("{} · net to date", super::projects::kind_words(f.kind)); + let resp = row( + ui, + w, + &[ + (name, 0.0, false, p.text), + ( + f.status.words().to_string(), + 100.0, + false, + super::projects::status_colors(f.status, &p).0, + ), + (net_t, amt, true, net_c), + ], + Some((sub.as_str(), p.faint)), + false, + true, + ); + if resp.on_hover_text("Open this project").clicked() { + *go = Some(Route::Project(id)); + } + } + }); + }, + ); +} + /// Every entry tagged with the party, newest first. fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option) { let p = pal(ui.ctx()); @@ -1608,15 +1649,12 @@ fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate) let groups: Vec = shown .iter() .map(|m| crate::charts::BarGroup { - label: if multi_year && m.month.month() == 1 { - m.month.format("%b %y").to_string() - } else { - m.month.format("%b").to_string() - }, + label: m.month.format("%b").to_string(), values: vec![m.paid, m.received], + year: multi_year.then(|| m.month.year()), }) .collect(); - crate::charts::bar_chart( + crate::charts::bar_chart_view( ui, &groups, &[("Paid out", p.series[2]), ("Received in", p.series[1])], diff --git a/crates/ledger-gui/src/pages/profit.rs b/crates/ledger-gui/src/pages/profit.rs index 3fd08ba..d8d7e67 100644 --- a/crates/ledger-gui/src/pages/profit.rs +++ b/crates/ledger-gui/src/pages/profit.rs @@ -18,10 +18,15 @@ use ledger_core::reports::profit_and_loss_for_period; use rust_decimal::Decimal; use std::collections::BTreeMap; -fn pct(part: Decimal, whole: Decimal) -> Option { +pub(crate) fn pct(part: Decimal, whole: Decimal) -> Option { part.checked_div(whole)? .checked_mul(Decimal::from(100)) - .map(|x| x.round_dp(1)) + .map(|x| { + // One place always, so 100% prints as "100.0", as 64.0 does. + let mut x = x.round_dp(1); + x.rescale(1); + x + }) } pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { @@ -74,6 +79,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { .map(|m| BarGroup { label: m.start.format("%b").to_string(), values: vec![m.revenue, m.expenses, m.net()], + year: None, }) .collect(); if let Some(i) = charts::bar_chart( @@ -160,10 +166,14 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ); ui.add_space(16.0); spending_changes(ui, b, shell); + let mut go = None; columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { 0 => by_customer(ui, b), - _ => by_class(ui, b), + _ => by_class(ui, b, &mut go), }); + if let Some(r) = go { + shell.go(r); + } }); } @@ -359,46 +369,52 @@ fn rows_total(by: &BTreeMap) -> Option { .try_fold(Decimal::ZERO, |t, v| t.checked_add(*v)) } -fn by_class(ui: &mut Ui, b: &Books) { +/// Each project's net income this year, a bar each; a bar opens its +/// project. +fn by_class(ui: &mut Ui, b: &Books, go: &mut Option) { let p = pal(ui.ctx()); let (ys, ye) = b.fiscal_ytd(); let classes = b.ledger.list_classes(); widgets::titled_card( ui, - "Net income by class, this year", + "Net income by project, this year", |_| {}, |ui| { if classes.is_empty() { widgets::empty( ui, - icon::TAG, - "No classes", - "Tag lines with classes (departments, products) to see profit by each.", + icon::BRIEFCASE, + "No projects", + "Tag lines with a project to see profit by each.", ); return; } - let mut rows: Vec<(String, Decimal)> = classes + let mut rows: Vec<(ledger_core::ClassId, String, Decimal)> = classes .iter() .filter_map(|c| { let pl = profit_and_loss_for_period(&b.ledger, ys, ye, Some(c.id)).ok()?; let net = pl.total_revenue - pl.total_expenses; (!pl.total_revenue.is_zero() || !pl.total_expenses.is_zero()) - .then(|| (c.name.clone(), net)) + .then(|| (c.id, c.name.clone(), net)) }) .collect(); - rows.sort_by_key(|r| std::cmp::Reverse(r.1)); + rows.sort_by_key(|r| std::cmp::Reverse(r.2)); if rows.is_empty() { widgets::empty( ui, - icon::TAG, - "No classified income or spending this year", + icon::BRIEFCASE, + "No project income or spending this year", "", ); return; } - charts::hbars(ui, &rows, p.series[0]); + let ids: Vec = rows.iter().map(|r| r.0).collect(); + let bars: Vec<(String, Decimal)> = rows.into_iter().map(|r| (r.1, r.2)).collect(); + if let Some(i) = charts::hbars_signed(ui, &bars, p.series[0], p.negative) { + *go = Some(Route::Project(ids[i])); + } ui.label( - RichText::new("Only lines tagged with a class count here.") + RichText::new("Only lines tagged with a project count here.") .font(theme::regular(12.0)) .color(p.faint), ); @@ -418,6 +434,9 @@ mod tests { assert_eq!(pct(cents(2_500), cents(20_000)), Some(Decimal::new(125, 1))); assert_eq!(pct(cents(1), cents(3)), Some(Decimal::new(333, 1))); assert_eq!(pct(cents(1), Decimal::ZERO), None); + // One place whatever the value. + assert_eq!(pct(cents(500), cents(500)).unwrap().to_string(), "100.0"); + assert_eq!(pct(cents(-100), cents(200)).unwrap().to_string(), "-50.0"); assert_eq!(pct(Decimal::MAX, Decimal::new(1, 2)), None); } @@ -440,6 +459,6 @@ mod tests { let (_dir, books) = b.books(); let (texts, _) = render(books, Route::Profit, |_| {}); find(&texts, "Invoiced by customer, this year"); - find(&texts, "Acme is 100% of invoiced income."); + find(&texts, "Acme is 100.0% of invoiced income."); } } diff --git a/crates/ledger-gui/src/pages/projects.rs b/crates/ledger-gui/src/pages/projects.rs new file mode 100644 index 0000000..c7066e4 --- /dev/null +++ b/crates/ledger-gui/src/pages/projects.rs @@ -0,0 +1,915 @@ +//! Projects: every class, with its facts (what it is, where it stands, who +//! it is for), and one project completely: what it earned and cost, by +//! month and by account, its entries, documents, decisions and history +//! (rules.md B-37). Every figure is ledger-core's (`project_figures`, +//! `project_to_date`, `project_entries`), the same the server's +//! `books.projects` and `books.project` return; this module only arranges +//! them, and never writes. + +use super::assets::{facts, row}; +use super::page; +use super::parties::chip; +use crate::app::Shell; +use crate::books::Books; +use crate::charts::{self, BarGroup}; +use crate::fmt; +use crate::nav::Route; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::{Datelike, NaiveDate}; +use egui::{RichText, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus}; +use rust_decimal::Decimal; + +/// The list's filter, kept across navigation. +#[derive(Default)] +pub struct State { + /// Only projects with this status (None: every one). + pub status: Option, +} + +/// One project on the list: its id, name, facts and net to date. +pub struct Listed { + pub id: ClassId, + pub name: String, + pub facts: ProjectFacts, + pub to_date: Option, +} + +/// Every project (every class), by name ignoring capitals, with its +/// figures to date through today. +pub fn listed(b: &Books) -> Vec { + let mut out: Vec = b + .ledger + .list_classes() + .into_iter() + .map(|c| Listed { + id: c.id, + name: c.name.clone(), + facts: b.ledger.project_facts(c.id).unwrap_or_default(), + to_date: ledger_core::project_to_date(&b.ledger, c.id, b.today).ok(), + }) + .collect(); + out.sort_by_key(|a| (a.name.to_lowercase(), a.id)); + out +} + +/// A status's colors: (text, fill). +pub(crate) fn status_colors( + s: ProjectStatus, + p: &theme::Palette, +) -> (egui::Color32, egui::Color32) { + match s { + ProjectStatus::Active => (p.positive, p.positive_soft), + ProjectStatus::Paused => (p.warning, p.warning_soft), + ProjectStatus::Finished => (p.muted, p.hover), + ProjectStatus::Abandoned => (p.faint, p.hover), + } +} + +/// A kind in words, or that it isn't recorded. +pub fn kind_words(k: Option) -> &'static str { + k.map_or("Kind not recorded", ProjectKind::words) +} + +/// The list's sentence: how many in each status, and the net across them. +fn summary(rows: &[Listed]) -> String { + if rows.is_empty() { + return "No projects yet.".into(); + } + let parts: Vec = ProjectStatus::ALL + .into_iter() + .filter_map(|s| { + let n = rows.iter().filter(|r| r.facts.status == s).count(); + (n > 0).then(|| format!("{n} {}", s.key())) + }) + .collect(); + let net: Option = rows + .iter() + .map(|r| r.to_date.as_ref().map(|t| t.net())) + .try_fold(Decimal::ZERO, |t, n| t.checked_add(n?)); + let counts = if parts.len() == 1 && rows.len() > 1 { + format!( + "{}, all {}", + fmt::count(rows.len(), "project", "projects"), + rows[0].facts.status.key() + ) + } else if rows.len() == 1 { + format!("1 project, {}", rows[0].facts.status.key()) + } else { + format!( + "{}: {}", + fmt::count(rows.len(), "project", "projects"), + parts.join(", ") + ) + }; + format!( + "{counts}. Net to date across them: {}.", + fmt::dollars_or(net) + ) +} + +/// The projects. +pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "projects", |ui| { + let all = listed(b); + widgets::page_header(ui, "Projects", &summary(&all)); + if all.is_empty() { + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::BRIEFCASE, + "No projects yet", + "The agent adds them (books.add_project). Tag lines with a project to see what it earns and costs.", + ) + }); + return; + } + let st = &mut shell.state.projects; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 6.0); + if chip(ui, &format!("All {}", all.len()), st.status.is_none()) { + st.status = None; + } + for s in ProjectStatus::ALL { + let n = all.iter().filter(|r| r.facts.status == s).count(); + if n == 0 { + continue; + } + let on = st.status == Some(s); + if chip(ui, &format!("{} {n}", s.words()), on) { + st.status = if on { None } else { Some(s) }; + } + } + }); + ui.add_space(12.0); + let rows: Vec<&Listed> = all + .iter() + .filter(|r| st.status.is_none_or(|s| r.facts.status == s)) + .collect(); + let mut go = None; + widgets::titled_card( + ui, + "Projects", + |ui| { + widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover); + }, + |ui| { + let amt = widgets::AMOUNT_MIN + 24.0; + let avail = ui.available_width(); + let status_w = 100.0; + let kind_w = if avail >= 760.0 { 140.0 } else { 0.0 }; + let party_w = if avail >= 900.0 { + (avail * 0.18).clamp(140.0, 220.0) + } else { + 0.0 + }; + let w = avail.max(status_w + kind_w + party_w + amt + 180.0); + let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> { + c.into_iter() + .enumerate() + .filter(|(i, _)| (*i != 2 || kind_w > 0.0) && (*i != 3 || party_w > 0.0)) + .map(|(_, c)| c) + .collect() + }; + widgets::min_width(ui, "project_list", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &cells([ + head("Name", 0.0, false), + head("Status", status_w, false), + head("Kind", kind_w, false), + head("Party", party_w, false), + head("Net to date", amt, true), + ]), + None, + true, + false, + ); + widgets::rule(ui); + for r in &rows { + let (sc, _) = status_colors(r.facts.status, &p); + let party = r + .facts + .party + .map(|x| b.party_label(x)) + .unwrap_or_else(|| "—".into()); + let (net_t, net_c) = match r.to_date.as_ref().map(|t| t.net()) { + Some(n) if n.is_zero() => ("—".to_string(), p.faint), + Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative), + Some(n) => (fmt::accounting(n), p.text), + None => (fmt::CANT_COMPUTE.to_string(), p.faint), + }; + let sub = r + .facts + .description + .clone() + .unwrap_or_else(|| format!("Project {}", r.id)); + let sub = sub.lines().next().unwrap_or("").to_string(); + let resp = row( + ui, + w, + &cells([ + (r.name.clone(), 0.0, false, p.text), + (r.facts.status.words().to_string(), status_w, false, sc), + (kind_words(r.facts.kind).to_string(), kind_w, false, p.muted), + (party, party_w, false, p.muted), + (net_t, amt, true, net_c), + ]), + Some((sub.as_str(), p.faint)), + false, + true, + ); + let resp = resp.on_hover_text("Open this project"); + resp.context_menu(|ui| { + widgets::copy_item(ui, "Copy name", &r.name); + widgets::copy_item(ui, "Copy project id", &r.id.to_string()); + }); + if resp.clicked() { + go = Some(r.id); + } + } + }); + }, + ); + ui.add_space(4.0); + ui.add( + egui::Label::new( + RichText::new(format!( + "Net to date: every posted revenue and expense line tagged to the project through {}, closing entries left out; revenue less expenses.", + fmt::date(b.today) + )) + .font(theme::regular(11.5)) + .color(p.faint), + ) + .wrap(), + ); + if let Some(id) = go { + shell.go(Route::Project(id)); + } + }); +} + +/// The journal events naming a project: its class and project records, +/// links of files to it, and decisions naming it (indexes into +/// [`Books::journal`], oldest first). +pub fn project_events(b: &Books, id: ClassId) -> Vec { + let id64 = u64::from(id); + let u = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_u64()); + b.journal + .iter() + .enumerate() + .filter(|(_, line)| { + let p = &line.payload; + match line.line_type.as_str() { + "class.added" | "project.set" => u(p, "id") == Some(id64), + "attachment.linked" | "attachment.unlinked" => { + p.get("subject") + .and_then(|s| s.get("kind")) + .and_then(|k| k.as_str()) + == Some("project") + && p.get("subject").and_then(|s| u(s, "id")) == Some(id64) + } + "decision.recorded" => p + .get("projects") + .and_then(|v| v.as_array()) + .is_some_and(|a| a.iter().any(|x| x.as_u64() == Some(id64))), + _ => false, + } + }) + .map(|(i, _)| i) + .collect() +} + +/// A number tile: (label, value, how it is computed, the line under it). +type Tile = ( + String, + Option, + String, + Option<(String, egui::Color32)>, +); + +/// One project, completely. +pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let (Some(class), Some(f)) = (b.ledger.get_class(id), b.ledger.project_facts(id)) else { + page(ui, "project", |ui| { + widgets::empty( + ui, + icon::BRIEFCASE, + "No such project", + "It isn't in these books.", + ); + }); + return; + }; + let mut go: Option = None; + let today = b.today; + let to_date = ledger_core::project_to_date(&b.ledger, id, today).ok(); + let entries = ledger_core::project_entries(&b.ledger, id); + page(ui, "project", |ui| { + ui.horizontal(|ui| { + if widgets::link( + ui, + RichText::new(format!("{} Projects", icon::ARROW_LEFT)) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .clicked() + { + go = Some(Route::Projects); + } + widgets::copyable( + ui, + RichText::new(format!("· Project {id}")) + .font(theme::regular(12.5)) + .color(p.muted), + &id.to_string(), + ); + }); + ui.add_space(4.0); + widgets::copyable_truncated( + ui, + RichText::new(&class.name).font(theme::bold(26.0)), + &class.name, + ); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + let (fg, bg) = status_colors(f.status, &p); + widgets::pill(ui, f.status.words(), fg, bg); + match f.kind { + Some(ProjectKind::OnSpec) => { + widgets::pill(ui, ProjectKind::OnSpec.words(), p.accent, p.accent_soft) + .on_hover_text( + "Work done hoping to be paid: its costs are expenses as they are spent, and a repayment is income only when it arrives.", + ); + } + k => { + widgets::pill(ui, kind_words(k), p.muted, p.hover); + } + } + }); + if let Some(d) = &f.description { + ui.add_space(6.0); + let w = ui.available_width().min(860.0); + ui.allocate_ui(vec2(w, 0.0), |ui| { + ui.set_max_width(w); + ui.label(RichText::new(d).font(theme::regular(15.0)).color(p.muted)); + }); + } + ui.add_space(14.0); + + // What it earned and cost, to date. + let span = format!( + "Every posted line tagged to this project through {}, closing entries left out.", + fmt::date(today) + ); + let mut tiles: Vec = vec![ + ( + "Income to date".into(), + to_date.as_ref().map(|t| t.revenue), + format!("{span} Revenue lines."), + None, + ), + ( + "Spending to date".into(), + to_date.as_ref().map(|t| t.expenses), + format!("{span} Expense lines."), + None, + ), + ( + "Net to date".into(), + to_date.as_ref().map(|t| t.net()), + format!("{span} Income less spending."), + to_date.as_ref().map(|t| { + let n = t.net(); + if n < Decimal::ZERO { + ("a loss so far".to_string(), p.negative) + } else if n.is_zero() { + ("even".to_string(), p.muted) + } else { + ("a profit so far".to_string(), p.positive) + } + }), + ), + ]; + if let Some(u) = to_date.as_ref().and_then(|t| t.unrecovered_cost) { + tiles.push(( + "Unrecovered cost".into(), + Some(u), + format!( + "{span} Spending less income, never below zero: what it would take to be repaid in full. A hoped-for payment isn't income or a receivable until it arrives (ASC 450-30-25-1), so this is a figure, not something owed to the company." + ), + Some(("if repaid: not a receivable".into(), p.muted)), + )); + } + if f.kind == Some(ProjectKind::Client) + && let Some(t) = &to_date + && !t.revenue.is_zero() + { + tiles.push(( + "Margin to date".into(), + None, + format!("{span} Net as a share of income."), + None, + )); + } + let margin = to_date + .as_ref() + .and_then(|t| super::profit::pct(t.net(), t.revenue)); + let n = tiles.len(); + super::grid(ui, n, 170.0, 6, |i, ui| { + let (label, value, how, sub) = &tiles[i]; + let text = if label == "Margin to date" { + margin.map_or_else(|| "—".into(), |m| format!("{m}%")) + } else { + fmt::dollars_or(*value) + }; + let r = widgets::kpi( + ui, + widgets::Kpi { + label, + value: text, + sub: sub.clone(), + spark: None, + how, + }, + ); + if let Some(v) = value { + r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string())); + } + }); + ui.add_space(2.0); + + // By month and by account. + let first = entries + .iter() + .filter_map(|(e, _)| b.ledger.get_entry(*e)) + .filter(|e| e.status() == ledger_core::EntryStatus::Posted) + .map(|e| e.date()) + .min(); + super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| { + if col == 0 { + months_card(ui, b, id, first); + } else { + accounts_card(ui, b, id, &mut go); + } + }); + super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| { + if col == 0 { + facts_card(ui, b, id, &class.name, &f, &mut go); + } else { + decisions_card(ui, b, id, &mut go); + } + }); + entries_card(ui, b, &entries, &mut go); + ui.add_space(14.0); + let here = [ledger_store::Subject::Project(u64::from(id))]; + super::documents::documents_card_with( + ui, + b, + "Documents", + &here, + &here, + "No documents linked to it (its contract, spec or pitch).", + &mut go, + ); + ui.add_space(14.0); + let events = project_events(b, id); + widgets::titled_card( + ui, + "History", + |_| {}, + |ui| { + if events.is_empty() { + ui.label(RichText::new("No journal events name it.").color(p.muted)); + } + for &i in &events { + let line = &b.journal[i]; + let ev = super::journal::describe_any(b, line); + if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() { + go = Some(Route::JournalEvent(line.seq)); + } + } + }, + ); + }); + if let Some(r) = go { + shell.go(r); + } +} + +/// Income, spending and net by month: from the month of its first posted +/// tagged entry through this month, at most the last 24. +fn months_card(ui: &mut Ui, b: &Books, id: ClassId, first: Option) { + let p = pal(ui.ctx()); + let span = first.map(|f| { + let start = ledger_core::analysis::interval_start( + f.min(b.today), + ledger_core::analysis::Interval::Month, + ); + crate::books::Books::last_months((start, b.today), 24) + }); + let months = span + .and_then(|(s, e)| ledger_core::project_figures(&b.ledger, id, s, e).ok()) + .map(|f| f.months) + .unwrap_or_default(); + widgets::titled_card( + ui, + "By month", + |ui| { + if let Some((s, _)) = span { + ui.label( + RichText::new(format!("since {}", fmt::month(s))) + .font(theme::regular(12.0)) + .color(p.muted), + ); + } + }, + |ui| { + if months.is_empty() { + widgets::empty(ui, icon::CHART_BAR, "No tagged lines yet", ""); + return; + } + let multi_year = + months.first().map(|m| m.start.year()) != months.last().map(|m| m.start.year()); + let groups: Vec = months + .iter() + .map(|m| BarGroup { + label: m.start.format("%b").to_string(), + values: vec![m.revenue, m.expenses, m.net()], + year: multi_year.then(|| m.start.year()), + }) + .collect(); + // Hover shows a month's figures; a click opens nothing, since + // the reports are the whole company's, not the project's. + charts::bar_chart_view( + ui, + &groups, + &[ + ("Income", p.series[1]), + ("Spending", p.series[2]), + ("Net", p.series[0]), + ], + 200.0, + ); + }, + ); +} + +/// Its revenue then expense accounts to date, with a total. +fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option) { + let p = pal(ui.ctx()); + // The to-date statement alone: project_figures would build every + // month's too, each frame. + let pl = ledger_core::reports::profit_and_loss_for_period( + &b.ledger, + ledger_core::calendar::FIRST_DAY.min(b.today), + b.today, + Some(id), + ) + .ok(); + widgets::titled_card( + ui, + "By account, to date", + |_| {}, + |ui| { + let Some(pl) = pl.filter(|pl| !pl.revenue.is_empty() || !pl.expenses.is_empty()) else { + widgets::empty(ui, icon::TREE_STRUCTURE, "No income or spending tagged", ""); + return; + }; + let amt = widgets::amount_col_w(ui.available_width(), 1, 160.0, 140.0); + let w = ui.available_width(); + let mut line = |ui: &mut Ui, l: &ledger_core::StatementLine, expense: bool| { + let shown = if expense { -l.amount } else { l.amount }; + let resp = row( + ui, + w, + &[ + (format!("{} {}", l.code, l.name), 0.0, false, p.text), + ( + fmt::accounting(shown), + amt, + true, + if shown < Decimal::ZERO { + p.negative + } else { + p.text + }, + ), + ], + None, + false, + true, + ); + if resp.on_hover_text("Open this account's register").clicked() + && let Some(a) = b.ledger.chart().find_by_code(&l.code) + { + *go = Some(Route::Account(a.id)); + } + }; + for l in &pl.revenue { + line(ui, l, false); + } + for l in &pl.expenses { + line(ui, l, true); + } + widgets::rule(ui); + let net = pl.net_income(); + row( + ui, + w, + &[ + ("Net".to_string(), 0.0, false, p.text), + ( + fmt::accounting(net), + amt, + true, + if net < Decimal::ZERO { + p.negative + } else { + p.text + }, + ), + ], + None, + true, + false, + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "Spending shows in parentheses; only revenue and expense lines count.", + ) + .font(theme::regular(11.5)) + .color(p.faint), + ); + }, + ); +} + +/// What it is: kind, status, party, dates, links, ids, description, notes. +fn facts_card( + ui: &mut Ui, + b: &Books, + id: ClassId, + name: &str, + f: &ProjectFacts, + go: &mut Option, +) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Facts", + |_| {}, + |ui| { + facts(ui, |ui, k| { + let none = |ui: &mut Ui| { + ui.label(RichText::new("not recorded").color(p.faint)); + }; + k(ui, "Name"); + widgets::copyable_truncated(ui, RichText::new(name), name); + ui.end_row(); + k(ui, "Kind"); + match f.kind { + Some(kind) => { + ui.label(kind.words()); + } + None => none(ui), + } + ui.end_row(); + k(ui, "Status"); + ui.label(f.status.words()); + ui.end_row(); + k(ui, "Party"); + match f.party { + Some(pid) => { + if widgets::link(ui, RichText::new(b.party_label(pid)).color(p.accent)) + .on_hover_text("Open this party") + .clicked() + { + *go = Some(Route::Party(pid)); + } + } + None => none(ui), + } + ui.end_row(); + k(ui, "Started"); + match f.started_on { + Some(d) => { + ui.label(fmt::date(d)); + } + None => none(ui), + } + ui.end_row(); + if f.ended_on.is_some() || f.status.is_over() { + k(ui, "Ended"); + match f.ended_on { + Some(d) => { + ui.label(fmt::date(d)); + } + None => none(ui), + } + ui.end_row(); + } + k(ui, "Links"); + if f.links.is_empty() { + none(ui); + } else { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for l in &f.links { + widgets::copyable_truncated(ui, RichText::new(l), l); + } + }); + } + ui.end_row(); + k(ui, "Project id"); + widgets::copyable(ui, RichText::new(id.to_string()), &id.to_string()); + ui.end_row(); + }); + if let Some(n) = &f.notes { + ui.add_space(8.0); + ui.label( + RichText::new("Notes") + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.add(egui::Label::new(RichText::new(n).font(theme::regular(13.0))).wrap()); + } + }, + ); +} + +/// The decisions naming it, newest first; replaced ones muted. +fn decisions_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option) { + let p = pal(ui.ctx()); + let decisions: Vec<_> = b + .ledger + .conventions() + .decisions() + .filter(|d| d.new.projects.contains(&id)) + .collect(); + widgets::titled_card( + ui, + "Decisions", + |ui| { + if !decisions.is_empty() { + widgets::pill(ui, &decisions.len().to_string(), p.muted, p.hover); + } + }, + |ui| { + if decisions.is_empty() { + ui.label(RichText::new("No decision names this project.").color(p.muted)); + return; + } + for (i, d) in decisions.iter().rev().enumerate() { + if i > 0 { + ui.add_space(4.0); + widgets::rule(ui); + ui.add_space(4.0); + } + let replaced = d.superseded_by.is_some(); + let text = if replaced { p.muted } else { p.text }; + if widgets::link( + ui, + RichText::new(&d.new.subject) + .font(theme::semibold(13.0)) + .color(text), + ) + .on_hover_text("Open it in the journal") + .clicked() + { + *go = Some(Route::JournalEvent(d.seq)); + } + ui.add( + egui::Label::new( + RichText::new(&d.new.decision_md) + .font(theme::regular(12.5)) + .color(if replaced { p.muted } else { p.text }), + ) + .wrap(), + ); + let mut by = format!( + "{} · {}", + d.new.decided_by.join(" and "), + fmt::date(d.new.decided_on) + ); + if let Some(n) = d.superseded_by { + by.push_str(&format!(" · replaced by #{n}")); + } + ui.label(RichText::new(by).font(theme::regular(11.0)).color(p.faint)); + } + }, + ); +} + +/// Every entry with a line tagged to it, newest first, with its project +/// amount. +fn entries_card(ui: &mut Ui, b: &Books, entries: &[(u64, Decimal)], go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Entries", + |ui| { + ui.label( + RichText::new(fmt::count(entries.len(), "entry", "entries")) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + if entries.is_empty() { + ui.label( + RichText::new("No line in the books is tagged with it yet.").color(p.muted), + ); + return; + } + let amt = widgets::AMOUNT_MIN + 24.0; + let w = ui.available_width().max(104.0 + 64.0 + 150.0 + amt + 200.0); + widgets::min_width(ui, "project_entries", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Date", 104.0, false), + head("Entry", 64.0, false), + head("Description", 0.0, false), + head("Kind", 150.0, false), + head("Project amount", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for (eid, amount) in entries { + let Some(e) = b.entry(*eid) else { + continue; + }; + let draft = e.status != ledger_core::EntryStatus::Posted; + let text = if draft { p.muted } else { p.text }; + let kind = if draft { + format!("{} · draft", e.origin) + } else { + e.origin.to_string() + }; + let mut sub: Vec = Vec::new(); + if e.amount != Some(amount.abs()) { + sub.push(format!("of {}", fmt::dollars_or(e.amount))); + } + if !e.receipts.is_empty() { + sub.push(fmt::count(e.receipts.len(), "receipt", "receipts")); + } + let accounts: Vec = + e.accounts.iter().map(|a| b.account_name(*a)).collect(); + sub.push(accounts.join(" → ")); + let sub = sub.join(" · "); + let resp = row( + ui, + w, + &[ + (fmt::date(e.date), 104.0, false, text), + (format!("#{}", e.id), 64.0, false, p.muted), + (e.description.clone(), 0.0, false, text), + (kind, 150.0, false, if draft { p.warning } else { p.muted }), + ( + fmt::accounting(*amount), + amt, + true, + if *amount < Decimal::ZERO { + p.negative + } else { + text + }, + ), + ], + Some((sub.as_str(), p.faint)), + false, + true, + ); + resp.context_menu(|ui| { + widgets::copy_item(ui, "Copy entry number", &format!("#{}", e.id)); + widgets::copy_item(ui, "Copy project amount", &amount.to_string()); + }); + if resp.on_hover_text("Open this entry").clicked() { + *go = Some(Route::Entry(e.id)); + } + } + }); + ui.add_space(4.0); + ui.label( + RichText::new( + "Project amount: the entry's tagged income less its tagged spending; a tagged balance-sheet line counts nothing.", + ) + .font(theme::regular(11.5)) + .color(p.faint), + ); + }, + ); +} diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index 3e5e521..a693232 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -244,6 +244,10 @@ pub fn fuzzy(hay: &str, query: &str) -> Option { /// "reconciliation reconciled", its account's code and name, and its /// statement date (as shown and as YYYY-MM-DD); it opens /// Route::Reconciliation(its id), voided ones too, saying so. +/// INVARIANTS (projects): +/// 9. A project is listed when the query fuzzily matches its name and +/// description; a query found whole in its name (ignoring case) adds +/// 500. It opens Route::Project(its id). /// ``` pub fn search(b: &Books, query: &str) -> Vec { let q = query.trim(); @@ -381,6 +385,22 @@ pub fn search(b: &Books, query: &str) -> Vec { }); } } + for c in b.ledger.list_classes() { + let f = b.ledger.project_facts(c.id).unwrap_or_default(); + let desc = f.description.as_deref().unwrap_or(""); + let hay = format!("{} {desc}", c.name); + if let Some(score) = fuzzy(&hay, q) { + let whole = c.name.to_lowercase().contains(&q.to_lowercase()); + let kind = f.kind.map_or("Kind not recorded", |k| k.words()); + out.push(Item { + icon: icon::BRIEFCASE, + title: c.name.clone(), + detail: format!("{kind} · {}", f.status.words()), + route: Route::Project(c.id), + score: score + if whole { 500 } else { 0 }, + }); + } + } for st in &b.statements { let file = b .attachment(st.attachment_id) @@ -514,6 +534,7 @@ pub fn describe(b: &Books, r: &Route) -> String { .unwrap_or_default(), Route::Asset(id) => b.asset_label(*id), Route::Party(id) => b.party_label(*id), + Route::Project(id) => b.project_label(*id), Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)), Route::JournalEvent(seq) => format!("Event {seq}"), _ => String::new(), diff --git a/crates/ledger-gui/src/schemes.rs b/crates/ledger-gui/src/schemes.rs index 8227139..4cf3e07 100644 --- a/crates/ledger-gui/src/schemes.rs +++ b/crates/ledger-gui/src/schemes.rs @@ -990,6 +990,7 @@ mod painted { Route::Reports(ReportKind::BalanceSheet), Route::Accounts, Route::Parties, + Route::Projects, Route::Journal, ]; // A page a thread: each checks every scheme. diff --git a/crates/ledger-gui/src/widgets.rs b/crates/ledger-gui/src/widgets.rs index c059bdb..d405460 100644 --- a/crates/ledger-gui/src/widgets.rs +++ b/crates/ledger-gui/src/widgets.rs @@ -73,6 +73,37 @@ pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response { .response } +/// One journal event on a History card: its icon, its text as a link +/// (cut with an ellipsis), then its number and time, which keep their +/// room, so a long name in the text never widens the card. Returns the +/// link's response. +pub fn history_row(ui: &mut Ui, icon: &str, text: &str, seq: u64, ts: &str) -> Response { + let p = pal(ui.ctx()); + ui.horizontal(|ui| { + ui.label(RichText::new(icon).color(p.muted)); + let meta = format!("event {seq} · {}", crate::fmt::timestamp(ts)); + let meta_w = ui + .painter() + .layout_no_wrap(meta.clone(), theme::regular(11.0), p.faint) + .size() + .x; + let w = (ui.available_width() - meta_w - ui.spacing().item_spacing.x).max(40.0); + let link = ui + .scope(|ui| { + ui.set_max_width(w); + link_truncated(ui, RichText::new(text).font(theme::regular(12.5))) + }) + .inner; + ui.label( + RichText::new(meta) + .font(theme::regular(11.0)) + .color(p.faint), + ); + link + }) + .inner +} + /// Text that looks like plain text until you hover it, then like a link. pub fn link(ui: &mut Ui, text: RichText) -> Response { link_label(ui, egui::Label::new(text)) @@ -276,11 +307,31 @@ pub fn recently_copied(ui: &Ui, id: egui::Id) -> bool { /// A value that copies itself when clicked: a pointer and a copy icon on /// hover, a check for a moment after. pub fn copyable(ui: &mut Ui, text: RichText, value: &str) -> Response { + copyable_label(ui, text, value, false) +} + +/// [`copyable`] on one line, cut with an ellipsis where the space ends +/// (the copy icon's slot kept), the full text on hover: for text of any +/// length with no spaces to wrap at, a long name or a web address. +pub fn copyable_truncated(ui: &mut Ui, text: RichText, value: &str) -> Response { + copyable_label(ui, text, value, true) +} + +fn copyable_label(ui: &mut Ui, text: RichText, value: &str, truncate: bool) -> Response { let id = ui.id().with(("copy", value)); let p = pal(ui.ctx()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - let r = ui.add(egui::Label::new(text).sense(Sense::click())); + let r = if truncate { + let w = (ui.available_width() - 18.0).max(24.0); + ui.scope(|ui| { + ui.set_max_width(w); + ui.add(egui::Label::new(text).truncate().sense(Sense::click())) + }) + .inner + } else { + ui.add(egui::Label::new(text).sense(Sense::click())) + }; let recent = recently_copied(ui, id); let (slot, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); if r.hovered() || recent { diff --git a/crates/ledger-gui/tests/breadcrumbs.rs b/crates/ledger-gui/tests/breadcrumbs.rs index 2c86b62..8d7ee51 100644 --- a/crates/ledger-gui/tests/breadcrumbs.rs +++ b/crates/ledger-gui/tests/breadcrumbs.rs @@ -49,6 +49,7 @@ fn crumbs_link_every_level_but_the_last() { let entry = b.entries.first().unwrap().id; let mut routes = vec![ Route::Party(party), + Route::Project(b.ledger.list_classes()[0].id), Route::Reconciliation(rec.id), Route::Entry(entry), Route::Reports(ReportKind::ProfitAndLoss), @@ -112,7 +113,11 @@ fn clicking_a_crumb_goes_there() { h.get_all_by_label(&account).next().unwrap().click(); h.run_steps(3); assert_eq!(h.state().route(), &Route::Account(rec.account_id)); - h.get_by_label("Accounts").click(); + // The crumb, not the sidebar's link of the same name (left of it). + h.get_all_by_label("Accounts") + .max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())) + .unwrap() + .click(); h.run_steps(3); assert_eq!(h.state().route(), &Route::Accounts); } diff --git a/crates/ledger-gui/tests/edges.rs b/crates/ledger-gui/tests/edges.rs index ebcaf7b..ca8dd6b 100644 --- a/crates/ledger-gui/tests/edges.rs +++ b/crates/ledger-gui/tests/edges.rs @@ -13,6 +13,7 @@ use chrono::{Datelike, NaiveDate}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use ledger_core::parties::{EntryFlow, entry_flow, money_accounts}; use ledger_core::{AccountType, Currency, Money, PartyShare, Role, Share, Side}; use ledger_gui::books::{Books, PartyPeriod, share_of_what_for}; @@ -276,3 +277,122 @@ fn the_page_state_a_test_sets_is_the_apps() { PartyPeriod::Year(2025) ); } + +/// The sidebar link named `name` (the leftmost node of that name). +fn sidebar_link(h: &Harness<'_, App>, name: &str) -> egui::Rect { + h.get_all_by_role_and_label(egui::accesskit::Role::Button, name) + .map(|n| n.rect()) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap_or_else(|| panic!("no {name} link")) +} + +/// A short window (owner, 2026-10-21: the links went under the status +/// box): scrolled to its end, the last link stops above the box. +#[test] +fn sidebar_links_never_draw_under_the_status_box() { + let f = fixture("sidebar-short"); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 500.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview)); + h.run_steps(4); + h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal") + .min_by(|a, b| a.rect().left().total_cmp(&b.rect().left())) + .unwrap() + .scroll_to_me(); + h.run_steps(4); + let journal = sidebar_link(&h, "Journal"); + let status = h.get_by_label_contains("files verified").rect(); + assert!( + journal.bottom() <= status.top(), + "Journal ends at {} under the status box at {}", + journal.bottom(), + status.top() + ); +} + +/// The rail's equivalent: its last link stops above its footer. +#[test] +fn rail_links_never_draw_under_its_footer() { + let f = fixture("rail-short"); + let mut h = Harness::builder() + .with_size(egui::vec2(900.0, 420.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview)); + h.run_steps(4); + h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal") + .min_by(|a, b| a.rect().left().total_cmp(&b.rect().left())) + .unwrap() + .scroll_to_me(); + h.run_steps(4); + let journal = sidebar_link(&h, "Journal"); + assert!(journal.right() <= 60.0, "the rail is drawn: {journal:?}"); + let footer = h + .ctx + .data(|d| d.get_temp::(egui::Id::new("rail_footer_h"))) + .expect("the rail's footer was measured"); + // The rail's frame keeps 16 points under its footer. + let footer_top = 420.0 - 16.0 - footer; + assert!( + journal.bottom() <= footer_top, + "Journal ends at {} under the footer at {footer_top}", + journal.bottom() + ); +} + +/// A window too short for the links and the status box together keeps +/// one link whole above the box (the box gives way, off the bottom), +/// sidebar and rail alike. +#[test] +fn a_very_short_window_keeps_one_link() { + for (w, h_px, tag) in [(1440.0, 220.0, "sidebar-tiny"), (900.0, 200.0, "rail-tiny")] { + let f = fixture(tag); + let mut h = Harness::builder() + .with_size(egui::vec2(w, h_px)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview)); + h.run_steps(4); + let first = sidebar_link(&h, "Overview"); + assert!( + first.height() >= 31.0 && first.bottom() <= h_px, + "{tag}: Overview at {first:?}" + ); + if let Some(status) = h.query_by_label_contains("files verified") { + assert!( + first.bottom() <= status.rect().top(), + "{tag}: Overview ends at {} under the status box at {}", + first.bottom(), + status.rect().top() + ); + } + let key = if w < 1000.0 { + "rail_footer_h" + } else { + "sidebar_footer_h" + }; + let footer = h + .ctx + .data(|d| d.get_temp::(egui::Id::new(key))) + .expect("the footer was measured"); + assert!(footer > 0.0, "{tag}"); + } +} + +/// At the usual height every section's link shows and nothing scrolls. +#[test] +fn a_normal_window_shows_every_link_unscrolled() { + let f = fixture("sidebar-normal"); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 920.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview)); + h.run_steps(4); + let first = sidebar_link(&h, "Overview"); + for s in ledger_gui::nav::Section::ALL { + sidebar_link(&h, s.title()); + } + let status = h.get_by_label_contains("files verified").rect(); + assert!(sidebar_link(&h, "Journal").bottom() <= status.top()); + h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal") + .min_by(|a, b| a.rect().left().total_cmp(&b.rect().left())) + .unwrap() + .scroll_to_me(); + h.run_steps(4); + assert_eq!(sidebar_link(&h, "Overview"), first, "the links scrolled"); +} diff --git a/crates/ledger-gui/tests/kept.rs b/crates/ledger-gui/tests/kept.rs index 2b2507b..a2d57b6 100644 --- a/crates/ledger-gui/tests/kept.rs +++ b/crates/ledger-gui/tests/kept.rs @@ -116,6 +116,7 @@ fn the_company_page_shows_how_the_books_are_kept() { evidence_attachment: None, supersedes: None, citation: None, + projects: vec![], }) .unwrap(); s.record_decision(&NewDecision { @@ -126,6 +127,7 @@ fn the_company_page_shows_how_the_books_are_kept() { evidence_attachment: None, supersedes: Some(first), citation: None, + projects: vec![], }) .unwrap(); s.raise_open_item(&item("Mail the IRS letter", Some(d(2026, 10, 16)))) diff --git a/crates/ledger-gui/tests/old_books.rs b/crates/ledger-gui/tests/old_books.rs index 589688c..122a5ce 100644 --- a/crates/ledger-gui/tests/old_books.rs +++ b/crates/ledger-gui/tests/old_books.rs @@ -198,11 +198,13 @@ fn routes(b: &Books) -> Vec { Route::Accounts, Route::Assets, Route::Parties, + Route::Projects, Route::Documents, Route::Company, Route::Journal, ]; out.extend(ReportKind::ALL.map(Route::Reports)); + out.extend(b.ledger.list_classes().iter().map(|c| Route::Project(c.id))); out.extend((1..=b.ledger.next_entry_id()).map(Route::Entry)); out.extend(b.ledger.chart().all().map(|a| Route::Account(a.id))); out.extend(b.ledger.registered_assets().map(|a| Route::Asset(a.id))); @@ -535,3 +537,97 @@ fn an_older_books_dirs_config_json_sets_its_receipt_rule() { .unwrap(); assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO); } + +/// rules.md B-37: the classes an earlier version wrote read as projects +/// with the default facts (active, kind not recorded), listed by name, +/// their figures ledger-core's. +#[test] +fn old_books_classes_read_as_projects() { + let mut seen = 0; + // Copies of their own: the shards above copy the same fixtures under + // the fixtures' names, side by side. + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../ledger-store/tests/fixtures/old-books"); + let mut fixtures: Vec = std::fs::read_dir(&root) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.is_dir()) + .collect(); + fixtures.sort(); + let copies = fixtures.iter().map(|f| { + let name = f.file_name().unwrap().to_string_lossy().to_string(); + let t = temp(&format!("projects-{name}")); + copy_books(f, &t.0.join("books-data")); + write_settings(&t.0); + (name, t) + }); + for (name, dir) in copies { + let b = data::load_once(&ledger_gui::Source { + data_dir: dir.0.join("books-data"), + today: Some(d(2026, 10, 12)), + }) + .unwrap(); + let listed = ledger_gui::pages::projects::listed(&b); + assert_eq!(listed.len(), b.ledger.list_classes().len(), "{name}"); + for p in &listed { + seen += 1; + assert_eq!(p.facts, ledger_core::ProjectFacts::default(), "{name}"); + assert_eq!( + p.to_date, + ledger_core::project_to_date(&b.ledger, p.id, b.today).ok(), + "{name}" + ); + } + let names: Vec = listed.iter().map(|p| p.name.to_lowercase()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted, "{name}: alphabetical"); + } + // No fixture holds a class: books with the lines an earlier version + // wrote for one (class.added, a line tagged with it), and nothing more. + let t = temp("old-class"); + let data = t.0.join("books-data"); + let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false) + .unwrap(); + let rev = store + .add_account( + "4000", + "Sales", + ledger_core::AccountType::Revenue, + None, + false, + ) + .unwrap(); + let class = store.add_class("Harbor app").unwrap(); + let e = store.create_entry(d(2026, 3, 1), "sale").unwrap(); + let usd = + |c| ledger_core::Money::new(rust_decimal::Decimal::new(c, 2), ledger_core::Currency::USD); + store + .add_line(e, cash, ledger_core::Side::Debit, usd(5000)) + .unwrap(); + store + .add_line(e, rev, ledger_core::Side::Credit, usd(5000)) + .unwrap(); + store.set_line_class(e, 1, Some(class)).unwrap(); + store.post_entry(e).unwrap(); + let journal = std::fs::read_to_string(data.join(ledger_store::JOURNAL_FILE)).unwrap(); + assert!(!journal.contains("project.set"), "nothing new was written"); + drop(store); + write_settings(&t.0); + let b = data::load_once(&ledger_gui::Source { + data_dir: data, + today: Some(d(2026, 10, 12)), + }) + .unwrap(); + let listed = ledger_gui::pages::projects::listed(&b); + assert_eq!(listed.len(), 1); + seen += 1; + assert_eq!(listed[0].name, "Harbor app"); + assert_eq!(listed[0].facts, ledger_core::ProjectFacts::default()); + let t = listed[0].to_date.as_ref().unwrap(); + assert_eq!(t.revenue, rust_decimal::Decimal::new(5000, 2)); + assert_eq!(t.unrecovered_cost, None); + assert!(seen > 0); +} diff --git a/crates/ledger-gui/tests/projects.rs b/crates/ledger-gui/tests/projects.rs new file mode 100644 index 0000000..725d45b --- /dev/null +++ b/crates/ledger-gui/tests/projects.rs @@ -0,0 +1,590 @@ +//! Projects in the viewer (rules.md B-37): the list and a project's page +//! show ledger-core's figures; rows, links and bars open what they name; +//! the journal, books.show and the party page know projects. + +use chrono::NaiveDate; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::conventions::NewDecision; +use ledger_core::{AccountType, ClassId, Currency, Money, ProjectFacts, ProjectKind, Side}; +use ledger_gui::books::Books; +use ledger_gui::nav::{Route, Section}; +use ledger_gui::pages::journal::describe; +use ledger_gui::pages::projects::listed; +use ledger_gui::{App, Source, data}; +use ledger_store::ops::ViewerTarget; +use ledger_store::{Store, Subject}; +use rust_decimal::Decimal; +use std::path::PathBuf; + +struct TempDir(PathBuf); + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +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 usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +fn today() -> NaiveDate { + d(2026, 10, 15) +} + +/// Books with three projects: "Zeta site" (client work for Riley Chen: +/// $5,000.00 earned, $1,800.00 of contractors), "alpha app" (on spec: +/// $300.00 of costs, and test devices bought for it, an asset), and +/// "Beta" (a bare class); rent is no project's. A pitch is linked to +/// alpha, and a decision names it. +struct Fixture { + _dir: TempDir, + books: Books, + zeta: ClassId, + alpha: ClassId, + beta: ClassId, + riley: u64, + alpha_cost: u64, + pitch: u64, +} + +fn fixture(tag: &str) -> Fixture { + let dir = TempDir( + std::env::temp_dir().join(format!("ledger-gui-projects-{tag}-{}", std::process::id())), + ); + let _ = std::fs::remove_dir_all(&dir.0); + let data_dir = dir.0.join("books-data"); + let mut s = Store::create(&data_dir, Currency::USD).unwrap(); + let mut add = |code: &str, name: &str, ty| s.add_account(code, name, ty, None, false).unwrap(); + let cash = add("1000", "Cash", AccountType::Asset); + let devices = add("1200", "Equipment", AccountType::Asset); + let revenue = add("4000", "Design Services", AccountType::Revenue); + let contractors = add("6000", "Contractors", AccountType::Expense); + let rent = add("6200", "Rent", AccountType::Expense); + let riley = s.add_party("Riley Chen", &[]).unwrap(); + let zeta = s + .add_project( + "Zeta site", + &ProjectFacts { + kind: Some(ProjectKind::Client), + party: Some(riley), + description: Some("A site for Riley".into()), + ..Default::default() + }, + ) + .unwrap(); + let alpha = s + .add_project( + "alpha app", + &ProjectFacts { + kind: Some(ProjectKind::OnSpec), + links: vec!["https://alpha.example".into()], + ..Default::default() + }, + ) + .unwrap(); + let beta = s.add_class("Beta").unwrap(); + let post = |s: &mut Store, date, what: &str, dr, cr, c, tag: Option<(ClassId, bool)>| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, dr, Side::Debit, usd(c)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + if let Some((class, debit)) = tag { + s.set_line_class(e, if debit { 0 } else { 1 }, Some(class)) + .unwrap(); + } + s.post_entry(e).unwrap(); + e + }; + post( + &mut s, + d(2026, 3, 2), + "Zeta milestone", + cash, + revenue, + 500_000, + Some((zeta, false)), + ); + post( + &mut s, + d(2026, 3, 9), + "Zeta contractor", + contractors, + cash, + 180_000, + Some((zeta, true)), + ); + let alpha_cost = post( + &mut s, + d(2026, 4, 1), + "Alpha prototype", + contractors, + cash, + 30_000, + Some((alpha, true)), + ); + post( + &mut s, + d(2026, 4, 2), + "Alpha test devices", + devices, + cash, + 90_000, + Some((alpha, true)), + ); + post(&mut s, d(2026, 4, 30), "Rent", rent, cash, 150_000, None); + let (pitch, _, _) = s + .file_document( + "other", + "alpha pitch.pdf", + "application/pdf", + b"%PDF-1.4 pitch", + None, + ) + .unwrap(); + s.link_attachment(pitch, Subject::Project(u64::from(alpha))) + .unwrap(); + s.record_decision(&NewDecision { + subject: "Alpha on spec".into(), + decision_md: "Build it on spec.".into(), + decided_by: vec!["Tobin Harlow".into()], + decided_on: d(2026, 4, 1), + evidence_attachment: None, + supersedes: None, + citation: None, + projects: vec![alpha], + }) + .unwrap(); + drop(s); + let books = load(&dir); + Fixture { + _dir: dir, + books, + zeta, + alpha, + beta, + riley, + alpha_cost, + pitch, + } +} + +fn load(dir: &TempDir) -> Books { + data::load_once(&Source { + data_dir: dir.0.join("books-data"), + today: Some(today()), + }) + .unwrap() +} + +fn harness(f: &Fixture, route: Route) -> Harness<'static, App> { + let books = load(&f._dir); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 2000.0)) + .build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, route.clone())); + h.run_steps(3); + h +} + +#[test] +fn projects_page_lists_every_project_alphabetically() { + let f = fixture("list"); + let rows = listed(&f.books); + let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, ["alpha app", "Beta", "Zeta site"]); + assert_eq!(rows[1].facts, ProjectFacts::default()); + assert_eq!(Section::Projects.home(), Route::Projects); + assert_eq!(Route::Project(f.beta).section(), Section::Projects); + let h = harness(&f, Route::Projects); + h.get_by_label("3 projects, all active. Net to date across them: $2,900.00."); + for n in names { + h.get_by_role_and_label(Role::Button, n); + } + assert_eq!(rows[1].facts.kind, None, "Beta: kind not recorded"); +} + +#[test] +fn project_figures_are_ledger_cores() { + let f = fixture("figures"); + let t = ledger_core::project_to_date(&f.books.ledger, f.zeta, today()).unwrap(); + assert_eq!((t.revenue, t.expenses), (cents(500_000), cents(180_000))); + let row = listed(&f.books) + .into_iter() + .find(|r| r.id == f.zeta) + .unwrap(); + assert_eq!(row.to_date, Some(t.clone())); + let h = harness(&f, Route::Project(f.zeta)); + h.get_by_label("$5,000.00"); + h.get_by_label("$1,800.00"); + h.get_by_label("$3,200.00"); + // By account to date: the same statement lines. + let fig = ledger_core::project_figures( + &f.books.ledger, + f.zeta, + ledger_core::calendar::FIRST_DAY, + today(), + ) + .unwrap(); + for l in fig.period.revenue.iter().chain(&fig.period.expenses) { + h.get_by_role_and_label(Role::Button, &format!("{} {}", l.code, l.name)); + } + h.get_by_label("Margin to date"); + h.get_by_label("64.0%"); +} + +#[test] +fn unrecovered_cost_shows_only_on_spec() { + let f = fixture("unrecovered"); + let h = harness(&f, Route::Project(f.alpha)); + h.get_by_label("Unrecovered cost"); + h.get_by_label("if repaid: not a receivable"); + // The devices (an asset) count nothing: spending and the unrecovered + // cost are both the prototype's $300.00. + assert_eq!(h.get_all_by_label("$300.00").count(), 2); + let h = harness(&f, Route::Project(f.zeta)); + assert!(h.query_by_label("Unrecovered cost").is_none()); +} + +#[test] +fn a_project_row_opens_the_project() { + let f = fixture("row"); + let mut h = harness(&f, Route::Projects); + h.get_by_role_and_label(Role::Button, "alpha app").click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Project(f.alpha)); +} + +#[test] +fn an_entry_row_opens_the_entry() { + let f = fixture("entry-row"); + let mut h = harness(&f, Route::Project(f.alpha)); + h.get_by_role_and_label(Role::Button, "Alpha prototype") + .click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Entry(f.alpha_cost)); +} + +#[test] +fn a_document_opens_the_document() { + let f = fixture("doc"); + let mut h = harness(&f, Route::Project(f.alpha)); + h.get_by_label("alpha pitch.pdf").click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Document(f.pitch)); +} + +#[test] +fn the_party_link_opens_the_party() { + let f = fixture("party-link"); + let mut h = harness(&f, Route::Project(f.zeta)); + h.get_by_label("Riley Chen").click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Party(f.riley)); +} + +#[test] +fn profit_by_project_bar_opens_the_project() { + let f = fixture("profit"); + let mut h = harness(&f, Route::Profit); + h.get_by_label("Net income by project, this year"); + h.get_by_role_and_label(Role::Button, "Zeta site").click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Project(f.zeta)); +} + +#[test] +fn party_page_lists_its_projects() { + let f = fixture("party"); + assert_eq!(f.books.ledger.projects_of_party(f.riley), vec![f.zeta]); + let mut h = harness(&f, Route::Party(f.riley)); + h.get_by_role_and_label(Role::Button, "Zeta site").click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Project(f.zeta)); +} + +#[test] +fn entry_line_project_opens_the_project() { + let f = fixture("entry-line"); + let mut h = harness(&f, Route::Entry(f.alpha_cost)); + h.get_by_label(&format!("{} alpha app", egui_phosphor::regular::TAG)) + .click(); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &Route::Project(f.alpha)); +} + +#[test] +fn journal_describes_project_set() { + let f = fixture("journal"); + let b = &f.books; + let texts: Vec<(String, Option)> = b + .journal + .iter() + .filter_map(|l| describe(b, l).map(|e| (e.text, e.route))) + .collect(); + let has = |t: &str, r: Route| texts.iter().any(|(x, y)| x == t && *y == Some(r.clone())); + assert!( + has("Added project Zeta site", Route::Project(f.zeta)), + "{texts:?}" + ); + assert!( + has( + "Project Zeta site: active, client work", + Route::Project(f.zeta) + ), + "{texts:?}" + ); + assert!( + has( + "Project alpha app: active, on spec", + Route::Project(f.alpha) + ), + "{texts:?}" + ); + assert!( + has("Added project Beta", Route::Project(f.beta)), + "{texts:?}" + ); + assert!( + texts + .iter() + .any(|(t, _)| t == "Linked alpha pitch.pdf to Project alpha app"), + "{texts:?}" + ); +} + +#[test] +fn show_project_target_opens_its_page() { + let f = fixture("show"); + let mut h = harness(&f, Route::Overview); + h.state_mut() + .show(ViewerTarget::Project { class_id: f.alpha }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Project(f.alpha)); + h.state_mut().show(ViewerTarget::Project { class_id: 999 }); + h.run_steps(1); + assert_eq!(h.state_mut().route(), &Route::Project(f.alpha)); + h.state_mut().show(ViewerTarget::Page { + page: "projects".into(), + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Projects); + // Every project's page, and one that doesn't exist, render. + for c in [f.zeta, f.alpha, f.beta, 999] { + h.state_mut().goto(Route::Project(c)); + h.run_steps(2); + } +} + +/// A project named `name` with `link`, a $100.00 sale and a decision, in +/// books of its own; a harness `w` by `h` on its page. +fn edge(tag: &str, name: &str, link: &str, w: f32, h: f32) -> (TempDir, Harness<'static, App>) { + let dir = TempDir(std::env::temp_dir().join(format!( + "ledger-gui-projects-edge-{tag}-{}", + std::process::id() + ))); + let _ = std::fs::remove_dir_all(&dir.0); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let revenue = s + .add_account("4000", "Design Services", AccountType::Revenue, None, false) + .unwrap(); + let id = s + .add_project( + name, + &ProjectFacts { + kind: Some(ProjectKind::Client), + links: vec![link.to_string()], + ..Default::default() + }, + ) + .unwrap(); + let e = s.create_entry(d(2026, 3, 2), "Sale").unwrap(); + s.add_line(e, cash, Side::Debit, usd(10_000)).unwrap(); + s.add_line(e, revenue, Side::Credit, usd(10_000)).unwrap(); + s.set_line_class(e, 1, Some(id)).unwrap(); + s.post_entry(e).unwrap(); + s.record_decision(&NewDecision { + subject: "Take the job".into(), + decision_md: "Do it.".into(), + decided_by: vec!["Tobin Harlow".into()], + decided_on: d(2026, 3, 1), + evidence_attachment: None, + supersedes: None, + citation: None, + projects: vec![id], + }) + .unwrap(); + drop(s); + let books = load(&dir); + let mut harness = Harness::builder() + .with_size(egui::vec2(w, h)) + .build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Project(id))); + harness.run_steps(4); + // For eyes: SHOT_DIR keeps a picture of each edge page. + if let Ok(out) = std::env::var("SHOT_DIR") { + let _ = std::fs::create_dir_all(&out); + if let Ok(img) = harness.render() { + img.save(std::path::Path::new(&out).join(format!("{tag}.png"))) + .unwrap(); + } + } + (dir, harness) +} + +/// A 200-character link with no space to wrap at stays inside the Facts +/// card: left of the Decisions card beside it, and inside the window. +#[test] +fn a_long_link_stays_in_its_card() { + let link = format!("https://git.example/{}", "a".repeat(180)); + for (w, tag) in [(1440.0, "link-1440"), (900.0, "link-900")] { + let (_dir, h) = edge(tag, "Harbor", &link, w, 2600.0); + let shown = h.get_by_label_contains("https://git.example/").rect(); + let decisions = h.get_by_label("Decisions").rect(); + assert!( + shown.right() <= w, + "{w}: the link runs to {}", + shown.right() + ); + if decisions.left() > shown.left() { + assert!( + shown.right() <= decisions.left(), + "{w}: the link runs to {}, under Decisions at {}", + shown.right(), + decisions.left() + ); + } + let net = h.get_by_label("Net to date").rect(); + assert!(net.right() <= w, "{w}: Net to date at {net:?}"); + } +} + +/// A 200-character name, with spaces or without, keeps the page inside +/// the window: the title and every tile show. +#[test] +fn a_long_name_keeps_the_page_in_the_window() { + for (name, tag) in [ + ("Harbor ".repeat(28).trim().to_string(), "name-spaces"), + ("H".repeat(200), "name-nospace"), + ] { + let (_dir, h) = edge(tag, &name, "https://harbor.example", 1440.0, 2000.0); + for tile in ["Income to date", "Net to date", "Margin to date"] { + let r = h.get_by_label(tile).rect(); + assert!(r.right() <= 1440.0, "{tag}: {tile} at {r:?}"); + } + let decisions = h.get_by_label("Decisions").rect(); + assert!( + decisions.right() <= 1440.0, + "{tag}: Decisions at {decisions:?}" + ); + let named: Vec<_> = h + .query_all_by_label_contains("HHHHHHHHHH") + .chain(h.query_all_by_label_contains("Harbor Harbor")) + .collect(); + assert!(!named.is_empty(), "{tag}: the name shows"); + for title in named { + assert!( + title.rect().right() <= 1440.0, + "{tag}: {title:?} at {:?}", + title.rect() + ); + } + } +} + +/// The by-month bars are the project's own: a click opens no report (the +/// reports are the whole company's), and the margin keeps one place. +#[test] +fn a_month_bar_opens_nothing_and_margin_keeps_one_place() { + let (_dir, mut h) = edge("month", "Harbor", "https://harbor.example", 1440.0, 2000.0); + let route = h.state_mut().route().clone(); + // All income, no spending: 100.0%, as 64.0% elsewhere. + h.get_by_label("100.0%"); + let title = h.get_by_label("By month").rect(); + let at = egui::pos2(title.left() + 200.0, title.bottom() + 120.0); + h.hover_at(at); + h.run_steps(2); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.run_steps(2); + assert_eq!(h.state_mut().route(), &route); +} + +/// An entry with no receipt says nothing about receipts. +#[test] +fn an_entry_without_receipts_says_nothing_of_them() { + fn walk(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let f = fixture("no-receipts"); + let h = harness(&f, Route::Project(f.zeta)); + let mut texts = Vec::new(); + for c in &h.output().shapes { + walk(&c.shape, &mut texts); + } + let subs: Vec<&String> = texts.iter().filter(|t| t.contains("Contractors")).collect(); + assert!( + !subs.is_empty(), + "the entries' sub-lines are painted: {texts:?}" + ); + assert!( + !texts.iter().any(|t| t.contains("0 receipts")), + "no \"0 receipts\": {subs:?}" + ); +} + +/// The party page has the same long-name edge: a 200-character name keeps +/// to its Facts card and the window. +#[test] +fn a_long_party_name_keeps_to_its_card() { + let dir = TempDir(std::env::temp_dir().join(format!( + "ledger-gui-projects-party-long-{}", + std::process::id() + ))); + let _ = std::fs::remove_dir_all(&dir.0); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let name = "P".repeat(200); + let party = s.add_party(&name, &[]).unwrap(); + drop(s); + 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::Party(party))); + h.run_steps(4); + let linked = h.get_by_label("Linked records").rect(); + let named: Vec<_> = h.query_all_by_label_contains("PPPPPPPPPP").collect(); + assert!(!named.is_empty()); + for n in named { + let r = n.rect(); + assert!(r.right() <= 1440.0, "{n:?} at {r:?}"); + // The Facts card's Name row, beside Linked records. + let key = h.get_by_label("Name").rect(); + if (r.center().y - key.center().y).abs() < 12.0 && r.left() < linked.left() { + assert!( + r.right() <= linked.left(), + "{n:?} at {r:?} under Linked records" + ); + } + } +} diff --git a/crates/ledger-gui/tests/read_only.rs b/crates/ledger-gui/tests/read_only.rs index 6f09ce8..4af8710 100644 --- a/crates/ledger-gui/tests/read_only.rs +++ b/crates/ledger-gui/tests/read_only.rs @@ -136,6 +136,9 @@ fn the_viewer_never_writes_into_the_books() { .map(|a| a.id) .unwrap(); let party = books.ledger.parties().next().map(|p| p.id).unwrap(); + // Every project's page too (visiting_project_pages_changes_nothing). + let projects: Vec = books.ledger.list_classes().iter().map(|c| c.id).collect(); + assert!(!projects.is_empty(), "the demo has projects"); drop(books); // Follow live for a moment. @@ -178,12 +181,16 @@ fn the_viewer_never_writes_into_the_books() { Route::Asset(asset), Route::Parties, Route::Party(party), + Route::Projects, Route::Document(doc), Route::JournalEvent(event), Route::Documents, Route::Company, Route::Journal, ]; + let routes = routes + .into_iter() + .chain(projects.iter().map(|&c| Route::Project(c))); for route in routes { let books = data::load_once(&source).unwrap(); let mut harness = Harness::builder() diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index 15574ac..a7f2557 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -89,6 +89,22 @@ fn screenshots() { .rev() .find(|a| loaded.links_of(a.id).len() > 1) .map_or(last_doc, |a| a.id); + // A project by name (SHOT_PROJECT: an id): the on-spec one, and the + // client work, else the first. + let project_named = |name: &str| { + loaded + .ledger + .list_classes() + .into_iter() + .find(|c| c.name == name) + .or_else(|| loaded.ledger.list_classes().into_iter().next()) + .map_or(1, |c| c.id) + }; + let project = std::env::var("SHOT_PROJECT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or_else(|| project_named("Tidepool app")); + let project_client = project_named("Client work"); drop(loaded); let routes: Vec<(&str, Route)> = vec![ ("overview", Route::Overview), @@ -122,6 +138,9 @@ fn screenshots() { ("asset", Route::Asset(first_asset)), ("parties", Route::Parties), ("party", Route::Party(party)), + ("projects", Route::Projects), + ("project", Route::Project(project)), + ("project-client", Route::Project(project_client)), ("document", Route::Document(last_doc)), ("document-linked", Route::Document(linked_doc)), ("reconciliation", Route::Reconciliation(reconciliation)), diff --git a/crates/ledger-mcp/handbook/projects.md b/crates/ledger-mcp/handbook/projects.md new file mode 100644 index 0000000..b8b030a --- /dev/null +++ b/crates/ledger-mcp/handbook/projects.md @@ -0,0 +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. +- 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 0594243..449bd53 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -71,6 +71,7 @@ pub const SECTIONS: &[(&str, &str)] = &[ "revenue-and-payouts", include_str!("../handbook/revenue-and-payouts.md"), ), + ("projects", include_str!("../handbook/projects.md")), ( "parties-and-tins", include_str!("../handbook/parties-and-tins.md"), diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 0400ca5..02ab1fe 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -62,7 +62,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 115] = [ +pub const TABLE: [(&str, Needs); 119] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -181,6 +181,10 @@ pub const TABLE: [(&str, Needs); 115] = [ ("books.add_class", W), ("books.list_classes", R), ("books.profit_and_loss_by_class", R), + ("books.add_project", W), + ("books.update_project", W), + ("books.projects", R), + ("books.project", R), ("books.export_cpa_package", A), ("books.access_log", A), ("books.backup", R), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 6d43876..82faaa9 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -785,6 +785,7 @@ impl BooksServer { + Self::files_router() + Self::backup_router() + Self::instruments_router() + + Self::projects_router() } /// The settings in force for this request (reloaded at its start, in @@ -1903,7 +1904,7 @@ fn resource_list() -> Vec { } /// The subject kinds, for messages. -const SUBJECT_KINDS: &str = "entry, account, asset, party, invoice, bill, period, payroll_run, schedule, reconciliation, company"; +const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company"; /// Resolve a tool's subject: its kind, and its id resolved the way the /// other tools resolve references (an account by code or id, an asset by @@ -1932,6 +1933,11 @@ fn resolve_subject(store: &Store, s: &SubjectParam) -> Result Subject::Account(resolve_account(store, &t)?), "asset" => Subject::Asset(resolve_asset_ref(store, &t)?), "party" => Subject::Party(resolve_party(store, &t)?), + // A JSON number is an id, never a name. + "project" => match &s.id { + Some(RefId::Number(n)) => Subject::Project(*n), + _ => Subject::Project(u64::from(resolve_class(store, &t)?)), + }, k => { let id = t .parse::() @@ -1962,6 +1968,10 @@ fn subject_out(store: &Store, sub: ledger_store::Subject) -> SubjectOut { None => format!("asset {i} {}", a.description), }), S::Party(i) => l.get_party(i).map(|p| format!("party {i} {}", p.label())), + S::Project(i) => u32::try_from(i) + .ok() + .and_then(|c| l.get_class(c)) + .map(|c| format!("project {i} {}", c.name)), S::Period(i) => l .list_periods() .iter() @@ -2031,7 +2041,8 @@ fn receipt_out(store: &Store, a: ledger_store::Attachment) -> Result, @@ -2040,6 +2051,12 @@ fn check_link_to( let mut out: Vec = Vec::new(); for s in link_to { let sub = resolve_subject(store, s)?; + // A link to a subject kind an older version doesn't know needs its + // journal feature, asked here, before the file is filed: a refusal + // leaves nothing half-filed (rules.md B-27). + if let Some(f) = Store::subject_feature(sub) { + store.ensure_feature(f).map_err(|e| e.to_string())?; + } if out.contains(&sub) { return Err(format!("link_to names {} twice", sub.describe())); } @@ -2590,21 +2607,31 @@ fn party_bills<'a>(ledger: &'a Ledger, party: &ledger_core::Party) -> Vec<&'a le .collect() } -/// Resolve a class by id or name; a number that is one class's id and -/// another's name is refused (INVARIANT 6). +/// Resolve a class (a project) by id or name, trimmed, the name ignoring +/// capitals as the duplicate check does; a number that is one class's id +/// and another's name is refused (INVARIANT 6). New names can't be digits +/// only, so only old books can hold such a pair. fn resolve_class(store: &Store, class: &str) -> Result { + let class = class.trim(); let by_id = class .parse::() .ok() .filter(|id| store.ledger().get_class(*id).is_some()); let by_name = store.ledger().find_class_by_name(class).map(|c| c.id); match (by_id, by_name) { - (Some(i), Some(n)) if i != n => Err(format!( - "class {class:?} is ambiguous: it is class {i}'s id and class {n}'s name; pass the id" - )), + (Some(i), Some(n)) if i != n => { + let name = store + .ledger() + .get_class(i) + .map(|c| c.name.clone()) + .unwrap_or_default(); + Err(format!( + "project {class:?} is ambiguous: it is project {i}'s id and project {n}'s name; pass {n} for project {n}, or {name:?} (project {i}'s name) for project {i}" + )) + } (Some(i), _) | (None, Some(i)) => Ok(i), (None, None) => Err(format!( - "unknown class {class:?}: use an id or name from books.list_classes" + "unknown project {class:?}: use an id or name from books.projects" )), } } @@ -2923,6 +2950,8 @@ mod files; mod instruments; #[path = "tools/kept.rs"] mod kept; +#[path = "tools/projects.rs"] +mod projects; pub use kept::SettingKeyOut; #[tool_router(router = core_router)] @@ -3106,7 +3135,7 @@ impl BooksServer { name = "books.profit_and_loss", title = "Profit and loss", description = described( - "Profit and loss for a period: revenue, expenses, net income. Pass class (name or id) to filter to one division.", + "Profit and loss for a period: revenue, expenses, net income. Pass class (a project: name or id; see books.projects) to filter to one project.", ledger_core::calendar::PeriodKind::Statement ), annotations(read_only_hint = true, open_world_hint = false) @@ -3297,7 +3326,7 @@ impl BooksServer { #[tool( name = "books.add_line", title = "Add a line to a draft", - description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (name or id) to tag the line for divisional tracking.", + description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (a project: name or id) to tag the line; see books.projects.", annotations( read_only_hint = false, destructive_hint = false, @@ -3356,7 +3385,8 @@ impl BooksServer { .set_line_class(p.entry_id, line_index, Some(class_id)) .map_err(|e| e.to_string())?; } - Ok(Json(AddLineOut { ok: true })) + let note = class_id.and_then(|c| projects::late_tag_note(store.ledger(), c)); + Ok(Json(AddLineOut { ok: true, note })) } #[tool( @@ -3611,7 +3641,7 @@ impl BooksServer { #[tool( name = "books.attach_receipt", title = "Attach a receipt", - description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 for a file of at most 256 KiB. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", + description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 for a file of at most 256 KiB. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", annotations( read_only_hint = false, destructive_hint = false, @@ -3666,7 +3696,7 @@ impl BooksServer { #[tool( name = "books.file_document", title = "File a document", - description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, the company: see books.link_attachment), all checked first. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 (at most 256 KiB). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", + description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 (at most 256 KiB). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", annotations( read_only_hint = false, destructive_hint = false, @@ -3828,7 +3858,7 @@ impl BooksServer { #[tool( name = "books.link_attachment", title = "Link a file to a subject", - description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links.", + description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), project (id or name: its contract, spec, pitch), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links.", annotations( read_only_hint = false, destructive_hint = false, @@ -4181,7 +4211,7 @@ impl BooksServer { #[tool( name = "books.show", title = "Show in the viewer", - description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", + description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a project (by id or name), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", annotations(read_only_hint = true, open_world_hint = false) )] async fn show(&self, params: Parameters) -> Result, String> { @@ -4196,6 +4226,7 @@ impl BooksServer { p.attachment_id.is_some(), p.asset.is_some(), p.party.is_some(), + p.project.is_some(), p.reconciliation.is_some(), p.search.is_some(), p.page.is_some(), @@ -4205,7 +4236,7 @@ impl BooksServer { .count(); if given != 1 { return Err( - "give exactly one of entry_id, account, report, attachment_id, asset, party, reconciliation, search, page" + "give exactly one of entry_id, account, report, attachment_id, asset, party, project, reconciliation, search, page" .into(), ); } @@ -4251,7 +4282,7 @@ impl BooksServer { "ap_aging", "fixed_asset_register", ]; - const PAGES: [&str; 11] = [ + const PAGES: [&str; 12] = [ "overview", "cash", "profit", @@ -4260,6 +4291,7 @@ impl BooksServer { "accounts", "assets", "parties", + "projects", "documents", "company", "journal", @@ -4285,6 +4317,13 @@ impl BooksServer { ViewerTarget::Party { party_id: id }, format!("party {id}, {}", party.label()), ) + } else if let Some(r) = &p.project { + let id = resolve_class(&store, r)?; + let c = ledger.get_class(id).expect("resolved"); + ( + ViewerTarget::Project { class_id: id }, + format!("project {id}, {}", c.name), + ) } else if let Some(id) = p.reconciliation { let r = store .reconciliations(None) @@ -5084,6 +5123,7 @@ impl BooksServer { .create_entry(parse_date(&p.date)?, &p.description) .map_err(|e| e.to_string())?; let mut described = Vec::new(); + let mut tagged: Vec = Vec::new(); for (index, (account, side, amount, class)) in plan.into_iter().enumerate() { store .add_line(entry_id, account, side, Money::new(amount, currency)) @@ -5092,6 +5132,9 @@ impl BooksServer { store .set_line_class(entry_id, index, Some(class)) .map_err(|e| e.to_string())?; + if !tagged.contains(&class) { + tagged.push(class); + } } let code = store .ledger() @@ -5109,6 +5152,10 @@ impl BooksServer { adjustments: money(totals.adjustments), deposit: money(totals.deposit), lines: described, + notes: tagged + .into_iter() + .filter_map(|c| projects::late_tag_note(store.ledger(), c)) + .collect(), next: "Attach the payout statement (books.attach_receipt), then preview and post with books.post_entry.".to_string(), })) } @@ -8215,7 +8262,7 @@ impl BooksServer { name = "books.party", title = "Get a party", description = described( - "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", + "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, its projects, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", ledger_core::calendar::PeriodKind::CalendarYear ), annotations(read_only_hint = true, open_world_hint = false) @@ -8346,6 +8393,11 @@ impl BooksServer { }) .collect::>()?, payroll, + projects: ledger + .projects_of_party(id) + .into_iter() + .map(|c| projects::project_ref(ledger, c)) + .collect(), })) } @@ -8876,7 +8928,7 @@ impl BooksServer { #[tool( name = "books.add_class", title = "Add a class", - description = "Add a tracking class for divisional P&L (e.g. an app, website, or channel). Tag journal lines with it via books.add_line's class param, then report per division.", + description = "Add a class: a project with only its name (books.add_project records its facts too; books.update_project adds them later). Names are unique, ignoring capitals. Tag journal lines with it via books.add_line's class param.", annotations( read_only_hint = false, destructive_hint = false, @@ -8896,7 +8948,7 @@ impl BooksServer { #[tool( name = "books.list_classes", title = "List classes", - description = "List tracking classes in id order.", + description = "List classes (projects) in id order; books.projects gives their facts and figures.", annotations(read_only_hint = true, open_world_hint = false) )] async fn list_classes(&self) -> Result, String> { @@ -8916,7 +8968,7 @@ impl BooksServer { name = "books.profit_and_loss_by_class", title = "Profit and loss by class", description = described( - "P&L broken down by tracking class for a period, plus an unclassified row for untagged lines.", + "P&L broken down by class (a project: see books.projects) for a period, plus an unclassified row for untagged lines.", ledger_core::calendar::PeriodKind::Statement ), annotations(read_only_hint = true, open_world_hint = false) @@ -10639,7 +10691,7 @@ mod tests { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 115] = [ + let table: [(&str, Hints); 119] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -10750,6 +10802,10 @@ mod tests { ("books.list_payroll_runs", R), ("books.add_class", W), ("books.list_classes", R), + ("books.add_project", W), + ("books.update_project", WI), + ("books.projects", R), + ("books.project", R), ("books.profit_and_loss_by_class", R), ("books.export_cpa_package", W), ("books.access_log", R), @@ -11387,6 +11443,11 @@ mod tests { PeriodKind::Statement, &[("start", START_DOC), ("end", END_DOC)][..], ), + ( + "books.project", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), ( "books.cash_flow", PeriodKind::Statement, diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index 11884bf..5432447 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -185,6 +185,20 @@ pub struct OpenItemBriefOut { pub due: Option, } +/// A project, in brief. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ProjectBriefOut { + /// Its id (a class id). + pub id: u32, + /// Its name. + pub name: String, + /// active or paused. + pub status: String, + /// Its kind, if recorded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + /// A handbook section. #[derive(Debug, Serialize, JsonSchema)] pub struct SectionOut { @@ -241,9 +255,12 @@ pub struct StartOut { pub conventions: Vec, /// What is open, oldest first. pub open_items: Vec, + /// The projects under way (active and paused), by name, at most 20 + /// (books.projects lists every one with its figures). + pub projects: Vec, /// Lists cut short to fit: what was left out, e.g. "conventions: 4 - /// more" (the full lists are in books.conventions and - /// books.open_items). + /// more" (the full lists are in books.conventions, + /// books.open_items and books.projects). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub truncated: Vec, /// The handbook's sections (books.handbook {section}). @@ -459,6 +476,9 @@ pub struct RecordDecisionParams { /// The rule or document it rests on, if any. #[serde(default)] pub citation: Option, + /// The projects it is about (ids or names), if any. + #[serde(default)] + pub projects: Vec, } /// A decision. @@ -486,6 +506,9 @@ pub struct DecisionOut { /// What it rests on. #[serde(default, skip_serializing_if = "Option::is_none")] pub citation: Option, + /// The projects it is about. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub projects: Vec, /// The journal line. pub seq: u64, } @@ -496,6 +519,9 @@ pub struct DecisionsParams { /// Include decisions a later one replaced. #[serde(default)] pub include_superseded: bool, + /// Only the decisions naming this project (id or name). + #[serde(default)] + pub project: Option, } /// Result of `books.decisions`. @@ -681,7 +707,7 @@ fn convention_out(c: &ledger_core::conventions::Convention) -> ConventionOut { } } -fn decision_out(d: &ledger_core::conventions::Decision) -> DecisionOut { +pub(super) fn decision_out(ledger: &Ledger, d: &ledger_core::conventions::Decision) -> DecisionOut { DecisionOut { id: d.id, subject: d.new.subject.clone(), @@ -692,6 +718,12 @@ fn decision_out(d: &ledger_core::conventions::Decision) -> DecisionOut { supersedes: d.new.supersedes, superseded_by: d.superseded_by, citation: d.new.citation.clone(), + projects: d + .new + .projects + .iter() + .map(|&p| super::projects::project_ref(ledger, p)) + .collect(), seq: d.seq, } } @@ -789,6 +821,28 @@ impl BooksServer { due: i.new.due.map(|d| d.to_string()), }) .collect(); + // The projects under way, by name (rules.md B-37): at most 20. + const START_PROJECTS: usize = 20; + let mut projects: Vec = ledger + .list_classes() + .into_iter() + .filter_map(|c| { + let f = ledger.project_facts(c.id)?; + matches!( + f.status, + ledger_core::ProjectStatus::Active | ledger_core::ProjectStatus::Paused + ) + .then(|| ProjectBriefOut { + id: c.id, + name: c.name.clone(), + status: f.status.key().to_string(), + kind: f.kind.map(|k| k.key().to_string()), + }) + }) + .collect(); + projects.sort_by_key(|a| (a.name.to_lowercase(), a.id)); + let mut p_cut = projects.len().saturating_sub(START_PROJECTS); + projects.truncate(START_PROJECTS); let journaled = cfg.journaled; let focus = focus.unwrap_or("daily"); let step = |tool: &str, args: Option, why: &str| NextStepOut { @@ -919,6 +973,7 @@ impl BooksServer { settings_summary: Vec::new(), conventions: Vec::new(), open_items: Vec::new(), + projects: Vec::new(), truncated: Vec::new(), handbook_sections: handbook::SECTIONS .iter() @@ -942,6 +997,20 @@ impl BooksServer { out.settings_summary = std::mem::take(&mut settings_summary); out.conventions = std::mem::take(&mut conventions); out.open_items = std::mem::take(&mut open_items); + out.projects = std::mem::take(&mut projects); + let cut_note = |c: usize, i: usize, s: usize, p: usize| -> Vec { + [ + ("conventions", c, "books.conventions"), + ("open_items", i, "books.open_items"), + ("settings_summary", s, "books.settings"), + ("projects", p, "books.projects"), + ] + .into_iter() + .filter(|(_, n, _)| *n > 0) + .map(|(what, n, tool)| format!("{what}: {n} more ({tool})")) + .collect() + }; + out.truncated = cut_note(0, 0, 0, p_cut); if size(&out) > START_MAX { for c in &mut out.conventions { c.summary.clear(); @@ -955,6 +1024,9 @@ impl BooksServer { } else if out.open_items.len() > 10 { out.open_items.pop(); i_cut += 1; + } else if out.projects.len() > 5 { + out.projects.pop(); + p_cut += 1; } else if !out.settings_summary.is_empty() { out.settings_summary.pop(); s_cut += 1; @@ -964,18 +1036,13 @@ impl BooksServer { } else if !out.open_items.is_empty() { out.open_items.pop(); i_cut += 1; + } else if !out.projects.is_empty() { + out.projects.pop(); + p_cut += 1; } else { break; } - out.truncated = [ - ("conventions", c_cut, "books.conventions"), - ("open_items", i_cut, "books.open_items"), - ("settings_summary", s_cut, "books.settings"), - ] - .into_iter() - .filter(|(_, n, _)| *n > 0) - .map(|(what, n, tool)| format!("{what}: {n} more ({tool})")) - .collect(); + out.truncated = cut_note(c_cut, i_cut, s_cut, p_cut); } out } @@ -1004,7 +1071,7 @@ impl BooksServer { #[tool( name = "books.start", title = "Start here", - description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.", + description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the projects under way, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.", annotations(read_only_hint = true, open_world_hint = false) )] pub(super) async fn start( @@ -1337,7 +1404,7 @@ impl BooksServer { #[tool( name = "books.record_decision", title = "Record a decision", - description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source.", + description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about.", annotations( read_only_hint = false, destructive_hint = false, @@ -1349,6 +1416,13 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let p = params.0; + let mut store = self.lock()?; + // The projects resolved before anything is written. + let projects = p + .projects + .iter() + .map(|r| resolve_class(&store, r.trim())) + .collect::, _>>()?; let d = NewDecision { subject: p.subject, decision_md: p.decision_md, @@ -1357,8 +1431,8 @@ impl BooksServer { evidence_attachment: p.evidence_attachment, supersedes: p.supersedes, citation: p.citation, + projects, }; - let mut store = self.lock()?; let id = store.record_decision(&d).map_err(bad)?; Ok(Json(IdOut { id })) } @@ -1366,7 +1440,7 @@ impl BooksServer { #[tool( name = "books.decisions", title = "Decisions", - description = "The owners' decisions about the books, oldest first, each with who decided, when, what it rests on, and what replaced it.", + description = "The owners' decisions about the books, oldest first, each with who decided, when, what it rests on, the projects it is about, and what replaced it. project filters to the decisions naming one.", annotations(read_only_hint = true, open_world_hint = false) )] async fn decisions( @@ -1374,13 +1448,20 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let store = self.lock()?; + let project = params + .0 + .project + .as_deref() + .map(|r| resolve_class(&store, r.trim())) + .transpose()?; + let ledger = store.ledger(); Ok(Json(DecisionsOut { - decisions: store - .ledger() + decisions: ledger .conventions() .decisions() .filter(|d| params.0.include_superseded || d.superseded_by.is_none()) - .map(decision_out) + .filter(|d| project.is_none_or(|p| d.new.projects.contains(&p))) + .map(|d| decision_out(ledger, d)) .collect(), })) } diff --git a/crates/ledger-mcp/src/tools/projects.rs b/crates/ledger-mcp/src/tools/projects.rs new file mode 100644 index 0000000..4f5e38e --- /dev/null +++ b/crates/ledger-mcp/src/tools/projects.rs @@ -0,0 +1,390 @@ +//! Projects through the server: `books.add_project`, +//! `books.update_project`, `books.projects` and `books.project` (rules.md +//! B-37). A project is a class with its facts: `books.add_line`'s class +//! tags a line with it, and every class tool keeps working. +//! +//! ```text +//! GOAL: the agent records what each project is and links what belongs to +//! it; anyone reads what it earned and cost from the same figures +//! the viewer and bookspec use. +//! SOURCE: docs/spec/rules.md B-37 (DECISION, owner, 2026-10-21; job +//! costing; ASC 450-30-25-1 for on-spec work), B-30 (omitted +//! dates), B-27 (journal feature `projects`). +//! INVARIANTS: +//! 1. Every field is checked before anything is written; a refusal +//! journals nothing. +//! 2. Figures come only from ledger_core::project_figures, +//! project_to_date and project_entries (never re-summed here). +//! 3. books.update_project with nothing new journals nothing. +//! 4. A project is named by id or name (ignoring capitals); a number +//! that is one project's id and another's name is refused. +//! ``` + +use super::kept::DecisionOut; +use super::*; +use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus}; +use schemars::JsonSchema; +use serde::Serialize; + +/// Result of `books.project`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ProjectDetailOut { + /// Its facts. + pub project: ProjectOut, + /// The period asked. + pub period: ProjectPeriodOut, + /// Every tagged line through the period's end. + pub to_date: ToDateOut, + /// Every entry with a line tagged to it (drafts included), newest first. + pub entries: Vec, + /// The files linked to it (its contract, spec, pitch), not withdrawn. + pub documents: Vec, + /// The decisions naming it, oldest first, superseded ones included. + pub decisions: Vec, +} + +/// A status key, or why not. +fn parse_status(s: &str) -> Result { + ProjectStatus::parse(s.trim()) + .ok_or_else(|| format!("status {s:?}: expected active, paused, finished, or abandoned")) +} + +/// A kind key, or why not. +fn parse_kind(s: &str) -> Result { + ProjectKind::parse(s.trim()) + .ok_or_else(|| format!("kind {s:?}: expected internal, client, on_spec, or other")) +} + +/// A project's facts and party for a tool's output. +pub(super) fn project_out(ledger: &Ledger, id: ClassId) -> ProjectOut { + let name = ledger + .get_class(id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + let f = ledger.project_facts(id).unwrap_or_default(); + ProjectOut { + id, + name, + status: f.status.key().to_string(), + kind: f.kind.map(|k| k.key().to_string()), + description: f.description, + party: f.party.map(|p| PartyRefOut { + party_id: p, + label: ledger + .get_party(p) + .map(|x| x.label().to_string()) + .unwrap_or_default(), + }), + started_on: f.started_on.map(|d| d.to_string()), + ended_on: f.ended_on.map(|d| d.to_string()), + links: f.links, + notes: f.notes, + } +} + +/// A project named by id and name. +pub(super) fn project_ref(ledger: &Ledger, id: ClassId) -> ProjectRefOut { + ProjectRefOut { + id, + name: ledger + .get_class(id) + .map(|c| c.name.clone()) + .unwrap_or_default(), + } +} + +/// Figures to date for a tool's output. +fn to_date_out(t: &ledger_core::ProjectToDate) -> ToDateOut { + ToDateOut { + through: t.through.to_string(), + revenue: money(t.revenue), + expenses: money(t.expenses), + net: money(t.net()), + unrecovered_cost: t.unrecovered_cost.map(money), + } +} + +/// An optional date from a tool: "" clears it. +fn optional_date(what: &str, s: &str) -> Result, String> { + match optional_text(s) { + None => Ok(None), + Some(t) => parse_date(&t).map(Some).map_err(|e| format!("{what}: {e}")), + } +} + +/// The projects ordered by name, ignoring capitals (then id). +fn by_name(ledger: &Ledger) -> Vec { + let mut ids: Vec<(String, ClassId)> = ledger + .list_classes() + .into_iter() + .map(|c| (c.name.to_lowercase(), c.id)) + .collect(); + ids.sort(); + ids.into_iter().map(|(_, id)| id).collect() +} + +/// The note `books.add_line` and `books.record_payout` give for a line tagged to a finished or +/// abandoned project (rules.md B-37 item 5). +pub(super) fn late_tag_note(ledger: &Ledger, id: ClassId) -> Option { + let f = ledger.project_facts(id)?; + if !f.status.is_over() { + return None; + } + let name = &ledger.get_class(id)?.name; + let ended = f + .ended_on + .map(|d| format!(" (ended {d})")) + .unwrap_or_default(); + Some(format!( + "project {name} is {}{ended}: tag a late cost or invoice to it only if it belongs to it", + f.status.key() + )) +} + +#[tool_router(router = projects_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.add_project", + title = "Add a project", + description = "Add a project: a class (books.add_line's class tags a line with it) with its facts. status: active (default), paused, finished, abandoned. kind: internal (the company's own product), client (work for a customer), on_spec (work done hoping to be paid: its costs are expenses, a repayment is income when received), other. party: who it is for or with (id or name). started_on, ended_on (finished or abandoned only), links (web addresses), description, notes. Names are unique. A name alone journals class.added; facts add project.set. Returns the project.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn add_project( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let facts = ProjectFacts { + description: p.description.as_deref().and_then(optional_text), + status: p + .status + .as_deref() + .map(parse_status) + .transpose()? + .unwrap_or_default(), + kind: match p.kind.as_deref().and_then(optional_text) { + Some(k) => Some(parse_kind(&k)?), + None => None, + }, + party: match p.party.as_deref().and_then(optional_text) { + Some(r) => Some(resolve_party(&store, &r)?), + None => None, + }, + started_on: optional_date("started_on", p.started_on.as_deref().unwrap_or(""))?, + ended_on: optional_date("ended_on", p.ended_on.as_deref().unwrap_or(""))?, + links: p.links, + notes: p.notes.as_deref().and_then(optional_text), + }; + let id = store + .add_project(&p.name, &facts) + .map_err(|e| e.to_string())?; + Ok(Json(project_out(store.ledger(), id))) + } + + #[tool( + name = "books.update_project", + title = "Update a project", + description = "Change a project's facts (see books.add_project): give only the fields to change; \"\" clears description, kind, party, started_on, ended_on or notes, and links replaces the list ([] clears it). Every field is checked before anything is written, then the whole record is journaled as project.set; nothing new journals nothing. A rename changes the label every report prints, nothing else; a name another project has is refused. Returns the project.", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn update_project( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_class(&store, &p.project)?; + if p.name.is_none() + && p.description.is_none() + && p.status.is_none() + && p.kind.is_none() + && p.party.is_none() + && p.started_on.is_none() + && p.ended_on.is_none() + && p.links.is_none() + && p.notes.is_none() + { + return Err( + "give at least one field to change: name, description, status, kind, party, started_on, ended_on, links, notes" + .into(), + ); + } + let ledger = store.ledger(); + let mut f = ledger.project_facts(id).expect("resolved"); + let name = p + .name + .clone() + .unwrap_or_else(|| ledger.get_class(id).expect("resolved").name.clone()); + if let Some(v) = &p.description { + f.description = optional_text(v); + } + if let Some(v) = &p.status { + f.status = parse_status(v)?; + } + if let Some(v) = &p.kind { + f.kind = optional_text(v).map(|k| parse_kind(&k)).transpose()?; + } + if let Some(v) = &p.party { + f.party = optional_text(v) + .map(|r| resolve_party(&store, &r)) + .transpose()?; + } + if let Some(v) = &p.started_on { + f.started_on = optional_date("started_on", v)?; + } + if let Some(v) = &p.ended_on { + f.ended_on = optional_date("ended_on", v)?; + } + if let Some(v) = p.links { + f.links = v; + } + if let Some(v) = &p.notes { + f.notes = optional_text(v); + } + store + .set_project(id, &name, &f) + .map_err(|e| e.to_string())?; + Ok(Json(project_out(store.ledger(), id))) + } + + #[tool( + name = "books.projects", + title = "Projects", + description = "Every project (every class, with its facts), by name: status, kind, party, dates, and its figures to date (every posted line tagged to it through today in the company's time zone, closes left out): revenue, expenses, net, and for an on_spec project its unrecovered cost. status filters to one status.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn projects( + &self, + params: Parameters, + ) -> Result, String> { + let only = params.0.status.as_deref().map(parse_status).transpose()?; + let through = today(); + let store = self.lock()?; + let ledger = store.ledger(); + let mut projects = Vec::new(); + for id in by_name(ledger) { + let f = ledger.project_facts(id).expect("listed"); + if only.is_some_and(|s| s != f.status) { + continue; + } + let t = ledger_core::project_to_date(ledger, id, through).map_err(|e| e.to_string())?; + projects.push(ProjectRowOut { + project: project_out(ledger, id), + to_date: to_date_out(&t), + }); + } + Ok(Json(ProjectsOut { + through: through.to_string(), + projects, + })) + } + + #[tool( + name = "books.project", + title = "Get a project", + description = described( + "One project, by id or name: its facts, party and decisions, the documents linked to it, and every entry with a line tagged to it (date, description, project amount, receipts). For the period: revenue, expenses, net, by account and by month. To date (every tagged line through end): the same totals and, for an on_spec project, its unrecovered cost, the 'if repaid' figure, never a receivable. Only revenue and expense lines count.", + ledger_core::calendar::PeriodKind::Statement + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn project( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let store = self.lock()?; + let ledger = store.ledger(); + let id = resolve_class(&store, &p.project)?; + let fig = + ledger_core::project_figures(ledger, id, start, end).map_err(|e| e.to_string())?; + let account = |l: &ledger_core::StatementLine, t: &str| ProjectAccountOut { + code: l.code.clone(), + name: l.name.clone(), + account_type: t.to_string(), + amount: money(l.amount), + }; + let by_account = fig + .period + .revenue + .iter() + .map(|l| account(l, "revenue")) + .chain(fig.period.expenses.iter().map(|l| account(l, "expense"))) + .collect(); + let by_month = fig + .months + .iter() + .map(|m| ProjectMonthOut { + month: m.start.format("%Y-%m").to_string(), + start: m.start.to_string(), + end: m.end.to_string(), + revenue: money(m.revenue), + expenses: money(m.expenses), + net: money(m.net()), + }) + .collect(); + let mut entries = Vec::new(); + for (eid, amount) in ledger_core::project_entries(ledger, id) { + let e = ledger.get_entry(eid).expect("listed"); + let receipts = store + .attachments_for(ledger_store::Subject::Entry(eid)) + .map_err(|e| e.to_string())? + .into_iter() + .map(|a| a.id) + .collect(); + entries.push(ProjectEntryOut { + entry_id: eid, + date: e.date().to_string(), + description: e.description().to_string(), + status: match e.status() { + ledger_core::EntryStatus::Draft => "draft", + ledger_core::EntryStatus::Posted => "posted", + } + .to_string(), + project_amount: money(amount), + total: ReceiptPolicy::entry_debits(e).ok().map(money), + reversal_of: e.reversal_of(), + receipts, + }); + } + let documents = store + .attachments_for(ledger_store::Subject::Project(u64::from(id))) + .map_err(|e| e.to_string())? + .into_iter() + .map(|a| receipt_out(&store, a)) + .collect::>()?; + let decisions = ledger + .conventions() + .decisions() + .filter(|d| d.new.projects.contains(&id)) + .map(|d| super::kept::decision_out(ledger, d)) + .collect(); + Ok(Json(ProjectDetailOut { + project: project_out(ledger, id), + period: ProjectPeriodOut { + start: start.to_string(), + end: end.to_string(), + revenue: money(fig.period.total_revenue), + expenses: money(fig.period.total_expenses), + net: money(fig.period.net_income()), + by_account, + by_month, + }, + to_date: to_date_out(&fig.to_date), + entries, + documents, + decisions, + })) + } +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index bd6bc73..55a8dd3 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -334,7 +334,7 @@ pub struct PayoutLineParam { pub account: String, /// Positive decimal string. pub amount: String, - /// Optional class (name or id), e.g. the app or product line. + /// Optional class (a project: name or id; see books.projects). pub class: Option, } @@ -349,7 +349,7 @@ pub struct PayoutAdjustmentParam { pub side: String, /// Positive decimal string. pub amount: String, - /// Optional class (name or id). + /// Optional class (a project: name or id; see books.projects). pub class: Option, } @@ -391,6 +391,10 @@ pub struct RecordPayoutOut { pub deposit: String, /// The entry's lines, e.g. "Debit 1000 1265.01". pub lines: Vec, + /// A note for each finished or abandoned project a line was tagged + /// to, as books.add_line gives; left out when none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, /// What to do next. pub next: String, } @@ -707,13 +711,15 @@ pub struct ShowParams { pub asset: Option, /// A party, by id or name (as `books.party`). pub party: Option, + /// A project, by id or name (as `books.project`). + pub project: Option, /// A reconciliation (id from `books.reconciliations`): its account's /// page, with the reconciliation and its statement. pub reconciliation: Option, /// Search the transactions list (description, account, vendor, amount). pub search: Option, /// A page: overview, cash, profit, transactions, reports, accounts, - /// assets, parties, documents, company, journal. + /// assets, parties, projects, documents, company, journal. pub page: Option, /// For an account or a report over a period: its first day (see /// [`show_start_doc`]). @@ -1032,7 +1038,8 @@ pub struct ProfitAndLossParams { /// Period end ([`END_DOC`]). #[schemars(description = END_DOC)] pub end: Option, - /// Optional class name or id to filter to one division. + /// Optional class (a project: name or id; see books.projects) to + /// filter to. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, } @@ -1511,8 +1518,9 @@ pub struct AddLineParams { /// and foreign_amount. The base amount is foreign_amount * fx_rate. #[serde(default, skip_serializing_if = "Option::is_none")] pub fx_rate: Option, - /// Optional class name or id to tag this line for divisional tracking. - /// Applied after the line is added; only valid on draft entries. + /// Optional class (a project: name or id, see books.projects) to tag + /// this line with. Applied after the line is added; only valid on + /// draft entries. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, } @@ -1522,6 +1530,10 @@ pub struct AddLineParams { pub struct AddLineOut { /// True when the line was added. pub ok: bool, + /// Something to know about the line: it is tagged to a finished or + /// abandoned project. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, } /// One line of a posting preview. @@ -3303,6 +3315,256 @@ pub struct ListClassesOut { pub classes: Vec, } +// --------------------------------------------------------------------------- +// Projects (a class with its facts, rules.md B-37) +// --------------------------------------------------------------------------- +// +// A `project` reference is a class's id or its name (ignoring capitals), +// as `books.add_line`'s class takes it. + +/// Parameters for `books.add_project`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct AddProjectParams { + /// Its name: one line, unique ignoring capitals, never a full TIN. + pub name: String, + /// What the project is (its scope of work). + #[serde(default)] + pub description: Option, + /// active (default), paused, finished, or abandoned. + #[serde(default)] + pub status: Option, + /// internal (the company's own product), client (work for a + /// customer), on_spec (work done hoping to be paid), or other. + #[serde(default)] + pub kind: Option, + /// Who it is for or with: a party's id or name. + #[serde(default)] + pub party: Option, + /// When it started, YYYY-MM-DD. + #[serde(default)] + pub started_on: Option, + /// When it ended, YYYY-MM-DD (finished or abandoned only). + #[serde(default)] + pub ended_on: Option, + /// Web addresses (its site, store listing, repository): at most 8. + #[serde(default)] + pub links: Vec, + /// Anything else. + #[serde(default)] + pub notes: Option, +} + +/// Parameters for `books.update_project`. Give at least one field to +/// change; "" clears description, kind, party, started_on, ended_on or +/// notes. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct UpdateProjectParams { + /// The project: id or name. + pub project: String, + /// A new name (not blank, not another project's). + #[serde(default)] + pub name: Option, + /// What the project is; "" clears it. + #[serde(default)] + pub description: Option, + /// active, paused, finished, or abandoned. + #[serde(default)] + pub status: Option, + /// internal, client, on_spec, or other; "" clears it (not recorded). + #[serde(default)] + pub kind: Option, + /// Who it is for or with (party id or name); "" clears it. + #[serde(default)] + pub party: Option, + /// When it started, YYYY-MM-DD; "" clears it. + #[serde(default)] + pub started_on: Option, + /// When it ended, YYYY-MM-DD (finished or abandoned only); "" clears it. + #[serde(default)] + pub ended_on: Option, + /// Replaces its links; [] clears them. + #[serde(default)] + pub links: Option>, + /// Notes; "" clears them. + #[serde(default)] + pub notes: Option, +} + +/// A party named by a project. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartyRefOut { + /// Party id. + pub party_id: u64, + /// Its name as shown. + pub label: String, +} + +/// A project: a class with its facts. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectOut { + /// Its id (the class id: books.add_line's class takes it). + pub id: u32, + /// Its name. + pub name: String, + /// active, paused, finished, or abandoned. + pub status: String, + /// internal, client, on_spec, or other; null: not recorded. + pub kind: Option, + /// What it is. + pub description: Option, + /// Who it is for or with. + pub party: Option, + /// When it started, YYYY-MM-DD. + pub started_on: Option, + /// When it ended, YYYY-MM-DD. + pub ended_on: Option, + /// Its web addresses (never opened by the books). + pub links: Vec, + /// Notes. + pub notes: Option, +} + +/// A project's figures to date: every posted revenue and expense line +/// tagged to it through `through`, closing entries left out, whatever its +/// dates. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ToDateOut { + /// The last day counted, YYYY-MM-DD. + pub through: String, + /// Tagged revenue, decimal string. + pub revenue: String, + /// Tagged expenses, decimal string. + pub expenses: String, + /// Revenue less expenses, decimal string. + pub net: String, + /// On spec only: expenses less revenue, never below zero (what it + /// would take to be repaid in full; a figure, never a receivable). + /// Null for any other kind. + pub unrecovered_cost: Option, +} + +/// Parameters for `books.projects`. +#[derive(Debug, Clone, Default, Deserialize, JsonSchema)] +pub struct ProjectsParams { + /// Only projects with this status: active, paused, finished, abandoned. + #[serde(default)] + pub status: Option, +} + +/// One project in `books.projects`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectRowOut { + /// Its facts. + #[serde(flatten)] + pub project: ProjectOut, + /// Its figures to date. + pub to_date: ToDateOut, +} + +/// Result of `books.projects`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectsOut { + /// The day the figures run through (today in the company's time zone). + pub through: String, + /// The projects, by name (ignoring capitals). + pub projects: Vec, +} + +/// Parameters for `books.project`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ProjectParams { + /// The project: id or name. + pub project: String, + /// Period start ([`START_DOC`]). + #[schemars(description = START_DOC)] + pub start: Option, + /// Period end ([`END_DOC`]). + #[schemars(description = END_DOC)] + pub end: Option, +} + +/// One account's tagged amount in a project's period. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectAccountOut { + /// Account code. + pub code: String, + /// Account name. + pub name: String, + /// "revenue" or "expense". + #[serde(rename = "type")] + pub account_type: String, + /// On the account's normal side, decimal string. + pub amount: String, +} + +/// One month of a project's period. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectMonthOut { + /// YYYY-MM. + pub month: String, + /// Its first day in the period, YYYY-MM-DD. + pub start: String, + /// Its last day in the period, YYYY-MM-DD. + pub end: String, + /// Tagged revenue, decimal string. + pub revenue: String, + /// Tagged expenses, decimal string. + pub expenses: String, + /// Revenue less expenses, decimal string. + pub net: String, +} + +/// A project's figures for the period asked. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectPeriodOut { + /// First day, YYYY-MM-DD. + pub start: String, + /// Last day, YYYY-MM-DD. + pub end: String, + /// Tagged revenue, decimal string. + pub revenue: String, + /// Tagged expenses, decimal string. + pub expenses: String, + /// Revenue less expenses, decimal string. + pub net: String, + /// Revenue accounts, then expense accounts, by code. + pub by_account: Vec, + /// By calendar month (the period's last 240 months at most). + pub by_month: Vec, +} + +/// An entry with a line tagged to a project. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectEntryOut { + /// Entry id. + pub entry_id: u64, + /// Entry date, YYYY-MM-DD. + pub date: String, + /// Description. + pub description: String, + /// "draft" or "posted". + pub status: String, + /// Its tagged revenue less its tagged expenses, decimal string (a + /// tagged balance-sheet line counts zero). + pub project_amount: String, + /// The entry's total debits, decimal string. + pub total: Option, + /// Id of the reversed entry, for reversals. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reversal_of: Option, + /// Its receipts (attachment ids: filed on it or linked to it). + pub receipts: Vec, +} + +/// A project named by a decision, a party or a list. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProjectRefOut { + /// Project (class) id. + pub id: u32, + /// Its name. + pub name: String, +} + /// One class's P&L for `books.profit_and_loss_by_class`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ClassPnlOut { @@ -4008,6 +4270,8 @@ pub struct PartyDetailOut { pub bills: Vec, /// Payroll lines naming it, by run. pub payroll: Vec, + /// The projects for or with it, by name. + pub projects: Vec, } // --------------------------------------------------------------------------- @@ -4027,12 +4291,12 @@ pub enum RefId { /// What an attachment is linked to. #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct SubjectParam { - /// entry, account, asset, party, invoice, bill, period, payroll_run, - /// schedule, reconciliation, or company. + /// entry, account, asset, party, project, invoice, bill, period, + /// payroll_run, schedule, reconciliation, or company. pub kind: String, /// Which one: an entry, invoice, bill, period, payroll run, schedule, /// or reconciliation id; an account's code (or id); an asset's tag (or - /// id); a party's id or name. Omitted for the company. + /// id); a party's or a project's id or name. Omitted for the company. pub id: Option, } @@ -4210,7 +4474,7 @@ pub struct ReconciliationsOut { /// Parameters for `books.handbook`. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct HandbookParams { - /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. + /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. #[serde(default)] pub section: Option, } diff --git a/crates/ledger-mcp/tests/hostile_args.rs b/crates/ledger-mcp/tests/hostile_args.rs index e92e68e..7b5c11f 100644 --- a/crates/ledger-mcp/tests/hostile_args.rs +++ b/crates/ledger-mcp/tests/hostile_args.rs @@ -1366,6 +1366,7 @@ fn reads_of_old_books() -> Vec<(&'static str, Value)> { ("books.list_receipts", json!({})), ("books.list_vendors", json!({})), ("books.list_classes", json!({})), + ("books.projects", json!({})), ("books.parties", json!({})), ("books.statements", json!({})), ("books.reconciliations", json!({})), @@ -1715,3 +1716,20 @@ fn hostile_renames_ranges_and_categories_are_answered() { ); reopens(m, &p, "P3's hostile inputs"); } + +/// books.project over the whole calendar answers, its months the last 240 +/// (each month is a statement: a hundred thousand of them would not). +#[test] +fn a_project_over_the_whole_calendar_answers_quickly() { + let p = places("project-wide"); + let mut m = Mcp::start(&p); + fixture(&mut m, &p); + m.ok("books.add_project", json!({"name": "Harbor"})); + let t = std::time::Instant::now(); + let p = m.ok( + "books.project", + json!({"project": "Harbor", "start": "0001-01-01", "end": "9999-12-31"}), + ); + assert_eq!(p["period"]["by_month"].as_array().unwrap().len(), 240); + assert!(t.elapsed() < std::time::Duration::from_secs(30)); +} diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index e95b8dd..fa66d98 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 112, "expected 112 tools, got {}", tools.len()); + assert_eq!(tools.len(), 116, "expected 116 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index 7143b0c..7545763 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -3259,6 +3259,16 @@ fn references_resolve_unless_truly_ambiguous() { let dir = test_dir("refs"); let s = Store::create(&data_dir(&dir), Currency::USD).unwrap(); drop(s); + // Classes as an earlier version could write them (a name of digits + // only can't be added now): 1 "placeholder-name", 2 named "1", 3 + // named "3". + let mut w = ledger_store::JournalWriter::open(&data_dir(&dir).join(ledger_store::JOURNAL_FILE)) + .unwrap(); + for (i, n) in ["placeholder-name", "1", "3"].iter().enumerate() { + w.append("class.added", json!({"id": i + 1, "name": n})) + .unwrap(); + } + drop(w); let mut m = Mcp::start(&dir, "1000000.00"); // An account whose code is its own id. let first = m.ok( @@ -3286,24 +3296,16 @@ fn references_resolve_unless_truly_ambiguous() { json!({"entry_id": e, "account": first.to_string(), "side": "debit", "amount": "5.00"}), ); - // A class named by its own id. - let c = m.ok("books.add_class", json!({"name": "placeholder-name"}))["id"] - .as_u64() - .unwrap(); - let c2 = m.ok("books.add_class", json!({"name": c.to_string()}))["id"] - .as_u64() - .unwrap(); + // Class 2 is named "1", class 1's id. + let c = 1u64; // "c" is class c's id and class c2's name: ambiguous. m.refuse( "books.add_line", json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00", "class": c.to_string()}), ); - // c2 named by its own id: only one class is meant. - let c3 = m.ok("books.add_class", json!({"name": (c2 + 1).to_string()}))["id"] - .as_u64() - .unwrap(); - assert_eq!(c3, c2 + 1, "classes number in order"); + // c3 named by its own id: only one class is meant. + let c3 = 3u64; m.ok( "books.add_line", json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00", diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 787ed3c..0dc6b29 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -775,6 +775,16 @@ fn happy_path(name: &str) -> Mcp { // Classes, parties, 1099. m.ok("books.add_class", json!({"name": "Rondo"})); m.ok("books.list_classes", json!({})); + m.ok( + "books.add_project", + json!({"name": "Harbor app", "kind": "on_spec", "started_on": "2026-01-05"}), + ); + m.ok( + "books.update_project", + json!({"project": "Rondo", "kind": "internal", "description": "The Rondo app"}), + ); + m.ok("books.projects", json!({})); + m.ok("books.project", json!({"project": "Rondo"})); let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"] .as_u64() .unwrap(); @@ -2194,15 +2204,12 @@ fn inv6_ambiguous_vendor_reference_refused() { fn inv6_ambiguous_class_reference_refused() { let dir = test_dir("inv6-class"); let toml = books_toml(&dir, "1000000.00", ""); + old_classes(&dir, &["Alpha", "1"]); let mut m = Mcp::start(&dir, &toml); m.accounts(BASE_CHART); - let alpha = m.ok("books.add_class", json!({"name": "Alpha"}))["id"] - .as_u64() - .unwrap(); - let named = m.ok("books.add_class", json!({"name": alpha.to_string()}))["id"] - .as_u64() - .unwrap(); - assert_ne!(alpha, named); + let (alpha, named) = (1u64, 2u64); + // No new name of digits only. + m.refuse("books.add_class", json!({"name": "3"})); let e = m.draft("2026-06-01", "classy", &[]); // add_line with an ambiguous class is refused too, but writes the // line first: see add_line_bad_class_leaves_journal_unchanged. @@ -2220,6 +2227,19 @@ fn inv6_ambiguous_class_reference_refused() { m.ok("books.profit_and_loss", json!({"class": "Alpha"})); } +/// Books an earlier version wrote holding these classes, in id order: a +/// name of digits only can't be added now (it would read as an id), so +/// only old books hold the id/name pairs invariant 6 refuses. +fn old_classes(dir: &Path, names: &[&str]) { + let books = dir.join("books-data"); + drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap()); + let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap(); + for (i, n) in names.iter().enumerate() { + w.append("class.added", json!({"id": i + 1, "name": n})) + .unwrap(); + } +} + /// Invariant 2: a refused add_line leaves the journal unchanged, here when /// the line's class doesn't resolve (unknown, or ambiguous per invariant /// 6). @@ -2227,10 +2247,9 @@ fn inv6_ambiguous_class_reference_refused() { fn add_line_bad_class_leaves_journal_unchanged() { let dir = test_dir("bug-class"); let toml = books_toml(&dir, "25.00", ""); + old_classes(&dir, &["Alpha", "1"]); let mut m = Mcp::start(&dir, &toml); m.accounts(BASE_CHART); - m.ok("books.add_class", json!({"name": "Alpha"})); - m.ok("books.add_class", json!({"name": "1"})); let e = m.draft("2026-06-01", "classy", &[]); for class in ["Nope", "99", "1"] { // Mcp::tool asserts the journal is unchanged on refusal. @@ -2251,10 +2270,9 @@ fn add_line_bad_class_leaves_journal_unchanged() { fn profit_and_loss_by_class_validates_class() { let dir = test_dir("bug-pnl-class"); let toml = books_toml(&dir, "1000000.00", ""); + old_classes(&dir, &["Alpha", "1"]); let mut m = Mcp::start(&dir, &toml); m.accounts(BASE_CHART); - m.ok("books.add_class", json!({"name": "Alpha"})); - m.ok("books.add_class", json!({"name": "1"})); m.refuse("books.profit_and_loss_by_class", json!({"class": "Nope"})); m.refuse("books.profit_and_loss_by_class", json!({"class": "1"})); } diff --git a/crates/ledger-mcp/tests/spec_projects.rs b/crates/ledger-mcp/tests/spec_projects.rs new file mode 100644 index 0000000..e9d1cdd --- /dev/null +++ b/crates/ledger-mcp/tests/spec_projects.rs @@ -0,0 +1,1119 @@ +//! Spec-blind tests for the project tools of `ledger-mcp` (rules.md +//! B-37), driven over stdio against the real binary. +//! +//! Expected behavior comes only from the tool descriptions and parameter / +//! result doc comments in `src/server.rs` and `src/types.rs` (the declared +//! interface, as `tools/list` serves it) and `docs/mcp-interface.md` +//! ("Tools — parties"). The harness is the minimal subset of +//! `tests/spec_mcp.rs` (copied from `tests/spec_assets.rs`): every success +//! validates against the tool's `outputSchema`, and every refusal leaves +//! `journal.jsonl` byte-identical (server invariant 2: a refused call +//! changes nothing). + +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(180); + +// ---------------------------------------------------------------- harness + +#[derive(Debug, Clone)] +enum Out { + Ok(Value), + ToolErr(String), + RpcErr(Value), +} + +impl Out { + fn refused(&self) -> bool { + !matches!(self, Out::Ok(_)) + } + fn err_text(&self) -> String { + match self { + Out::Ok(v) => panic!("expected a refusal, got {v}"), + Out::ToolErr(t) => t.clone(), + Out::RpcErr(e) => e.to_string(), + } + } +} + +struct Mcp { + stdin: ChildStdin, + rx: Receiver, + child: Child, + next_id: u64, + data: PathBuf, + validators: HashMap, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DIR_SEQ: AtomicU64 = AtomicU64::new(0); + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ledger-mcp-spec-projects-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The +/// receipt threshold is high so ordinary test entries need no receipt. +fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { + let path = dir.join("wanderbooks.toml"); + std::fs::write( + &path, + format!( + "[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}" + ), + ) + .unwrap(); + path +} + +impl Mcp { + fn start(dir: &Path, toml: &Path) -> Self { + let run = dir.join("run"); + std::fs::create_dir_all(&run).unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) + .arg("--data-dir") + .arg(dir.join("books-data")) + .arg("--books-toml") + .arg(toml) + .env("XDG_RUNTIME_DIR", &run) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ledger-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in stdout.lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + let mut mcp = Self { + books_dir: None, + stdin, + rx, + child, + next_id: 1, + data: dir.join("books-data"), + validators: HashMap::new(), + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "spec-projects", "version": "0.1.0"}, + }), + ); + assert!(init.get("result").is_some(), "initialize failed: {init}"); + mcp.send_line( + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(), + ); + let list = mcp.rpc("tools/list", json!({})); + for tool in list["result"]["tools"].as_array().expect("tools") { + let name = tool["name"].as_str().unwrap().to_string(); + let validator = jsonschema::validator_for(&tool["outputSchema"]) + .unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}")); + mcp.validators.insert(name, validator); + } + mcp + } + + fn journal(&self) -> Vec { + std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() + } + + /// The journal's line types, in order. + fn journal_types(&self) -> Vec { + String::from_utf8(self.journal()) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let v: Value = serde_json::from_str(l).unwrap(); + v["type"] + .as_str() + .or_else(|| v["line_type"].as_str()) + .unwrap_or_default() + .to_string() + }) + .collect() + } + + fn send_line(&mut self, line: &str) { + writeln!(self.stdin, "{line}").unwrap(); + self.stdin.flush().unwrap(); + } + + fn recv(&mut self) -> Option { + loop { + match self.rx.recv_timeout(TIMEOUT) { + Ok(line) => { + let v: Value = serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); + if v.get("id").is_none() && v.get("method").is_some() { + continue; + } + return Some(v); + } + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, + } + } + } + + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + self.send_line(&msg.to_string()); + let resp = self + .recv() + .unwrap_or_else(|| panic!("no response to {method} (id {id})")); + assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}"); + resp + } + + /// Call a tool; a refusal must leave the journal unchanged, a success + /// must match the outputSchema. + fn tool(&mut self, name: &str, args: Value) -> Out { + let before = self.journal(); + let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let out = if let Some(err) = resp.get("error") { + Out::RpcErr(err.clone()) + } else { + let result = &resp["result"]; + if result.get("isError") == Some(&json!(true)) { + Out::ToolErr( + result["content"] + .as_array() + .and_then(|c| c.iter().find_map(|b| b["text"].as_str())) + .unwrap_or_default() + .to_string(), + ) + } else { + let sc = result["structuredContent"].clone(); + let v = self + .validators + .get(name) + .unwrap_or_else(|| panic!("{name} is not in tools/list")); + let errors: Vec = v.iter_errors(&sc).map(|e| e.to_string()).collect(); + assert!( + errors.is_empty(), + "{name} violates its outputSchema: {errors:?}\n{sc}" + ); + Out::Ok(sc) + } + }; + if out.refused() { + assert!( + self.journal() == before, + "refused {name} {args} changed the journal ({out:?})" + ); + } + out + } + + fn ok(&mut self, name: &str, args: Value) -> Value { + let a = args.to_string(); + match self.tool(name, args) { + Out::Ok(v) => v, + other => panic!("{name} {a}: expected success, got {other:?}"), + } + } + + /// `tool`, expecting a refusal that says why. + fn refuse(&mut self, name: &str, args: Value) -> String { + let a = args.to_string(); + let out = self.tool(name, args); + assert!(out.refused(), "{name} {a} should be refused, got {out:?}"); + let why = out.err_text(); + assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why"); + why + } + + /// `ok`, and the journal must not change (read-only tools). + fn read_only(&mut self, name: &str, args: Value) -> Value { + let before = self.journal(); + let v = self.ok(name, args); + assert!( + self.journal() == before, + "read-only {name} changed the journal" + ); + v + } + + fn accounts(&mut self, accts: &[(&str, &str, &str)]) { + for (code, name, ty) in accts { + self.ok( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ); + } + } + + /// How many journal lines have this type. + fn count(&self, line_type: &str) -> usize { + self.journal_types() + .iter() + .filter(|t| t.as_str() == line_type) + .count() + } +} + +const CHART: &[(&str, &str, &str)] = &[ + ("1000", "Bank", "asset"), + ("1200", "Equipment", "asset"), + ("3000", "Capital", "equity"), + ("4000", "Revenue", "revenue"), + ("6000", "Contract Labor", "expense"), + ("6200", "Rent", "expense"), +]; + +/// Books with the chart; cash is 1000. +fn books(name: &str) -> Mcp { + books_in(name, false) +} + +/// [`books`]; `p2`: as P2 left them (format 2, no P3 feature), waiting +/// for their format upgrade. +fn books_in(name: &str, p2: bool) -> Mcp { + let dir = test_dir(name); + if p2 { + ledger_store::Store::create_in_format( + &dir.join("books-data"), + ledger_core::Currency::USD, + &ledger_store::JournalFormat { + format: 2, + features: Vec::new(), + }, + ) + .unwrap(); + } + let toml = books_toml(&dir, "Spec Co", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m.books_dir = Some(dir); + m +} + +fn b64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn sha(bytes: &[u8]) -> String { + ledger_store::journal::sha256_hex(bytes) +} + +impl Mcp { + /// Draft with lines (account, side, amount, class), post it; the id. + fn tagged( + &mut self, + date: &str, + what: &str, + lines: &[(&str, &str, &str, Option<&str>)], + ) -> u64 { + let id = self.ok( + "books.draft_entry", + json!({"date": date, "description": what, "no_party_reason": "test"}), + )["entry_id"] + .as_u64() + .unwrap(); + for (account, side, amount, class) in lines { + let mut a = json!({"entry_id": id, "account": account, "side": side, "amount": amount}); + if let Some(c) = class { + a["class"] = json!(c); + } + self.ok("books.add_line", a); + } + self.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + id + } + + fn doc(&mut self, bytes: &[u8], link_to: Value) -> Out { + self.tool( + "books.file_document", + json!({"category": "contract", "file_name": "spec.pdf", + "mime_type": "application/pdf", "content_base64": b64(bytes), + "sha256": sha(bytes), "link_to": link_to}), + ) + } +} + +/// Harbor app (on spec): $1,400.00 + $60.00 of costs, repaid $1,000.00; +/// Zeta site (client): $5,000.00 earned, $1,800.00 of contractors, one +/// line in 2025; rent is no project's; test devices tagged Harbor. +fn studio(name: &str) -> (Mcp, u64, u64) { + let mut m = books(name); + m.ok( + "books.add_project", + json!({"name": "Harbor app", "kind": "on_spec", "started_on": "2026-04-01"}), + ); + m.ok( + "books.add_project", + json!({"name": "Zeta site", "kind": "client"}), + ); + m.tagged( + "2025-12-10", + "Zeta deposit", + &[ + ("1000", "debit", "500.00", None), + ("4000", "credit", "500.00", Some("Zeta site")), + ], + ); + let proto = m.tagged( + "2026-04-02", + "Prototype", + &[ + ("6000", "debit", "1400.00", Some("Harbor app")), + ("1000", "credit", "1400.00", None), + ], + ); + m.tagged( + "2026-04-20", + "Hosting", + &[ + ("6000", "debit", "60.00", Some("Harbor app")), + ("1000", "credit", "60.00", None), + ], + ); + m.tagged( + "2026-04-21", + "Devices", + &[ + ("1200", "debit", "900.00", Some("Harbor app")), + ("1000", "credit", "900.00", None), + ], + ); + m.tagged( + "2026-06-15", + "Harbor repays", + &[ + ("1000", "debit", "1000.00", None), + ("4000", "credit", "1000.00", Some("Harbor app")), + ], + ); + m.tagged( + "2026-03-02", + "Zeta milestone", + &[ + ("1000", "debit", "4500.00", None), + ("4000", "credit", "4500.00", Some("Zeta site")), + ], + ); + let zc = m.tagged( + "2026-03-09", + "Zeta contractor", + &[ + ("6000", "debit", "1800.00", Some("Zeta site")), + ("1000", "credit", "1800.00", None), + ], + ); + m.tagged( + "2026-03-31", + "Rent", + &[ + ("6200", "debit", "1500.00", None), + ("1000", "credit", "1500.00", None), + ], + ); + (m, proto, zc) +} + +// ------------------------------------------------- add, update + +#[test] +fn add_project_then_project_returns_its_facts_and_figures() { + let mut m = books("add"); + let party = m.ok("books.add_party", json!({"name": "Harbor Co"}))["party_id"] + .as_u64() + .unwrap(); + let p = m.ok( + "books.add_project", + json!({"name": " Harbor app ", "description": "An app", "kind": "on_spec", + "party": "harbor co", "started_on": "2026-04-01", + "links": ["https://harbor.example"], "notes": "pitched in March"}), + ); + assert_eq!(p["name"], "Harbor app"); + assert_eq!(p["status"], "active"); + assert_eq!(p["kind"], "on_spec"); + assert_eq!(p["party"]["party_id"], party); + assert_eq!(p["party"]["label"], "Harbor Co"); + assert_eq!(p["links"], json!(["https://harbor.example"])); + assert_eq!(m.count("class.added"), 1); + assert_eq!(m.count("project.set"), 1); + let d = m.read_only( + "books.project", + json!({"project": "harbor app", "start": "2026-01-01", "end": "2026-12-31"}), + ); + assert_eq!(d["project"]["notes"], "pitched in March"); + assert_eq!(d["period"]["net"], "0.00"); + assert_eq!(d["period"]["by_month"].as_array().unwrap().len(), 12); + assert_eq!(d["to_date"]["unrecovered_cost"], "0.00"); + // The class tools see it. + let classes = m.read_only("books.list_classes", json!({})); + assert_eq!(classes["classes"][0]["name"], "Harbor app"); +} + +#[test] +fn a_name_only_project_journals_only_class_added() { + let mut m = books("name-only"); + let p = m.ok("books.add_project", json!({"name": "Dock"})); + assert_eq!(p["kind"], Value::Null); + assert_eq!(m.count("class.added"), 1); + assert_eq!(m.count("project.set"), 0); + // add_class refuses a taken name now. + let why = m.refuse("books.add_class", json!({"name": "DOCK"})); + assert!(why.contains("already exists"), "{why}"); +} + +#[test] +fn update_project_checks_every_field_before_writing() { + let mut m = books("update-checks"); + m.ok("books.add_project", json!({"name": "Harbor"})); + m.ok("books.add_project", json!({"name": "Dock"})); + for (args, needle) in [ + (json!({"project": "Harbor", "name": " "}), "blank"), + ( + json!({"project": "Harbor", "name": "dock"}), + "already exists", + ), + (json!({"project": "Harbor", "status": "done"}), "status"), + (json!({"project": "Harbor", "kind": "grant"}), "kind"), + ( + json!({"project": "Harbor", "party": "Nobody"}), + "unknown party", + ), + ( + json!({"project": "Harbor", "ended_on": "2026-09-30"}), + "ended_on", + ), + ( + json!({"project": "Harbor", "status": "finished", "started_on": "2026-10-01", "ended_on": "2026-09-30"}), + "before", + ), + ( + json!({"project": "Harbor", "notes": "SSN 123-45-6789"}), + "TIN", + ), + ( + json!({"project": "Nowhere", "status": "paused"}), + "unknown project", + ), + (json!({"project": "Harbor"}), "at least one"), + ] { + let why = m.refuse("books.update_project", args.clone()); + assert!(why.contains(needle), "{args}: {why}"); + } + assert_eq!(m.count("project.set"), 0); + let p = m.ok( + "books.update_project", + json!({"project": "Harbor", "status": "finished", "ended_on": "2026-09-30", "name": "Harbor app"}), + ); + assert_eq!(p["status"], "finished"); + assert_eq!(p["name"], "Harbor app"); + // "" clears. + let p = m.ok( + "books.update_project", + json!({"project": "Harbor app", "ended_on": "", "status": "active", "kind": "client"}), + ); + assert_eq!(p["ended_on"], Value::Null); + let p = m.ok( + "books.update_project", + json!({"project": "Harbor app", "kind": ""}), + ); + assert_eq!(p["kind"], Value::Null); +} + +#[test] +fn update_project_with_nothing_new_journals_nothing() { + let mut m = books("update-same"); + m.ok( + "books.add_project", + json!({"name": "Harbor", "kind": "client"}), + ); + let before = m.journal(); + m.ok( + "books.update_project", + json!({"project": "Harbor", "kind": "client"}), + ); + m.ok( + "books.update_project", + json!({"project": "harbor", "name": "Harbor"}), + ); + assert_eq!(m.journal(), before); +} + +// ------------------------------------------------- figures + +#[test] +fn project_figures_match_profit_and_loss_by_class() { + let (mut m, _, _) = studio("by-class"); + let period = json!({"start": "2026-01-01", "end": "2026-12-31"}); + let by_class = m.read_only("books.profit_and_loss_by_class", period.clone()); + for name in ["Harbor app", "Zeta site"] { + let mut a = period.clone(); + a["project"] = json!(name); + let p = m.read_only("books.project", a); + let row = by_class["classes"] + .as_array() + .unwrap() + .iter() + .find(|r| r["class"] == name) + .unwrap(); + assert_eq!(p["period"]["net"], row["net_income"], "{name}"); + assert_eq!(p["period"]["revenue"], row["revenue"], "{name}"); + assert_eq!(p["period"]["expenses"], row["expenses"], "{name}"); + } + let z = m.read_only( + "books.project", + json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}), + ); + let accounts: Vec<(&str, &str, &str)> = z["period"]["by_account"] + .as_array() + .unwrap() + .iter() + .map(|a| { + ( + a["code"].as_str().unwrap(), + a["type"].as_str().unwrap(), + a["amount"].as_str().unwrap(), + ) + }) + .collect(); + assert_eq!( + accounts, + [ + ("4000", "revenue", "4500.00"), + ("6000", "expense", "1800.00") + ] + ); + let march = &z["period"]["by_month"][2]; + assert_eq!(march["month"], "2026-03"); + assert_eq!(march["net"], "2700.00"); +} + +#[test] +fn project_to_date_counts_lines_before_the_period() { + let (mut m, _, _) = studio("to-date"); + let z = m.read_only( + "books.project", + json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}), + ); + assert_eq!(z["period"]["revenue"], "4500.00"); + assert_eq!(z["to_date"]["revenue"], "5000.00"); + assert_eq!(z["to_date"]["through"], "2026-12-31"); + let list = m.read_only("books.projects", json!({})); + let zeta = list["projects"] + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == "Zeta site") + .unwrap() + .clone(); + assert_eq!(zeta["to_date"]["net"], "3200.00"); +} + +#[test] +fn on_spec_project_shows_unrecovered_cost_and_others_null() { + let (mut m, _, _) = studio("unrecovered"); + let h = m.read_only( + "books.project", + json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-04-30"}), + ); + assert_eq!( + h["to_date"]["expenses"], "1460.00", + "the devices count nothing" + ); + assert_eq!(h["to_date"]["unrecovered_cost"], "1460.00"); + let h = m.read_only( + "books.project", + json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-12-31"}), + ); + assert_eq!(h["to_date"]["unrecovered_cost"], "460.00"); + let z = m.read_only( + "books.project", + json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}), + ); + assert_eq!(z["to_date"]["unrecovered_cost"], Value::Null); + // Nothing receivable: the balance sheet has no receivable for it. + let names: Vec = m.read_only("books.projects", json!({"status": "active"}))["projects"] + .as_array() + .unwrap() + .iter() + .map(|p| p["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, ["Harbor app", "Zeta site"]); + assert!( + m.read_only("books.projects", json!({"status": "paused"}))["projects"] + .as_array() + .unwrap() + .is_empty() + ); + m.refuse("books.projects", json!({"status": "done"})); +} + +#[test] +fn project_lists_entries_documents_decisions_and_party() { + let (mut m, proto, _) = studio("detail"); + m.ok("books.add_party", json!({"name": "Harbor Co"})); + m.ok( + "books.update_project", + json!({"project": "Harbor app", "party": "Harbor Co"}), + ); + let doc = match m.doc( + b"%PDF spec", + json!([{"kind": "project", "id": "Harbor app"}]), + ) { + Out::Ok(v) => v["attachment_id"].as_u64().unwrap(), + o => panic!("{o:?}"), + }; + m.ok( + "books.record_decision", + json!({"subject": "Harbor", "decision_md": "Build it on spec.", + "decided_by": ["Tobin Harlow"], "decided_on": "2026-04-01", + "projects": ["Harbor app"]}), + ); + let h = m.read_only( + "books.project", + json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-12-31"}), + ); + assert_eq!(h["project"]["party"]["label"], "Harbor Co"); + let entries = h["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 4); + assert_eq!(entries[0]["description"], "Harbor repays", "newest first"); + let p = entries.iter().find(|e| e["entry_id"] == proto).unwrap(); + assert_eq!(p["project_amount"], "-1400.00"); + assert_eq!(p["total"], "1400.00"); + let devices = entries + .iter() + .find(|e| e["description"] == "Devices") + .unwrap(); + assert_eq!(devices["project_amount"], "0.00"); + assert_eq!(h["documents"][0]["attachment_id"], doc); + assert_eq!(h["decisions"][0]["projects"][0]["name"], "Harbor app"); +} + +// ------------------------------------------------- links + +#[test] +fn link_attachment_to_a_project_by_name_and_id() { + let mut m = books("link"); + let id = m.ok("books.add_project", json!({"name": "Harbor"}))["id"] + .as_u64() + .unwrap(); + let a = match m.doc(b"%PDF a", json!([])) { + Out::Ok(v) => v["attachment_id"].as_u64().unwrap(), + o => panic!("{o:?}"), + }; + let out = m.ok( + "books.link_attachment", + json!({"attachment_id": a, "subject": {"kind": "project", "id": "harbor"}}), + ); + assert!(out.to_string().contains("project"), "{out}"); + m.refuse( + "books.link_attachment", + json!({"attachment_id": a, "subject": {"kind": "project", "id": id}}), + ); + let files = m.read_only( + "books.attachments", + json!({"subject": {"kind": "project", "id": id}}), + ); + assert_eq!(files["subject"]["label"], format!("project {id} Harbor")); + assert_eq!(files["attachments"][0]["attachment_id"], a); + m.refuse( + "books.link_attachment", + json!({"attachment_id": a, "subject": {"kind": "project", "id": 99}}), + ); + m.ok( + "books.unlink_attachment", + json!({"attachment_id": a, "subject": {"kind": "project", "id": id}, "reason": "wrong"}), + ); +} + +#[test] +fn file_document_with_a_bad_project_files_nothing() { + let mut m = books("bad-link"); + m.ok("books.add_project", json!({"name": "Harbor"})); + let out = m.doc(b"%PDF b", json!([{"kind": "project", "id": "Nowhere"}])); + assert!(out.refused()); + assert_eq!(m.count("document.filed"), 0); +} + +#[test] +fn record_decision_names_projects_and_decisions_filter_by_project() { + let mut m = books("decisions"); + m.ok("books.add_project", json!({"name": "Harbor"})); + m.ok("books.add_project", json!({"name": "Dock"})); + let base = + json!({"decision_md": "x", "decided_by": ["Tobin Harlow"], "decided_on": "2026-04-01"}); + let with = |subject: &str, projects: Value| { + let mut a = base.clone(); + a["subject"] = json!(subject); + a["projects"] = projects; + a + }; + m.ok("books.record_decision", with("One", json!(["Harbor"]))); + m.ok("books.record_decision", with("Two", json!([]))); + m.refuse("books.record_decision", with("Bad", json!(["Nowhere"]))); + m.refuse( + "books.record_decision", + with("Twice", json!(["Harbor", "harbor"])), + ); + let all = m.read_only("books.decisions", json!({})); + assert_eq!(all["decisions"].as_array().unwrap().len(), 2); + assert!( + all["decisions"][1].get("projects").is_none(), + "none: left out" + ); + let harbor = m.read_only("books.decisions", json!({"project": "Harbor"})); + assert_eq!(harbor["decisions"].as_array().unwrap().len(), 1); + assert_eq!(harbor["decisions"][0]["subject"], "One"); + assert!( + m.read_only("books.decisions", json!({"project": "Dock"}))["decisions"] + .as_array() + .unwrap() + .is_empty() + ); +} + +#[test] +fn party_lists_its_projects() { + let mut m = books("party"); + m.ok("books.add_party", json!({"name": "Riley Chen"})); + m.ok( + "books.add_project", + json!({"name": "Zeta", "party": "Riley Chen"}), + ); + m.ok( + "books.add_project", + json!({"name": "alpha", "party": "Riley Chen"}), + ); + m.ok("books.add_project", json!({"name": "Other"})); + let p = m.read_only("books.party", json!({"party": "Riley Chen"})); + let names: Vec<&str> = p["projects"] + .as_array() + .unwrap() + .iter() + .map(|x| x["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["alpha", "Zeta"]); +} + +#[test] +fn start_lists_active_and_paused_projects() { + let mut m = books("start"); + m.ok( + "books.add_project", + json!({"name": "Zeta", "kind": "client"}), + ); + m.ok( + "books.add_project", + json!({"name": "alpha", "status": "paused"}), + ); + m.ok( + "books.add_project", + json!({"name": "Done", "status": "finished", "ended_on": "2026-01-31"}), + ); + let s = m.read_only("books.start", json!({})); + let got: Vec<(String, String)> = s["projects"] + .as_array() + .unwrap() + .iter() + .map(|p| { + ( + p["name"].as_str().unwrap().to_string(), + p["status"].as_str().unwrap().to_string(), + ) + }) + .collect(); + assert_eq!( + got, + [ + ("alpha".to_string(), "paused".to_string()), + ("Zeta".into(), "active".into()) + ] + ); + assert_eq!(s["projects"][1]["kind"], "client"); +} + +#[test] +fn show_project_sends_its_target() { + let mut m = books("show"); + let id = m.ok("books.add_project", json!({"name": "Harbor"}))["id"] + .as_u64() + .unwrap(); + let s = m.read_only("books.show", json!({"project": "harbor"})); + assert!(s["shown"].as_str().unwrap().contains("Harbor"), "{s}"); + let cmd = ledger_store::ops::read(&m.data).expect("request written"); + assert_eq!( + cmd.target, + ledger_store::ops::ViewerTarget::Project { + class_id: id as u32 + } + ); + m.read_only("books.show", json!({"page": "projects"})); + m.refuse("books.show", json!({"project": "Nowhere"})); + m.refuse( + "books.show", + json!({"project": "Harbor", "page": "projects"}), + ); +} + +#[test] +fn project_writes_wait_for_the_format_upgrade() { + let mut m = books_in("upgrade", true); + // A name alone: a plain class, accepted. + m.ok("books.add_project", json!({"name": "Harbor"})); + let a = match m.doc(b"%PDF c", json!([])) { + Out::Ok(v) => v["attachment_id"].as_u64().unwrap(), + o => panic!("{o:?}"), + }; + let head = m.journal(); + for (tool, args) in [ + ( + "books.add_project", + json!({"name": "Dock", "kind": "client"}), + ), + ( + "books.update_project", + json!({"project": "Harbor", "kind": "client"}), + ), + ( + "books.link_attachment", + json!({"attachment_id": a, "subject": {"kind": "project", "id": "Harbor"}}), + ), + ( + "books.record_decision", + json!({"subject": "s", "decision_md": "x", "decided_by": ["Tobin Harlow"], + "decided_on": "2026-04-01", "projects": ["Harbor"]}), + ), + ] { + let why = m.refuse(tool, args); + assert!( + why.starts_with("format_upgrade_pending:") && why.contains("projects"), + "{tool}: {why}" + ); + } + // Filing with a link to a project is refused before the file is filed. + let out = m.doc(b"%PDF d", json!([{"kind": "project", "id": "Harbor"}])); + assert!(out.refused()); + assert!(out.err_text().contains("projects"), "{out:?}"); + assert_eq!(m.journal(), head, "nothing journaled"); +} + +#[test] +fn add_line_notes_a_finished_project() { + let mut m = books("late"); + m.ok( + "books.add_project", + json!({"name": "Harbor", "status": "finished", "ended_on": "2026-09-30"}), + ); + m.ok("books.add_project", json!({"name": "Dock"})); + let id = m.ok( + "books.draft_entry", + json!({"date": "2026-10-05", "description": "late cost", "no_party_reason": "test"}), + )["entry_id"] + .as_u64() + .unwrap(); + let out = m.ok( + "books.add_line", + json!({"entry_id": id, "account": "6000", "side": "debit", "amount": "10.00", "class": "Harbor"}), + ); + let note = out["note"].as_str().unwrap(); + assert!( + note.contains("Harbor is finished (ended 2026-09-30)"), + "{note}" + ); + let out = m.ok( + "books.add_line", + json!({"entry_id": id, "account": "1000", "side": "credit", "amount": "10.00", "class": "Dock"}), + ); + assert!(out.get("note").is_none()); +} + +#[test] +fn record_payout_notes_a_finished_project() { + let mut m = books("late-payout"); + m.ok( + "books.add_project", + json!({"name": "Harbor", "status": "abandoned", "ended_on": "2026-09-30"}), + ); + m.ok("books.add_project", json!({"name": "Dock"})); + let out = m.ok( + "books.record_payout", + json!({"date": "2026-10-05", "description": "App Store payout", + "deposit_account": "1000", "deposit": "70.00", + "revenue": [{"account": "4000", "amount": "60.00", "class": "Harbor"}, + {"account": "4000", "amount": "40.00", "class": "Harbor"}, + {"account": "4000", "amount": "10.00", "class": "Dock"}], + "fees": [{"account": "6000", "amount": "40.00", "class": "Harbor"}]}), + ); + let notes = out["notes"].as_array().unwrap(); + assert_eq!(notes.len(), 1, "one note per project: {notes:?}"); + assert!( + notes[0] + .as_str() + .unwrap() + .contains("Harbor is abandoned (ended 2026-09-30)"), + "{notes:?}" + ); + // No project over: no notes. + let out = m.ok( + "books.record_payout", + json!({"date": "2026-10-06", "description": "App Store payout", + "deposit_account": "1000", "deposit": "10.00", + "revenue": [{"account": "4000", "amount": "10.00", "class": "Dock"}]}), + ); + assert!(out.get("notes").is_none(), "{out}"); +} + +// ------------------------------------------------- names + +#[test] +fn a_name_resolves_as_the_duplicate_check_compares() { + let mut m = books("fold"); + m.ok("books.add_project", json!({"name": "Éclair"})); + let why = m.refuse("books.add_project", json!({"name": "éCLAIR"})); + assert!(why.contains("already exists"), "{why}"); + for name in ["éclair", "ÉCLAIR", " Éclair ", " 1 "] { + let p = m.read_only("books.project", json!({"project": name})); + assert_eq!(p["project"]["name"], "Éclair", "{name:?}"); + } + let p = m.ok( + "books.update_project", + json!({"project": " éclair ", "status": "paused"}), + ); + assert_eq!( + (p["name"].clone(), p["status"].clone()), + (json!("Éclair"), json!("paused")) + ); + let why = m.refuse("books.add_project", json!({"name": "42"})); + assert!(why.contains("digits only"), "{why}"); +} + +/// Books an earlier version wrote with these class names (replay never +/// checks names): no uniqueness, digits allowed. +fn old_classes(name: &str, names: &[&str]) -> Mcp { + let dir = test_dir(name); + let books = dir.join("books-data"); + drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap()); + let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap(); + for (i, n) in names.iter().enumerate() { + w.append("class.added", json!({"id": i + 1, "name": n})) + .unwrap(); + } + drop(w); + let toml = books_toml(&dir, "Spec Co", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m.books_dir = Some(dir); + m +} + +#[test] +fn old_books_duplicates_take_fact_only_changes() { + let mut m = old_classes("old-dup", &["Alpha", "alpha", "Harbor"]); + // The lowest id answers the shared name; each changes by id. + assert_eq!( + m.read_only("books.project", json!({"project": "ALPHA"}))["project"]["id"], + 1 + ); + for id in ["1", "2"] { + let p = m.ok( + "books.update_project", + json!({"project": id, "status": "paused"}), + ); + assert_eq!(p["status"], "paused"); + } + let why = m.refuse( + "books.update_project", + json!({"project": "2", "name": "harbor"}), + ); + assert!(why.contains("already exists"), "{why}"); +} + +#[test] +fn a_digit_name_that_shadows_an_id_says_what_to_pass() { + let mut m = old_classes("old-digits", &["Éclair", "Dock", "1"]); + let why = m.refuse("books.project", json!({"project": "1"})); + assert!( + why.contains("pass 3 for project 3") && why.contains("\"Éclair\" (project 1's name)"), + "{why}" + ); + assert_eq!( + m.read_only("books.project", json!({"project": "3"}))["project"]["name"], + "1" + ); + assert_eq!( + m.read_only("books.project", json!({"project": "éclair"}))["project"]["id"], + 1 + ); + // A JSON number in a subject is an id, never a name. + let a = match m.doc(b"%PDF digits", json!([])) { + Out::Ok(v) => v["attachment_id"].as_u64().unwrap(), + o => panic!("{o:?}"), + }; + m.ok( + "books.link_attachment", + json!({"attachment_id": a, "subject": {"kind": "project", "id": 1}}), + ); + let files = m.read_only( + "books.attachments", + json!({"subject": {"kind": "project", "id": 1}}), + ); + assert_eq!(files["subject"]["label"], "project 1 Éclair"); +} diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs index f4616de..3f9b950 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -2071,6 +2071,7 @@ fn idempotent_tools_change_nothing_when_repeated() { ("6300", "Insurance", "expense"), ]); m.add_party("Dana Employee", &["employee"]); + m.ok("books.add_project", json!({"name": "Harbor"})); let entry = m.post( "2026-02-01", json!({}), @@ -2193,6 +2194,10 @@ fn idempotent_tools_change_nothing_when_repeated() { "books.set_fx_rate", json!({"currency": "EUR", "date": "2026-01-02", "rate": "1.10"}), ), + ( + "books.update_project", + json!({"project": "Harbor", "kind": "client", "status": "paused"}), + ), ]; let list = m.rpc("tools/list", json!({})); let marked: std::collections::BTreeSet = list["result"]["tools"] diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 51173bf..241fa4d 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -59,6 +59,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ TAX_CATEGORIES_GAIN_EXEMPT, DISPOSAL_BEFORE_SERVICE, DEPRECIATION_CATCH_UP, + PROJECTS, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -147,6 +148,13 @@ pub const SCHEDULE_CONVENTION: &str = "schedule_convention"; /// with the value at contribution and the §704(c) method (P3, defect 13). pub const ASSET_BASIS: &str = "asset_basis"; +/// Feature: projects (rules.md B-37): `project.set`, a new event type (a +/// class's project facts); `attachment.linked` to the subject kind +/// `project` (an older version would call it corrupt); and +/// `decision.recorded` with `projects` (an older version would drop them +/// silently). +pub const PROJECTS: &str = "projects"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -209,6 +217,7 @@ pub const EVENT_TYPES: &[&str] = &[ "payroll.taxes_remitted", "period.closed", "period.created", + "project.set", "receipt.attached", "receipt.linked", "receipt.unlinked", diff --git a/crates/ledger-store/src/meta.rs b/crates/ledger-store/src/meta.rs index cbda07f..8205453 100644 --- a/crates/ledger-store/src/meta.rs +++ b/crates/ledger-store/src/meta.rs @@ -17,6 +17,7 @@ use crate::error::StoreError; use chrono::NaiveDate; use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem}; use ledger_core::settings::{Change, Source}; +use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus}; use serde_json::{Map, Value, json}; /// The journal format these events need: an older reader stops at the @@ -170,7 +171,9 @@ pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), Stor } /// `decision.recorded {id, subject, decision_md, decided_by, decided_on, -/// evidence_attachment?, supersedes?, citation?}`. +/// evidence_attachment?, supersedes?, citation?, projects?}`: `projects` +/// only when it names any (journal feature `projects`), so a decision +/// without them writes the bytes it always did. pub fn decision_recorded(id: u64, d: &NewDecision) -> Value { let mut o = Map::new(); o.insert("id".into(), json!(id)); @@ -181,6 +184,9 @@ pub fn decision_recorded(id: u64, d: &NewDecision) -> Value { put(&mut o, "evidence_attachment", d.evidence_attachment); put(&mut o, "supersedes", d.supersedes); put(&mut o, "citation", d.citation.clone()); + if !d.projects.is_empty() { + o.insert("projects".into(), json!(d.projects)); + } Value::Object(o) } @@ -196,6 +202,17 @@ pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError> .collect::>>() }) .ok_or_else(|| corrupt(E, "decided_by"))?; + let projects = match p.get("projects") { + None | Some(Value::Null) => vec![], + Some(v) => v + .as_array() + .and_then(|a| { + a.iter() + .map(|x| x.as_u64().and_then(|n| ClassId::try_from(n).ok())) + .collect::>>() + }) + .ok_or_else(|| corrupt(E, "projects"))?, + }; Ok(( u64_of(p, E, "id")?, NewDecision { @@ -206,6 +223,74 @@ pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError> evidence_attachment: opt_u64(p, E, "evidence_attachment")?, supersedes: opt_u64(p, E, "supersedes")?, citation: opt_str(p, E, "citation")?, + projects, + }, + )) +} + +/// `project.set {id, name, status, kind?, description?, party_id?, +/// started_on?, ended_on?, links?, notes?}`: a class's whole project +/// record (rules.md B-37; journal feature `projects`). Every optional key +/// is left out when empty, so the payload is canonical. +pub fn project_set(id: ClassId, name: &str, f: &ProjectFacts) -> Value { + let mut o = Map::new(); + o.insert("id".into(), json!(id)); + o.insert("name".into(), json!(name)); + o.insert("status".into(), json!(f.status.key())); + put(&mut o, "kind", f.kind.map(ProjectKind::key)); + put(&mut o, "description", f.description.clone()); + put(&mut o, "party_id", f.party); + put(&mut o, "started_on", f.started_on.map(|d| d.to_string())); + put(&mut o, "ended_on", f.ended_on.map(|d| d.to_string())); + if !f.links.is_empty() { + o.insert("links".into(), json!(f.links)); + } + put(&mut o, "notes", f.notes.clone()); + Value::Object(o) +} + +/// Read a `project.set`: `(id, name, facts)`. A missing or mistyped key is +/// corrupt; a status or kind this version doesn't know is newer (a newer +/// version wrote it, rules.md B-27). +pub fn project_set_of( + line: &crate::journal::JournalLine, +) -> Result<(ClassId, String, ProjectFacts), StoreError> { + const E: &str = "project.set"; + let p = &line.payload; + let id = ClassId::try_from(u64_of(p, E, "id")?).map_err(|_| corrupt(E, "id"))?; + let name = str_of(p, E, "name")?; + let key = str_of(p, E, "status")?; + let status = ProjectStatus::parse(&key) + .ok_or_else(|| crate::format::newer_content(line, format!("the project status {key:?}")))?; + let kind = match opt_str(p, E, "kind")? { + None => None, + Some(k) => Some(ProjectKind::parse(&k).ok_or_else(|| { + crate::format::newer_content(line, format!("the project kind {k:?}")) + })?), + }; + let links = match p.get("links") { + None | Some(Value::Null) => vec![], + Some(v) => v + .as_array() + .and_then(|a| { + a.iter() + .map(|x| x.as_str().map(str::to_string)) + .collect::>>() + }) + .ok_or_else(|| corrupt(E, "links"))?, + }; + Ok(( + id, + name, + ProjectFacts { + description: opt_str(p, E, "description")?, + status, + kind, + party: opt_u64(p, E, "party_id")?, + started_on: opt_date(p, E, "started_on")?, + ended_on: opt_date(p, E, "ended_on")?, + links, + notes: opt_str(p, E, "notes")?, }, )) } @@ -328,6 +413,7 @@ mod tests { evidence_attachment: Some(7), supersedes: None, citation: None, + projects: vec![], }; assert_eq!( decision_recorded_of(&decision_recorded(3, &d)).unwrap(), diff --git a/crates/ledger-store/src/ops.rs b/crates/ledger-store/src/ops.rs index d7345a3..ef13a87 100644 --- a/crates/ledger-store/src/ops.rs +++ b/crates/ledger-store/src/ops.rs @@ -63,7 +63,7 @@ pub fn report_columns( #[serde(tag = "show", rename_all = "snake_case")] pub enum ViewerTarget { /// A top-level page: overview, cash, profit, transactions, reports, - /// accounts, assets, parties, documents, company, journal. + /// accounts, assets, parties, projects, documents, company, journal. Page { /// Page name. page: String, @@ -120,6 +120,12 @@ pub enum ViewerTarget { /// Party id. party_id: u64, }, + /// One project (a class with its facts): its figures, entries, + /// documents and decisions. + Project { + /// Class id. + class_id: u32, + }, /// One reconciliation: its account, with the account's statements and /// reconciliations. Reconciliation { diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index b4ba3e0..56a5c79 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -950,6 +950,9 @@ impl Store { return Err(StoreError::AttachmentWithdrawn(a)); } self.ensure_format()?; + if !d.projects.is_empty() { + self.ensure_feature(crate::format::PROJECTS)?; + } let line = self.append_journal("decision.recorded", crate::meta::decision_recorded(id, &d))?; self.ledger.apply_decision(id, d, line.seq)?; @@ -3365,15 +3368,19 @@ impl Store { // Classes // ------------------------------------------------------------------ - /// Add a class (division) for line-level P&L tracking. Journaled as - /// `class.added`. + /// Add a class (division) for line-level P&L tracking: a project with + /// only its name. Journaled as `class.added`. pub fn add_class(&mut self, name: &str) -> Result { + // Rehearsed on a copy: the name rules (blank, length, a name + // another class has) refuse here, so the journal write can't be + // followed by a failed mutation. self.rehearse(|l| l.add_class(name))?; - // The engine's only failure mode is an empty name; replicate it so - // the journal write cannot be followed by a failed mutation. - if name.trim().is_empty() { - return Err(LedgerError::EmptyClassName.into()); - } + self.append_class(name) + } + + /// `class.added` for a name already rehearsed. + fn append_class(&mut self, name: &str) -> Result { + let name = name.trim(); let id = self.ledger.next_class_id(); let line = self.append_journal("class.added", json!({ "id": id, "name": name }))?; let live = self.ledger.add_class(name)?; @@ -3382,6 +3389,83 @@ impl Store { Ok(live) } + /// Add a project (rules.md B-37): `class.added`, then `project.set` + /// when `facts` say more than the defaults. Returns its id. + /// + /// ```text + /// GOAL: a project in one command, journaled as two events only when + /// it has facts, so a name-only project is a plain class. + /// SOURCE: docs/spec/rules.md B-37 item 1; B-27 (journal feature + /// `projects`). + /// INVARIANTS: + /// 1. A refusal journals nothing: the whole record is rehearsed, and + /// the feature asked for, before the first line. + /// 2. Facts equal to the defaults journal `class.added` alone (books + /// waiting for their format upgrade can still add one). + /// ``` + pub fn add_project( + &mut self, + name: &str, + facts: &ledger_core::ProjectFacts, + ) -> Result { + self.rehearse(|l| { + let id = l.add_class(name)?; + if !facts.is_default() { + l.set_project(id, name, facts.clone())?; + } + Ok(()) + })?; + let with_facts = !facts.is_default(); + if with_facts { + self.ensure_feature(crate::format::PROJECTS)?; + } + let id = self.append_class(name)?; + if with_facts { + let (name, facts) = self.ledger.check_set_project(id, name, facts)?; + self.append_project(id, name, facts)?; + } + Ok(id) + } + + /// Set a project's whole record (`project.set`): its name and facts. + /// Unchanged (the same name and facts once checked): journals nothing + /// and returns false. + pub fn set_project( + &mut self, + id: ClassId, + name: &str, + facts: &ledger_core::ProjectFacts, + ) -> Result { + let (name, facts) = self.ledger.check_set_project(id, name, facts)?; + let same_name = self.ledger.get_class(id).is_some_and(|c| c.name == name); + if same_name && self.ledger.project_facts(id).as_ref() == Some(&facts) { + return Ok(false); + } + self.ensure_feature(crate::format::PROJECTS)?; + self.append_project(id, name, facts)?; + Ok(true) + } + + /// `project.set` for a record already checked. + fn append_project( + &mut self, + id: ClassId, + name: String, + facts: ledger_core::ProjectFacts, + ) -> Result<(), StoreError> { + let line = + self.append_journal("project.set", crate::meta::project_set(id, &name, &facts))?; + self.ledger.set_project(id, &name, facts)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// A class's project facts (the defaults for a class with none); + /// `None` for no such class. + pub fn project_facts(&self, id: ClassId) -> Option { + self.ledger.project_facts(id) + } + /// All classes, in id order. pub fn list_classes(&self) -> Vec { self.ledger.list_classes().into_iter().cloned().collect() @@ -4157,6 +4241,17 @@ impl Store { // Attachment links, statements, reconciliations // ------------------------------------------------------------------ + /// The journal feature a link to `subject` needs, if any: a subject + /// kind an older version doesn't know (a project, rules.md B-37). + /// Callers filing a file with links ask it before filing, so a refusal + /// leaves nothing half-filed. + pub fn subject_feature(subject: Subject) -> Option<&'static str> { + match subject { + Subject::Project(_) => Some(crate::format::PROJECTS), + _ => None, + } + } + /// Whether `subject` exists in the books (what [`Store::link_attachment`] /// checks, so callers can validate several subjects before linking any). pub fn subject_exists(&self, subject: Subject) -> Result { @@ -4166,6 +4261,7 @@ impl Store { Subject::Account(i) => l.chart().get(i).is_some(), Subject::Asset(i) => l.get_asset(i).is_some(), Subject::Party(i) => l.get_party(i).is_some(), + Subject::Project(i) => u32::try_from(i).ok().and_then(|c| l.get_class(c)).is_some(), Subject::Invoice(i) => l.get_invoice(i).is_some(), Subject::Bill(i) => l.get_bill(i).is_some(), Subject::Period(i) => l.list_periods().iter().any(|p| p.id == i), @@ -4227,8 +4323,8 @@ impl Store { } /// Link a stored attachment to a subject: another entry, an account, an - /// asset, a party, an invoice, a bill, a period, a payroll run, a - /// schedule, a reconciliation, or the company. + /// asset, a party, a project, an invoice, a bill, a period, a payroll + /// run, a schedule, a reconciliation, or the company. /// /// ```text /// GOAL: one file, linked wherever it's evidence, without refiling. @@ -4262,6 +4358,9 @@ impl Store { { return Err(StoreError::AlreadyLinked(attachment_id, subject.describe())); } + if let Some(f) = Self::subject_feature(subject) { + self.ensure_feature(f)?; + } let line = self.append_journal( "attachment.linked", json!({"attachment_id": attachment_id, "subject": subject}), @@ -6105,6 +6204,10 @@ fn replay_line( "class.added" => { ledger.replay_class(get_u64(p, "id")? as u32, get_str(p, "name")?.to_string())?; } + "project.set" => { + let (id, name, facts) = crate::meta::project_set_of(line)?; + ledger.replay_set_project(id, name, facts)?; + } "entry.line_class_set" => { // Journaled while the entry was a draft, so at replay time the // draft exists and takes the class. Missing/null class_id @@ -7140,6 +7243,14 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr params![get_u64(p, "id")? as i64, get_str(p, "name")?], )?; } + "project.set" => { + // Facts live in the ledger (as parties'); the projection keeps + // the name, so verify's class check stays exact after a rename. + conn.execute( + "UPDATE classes SET name = ? WHERE id = ?", + params![get_str(p, "name")?, get_u64(p, "id")? as i64], + )?; + } "entry.line_class_set" => { // Journaled while the entry is a draft, so the projection // usually has no lines row yet (drafts never touch the @@ -7440,6 +7551,9 @@ pub enum Subject { Schedule(u64), /// A reconciliation (its statement). Reconciliation(u64), + /// A project (its contract, spec, pitch, correspondence; rules.md + /// B-37). Journal feature `projects`. + Project(u64), /// The company itself (formation documents). Company, } @@ -7458,6 +7572,7 @@ impl Subject { Subject::PayrollRun(_) => "payroll_run", Subject::Schedule(_) => "schedule", Subject::Reconciliation(_) => "reconciliation", + Subject::Project(_) => "project", Subject::Company => "company", } } @@ -7474,7 +7589,8 @@ impl Subject { | Subject::Period(i) | Subject::PayrollRun(i) | Subject::Schedule(i) - | Subject::Reconciliation(i) => i, + | Subject::Reconciliation(i) + | Subject::Project(i) => i, Subject::Company => 0, } } @@ -7492,6 +7608,7 @@ impl Subject { "payroll_run" => Subject::PayrollRun(id), "schedule" => Subject::Schedule(id), "reconciliation" => Subject::Reconciliation(id), + "project" => Subject::Project(id), "company" => Subject::Company, _ => return None, }) diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index 1df48aa..815b47f 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -897,7 +897,7 @@ fn read_journal_and_format_gives_every_line_and_the_format() { /// declaration as newer, never at the line, and never calls it corrupt. #[test] fn p3_features_wait_for_the_upgrade_and_are_declared_once() { - for f in ["account_renamed", "require_tax_category"] { + for f in ["account_renamed", "require_tax_category", "projects"] { assert!(KNOWN_FEATURES.contains(&f), "{f}"); } let dir = test_dir("p3-features"); @@ -927,11 +927,20 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() { store.change_settings(&[policy()], &source).map(|_| ()), "the journal feature require_tax_category", ); + let facts = ledger_core::ProjectFacts { + kind: Some(ledger_core::ProjectKind::OnSpec), + ..Default::default() + }; + waits( + store.add_project("Harbor", &facts).map(|_| ()), + "the journal feature projects", + ); assert_eq!(store.head(), head, "refused whole"); assert!(store.upgrade_format().unwrap().is_some()); store.rename_account(1, "Business Checking", None).unwrap(); store.rename_account(1, "Checking", None).unwrap(); store.change_settings(&[policy()], &source).unwrap(); + store.add_project("Harbor", &facts).unwrap(); assert_eq!(*store.journal_format(), ledger_store::format::current()); drop(store); let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap(); @@ -942,7 +951,12 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() { let kinds: Vec<(&str, serde_json::Value)> = lines .iter() .map(|l| (l["type"].as_str().unwrap(), l["payload"].clone())) - .filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set")) + .filter(|(t, _)| { + matches!( + *t, + FORMAT_SET | "account.renamed" | "settings.set" | "project.set" + ) + }) .collect(); let current = ledger_store::format::current(); assert_eq!(kinds[0].0, FORMAT_SET); @@ -953,7 +967,8 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() { assert_eq!(kinds[1].0, "account.renamed"); assert_eq!(kinds[2].0, "account.renamed"); assert_eq!(kinds[3].0, "settings.set"); - assert_eq!(kinds.len(), 4); + assert_eq!(kinds[4].0, "project.set"); + assert_eq!(kinds.len(), 5); // An earlier reader knows no feature (P2: KNOWN_FEATURES was empty), // so the gate stops it at the declaration, before the rename: the // gate's refusal of a feature it doesn't know is pinned by diff --git a/crates/ledger-store/tests/projects.rs b/crates/ledger-store/tests/projects.rs new file mode 100644 index 0000000..3c30ef0 --- /dev/null +++ b/crates/ledger-store/tests/projects.rs @@ -0,0 +1,513 @@ +//! Projects in the journal (rules.md B-37): `class.added`, then the whole +//! record as `project.set` (journal feature `projects`); links to a +//! project and decisions naming one wait for the feature too. +//! +//! ```text +//! GOAL: a project's record is journaled once, whole, replays exactly, +//! and books waiting for their format upgrade can still add +//! projects by name but write nothing an older reader would misread. +//! SOURCE: docs/spec/rules.md B-37, B-27; D-39 (the owner's upgrade). +//! ``` + +use chrono::NaiveDate; +use ledger_core::conventions::NewDecision; +use ledger_core::{Currency, ProjectFacts, ProjectKind, ProjectStatus}; +use ledger_store::{ + JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, Subject, +}; +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-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 facts() -> ProjectFacts { + ProjectFacts { + description: Some("An app for the harbor".into()), + kind: Some(ProjectKind::OnSpec), + started_on: Some(d(2026, 8, 1)), + links: vec!["https://harbor.example/app".into()], + ..Default::default() + } +} + +/// 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() +} + +fn decision(projects: Vec) -> NewDecision { + NewDecision { + subject: "Harbor".into(), + decision_md: "Build it on spec.".into(), + decided_by: vec!["Tobin Harlow".into()], + decided_on: d(2026, 8, 1), + evidence_attachment: None, + supersedes: None, + citation: None, + projects, + } +} + +#[test] +fn add_project_journals_class_added_then_project_set() { + let dir = test_dir("add"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let id = s.add_project(" Harbor app ", &facts()).unwrap(); + let l = lines(&dir.0); + let n = l.len(); + assert_eq!( + l[n - 2], + ( + "class.added".into(), + json!({"id": id, "name": "Harbor app"}) + ) + ); + assert_eq!( + l[n - 1], + ( + "project.set".into(), + json!({"id": id, "name": "Harbor app", "status": "active", "kind": "on_spec", + "description": "An app for the harbor", "started_on": "2026-08-01", + "links": ["https://harbor.example/app"]}) + ) + ); + assert_eq!(s.project_facts(id), Some(facts())); + // A refused add journals nothing: a taken name. + let head = s.head(); + assert!(s.add_project("HARBOR APP", &facts()).is_err()); + assert!( + s.add_project( + "Dock", + &ProjectFacts { + ended_on: Some(d(2026, 1, 1)), + ..Default::default() + } + ) + .is_err() + ); + assert_eq!(s.head(), head); +} + +#[test] +fn a_name_only_project_journals_class_added_alone() { + let dir = test_dir("name-only"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let before = lines(&dir.0).len(); + let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap(); + let l = lines(&dir.0); + assert_eq!(l.len(), before + 1); + assert_eq!(l[before].0, "class.added"); + assert_eq!(s.project_facts(id), Some(ProjectFacts::default())); +} + +#[test] +fn project_writes_wait_for_the_upgrade_and_journal_nothing() { + let dir = test_dir("upgrade"); + let mut s = Store::create_in_format(&dir.0, Currency::USD, &JournalFormat::default()).unwrap(); + // A name alone is a plain class: accepted. + let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap(); + let (att, _, _) = s + .file_document("other", "pitch.pdf", "application/pdf", b"%PDF pitch", None) + .unwrap(); + let head = s.head(); + let waits = |r: Result<(), StoreError>| match r { + Err(StoreError::FormatUpgradePending { need }) => need, + other => panic!("expected FormatUpgradePending, got {other:?}"), + }; + let projects = "the journal feature projects"; + assert_eq!(waits(s.add_project("Dock", &facts()).map(|_| ())), projects); + assert_eq!( + waits(s.set_project(id, "Harbor", &facts()).map(|_| ())), + projects + ); + assert_eq!( + waits(s.link_attachment(att, Subject::Project(u64::from(id)))), + projects + ); + // A decision waits for format 2 itself on these books; with + // projects it waits for that feature too. + waits(s.record_decision(&decision(vec![id])).map(|_| ())); + assert_eq!(s.head(), head, "nothing journaled"); + assert!(s.upgrade_format().unwrap().is_some()); + s.add_project("Dock", &facts()).unwrap(); + assert!(s.set_project(id, "Harbor", &facts()).unwrap()); + s.link_attachment(att, Subject::Project(u64::from(id))) + .unwrap(); + s.record_decision(&decision(vec![id])).unwrap(); + s.verify().unwrap(); +} + +/// The live books after this version is installed: format 2 with every +/// earlier feature but not `projects`. The format check passes; only the +/// feature check refuses, and nothing is written. +#[test] +fn format_2_books_without_the_feature_wait_for_it_alone() { + let dir = test_dir("no-feature"); + let mut format = ledger_store::format::current(); + format + .features + .retain(|f| f != ledger_store::format::PROJECTS); + assert_eq!(format.format, 2); + let mut s = Store::create_in_format(&dir.0, Currency::USD, &format).unwrap(); + let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap(); + let (att, _, _) = s + .file_document("other", "pitch.pdf", "application/pdf", b"%PDF pitch", None) + .unwrap(); + // A decision naming no project is format 2's: accepted. + s.record_decision(&decision(vec![])).unwrap(); + let head = s.head(); + let waits = |r: Result<(), StoreError>| match r { + Err(StoreError::FormatUpgradePending { need }) => need, + other => panic!("expected FormatUpgradePending, got {other:?}"), + }; + let projects = "the journal feature projects"; + assert_eq!(waits(s.add_project("Dock", &facts()).map(|_| ())), projects); + assert_eq!( + waits(s.set_project(id, "Harbor", &facts()).map(|_| ())), + projects + ); + assert_eq!( + waits(s.link_attachment(att, Subject::Project(u64::from(id)))), + projects + ); + assert_eq!( + waits(s.record_decision(&decision(vec![id])).map(|_| ())), + projects + ); + assert_eq!(s.head(), head, "nothing journaled"); + // A name alone is still a plain class. + s.add_project("Dock", &ProjectFacts::default()).unwrap(); + s.verify().unwrap(); +} + +/// `class.added` lines appended as an earlier version wrote them: no +/// uniqueness and no trimming. +fn old_classes(dir: &Path, names: &[&str]) { + let s = Store::create(dir, Currency::USD).unwrap(); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + for (i, n) in names.iter().enumerate() { + w.append("class.added", json!({"id": i + 1, "name": n})) + .unwrap(); + } +} + +#[test] +fn old_books_with_a_duplicate_name_change_either_project() { + let dir = test_dir("dup"); + old_classes(&dir.0, &["Alpha", "alpha", "Harbor"]); + let mut s = Store::open(&dir.0).unwrap(); + let paused = ProjectFacts { + status: ProjectStatus::Paused, + ..Default::default() + }; + // A fact-only change to each keeps its name. + assert!(s.set_project(1, "Alpha", &paused).unwrap()); + assert!(s.set_project(2, "alpha", &paused).unwrap()); + // Its own name up to capitals and space is still its own. + assert!(s.set_project(2, " ALPHA ", &paused).unwrap()); + assert_eq!(s.list_classes()[1].name, "ALPHA"); + // A rename onto another project's name is still refused. + let head = s.head(); + assert!(matches!( + s.set_project(1, "harbor", &paused), + Err(StoreError::Ledger( + ledger_core::LedgerError::DuplicateClassName(_) + )) + )); + assert_eq!(s.head(), head); + // A rename away from the duplicate is accepted. + assert!(s.set_project(2, "Alpha two", &paused).unwrap()); + s.verify().unwrap(); +} + +#[test] +fn a_fact_only_change_never_renames_an_untrimmed_old_name() { + let dir = test_dir("untrimmed"); + old_classes(&dir.0, &["Retail "]); + let mut s = Store::open(&dir.0).unwrap(); + let head = s.head(); + // Its own name, exactly: unchanged, nothing journaled. + assert!( + !s.set_project(1, "Retail ", &ProjectFacts::default()) + .unwrap() + ); + assert_eq!(s.head(), head); + let paused = ProjectFacts { + status: ProjectStatus::Paused, + ..Default::default() + }; + assert!(s.set_project(1, "Retail ", &paused).unwrap()); + let l = lines(&dir.0); + assert_eq!( + l.last().unwrap().1["name"], + "Retail ", + "no rename journaled" + ); + assert_eq!(s.list_classes()[0].name, "Retail "); + s.verify().unwrap(); +} + +#[test] +fn update_with_nothing_new_journals_nothing() { + let dir = test_dir("same"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let id = s.add_project("Harbor", &facts()).unwrap(); + let head = s.head(); + assert!(!s.set_project(id, " Harbor ", &facts()).unwrap()); + assert_eq!(s.head(), head); + let mut f = facts(); + f.status = ProjectStatus::Paused; + assert!(s.set_project(id, "Harbor", &f).unwrap()); + assert_ne!(s.head(), head); +} + +#[test] +fn project_set_replays_verifies_and_reloads() { + let dir = test_dir("replay"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let party = s.add_party("Harbor Co", &[]).unwrap(); + let id = s.add_project("Harbor", &facts()).unwrap(); + let mut f = facts(); + f.party = Some(party); + f.status = ProjectStatus::Finished; + f.ended_on = Some(d(2026, 9, 30)); + s.set_project(id, "Harbor app", &f).unwrap(); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + s.verify().unwrap(); + assert_eq!(s.project_facts(id), Some(f.clone())); + assert_eq!(s.list_classes()[0].name, "Harbor app"); + drop(s); + let snap = Snapshot::load(&dir.0).unwrap(); + assert_eq!(snap.ledger().project_facts(id), Some(f)); + assert_eq!(snap.ledger().projects_of_party(party), vec![id]); +} + +#[test] +fn an_unknown_status_or_kind_is_newer_a_malformed_record_corrupt() { + for (case, payload, newer) in [ + ( + "status", + json!({"id": 1, "name": "Harbor", "status": "on_hold"}), + true, + ), + ( + "kind", + json!({"id": 1, "name": "Harbor", "status": "active", "kind": "grant"}), + true, + ), + ("no-status", json!({"id": 1, "name": "Harbor"}), false), + ( + "links", + json!({"id": 1, "name": "Harbor", "status": "active", "links": "x"}), + false, + ), + ( + "date", + json!({"id": 1, "name": "Harbor", "status": "active", "started_on": "soon"}), + false, + ), + ] { + let dir = test_dir(&format!("bad-{case}")); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + s.add_class("Harbor").unwrap(); + drop(s); + let mut w = JournalWriter::open(&dir.0.join(JOURNAL_FILE)).unwrap(); + w.append("project.set", payload).unwrap(); + drop(w); + match Store::open(&dir.0) { + Err(StoreError::NewerJournal { .. }) if newer => {} + Err(StoreError::JournalCorrupt(_)) if !newer => {} + Err(e) => panic!("{case}: {e:?}"), + Ok(_) => panic!("{case}: opened"), + } + } +} + +#[test] +fn a_decision_without_projects_writes_the_bytes_it_always_did() { + let d0 = decision(vec![]); + let v = ledger_store::meta::decision_recorded(1, &d0); + assert!(v.get("projects").is_none()); + assert_eq!( + v, + json!({"id": 1, "subject": "Harbor", "decision_md": "Build it on spec.", + "decided_by": ["Tobin Harlow"], "decided_on": "2026-08-01"}) + ); + let (_, back) = ledger_store::meta::decision_recorded_of(&v).unwrap(); + assert_eq!(back, d0); + let with = ledger_store::meta::decision_recorded(1, &decision(vec![3, 1])); + assert_eq!(with["projects"], json!([3, 1])); + assert_eq!( + ledger_store::meta::decision_recorded_of(&with) + .unwrap() + .1 + .projects, + vec![3, 1] + ); + let mut bad = with.clone(); + bad["projects"] = json!(["x"]); + assert!(ledger_store::meta::decision_recorded_of(&bad).is_err()); +} + +#[test] +fn attachments_link_to_a_project_and_unlink() { + let dir = test_dir("links"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap(); + let (att, _, _) = s + .file_document( + "contract", + "spec.pdf", + "application/pdf", + b"%PDF spec", + None, + ) + .unwrap(); + let sub = Subject::Project(u64::from(id)); + assert_eq!(Store::subject_feature(sub), Some("projects")); + assert_eq!(Store::subject_feature(Subject::Party(1)), None); + assert!(s.subject_exists(sub).unwrap()); + assert!(!s.subject_exists(Subject::Project(99)).unwrap()); + assert!(!s.subject_exists(Subject::Project(u64::MAX)).unwrap()); + assert!(s.link_attachment(att, Subject::Project(99)).is_err()); + s.link_attachment(att, sub).unwrap(); + assert_eq!(s.attachments_for(sub).unwrap()[0].id, att); + assert!(s.attachment_links(att).unwrap().contains(&sub)); + drop(s); + let mut s = Store::open(&dir.0).unwrap(); + s.verify().unwrap(); + s.unlink_attachment(att, sub, "wrong project").unwrap(); + assert!(s.attachments_for(sub).unwrap().is_empty()); + s.verify().unwrap(); +} + +#[test] +fn old_books_open_to_their_figures_with_projects_known() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books"); + let mut dirs: Vec = std::fs::read_dir(&root) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + assert!(!dirs.is_empty()); + for fixture in dirs { + let name = fixture.file_name().unwrap().to_string_lossy().to_string(); + let dir = test_dir(&format!("old-{name}")); + copy(&fixture, &dir.0); + let journal = std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap(); + let expected: Value = + serde_json::from_slice(&std::fs::read(fixture.join("expected.json")).unwrap()).unwrap(); + let s = Store::open(&dir.0).unwrap_or_else(|e| panic!("{name}: refused: {e}")); + // The figures that version computed, unchanged by knowing projects. + assert_eq!( + json!(ledger_core::reports::snapshot(s.ledger()).map_err(|e| e.to_string())), + expected["snapshot"], + "{name}: figures" + ); + assert_eq!( + json!(s.verify().map(|_| ()).map_err(|e| e.to_string())), + expected["verify"], + "{name}: verify" + ); + for c in s.list_classes() { + assert_eq!( + s.project_facts(c.id), + Some(ProjectFacts::default()), + "{name}" + ); + } + assert!( + !lines(&dir.0).iter().any(|(t, _)| t == "project.set"), + "{name}" + ); + drop(s); + assert_eq!( + std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap(), + journal, + "{name}: opening journals nothing" + ); + } +} + +fn copy(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap() { + let e = e.unwrap(); + if e.file_name() == "expected.json" || e.file_name() == "record.json" { + continue; + } + if e.file_type().unwrap().is_dir() { + copy(&e.path(), &to.join(e.file_name())); + } else { + std::fs::copy(e.path(), to.join(e.file_name())).unwrap(); + } + } +} + +proptest::proptest! { + /// Any record a project can hold reads back as written. + #[test] + fn prop_project_set_round_trips( + status in 0usize..4, + kind in proptest::option::of(0usize..4), + party in proptest::option::of(1u64..1000), + desc in proptest::option::of("[a-z ]{1,20}"), + start in proptest::option::of(0u32..3000), + links in proptest::collection::vec("[a-z]{1,10}", 0..8), + ) { + let f = ProjectFacts { + description: desc, + status: ProjectStatus::ALL[status], + kind: kind.map(|k| ProjectKind::ALL[k]), + party, + started_on: start.map(|n| d(2020, 1, 1) + chrono::Days::new(u64::from(n))), + ended_on: None, + links, + notes: None, + }; + let payload = ledger_store::meta::project_set(7, "Harbor", &f); + let line = ledger_store::journal::JournalLine { + seq: 1, + ts: String::new(), + line_type: "project.set".into(), + payload, + prev: String::new(), + }; + let back = ledger_store::meta::project_set_of(&line).unwrap(); + proptest::prop_assert_eq!(back, (7, "Harbor".to_string(), f)); + } +} diff --git a/crates/ledger-store/tests/settings.rs b/crates/ledger-store/tests/settings.rs index d7868c0..643046b 100644 --- a/crates/ledger-store/tests/settings.rs +++ b/crates/ledger-store/tests/settings.rs @@ -609,6 +609,7 @@ fn decisions_and_open_items() { evidence_attachment: evidence, supersedes, citation: Some("Treas. Reg. 1.263(a)-1(f)".into()), + projects: vec![], }; assert_eq!(s.record_decision(&decision(None, Some(doc))).unwrap(), 1); assert_eq!(s.record_decision(&decision(Some(1), None)).unwrap(), 2); @@ -756,6 +757,7 @@ fn run_op(s: &mut Store, op: &Op) { evidence_attachment: None, supersedes: *sup, citation: None, + projects: vec![], }) .map(|_| ()), Op::Raise => s @@ -978,6 +980,7 @@ fn write_p1_fixture() { evidence_attachment: Some(doc), supersedes: None, citation: Some("Treas. Reg. 1.263(a)-1(f)".into()), + projects: vec![], }) .unwrap(); s.record_decision(&NewDecision { @@ -988,6 +991,7 @@ fn write_p1_fixture() { evidence_attachment: None, supersedes: Some(1), citation: None, + projects: vec![], }) .unwrap(); s.raise_open_item(&NewOpenItem { diff --git a/docs/capability-parity.md b/docs/capability-parity.md index 53b40c7..76f77b2 100644 --- a/docs/capability-parity.md +++ b/docs/capability-parity.md @@ -35,6 +35,11 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed), - Row 10 (`books.digest`) has no sheet equivalent — the sheet can't tell you what changed. This is parity *plus*: the new system is more reviewable than the old one, which is the point of the exercise. +- Projects (rules.md B-37) have no sheet equivalent either: a class with + its facts, created and changed by the agent (`books.add_project`, + `books.update_project`), read by the viewer's Projects pages and by + `books.projects` / `books.project` (D-28: the agent writes, the viewer + reads). - Row 12 is deliberately excluded. Email filing is a mail problem, not a books problem; it stays where it is. diff --git a/docs/glossary.md b/docs/glossary.md index e61972f..fd23052 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -62,3 +62,14 @@ definitions exactly. reports it: cash plus the adjusted tax basis of property contributed, plus their share of income as figured for tax, less cash and the adjusted tax basis of property distributed (Form 1065 instructions, Item L). +- **Project** — A class with its facts (rules.md B-37): what it is, where it + stands (active, paused, finished, abandoned), what kind of work it is + (internal product, client work, on spec, other), who it is for, its + dates and links. Its figures are the revenue and expense lines tagged to + it (job costing). +- **On spec** — Work done hoping to be paid. Its costs are expenses as they + are spent; the hoped-for payment is a gain contingency, recorded neither + as a receivable nor as income until it arrives (ASC 450-30-25-1). +- **Unrecovered cost** — An on-spec project's expenses less its revenue to + date, never below zero: what it would take to be repaid in full. A + figure, never an asset. diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 98ec2d8..370069d 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -131,7 +131,8 @@ settings-and-conventions section says how to use them. state ("needs_onboarding" or "active"), settings_source, rules: { evidence, parties, approval_rules }, settings_summary: [{ key, value, set_by, seq }], conventions: [{ id, kind, title, summary }], open_items: - [{ id, title, due }], truncated?, handbook_sections, format_upgrade?: { + [{ id, title, due }], projects: [{ id, name, status, kind? }], + truncated?, handbook_sections, format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool, args?, why }], isolation }`. The connect text names it as the first call. `format_upgrade` is there only for books waiting for a format upgrade @@ -141,6 +142,8 @@ settings-and-conventions section says how to use them. `isolation` is `same_user (advisory)` (D-32): the books are hidden from agents in the owner's own account, not locked against them. Lists cut to fit say so in `truncated` ("conventions: 4 more (books.conventions)"). + `projects` lists the active and paused projects by name, at most 20 + ("projects: N more (books.projects)" past that). `next_steps`, in order: the format upgrade's (when waiting); `books.import_settings` (books that still read a settings file); the onboarding step (`books.handbook {section: "contract"}`, when the chart @@ -192,9 +195,15 @@ as it is made. failing midway could leave a partial draft, as for any draft built line by line). - `books.record_decision { subject, decision_md, decided_by: [..], - decided_on, evidence_attachment?, supersedes?, citation? }` → `{ id }`; - `books.decisions { include_superseded? }`. A decision supersedes at most - one live decision; its evidence is a document still in the books. + decided_on, evidence_attachment?, supersedes?, citation?, projects? }` → + `{ id }`; `books.decisions { include_superseded?, project? }`. A decision + supersedes at most one live decision; its evidence is a document still + in the books. `projects` (ids or names, each once, resolved before + anything is written) names the projects it is about (rules.md B-37); a + decision without them journals the bytes it always did, and one with + them needs the journal feature `projects`. `books.decisions` gives each + decision's `projects: [{ id, name }]` (left out when none); `project` + filters to the decisions naming one. - `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }`, `books.resolve_item { id, resolution, decided_by, ... }`, `books.open_items { include_resolved? }`. @@ -379,11 +388,12 @@ that changes anything also has a `destructiveHint`, true for these 29: `books.set_tin` (both overwrite a vault record), `books.purge_tin`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.run_payroll`, `books.remit_payroll_taxes`; and an -`idempotentHint` on the ten that a repeat with the same arguments leaves +`idempotentHint` on the eleven that a repeat with the same arguments leaves the journal and the vault as they were: `books.verify`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.set_party_roles`, `books.set_setting`, `books.import_settings`, -`books.set_vendor_1099`, `books.set_entry_instrument` and +`books.set_vendor_1099`, `books.set_entry_instrument`, +`books.update_project` and `books.back_up_now` (a setter such as `books.update_party`, `books.set_fx_rate` or `books.set_tin` journals a line, and `books.set_tin` seals a new vault record, every time, so none carries it; @@ -835,12 +845,14 @@ through the policy. ## Tools — the viewer - `books.show { entry_id | account | report | attachment_id | asset | - party | reconciliation | search | page, start?, end?, columns?, compare? }` + party | project | reconciliation | search | page, start?, end?, columns?, + compare? }` → `{ shown, viewer_open, note }`. Asks the human's viewer (`ledger-gui`) to open an entry, an account register or a report (for start/end dates if given), a stored file, an asset, a party (by id or name, resolved as in "Tools - — parties"), a reconciliation (by id: its account's page, with its + — parties"), a project (by id or name: viewer target `{show: + "project", class_id}`; page `projects` its list), a reconciliation (by id: its account's page, with its statements and reconciliations; viewer target `{show: "reconciliation", id}`), a transactions search, or a page (`parties` among them). Exactly one target; entries, accounts, attachments, @@ -1270,11 +1282,12 @@ texts are at most 500 characters, no control characters. after `books.purge_tin` (P3 defect 11: it read W-9 facts only, so a member's showed as missing). - `books.party { party, year?, basis? }` → `{ party, year, totals, - entries, invoices, bills, payroll }`: the facts; the calendar year's + entries, invoices, bills, payroll, projects }`: the facts; the calendar year's totals (default this year); every entry it is on (drafts included, newest first); its invoices and bills (`{ id, date, due_date, total, paid, balance, status, entry_id }`); and the payroll lines naming it - (`{ run_id, date, entry_id, gross_pay, net_pay }`). + (`{ run_id, date, entry_id, gross_pay, net_pay }`); and the projects for + or with it, by name (`{ id, name }`, rules.md B-37). - `basis` (both): `"cash"` (the default) or `"all"`; any other word is refused (`basis is "cash" or "all"`). Echoed in the output. Not the books' accounting method (`books.accounting_basis`). @@ -1480,7 +1493,8 @@ and the CPA package carries them. A stored file (a receipt or a document) links to any number of subjects; a subject is `{ kind, id? }`: `entry` (id), `account` (code or id), -`asset` (tag or id), `party` (id or name), `invoice`, `bill`, `period`, +`asset` (tag or id), `party` (id or name), `project` (id or name: its +contract, spec, pitch; journal feature `projects`), `invoice`, `bill`, `period`, `payroll_run`, `schedule`, `reconciliation` (ids), or `company` (no id). Every subject is checked to exist. In outputs a subject is `{ kind, id?, label }` (`id` absent for the company; for an account, its id, with the @@ -1504,7 +1518,9 @@ the receipt policy counts it. - `link_to: [subject]` on `books.attach_receipt` and `books.file_document` links the new file in the same call, each link its own `attachment.linked`. All or nothing: every subject is checked (exists, - named once, not the entry it is filed on) before anything is filed. + named once, not the entry it is filed on, its journal feature declared: + a `project` needs `projects`) before anything is filed, so a refusal + leaves no file filed without its links. ## Tools — receipts & documents @@ -2019,13 +2035,90 @@ entries. - `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count, total_gross, entry_id }] }`. -## Tools — classes (divisional tracking) +## Tools — projects (classes) -- `books.add_class { name }` → `{ id }`. E.g. "Rondo", "Coach vs Coach". -- `books.list_classes` → `{ classes: [{ id, name }] }`. -- `books.add_line` gains optional `class` (name or id): tags the new line. - Shared costs are split explicitly across lines at entry time. -- `books.profit_and_loss` gains optional `class` filter. +A project is a class with its facts (`docs/spec/rules.md` B-37): the +same id, the same name, so `books.add_line`'s `class`, the P&L `class` +filter and `books.profit_and_loss_by_class` take projects. A `project` +reference is an id or a name (ignoring capitals in any script and +surrounding space; in a link's subject a JSON number is always an id); a +number that is one project's id and another's name, which only old books +can hold, is refused, naming what to pass instead. Names are unique, +ignoring capitals and surrounding space, and never digits only (they +would read as an id). A change that keeps a project's own name never +checks it again, so old books holding a duplicate name can change either +project's facts; a rename onto another project's name is refused. Every project write but a name alone needs the journal feature +`projects` (B-27): books waiting for their format upgrade (D-39) can add +a project by name only, and every other project write (facts, a link to +a project, a decision naming one) is refused with `format_upgrade_pending` +until the owner upgrades them. + +- `books.add_project { name, description?, status?, kind?, party?, + started_on?, ended_on?, links?, notes? }` (books:write) → the project: `{ id, + name, status, kind, description, party: { party_id, label }?, + started_on, ended_on, links, notes }`. `status`: `active` (default), + `paused`, `finished`, `abandoned`. `kind`: `internal` (the company's own + product), `client` (work for a customer), `on_spec` (work done hoping + to be paid), `other`; null when not recorded. `party`: who it is for or + with (id or name). `ended_on` only when finished or abandoned, never + before `started_on`. `links`: up to 8 web addresses, one line each, no + spaces, none twice (never opened). Text fields: one line (name, links, + at most 200 characters) or long text (description, notes, at most + 2,000), never a full TIN. A name alone journals `class.added`; facts add + `project.set` (the whole record). +- `books.update_project { project, name?, description?, status?, kind?, + party?, started_on?, ended_on?, links?, notes? }` (books:write; + idempotentHint) → the + project. Only the fields given change; `""` clears description, kind, + party, started_on, ended_on or notes; `links` replaces the list (`[]` + clears it). At least one field. Every field is checked before anything + is written; then the whole record is journaled as `project.set`, and a + record with nothing new journals nothing. A rename changes the label + every report prints, nothing else (not destructive, as a party's name). +- `books.projects { status? }` (books:read) → `{ through, projects: [{ ...project, + to_date: { through, revenue, expenses, net, unrecovered_cost } }] }`: every + project (every class), by name ignoring capitals; `to_date` counts every + posted revenue and expense line tagged to it through today in the + company's time zone, closing entries left out; `unrecovered_cost` only + for an on-spec project (else null): expenses less revenue, never below + zero. +- `books.project { project, start?, end? }` (books:read; omitted dates: the fiscal + year to date, B-30, as every period report) → `{ project, period: { + start, end, revenue, expenses, net, by_account: [{ code, name, type + ("revenue" | "expense"), amount }], by_month: [{ month ("YYYY-MM"), + start, end, revenue, expenses, net }] }, to_date (as above, through + end), entries: [{ entry_id, date, description, status, project_amount, + total, reversal_of?, receipts: [attachment_id] }], documents: [as + books.list_receipts], decisions: [as books.decisions] }`. The figures + are `ledger_core::project_figures`'s, the viewer's: only revenue and + expense lines count (a tagged asset purchase is listed with its entries, + its project amount zero); `by_month` holds the period's last 240 months + at most. `to_date` always counts every tagged line through `end`, + whatever the project's dates: job-to-date beside the period. The on-spec + `unrecovered_cost` is the "if repaid" figure, never a receivable (ASC + 450-30-25-1). Entries are newest first, drafts included; decisions + oldest first, superseded ones too. +- `books.add_class { name }` → `{ id }`: a project with only its name; + refuses a name another project has. +- `books.list_classes` → `{ classes: [{ id, name }] }` in id order. +- `books.add_line` takes optional `class` (a project: name or id): tags the + new line. Shared costs are split explicitly across lines at entry time. + Its result gains `note` when the project is finished or abandoned + ("project X is finished (ended 2026-09-30): tag a late cost or invoice + to it only if it belongs to it"): tagged anyway. +- `books.profit_and_loss` takes optional `class` (a project: name or id) + to filter to. +- `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). - `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/spec/rules.md b/docs/spec/rules.md index e43722a..39258bf 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1374,7 +1374,16 @@ Treas. Reg. §301.6109-4(a)); no party field may hold a nine-digit number, and the full TIN lives only in the encrypted vault, outside the journal (DECISION). W-9 facts need a 1099 role (contractor, landlord); W-4 facts need the employee role; payroll lines that name their employee name a -party with that role. +party with that role. A project's text (its name, description, links and +notes: B-37) holds no full TIN either (`settings::line_text`, +`long_text`). A link is a web address, so its rule reads it as one +(`settings::holds_printed_tin_in_link`, DECISION of this rule, so store +listings can be kept): the printed SSN (3-2-4) and EIN (2-7) forms and a +bare nine digits standing alone are refused, but nine digits joined to a +letter or to `/ = ? & #` are an id in a path or query (an app store +listing's `.../id284882215`) and are kept. A project's name keeps the +plain rule: a nine-digit job or PO number is written by its last four, +as everywhere else in the books. The full TIN, when the books need it for the 1099 worksheet, lives in the TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key @@ -1570,9 +1579,13 @@ B-16; and a `settings.set` naming `books.schedule_convention`, a key an earlier version refuses), `month_reposting` (a schedule month or a depreciation period posted again after a reversal un-posted it; an older version would refuse the second `schedule.posted`, and its projection ignore the second -`asset.depreciation_posted`, B-15) and `asset_basis` (an +`asset.depreciation_posted`, B-15), `asset_basis` (an `asset.basis_set`, a new event type: an asset's basis facts with the -value at contribution and the §704(c) method, B-25). A command asks for +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 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 @@ -2525,3 +2538,78 @@ package). **Checked:** `ledger-core` `tests/tax_capital.rs`; (`the_k1_worksheet_reports_tax_basis_capital`), `tests/p3_seams.rs` (`depreciation_is_one_figure_everywhere`, `the_cpa_package_as_of_each_date_is_the_tools_figures`). + +## B-37 A project is a class with its facts; its figures are its tagged lines + +**Status:** DECISION (the owner, 2026-10-21: "a Projects page that treats +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); AUTHORITY (on +spec, item 4: ASC 450-30-25-1; ASC 340-40-25-5 with B-22; +`sources/fasb-asc.md`, both quoted through Deloitte's DART roadmaps and +not yet re-read in the ASC basic view: an open item) + +1. **A project is a class.** Same id and name; every class is one (a + class an earlier version wrote has the default facts: active, kind not + recorded). Its facts, the few a job cost sheet carries: a + description (its scope of work), a status (`active`, `paused`, + `finished`, `abandoned`), a kind (`internal`: the company's own + product; `client`: work for a customer; `on_spec`: work done hoping to + be paid; `other`; or not recorded), a party (who it is for or with), + the dates it started and ended (an end only when finished or + abandoned, never before the start; informational: no figure filters by + them), up to eight links (web addresses, never opened) and notes. Names + are one line, unique ignoring capitals (in any script) and surrounding + space, and never digits only (a project is named by id or name, so such + a name would read as an id); no field holds a full TIN (B-26). A + change that keeps a project's own name never checks the name again, so + old books (replay never checks names) holding a duplicate can still + change either project, and a fact-only change never renames. Journaled as `class.added`, then the whole + record as `project.set` when there is more than a name (journal feature + `projects`, B-27). Replay checks only what it can't do without (the + class and the party exist), never the text rules, so a rule tightened + later never breaks books already kept. No budget, estimate, contract + value or percent complete: each is a document linked to the project, a + revenue-recognition question the books don't face, or the CPA's. +2. **Links.** A line is tagged with a project (its class); a receipt or + document is linked to a project (attachment subject `project`); a + decision names the projects it is about. Whether to link is the + company's convention; how is this rule. +3. **Figures (job costing).** For a period: the revenue and expense lines + tagged to it, dated in the period, closing entries left out (B-11), + each on its normal side; net is revenue less expenses. To date: every + 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 + 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 + contract an asset only if, among its criteria, it is expected to be + recovered, which on spec it isn't). The hoped-for payment is a gain + contingency: no receivable and no income until received (ASC + 450-30-25-1). A repayment is revenue tagged to the project (a + reimbursement credited to the expense it repays, tagged, comes to the + 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. +6. Omitted dates mean what B-30 says (`books.project`'s period); to date + is always stated as such, beside it. + +**Enforced:** `ledger_core::classes::{ProjectFacts, ProjectStatus, +ProjectKind}`, `Ledger::{check_set_project, set_project, +replay_set_project, projects_of_party}`, `reports::{project_figures, +project_to_date, project_entries}`, `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`; +proptests `prop_project_nets_partition_the_pnl`, +`prop_project_set_round_trips`. diff --git a/docs/spec/sources/fasb-asc.md b/docs/spec/sources/fasb-asc.md index f4d461f..f46641f 100644 --- a/docs/spec/sources/fasb-asc.md +++ b/docs/spec/sources/fasb-asc.md @@ -249,3 +249,28 @@ consideration" it expects (55-37B); an agent recognizes only its fee or commission (55-38). → B-17: an app seller is the principal for its app, so its revenue is the gross price; the platform's commission is an expense. + +## 450-30-25-1 (Gain contingencies) + +Added 2026-10-21 for B-37 from the paragraph's text as Deloitte's DART +roadmaps quote it (originally FAS 5 ¶17(a)); not re-read in the ASC +basic view: check it there at the next reading. A contingency that might +result in a gain "usually should not be reflected in the financial +statements because to do so might be to recognize revenue before its +realization." +→ B-37 item 4: a payment hoped for from work done on spec is recorded +neither as a receivable nor as income until it is received; the +unrecovered cost is a figure, never an asset. + +## 340-40-25-5 (Costs to fulfill a contract) + +Added 2026-10-21 for B-37 as the DART revenue roadmap sets it out; not +re-read in the ASC basic view: check it there at the next reading. Costs +incurred to fulfill a contract (one not in another Topic's scope) are an +asset only if all three criteria are met: they relate directly to a +contract or an anticipated contract the entity can specifically +identify; they generate or enhance resources used in satisfying +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. diff --git a/docs/testing.md b/docs/testing.md index 998e3d9..e79267d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,7 +23,7 @@ promise. | Model-based stateful | the Lean state machine, transcribed (3) | `tests/model.rs` | `just check` | ~3s | every change | | Differential: Decimal vs i64 | the kernel at two types (3) | `kernel_bridge.rs` | `just check` | <1s | every change | | Differential: GnuCash | another implementation, adjudicated (3) | `tests/oracle_gnucash.rs`, `oracle/gnucash/` | `just oracle` | ~2 min | weekly | -| Formal proofs | Kani (bounded) and Lean (unbounded) | `src/proofs.rs`, `verify/lean/` | `just prove` (47 harnesses and Lean), `just prove-slow` (the 8 in the justfile's `kani_slow`, each under its own 30-minute cap) | prove ~5 min and prove-slow ~8 min at four jobs on a quiet laptop (each ~25 min under heavy load; docs/verification.md, "Running it") | weekly (CI runs both) | +| Formal proofs | Kani (bounded) and Lean (unbounded) | `src/proofs.rs`, `verify/lean/` | `just prove` (48 harnesses and Lean), `just prove-slow` (the 8 in the justfile's `kani_slow`, each under its own 30-minute cap) | prove ~5 min and prove-slow ~8 min at four jobs on a quiet laptop (each ~25 min under heavy load; docs/verification.md, "Running it") | weekly (CI runs both) | | Fuzzing | crash, hang, broken books (4); for `fuzz_store_replay` (validly chained journals with fuzz-chosen events): books that open must verify, and the viewer must accept exactly what the writer does | `fuzz/fuzz_targets/`, corpora in `fuzz/corpus/` | `just fuzz ` | 10 min each | weekly | | Miri | undefined behavior in our use of dependencies (4) | the core's unit, bridge, invariant tests (the exhaustive calendar walk aside) | `just miri` | ~4 min | on demand | | Golden | `tests/golden/fixture_company.json` (5) | `invariants.rs` | `just check` | <1s | every change | @@ -1755,6 +1755,145 @@ before); the bookspec loader's exact-spelling filters and `check_k1`'s The earlier P3 sections' lists stand (one name corrected: `Ledger::depreciation_runs`, which was `depreciation_runs_ever`). +## P3: projects (2026-10-21) + +The owner's ask: "a Projects page that treats a class as a project ... +Projects are entities too", a company rule to link things to projects +(the convention `link-to-projects`), and the sidebar's links drawn under +the status box in a short window (rules.md B-37). Mutation runs wait for +P12 (D-33); none was run. + +- **ledger-core:** `tests/spec_projects.rs` + (`project_facts_default_for_a_bare_class`, + `set_project_checks_every_field`, + `set_project_renames_the_class_and_moves_no_figure`, + `add_class_refuses_a_duplicate_name`, + `replay_set_project_takes_what_the_text_rules_would_refuse`, + `project_figures_are_its_tagged_lines`, + `project_to_date_ignores_the_start_and_the_projects_dates`, + `a_tagged_balance_sheet_line_counts_nothing`, + `closing_entries_are_left_out_of_project_figures`, + `a_reversal_nets_a_project_to_zero`, + `unrecovered_cost_only_for_on_spec_and_never_negative`, + `projects_of_party_lists_them_by_name`, + `check_decision_refuses_an_unknown_or_repeated_project`, + `differs_from_names_projects`, + `names_compare_one_way_for_the_duplicate_check_and_the_lookup`, + `old_books_duplicates_resolve_to_the_lowest_id_and_change_freely`, + `an_unchanged_name_is_kept_exactly_and_not_checked_again`, + `a_name_of_digits_only_is_refused`, + `a_link_is_read_as_a_web_address_for_a_full_tin`); `properties.rs` + `prop_project_nets_partition_the_pnl` (random tags on both lines of + random entries, a random period: the projects plus the untagged lines + are the P&L, and each project's months sum to its period) and + `prop_project_entries_sum_to_the_net_to_date` (project_entries' own + signs: the posted entries' project amounts are the net to date, with + refunds, reimbursements, tagged cash lines and drafts beside them); + Kani `unrecovered_cost_laws` (`src/proofs.rs`, docs/verification.md law + 32, run by `just prove`: never above + max(expenses, 0) when revenue isn't negative; the earlier "never above + expenses" fails it, checked). +- **ledger-store:** `tests/projects.rs` + (`add_project_journals_class_added_then_project_set`, + `a_name_only_project_journals_class_added_alone`, + `project_writes_wait_for_the_upgrade_and_journal_nothing`, + `update_with_nothing_new_journals_nothing`, + `project_set_replays_verifies_and_reloads`, + `an_unknown_status_or_kind_is_newer_a_malformed_record_corrupt`, + `a_decision_without_projects_writes_the_bytes_it_always_did`, + `attachments_link_to_a_project_and_unlink`, + `old_books_open_to_their_figures_with_projects_known` (each fixture's + report snapshot and verify against its expected.json), + `format_2_books_without_the_feature_wait_for_it_alone` (the live + books after install: format 2, every earlier feature, not `projects`), + `old_books_with_a_duplicate_name_change_either_project`, + `a_fact_only_change_never_renames_an_untrimmed_old_name`, and the proptest + `prop_project_set_round_trips`); `tests/format.rs` + `p3_features_wait_for_the_upgrade_and_are_declared_once` gains + `projects` and a `project.set`. +- **ledger-mcp:** `tests/spec_projects.rs` + (`add_project_then_project_returns_its_facts_and_figures`, + `a_name_only_project_journals_only_class_added`, + `update_project_checks_every_field_before_writing`, + `update_project_with_nothing_new_journals_nothing`, + `project_figures_match_profit_and_loss_by_class`, + `project_to_date_counts_lines_before_the_period`, + `on_spec_project_shows_unrecovered_cost_and_others_null`, + `project_lists_entries_documents_decisions_and_party`, + `link_attachment_to_a_project_by_name_and_id`, + `file_document_with_a_bad_project_files_nothing`, + `record_decision_names_projects_and_decisions_filter_by_project`, + `party_lists_its_projects`, `start_lists_active_and_paused_projects`, + `show_project_sends_its_target`, + `project_writes_wait_for_the_format_upgrade`, + `add_line_notes_a_finished_project`, + `record_payout_notes_a_finished_project`, + `a_name_resolves_as_the_duplicate_check_compares`, + `old_books_duplicates_take_fact_only_changes`, + `a_digit_name_that_shadows_an_id_says_what_to_pass`); `hostile_args.rs` + `a_project_over_the_whole_calendar_answers_quickly` and + `books.projects` among the read-only calls; `spec_vault.rs`'s idempotent + list gains `books.update_project`; the unit tables (`annotations_complete`, + scopes' table) list 119 tools, `period_tools_describe_the_one_default` + checks the period tools' wording, and `mcp_stdio.rs`'s `tools/list` + shows 116; `every_tool_is_documented_and_every_documented_tool_exists` + holds with the four new tools (on the rebase onto the surface review it + caught `books.list_classes` written mid-line and a `books.set_line_class` + the doc named, which is no tool). `ledger-store` `tests/format.rs` + `the_replay_fuzzer_writes_every_event_type` caught `project.set` + missing from `fuzz_store_replay`'s types; it and the project fields are + in them now. +- **ledger-gui:** `tests/projects.rs` + (`projects_page_lists_every_project_alphabetically`, + `project_figures_are_ledger_cores`, `unrecovered_cost_shows_only_on_spec`, + `a_project_row_opens_the_project`, `an_entry_row_opens_the_entry`, + `a_document_opens_the_document`, `the_party_link_opens_the_party`, + `profit_by_project_bar_opens_the_project`, + `party_page_lists_its_projects`, `entry_line_project_opens_the_project`, + `journal_describes_project_set`, `show_project_target_opens_its_page`, + `a_long_link_stays_in_its_card` (200 characters, 1440 and 900 wide), + `a_long_name_keeps_the_page_in_the_window` (with spaces and without), + `a_long_party_name_keeps_to_its_card`, + `a_month_bar_opens_nothing_and_margin_keeps_one_place`, + `an_entry_without_receipts_says_nothing_of_them`; with `SHOT_DIR` set + the edge pages are saved as PNGs); `charts.rs` + `a_crowded_chart_keeps_its_years` and + `a_view_only_chart_shows_figures_and_takes_no_click`; + `old_books.rs` `old_books_classes_read_as_projects` and every project + page over the old books; `read_only.rs` renders the Projects list and + every project page over read-only books (what the brief called + `visiting_project_pages_changes_nothing`); `breadcrumbs.rs` a project's + crumbs. The sidebar: `edges.rs` + `sidebar_links_never_draw_under_the_status_box` (1440x500, scrolled to + the end: Journal ends above the status box), + `rail_links_never_draw_under_its_footer` (900x420) and + `a_normal_window_shows_every_link_unscrolled` (1440x920); both short + ones fail on the code before the fix (checked by putting it back); + `a_very_short_window_keeps_one_link` (1440x220, 900x200: a link stays + whole above the footer, which gives way off the bottom). The + sidebar links, painted table rows and bars now carry accessible names, + which the tests find them by. Shots before and after: + `~/.cache/wanderbooks/shots-p3-projects/` (`before-*`, `after-*`, + `final-*`, and the project pages light, dark, 1100 and 900 wide). +- **bookspec:** `setup.projects`, a line's `project`, and the query + `project`; examples `projects-net-is-its-tagged-lines` and + `projects-on-spec-costs-are-expenses`; refused: + `a_project_named_twice_is_refused`, + `a_line_naming_an_unknown_project_is_refused`, + `a_project_query_leaving_a_value_out_is_refused`. + +**For P12 (mutation targets):** `check_set_project`'s arms (ended_on with +active/paused, `<` vs `<=` on the dates, the duplicate name's +self-exception, case folding) and `ProjectFacts::check` (the link limit, +whitespace, duplicates); `kernel::unrecovered_cost` (`max`, the sign); +`project_figures`' class filter, its 240-month window and the +closing-entry exclusion; `project_entries`' normal-side sign and the +balance-sheet zero; `project_set_of` (newer vs corrupt); +`Store::add_project`'s "facts beyond the default" test (one line vs two); +`set_project`'s unchanged short-cut; `subject_feature`; `check_link_to`'s +feature check; the decision `projects` omit-when-empty; +`late_tag_note`'s `is_over`; `remember_footer`'s threshold. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/docs/verification.md b/docs/verification.md index 489b7ed..3ff8029 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -450,6 +450,21 @@ one kernel function, `kernel::basis_figures`, for every rule: (the `Decimal` instantiation agrees, rule by rule) · property `prop_basis_figures_laws` +### A project's unrecovered cost (P3, projects) + +The "if repaid" figure of work done on spec (rules.md B-37 item 4) is +one kernel function, `kernel::unrecovered_cost`: + +32. **Never below zero, the shortfall exactly, and no overflow**: the + result is at least zero; it is expenses less revenue when expenses + are at least revenue, and zero otherwise; with revenue not negative + it is never above the expenses or zero, whichever is larger + (expenses can be negative: a reimbursement credited to the expense + above its cost). · Kani `unrecovered_cost_laws` (every + revenue and expense a line could carry) · ledger-core + `unrecovered_cost_only_for_on_spec_and_never_negative`. No Lean model: + the function is one subtraction and a `max`. + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, @@ -606,7 +621,7 @@ Lean carrying any weights. ```sh mise install # once: Kani, Lean, and the rest, pinned in mise.toml -just prove # 47 Kani harnesses and Lean +just prove # 48 Kani harnesses and Lean just prove-slow # the other 8: the justfile's kani_slow just prove-all # both ``` @@ -705,4 +720,5 @@ each harness's whole run, its compile of about 50 s included): | `catch_ups_is_every_suffixs_catch_up` | 29 | prove | 35 | | `true_up_reaches_the_target` | 30 | prove | 0 | | `basis_figures_laws` | 31 | prove | 0 | +| `unrecovered_cost_laws` | 32 | prove | 0 | | `kani_symbolic_copy_canary` | (the checker: "Trusting the checker") | prove | 3 | diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index 46984ae..94878f9 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -40,6 +40,7 @@ const TYPES: &[&str] = &[ "entry.party_set", "party.added", "party.role_set", "party.tin_set", "party.updated", "party.w4_set", "party.w9_set", "payroll.taxes_remitted", "reconciliation.completed", "reconciliation.voided", "role.assigned", "statement.recorded", "statement.voided", + "project.set", ]; const FIELDS: &[&str] = &[ @@ -59,6 +60,7 @@ const FIELDS: &[&str] = &[ "reason_kind", "effective", "instrument_id", "label", "last4", "owner_party_id", "rule", "contributor_adjusted_basis", "fmv_at_contribution", "sec704c_method", "tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention", + "status", "party_id", "started_on", "ended_on", "links", "notes", "projects", ]; /// A value replay might plausibly see: small ids, amounts as decimal