From 3eda95eb3cf057a61d5b955e514c60509895f279 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 22 Oct 2026 04:00:17 -0600 Subject: [PATCH] Projects: the snapshot of a project's figures The project page opens on a snapshot: revenue, direct costs, the contribution before overhead and its margin of revenue (a segment margin: revenue less every cost traced to the project, fixed and variable; B-37 item 7), in the black or red, and for on-spec work the cost unrecovered if repaid. To date or this fiscal year. By month, revenue and costs with the net to date in its own strip; costs by category; the list colors each net by its sign. books.project carries the same figures, and a parity test runs the viewer and the MCP on the same books to the cent. Tests honor TMPDIR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- .cargo/config.toml | 7 - Cargo.lock | 1 + crates/ledger-cli/Cargo.toml | 1 + crates/ledger-cli/tests/local_server.rs | 11 +- crates/ledger-cli/tests/projects_parity.rs | 259 +++++++ crates/ledger-core/src/lib.rs | 6 +- crates/ledger-core/src/reports.rs | 148 +++- crates/ledger-core/tests/properties.rs | 89 +++ crates/ledger-core/tests/spec_projects.rs | 81 +++ crates/ledger-gui/src/charts.rs | 132 +++- crates/ledger-gui/src/demo.rs | 8 +- crates/ledger-gui/src/pages/projects.rs | 667 +++++++++++++---- crates/ledger-gui/tests/exports.rs | 40 +- .../tests/projects.proptest-regressions | 7 + crates/ledger-gui/tests/projects.rs | 688 +++++++++++++++++- crates/ledger-gui/tests/screens.rs | 2 + crates/ledger-mcp/src/tools/projects.rs | 51 +- crates/ledger-mcp/src/types.rs | 22 +- crates/ledger-mcp/tests/spec_projects.rs | 83 +++ docs/mcp-interface.md | 22 +- docs/spec/rules.md | 35 +- docs/testing.md | 111 +++ justfile | 6 +- 23 files changed, 2231 insertions(+), 246 deletions(-) delete mode 100644 .cargo/config.toml create mode 100644 crates/ledger-cli/tests/projects_parity.rs create mode 100644 crates/ledger-gui/tests/projects.proptest-regressions diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 45f5bff..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,7 +0,0 @@ -# Test books (a few KB each, every journal append synced) go in the system -# temp dir, /tmp, even when TMPDIR points elsewhere: `just mutants*` sets -# TMPDIR to ~/.cache for cargo-mutants' multi-GB tree copies, and cargo -# passes [env] to the tests it runs, not to cargo-mutants itself. On disk -# the synced appends made each mutant's tests several times slower. -[env] -TMPDIR = { value = "/tmp", force = true } diff --git a/Cargo.lock b/Cargo.lock index 4523853..9d4f4dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3841,6 +3841,7 @@ dependencies = [ "libc", "reqwest", "rmcp", + "rust_decimal", "serde_json", "sha2 0.10.9", "tempfile", diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index f320002..8770d8d 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -27,6 +27,7 @@ base64 = "0.22" chrono = "0.4" libc = "0.2" reqwest = "0.13" +rust_decimal = "1" rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } ledger-core = { path = "../ledger-core" } diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index c0f0b1f..4dc7970 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -569,10 +569,17 @@ fn the_admin_socket_refuses_bad_requests_plainly() { assert!(server.close().success()); } -/// Send `line` to the admin socket as is; the answer's text. +/// Send `line` to the admin socket as is; the answer's text. Connects +/// through `SocketPath`, as the server binds: under a long TMPDIR the +/// socket's path is longer than a socket address holds (SUN_LEN). fn raw_admin(sock: &Path, line: &str) -> String { use std::io::{Read, Write}; - let mut s = std::os::unix::net::UnixStream::connect(sock).unwrap(); + let short = ledger_store::root::SocketPath::new( + sock.parent().unwrap(), + sock.file_name().unwrap().to_str().unwrap(), + ) + .unwrap(); + let mut s = std::os::unix::net::UnixStream::connect(short.path()).unwrap(); s.write_all(line.as_bytes()).unwrap(); s.shutdown(std::net::Shutdown::Write).unwrap(); let mut out = String::new(); diff --git a/crates/ledger-cli/tests/projects_parity.rs b/crates/ledger-cli/tests/projects_parity.rs new file mode 100644 index 0000000..eb31361 --- /dev/null +++ b/crates/ledger-cli/tests/projects_parity.rs @@ -0,0 +1,259 @@ +//! A project's snapshot (rules.md B-37 item 7) in the viewer and from the +//! books server, on the same books: `books.project`'s strings are the +//! figures the viewer's page draws (its `PageFigures` and `Snap`), to the +//! cent, to date, by account, by month with the running net, and for the +//! fiscal year. Both read ledger-core's `project_figures`; this checks +//! that neither side rearranges them. +#![cfg(feature = "gui")] + +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, Money, ProjectFacts, ProjectKind, Side}; +use ledger_gui::pages::projects::{Snap, page_figures}; +use ledger_gui::{Source, data}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn usd(c: i64) -> Money { + Money::new(Decimal::new(c, 2), Currency::USD) +} + +/// A decimal as the tools print an amount: two places. +fn s(v: Decimal) -> String { + format!("{v:.2}") +} + +/// The books server over stdio (`wanderbooks dev-stdio`), one request at +/// a time. +struct Server { + child: std::process::Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + id: u64, +} + +impl Server { + fn start(data: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .arg("dev-stdio") + .arg("--data-dir") + .arg(data) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn wanderbooks dev-stdio"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut s = Server { + child, + stdin, + stdout, + id: 0, + }; + s.ask( + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "projects-parity", "version": "0"}}), + ); + writeln!( + s.stdin, + "{}", + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}) + ) + .unwrap(); + s + } + + fn ask(&mut self, method: &str, params: Value) -> Value { + self.id += 1; + let msg = json!({"jsonrpc": "2.0", "id": self.id, "method": method, "params": params}); + writeln!(self.stdin, "{msg}").unwrap(); + self.stdin.flush().unwrap(); + let mut line = String::new(); + self.stdout.read_line(&mut line).expect("a response"); + let resp: Value = serde_json::from_str(&line).expect("JSON"); + assert_eq!(resp["id"], self.id, "{resp}"); + resp + } + + /// A tool's structured result. + fn tool(&mut self, name: &str, args: Value) -> Value { + let r = self.ask("tools/call", json!({"name": name, "arguments": args})); + let out = &r["result"]; + assert_ne!(out["isError"], true, "{name}: {r}"); + out["structuredContent"].clone() + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() { + let dir = tempfile::Builder::new() + .prefix("wb-projects-parity-") + .tempdir() + .unwrap(); + let data = dir.path().join("books-data"); + std::fs::write( + dir.path().join("wanderbooks.toml"), + "[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", + ) + .unwrap(); + let today = d(2026, 10, 15); + let id = { + let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Cash", AccountType::Asset), + ("4000", "Design Services", AccountType::Revenue), + ("6000", "Contractors", AccountType::Expense), + ("6100", "Software", AccountType::Expense), + ("6200", "Rent", AccountType::Expense), + ] { + st.add_account(code, name, ty, None, false).unwrap(); + } + let id = st + .add_project( + "Kestrel", + &ProjectFacts { + kind: Some(ProjectKind::OnSpec), + started_on: Some(d(2025, 11, 1)), + ..Default::default() + }, + ) + .unwrap(); + let code = + |st: &ledger_store::Store, c: &str| st.ledger().chart().find_by_code(c).unwrap().id; + let mut posted = Vec::new(); + // (date, debit, credit, cents, tagged): last year's sale, this + // year's costs (one reversed), a refund, and rent (overhead). + for (date, dr, cr, c, tagged) in [ + (d(2025, 11, 3), "1000", "4000", 100_000, true), + (d(2026, 2, 9), "6000", "1000", 40_000, true), + (d(2026, 3, 9), "6100", "1000", 12_345, true), + (d(2026, 5, 1), "4000", "1000", 10_000, true), + (d(2026, 6, 30), "6200", "1000", 150_000, false), + (d(2026, 8, 4), "6000", "1000", 22_222, true), + ] { + let e = st.create_entry(date, "Line").unwrap(); + let (a, b) = (code(&st, dr), code(&st, cr)); + st.add_line(e, a, Side::Debit, usd(c)).unwrap(); + st.add_line(e, b, Side::Credit, usd(c)).unwrap(); + if tagged { + st.set_line_class(e, if dr == "1000" { 1 } else { 0 }, Some(id)) + .unwrap(); + } + st.post_entry(e).unwrap(); + posted.push(e); + } + // The software, reversed in September. + st.reverse_entry(posted[2], d(2026, 9, 2), "Reverse software") + .unwrap(); + id + }; + let b = data::load_once(&Source { + data_dir: data.clone(), + today: Some(today), + }) + .unwrap(); + let mut server = Server::start(&data); + + // To date, and by month over the viewer's chart span. + let page = page_figures(&b, id, Some(d(2025, 11, 1)), false); + let t = page.to_date.as_ref().unwrap(); + let snap = Snap::to_date(t); + let ((start, end), months) = page.months.as_ref().unwrap(); + assert_eq!((*start, *end), (d(2025, 11, 1), today)); + let p = server.tool( + "books.project", + json!({"project": "Kestrel", "start": start.to_string(), "end": end.to_string()}), + ); + let to = &p["to_date"]; + assert_eq!(to["revenue"], s(snap.revenue)); + assert_eq!(to["expenses"], s(snap.expenses)); + assert_eq!(to["net"], s(snap.contribution)); + assert_eq!( + to["margin_pct"], + snap.margin_pct + .map_or(Value::Null, |m| json!(m.to_string())) + ); + assert_eq!( + to["unrecovered_cost"], + t.unrecovered_cost.map_or(Value::Null, |u| json!(s(u))) + ); + // The figures themselves, so a slip on both sides can't pass: $900 + // earned net of the refund, $622.22 of contractors (the software + // reversed), $277.78, 30.9% of revenue. + assert_eq!( + (to["net"].as_str(), to["margin_pct"].as_str()), + (Some("277.78"), Some("30.9")) + ); + let accounts: Vec<(String, String, String)> = t + .revenue_by_account + .iter() + .map(|l| (l.code.clone(), "revenue".to_string(), s(l.amount))) + .chain( + t.expenses_by_account + .iter() + .map(|l| (l.code.clone(), "expense".to_string(), s(l.amount))), + ) + .collect(); + let served: Vec<(String, String, String)> = to["by_account"] + .as_array() + .unwrap() + .iter() + .map(|a| { + ( + a["code"].as_str().unwrap().to_string(), + a["type"].as_str().unwrap().to_string(), + a["amount"].as_str().unwrap().to_string(), + ) + }) + .collect(); + assert_eq!(served, accounts); + let by_month = p["period"]["by_month"].as_array().unwrap(); + assert_eq!(by_month.len(), months.months.len()); + for ((m, ours), net) in by_month + .iter() + .zip(&months.months) + .zip(&months.months_net_to_date) + { + assert_eq!(m["month"], ours.start.format("%Y-%m").to_string()); + assert_eq!(m["revenue"], s(ours.revenue), "{m}"); + assert_eq!(m["expenses"], s(ours.expenses), "{m}"); + assert_eq!(m["net_to_date"], s(*net), "{m}"); + } + assert_eq!(by_month.last().unwrap()["net_to_date"], to["net"]); + + // This fiscal year, the snapshot's other view: books.project's + // default period. + let page = page_figures(&b, id, Some(d(2025, 11, 1)), true); + let y = page.year.as_ref().unwrap(); + let fy = Snap::period(y); + let (fs, fe) = b.periods().statement(None, None).unwrap(); + let p = server.tool( + "books.project", + json!({"project": "Kestrel", "start": fs.to_string(), "end": fe.to_string()}), + ); + let period = &p["period"]; + assert_eq!(period["start"], fs.to_string()); + assert_eq!(period["revenue"], s(fy.revenue)); + assert_eq!(period["expenses"], s(fy.expenses)); + assert_eq!(period["net"], s(fy.contribution)); + assert_eq!( + period["margin_pct"], + fy.margin_pct.map_or(Value::Null, |m| json!(m.to_string())) + ); + assert_eq!(period["net"], "-722.22", "a refund and costs, no sale"); +} diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 94abfe7..20b29fd 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -89,9 +89,9 @@ pub use reports::{ AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, MemberEquityColumn, 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, + StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, contribution_margin_pct, + 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/reports.rs b/crates/ledger-core/src/reports.rs index daf89b3..01c96cd 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -328,7 +328,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result { } /// One account's line on a financial statement. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct StatementLine { /// Account code, e.g. `"6000"`. pub code: String, @@ -470,6 +470,18 @@ pub fn profit_and_loss_for_period( end: NaiveDate, class_id: Option, ) -> Result { + Ok(profit_and_loss_counted(ledger, start, end, class_id)?.0) +} + +/// [`profit_and_loss_for_period`], and with a class how many revenue and +/// expense lines it counted, (revenue, expense): lines netting to zero +/// leave no statement line but are counted (with no class: (0, 0)). +fn profit_and_loss_counted( + ledger: &Ledger, + start: NaiveDate, + end: NaiveDate, + class_id: Option, +) -> Result<(ProfitAndLossStatement, (usize, usize)), LedgerError> { if end < start { return Err(LedgerError::InvalidDateRange { start: start.to_string(), @@ -477,11 +489,21 @@ pub fn profit_and_loss_for_period( }); } let closings = ledger.closing_entry_ids(); + let counts = std::cell::Cell::new((0usize, 0usize)); let lines = statement_lines(ledger, &|entry, line| { - entry.date() >= start + let counted = entry.date() >= start && entry.date() <= end && !closings.contains(&entry.id()) - && class_id.is_none_or(|c| line.class_id == Some(c)) + && class_id.is_none_or(|c| line.class_id == Some(c)); + if counted && class_id.is_some() { + let (r, x) = counts.get(); + match ledger.chart().get(line.account).map(|a| a.account_type) { + Some(AccountType::Revenue) => counts.set((r + 1, x)), + Some(AccountType::Expense) => counts.set((r, x + 1)), + _ => {} + } + } + counted })?; let mut revenue = Vec::new(); let mut expenses = Vec::new(); @@ -494,14 +516,17 @@ pub fn profit_and_loss_for_period( } let total_revenue = revenue.iter().map(|l| l.amount).sum(); let total_expenses = expenses.iter().map(|l| l.amount).sum(); - Ok(ProfitAndLossStatement { - start, - end, - revenue, - expenses, - total_revenue, - total_expenses, - }) + Ok(( + ProfitAndLossStatement { + start, + end, + revenue, + expenses, + total_revenue, + total_expenses, + }, + counts.get(), + )) } /// A project's figures (rules.md B-37 item 3): the period's statement and @@ -512,10 +537,27 @@ pub struct ProjectFigures { pub period: ProfitAndLossStatement, /// The period by calendar month: its last [`PROJECT_MONTHS`] at most. pub months: Vec, + /// Each month's net to date: every tagged line from the first day + /// the books keep through that month's last day in the period (the + /// running net the months move toward or away from break-even; the + /// last is `to_date`'s net). One per month in `months`. + pub months_net_to_date: Vec, + /// How many posted revenue and expense lines tagged to it fall in + /// the period (closing entries left out): whether anything is tagged + /// there, even lines that net to zero (a cost and its reversal). + pub period_lines: usize, /// Every tagged line through the period's end. pub to_date: ProjectToDate, } +impl ProjectFigures { + /// The period's contribution margin: [`contribution_margin_pct`] of + /// its net and revenue. + pub fn period_margin_pct(&self) -> Option { + contribution_margin_pct(self.period.net_income(), self.period.total_revenue) + } +} + /// 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)] @@ -529,13 +571,67 @@ pub struct ProjectToDate { /// 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, + /// Its tagged revenue by account, by code. + pub revenue_by_account: Vec, + /// Its tagged expenses by account, by code: its direct costs by + /// category (an account credited above its cost, a reimbursement, + /// is negative). + pub expenses_by_account: Vec, + /// How many posted revenue lines are tagged to it through `through` + /// (closing entries left out), even ones netting to zero: an account + /// whose lines cancel out isn't in `revenue_by_account`. + pub revenue_lines: usize, + /// How many posted expense lines, likewise (a cost and its reversal, + /// or a cost reimbursed exactly, are two lines and no account). + pub expenses_lines: usize, } impl ProjectToDate { - /// Revenue less expenses. + /// Revenue less expenses: the project's contribution before overhead + /// (rules.md B-37 item 7), since only its own tagged lines count. pub fn net(&self) -> Decimal { self.revenue - self.expenses } + + /// Its contribution margin to date: [`contribution_margin_pct`] of + /// its net and revenue. + pub fn margin_pct(&self) -> Option { + contribution_margin_pct(self.net(), self.revenue) + } + + /// Whether any revenue or expense line is tagged to it, netting to + /// zero or not. + pub fn any_tagged(&self) -> bool { + self.revenue_lines + self.expenses_lines > 0 + } +} + +/// Contribution as a percent of revenue, to one place (half to even), or +/// `None` without revenue to divide by (rules.md B-37 item 7). +/// +/// ```text +/// GOAL: one margin the tools and the viewer print alike. +/// SOURCE: docs/spec/rules.md B-37 item 7 (revenue less the costs traced +/// to the project, fixed and variable alike: Garrison's segment +/// margin, as a ratio; not Horngren's variable-cost contribution +/// margin; a presentation choice, not GAAP). +/// INVARIANTS: +/// 1. None when revenue <= 0: a margin on no (or negative) revenue +/// means nothing. +/// 2. Otherwise contribution / revenue * 100, rounded to one place, +/// half to even, always printed with that place ("100.0"). +/// 3. NEVER panics: None when the quotient can't be represented. +/// ``` +pub fn contribution_margin_pct(contribution: Decimal, revenue: Decimal) -> Option { + if revenue <= Decimal::ZERO { + return None; + } + let mut x = contribution + .checked_div(revenue)? + .checked_mul(Decimal::ONE_HUNDRED)? + .round_dp_with_strategy(1, rust_decimal::RoundingStrategy::MidpointNearestEven); + x.rescale(1); + Some(x) } /// The most months [`ProjectFigures::months`] holds: the period's last. @@ -555,6 +651,9 @@ pub const PROJECT_MONTHS: u32 = 240; /// 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. +/// 4. months_net_to_date[i] is the net to date through months[i].end: +/// the net before the first month plus the months' nets so far; the +/// last equals to_date's net. /// SCENARIOS: /// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass). /// 2. IF `start` is after `end`: SHOULD refuse. @@ -568,7 +667,7 @@ pub fn project_figures( if ledger.get_class(id).is_none() { return Err(LedgerError::UnknownClass(id)); } - let period = profit_and_loss_for_period(ledger, start, end, Some(id))?; + let (period, (r, x)) = profit_and_loss_counted(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) @@ -582,9 +681,25 @@ pub fn project_figures( Some(id), )?; let to_date = project_to_date(ledger, id, end)?; + // The net before the first month: to date less the months, which run + // through `end` too (one scan fewer than a to date through the day + // before them). + let mut running = months + .iter() + .try_fold(to_date.net(), |t, m| t.checked_sub(m.net())) + .ok_or(LedgerError::AmountOutOfRange)?; + let mut months_net_to_date = Vec::with_capacity(months.len()); + for m in &months { + running = running + .checked_add(m.net()) + .ok_or(LedgerError::AmountOutOfRange)?; + months_net_to_date.push(running); + } Ok(ProjectFigures { period, months, + months_net_to_date, + period_lines: r + x, to_date, }) } @@ -601,7 +716,8 @@ pub fn project_to_date( .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 (pl, (revenue_lines, expenses_lines)) = + profit_and_loss_counted(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) @@ -614,6 +730,10 @@ pub fn project_to_date( revenue: pl.total_revenue, expenses: pl.total_expenses, unrecovered_cost, + revenue_by_account: pl.revenue, + expenses_by_account: pl.expenses, + revenue_lines, + expenses_lines, }) } diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index 0388417..e0fcf82 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -1002,6 +1002,95 @@ proptest! { prop_assert_eq!(exp_sum + untagged_exp, all.total_expenses); } + /// rules.md B-37 item 7: the snapshot's contribution is + /// project_to_date's net, revenue less direct costs, whatever the + /// tags and sides (a refund debits revenue, a reimbursement credits an + /// expense below zero): the accounts sum to the totals, each month's + /// net to date is project_to_date through its end (the last is the + /// net to date), and a margin exists exactly when revenue is above + /// zero, with the contribution's sign. + #[test] + fn prop_project_contribution_is_the_net_to_date( + entries in prop::collection::vec( + (0u32..400, 1i64..1_000_000, 0u32..4, any::(), 0u32..3, 0u32..3), + 0..20, + ), + from in 0u32..400, + len in 0u32..400, + ) { + 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 soft = l.add_account("6100", "Software", 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, soft]; + // Each tagged revenue or expense line: (date, class). + let mut tagged = Vec::new(); + for (day, c, other, debit_first, tag_a, tag_b) in entries { + let date = base + chrono::Days::new(u64::from(day)); + let e = l.create_entry(date, "e"); + let amt = Money::new(Decimal::new(c, 2), Currency::USD); + 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 [dr, cr][i] != cash { + tagged.push((date, k)); + } + } + } + l.post_entry(e).unwrap(); + } + let start = base + chrono::Days::new(u64::from(from)); + let end = start + chrono::Days::new(u64::from(len)); + for &k in &classes { + let t = ledger_core::project_to_date(&l, k, end).unwrap(); + let lines = |v: &[ledger_core::StatementLine]| v.iter().map(|x| x.amount).sum::(); + prop_assert_eq!(lines(&t.revenue_by_account), t.revenue); + prop_assert_eq!(lines(&t.expenses_by_account), t.expenses); + prop_assert_eq!(t.net(), t.revenue - t.expenses); + match t.margin_pct() { + None => prop_assert!(t.revenue <= Decimal::ZERO), + Some(m) => { + prop_assert!(t.revenue > Decimal::ZERO); + prop_assert_eq!(m.scale(), 1); + // Its sign is the contribution's (a tiny one rounds to 0.0). + if t.net() >= Decimal::ZERO { + prop_assert!(m >= Decimal::ZERO); + } else { + prop_assert!(m <= Decimal::ZERO); + } + } + } + // Whether anything is tagged counts lines, even ones netting + // to zero, never accounts left with a balance. + let count = |from: NaiveDate| { + tagged.iter().filter(|(d, c)| *c == k && *d >= from && *d <= end).count() + }; + prop_assert_eq!(t.revenue_lines + t.expenses_lines, count(NaiveDate::MIN)); + let f = ledger_core::project_figures(&l, k, start, end).unwrap(); + prop_assert_eq!(f.period_lines, count(start)); + prop_assert_eq!(&f.to_date, &t); + prop_assert_eq!(f.months_net_to_date.len(), f.months.len()); + for (m, running) in f.months.iter().zip(&f.months_net_to_date) { + let at = ledger_core::project_to_date(&l, k, m.end).unwrap(); + prop_assert_eq!(*running, at.net()); + } + if let Some(last) = f.months_net_to_date.last() { + prop_assert_eq!(*last, t.net()); + } + prop_assert_eq!( + f.period_margin_pct(), + ledger_core::contribution_margin_pct(f.period.net_income(), f.period.total_revenue) + ); + } + } + /// 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, diff --git a/crates/ledger-core/tests/spec_projects.rs b/crates/ledger-core/tests/spec_projects.rs index a210e08..ed09917 100644 --- a/crates/ledger-core/tests/spec_projects.rs +++ b/crates/ledger-core/tests/spec_projects.rs @@ -510,6 +510,9 @@ fn closing_entries_are_left_out_of_project_figures() { 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)); + // The close's lines aren't counted as tagged ones either. + assert_eq!((f.to_date.revenue_lines, f.to_date.expenses_lines), (1, 0)); + assert_eq!(f.period_lines, 1); } #[test] @@ -530,6 +533,23 @@ fn a_reversal_nets_a_project_to_zero() { assert!(t.expenses.is_zero()); let entries = project_entries(&b.l, c); assert_eq!(entries, vec![(r, cents(180_000)), (e, cents(-180_000))]); + // Nothing left on an account, but two lines tagged: it breaks even, + // it isn't untagged (rules.md B-37 item 7). + assert!(t.expenses_by_account.is_empty()); + assert_eq!((t.revenue_lines, t.expenses_lines), (0, 2)); + assert!(t.any_tagged()); + let f = project_figures(&b.l, c, d(2026, 1, 1), d(2026, 12, 31)).unwrap(); + assert_eq!(f.period_lines, 2); + // A period before both: nothing there, both still to date. + let f = project_figures(&b.l, c, d(2026, 1, 1), d(2026, 2, 28)).unwrap(); + assert_eq!(f.period_lines, 0); + assert!(!f.to_date.any_tagged(), "to date through the period's end"); + let bare = b.l.add_class("Bare").unwrap(); + assert!( + !project_to_date(&b.l, bare, d(2026, 12, 31)) + .unwrap() + .any_tagged() + ); } #[test] @@ -631,3 +651,64 @@ fn differs_from_names_projects() { a.l.set_project(c, "Harbor", on_spec()).unwrap(); assert_eq!(a.l.differs_from(&before), vec!["projects"]); } + +/// rules.md B-37 item 7: the margin is the contribution's share of +/// revenue, one place, half to even, and none without revenue. +#[test] +fn contribution_margin_is_one_place_and_none_without_revenue() { + use ledger_core::contribution_margin_pct as pct; + assert_eq!( + pct(cents(320_000), cents(500_000)).unwrap().to_string(), + "64.0" + ); + assert_eq!(pct(cents(100), cents(100)).unwrap().to_string(), "100.0"); + assert_eq!(pct(cents(-300), cents(200)).unwrap().to_string(), "-150.0"); + // Half to even: 1.25% to 1.2, 1.35% to 1.4. + assert_eq!(pct(cents(125), cents(10_000)).unwrap().to_string(), "1.2"); + assert_eq!(pct(cents(135), cents(10_000)).unwrap().to_string(), "1.4"); + assert_eq!(pct(cents(-500), Decimal::ZERO), None); + assert_eq!(pct(cents(500), cents(-500)), None, "refunds only"); + assert_eq!(pct(Decimal::MAX, cents(1)), None, "never panics"); +} + +/// rules.md B-37 item 7: each month's net to date opens on the lines +/// before the months (a deposit last year) and ends on the net to date; +/// a reimbursement credited above its cost leaves the expense below zero. +#[test] +fn months_net_to_date_runs_from_before_the_months() { + let mut b = books(); + let c = b.l.add_class("Zeta site").unwrap(); + b.post(d(2025, 12, 10), b.cash, b.revenue, 50_000, Some((c, false))); + b.post( + d(2026, 2, 9), + b.contractors, + b.cash, + 20_000, + Some((c, true)), + ); + // The client reimburses more than the cost, credited to it. + b.post( + d(2026, 3, 1), + b.cash, + b.contractors, + 30_000, + Some((c, false)), + ); + let f = project_figures(&b.l, c, d(2026, 1, 1), d(2026, 3, 31)).unwrap(); + assert_eq!( + f.months_net_to_date, + vec![cents(50_000), cents(30_000), cents(60_000)] + ); + assert_eq!(f.to_date.net(), cents(60_000)); + assert_eq!(f.to_date.expenses, cents(-10_000)); + assert_eq!(f.to_date.expenses_by_account.len(), 1); + assert_eq!(f.to_date.expenses_by_account[0].amount, cents(-10_000)); + assert_eq!(f.to_date.margin_pct().unwrap().to_string(), "120.0"); + assert_eq!(f.period_margin_pct(), None, "no revenue in the period"); + // No entries at all: nothing to date, no margin, every month zero. + let empty = b.l.add_class("Empty").unwrap(); + let f = project_figures(&b.l, empty, d(2026, 1, 1), d(2026, 3, 31)).unwrap(); + assert!(f.to_date.revenue_by_account.is_empty() && f.to_date.expenses_by_account.is_empty()); + assert_eq!(f.to_date.margin_pct(), None); + assert_eq!(f.months_net_to_date, vec![Decimal::ZERO; 3]); +} diff --git a/crates/ledger-gui/src/charts.rs b/crates/ledger-gui/src/charts.rs index 8f70220..8f487bb 100644 --- a/crates/ledger-gui/src/charts.rs +++ b/crates/ledger-gui/src/charts.rs @@ -250,19 +250,45 @@ pub fn bar_chart( series: &[(&str, Color32)], height: f32, ) -> Option { - bars(ui, groups, series, height, true) + bars(ui, groups, series, None, 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); + bars(ui, groups, series, None, height, false); } +/// A line under a [`bar_chart_view`]'s bars: one value per group, marked +/// at each group's center (e.g. a running net toward break-even), in a +/// strip of its own with its own scale and a $0 rule, so a running total +/// many times one group's figures doesn't flatten the bars. +pub struct Overlay<'a> { + pub name: &'a str, + pub color: Color32, + pub values: &'a [Decimal], +} + +/// [`bar_chart_view`] with an [`Overlay`] line in a strip under the bars +/// ([`LINE_STRIP`] taller); its value joins each group's hover figures. +pub fn bar_chart_view_with_line( + ui: &mut Ui, + groups: &[BarGroup], + series: &[(&str, Color32)], + line: &Overlay<'_>, + height: f32, +) { + bars(ui, groups, series, Some(line), height, false); +} + +/// How much taller an [`Overlay`] makes a bar chart: its strip. +pub const LINE_STRIP: f32 = 84.0; + fn bars( ui: &mut Ui, groups: &[BarGroup], series: &[(&str, Color32)], + line: Option<&Overlay<'_>>, height: f32, clickable: bool, ) -> Option { @@ -278,17 +304,32 @@ fn bars( ); ui.add_space(8.0); } + if let Some(l) = line { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 10.0), Sense::hover()); + ui.painter().line_segment( + [r.left_center(), r.right_center()], + Stroke::new(2.0, l.color), + ); + ui.painter().circle_filled(r.center(), 3.0, l.color); + ui.label( + egui::RichText::new(l.name) + .font(theme::regular(12.0)) + .color(p.muted), + ); + } }); let sense = if clickable { Sense::click() } else { Sense::hover() }; - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), sense); + let strip_h = if line.is_some() { LINE_STRIP } else { 0.0 }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height + strip_h), sense); let painter = ui.painter_at(rect); if groups.is_empty() { return None; } + // The bars' scale is the bars' own; the line has its strip's. let all: Vec = groups .iter() .flat_map(|g| g.values.iter().map(|v| f(*v))) @@ -297,7 +338,17 @@ fn bars( let hi = all.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0); let ts = ticks(lo, hi, 4); let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi)); - let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(4.0, 22.0)); + let plot = Rect::from_min_max( + rect.min + vec2(52.0, 6.0), + pos2(rect.max.x - 4.0, rect.min.y + height - 22.0), + ); + let strip = line.map(|_| { + Rect::from_min_max( + pos2(plot.left(), plot.bottom() + 24.0), + pos2(plot.right(), plot.bottom() + strip_h - 4.0), + ) + }); + let bottom = strip.map_or(plot.bottom(), |s| s.bottom()); let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height(); for t in &ts { let yy = y(*t); @@ -319,13 +370,57 @@ fn bars( let mut clicked = None; let hover = resp.hover_pos(); let mut drawn_year: Option = None; + let mut tip: Option<(egui::Pos2, String, String)> = None; + // The line goes over the hover band and under the tooltip, on its + // own scale with room above and below (its markers never sit on the + // strip's edge), and a dashed $0: break-even. + let mut overlay = Vec::new(); + if let (Some(l), Some(s)) = (line, strip) { + let vals: Vec = l.values.iter().take(groups.len()).map(|v| f(*v)).collect(); + let lo = vals.iter().cloned().fold(0.0f64, f64::min); + let hi = vals.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0); + let pad = (hi - lo) * 0.12; + let (lo, hi) = (lo - pad, hi + pad); + let sy = |v: f64| s.bottom() - ((v - lo) / (hi - lo)) as f32 * s.height(); + let zero = sy(0.0); + overlay.extend(egui::Shape::dashed_line( + &[pos2(s.left(), zero), pos2(s.right(), zero)], + Stroke::new(1.0, p.faint), + 4.0, + 3.0, + )); + let label = |v: f64, at: f32| { + let g = painter.layout_no_wrap(money_label(v), theme::regular(11.0), p.faint); + let r = Align2::RIGHT_CENTER.anchor_size(pos2(s.left() - 8.0, at), g.size()); + egui::Shape::galley(r.min, g, p.faint) + }; + overlay.push(label(0.0, zero)); + let (vmin, vmax) = ( + vals.iter().cloned().fold(0.0f64, f64::min), + vals.iter().cloned().fold(0.0f64, f64::max), + ); + for v in [vmax, vmin] { + if v != 0.0 && (sy(v) - zero).abs() >= 12.0 { + overlay.push(label(v, sy(v))); + } + } + let pts: Vec = vals + .iter() + .enumerate() + .map(|(gi, v)| pos2(plot.left() + gw * (gi as f32 + 0.5), sy(*v))) + .collect(); + overlay.push(egui::Shape::line(pts.clone(), Stroke::new(2.0, l.color))); + for pt in pts { + overlay.push(egui::Shape::circle_filled(pt, 3.0, l.color)); + } + } 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 grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, bottom)); let hovered = hover.is_some_and(|h| grect.contains(h)); if hovered { painter.rect_filled(grect, CornerRadius::same(6), p.hover); @@ -358,7 +453,7 @@ fn bars( 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), + pos2(gx + gw / 2.0, bottom + 6.0), Align2::CENTER_TOP, if new_year { &full } else { &g.label }, theme::regular(11.0), @@ -366,22 +461,17 @@ fn bars( ); } if hovered { - let lines: Vec = series + let mut lines: Vec = series .iter() .zip(&g.values) .map(|((name, _), v)| format!("{name}: {}", fmt::dollars(*v))) .collect(); - tooltip( - &painter, - pos2(gx + gw / 2.0, plot.top()), - &full, - &lines.join("\n"), - p.surface, - p.border, - p.text, - p.muted, - rect, - ); + if let Some(l) = line + && let Some(v) = l.values.get(gi) + { + lines.push(format!("{}: {}", l.name, fmt::dollars(*v))); + } + tip = Some((pos2(gx + gw / 2.0, plot.top()), full, lines.join("\n"))); if clickable { if resp.clicked() { clicked = Some(gi); @@ -390,6 +480,12 @@ fn bars( } } } + painter.extend(overlay); + if let Some((at, title, body)) = tip { + tooltip( + &painter, at, &title, &body, p.surface, p.border, p.text, p.muted, rect, + ); + } clicked } diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index ef3a13a..f3c8007 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -246,7 +246,13 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { let client_class = s.add_project( "Client work", &ledger_core::ProjectFacts { - description: Some("Design work for the studio's clients".into()), + // Its invoices can't be tagged to it: an invoice posts one + // untagged revenue line (Ledger::create_invoice takes no + // class), so only the contractors it pays show here, said so. + description: Some( + "Design work for the studio's clients. Only its contractors are tagged to it: an invoice can't carry a project yet, so what the clients pay shows on the profit and loss, not here." + .into(), + ), kind: Some(ledger_core::ProjectKind::Client), ..Default::default() }, diff --git a/crates/ledger-gui/src/pages/projects.rs b/crates/ledger-gui/src/pages/projects.rs index c7066e4..c0dc58f 100644 --- a/crates/ledger-gui/src/pages/projects.rs +++ b/crates/ledger-gui/src/pages/projects.rs @@ -1,7 +1,9 @@ //! 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`, +//! it is for), and one project completely: its snapshot (revenue, direct +//! costs, contribution before overhead and its margin, to date or this +//! fiscal year, rules.md B-37 item 7), revenue and costs by month with the +//! running net, costs by category 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. @@ -27,6 +29,9 @@ use rust_decimal::Decimal; pub struct State { /// Only projects with this status (None: every one). pub status: Option, + /// A project's snapshot shows this fiscal year to date (B-30's + /// omitted dates), not to date. + pub fiscal: bool, } /// One project on the list: its id, name, facts and net to date. @@ -160,17 +165,24 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { 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 }; + // The cue repeats what the net's color says: it goes + // first as the list narrows, then the kind. + let kind_w = if avail >= 640.0 { 140.0 } else { 0.0 }; + let cue_w = if avail >= 760.0 { 120.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<_> { + let w = avail.max(status_w + kind_w + party_w + cue_w + amt + 180.0); + let cells = |c: [(String, f32, bool, egui::Color32); 6]| -> Vec<_> { c.into_iter() .enumerate() - .filter(|(i, _)| (*i != 2 || kind_w > 0.0) && (*i != 3 || party_w > 0.0)) + .filter(|(i, _)| { + (*i != 2 || kind_w > 0.0) + && (*i != 3 || party_w > 0.0) + && (*i != 4 || cue_w > 0.0) + }) .map(|(_, c)| c) .collect() }; @@ -184,6 +196,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { head("Status", status_w, false), head("Kind", kind_w, false), head("Party", party_w, false), + head("", cue_w, true), head("Net to date", amt, true), ]), None, @@ -198,12 +211,29 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { .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), + let snap = r.to_date.as_ref().map(Snap::to_date); + let (net_t, net_c) = match &snap { + Some(s) if s.standing == Standing::Nothing => { + ("—".to_string(), p.faint) + } + Some(s) => (fmt::accounting(s.contribution), s.standing.colors(&p).0), None => (fmt::CANT_COMPUTE.to_string(), p.faint), }; + // A small cue beside the net: where it stands. + let (cue_t, cue_c) = match snap.as_ref().map(|s| s.standing) { + Some(st @ (Standing::Black | Standing::Red | Standing::Even)) => { + let mark = match st { + Standing::Black => icon::TREND_UP, + Standing::Red => icon::TREND_DOWN, + _ => icon::EQUALS, + }; + ( + format!("{mark} {}", st.words().to_lowercase()), + st.colors(&p).0, + ) + } + _ => (String::new(), p.faint), + }; let sub = r .facts .description @@ -218,6 +248,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { (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), + (cue_t, cue_w, true, cue_c), (net_t, amt, true, net_c), ]), Some((sub.as_str(), p.faint)), @@ -240,7 +271,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { 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.", + "Net to date: every posted revenue and expense line tagged to the project through {}, closing entries left out; revenue less its direct costs, before overhead (untagged company costs aren't counted).", fmt::date(b.today) )) .font(theme::regular(11.5)) @@ -285,14 +316,248 @@ pub fn project_events(b: &Books, id: ClassId) -> Vec { .collect() } -/// A number tile: (label, value, how it is computed, the line under it). -type Tile = ( - String, - Option, +/// A snapshot tile: (label, value, the line under it, how it is +/// computed, the amount its menu copies). +type Tile<'a> = ( + &'a str, String, Option<(String, egui::Color32)>, + String, + Option, ); +/// Where a project stands on its contribution before overhead. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Standing { + /// Contribution above zero. + Black, + /// Contribution below zero. + Red, + /// Lines tagged, netting to zero (a cost and its reversal too). + Even, + /// No revenue or expense line tagged (in the span shown). + Nothing, +} + +impl Standing { + /// From a contribution, and whether any posted revenue or expense + /// line is tagged at all (counted lines, not accounts left with a + /// balance: a reversed cost is tagged and breaks even). + pub fn of(contribution: Decimal, any: bool) -> Self { + if contribution > Decimal::ZERO { + Standing::Black + } else if contribution < Decimal::ZERO { + Standing::Red + } else if any { + Standing::Even + } else { + Standing::Nothing + } + } + + /// Its words on a pill. + pub fn words(self) -> &'static str { + match self { + Standing::Black => "In the black", + Standing::Red => "In the red", + Standing::Even => "Break-even", + Standing::Nothing => "Nothing tagged yet", + } + } + + /// Its colors: (text, fill), the palette's positive and negative. + pub fn colors(self, p: &theme::Palette) -> (egui::Color32, egui::Color32) { + match self { + Standing::Black => (p.positive, p.positive_soft), + Standing::Red => (p.negative, p.negative_soft), + Standing::Even | Standing::Nothing => (p.muted, p.hover), + } + } +} + +/// What a snapshot shows, all ledger-core's: to date +/// (`project_to_date`) or for a period (`project_figures`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Snap { + /// Tagged revenue. + pub revenue: Decimal, + /// Tagged expenses: its direct costs. + pub expenses: Decimal, + /// Revenue less direct costs: its contribution before overhead. + pub contribution: Decimal, + /// Contribution as a percent of revenue (none without revenue). + pub margin_pct: Option, + /// Where it stands. + pub standing: Standing, +} + +impl Snap { + /// To date. + pub fn to_date(t: &ledger_core::ProjectToDate) -> Self { + let any = t.any_tagged(); + Snap { + revenue: t.revenue, + expenses: t.expenses, + contribution: t.net(), + margin_pct: t.margin_pct(), + standing: Standing::of(t.net(), any), + } + } + + /// For the figures' period. + pub fn period(f: &ledger_core::ProjectFigures) -> Self { + let pl = &f.period; + let any = f.period_lines > 0; + Snap { + revenue: pl.total_revenue, + expenses: pl.total_expenses, + contribution: pl.net_income(), + margin_pct: f.period_margin_pct(), + standing: Standing::of(pl.net_income(), any), + } + } +} + +/// The note on contribution: what it counts and what it leaves out. +pub const CONTRIBUTION_NOTE: &str = "Revenue less this project's direct costs: every expense line tagged to it, fixed and variable alike (what management accounting calls a segment margin, not a variable-cost contribution margin). Company overhead that isn't tagged to a project (rent, software, insurance, the owner's time) isn't counted, so a project in the black can still leave the company in the red. A management-accounting view (rules.md B-37 item 7), not a GAAP figure."; + +/// The snapshot: a header with where it stands to date and the to date / +/// fiscal year switch, then its tiles (`shown`: to date, or for `year`, +/// the fiscal year to date, when `fiscal`). Returns the switch's new +/// setting (true: this fiscal year) when clicked. +fn snapshot( + ui: &mut Ui, + b: &Books, + kind: Option, + to_date: Option<&ledger_core::ProjectToDate>, + year: Option<(NaiveDate, NaiveDate)>, + shown: Option, + fiscal: bool, +) -> Option { + let p = pal(ui.ctx()); + let mut switch = None; + let year = year.filter(|_| fiscal); + let span = match year { + Some((s, e)) => format!( + "Every posted line tagged to this project from {} through {} (the fiscal year to date), closing entries left out.", + fmt::date(s), + fmt::date(e) + ), + None => format!( + "Every posted line tagged to this project through {}, closing entries left out.", + fmt::date(b.today) + ), + }; + let when = match year { + Some((s, _)) => format!("since {}", fmt::date(s)), + None => format!("through {}", fmt::date(b.today)), + }; + ui.horizontal(|ui| { + ui.label(RichText::new("Snapshot").font(theme::semibold(14.0))); + // Where it stands is always to date, whichever view: the list's + // cue too, and the page's to-date cards beside it agree. + if let Some(t) = to_date.map(Snap::to_date) { + let (fg, bg) = t.standing.colors(&p); + widgets::pill(ui, t.standing.words(), fg, bg).on_hover_text(format!( + "Contribution before overhead, to date (through {}): {}.", + fmt::date(b.today), + fmt::dollars(t.contribution) + )); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if chip(ui, "This fiscal year", fiscal) && !fiscal { + switch = Some(true); + } + if chip(ui, "To date", !fiscal) && fiscal { + switch = Some(false); + } + }); + }); + ui.add_space(8.0); + let money = |v: Option| fmt::dollars_or(v); + let contribution_sub = shown.as_ref().map(|s| { + let (fg, _) = s.standing.colors(&p); + let text = match s.standing { + Standing::Black | Standing::Red | Standing::Even => "before overhead", + Standing::Nothing if fiscal => "nothing this fiscal year", + Standing::Nothing => "nothing tagged yet", + }; + ( + text.to_string(), + if s.standing == Standing::Nothing { + p.muted + } else { + fg + }, + ) + }); + let mut tiles: Vec> = vec![ + ( + "Revenue", + money(shown.as_ref().map(|s| s.revenue)), + Some((when.clone(), p.muted)), + format!("{span} Revenue lines."), + shown.as_ref().map(|s| s.revenue), + ), + ( + "Direct costs", + money(shown.as_ref().map(|s| s.expenses)), + Some((when.clone(), p.muted)), + format!("{span} Expense lines: the costs tagged to it."), + shown.as_ref().map(|s| s.expenses), + ), + ( + "Contribution", + money(shown.as_ref().map(|s| s.contribution)), + contribution_sub, + format!("{span} {CONTRIBUTION_NOTE}"), + shown.as_ref().map(|s| s.contribution), + ), + ]; + if let Some(m) = shown.as_ref().and_then(|s| s.margin_pct) { + tiles.push(( + "Contribution margin", + format!("{m}%"), + Some(("of revenue".to_string(), p.muted)), + format!("{span} Contribution as a share of revenue, to one place. Shown only when there is revenue."), + None, + )); + } + if kind == Some(ProjectKind::OnSpec) + && let Some(u) = to_date.and_then(|t| t.unrecovered_cost) + { + tiles.push(( + "Unrecovered cost", + fmt::dollars(u), + Some(("if repaid: not a receivable".into(), p.muted)), + format!( + "Every posted line tagged to this project through {}, whichever view: direct costs less revenue to date, 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.", + fmt::date(b.today) + ), + Some(u), + )); + } + let n = tiles.len(); + super::grid(ui, n, 170.0, 6, |i, ui| { + let (label, value, sub, how, copy) = &tiles[i]; + let r = widgets::kpi( + ui, + widgets::Kpi { + label, + value: value.clone(), + sub: sub.clone(), + spark: None, + how, + }, + ); + if let Some(v) = copy { + r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string())); + } + }); + switch +} + /// One project, completely. pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); @@ -308,9 +573,10 @@ pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) 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); + let fiscal = shell.state.projects.fiscal; + let figs = figures(ui.ctx(), b, id, f.started_on, fiscal); + let to_date = figs.to_date.as_ref(); + let entries = &figs.entries; page(ui, "project", |ui| { ui.horizontal(|ui| { if widgets::link( @@ -363,110 +629,37 @@ pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) } 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)), - )); + // The snapshot: what it earned and cost, to date or this fiscal + // year, before overhead. + let shown = if fiscal { + figs.year.as_ref().map(Snap::period) + } else { + to_date.map(Snap::to_date) + }; + let year = figs.year.as_ref().map(|y| (y.period.start, y.period.end)); + if let Some(fy) = snapshot(ui, b, f.kind, to_date, year, shown, fiscal) { + shell.state.projects.fiscal = fy; } - 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| { + // By month and by category. + super::columns(ui, 860.0, &[1.3, 1.0], |col, ui| { if col == 0 { - months_card(ui, b, id, first); + months_card(ui, &figs); } else { - accounts_card(ui, b, id, &mut go); + costs_card(ui, b, to_date, &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 { + accounts_card(ui, b, to_date, &mut go); + ui.add_space(16.0); decisions_card(ui, b, id, &mut go); } }); - entries_card(ui, b, &entries, &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( @@ -503,21 +696,108 @@ pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) } } -/// 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) { +/// The most months the by-month chart shows: the latest. +pub const CHART_MONTHS: usize = 24; + +/// The months the by-month chart spans: from the month the project +/// started, or of its first posted tagged entry if earlier, through +/// today, the last [`CHART_MONTHS`] at most. None with neither date. +pub fn chart_span( + today: NaiveDate, + started_on: Option, + first_entry: Option, +) -> Option<(NaiveDate, NaiveDate)> { + let from = match (started_on, first_entry) { + (Some(s), Some(e)) => s.min(e), + (s, e) => s.or(e)?, + }; + let start = ledger_core::analysis::interval_start( + from.min(today), + ledger_core::analysis::Interval::Month, + ); + Some(Books::last_months((start, today), CHART_MONTHS as u32)) +} + +/// A project page's figures, all ledger-core's, worked out once per load +/// of the books (and the fiscal year's once it is asked for), not every +/// frame: a hovered chart repaints continuously. +#[derive(Clone)] +pub struct PageFigures { + /// Through today (`project_to_date`). + pub to_date: Option, + /// Every entry tagged to it (`project_entries`). + pub entries: Vec<(u64, Decimal)>, + /// The by-month chart's span ([`chart_span`]) and figures, when + /// anything tagged is posted. + pub months: Option<((NaiveDate, NaiveDate), ledger_core::ProjectFigures)>, + /// The fiscal year to date (B-30's omitted dates, as `books.project`), + /// only when the snapshot shows it. + pub year: Option, +} + +/// [`PageFigures`] for project `id`, kept in egui's memory against the +/// load of the books they came from (a reload, a new day or another +/// project works them out again). +pub fn figures( + ctx: &egui::Context, + b: &Books, + id: ClassId, + started_on: Option, + fiscal: bool, +) -> std::sync::Arc { + type Stamp = (chrono::DateTime, String, NaiveDate, bool); + let key = egui::Id::new(("project_figures", id)); + let stamp: Stamp = (b.loaded_at, b.head.clone(), b.today, fiscal); + if let Some((was, figs)) = ctx.data(|d| d.get_temp::<(Stamp, std::sync::Arc)>(key)) + && was == stamp + { + return figs; + } + let figs = std::sync::Arc::new(page_figures(b, id, started_on, fiscal)); + ctx.data_mut(|d| d.insert_temp(key, (stamp, figs.clone()))); + figs +} + +/// [`PageFigures`], worked out. +pub fn page_figures( + b: &Books, + id: ClassId, + started_on: Option, + fiscal: bool, +) -> PageFigures { + let to_date = ledger_core::project_to_date(&b.ledger, id, b.today).ok(); + let entries = ledger_core::project_entries(&b.ledger, id); + 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(); + // Nothing posted yet: no bars to draw, however long ago it started. + let months = chart_span(b.today, started_on, first) + .filter(|_| first.is_some()) + .and_then(|(s, e)| { + let fig = ledger_core::project_figures(&b.ledger, id, s, e).ok()?; + Some(((s, e), fig)) + }); + let year = fiscal + .then(|| b.periods().statement(None, None).ok()) + .flatten() + .and_then(|(s, e)| ledger_core::project_figures(&b.ledger, id, s, e).ok()); + PageFigures { + to_date, + entries, + months, + year, + } +} + +/// Revenue and costs by month, with the net to date at each month's end: +/// the trend toward or away from break-even. +fn months_card(ui: &mut Ui, figs: &PageFigures) { 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(); + let span = figs.months.as_ref().map(|(span, _)| *span); + let fig = figs.months.as_ref().map(|(_, fig)| fig); widgets::titled_card( ui, "By month", @@ -531,55 +811,143 @@ fn months_card(ui: &mut Ui, b: &Books, id: ClassId, first: Option) { } }, |ui| { - if months.is_empty() { + let Some(fig) = fig.filter(|f| !f.months.is_empty()) else { widgets::empty(ui, icon::CHART_BAR, "No tagged lines yet", ""); return; - } + }; + let months = &fig.months; 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()], + values: vec![m.revenue, m.expenses], 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( + charts::bar_chart_view_with_line( ui, &groups, - &[ - ("Income", p.series[1]), - ("Spending", p.series[2]), - ("Net", p.series[0]), - ], + &[("Revenue", p.series[1]), ("Direct costs", p.series[2])], + &charts::Overlay { + name: "Net to date", + color: p.series[0], + values: &fig.months_net_to_date, + }, 200.0, ); + ui.add_space(4.0); + ui.add( + egui::Label::new( + RichText::new( + "Net to date: every tagged line through each month's end, before overhead; above $0 it is in the black.", + ) + .font(theme::regular(11.5)) + .color(p.faint), + ) + .wrap(), + ); }, ); } +/// Its direct costs by expense account to date, largest first; a click +/// opens the account's register. +fn costs_card( + ui: &mut Ui, + b: &Books, + to_date: Option<&ledger_core::ProjectToDate>, + go: &mut Option, +) { + let p = pal(ui.ctx()); + let mut lines: Vec<&ledger_core::StatementLine> = to_date + .map(|t| t.expenses_by_account.iter().collect()) + .unwrap_or_default(); + // Largest first, then by code: the bars rank. + lines.sort_by(|x, y| y.amount.cmp(&x.amount).then_with(|| x.code.cmp(&y.code))); + widgets::titled_card( + ui, + "Costs by category, to date", + |ui| { + if let Some(t) = to_date.filter(|_| !lines.is_empty()) { + ui.label( + RichText::new(fmt::dollars(t.expenses)) + .font(theme::regular(12.0)) + .color(p.muted), + ); + } + }, + |ui| { + if lines.is_empty() { + // Costs tagged and cancelled out (a reversal, or repaid + // exactly) leave no account, but they were tagged. + if to_date.is_some_and(|t| t.expenses_lines > 0) { + widgets::empty( + ui, + icon::CHART_BAR_HORIZONTAL, + "Tagged costs net to zero", + "Every cost tagged to it was reversed or repaid in full.", + ); + } else { + widgets::empty(ui, icon::CHART_BAR_HORIZONTAL, "No costs tagged", ""); + } + return; + } + let rows: Vec<(String, Decimal)> = lines + .iter() + .map(|l| (format!("{} {}", l.code, l.name), l.amount)) + .collect(); + if let Some(i) = charts::hbars_signed(ui, &rows, p.series[2], p.positive) + && let Some(a) = b.ledger.chart().find_by_code(&lines[i].code) + { + *go = Some(Route::Account(a.id)); + } + if lines.iter().any(|l| l.amount < Decimal::ZERO) { + ui.add_space(4.0); + ui.add( + egui::Label::new( + RichText::new( + "Below zero: an account credited more than it cost (a reimbursement).", + ) + .font(theme::regular(11.5)) + .color(p.faint), + ) + .wrap(), + ); + } + }, + ); +} + /// Its revenue then expense accounts to date, with a total. -fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option) { +fn accounts_card( + ui: &mut Ui, + b: &Books, + to_date: Option<&ledger_core::ProjectToDate>, + 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", ""); + let Some(t) = to_date + .filter(|t| !t.revenue_by_account.is_empty() || !t.expenses_by_account.is_empty()) + else { + if to_date.is_some_and(|t| t.any_tagged()) { + widgets::empty( + ui, + icon::TREE_STRUCTURE, + "Tagged lines net to zero", + "Each account's tagged lines cancel out (a reversal, or a cost repaid in full): a contribution of 0.00.", + ); + } 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); @@ -612,19 +980,19 @@ fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option) { *go = Some(Route::Account(a.id)); } }; - for l in &pl.revenue { + for l in &t.revenue_by_account { line(ui, l, false); } - for l in &pl.expenses { + for l in &t.expenses_by_account { line(ui, l, true); } widgets::rule(ui); - let net = pl.net_income(); + let net = t.net(); row( ui, w, &[ - ("Net".to_string(), 0.0, false, p.text), + ("Contribution".to_string(), 0.0, false, p.text), ( fmt::accounting(net), amt, @@ -639,7 +1007,8 @@ fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option) { None, true, false, - ); + ) + .on_hover_text(CONTRIBUTION_NOTE); ui.add_space(4.0); ui.label( RichText::new( diff --git a/crates/ledger-gui/tests/exports.rs b/crates/ledger-gui/tests/exports.rs index acaaa30..750c2f3 100644 --- a/crates/ledger-gui/tests/exports.rs +++ b/crates/ledger-gui/tests/exports.rs @@ -22,18 +22,36 @@ use std::path::{Path, PathBuf}; const SSN: &str = "620-37-1111"; /// This test binary's downloads folder: its own, never the human's. +static DOWNLOADS: std::sync::OnceLock = std::sync::OnceLock::new(); + +unsafe extern "C" { + /// The C library's: runs `f` as the process exits, which libtest's + /// main does pass or fail (it returns, or calls `exit`). + safe fn atexit(f: extern "C" fn()) -> std::ffi::c_int; +} + +/// Removes the downloads folder as the test binary exits: its files hold +/// (made-up) TINs, and every test's dir is removed when it ends (the +/// justfile's sweep-test-tmp catches a run killed first: it is `wb-*`). +extern "C" fn remove_downloads() { + if let Some(d) = DOWNLOADS.get() { + let _ = std::fs::remove_dir_all(d); + } +} + fn downloads() -> PathBuf { - static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); - DIR.get_or_init(|| { - let d = tempfile::Builder::new() - .prefix("wb-gui-downloads-") - .tempdir() - .unwrap() - .keep(); - ledger_gui::export::set_downloads_dir(d.clone()); - d - }) - .clone() + DOWNLOADS + .get_or_init(|| { + let d = tempfile::Builder::new() + .prefix("wb-gui-downloads-") + .tempdir() + .unwrap() + .keep(); + assert_eq!(atexit(remove_downloads), 0, "atexit refused"); + ledger_gui::export::set_downloads_dir(d.clone()); + d + }) + .clone() } /// A fresh folder in the downloads folder for one test (tests run side by diff --git a/crates/ledger-gui/tests/projects.proptest-regressions b/crates/ledger-gui/tests/projects.proptest-regressions new file mode 100644 index 0000000..788682a --- /dev/null +++ b/crates/ledger-gui/tests/projects.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 9afb8bbc956b20e53a67ecdd13e018dc139152a98f0d94057929cfeea935e4e2 # shrinks to lines = [(0, 1, false, true, true)] diff --git a/crates/ledger-gui/tests/projects.rs b/crates/ledger-gui/tests/projects.rs index 725d45b..4638fc6 100644 --- a/crates/ledger-gui/tests/projects.rs +++ b/crates/ledger-gui/tests/projects.rs @@ -11,7 +11,7 @@ use ledger_core::{AccountType, ClassId, Currency, Money, ProjectFacts, ProjectKi 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::pages::projects::{CONTRIBUTION_NOTE, Snap, Standing, chart_span, listed}; use ledger_gui::{App, Source, data}; use ledger_store::ops::ViewerTarget; use ledger_store::{Store, Subject}; @@ -224,7 +224,8 @@ fn project_figures_are_ledger_cores() { 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"); + // Direct costs, and the costs card's total. + assert_eq!(h.get_all_by_label("$1,800.00").count(), 2); h.get_by_label("$3,200.00"); // By account to date: the same statement lines. let fig = ledger_core::project_figures( @@ -234,10 +235,21 @@ fn project_figures_are_ledger_cores() { today(), ) .unwrap(); - for l in fig.period.revenue.iter().chain(&fig.period.expenses) { + assert_eq!(fig.to_date.revenue_by_account, fig.period.revenue); + assert_eq!(fig.to_date.expenses_by_account, fig.period.expenses); + for l in &fig.period.revenue { h.get_by_role_and_label(Role::Button, &format!("{} {}", l.code, l.name)); } - h.get_by_label("Margin to date"); + // An expense account is a row and a bar on Costs by category. + for l in &fig.period.expenses { + let label = format!("{} {}", l.code, l.name); + assert_eq!( + h.get_all_by_role_and_label(Role::Button, &label).count(), + 2, + "{label}" + ); + } + h.get_by_label("Contribution margin"); h.get_by_label("64.0%"); } @@ -247,9 +259,10 @@ fn unrecovered_cost_shows_only_on_spec() { 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); + // The devices (an asset) count nothing: direct costs, the costs + // card's total and the unrecovered cost are all the prototype's + // $300.00. + assert_eq!(h.get_all_by_label("$300.00").count(), 3); let h = harness(&f, Route::Project(f.zeta)); assert!(h.query_by_label("Unrecovered cost").is_none()); } @@ -464,8 +477,8 @@ fn a_long_link_stays_in_its_card() { decisions.left() ); } - let net = h.get_by_label("Net to date").rect(); - assert!(net.right() <= w, "{w}: Net to date at {net:?}"); + let net = h.get_by_label("Contribution").rect(); + assert!(net.right() <= w, "{w}: Contribution at {net:?}"); } } @@ -478,9 +491,10 @@ fn a_long_name_keeps_the_page_in_the_window() { ("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:?}"); + for tile in ["Revenue", "Contribution", "Contribution margin"] { + for r in h.get_all_by_label(tile).map(|n| n.rect()) { + assert!(r.right() <= 1440.0, "{tag}: {tile} at {r:?}"); + } } let decisions = h.get_by_label("Decisions").rect(); assert!( @@ -588,3 +602,653 @@ fn a_long_party_name_keeps_to_its_card() { } } } + +// ------------------------------------------------- the snapshot (B-37 item 7) + +/// Books holding one project of `kind`, started `started_on`, with +/// `posts`: (date, debit code, credit code, cents), the line that isn't +/// cash tagged to it. Codes: 1000 Cash, 4000 Design Services, 6000 +/// Contractors, 6100 Software. Rent (6200) is no project's. +struct Mini { + dir: TempDir, + id: ClassId, + tag: String, +} + +fn mini( + tag: &str, + kind: ProjectKind, + started_on: Option, + posts: &[(NaiveDate, &str, &str, i64)], +) -> Mini { + let dir = TempDir(std::env::temp_dir().join(format!( + "ledger-gui-projects-snap-{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(); + for (code, name, ty) in [ + ("1000", "Cash", AccountType::Asset), + ("4000", "Design Services", AccountType::Revenue), + ("6000", "Contractors", AccountType::Expense), + ("6100", "Software", AccountType::Expense), + ("6200", "Rent", AccountType::Expense), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let id = s + .add_project( + "Kestrel", + &ProjectFacts { + kind: Some(kind), + started_on, + ..Default::default() + }, + ) + .unwrap(); + let code = |s: &Store, c: &str| s.ledger().chart().find_by_code(c).unwrap().id; + for &(date, dr, cr, c) in posts { + let e = s.create_entry(date, "Line").unwrap(); + let (dr_id, cr_id) = (code(&s, dr), code(&s, cr)); + s.add_line(e, dr_id, Side::Debit, usd(c)).unwrap(); + s.add_line(e, cr_id, Side::Credit, usd(c)).unwrap(); + s.set_line_class(e, if dr == "1000" { 1 } else { 0 }, Some(id)) + .unwrap(); + s.post_entry(e).unwrap(); + } + // Overhead, no project's: never in the snapshot. + let e = s.create_entry(d(2026, 4, 30), "Rent").unwrap(); + let (rent, cash) = (code(&s, "6200"), code(&s, "1000")); + s.add_line(e, rent, Side::Debit, usd(150_000)).unwrap(); + s.add_line(e, cash, Side::Credit, usd(150_000)).unwrap(); + s.post_entry(e).unwrap(); + drop(s); + Mini { + dir, + id, + tag: format!("snap-{tag}"), + } +} + +fn mini_harness(m: &Mini) -> Harness<'static, App> { + let books = load(&m.dir); + let id = m.id; + 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::Project(id))); + h.run_steps(3); + shot(&mut h, &m.tag); + h +} + +/// For eyes: SHOT_DIR keeps a picture of the page. +fn shot(h: &mut Harness<'static, App>, tag: &str) { + if let Ok(out) = std::env::var("SHOT_DIR") { + let _ = std::fs::create_dir_all(&out); + if let Ok(img) = h.render() { + img.save(std::path::Path::new(&out).join(format!("{tag}.png"))) + .unwrap(); + } + } +} + +/// Every text painted this frame. +fn painted(h: &Harness<'static, App>) -> Vec { + painted_colored(h).into_iter().map(|(t, _)| t).collect() +} + +/// Every text painted this frame, with its color. +fn painted_colored(h: &Harness<'static, App>) -> Vec<(String, egui::Color32)> { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Color32)>) { + match s { + egui::Shape::Text(t) => { + let color = t + .galley + .job + .sections + .first() + .map_or(t.fallback_color, |s| s.format.color); + let color = t.override_text_color.unwrap_or(color); + out.push((t.galley.text().to_string(), color)); + } + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + for c in &h.output().shapes { + walk(&c.shape, &mut out); + } + out +} + +/// The value a snapshot tile labelled `label` shows: the label directly +/// under that tile's own (the same left edge, just below), so a figure +/// elsewhere on the page can't stand in for it. Exactly one such tile. +fn tile(h: &Harness<'static, App>, label: &str) -> String { + let nodes: Vec<(egui::Rect, String)> = h + .query_all(egui_kittest::kittest::By::new().role(Role::Label)) + .filter_map(|n| Some((n.rect(), n.value()?))) + .collect(); + let found: Vec = nodes + .iter() + .filter(|(_, v)| v == label) + .filter_map(|(r, _)| { + nodes + .iter() + .filter(|(x, _)| { + (x.left() - r.left()).abs() < 1.0 + && x.top() >= r.bottom() - 1.0 + && x.top() <= r.bottom() + 12.0 + }) + .min_by(|a, b| a.0.top().total_cmp(&b.0.top())) + .map(|(_, v)| v.clone()) + }) + .filter(|v| v.contains('$') || v.ends_with('%')) + .collect(); + assert_eq!(found.len(), 1, "one tile {label}: {found:?}"); + found[0].clone() +} + +/// The tiles' figures are ledger-core's, the same books.project returns +/// (to_date's revenue, expenses, net and margin_pct; its tests check the +/// server's side, and ledger-cli's projects_parity both on one book). +fn assert_tiles(h: &Harness<'static, App>, s: &Snap) { + use ledger_gui::fmt::dollars; + assert_eq!(tile(h, "Revenue"), dollars(s.revenue), "revenue"); + assert_eq!(tile(h, "Direct costs"), dollars(s.expenses), "direct costs"); + assert_eq!( + tile(h, "Contribution"), + dollars(s.contribution), + "contribution" + ); + match s.margin_pct { + Some(m) => assert_eq!(tile(h, "Contribution margin"), format!("{m}%")), + None => assert!(h.query_by_label("Contribution margin").is_none()), + } +} + +/// [`assert_tiles`], and the pill says where it stands (to date, whichever +/// view: `standing`). +fn assert_snapshot(h: &Harness<'static, App>, s: &Snap, standing: Standing) { + assert_tiles(h, s); + h.get_by_label(standing.words()); +} + +/// The tooltip shown after hovering `at` a while: every label containing +/// `text` once it shows. +fn hover_shows(h: &mut Harness<'static, App>, at: egui::Pos2, text: &str) -> bool { + h.hover_at(at); + for _ in 0..60 { + h.run_steps(1); + if h.query_all_by_label_contains(text).next().is_some() + || painted(h).iter().any(|t| t.contains(text)) + { + return true; + } + } + false +} + +#[test] +fn a_profitable_projects_snapshot_is_ledger_cores() { + let f = fixture("snap-profit"); + let t = ledger_core::project_to_date(&f.books.ledger, f.zeta, today()).unwrap(); + let s = Snap::to_date(&t); + assert_eq!( + s, + Snap { + revenue: cents(500_000), + expenses: cents(180_000), + contribution: cents(320_000), + margin_pct: Some(Decimal::new(640, 1)), + standing: Standing::Black, + } + ); + assert_eq!(s.contribution, t.net()); + let mut h = harness(&f, Route::Project(f.zeta)); + shot(&mut h, "snap-profit"); + assert_snapshot(&h, &s, s.standing); + h.get_by_label("before overhead"); + assert!(h.query_all_by_label("Snapshot").next().is_some()); + // The Contribution tile says what it leaves out, on hover. + let at = h + .query_all_by_label("Contribution") + .map(|n| n.rect()) + .min_by(|a, b| a.top().total_cmp(&b.top())) + .unwrap() + .center(); + assert!( + hover_shows(&mut h, at, CONTRIBUTION_NOTE), + "the Contribution tile's hover note" + ); + // Costs by category: its one expense account, a bar that opens it. + h.get_by_label("Costs by category, to date"); + assert_eq!( + h.get_all_by_role_and_label(Role::Button, "6000 Contractors") + .count(), + 2, + "a bar and By account's row" + ); +} + +#[test] +fn a_loss_making_project_is_in_the_red() { + let m = mini( + "loss", + ProjectKind::Client, + None, + &[ + (d(2026, 3, 2), "1000", "4000", 20_000), + (d(2026, 3, 9), "6000", "1000", 10_000), + (d(2026, 4, 9), "6100", "1000", 50_000), + ], + ); + let b = load(&m.dir); + let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap(); + let s = Snap::to_date(&t); + assert_eq!(s.contribution, cents(-40_000)); + assert_eq!(s.standing, Standing::Red); + assert_eq!(s.margin_pct, Some(Decimal::new(-2000, 1))); + let h = mini_harness(&m); + assert_snapshot(&h, &s, Standing::Red); + // Rent, overhead, is no part of it. + assert!(h.query_by_label("$1,500.00").is_none()); + // Both cost accounts, largest first: the costs card's bars (the + // first buttons so named; By account has the others, by code), so + // Software's $500 is above Contractors' $100 though its code is after. + let first = |label: &str| { + h.get_all_by_role_and_label(Role::Button, label) + .next() + .unwrap() + .rect() + }; + assert!(first("6100 Software").top() < first("6000 Contractors").top()); +} + +#[test] +fn an_on_spec_projects_snapshot_has_its_unrecovered_cost() { + let f = fixture("snap-spec"); + let t = ledger_core::project_to_date(&f.books.ledger, f.alpha, today()).unwrap(); + let s = Snap::to_date(&t); + assert_eq!(s.standing, Standing::Red); + assert_eq!(t.unrecovered_cost, Some(cents(30_000))); + let mut h = harness(&f, Route::Project(f.alpha)); + shot(&mut h, "snap-spec"); + assert_snapshot(&h, &s, Standing::Red); + h.get_by_label("Unrecovered cost"); + h.get_by_label("if repaid: not a receivable"); +} + +#[test] +fn no_revenue_means_no_margin() { + let m = mini( + "no-revenue", + ProjectKind::Internal, + None, + &[(d(2026, 5, 1), "6100", "1000", 9_900)], + ); + let h = mini_harness(&m); + assert!(h.query_by_label("Contribution margin").is_none()); + h.get_by_label("In the red"); + h.get_by_label("−$99.00"); +} + +#[test] +fn a_project_with_no_entries_has_nothing_tagged_yet() { + let m = mini("empty", ProjectKind::Client, None, &[]); + let b = load(&m.dir); + let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap(); + assert_eq!(Snap::to_date(&t).standing, Standing::Nothing); + assert_eq!(chart_span(today(), None, None), None); + let h = mini_harness(&m); + h.get_by_label("Nothing tagged yet"); + h.get_by_label("nothing tagged yet"); + h.get_by_label("No tagged lines yet"); + h.get_by_label("No costs tagged"); + assert!(h.query_by_label("Contribution margin").is_none()); +} + +#[test] +fn a_reimbursement_above_its_cost_leaves_costs_below_zero() { + let m = mini( + "reimbursed", + ProjectKind::Client, + None, + &[ + (d(2026, 2, 9), "6000", "1000", 20_000), + // The client repays the contractor and more, credited to it. + (d(2026, 3, 1), "1000", "6000", 30_000), + ], + ); + let b = load(&m.dir); + let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap(); + assert_eq!(t.expenses, cents(-10_000)); + assert_eq!(t.expenses_by_account[0].amount, cents(-10_000)); + let s = Snap::to_date(&t); + assert_eq!( + (s.contribution, s.standing), + (cents(10_000), Standing::Black) + ); + assert_eq!(s.margin_pct, None, "no revenue"); + let h = mini_harness(&m); + // Direct costs, and the costs card's total. + assert_eq!(h.get_all_by_label("−$100.00").count(), 2); + h.get_by_label("Below zero: an account credited more than it cost (a reimbursement)."); + assert_eq!( + h.get_all_by_role_and_label(Role::Button, "6000 Contractors") + .count(), + 2 + ); +} + +#[test] +fn a_project_started_this_month_charts_one_month() { + let m = mini( + "this-month", + ProjectKind::Client, + Some(d(2026, 10, 1)), + &[(d(2026, 10, 5), "1000", "4000", 50_000)], + ); + let b = load(&m.dir); + assert_eq!( + chart_span(today(), Some(d(2026, 10, 1)), Some(d(2026, 10, 5))), + Some((d(2026, 10, 1), today())) + ); + let f = ledger_core::project_figures(&b.ledger, m.id, d(2026, 10, 1), today()).unwrap(); + assert_eq!(f.months.len(), 1); + assert_eq!(f.months_net_to_date, vec![cents(50_000)]); + let h = mini_harness(&m); + h.get_by_label("since Oct 2026"); + h.get_by_label("In the black"); + h.get_by_label("100.0%"); +} + +#[test] +fn the_chart_runs_from_the_start_or_first_entry_and_caps() { + // The earlier of the two, from its month's first day. + assert_eq!( + chart_span(today(), Some(d(2026, 6, 20)), Some(d(2026, 3, 9))), + Some((d(2026, 3, 1), today())) + ); + assert_eq!( + chart_span(today(), Some(d(2026, 2, 2)), Some(d(2026, 3, 9))), + Some((d(2026, 2, 1), today())) + ); + assert_eq!( + chart_span(today(), Some(d(2026, 2, 2)), None), + Some((d(2026, 2, 1), today())) + ); + // A start after today charts only this month. + assert_eq!( + chart_span(today(), Some(d(2027, 1, 1)), None), + Some((d(2026, 10, 1), today())) + ); + // Ten years back: the last 24 months. + assert_eq!( + chart_span(today(), Some(d(2016, 1, 1)), None), + Some((d(2024, 11, 1), today())) + ); +} + +/// The fiscal year switch shows books.project's default period (B-30's +/// omitted dates: the fiscal year to date), and switching writes nothing. +#[test] +fn the_fiscal_year_switch_shows_the_default_period() { + let m = mini( + "fiscal", + ProjectKind::Client, + None, + &[ + (d(2025, 11, 3), "1000", "4000", 100_000), + (d(2025, 12, 1), "6100", "1000", 25_000), + (d(2026, 2, 9), "6000", "1000", 40_000), + ], + ); + let b = load(&m.dir); + let (start, end) = b.periods().statement(None, None).unwrap(); + assert!( + start > d(2025, 12, 1), + "the sale and the software are before this fiscal year" + ); + let fy = Snap::period(&ledger_core::project_figures(&b.ledger, m.id, start, end).unwrap()); + let all = Snap::to_date(&ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap()); + assert_eq!( + (all.expenses, all.contribution, all.standing), + (cents(65_000), cents(35_000), Standing::Black) + ); + assert_eq!( + (fy.revenue, fy.expenses, fy.contribution, fy.standing), + (Decimal::ZERO, cents(40_000), cents(-40_000), Standing::Red) + ); + let journal = std::fs::read(m.dir.0.join("books-data").join("journal.jsonl")).unwrap(); + let mut h = mini_harness(&m); + assert_snapshot(&h, &all, Standing::Black); + h.get_by_label("This fiscal year").click(); + h.run_steps(3); + assert!(h.state_mut().state_mut().projects.fiscal); + shot(&mut h, "snap-fiscal"); + // The tiles are the fiscal year's; where it stands is still to date, + // as the to-date cards beside it and the list say. + assert_snapshot(&h, &fy, Standing::Black); + assert!(h.query_by_label("In the red").is_none()); + assert_eq!( + h.query_all_by_label(&format!("since {}", ledger_gui::fmt::date(start))) + .count(), + 2, + "Revenue's and Direct costs' span" + ); + h.get_by_label("To date").click(); + h.run_steps(3); + assert!(!h.state_mut().state_mut().projects.fiscal); + assert_snapshot(&h, &all, Standing::Black); + assert_eq!( + std::fs::read(m.dir.0.join("books-data").join("journal.jsonl")).unwrap(), + journal, + "the viewer wrote nothing" + ); +} + +/// The list colors each net by its sign and says where it stands. +#[test] +fn the_list_cues_in_the_black_and_in_the_red() { + let f = fixture("list-cue"); + let rows = listed(&f.books); + let standing = |id| { + rows.iter() + .find(|r| r.id == id) + .and_then(|r| r.to_date.as_ref()) + .map(|t| Snap::to_date(t).standing) + }; + assert_eq!(standing(f.zeta), Some(Standing::Black)); + assert_eq!(standing(f.alpha), Some(Standing::Red)); + assert_eq!(standing(f.beta), Some(Standing::Nothing)); + let mut h = harness(&f, Route::Projects); + shot(&mut h, "snap-list"); + let texts = painted_colored(&h); + let color = |t: &str| { + texts + .iter() + .find(|(x, _)| x.ends_with(t)) + .map(|(_, c)| *c) + .unwrap_or_else(|| panic!("{t} painted: {texts:?}")) + }; + // Each net in its standing's color, as its cue: never the names'. + let (black, red, name) = (color("3,200.00"), color("(300.00)"), color("Zeta site")); + assert_eq!(black, color("in the black")); + assert_eq!(red, color("in the red")); + assert!( + black != name && red != name && black != red, + "{black:?} {red:?} {name:?}" + ); + // Alphabetical still. + let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, ["alpha app", "Beta", "Zeta site"]); +} + +/// A cost tagged and then repaid exactly (or reversed) leaves no account +/// with a balance, but it was tagged: break-even, never "nothing tagged". +#[test] +fn a_cost_repaid_exactly_breaks_even() { + let m = mini( + "even", + ProjectKind::Client, + None, + &[ + (d(2026, 3, 9), "6000", "1000", 40_000), + (d(2026, 4, 1), "1000", "6000", 40_000), + ], + ); + let b = load(&m.dir); + let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap(); + assert!(t.expenses_by_account.is_empty(), "the account nets to zero"); + assert_eq!((t.revenue_lines, t.expenses_lines), (0, 2)); + let s = Snap::to_date(&t); + assert_eq!( + (s.contribution, s.standing), + (Decimal::ZERO, Standing::Even) + ); + let mut h = mini_harness(&m); + assert_snapshot(&h, &s, Standing::Even); + h.get_by_label("before overhead"); + h.get_by_label("Tagged costs net to zero"); + h.get_by_label("Tagged lines net to zero"); + assert!(h.query_by_label("Nothing tagged yet").is_none()); + assert!(h.query_by_label("No costs tagged").is_none()); + // The list: 0.00 and its cue, not a dash. + h.state_mut().goto(Route::Projects); + h.run_steps(3); + let texts = painted(&h); + assert!(texts.iter().any(|t| t.ends_with("break-even")), "{texts:?}"); + assert!(texts.iter().any(|t| t == "0.00"), "{texts:?}"); +} + +/// Lines only in earlier years: this fiscal year has nothing, said so, +/// while the pill keeps the standing to date. +#[test] +fn a_fiscal_year_with_nothing_says_so() { + let m = mini( + "fiscal-empty", + ProjectKind::Client, + None, + &[(d(2025, 6, 3), "1000", "4000", 70_000)], + ); + let mut h = mini_harness(&m); + h.get_by_label("This fiscal year").click(); + h.run_steps(3); + shot(&mut h, "snap-fiscal-empty"); + h.get_by_label("nothing this fiscal year"); + h.get_by_label("In the black"); + assert!(h.query_by_label("Nothing tagged yet").is_none()); +} + +/// The by-month chart's running net is ledger-core's months_net_to_date: +/// hovering each month shows its "Net to date", in the chart's own strip. +#[test] +fn the_running_net_is_each_months_net_to_date() { + let m = mini( + "running", + ProjectKind::Client, + None, + &[ + (d(2026, 3, 2), "1000", "4000", 120_000), + (d(2026, 3, 9), "6000", "1000", 10_000), + (d(2026, 5, 9), "6100", "1000", 30_000), + (d(2026, 8, 1), "1000", "4000", 45_000), + ], + ); + let b = load(&m.dir); + let (s, e) = chart_span(today(), None, Some(d(2026, 3, 2))).unwrap(); + let f = ledger_core::project_figures(&b.ledger, m.id, s, e).unwrap(); + assert_eq!(f.months.len(), 8); + // In month order, a month repeating the one before it (nothing new + // that month) told once: what sweeping the pointer across shows. + let mut want: Vec = f + .months_net_to_date + .iter() + .map(|v| format!("Net to date: {}", ledger_gui::fmt::dollars(*v))) + .collect(); + want.dedup(); + assert_eq!(want.len(), 3, "{want:?}"); + let mut h = mini_harness(&m); + let title = h.get_by_label("By month").rect(); + let right = h.get_by_label("Costs by category, to date").rect().left(); + // Over the bars, and over the strip under them. + for dy in [100.0, 230.0] { + let mut seen = Vec::new(); + let mut x = title.left(); + while x < right { + h.hover_at(egui::pos2(x, title.bottom() + dy)); + h.run_steps(2); + for t in painted(&h) { + seen.extend( + t.lines() + .filter(|l| { + l.starts_with("Net to date: $") || l.starts_with("Net to date: −$") + }) + .map(str::to_string), + ); + } + x += 12.0; + } + seen.dedup(); + assert_eq!(seen, want, "at {dy}"); + } + shot(&mut h, "snap-running"); +} + +proptest::proptest! { + #![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))] + + /// The snapshot's contribution is project_to_date's net, whatever the + /// lines (refunds, reimbursements), and its standing is the net's sign. + #[test] + fn prop_snapshot_contribution_is_the_net_to_date( + lines in proptest::collection::vec((0usize..3, 1i64..500_000, proptest::bool::ANY, proptest::bool::ANY, proptest::bool::ANY), 0..12), + ) { + let mut l = ledger_core::Ledger::new(Currency::USD); + let cash = l.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap(); + let accts = [ + l.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap(), + l.add_account("6000", "Costs", AccountType::Expense, None, false).unwrap(), + l.add_account("6100", "Tools", AccountType::Expense, None, false).unwrap(), + ]; + let k = l.add_class("K").unwrap(); + let mut tagged = 0usize; + for (a, c, debit, tag, reverse) in lines { + let e = l.create_entry(d(2026, 3, 1), "e"); + let (dr, cr) = if debit { (accts[a], cash) } else { (cash, accts[a]) }; + l.add_line(e, dr, Side::Debit, usd(c)).unwrap(); + l.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + if tag { + l.set_line_class(e, if debit { 0 } else { 1 }, Some(k)).unwrap(); + tagged += 1; + } + l.post_entry(e).unwrap(); + // A reversal: its tagged line too, netting the account to + // zero (it was tagged all the same). + if reverse { + l.reverse_entry(e, d(2026, 3, 2), "undo").unwrap(); + if tag { + tagged += 1; + } + } + } + let t = ledger_core::project_to_date(&l, k, today()).unwrap(); + proptest::prop_assert_eq!(t.revenue_lines + t.expenses_lines, tagged); + let s = Snap::to_date(&t); + proptest::prop_assert_eq!(s.contribution, t.net()); + proptest::prop_assert_eq!(s.revenue - s.expenses, s.contribution); + proptest::prop_assert_eq!(s.margin_pct, t.margin_pct()); + let want = if t.net() > Decimal::ZERO { + Standing::Black + } else if t.net() < Decimal::ZERO { + Standing::Red + } else if tagged == 0 { + Standing::Nothing + } else { + Standing::Even + }; + proptest::prop_assert_eq!(s.standing, want); + } +} diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index a7f2557..681bf7f 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -105,6 +105,7 @@ fn screenshots() { .and_then(|p| p.parse().ok()) .unwrap_or_else(|| project_named("Tidepool app")); let project_client = project_named("Client work"); + let project_templates = project_named("Templates"); drop(loaded); let routes: Vec<(&str, Route)> = vec![ ("overview", Route::Overview), @@ -141,6 +142,7 @@ fn screenshots() { ("projects", Route::Projects), ("project", Route::Project(project)), ("project-client", Route::Project(project_client)), + ("project-templates", Route::Project(project_templates)), ("document", Route::Document(last_doc)), ("document-linked", Route::Document(linked_doc)), ("reconciliation", Route::Reconciliation(reconciliation)), diff --git a/crates/ledger-mcp/src/tools/projects.rs b/crates/ledger-mcp/src/tools/projects.rs index 4f5e38e..89937e7 100644 --- a/crates/ledger-mcp/src/tools/projects.rs +++ b/crates/ledger-mcp/src/tools/projects.rs @@ -100,10 +100,30 @@ fn to_date_out(t: &ledger_core::ProjectToDate) -> ToDateOut { revenue: money(t.revenue), expenses: money(t.expenses), net: money(t.net()), + margin_pct: t.margin_pct().map(|m| m.to_string()), unrecovered_cost: t.unrecovered_cost.map(money), + by_account: None, } } +/// Statement lines for a tool's output: revenue, then expenses. +fn accounts_out( + revenue: &[ledger_core::StatementLine], + expenses: &[ledger_core::StatementLine], +) -> Vec { + 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), + }; + revenue + .iter() + .map(|l| account(l, "revenue")) + .chain(expenses.iter().map(|l| account(l, "expense"))) + .collect() +} + /// An optional date from a tool: "" clears it. fn optional_date(what: &str, s: &str) -> Result, String> { match optional_text(s) { @@ -260,7 +280,7 @@ impl BooksServer { #[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.", + 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 (contribution before overhead), margin_pct, 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( @@ -293,7 +313,7 @@ impl BooksServer { 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.", + "One project, by id or name: facts, party, decisions, linked documents, and every entry with a line tagged to it. For the period: revenue, expenses, net (contribution before overhead: untagged costs aren't counted), margin_pct, by account, by month (with net_to_date). To date (every tagged line through end): the same totals, by account, and for on_spec the 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) @@ -309,29 +329,19 @@ impl BooksServer { 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_account = accounts_out(&fig.period.revenue, &fig.period.expenses); let by_month = fig .months .iter() - .map(|m| ProjectMonthOut { + .zip(&fig.months_net_to_date) + .map(|(m, to_date)| 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()), + net_to_date: money(*to_date), }) .collect(); let mut entries = Vec::new(); @@ -378,10 +388,17 @@ impl BooksServer { revenue: money(fig.period.total_revenue), expenses: money(fig.period.total_expenses), net: money(fig.period.net_income()), + margin_pct: fig.period_margin_pct().map(|m| m.to_string()), by_account, by_month, }, - to_date: to_date_out(&fig.to_date), + to_date: ToDateOut { + by_account: Some(accounts_out( + &fig.to_date.revenue_by_account, + &fig.to_date.expenses_by_account, + )), + ..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 55a8dd3..40bae99 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3435,12 +3435,23 @@ pub struct ToDateOut { pub revenue: String, /// Tagged expenses, decimal string. pub expenses: String, - /// Revenue less expenses, decimal string. + /// Revenue less expenses, decimal string: its contribution before + /// overhead (untagged company costs aren't counted). pub net: String, + /// Net as a percent of revenue, one place ("64.0"); null without + /// revenue (zero or less). Net is revenue less every cost traced to + /// the project, fixed and variable alike (a segment margin), not a + /// variable-cost contribution margin. + pub margin_pct: Option, /// 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, + /// `books.project` only, always there (`[]` when nothing tagged nets + /// to anything): revenue accounts, then expense accounts (its direct + /// costs by category), by code, to date. Absent from `books.projects`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub by_account: Option>, } /// Parameters for `books.projects`. @@ -3512,6 +3523,9 @@ pub struct ProjectMonthOut { pub expenses: String, /// Revenue less expenses, decimal string. pub net: String, + /// Net to date through its end (every tagged line from the first day + /// the books keep): the running net toward or away from break-even. + pub net_to_date: String, } /// A project's figures for the period asked. @@ -3525,8 +3539,12 @@ pub struct ProjectPeriodOut { pub revenue: String, /// Tagged expenses, decimal string. pub expenses: String, - /// Revenue less expenses, decimal string. + /// Revenue less expenses, decimal string: its contribution before + /// overhead. pub net: String, + /// Net as a percent of revenue, one place; null without revenue + /// (as to date's: every traced cost, not only variable ones). + pub margin_pct: Option, /// Revenue accounts, then expense accounts, by code. pub by_account: Vec, /// By calendar month (the period's last 240 months at most). diff --git a/crates/ledger-mcp/tests/spec_projects.rs b/crates/ledger-mcp/tests/spec_projects.rs index e9d1cdd..12a1bef 100644 --- a/crates/ledger-mcp/tests/spec_projects.rs +++ b/crates/ledger-mcp/tests/spec_projects.rs @@ -667,6 +667,89 @@ fn project_to_date_counts_lines_before_the_period() { assert_eq!(zeta["to_date"]["net"], "3200.00"); } +/// The snapshot's figures (rules.md B-37 item 7), as the viewer shows +/// them: net is the contribution before overhead, margin_pct its share +/// of revenue, to_date.by_account the direct costs by category, and each +/// month's net_to_date the running net, the last being to_date's. +#[test] +fn project_snapshot_margin_by_account_and_running_net() { + let (mut m, _, _) = studio("snapshot"); + let z = m.read_only( + "books.project", + json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}), + ); + // The period: 4,500 earned, 1,800 spent: 2,700, 60.0% of revenue. + assert_eq!(z["period"]["net"], "2700.00"); + assert_eq!(z["period"]["margin_pct"], "60.0"); + // To date, the December deposit too: 3,200 of 5,000, 64.0%. + assert_eq!(z["to_date"]["net"], "3200.00"); + assert_eq!(z["to_date"]["margin_pct"], "64.0"); + let accounts: Vec<(&str, &str, &str)> = z["to_date"]["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", "5000.00"), + ("6000", "expense", "1800.00") + ] + ); + let running: Vec<&str> = z["period"]["by_month"] + .as_array() + .unwrap() + .iter() + .map(|m| m["net_to_date"].as_str().unwrap()) + .collect(); + assert_eq!(running.len(), 12); + // January opens on the December deposit; March adds the milestone + // less the contractor; the year ends on the net to date. + assert_eq!(&running[..3], ["500.00", "500.00", "3200.00"]); + assert_eq!(running[11], z["to_date"]["net"].as_str().unwrap()); + // On spec, repaid in part: a loss for the year, margin below zero. + let h = m.read_only( + "books.project", + json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-12-31"}), + ); + assert_eq!(h["period"]["net"], "-460.00"); + assert_eq!(h["period"]["margin_pct"], "-46.0"); + // No revenue yet: no margin. + let h = m.read_only( + "books.project", + json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-04-30"}), + ); + assert_eq!(h["to_date"]["margin_pct"], Value::Null); + assert_eq!(h["period"]["margin_pct"], Value::Null); + assert_eq!( + h["to_date"]["by_account"][0]["amount"], "1460.00", + "the devices, an asset, count nothing" + ); + // Nothing tagged: books.project still carries to_date.by_account, + // empty, and no margin. + m.ok( + "books.add_project", + json!({"name": "Quiet one", "kind": "internal"}), + ); + let q = m.read_only("books.project", json!({"project": "Quiet one"})); + assert_eq!(q["to_date"]["by_account"], json!([]), "{q}"); + assert_eq!(q["period"]["by_account"], json!([]), "{q}"); + assert_eq!(q["to_date"]["margin_pct"], Value::Null); + // The list carries the margin, not the accounts. + let list = m.read_only("books.projects", json!({})); + for p in list["projects"].as_array().unwrap() { + assert!(p["to_date"].get("margin_pct").is_some(), "{p}"); + assert!(p["to_date"].get("by_account").is_none(), "{p}"); + } +} + #[test] fn on_spec_project_shows_unrecovered_cost_and_others_null() { let (mut m, _, _) = studio("unrecovered"); diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 370069d..5bbaa2b 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2076,24 +2076,34 @@ until the owner upgrades them. 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 + to_date: { through, revenue, expenses, net, margin_pct, 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 + company's time zone, closing entries left out; `net` is its + contribution before overhead (B-37 item 7: revenue less every cost + tagged to it, fixed and variable alike, no company overhead allocated: + a segment margin in Garrison's sense, not a variable-cost contribution + margin); `margin_pct` is net as a percent of revenue to one place, half + to even ("64.0"), null when revenue is zero or less; `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 + start, end, revenue, expenses, net, margin_pct, 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, + start, end, revenue, expenses, net, net_to_date }] }, to_date (as above, through + end, plus `by_account` to date, as the period's: its revenue accounts, + then its direct costs by expense account; always present here, `[]` + when nothing tagged nets to anything, and absent from + `books.projects`), 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`, + at most, each month's `net_to_date` the running net through its end + (every tagged line from the first day the books keep: the last month's + is `to_date.net`). `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 diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 39258bf..9ba1c22 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -2545,7 +2545,7 @@ package). **Checked:** `ledger-core` `tests/tax_capital.rs`; a class as a project ... Projects are entities too and can have name, description, whatever other metadata you think they should have"; the convention `link-to-projects` in the books: "whenever applicable, we link -things to [projects]"); CONVENTION (job costing, item 3); AUTHORITY (on +things to [projects]"); CONVENTION (job costing, item 3; the snapshot, item 7: a presentation choice from management accounting, not GAAP); AUTHORITY (on spec, item 4: ASC 450-30-25-1; ASC 340-40-25-5 with B-22; `sources/fasb-asc.md`, both quoted through Deloitte's DART roadmaps and not yet re-read in the ASC basic view: an open item) @@ -2599,11 +2599,39 @@ not yet re-read in the ASC basic view: an open item) so when one is tagged. 6. Omitted dates mean what B-30 says (`books.project`'s period); to date is always stated as such, beside it. +7. **Snapshot (a presentation choice, not GAAP).** A project's net is + shown as its *contribution before overhead*: its revenue less its + direct costs (every expense line tagged to it), with no company + overhead allocated, since item 3 allocates none. The owner's label is + "contribution"; what it means is revenue less the costs traceable to + the project, fixed and variable alike (the books don't split costs by + behavior), before the common costs no project causes. That is + Garrison, Noreen and Brewer's *segment margin* (*Managerial + Accounting*, segment reporting: a segment's sales less its variable + expenses and its traceable fixed expenses, the defining source here), + who warn against allocating common costs to judge a segment. It is + not Horngren's *contribution margin* (revenue less variable costs + only; Horngren, Datar and Rajan, *Cost Accounting: A Managerial + Emphasis*), which the books can't compute and the viewer doesn't + claim; "contribution" here is only the everyday sense, what the + project contributes toward overhead. So a project in the black can + still leave the company in the red. Its margin ("contribution + margin" on the page, `margin_pct` in the tools) is that contribution + as a percent of revenue, one place, half to even, and none without + revenue (zero or less). "In the black" is a contribution above zero, + "in the red" below, "break-even" lines tagged that net to zero (a + cost and its reversal). The running net by month is the net to date + through each month's end; its last is the net to date. No GAAP + standard defines or requires these figures (ASC 280's segment + reporting is for public entities' operating segments, not projects); + they come from item 3's, one function the tools and the viewer share. **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`; +project_to_date, project_entries, contribution_margin_pct}`, +`ProjectToDate::{net, margin_pct, any_tagged}`, `ProjectFigures::{months_net_to_date, +period_margin_pct, period_lines}`, `kernel::unrecovered_cost`; `Store::{add_project, set_project, subject_feature}` (feature `projects`); `books.add_project`, `books.update_project`, `books.projects`, `books.project`; the viewer's Projects pages. @@ -2612,4 +2640,5 @@ project_to_date, project_entries}`, `kernel::unrecovered_cost`; `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`. +`prop_project_set_round_trips`, `prop_project_contribution_is_the_net_to_date` +(core) and `prop_snapshot_contribution_is_the_net_to_date` (viewer). diff --git a/docs/testing.md b/docs/testing.md index 6c1de1e..7a4b4f8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1917,6 +1917,117 @@ balance-sheet zero; `project_set_of` (newer vs corrupt); feature check; the decision `projects` omit-when-empty; `late_tag_note`'s `is_over`; `remember_footer`'s threshold. +## P3: the project snapshot (2026-10-22) + +The owner's ask: a "mini financial snapshot" of one project on its page, +overhead left out, so it shows at a glance whether the project is in the +black or the red, with charts (rules.md B-37 item 7: contribution before +overhead, revenue less every traced cost, a segment margin in Garrison's +sense; a presentation choice, not GAAP). Every figure is ledger-core's: +`ProjectToDate::{net, margin_pct, any_tagged, revenue_by_account, +expenses_by_account, revenue_lines, expenses_lines}`, +`ProjectFigures::{months_net_to_date, period_margin_pct, period_lines}` +and `contribution_margin_pct`, which `books.project` and +`books.projects` now return too (`margin_pct`, `to_date.by_account`, +`by_month[].net_to_date`). No mutation run (P12, D-33; none on the GUI); +each test below was checked by breaking what it guards and watching it +fail. + +- **ledger-core:** `tests/spec_projects.rs` + `contribution_margin_is_one_place_and_none_without_revenue` (64.0, + 100.0, -150.0, half to even at 1.25 and 1.35, none at zero or negative + revenue, none, not a panic, on overflow), + `months_net_to_date_runs_from_before_the_months` (opens on last year's + deposit; a reimbursement above its cost leaves the expense below zero; + no entries: every month zero, no margin), + `a_reversal_nets_a_project_to_zero` (no account left, two lines + counted: tagged, breaking even; a period before both counts none) and + `closing_entries_are_left_out_of_project_figures` (a close's lines + aren't counted); `properties.rs` + `prop_project_contribution_is_the_net_to_date` (random tags, sides, + refunds and reimbursements over two expense accounts and a random + period: the accounts sum to the totals, the contribution is revenue + less expenses, each month's net to date is `project_to_date` through + its end and the last is the net to date, the counted lines are the + tagged revenue and expense lines to date and in the period, a margin + exists exactly when revenue is above zero and has the contribution's + sign, and `project_figures`' to-date is `project_to_date`'s). + `project_figures` takes the net before its first month from to date + less the months (one scan fewer); the proptest pins it. +- **ledger-mcp:** `tests/spec_projects.rs` + `project_snapshot_margin_by_account_and_running_net` (the period's and + to date's margin_pct, to_date.by_account, the 12 months' net_to_date + opening on the December deposit and ending on to_date.net, a loss's + negative margin, null without revenue, a project with nothing tagged + still carrying `to_date.by_account: []`, and books.projects carrying + the margin but not the accounts). `books.project`'s description stays + under 700 characters (`tool_descriptions_fit_700_characters`). +- **viewer and server on one book:** ledger-cli + `tests/projects_parity.rs` + `a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server` + (`wanderbooks dev-stdio`'s `books.project` against the viewer's + `page_figures` and `Snap` on the same books: to date's revenue, + expenses, net, margin_pct, unrecovered cost and by_account, every + month's figures and net_to_date over the chart's span, and the fiscal + year's; with a refund, a reversal and overhead; gui builds only). +- **ledger-gui:** `tests/projects.rs` + `a_profitable_projects_snapshot_is_ledger_cores` (and the Contribution + tile's hover note is `CONTRIBUTION_NOTE`), + `a_loss_making_project_is_in_the_red` (costs largest first: the + higher-coded account is the larger), + `an_on_spec_projects_snapshot_has_its_unrecovered_cost` (each tile's + own value, read under its label, equal to `Snap::to_date` of + `project_to_date`, the pill, the margin shown only with revenue, + overhead never counted), + `no_revenue_means_no_margin`, + `a_project_with_no_entries_has_nothing_tagged_yet`, + `a_cost_repaid_exactly_breaks_even` (no account left: break-even, not + "nothing tagged", on the page and the list), + `a_reimbursement_above_its_cost_leaves_costs_below_zero`, + `a_project_started_this_month_charts_one_month`, + `the_chart_runs_from_the_start_or_first_entry_and_caps` (the earlier of + start and first entry, a start after today, 24 months at most), + `the_running_net_is_each_months_net_to_date` (sweeping the pointer over + the bars and over the net's own strip shows each month's + `months_net_to_date`, in order), + `the_fiscal_year_switch_shows_the_default_period` (B-30's omitted dates, + as `books.project`, with fiscal-year costs unlike to date's; the pill + stays to date; switching writes nothing to the journal), + `a_fiscal_year_with_nothing_says_so`, + `the_list_cues_in_the_black_and_in_the_red` (each net painted in its + cue's color, never the names', still alphabetical), and the proptest + `prop_snapshot_contribution_is_the_net_to_date` (reversals too: a + reversed tagged line is tagged and breaks even); the earlier tests take + the new labels (Revenue, Contribution, Contribution margin). With + `SHOT_DIR` set each snapshot case is saved as a PNG; run them one at a + time then (`SHOT_DIR=... cargo test -p ledger-gui --test projects -- + --test-threads 1`: rendering from tests run in parallel crashed with + SIGSEGV, 2026-10-22; without `SHOT_DIR` nothing renders and they run + side by side). `screens.rs` gains `project-templates` + (the demo's profitable project). Shots: + `~/.cache/wanderbooks/shots-p3-snapshot/` (`light/`, `dark/`, `w900/`, + `tests/`). +- **Every test honors TMPDIR.** `.cargo/config.toml` forced `[env] + TMPDIR = "/tmp"` for every cargo-run test (for `just mutants*`' speed), + so a TMPDIR set to keep tests off a small RAM /tmp was overridden; + it is gone. Every test makes its dirs with `std::env::temp_dir()` or + `tempfile` (both TMPDIR), and the binaries tests spawn with a cleared + environment write no temp files. A socket under a long TMPDIR is longer + than a socket address holds: every test connects through + `ledger_store::root::SocketPath`, as the servers bind (`raw_admin` in + `local_server.rs` didn't, and failed with TMPDIR under + `~/.cache/wanderbooks/p3-snapshot-tmp`). ledger-gui's `exports.rs` + downloads folder, shared by its tests, is removed as the binary exits + (`atexit`; `wb-*`, so `sweep-test-tmp` catches a killed run). Checked + by running `just check` with TMPDIR under `~/.cache/wanderbooks` and + finding no new test dir in /tmp. Mutation runs (`just mutants*`) now + put test books under that TMPDIR too, on disk: slower per mutant than + /tmp was; none is running (P12 paused). + +**For P12 (mutation targets):** `contribution_margin_pct`'s `<=` and +rounding strategy; `project_figures`' opening net and running sum; +`profit_and_loss_counted`'s counts; `Standing::of`'s order. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/justfile b/justfile index 909b146..ce98277 100644 --- a/justfile +++ b/justfile @@ -34,7 +34,7 @@ server-only: fi echo "server-only build: no window libraries" -# Test books go in the system temp dir (often RAM). Every test's dir is +# Test books go in the system temp dir: TMPDIR, else /tmp. Every test's dir is # removed when the test ends, pass or panic (a guard dropped on unwind; # until 2026-10-17 many were removed only on pass, or never: 117,000 piled # up). A run killed mid-test (a mutant's timeout, ^C) runs no destructor, @@ -145,6 +145,10 @@ miri: # Where cargo-mutants copies and builds the tree for each mutant (several # GB per job): the user's cache on disk, never /tmp, which is often RAM. +# The tests it runs honor the same TMPDIR (every test's books go in +# std::env::temp_dir(), so TMPDIR); until 2026-10-22 a forced +# `[env] TMPDIR = "/tmp"` in .cargo/config.toml kept them in /tmp for +# speed, which also overrode a TMPDIR set for any cargo test. # A mutant only needs some test to fail, so proptest doesn't shrink. # # Every mutation recipe takes a shard, "k/n" (default: all of it), so CI