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

+1 -1
View File
@@ -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"));
+114
View File
@@ -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}"
);
}
@@ -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.
+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 {
+32 -9
View File
@@ -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).
+15
View File
@@ -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