feat(mcp): payments on terms, bills due, and the agings' tie-out (P4b S6)

books.receive_payment and books.pay_bill take applications, each
document's cash with an optional discount and reason, and a customer
payment may carry the processor's fee; the result names every document it
settled and what reached the account. books.bills_due lists what to pay
and the running total by due date. The agings take detail and return the
control account's tie-out, and books.export writes ar_aging/ap_aging.

The handlers live in tools/settlements.rs (the EM's split, so S4.4's
documents.rs stays free); documents.rs keeps the documents, the void and
the control check. docs/mcp-interface.md documents every changed tool and
the new one; docs/architecture.md names the group; docs/testing.md
records the tests and P12's mutation targets.

Old names (document_id, cash_account) still work; the output keeps
remaining/status for one document. A fee's processor is named in the
description, not tagged as the entry's vendor: ledger tags are entry-wide
and tagging would move the customer's party totals and 1099-NEC.

The handbook's invoices-and-bills section gains the payment, discount,
fee and bounce-replacement procedures (S4.5 can extend it). Over the wire:
one_payment_settles_two_invoices, late_discount_needs_a_reason,
a_fee_posts_gross_to_receivables_and_net_to_the_bank,
card_paid_bill_is_not_1099_nec,
multi_document_payment_counts_once_for_1099_and_party_totals,
overpayment_refused_with_the_excess,
bills_due_orders_by_due_and_discount and the_aging_export_ties_out.

Terms cannot yet be journaled on a document (S4.3) and a bill line's
account is not an MCP field (S4.4), so bill_on_terms_with_discount,
equipment_bill_payment_is_investing_cash and the discount half of
bills_due are core tests until those land; the unit's As-built block
records this.
This commit is contained in:
swaits committed 2026-10-27 12:59:40 -06:00
1 parent 78a2385440
commit 57f04dbb0c
17 files changed
+1467 -231

No files matched your search

+55 -3
View File
@@ -848,7 +848,7 @@ impl BooksServer {
#[tool(
name = "books.export",
title = "Export a report",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -911,9 +911,11 @@ impl BooksServer {
"fixed_asset_register" => {
self.export_fixed_asset_register(&format, p.as_of.as_deref())?
}
"ar_aging" => self.export_aging(&format, true, p.as_of.as_deref())?,
"ap_aging" => self.export_aging(&format, false, p.as_of.as_deref())?,
_ => {
return Err(format!(
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register",
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, fixed_asset_register, ar_aging, or ap_aging",
p.report
));
}
@@ -1205,14 +1207,64 @@ impl BooksServer {
.map_err(|e| e.to_string())?;
report::members_equity(&me)
}
"ar_aging" => {
ledger_package::aging_statement(ledger, true, self.as_of(p.as_of.as_deref())?)
.map_err(|e| e.to_string())?
}
"ap_aging" => {
ledger_package::aging_statement(ledger, false, self.as_of(p.as_of.as_deref())?)
.map_err(|e| e.to_string())?
}
_ => {
return Err(format!(
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary"
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, tax_summary, ar_aging, or ap_aging"
));
}
})
}
/// The receivables or payables aging as CSV or JSON (plan P4b S6,
/// 8.3.10): one row per open document, then the tie-out line; the
/// same CSV the CPA package carries.
fn export_aging(
&self,
format: &str,
invoices: bool,
as_of: Option<&str>,
) -> Result<String, String> {
let as_of = self.as_of(as_of)?;
let store = self.lock()?;
if format == "json" {
let docs = store
.ledger()
.aging_documents(as_of, invoices)
.map_err(|e| e.to_string())?;
let rows: Vec<serde_json::Value> = docs
.iter()
.map(|d| {
serde_json::json!({
"number": match d.doc {
ledger_core::DocRef::Invoice(id) => store.ledger().get_invoice(id).and_then(|i| i.number.clone()),
ledger_core::DocRef::Bill(id) => store.ledger().get_bill(id).and_then(|b| b.vendor_ref.clone()),
},
"party": d.party,
"date": d.date.to_string(),
"due_date": d.due_date.to_string(),
"terms": d.terms,
"total": money(d.total),
"paid": money(d.paid),
"discounted": money(d.discounted),
"outstanding": money(d.outstanding),
"bucket": d.bucket,
"days_overdue": d.days_overdue,
})
})
.collect();
return serde_json::to_string_pretty(&rows).map_err(|e| e.to_string());
}
ledger_package::aging_csv(store.ledger(), invoices, as_of)
}
/// Render a report to XLSX bytes via ledger-import.
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
use ledger_import::{XlsxReport, report_to_xlsx};