Projects: the snapshot of a project's figures
check / check (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 06:54:26 -06:00
1 parent d24653558c
commit 3eda95eb3c
23 files changed
+2231 -246

No files matched your search

-7
View File
@@ -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 }
Generated
+1
View File
@@ -3841,6 +3841,7 @@ dependencies = [
"libc",
"reqwest",
"rmcp",
"rust_decimal",
"serde_json",
"sha2 0.10.9",
"tempfile",
+1
View File
@@ -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" }
+9 -2
View File
@@ -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();
+259
View File
@@ -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<std::process::ChildStdout>,
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");
}
+3 -3
View File
@@ -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,
+134 -14
View File
@@ -328,7 +328,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
}
/// 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<ClassId>,
) -> Result<ProfitAndLossStatement, LedgerError> {
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<ClassId>,
) -> 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<crate::analysis::IntervalPnl>,
/// 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<Decimal>,
/// 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<Decimal> {
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<Decimal>,
/// Its tagged revenue by account, by code.
pub revenue_by_account: Vec<StatementLine>,
/// 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<StatementLine>,
/// 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<Decimal> {
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<Decimal> {
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,
})
}
+89
View File
@@ -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::<bool>(), 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<u32> = (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::<Decimal>();
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,
+81
View File
@@ -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]);
}
+114 -18
View File
@@ -250,19 +250,45 @@ pub fn bar_chart(
series: &[(&str, Color32)],
height: f32,
) -> Option<usize> {
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<usize> {
@@ -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<f64> = 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<i32> = 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<f64> = 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<egui::Pos2> = 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<String> = series
let mut lines: Vec<String> = 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
}
+7 -1
View File
@@ -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()
},
+518 -149
View File
@@ -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<ProjectStatus>,
/// 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<usize> {
.collect()
}
/// A number tile: (label, value, how it is computed, the line under it).
type Tile = (
String,
Option<Decimal>,
/// 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<Decimal>,
);
/// 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<Decimal>,
/// 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<ProjectKind>,
to_date: Option<&ledger_core::ProjectToDate>,
year: Option<(NaiveDate, NaiveDate)>,
shown: Option<Snap>,
fiscal: bool,
) -> Option<bool> {
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<Decimal>| 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<Tile<'_>> = 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<Route> = 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<Tile> = 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<NaiveDate>) {
/// 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<NaiveDate>,
first_entry: Option<NaiveDate>,
) -> 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<ledger_core::ProjectToDate>,
/// 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<ledger_core::ProjectFigures>,
}
/// [`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<NaiveDate>,
fiscal: bool,
) -> std::sync::Arc<PageFigures> {
type Stamp = (chrono::DateTime<chrono::Utc>, 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<PageFigures>)>(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<NaiveDate>,
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<NaiveDate>) {
}
},
|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<BarGroup> = 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<Route>,
) {
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<Route>) {
fn accounts_card(
ui: &mut Ui,
b: &Books,
to_date: Option<&ledger_core::ProjectToDate>,
go: &mut Option<Route>,
) {
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<Route>) {
*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<Route>) {
None,
true,
false,
);
)
.on_hover_text(CONTRIBUTION_NOTE);
ui.add_space(4.0);
ui.label(
RichText::new(
+29 -11
View File
@@ -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<PathBuf> = 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<PathBuf> = 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
@@ -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)]
+676 -12
View File
@@ -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<NaiveDate>,
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<String> {
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<String> = 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<String> = 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);
}
}
+2
View File
@@ -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)),
+34 -17
View File
@@ -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<ProjectAccountOut> {
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<Option<NaiveDate>, 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,
+20 -2
View File
@@ -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<String>,
/// On spec only: expenses less revenue, never below zero (what it
/// would take to be repaid in full; a figure, never a receivable).
/// Null for any other kind.
pub unrecovered_cost: Option<String>,
/// `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<Vec<ProjectAccountOut>>,
}
/// 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<String>,
/// Revenue accounts, then expense accounts, by code.
pub by_account: Vec<ProjectAccountOut>,
/// By calendar month (the period's last 240 months at most).
+83
View File
@@ -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");
+16 -6
View File
@@ -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
+32 -3
View File
@@ -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).
+111
View File
@@ -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
+5 -1
View File
@@ -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