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

+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