feat(mcp): books.write_off_invoice (P4b S7)

One tool for the bad debt (rules.md B-41): it writes off an invoice that
will not be collected, Dr 6990 Bad Debt Expense / Cr 1200 Accounts
Receivable, the receivable line tagged with the invoice so the document
reads written_off and its figures stay derived (B-38).

The evidence is the point, and the tool takes exactly one of
upload_id (an upload, filed with the write-off) and
existing_attachment_id (one already filed, linked to the write-off's
entry and the invoice); neither or both is refused. amount defaults to
what the invoice owes and may be partial.

Without confirmed it is a preview: the engine runs on a clone (every
refusal the post would give) and the answer is the figures the write-off
would leave, entry_id 0, nothing journaled.

The tool lives in its own group file, tools/write_offs.rs (plan U0a), so
S4.4's document tools and this one land apart; it is destructive, needs
books:write to preview and books:post to post (scopes.rs), and is
documented under "Tools — A/R and A/P". The destructive-tool count, the
tool tables and the journey test's tool count each move by one.

Tests over the wire: write_off_then_recovery, write_off_needs_evidence.
This commit is contained in:
swaits committed 2026-10-27 12:44:45 -06:00
1 parent 4e71cf6320
commit 0f1c7786ab
8 files changed
+464 -7

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); 122] = [
pub const TABLE: [(&str, Needs); 123] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
@@ -186,6 +186,8 @@ pub const TABLE: [(&str, Needs); 122] = [
("books.access_log", A),
("books.backup", R),
("books.back_up_now", A),
// P4b S7: a bad-debt write-off (preview, then post).
("books.write_off_invoice", C),
];
/// What `tool` needs, given its arguments; None for a tool this server
+3
View File
@@ -579,6 +579,7 @@ impl BooksServer {
+ Self::payroll_router()
+ Self::payouts_router()
+ Self::tax_router()
+ Self::write_offs_router()
}
/// The settings in force for this request: the books' own, taken from
@@ -1424,6 +1425,8 @@ mod schedules;
mod settings_tools;
#[path = "tools/tax.rs"]
mod tax;
#[path = "tools/write_offs.rs"]
mod write_offs;
// The shared helpers are re-exported so every group file reaches them
// through `use super::*`, and no group file reaches into another.
+5 -3
View File
@@ -42,7 +42,7 @@ fn annotations_complete() {
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); 122] = [
let table: [(&str, Hints); 123] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
@@ -165,6 +165,8 @@ fn annotations_complete() {
("books.access_log", R),
("books.backup", R),
("books.back_up_now", WI),
// P4b S7: a bad-debt write-off.
("books.write_off_invoice", D),
];
let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len());
@@ -204,8 +206,8 @@ fn annotations_complete() {
"{name}: openWorldHint"
);
}
// docs/mcp-interface.md names the 29 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29);
// docs/mcp-interface.md names the 30 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 30);
}
/// Every refusal an agent can be given whose text is fixed here, or
+137
View File
@@ -0,0 +1,137 @@
//! `books.write_off_invoice`'s group: bad debts (plan P4b S7; rules.md
//! B-41). A file of its own (plan 8.2 U0a) so S4.4's document tools and
//! this one land apart: a tool is added to its group's file and nowhere
//! else.
use super::*;
/// An invoice's figures after a write-off, as the tool answers them: read
/// from its tagged lines that stand (rules.md B-38), at the end of the
/// books.
fn write_off_out(
ledger: &Ledger,
invoice: u64,
entry_id: u64,
) -> Result<WriteOffInvoiceOut, String> {
let st = ledger
.doc_state(ledger_core::DocRef::Invoice(invoice), NaiveDate::MAX)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("invoice {invoice} not found"))?;
Ok(WriteOffInvoiceOut {
entry_id,
document: WriteOffDocumentOut {
r#ref: format!("invoice:{invoice}"),
status: st.status.key().to_string(),
paid: money(st.paid),
written_off: money(st.written_off),
outstanding: money(st.outstanding),
},
})
}
#[tool_router(router = write_offs_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.write_off_invoice",
title = "Write off an invoice",
description = "Write off an invoice that will not be collected (a bad debt): Dr Bad Debt Expense 6990 / Cr Accounts Receivable, the receivable line tagged with the invoice, so it reads written_off and its figures stay derived. The evidence of worthlessness (collection correspondence, a bankruptcy notice) is filed with it in the same call: upload_id, or existing_attachment_id for one already filed. amount (default: all it owes) may be partial. Refused on cash-basis books, above what it owes, before its date or in a closed period. To recover it: reverse the write-off (books.reverse_entry; the invoice reopens), then books.receive_payment. Without confirmed=true, a preview.",
annotations(
read_only_hint = false,
destructive_hint = true,
open_world_hint = false
)
)]
async fn write_off_invoice(
&self,
params: Parameters<WriteOffInvoiceParams>,
) -> Result<Json<WriteOffInvoiceOut>, String> {
let p = params.0;
let date = parse_date(&p.date)?;
let mut store = self.lock()?;
// The event type is new: books that haven't declared documents_v2
// refuse it with the upgrade remedy, journaling nothing (B-27).
store
.ensure_feature(ledger_store::format::DOCUMENTS_V2)
.map_err(|e| e.to_string())?;
let doc = ledger_core::DocRef::Invoice(p.invoice);
let owed = store
.ledger()
.doc_state(doc, date)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("invoice {} is dated after {date}", p.invoice))?
.outstanding;
let amount = match p.amount.as_deref() {
Some(a) => parse_amount(a)?,
None => owed,
};
// The evidence is the point of a write-off (B-41 INVARIANT 1): one
// of the two, and the preview says so too rather than posting
// without it later.
match (p.upload_id.is_some(), p.existing_attachment_id.is_some()) {
(true, true) | (false, false) => {
return Err(
"give the evidence once: upload_id (a file to file with the write-off) or \
existing_attachment_id (one already filed), exactly one"
.into(),
);
}
_ => {}
}
if !p.confirmed.unwrap_or(false) {
// The engine on a clone: every refusal the post would give, and
// the figures the write-off would leave. Nothing is written.
let mut probe = store.ledger().clone();
probe
.write_off(p.invoice, date, amount, &p.reason)
.map_err(|e| e.to_string())?;
return Ok(Json(write_off_out(&probe, p.invoice, 0)?));
}
let entry_id = match p.upload_id.as_deref() {
Some(upload_id) => {
let file = self.take_file(files::Given {
upload_id,
purposes: files::FILED,
})?;
let (file_name, mime_type) = named(None, None, &file)?;
let (bytes, claim) = (file.bytes, file.claim);
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
let (entry_id, _) = store
.write_off_invoice(
p.invoice,
date,
amount,
&p.reason,
Some(ledger_store::WriteOffEvidence::File {
file_name: &file_name,
mime_type: &mime_type,
bytes: &bytes,
description: Some("Evidence of worthlessness"),
}),
)
.map_err(|e| e.to_string())?;
files::filed(claim);
entry_id
}
None => {
let id = p
.existing_attachment_id
.expect("checked: one of the two is given");
let (entry_id, _) = store
.write_off_invoice(
p.invoice,
date,
amount,
&p.reason,
Some(ledger_store::WriteOffEvidence::Existing(id)),
)
.map_err(|e| e.to_string())?;
entry_id
}
};
Ok(Json(write_off_out(
store.ledger(),
p.invoice,
entry_id,
)?))
}
}
+63 -2
View File
@@ -892,7 +892,7 @@ pub struct SettingsOut {
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AssignRoleParams {
/// The role, from books.roles: receivables, revenue, payables,
/// general_expense, retained_earnings, salary_expense,
/// general_expense, bad_debts, retained_earnings, salary_expense,
/// payroll_tax_expense, federal_withholding, state_withholding,
/// social_security_payable, medicare_payable, other_deductions_payable.
pub role: String,
@@ -4719,7 +4719,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams {
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, invoices-and-bills, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
#[serde(default)]
pub section: Option<String>,
}
@@ -4758,3 +4758,64 @@ pub struct HandbookOut {
/// The section asked for (null: none was).
pub section: Option<HandbookTextOut>,
}
// ---------------------------------------------------------------------------
// P4b S7: bad debts (books.write_off_invoice)
// ---------------------------------------------------------------------------
/// Parameters for `books.write_off_invoice`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct WriteOffInvoiceParams {
/// The invoice's id (books.documents).
pub invoice: u64,
/// The write-off's day, YYYY-MM-DD: not before the invoice, and in an
/// open period.
pub date: String,
/// How much to write off, a decimal string (at most what the invoice
/// owes on the day); left out, all of it.
#[serde(default)]
pub amount: Option<String>,
/// Why it will not be collected: the collection attempts, a bankruptcy
/// notice, the customer's liquidation. One line, journaled with the
/// write-off.
pub reason: String,
/// The evidence's bytes: an upload_id from books.begin_upload (purpose
/// document or receipt) and the command it returns. Exactly one of
/// this and existing_attachment_id.
#[serde(default)]
pub upload_id: Option<String>,
/// An attachment already filed and not linked to an entry
/// (books.list_receipts): it is linked to the write-off's entry and
/// to the invoice, the same call. Exactly one of this and upload_id.
#[serde(default)]
pub existing_attachment_id: Option<u64>,
/// false or left out: a preview (the posting and the figures it would
/// leave), writing nothing; true: post it.
#[serde(default)]
pub confirmed: Option<bool>,
}
/// An invoice's figures after a write-off, derived from its tagged lines
/// that stand.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WriteOffDocumentOut {
/// "invoice:N", as books.document names it.
pub r#ref: String,
/// Its status after the write-off: written_off, partial, paid, ...
pub status: String,
/// Cash paid against it that stands, decimal string.
pub paid: String,
/// What was written off that stands, decimal string.
pub written_off: String,
/// What is still owed, decimal string.
pub outstanding: String,
}
/// Output of `books.write_off_invoice`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WriteOffInvoiceOut {
/// The write-off's journal entry's id (0 in a preview).
pub entry_id: u64,
/// The invoice's derived figures after it.
pub document: WriteOffDocumentOut,
}