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

The project page opens on a snapshot: revenue, direct costs, the
contribution before overhead and its margin of revenue (a segment
margin: revenue less every cost traced to the project, fixed and
variable; B-37 item 7), in the black or red, and for on-spec work the
cost unrecovered if repaid. To date or this fiscal year. By month,
revenue and costs with the net to date in its own strip; costs by
category; the list colors each net by its sign. books.project carries
the same figures, and a parity test runs the viewer and the MCP on the
same books to the cent. Tests honor TMPDIR.

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

No files matched your search

+34 -17
View File
@@ -100,10 +100,30 @@ fn to_date_out(t: &ledger_core::ProjectToDate) -> ToDateOut {
revenue: money(t.revenue),
expenses: money(t.expenses),
net: money(t.net()),
margin_pct: t.margin_pct().map(|m| m.to_string()),
unrecovered_cost: t.unrecovered_cost.map(money),
by_account: None,
}
}
/// Statement lines for a tool's output: revenue, then expenses.
fn accounts_out(
revenue: &[ledger_core::StatementLine],
expenses: &[ledger_core::StatementLine],
) -> Vec<ProjectAccountOut> {
let account = |l: &ledger_core::StatementLine, t: &str| ProjectAccountOut {
code: l.code.clone(),
name: l.name.clone(),
account_type: t.to_string(),
amount: money(l.amount),
};
revenue
.iter()
.map(|l| account(l, "revenue"))
.chain(expenses.iter().map(|l| account(l, "expense")))
.collect()
}
/// An optional date from a tool: "" clears it.
fn optional_date(what: &str, s: &str) -> Result<Option<NaiveDate>, String> {
match optional_text(s) {
@@ -260,7 +280,7 @@ impl BooksServer {
#[tool(
name = "books.projects",
title = "Projects",
description = "Every project (every class, with its facts), by name: status, kind, party, dates, and its figures to date (every posted line tagged to it through today in the company's time zone, closes left out): revenue, expenses, net, and for an on_spec project its unrecovered cost. status filters to one status.",
description = "Every project (every class, with its facts), by name: status, kind, party, dates, and its figures to date (every posted line tagged to it through today in the company's time zone, closes left out): revenue, expenses, net (contribution before overhead), margin_pct, and for an on_spec project its unrecovered cost. status filters to one status.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn projects(
@@ -293,7 +313,7 @@ impl BooksServer {
name = "books.project",
title = "Get a project",
description = described(
"One project, by id or name: its facts, party and decisions, the documents linked to it, and every entry with a line tagged to it (date, description, project amount, receipts). For the period: revenue, expenses, net, by account and by month. To date (every tagged line through end): the same totals and, for an on_spec project, its unrecovered cost, the 'if repaid' figure, never a receivable. Only revenue and expense lines count.",
"One project, by id or name: facts, party, decisions, linked documents, and every entry with a line tagged to it. For the period: revenue, expenses, net (contribution before overhead: untagged costs aren't counted), margin_pct, by account, by month (with net_to_date). To date (every tagged line through end): the same totals, by account, and for on_spec the unrecovered cost, the 'if repaid' figure, never a receivable. Only revenue and expense lines count.",
ledger_core::calendar::PeriodKind::Statement
),
annotations(read_only_hint = true, open_world_hint = false)
@@ -309,29 +329,19 @@ impl BooksServer {
let id = resolve_class(&store, &p.project)?;
let fig =
ledger_core::project_figures(ledger, id, start, end).map_err(|e| e.to_string())?;
let account = |l: &ledger_core::StatementLine, t: &str| ProjectAccountOut {
code: l.code.clone(),
name: l.name.clone(),
account_type: t.to_string(),
amount: money(l.amount),
};
let by_account = fig
.period
.revenue
.iter()
.map(|l| account(l, "revenue"))
.chain(fig.period.expenses.iter().map(|l| account(l, "expense")))
.collect();
let by_account = accounts_out(&fig.period.revenue, &fig.period.expenses);
let by_month = fig
.months
.iter()
.map(|m| ProjectMonthOut {
.zip(&fig.months_net_to_date)
.map(|(m, to_date)| ProjectMonthOut {
month: m.start.format("%Y-%m").to_string(),
start: m.start.to_string(),
end: m.end.to_string(),
revenue: money(m.revenue),
expenses: money(m.expenses),
net: money(m.net()),
net_to_date: money(*to_date),
})
.collect();
let mut entries = Vec::new();
@@ -378,10 +388,17 @@ impl BooksServer {
revenue: money(fig.period.total_revenue),
expenses: money(fig.period.total_expenses),
net: money(fig.period.net_income()),
margin_pct: fig.period_margin_pct().map(|m| m.to_string()),
by_account,
by_month,
},
to_date: to_date_out(&fig.to_date),
to_date: ToDateOut {
by_account: Some(accounts_out(
&fig.to_date.revenue_by_account,
&fig.to_date.expenses_by_account,
)),
..to_date_out(&fig.to_date)
},
entries,
documents,
decisions,
+20 -2
View File
@@ -3435,12 +3435,23 @@ pub struct ToDateOut {
pub revenue: String,
/// Tagged expenses, decimal string.
pub expenses: String,
/// Revenue less expenses, decimal string.
/// Revenue less expenses, decimal string: its contribution before
/// overhead (untagged company costs aren't counted).
pub net: String,
/// Net as a percent of revenue, one place ("64.0"); null without
/// revenue (zero or less). Net is revenue less every cost traced to
/// the project, fixed and variable alike (a segment margin), not a
/// variable-cost contribution margin.
pub margin_pct: Option<String>,
/// On spec only: expenses less revenue, never below zero (what it
/// would take to be repaid in full; a figure, never a receivable).
/// Null for any other kind.
pub unrecovered_cost: Option<String>,
/// `books.project` only, always there (`[]` when nothing tagged nets
/// to anything): revenue accounts, then expense accounts (its direct
/// costs by category), by code, to date. Absent from `books.projects`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by_account: Option<Vec<ProjectAccountOut>>,
}
/// Parameters for `books.projects`.
@@ -3512,6 +3523,9 @@ pub struct ProjectMonthOut {
pub expenses: String,
/// Revenue less expenses, decimal string.
pub net: String,
/// Net to date through its end (every tagged line from the first day
/// the books keep): the running net toward or away from break-even.
pub net_to_date: String,
}
/// A project's figures for the period asked.
@@ -3525,8 +3539,12 @@ pub struct ProjectPeriodOut {
pub revenue: String,
/// Tagged expenses, decimal string.
pub expenses: String,
/// Revenue less expenses, decimal string.
/// Revenue less expenses, decimal string: its contribution before
/// overhead.
pub net: String,
/// Net as a percent of revenue, one place; null without revenue
/// (as to date's: every traced cost, not only variable ones).
pub margin_pct: Option<String>,
/// Revenue accounts, then expense accounts, by code.
pub by_account: Vec<ProjectAccountOut>,
/// By calendar month (the period's last 240 months at most).
+83
View File
@@ -667,6 +667,89 @@ fn project_to_date_counts_lines_before_the_period() {
assert_eq!(zeta["to_date"]["net"], "3200.00");
}
/// The snapshot's figures (rules.md B-37 item 7), as the viewer shows
/// them: net is the contribution before overhead, margin_pct its share
/// of revenue, to_date.by_account the direct costs by category, and each
/// month's net_to_date the running net, the last being to_date's.
#[test]
fn project_snapshot_margin_by_account_and_running_net() {
let (mut m, _, _) = studio("snapshot");
let z = m.read_only(
"books.project",
json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}),
);
// The period: 4,500 earned, 1,800 spent: 2,700, 60.0% of revenue.
assert_eq!(z["period"]["net"], "2700.00");
assert_eq!(z["period"]["margin_pct"], "60.0");
// To date, the December deposit too: 3,200 of 5,000, 64.0%.
assert_eq!(z["to_date"]["net"], "3200.00");
assert_eq!(z["to_date"]["margin_pct"], "64.0");
let accounts: Vec<(&str, &str, &str)> = z["to_date"]["by_account"]
.as_array()
.unwrap()
.iter()
.map(|a| {
(
a["code"].as_str().unwrap(),
a["type"].as_str().unwrap(),
a["amount"].as_str().unwrap(),
)
})
.collect();
assert_eq!(
accounts,
[
("4000", "revenue", "5000.00"),
("6000", "expense", "1800.00")
]
);
let running: Vec<&str> = z["period"]["by_month"]
.as_array()
.unwrap()
.iter()
.map(|m| m["net_to_date"].as_str().unwrap())
.collect();
assert_eq!(running.len(), 12);
// January opens on the December deposit; March adds the milestone
// less the contractor; the year ends on the net to date.
assert_eq!(&running[..3], ["500.00", "500.00", "3200.00"]);
assert_eq!(running[11], z["to_date"]["net"].as_str().unwrap());
// On spec, repaid in part: a loss for the year, margin below zero.
let h = m.read_only(
"books.project",
json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert_eq!(h["period"]["net"], "-460.00");
assert_eq!(h["period"]["margin_pct"], "-46.0");
// No revenue yet: no margin.
let h = m.read_only(
"books.project",
json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-04-30"}),
);
assert_eq!(h["to_date"]["margin_pct"], Value::Null);
assert_eq!(h["period"]["margin_pct"], Value::Null);
assert_eq!(
h["to_date"]["by_account"][0]["amount"], "1460.00",
"the devices, an asset, count nothing"
);
// Nothing tagged: books.project still carries to_date.by_account,
// empty, and no margin.
m.ok(
"books.add_project",
json!({"name": "Quiet one", "kind": "internal"}),
);
let q = m.read_only("books.project", json!({"project": "Quiet one"}));
assert_eq!(q["to_date"]["by_account"], json!([]), "{q}");
assert_eq!(q["period"]["by_account"], json!([]), "{q}");
assert_eq!(q["to_date"]["margin_pct"], Value::Null);
// The list carries the margin, not the accounts.
let list = m.read_only("books.projects", json!({}));
for p in list["projects"].as_array().unwrap() {
assert!(p["to_date"].get("margin_pct").is_some(), "{p}");
assert!(p["to_date"].get("by_account").is_none(), "{p}");
}
}
#[test]
fn on_spec_project_shows_unrecovered_cost_and_others_null() {
let (mut m, _, _) = studio("unrecovered");