From 07f213c9e5d5abb38a3ea6261c621f58e1356c38 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sat, 24 Oct 2026 10:56:58 -0600 Subject: [PATCH] feat(mcp): void, control check, payment method and reference (P4b S1, part B) books.void_document voids an invoice or bill that should never have existed by reversing its own entry with the reason (refused while a payment stands on it, naming that payment). books.control_check ties receivables and payables to their documents (B-23, B-38 INVARIANT 1): balance, documents' total, lines that are no document's, the documents needing attention; books.verify checks the identity and books.digest lists its problems (control_problems). books.receive_payment and books.pay_bill take instrument (tagged in the same call) and reference (in the description), and answer the document's derived remaining and status. The books record payments; they never collect or make them (D-45). Docs, handbook and STATUS; the over-the-wire test documents_void_and_tie_out_over_the_wire; the happy-path journey covers both tools' output schemas. The processor fee waits for S6. 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 | 114 ++++++++ .../handbook/revenue-and-payouts.md | 1 + crates/ledger-mcp/src/scopes.rs | 4 +- crates/ledger-mcp/src/server.rs | 263 +++++++++++++++--- crates/ledger-mcp/src/types.rs | 106 ++++++- docs/mcp-interface.md | 41 ++- plan/STATUS.md | 15 + 8 files changed, 502 insertions(+), 44 deletions(-) diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index a34d02a..179b6db 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(), 118, "expected 118 tools, got {}", tools.len()); + assert_eq!(tools.len(), 120, "expected 120 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 63b3327..9006a6f 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -878,6 +878,19 @@ fn happy_path(name: &str) -> Mcp { ); m.ok("books.ar_aging", json!({"as_of": "2026-06-30"})); m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); + // P4b S1: a document voided, and the control accounts' tie-out. + let dup = m.ok( + "books.create_invoice", + json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02", + "lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}), + )["id"] + .as_u64() + .unwrap(); + m.ok( + "books.void_document", + json!({"kind": "invoice", "id": dup, "date": "2026-02-02", "reason": "entered twice"}), + ); + m.ok("books.control_check", json!({"as_of": "2026-06-30"})); // Fixed assets. let registered = m.ok( @@ -2503,3 +2516,104 @@ fn db_digest(path: &Path) -> String { } ledger_store::journal::sha256_hex(dump.as_bytes()) } + +/// P4b S1 over the real server (rules.md B-38, D-45): a payment carries +/// its method and reference and answers with the invoice's derived +/// figures; an invoice with a payment standing can't be voided (the +/// refusal names the payment); one entered twice is voided and shows as +/// void; a hand line to receivables is refused; and the control check +/// ties receivables to the open invoices, with nothing for the digest. +#[test] +fn documents_void_and_tie_out_over_the_wire() { + let dir = test_dir("p4b_s1"); + 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(); + m.ok( + "books.add_instrument", + json!({"kind": "bank", "label": "Harbor checking", "last4": "1234", "account": "1000"}), + ); + let inv = m.ok( + "books.create_invoice", + json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + "lines": [{"description": "Consulting", "amount": "500.00"}]}), + )["id"] + .as_u64() + .unwrap(); + let paid = m.ok( + "books.receive_payment", + json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", + "cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}), + ); + assert_eq!(paid["remaining"], "300.00", "{paid}"); + assert_eq!(paid["status"], "partial", "{paid}"); + let payment = paid["entry_id"].as_u64().unwrap(); + let entry = m.ok("books.get_entry", json!({"entry_id": payment})); + assert!( + entry["description"].as_str().unwrap().contains("tr_8812"), + "{entry}" + ); + assert_eq!(entry["instrument"], "Harbor checking", "{entry}"); + + let why = m + .tool( + "books.void_document", + json!({"kind": "invoice", "id": inv, "date": "2026-02-11", "reason": "wrong client"}), + ) + .err_text(); + assert!(why.contains(&format!("entry {payment}")), "{why}"); + + let dup = m.ok( + "books.create_invoice", + json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02", + "lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}), + )["id"] + .as_u64() + .unwrap(); + let void = m.ok( + "books.void_document", + json!({"kind": "invoice", "id": dup, "date": "2026-02-02", "reason": "entered twice"}), + ); + assert_eq!(void["status"], "void", "{void}"); + + let e = m.ok( + "books.draft_entry", + json!({"date": "2026-02-15", "description": "By hand", "no_party_reason": "test"}), + )["entry_id"] + .as_u64() + .unwrap(); + assert!( + m.tool( + "books.add_line", + json!({"entry_id": e, "account": "1200", "side": "debit", "amount": "50.00"}), + ) + .refused() + ); + + let check = m.ok("books.control_check", json!({"as_of": "2026-06-30"})); + assert_eq!(check["agrees"], true, "{check}"); + let ar = check["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["role"] == "receivables") + .cloned() + .unwrap(); + assert_eq!(ar["code"], "1200", "{check}"); + assert_eq!(ar["balance"], "300.00", "{check}"); + assert_eq!(ar["documents_total"], "300.00", "{check}"); + assert_eq!(ar["difference"], "0.00", "{check}"); + let digest = m.ok("books.digest", json!({"since": "2026-01-01"})); + assert_eq!(digest["control_problems"], json!([]), "{digest}"); + let verified = m.ok("books.verify", json!({})); + assert!( + verified["checked"].as_array().unwrap().iter().any(|c| c + .as_str() + .unwrap() + .starts_with("receivables and payables equal")), + "{verified}" + ); +} diff --git a/crates/ledger-mcp/handbook/revenue-and-payouts.md b/crates/ledger-mcp/handbook/revenue-and-payouts.md index abe8953..8701c36 100644 --- a/crates/ledger-mcp/handbook/revenue-and-payouts.md +++ b/crates/ledger-mcp/handbook/revenue-and-payouts.md @@ -2,3 +2,4 @@ - Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post. - Invoices and bills: books.create_invoice, books.receive_payment, books.create_bill and books.pay_bill post to the control accounts (the control-roles section). +- An invoice's or bill's figures come from its ledger lines that still stand: reversing a payment un-pays it, and nothing is ever typed into Accounts Receivable or Accounts Payable by hand (refused). Record each payment with its instrument and the processor's or bank's reference; the books record payments, they never collect or make them (the human or a delegate does). A document that should never have existed is voided (books.void_document, with the reason), not deleted: reverse any payment on it first. books.control_check ties receivables and payables to their documents; anything it lists goes to the human. diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index af6c8d5..509c7aa 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); 118] = [ +pub const TABLE: [(&str, Needs); 120] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -165,6 +165,8 @@ pub const TABLE: [(&str, Needs); 118] = [ ("books.create_bill", P), ("books.receive_payment", P), ("books.pay_bill", P), + ("books.void_document", P), + ("books.control_check", 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 5ed8248..69fb698 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1535,6 +1535,135 @@ fn resolve_account(store: &Store, account: &str) -> Result { } } +/// A payment's method and reference from its parameters (the method by +/// id or label, books.instruments). +fn payment_details( + store: &Store, + p: &PaymentParams, +) -> Result { + Ok(ledger_core::PaymentDetails { + instrument: p + .instrument + .as_deref() + .map(|r| instruments::resolve_instrument(store, r)) + .transpose()?, + reference: p.reference.clone(), + }) +} + +/// What a payment tool answers: the document's figures after it, derived +/// from its lines that stand (rules.md B-38), as of the end of the books. +fn payment_out( + ledger: &Ledger, + doc: ledger_core::DocRef, + entry_id: u64, +) -> Result, String> { + let st = ledger + .doc_state(doc, chrono::NaiveDate::MAX) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{} not found", doc_label(doc)))?; + Ok(Json(PaymentOut { + entry_id, + remaining: money(st.outstanding), + status: st.status.key().to_string(), + })) +} + +/// "invoice" or "bill" and an id, as a document. +fn doc_ref(kind: &str, id: u64) -> Result { + match kind.trim().to_ascii_lowercase().as_str() { + "invoice" => Ok(ledger_core::DocRef::Invoice(id)), + "bill" => Ok(ledger_core::DocRef::Bill(id)), + other => Err(format!("bad kind {other:?}: expected invoice or bill")), + } +} + +/// A document's kind and id for people ("invoice 7"). +fn doc_label(doc: ledger_core::DocRef) -> String { + match doc { + ledger_core::DocRef::Invoice(id) => format!("invoice {id}"), + ledger_core::DocRef::Bill(id) => format!("bill {id}"), + } +} + +/// What the digest lists from the control check: each line on a control +/// account that is no document's, and each document needing attention. +fn control_problems(out: &ControlCheckOut) -> Vec { + let mut v = Vec::new(); + for a in &out.accounts { + for u in &a.unattached { + v.push(format!( + "{} {}: entry {} line {} ({}, {}) is no invoice's or bill's", + a.code, a.name, u.entry_id, u.line, u.date, u.amount + )); + } + for d in &a.attention { + v.push(format!( + "{} {}: {} {} is {} (total {}, owes {})", + a.code, a.name, d.kind, d.id, d.status, d.total, d.outstanding + )); + } + } + v +} + +/// The control check as `books.control_check` answers it. +fn control_check_out( + ledger: &Ledger, + as_of: chrono::NaiveDate, + checks: &[ledger_core::ControlCheck], +) -> ControlCheckOut { + let accounts: Vec = checks + .iter() + .map(|c| { + let acct = ledger.chart().get(c.account); + ControlCheckRowOut { + role: c.role.clone(), + code: acct.map(|a| a.code.clone()).unwrap_or_default(), + name: acct.map(|a| a.name.clone()).unwrap_or_default(), + balance: money(c.balance), + documents_total: money(c.documents_total), + unattached: c + .unattached + .iter() + .map(|u| UnattachedLineOut { + entry_id: u.entry, + line: u.line, + date: u.date.to_string(), + amount: money(u.amount), + }) + .collect(), + difference: money(c.difference), + attention: c + .attention + .iter() + .map(|a| { + let (kind, id) = match a.doc { + ledger_core::DocRef::Invoice(id) => ("invoice", id), + ledger_core::DocRef::Bill(id) => ("bill", id), + }; + DocAttentionOut { + kind: kind.to_string(), + id, + status: a.status.key().to_string(), + total: money(a.total), + outstanding: money(a.outstanding), + } + }) + .collect(), + } + }) + .collect(); + let agrees = checks + .iter() + .all(|c| c.difference.is_zero() && c.unattached.is_empty() && c.attention.is_empty()); + ControlCheckOut { + as_of: as_of.to_string(), + agrees, + accounts, + } +} + /// Resolve a vendor by id or name (case-insensitive); a number that is /// one vendor's id and another's name is refused (INVARIANT 6). fn resolve_vendor(store: &Store, vendor: &str) -> Result { @@ -4363,6 +4492,24 @@ impl BooksServer { if let Some(n) = blobs { checked.push(format!("{n} stored files match their SHA-256")); } + // Receivables and payables equal their documents (rules.md B-38 + // INVARIANT 1): an identity, so a difference is an engine error. + let checks = store + .ledger() + .control_check(chrono::NaiveDate::MAX) + .map_err(|e| e.to_string())?; + if let Some(c) = checks.iter().find(|c| !c.difference.is_zero()) { + return Err(format!( + "{}: its balance {} is not its documents' {} plus its other lines: an engine error, report it", + c.role, + money(c.balance), + money(c.documents_total) + )); + } + checked.push(format!( + "receivables and payables equal their documents and other lines ({} control accounts)", + checks.len() + )); Ok(Json(VerifyOut { status: if report.healed.is_some() { "healed" @@ -4383,7 +4530,7 @@ impl BooksServer { #[tool( name = "books.digest", title = "Activity digest", - description = "What changed since a date: entries posted, per-account movement, and receipt coverage; plus what needs doing: rules due for review, settings problems, cash and card accounts not reconciled through the end of last month (unreconciled_accounts), and revenue and expense accounts with no tax category (uncategorized_accounts). Built for human review.", + description = "What changed since a date: entries posted, per-account movement, and receipt coverage; plus what needs doing: rules due for review, settings problems, cash and card accounts not reconciled through the end of last month (unreconciled_accounts), revenue and expense accounts with no tax category (uncategorized_accounts), and receivables or payables lines that are no document's or documents needing attention (control_problems, books.control_check). Built for human review.", annotations(read_only_hint = true, open_world_hint = false) )] async fn digest(&self, params: Parameters) -> Result, String> { @@ -4457,9 +4604,15 @@ impl BooksServer { .into_iter() .map(|a| format!("{} {}", a.code, a.name)) .collect(); + let control_problems = control_problems(&control_check_out( + ledger, + today, + &ledger.control_check(today).map_err(|e| e.to_string())?, + )); Ok(Json(DigestOut { unreconciled_accounts, uncategorized_accounts, + control_problems, since: since.to_string(), entries, totals_by_account: totals, @@ -8421,7 +8574,7 @@ impl BooksServer { #[tool( name = "books.receive_payment", title = "Receive an invoice payment", - description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R.", + description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments) and the processor's or bank's reference when known. The books record payments; they never collect them (D-45). Refused above what it owes, before its date, or into an account that isn't cash.", annotations( read_only_hint = false, destructive_hint = true, @@ -8435,33 +8588,27 @@ impl BooksServer { let p = params.0; let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; + let details = payment_details(&store, &p)?; let entry_id = store - .receive_payment( + .receive_payment_with( p.document_id, parse_date(&p.date)?, parse_amount(&p.amount)?, cash, + &details, ) .map_err(|e| e.to_string())?; - let inv = store - .ledger() - .get_invoice(p.document_id) - .ok_or_else(|| format!("invoice {} not found", p.document_id))?; - Ok(Json(PaymentOut { + payment_out( + store.ledger(), + ledger_core::DocRef::Invoice(p.document_id), entry_id, - remaining: inv.balance().map_err(|e| e.to_string())?.to_string(), - status: match inv.status { - DocumentStatus::Open => "open".to_string(), - DocumentStatus::Partial => "partial".to_string(), - DocumentStatus::Paid => "paid".to_string(), - }, - })) + ) } #[tool( name = "books.pay_bill", title = "Pay a bill", - description = "Pay a vendor bill. Posts Dr A/P / Cr Cash.", + description = "Record a payment of a vendor bill. Posts Dr A/P / Cr Cash, the payable line tagged with the bill (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments: a card payment is left out of 1099-NEC) and the payment's reference when known. The books record payments; they never make them (D-45). Refused above what it owes, before its date, or from an account that isn't cash.", annotations( read_only_hint = false, destructive_hint = true, @@ -8475,29 +8622,76 @@ impl BooksServer { let p = params.0; let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; + let details = payment_details(&store, &p)?; let entry_id = store - .pay_bill( + .pay_bill_with( p.document_id, parse_date(&p.date)?, parse_amount(&p.amount)?, cash, + &details, ) .map_err(|e| e.to_string())?; - let bill = store - .ledger() - .get_bill(p.document_id) - .ok_or_else(|| format!("bill {} not found", p.document_id))?; - Ok(Json(PaymentOut { + payment_out( + store.ledger(), + ledger_core::DocRef::Bill(p.document_id), entry_id, - remaining: bill.balance().map_err(|e| e.to_string())?.to_string(), - status: match bill.status { - DocumentStatus::Open => "open".to_string(), - DocumentStatus::Partial => "partial".to_string(), - DocumentStatus::Paid => "paid".to_string(), - }, + ) + } + + #[tool( + name = "books.void_document", + title = "Void an invoice or bill", + description = "Void an invoice or bill that should never have existed (a duplicate, the wrong customer or vendor): reverses its own entry, dated as given, with the reason. It keeps its number and shows as void; nothing is deleted. Refused while a payment stands on it (reverse that payment first: the refusal names it), when it is void already, before its date, or in a closed period. To lower a valid document, not void it: a credit memo (later). Posts at once.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn void_document( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let doc = doc_ref(&p.kind, p.id)?; + let date = parse_date(&p.date)?; + let mut store = self.lock()?; + let entry_id = store + .void_document(doc, date, &p.reason) + .map_err(|e| e.to_string())?; + let status = store + .ledger() + .doc_state(doc, date) + .map_err(|e| e.to_string())? + .map_or("void", |st| st.status.key()); + Ok(Json(VoidDocumentOut { + entry_id, + status: status.to_string(), })) } + #[tool( + name = "books.control_check", + title = "Receivables and payables agree", + description = described( + "Check that Accounts Receivable and Accounts Payable equal their documents as of a day: for each control account, its ledger balance, what its open invoices or bills still owe on it, every line on it that is no document's (a hand entry or an older version's), the difference (always 0.00), and the documents needing attention (overpaid, or not in the ledger). agrees is true when there is nothing to look at. The first thing a CPA ties out.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn control_check( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let store = self.lock()?; + let ledger = store.ledger(); + let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; + Ok(Json(control_check_out(ledger, as_of, &checks))) + } + #[tool( name = "books.ar_aging", title = "Receivables aging", @@ -10514,7 +10708,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); 118] = [ + let table: [(&str, Hints); 120] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -10616,6 +10810,8 @@ mod tests { ("books.create_bill", D), ("books.receive_payment", D), ("books.pay_bill", D), + ("books.void_document", D), + ("books.control_check", R), ("books.ar_aging", R), ("books.ap_aging", R), ("books.set_fx_rate", W), @@ -10672,8 +10868,8 @@ mod tests { "{name}: openWorldHint" ); } - // docs/mcp-interface.md names the 28 destructive tools. - assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 28); + // docs/mcp-interface.md names the 29 destructive tools. + assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); } /// Every refusal an agent can be given whose text is fixed here, or @@ -11293,6 +11489,11 @@ mod tests { PeriodKind::AsOf, &[("as_of", AS_OF_DOC)][..], ), + ( + "books.control_check", + 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 73a4c1e..b716b96 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -2079,6 +2079,10 @@ pub struct DigestOut { /// shows. Propose a category for each from books.tax_categories and /// set it with books.set_tax_category once the human agrees. pub uncategorized_accounts: Vec, + /// Receivables and payables lines that are no document's, and + /// documents needing attention (overpaid, not in the ledger), as of + /// today (books.control_check): look at each with the human. + pub control_problems: Vec, } /// An account due for reconciliation (`books.digest`). @@ -3098,6 +3102,16 @@ pub struct PaymentParams { pub amount: String, /// Cash account id or code. pub cash_account: String, + /// How the money moved: a payment method's id or label + /// (`books.instruments`), tagged on the payment's entry in the same + /// call (D-40: a card payment is left out of 1099-NEC). + #[serde(default)] + pub instrument: Option, + /// The processor's or bank's own reference for this payment (a charge + /// id, a trace or check number): one line, at most 200 characters, + /// carried in the entry's description (D-45). + #[serde(default)] + pub reference: Option, } /// Output of `books.receive_payment` / `books.pay_bill`. @@ -3105,12 +3119,100 @@ pub struct PaymentParams { pub struct PaymentOut { /// The payment journal entry's id. pub entry_id: u64, - /// Remaining balance, decimal string. + /// What the document still owes after it, decimal string (derived + /// from its lines that stand). pub remaining: String, - /// "open", "partial", or "paid". + /// The document's status after it: open, partial, paid, written_off, + /// void, overpaid, or unattached. pub status: String, } +/// Parameters for `books.void_document`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VoidDocumentParams { + /// "invoice" or "bill". + pub kind: String, + /// The invoice's or bill's id. + pub id: u64, + /// The void's date, YYYY-MM-DD: on or after the document's, in an + /// open period. + pub date: String, + /// Why it should never have existed (a duplicate, the wrong + /// customer): one line, at most 200 characters. + pub reason: String, +} + +/// Output of `books.void_document`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VoidDocumentOut { + /// The reversal entry that voids it. + pub entry_id: u64, + /// The document's status now ("void"). + pub status: String, +} + +/// One line on a control account that is no document's. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UnattachedLineOut { + /// The entry. + pub entry_id: u64, + /// The line's index in it. + pub line: usize, + /// The entry's date. + pub date: String, + /// Its amount as the control account counts it (plus raises it). + pub amount: String, +} + +/// A document the control check lists for attention. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DocAttentionOut { + /// "invoice" or "bill". + pub kind: String, + /// Its id. + pub id: u64, + /// Its status as of the day (overpaid, unattached, ...). + pub status: String, + /// Its total. + pub total: String, + /// What it owes as of the day. + pub outstanding: String, +} + +/// One control account's tie-out. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ControlCheckRowOut { + /// "receivables" or "payables". + pub role: String, + /// The account's code. + pub code: String, + /// The account's name. + pub name: String, + /// Its general-ledger balance on its normal side. + pub balance: String, + /// What its documents still owe on it. + pub documents_total: String, + /// Its lines that are no document's (hand or older lines). + pub unattached: Vec, + /// balance - documents_total - the unattached lines: always "0.00". + pub difference: String, + /// The role's documents needing attention. + pub attention: Vec, +} + +/// Output of `books.control_check`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ControlCheckOut { + /// The day. + pub as_of: String, + /// True when every control account equals its documents (no + /// unattached line, nothing needing attention, no difference). + pub agrees: bool, + /// One row per control account. + pub accounts: Vec, +} + /// Parameters for `books.ar_aging` / `books.ap_aging`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AgingParams { diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index aa553f5..a8afc16 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -342,7 +342,7 @@ Tool descriptions are at most 700 characters; eight longer ones are listed in `tool_descriptions_fit_700_characters`, to be shortened in P5. **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool -that changes anything also has a `destructiveHint`, true for these 28: +that changes anything also has a `destructiveHint`, true for these 29: `books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.unlink_attachment`, `books.withdraw_attachment`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, @@ -352,7 +352,8 @@ that changes anything also has a `destructiveHint`, true for these 28: `books.void_statement`, `books.void_reconciliation`, `books.set_w9`, `books.set_tin` (both overwrite a vault record), `books.purge_tin`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, -`books.pay_bill`, `books.run_payroll`, `books.remit_payroll_taxes`; and an +`books.pay_bill`, `books.void_document`, `books.run_payroll`, +`books.remit_payroll_taxes`; and an `idempotentHint` on the ten that a repeat with the same arguments leaves the journal and the vault as they were: `books.verify`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, @@ -1997,13 +1998,35 @@ accounting only: the caller supplies every figure), tax filing. never tagged; `notes` as for invoices, but none for an on-spec project (a cost of on-spec work is an expense like any other); the feature as for invoices. -- `books.receive_payment { document_id, date, amount, cash_account }` → - `{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R, neither line - tagged with a project (balance-sheet only: the invoice's lines carry - its projects). -- `books.pay_bill { document_id, date, amount, cash_account }` → - `{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash, neither line - tagged with a project. +- `books.receive_payment { document_id, date, amount, cash_account, + instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts + Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38), + neither line tagged with a project (balance-sheet only: the invoice's + lines carry its projects). `instrument` (id or label) is tagged on the + entry in the same call; `reference` (one line, at most 200 characters: + a charge id, a trace number) goes into its description. `remaining` + and `status` (open, partial, paid, written_off, void, overpaid, + unattached) are the invoice's derived figures after it. The books + record payments; they never collect them (D-45). +- `books.pay_bill { document_id, date, amount, cash_account, instrument?, + reference? }` → `{ entry_id, remaining, status }`. Posts Dr A/P / Cr + Cash, the payable line tagged with the bill, neither line tagged with a + project; `instrument` and `reference` as for `receive_payment` (a card + payment is left out of 1099-NEC, D-40). +- `books.void_document { kind, id, date, reason }` → `{ entry_id, status }`. + Voids an invoice or bill (`kind` `invoice` or `bill`) that should never + have existed by reversing its own entry with the reason; it keeps its + 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.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, + outstanding }] }] }`. Receivables and payables tied to their documents + (B-23, B-38 INVARIANT 1): `difference` is always `0.00`; `agrees` when + there is no unattached line and nothing needing attention. + `books.verify` checks the same identity and `books.digest` lists its + problems (`control_problems`). - `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30, days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today). - `books.ap_aging { as_of? }` → same, per vendor (`as_of` omitted: today). diff --git a/plan/STATUS.md b/plan/STATUS.md index 6e35ffb..50e4331 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -221,6 +221,21 @@ token keeps up to 64 handed files (was 16). The tests file by upload (`tests/common`: `Wire::upload`, `upload_on`) and read every file by its download (`docs/testing.md`, "D-46"). +**P4b slice S1, receivables and payables read their figures from the +ledger (2026-10-24, D-45).** Part A (ledger-core, store, viewer, Lean +`control_agrees`, Kani bounds): an invoice's or bill's paid, outstanding +and status are derived from its tagged lines that still stand, so a +reversed payment no longer counts and a void is exact; accounts in the +receivables or payables role (or holding document lines) refuse hand +lines; format feature `document_standing` (the owner upgraded +2026-10-24). Part B (MCP): `books.void_document`, `books.control_check`, +`instrument` and `reference` on `books.receive_payment` and +`books.pay_bill` (status and remaining from the derived figures), the +tie-out in `books.verify` and `control_problems` in `books.digest`; the +over-the-wire test `documents_void_and_tie_out_over_the_wire`. Still for +later slices: the processor fee on a payment (S6, with multi-document +payments and discounts), Needs attention in the viewer for the control +check, and S2 to S7. **Still open after P3 (each for the phase named, or the lead):** - Invoices and bills take a project now (above), behind the new journal feature `document_line_projects`: the first company's books declared