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).