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,