feat(mcp): void, control check, payment method and reference (P4b S1, part B)
check / check (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 12:22:53 -06:00
1 parent ebb5fcb46f
commit 07f213c9e5
8 files changed
+502 -44

No files matched your search

+3 -1
View File
@@ -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),
+232 -31
View File
@@ -1535,6 +1535,135 @@ fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
}
}
/// 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<ledger_core::PaymentDetails, String> {
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<Json<PaymentOut>, 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<ledger_core::DocRef, String> {
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<String> {
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<ControlCheckRowOut> = 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<u64, String> {
@@ -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<DigestParams>) -> Result<Json<DigestOut>, 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<VoidDocumentParams>,
) -> Result<Json<VoidDocumentOut>, 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<AgingParams>,
) -> Result<Json<ControlCheckOut>, 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,
+104 -2
View File
@@ -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<String>,
/// 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<String>,
}
/// 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<String>,
/// 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<String>,
}
/// 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<UnattachedLineOut>,
/// balance - documents_total - the unattached lines: always "0.00".
pub difference: String,
/// The role's documents needing attention.
pub attention: Vec<DocAttentionOut>,
}
/// 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<ControlCheckRowOut>,
}
/// Parameters for `books.ar_aging` / `books.ap_aging`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgingParams {