Files
wanderbooks/crates/ledger-cli/tests/projects_parity.rs
T
swaitsandClaude Fable 5.1 a319a64c3a refactor(mcp): the party_id, document_id and cash_account aliases are removed (P5)
Agent reason: one name for one thing. S4 and S6 kept the old spellings "until
P5" so an agent that learned them did not break; P5 removes them, so the
schema an agent reads has one parameter for each fact and a call cannot name
the same thing twice with different answers.

books.create_invoice takes customer and books.create_bill vendor (a record's
id, its party's id or its name; required), no party_id.
books.receive_payment and books.pay_bill take invoice or bill and
deposit_to or paid_from, no document_id or cash_account; a payment names its
document and its account or is refused saying which to pass. A number that is
one record's id and another's name is still refused, never guessed (INVARIANT
6).

The over-the-wire tests that used the old spellings now use the new
(document_id becomes invoice or bill, cash_account becomes deposit_to or
paid_from, party_id becomes customer or vendor given as the id's text); the
one test that named the alias, party_id_alias_still_works, is now
a_customer_by_id_and_by_name_is_one_record and checks the same two spellings
of a customer, by id and by name, name one record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00

348 lines
13 KiB
Rust

//! 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};
mod common;
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 settings both tests' books keep.
const SETTINGS: &str = "[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n";
/// A session with the books server (common.rs), one request at a time.
struct Server {
wire: common::Wire,
}
impl Server {
/// Start `place`'s server on the books the test wrote in its root,
/// with [`SETTINGS`], and a session with them.
fn start(place: &common::Place) -> Self {
let mut wire = place.start(Some(SETTINGS));
wire.initialize("projects-parity");
Server { wire }
}
/// A tool's structured result.
fn tool(&mut self, name: &str, args: Value) -> Value {
let r = self.wire.call(name, args);
let out = &r["result"];
assert_ne!(out["isError"], true, "{name}: {r}");
out["structuredContent"].clone()
}
}
#[test]
fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
// The books written in a data root by the store itself, then served
// there.
let place = common::Place::new("projects-parity");
let data = place.books_data();
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 mut server = Server::start(&place);
let b = data::load_once(&Source {
data_dir: data.clone(),
today: Some(today),
})
.unwrap();
// 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");
}
/// rules.md B-37 item 8: a project's invoiced revenue and billed costs,
/// made through the books server's own tools, read the same in the viewer
/// (its snapshot, and the invoice's and bill's pages: each line's
/// project) and from the server (books.project, books.get_entry).
#[test]
fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server() {
let place = common::Place::new("projects-parity-docs");
let data = place.books_data();
let today = d(2026, 10, 15);
{
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
st.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
}
let mut server = Server::start(&place);
server.tool(
"books.add_project",
json!({"name": "Kestrel", "kind": "client", "started_on": "2026-01-05"}),
);
server.tool("books.add_project", json!({"name": "Heron"}));
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone();
let doc = |party: &Value, date: &str, lines: Value| {
json!({"party_id": party, "date": date, "due_date": date, "lines": lines,
"confirmed": true})
};
server.tool(
"books.add_account",
json!({"code": "6500", "name": "Supplies", "account_type": "expense"}),
);
let inv = server.tool(
"books.create_invoice",
doc(
&cedar,
"2026-02-02",
json!([
{"description": "Discovery", "amount": "1500.00", "class": "Kestrel"},
{"description": "Heron sketches", "amount": "250.00", "class": "Heron"},
{"description": "Travel", "amount": "80.00"},
]),
),
);
let inv2 = server.tool(
"books.create_invoice",
doc(
&cedar,
"2026-05-04",
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
),
);
let bill_file = server
.wire
.upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt");
let mut bill_args = doc(
&dana,
"2026-03-09",
json!([
{"description": "Illustration", "amount": "987.65", "account": "6500", "class": "Kestrel"},
{"description": "Postage", "amount": "12.00", "account": "6500"},
]),
);
bill_args["vendor_ref"] = json!("DANA-9");
bill_args["upload_id"] = json!(bill_file);
let bill = server.tool("books.create_bill", bill_args);
server.tool(
"books.receive_payment",
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-02-20", "amount": "1830.00", "deposit_to": "1000"}),
);
let b = data::load_once(&Source {
data_dir: data.clone(),
today: Some(today),
})
.unwrap();
let id = b.ledger.find_class_by_name("Kestrel").unwrap().id;
let page = page_figures(&b, id, Some(d(2026, 1, 5)), false);
let t = page.to_date.as_ref().unwrap();
let snap = Snap::to_date(t);
let p = server.tool(
"books.project",
json!({"project": "Kestrel", "start": "2026-01-01", "end": today.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()))
);
// By hand: 1,500.00 + 4,321.09 invoiced, 987.65 billed.
assert_eq!(
(
to["revenue"].as_str(),
to["expenses"].as_str(),
to["net"].as_str()
),
(Some("5821.09"), Some("987.65"), Some("4833.44"))
);
let ((_, _), months) = page.months.as_ref().unwrap();
let by_month = p["period"]["by_month"].as_array().unwrap();
assert!(!by_month.is_empty(), "{p}");
assert_eq!(by_month.len(), months.months.len());
for (m, ours) in by_month.iter().zip(&months.months) {
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}");
}
// Each document's lines: the viewer's page names the project the
// server's entry line carries.
for out in [&inv, &inv2, &bill] {
let entry = out["entry_id"].as_u64().unwrap();
let doc = ledger_gui::pages::entry::document_of(&b, entry).unwrap();
let viewer: Vec<Option<String>> = {
let mut seen: Vec<Option<String>> = Vec::new();
for l in doc.lines {
let name = l
.class_id
.and_then(|c| b.ledger.get_class(c))
.map(|c| c.name.clone());
if !seen.contains(&name) {
seen.push(name);
}
}
seen
};
let served = server.tool("books.get_entry", json!({"entry_id": entry}));
let served: Vec<Option<String>> = served["lines"]
.as_array()
.unwrap()
.iter()
.filter(|l| matches!(l["code"].as_str(), Some("4000" | "6500" | "6999")))
.map(|l| l["project"].as_str().map(str::to_string))
.collect();
assert_eq!(viewer, served, "entry {entry}");
}
}