From 7d2bf0b75f559563bfa73dcd57819ea6eb6525d3 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sat, 24 Oct 2026 12:44:27 -0600 Subject: [PATCH] feat(mcp): books.documents and books.document (P4b S2, read tools) books.documents lists invoices or bills, due date first, with what each still owes and its status, all derived from its ledger lines that stand (B-38): status open (default), overdue, paid, void, written_off, overpaid or all; party and due_by narrow it (what to pay this week). books.document shows one (ref invoice:N or bill:N) as of a day: lines with projects, total, paid, discounted, written off, outstanding, status, days overdue, and its timeline of every tagged ledger line, each saying whether its entry still stands. The over-the-wire test documents_list_and_timeline_over_the_wire covers a reversed payment; the happy-path journey covers both output schemas. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- crates/ledger-cli/tests/mcp_journeys.rs | 2 +- crates/ledger-cli/tests/spec_mcp.rs | 122 ++++++++++++ crates/ledger-mcp/src/scopes.rs | 4 +- crates/ledger-mcp/src/server.rs | 250 +++++++++++++++++++++++- crates/ledger-mcp/src/types.rs | 134 +++++++++++++ docs/mcp-interface.md | 15 ++ 6 files changed, 524 insertions(+), 3 deletions(-) diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index 179b6db..c8f33cc 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -125,7 +125,7 @@ fn mcp_end_to_end() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 120, "expected 120 tools, got {}", tools.len()); + assert_eq!(tools.len(), 122, "expected 122 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index 9006a6f..9867644 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -891,6 +891,11 @@ fn happy_path(name: &str) -> Mcp { json!({"kind": "invoice", "id": dup, "date": "2026-02-02", "reason": "entered twice"}), ); m.ok("books.control_check", json!({"as_of": "2026-06-30"})); + m.ok( + "books.documents", + json!({"kind": "invoices", "status": "all"}), + ); + m.ok("books.document", json!({"ref": format!("invoice:{inv}")})); // Fixed assets. let registered = m.ok( @@ -2617,3 +2622,120 @@ fn documents_void_and_tie_out_over_the_wire() { "{verified}" ); } + +/// P4b S2 over the real server: books.documents lists what is owed, due +/// date first, and books.document shows one with its timeline; a +/// reversed payment shows twice (the payment, which no longer stands, +/// and its reversal, which does: the two net to nothing), and the invoice +/// owes its whole total again. +#[test] +fn documents_list_and_timeline_over_the_wire() { + let dir = test_dir("p4b_s2"); + let toml = books_toml(&dir, "600.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"] + .as_u64() + .unwrap(); + let invoice = |m: &mut Mcp, date: &str, due: &str, amount: &str| { + m.ok( + "books.create_invoice", + json!({"party_id": customer, "date": date, "due_date": due, + "lines": [{"description": "Consulting", "amount": amount}]}), + )["id"] + .as_u64() + .unwrap() + }; + let late = invoice(&mut m, "2026-02-01", "2026-03-01", "500.00"); + let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00"); + let pay = m.ok( + "books.receive_payment", + json!({"document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), + )["entry_id"] + .as_u64() + .unwrap(); + m.ok( + "books.reverse_entry", + json!({"entry_id": pay, "date": "2026-02-12", "description": "the transfer bounced", "confirmed": true}), + ); + + let open = m.ok( + "books.documents", + json!({"kind": "invoices", "as_of": "2026-03-15"}), + ); + let refs: Vec<&str> = open["documents"] + .as_array() + .unwrap() + .iter() + .map(|d| d["ref"].as_str().unwrap()) + .collect(); + assert_eq!( + refs, + [format!("invoice:{late}"), format!("invoice:{later}")], + "{open}" + ); + assert_eq!(open["outstanding_total"], "580.00", "{open}"); + assert_eq!(open["documents"][0]["overdue_days"], 14, "{open}"); + let overdue = m.ok( + "books.documents", + json!({"kind": "invoices", "status": "overdue", "as_of": "2026-03-15"}), + ); + assert_eq!( + overdue["documents"].as_array().unwrap().len(), + 1, + "{overdue}" + ); + let due_soon = m.ok( + "books.documents", + json!({"kind": "invoices", "due_by": "2026-03-31", "as_of": "2026-03-15"}), + ); + assert_eq!( + due_soon["documents"].as_array().unwrap().len(), + 1, + "{due_soon}" + ); + assert_eq!( + m.ok("books.documents", json!({"kind": "bills"}))["documents"], + json!([]) + ); + + let one = m.ok( + "books.document", + json!({"ref": format!("invoice:{late}"), "as_of": "2026-03-15"}), + ); + assert_eq!(one["outstanding"], "500.00", "{one}"); + assert_eq!(one["paid"], "0.00", "{one}"); + assert_eq!(one["status"], "open", "{one}"); + let moves: Vec<(String, bool, bool)> = one["timeline"] + .as_array() + .unwrap() + .iter() + .map(|e| { + ( + e["kind"].as_str().unwrap().to_string(), + e["reversal"].as_bool().unwrap(), + e["stands"].as_bool().unwrap(), + ) + }) + .collect(); + assert_eq!( + moves, + [ + ("origin".to_string(), false, true), + ("payment".to_string(), false, false), + ("payment".to_string(), true, true), + ], + "{one}" + ); + assert!( + m.tool("books.document", json!({"ref": "invoice"})) + .refused() + ); + assert!( + m.tool( + "books.documents", + json!({"kind": "invoices", "status": "late"}) + ) + .refused() + ); +} diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 509c7aa..2814e5d 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 120] = [ +pub const TABLE: [(&str, Needs); 122] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -167,6 +167,8 @@ pub const TABLE: [(&str, Needs); 120] = [ ("books.pay_bill", P), ("books.void_document", P), ("books.control_check", R), + ("books.documents", R), + ("books.document", R), ("books.ar_aging", R), ("books.ap_aging", R), ("books.set_fx_rate", W), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 69fb698..39078b7 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1578,6 +1578,42 @@ fn doc_ref(kind: &str, id: u64) -> Result { } } +/// A document's id. +fn doc_id(doc: ledger_core::DocRef) -> u64 { + match doc { + ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id, + } +} + +/// A document as books.documents and books.document name it +/// ("invoice:7", "bill:12"). +fn doc_ref_text(doc: ledger_core::DocRef) -> String { + match doc { + ledger_core::DocRef::Invoice(id) => format!("invoice:{id}"), + ledger_core::DocRef::Bill(id) => format!("bill:{id}"), + } +} + +/// "invoice:7" or "bill:12" as a document. +fn parse_doc_ref(r: &str) -> Result { + let bad = || format!("bad ref {r:?}: expected invoice:N or bill:N (from books.documents)"); + let (kind, id) = r.trim().split_once(':').ok_or_else(bad)?; + let id = id.trim().parse::().map_err(|_| bad())?; + doc_ref(kind, id).map_err(|_| bad()) +} + +/// A tagged line's movement on the wire. +fn movement_key(m: ledger_core::Movement) -> &'static str { + match m { + ledger_core::Movement::Origin => "origin", + ledger_core::Movement::Payment => "payment", + ledger_core::Movement::Discount => "discount", + ledger_core::Movement::WriteOff => "write_off", + ledger_core::Movement::Adopted => "adopted", + ledger_core::Movement::RoleMove => "role_move", + } +} + /// A document's kind and id for people ("invoice 7"). fn doc_label(doc: ledger_core::DocRef) -> String { match doc { @@ -8671,6 +8707,206 @@ impl BooksServer { })) } + #[tool( + name = "books.documents", + title = "List invoices or bills", + description = described( + "List invoices (kind invoices: money owed to the company) or bills (kind bills: money it owes), due date first: each with its ref (for books.document), party, dates, total, what it still owes and its status, all derived from its ledger lines that stand. status: open (default: anything still owed), overdue, paid, void, written_off, overpaid, or all; party and due_by narrow it (due_by: what is due by a day, e.g. what to pay this week).", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn documents( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let due_by = p.due_by.as_deref().map(parse_date).transpose()?; + let invoices = match p.kind.trim().to_ascii_lowercase().as_str() { + "invoices" | "invoice" => true, + "bills" | "bill" => false, + other => return Err(format!("bad kind {other:?}: expected invoices or bills")), + }; + let want = p + .status + .as_deref() + .unwrap_or("open") + .trim() + .to_ascii_lowercase(); + if !matches!( + want.as_str(), + "open" | "overdue" | "paid" | "void" | "written_off" | "overpaid" | "all" + ) { + return Err(format!( + "bad status {want:?}: expected open, overdue, paid, void, written_off, overpaid or all" + )); + } + let store = self.lock()?; + let ledger = store.ledger(); + let docs: Vec<(ledger_core::DocRef, String, NaiveDate, NaiveDate)> = if invoices { + ledger + .invoices() + .into_iter() + .map(|i| { + let who = ledger + .get_customer(i.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + (ledger_core::DocRef::Invoice(i.id), who, i.date, i.due_date) + }) + .collect() + } else { + ledger + .bills() + .into_iter() + .map(|b| { + let who = ledger + .get_vendor(b.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(); + (ledger_core::DocRef::Bill(b.id), who, b.date, b.due_date) + }) + .collect() + }; + let party = p.party.as_deref().map(|s| s.trim().to_lowercase()); + let mut rows = Vec::new(); + let mut owed = Decimal::ZERO; + for (doc, who, date, due) in docs { + if let Some(want_party) = &party + && who.to_lowercase() != *want_party + && doc_id(doc).to_string() != *want_party + { + continue; + } + if due_by.is_some_and(|d| due > d) { + continue; + } + let Some(st) = ledger.doc_state(doc, as_of).map_err(|e| e.to_string())? else { + continue; + }; + let key = st.status.key(); + let keep = match want.as_str() { + "all" => true, + "open" => !st.outstanding.is_zero() && key != "void", + "overdue" => st.overdue_days > 0, + other => key == other, + }; + if !keep { + continue; + } + owed = owed + .checked_add(st.outstanding) + .ok_or("the total is too large to compute")?; + rows.push(( + due, + doc_id(doc), + DocumentRowOut { + r#ref: doc_ref_text(doc), + party: who, + date: date.to_string(), + due_date: due.to_string(), + total: money(st.total), + outstanding: money(st.outstanding), + status: key.to_string(), + overdue_days: st.overdue_days, + }, + )); + } + rows.sort_by_key(|r| (r.0, r.1)); + Ok(Json(DocumentsOut { + as_of: as_of.to_string(), + kind: if invoices { "invoices" } else { "bills" }.to_string(), + documents: rows.into_iter().map(|r| r.2).collect(), + outstanding_total: money(owed), + })) + } + + #[tool( + name = "books.document", + title = "An invoice or bill", + description = described( + "One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents): party, dates, lines with their projects, total, what was paid, discounted and written off, what it still owes and its status, all as of a day; and its timeline: every ledger line tagged with it in posting order (its own entry, each payment, each reversal), each saying whether its entry still stands. A reversed payment shows twice: the payment (no longer standing) and its reversal (standing), which net to nothing.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn document( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let doc = parse_doc_ref(&p.r#ref)?; + let store = self.lock()?; + let ledger = store.ledger(); + let (who, date, due, entry_id, lines) = match doc { + ledger_core::DocRef::Invoice(id) => { + let i = ledger + .get_invoice(id) + .ok_or_else(|| format!("no invoice {id}: see books.documents"))?; + let who = ledger + .get_customer(i.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + (who, i.date, i.due_date, i.entry_id, i.lines.clone()) + } + ledger_core::DocRef::Bill(id) => { + let b = ledger + .get_bill(id) + .ok_or_else(|| format!("no bill {id}: see books.documents"))?; + let who = ledger + .get_vendor(b.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(); + (who, b.date, b.due_date, b.entry_id, b.lines.clone()) + } + }; + let st = ledger + .doc_state(doc, as_of) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{} is dated after {as_of}", doc_ref_text(doc)))?; + let timeline = ledger + .doc_lines(doc) + .into_iter() + .filter(|l| l.date <= as_of) + .map(|l| DocumentEventOut { + date: l.date.to_string(), + entry_id: l.entry, + kind: movement_key(l.movement).to_string(), + amount: money(l.amount), + reversal: l.reversal, + stands: ledger.stands_on(l.entry, as_of), + }) + .collect(); + Ok(Json(DocumentOut { + r#ref: doc_ref_text(doc), + party: who, + date: date.to_string(), + due_date: due.to_string(), + entry_id, + lines: lines + .iter() + .map(|l| DocumentLineOut { + description: l.description.clone(), + amount: money(l.amount), + project: l + .class_id + .and_then(|c| ledger.get_class(c)) + .map(|c| c.name.clone()), + }) + .collect(), + total: money(st.total), + paid: money(st.paid), + discounted: money(st.discounted), + written_off: money(st.written_off), + outstanding: money(st.outstanding), + status: st.status.key().to_string(), + overdue_days: st.overdue_days, + timeline, + })) + } + #[tool( name = "books.control_check", title = "Receivables and payables agree", @@ -10708,7 +10944,7 @@ mod tests { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 120] = [ + let table: [(&str, Hints); 122] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -10812,6 +11048,8 @@ mod tests { ("books.pay_bill", D), ("books.void_document", D), ("books.control_check", R), + ("books.documents", R), + ("books.document", R), ("books.ar_aging", R), ("books.ap_aging", R), ("books.set_fx_rate", W), @@ -11494,6 +11732,16 @@ mod tests { PeriodKind::AsOf, &[("as_of", AS_OF_DOC)][..], ), + ( + "books.documents", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.document", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), ( "books.ar_aging", PeriodKind::AsOf, diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index b716b96..f6587ae 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3127,6 +3127,140 @@ pub struct PaymentOut { pub status: String, } +/// Parameters for `books.documents`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DocumentsParams { + /// "invoices" or "bills". + pub kind: String, + /// Which: open (default: anything still owed, partly paid and + /// overdue included), overdue, paid, void, written_off, overpaid, or + /// all. + #[serde(default)] + pub status: Option, + /// Only this customer's or vendor's (party or customer/vendor name, or + /// id). + #[serde(default)] + pub party: Option, + /// Only those due on or before this day, YYYY-MM-DD. + #[serde(default)] + pub due_by: Option, + /// As of this day ([`AS_OF_DOC`]). + #[serde(default)] + #[schemars(description = AS_OF_DOC)] + pub as_of: Option, +} + +/// One document in a list. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DocumentRowOut { + /// "invoice:7" or "bill:12": what books.document takes. + pub r#ref: String, + /// The customer or vendor. + pub party: String, + /// Its date. + pub date: String, + /// When payment is due. + pub due_date: String, + /// The sum of its lines. + pub total: String, + /// What it still owes as of the day. + pub outstanding: String, + /// open, partial, paid, written_off, void, overpaid, or unattached. + pub status: String, + /// Days past due while something is owed; 0 otherwise. + pub overdue_days: i64, +} + +/// Output of `books.documents`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DocumentsOut { + /// The day. + pub as_of: String, + /// "invoices" or "bills". + pub kind: String, + /// Due date first, then number. + pub documents: Vec, + /// What the listed documents still owe, together. + pub outstanding_total: String, +} + +/// Parameters for `books.document`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DocumentParams { + /// "invoice:7" or "bill:12". + pub r#ref: String, + /// As of this day ([`AS_OF_DOC`]). + #[serde(default)] + #[schemars(description = AS_OF_DOC)] + pub as_of: Option, +} + +/// One line of a document. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DocumentLineOut { + /// What it is for. + pub description: String, + /// Its amount. + pub amount: String, + /// The project it belongs to, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, +} + +/// One event in a document's life: a ledger line tagged with it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DocumentEventOut { + /// The entry's date. + pub date: String, + /// The entry. + pub entry_id: u64, + /// What it is: origin (the document itself), payment, discount, + /// write_off, adopted, or role_move. + pub kind: String, + /// What it did to what is owed (plus raises it). + pub amount: String, + /// The entry reverses another (this line undoes an earlier one). + pub reversal: bool, + /// Whether its entry still stands as of the day: a payment that was + /// reversed doesn't; its reversal does, and the two net to nothing. + pub stands: bool, +} + +/// Output of `books.document`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DocumentOut { + /// "invoice:7" or "bill:12". + pub r#ref: String, + /// The customer or vendor. + pub party: String, + /// Its date. + pub date: String, + /// When payment is due. + pub due_date: String, + /// The entry that recorded it. + pub entry_id: u64, + /// Its lines. + pub lines: Vec, + /// The sum of its lines. + pub total: String, + /// Cash paid against it that stands. + pub paid: String, + /// Discounts taken that stand. + pub discounted: String, + /// Amounts written off that stand. + pub written_off: String, + /// What it still owes as of the day. + pub outstanding: String, + /// Its status as of the day. + pub status: String, + /// Days past due while something is owed; 0 otherwise. + pub overdue_days: i64, + /// Every ledger line tagged with it, in posting order. + pub timeline: Vec, +} + /// Parameters for `books.void_document`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index a8afc16..53ee38a 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2019,6 +2019,21 @@ accounting only: the caller supplies every figure), tax filing. number and reads `void`. Refused while a payment stands on it (the refusal names the payment entry to reverse first), when void already, before its date or in a closed period (P4b S1, B-38). +- `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of, + kind, documents: [{ ref, party, date, due_date, total, outstanding, + status, overdue_days }], outstanding_total }`. Invoices (`kind` + `invoices`) or bills (`bills`), due date first, every figure derived + from the document's lines that stand (B-38). `status` `open` (default: + anything still owed), `overdue`, `paid`, `void`, `written_off`, + `overpaid` or `all`; `party` (a customer's or vendor's name or id) and + `due_by` narrow the list (P4b S2). +- `books.document { ref, as_of? }` → `{ ref, party, date, due_date, + entry_id, lines: [{ description, amount, project? }], total, paid, + discounted, written_off, outstanding, status, overdue_days, timeline: + [{ date, entry_id, kind, amount, reversal, stands }] }`. `ref` is + `invoice:N` or `bill:N`; the timeline is every ledger line tagged with + the document in posting order, each saying whether its entry still + stands as of the day (P4b S2). - `books.control_check { as_of? }` → `{ as_of, agrees, accounts: [{ role, code, name, balance, documents_total, unattached: [{ entry_id, line, date, amount }], difference, attention: [{ kind, id, status, total,