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:
23 files changed
+2231
-246
No files matched your search
@@ -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" }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user