From 0f1c7786aba82de5a897fa5eb2a148bf1f8141ae Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:49:35 -0600 Subject: [PATCH] feat(mcp): books.write_off_invoice (P4b S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ledger-cli/tests/documents.rs | 230 ++++++++++++++++++++++ crates/ledger-cli/tests/mcp_journeys.rs | 2 +- crates/ledger-mcp/src/scopes.rs | 4 +- crates/ledger-mcp/src/server.rs | 3 + crates/ledger-mcp/src/server_tests.rs | 8 +- crates/ledger-mcp/src/tools/write_offs.rs | 137 +++++++++++++ crates/ledger-mcp/src/types.rs | 65 +++++- docs/mcp-interface.md | 22 +++ 8 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 crates/ledger-cli/tests/documents.rs create mode 100644 crates/ledger-mcp/src/tools/write_offs.rs diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs new file mode 100644 index 0000000..b4d5cab --- /dev/null +++ b/crates/ledger-cli/tests/documents.rs @@ -0,0 +1,230 @@ +//! Documents over the wire (plan P4b S4 to S7; this file starts with S7's +//! bad debts). One test per behavior the tools promise, through the +//! books server exactly as an agent reaches it: `common::Books` on a data +//! root of the test's own, a session, and `books.*` calls. +//! +//! ```text +//! GOAL: a bad debt is written off with its evidence in one call, reads +//! written off, and a same-year recovery is the reversal D-49 +//! records; a write-off with no evidence is refused. +//! SOURCE: docs/spec/rules.md B-41; plan P4b S7; IRC §166(a), §111; +//! Treas. Reg. §1.166-1(e), -2(a), -3(a). +//! ``` + +mod common; + +use serde_json::{Value, json}; + +/// A call's structured result, or a panic naming the tool and the refusal. +fn ok(wire: &mut common::Wire, tool: &str, args: Value) -> Value { + common::answer_of(&wire.call(tool, args)).unwrap_or_else(|e| panic!("{tool}: {e}")) +} + +/// A call's refusal, or a panic naming the tool. +fn refused(wire: &mut common::Wire, tool: &str, args: Value) -> String { + match common::answer_of(&wire.call(tool, args)) { + Err(e) => e, + Ok(v) => panic!("{tool} accepted: {v}"), + } +} + +/// The books of every test here: a bank account 1000, a customer, and one +/// invoice for 100.00 dated 2026-01-05, due 2026-02-04. +fn invoice(wire: &mut common::Wire) -> u64 { + ok( + wire, + "books.add_account", + json!({ + "code": "1000", + "name": "Checking", + "account_type": "asset", + "parent": null, + "placeholder": null, + }), + ); + let customer = ok( + wire, + "books.add_customer", + json!({ "name": "Fernhill Labs" }), + )["id"] + .as_u64() + .unwrap(); + ok( + wire, + "books.create_invoice", + json!({ + "party_id": customer, + "date": "2026-01-05", + "due_date": "2026-02-04", + "lines": [{ "description": "Design", "amount": "100.00" }], + }), + )["id"] + .as_u64() + .unwrap() +} + +/// A collection letter, the evidence of worthlessness. +const LETTER: &[u8] = b"%PDF-1.4\nFinal demand, unpaid\n%%EOF\n"; + +/// P4b S7: an invoice half paid is written off for the rest with its +/// evidence in the same call, reads written off, and the same-year +/// recovery D-49 records (reverse the write-off, then a payment) reopens +/// and settles it. +#[test] +fn write_off_then_recovery() { + let books = common::Books::new("s7-recovery"); + let mut wire = books.wire(); + wire.initialize("s7-recovery"); + let inv = invoice(&mut wire); + ok( + &mut wire, + "books.receive_payment", + json!({ + "document_id": inv, + "date": "2026-01-20", + "amount": "40.00", + "cash_account": "1000", + }), + ); + // The write-off's evidence, uploaded as an agent uploads it (D-46). + let upload = wire.upload(LETTER, "collection-letter.pdf", "application/pdf", "document"); + let out = ok( + &mut wire, + "books.write_off_invoice", + json!({ + "invoice": inv, + "date": "2026-01-31", + "reason": "collection exhausted", + "upload_id": upload, + "confirmed": true, + }), + ); + let entry = out["entry_id"].as_u64().unwrap(); + assert!(entry > 0, "{out}"); + assert_eq!(out["document"]["ref"], json!(format!("invoice:{inv}"))); + assert_eq!(out["document"]["status"], json!("written_off")); + assert_eq!(out["document"]["paid"], json!("40.00")); + assert_eq!(out["document"]["written_off"], json!("60.00")); + assert_eq!(out["document"]["outstanding"], json!("0.00")); + // The evidence is on the write-off's entry, as a receipt, and on the + // invoice: get_entry shows the one, the document's timeline the other. + let got = ok(&mut wire, "books.get_entry", json!({ "entry_id": entry })); + assert_eq!(got["receipts"].as_array().map(Vec::len), Some(1), "{got}"); + assert_eq!(got["receipts"][0]["file_name"], json!("collection-letter.pdf")); + let doc = ok( + &mut wire, + "books.document", + json!({ "ref": format!("invoice:{inv}"), "as_of": "2026-02-01" }), + ); + assert_eq!(doc["status"], json!("written_off")); + assert_eq!(doc["written_off"], json!("60.00")); + let write_off_lines: Vec<&Value> = doc["timeline"] + .as_array() + .unwrap() + .iter() + .filter(|t| t["kind"] == json!("write_off")) + .collect(); + assert_eq!(write_off_lines.len(), 1, "{doc}"); + // The same tax year: reverse the write-off; the invoice reopens. + ok( + &mut wire, + "books.reverse_entry", + json!({ + "entry_id": entry, + "date": "2026-03-01", + "description": "Paid after all", + "confirmed": true, + }), + ); + let doc = ok( + &mut wire, + "books.document", + json!({ "ref": format!("invoice:{inv}"), "as_of": "2026-03-02" }), + ); + assert_eq!(doc["status"], json!("partial")); + assert_eq!(doc["outstanding"], json!("60.00")); + ok( + &mut wire, + "books.receive_payment", + json!({ + "document_id": inv, + "date": "2026-03-02", + "amount": "60.00", + "cash_account": "1000", + }), + ); + let doc = ok( + &mut wire, + "books.document", + json!({ "ref": format!("invoice:{inv}"), "as_of": "2026-03-03" }), + ); + assert_eq!(doc["status"], json!("paid")); + assert_eq!(doc["outstanding"], json!("0.00")); + // The control accounts still equal their documents. + let check = ok( + &mut wire, + "books.control_check", + json!({ "as_of": "2026-03-31" }), + ); + assert_eq!(check["agrees"], json!(true), "{check}"); +} + +/// P4b S7: no evidence, no write-off (B-41 INVARIANT 1): the tool refuses +/// with neither or both, and an attachment already filed is linked +/// instead of a new file. +#[test] +fn write_off_needs_evidence() { + let books = common::Books::new("s7-evidence"); + let mut wire = books.wire(); + wire.initialize("s7-evidence"); + let inv = invoice(&mut wire); + let args = |extra: Value| { + let mut v = json!({ + "invoice": inv, + "date": "2026-01-31", + "reason": "uncollectible", + "confirmed": true, + }); + v.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + v + }; + let why = refused(&mut wire, "books.write_off_invoice", args(json!({}))); + assert!(why.contains("upload_id"), "{why}"); + assert!(why.contains("existing_attachment_id"), "{why}"); + let why = refused( + &mut wire, + "books.write_off_invoice", + args(json!({ "upload_id": "up_nope", "existing_attachment_id": 1 })), + ); + assert!(why.contains("exactly one"), "{why}"); + // A filed attachment (not linked to an entry) is linked to the + // write-off's entry and the invoice. + let upload = wire.upload(LETTER, "letter.pdf", "application/pdf", "document"); + let filed = ok( + &mut wire, + "books.file_document", + json!({ + "category": "other", + "upload_id": upload, + "description": "Collection attempts", + }), + ); + let attachment = filed["attachment_id"].as_u64().unwrap(); + let out = ok( + &mut wire, + "books.write_off_invoice", + args(json!({ "existing_attachment_id": attachment })), + ); + assert_eq!(out["document"]["status"], json!("written_off")); + assert_eq!(out["document"]["written_off"], json!("100.00")); + let receipt = ok( + &mut wire, + "books.get_receipt", + json!({ "attachment_id": attachment }), + ); + let links = receipt["links"].as_array().cloned().unwrap_or_default(); + assert!(links.iter().any(|l| l["kind"] == json!("invoice")), "{receipt}"); + assert!(links.iter().any(|l| l["kind"] == json!("entry")), "{receipt}"); +} diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index c8f33cc..5e7cf89 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -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(), 122, "expected 122 tools, got {}", tools.len()); + assert_eq!(tools.len(), 123, "expected 123 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")); diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 2814e5d..c36b7a1 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -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 diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index dce5b8b..80c10ff 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -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. diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index 2f12cfb..e472ce8 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -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 diff --git a/crates/ledger-mcp/src/tools/write_offs.rs b/crates/ledger-mcp/src/tools/write_offs.rs new file mode 100644 index 0000000..eda005e --- /dev/null +++ b/crates/ledger-mcp/src/tools/write_offs.rs @@ -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 { + 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, + ) -> Result, 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, + )?)) + } +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 7a4ef1b..bbcca71 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -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, } @@ -4758,3 +4758,64 @@ pub struct HandbookOut { /// The section asked for (null: none was). pub section: Option, } + +// --------------------------------------------------------------------------- +// 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, + /// 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, + /// 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, + /// false or left out: a preview (the posting and the figures it would + /// leave), writing nothing; true: post it. + #[serde(default)] + pub confirmed: Option, +} + +/// 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, +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index d3734d5..7010737 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2029,6 +2029,28 @@ S4 changes `books.create_invoice` and `books.create_bill`; see plan 8.2 S4. 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.write_off_invoice { invoice, date, amount?, reason, upload_id | + existing_attachment_id, confirmed? }` → `{ entry_id, document: { ref, + status, paid, written_off, outstanding } }`. Writes off an invoice that + will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr + `6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable + line tagged with the invoice (`WriteOff`), so the invoice reads + `written_off` and its figures stay derived (B-38). `amount` (a decimal + string, default all it owes) is at most what is owed and may be partial + (§1.166-3(a)). Its evidence of worthlessness (collection + correspondence, a bankruptcy notice: §1.166-2(a)) is filed in the same + call, as `upload_id` (books.begin_upload, purpose `document` or + `receipt`, then `wanderbooks upload`) or as an + `existing_attachment_id` already filed and not linked; it becomes a + receipt on the write-off's entry and a link to the invoice. Refused on + cash-basis books (§1.166-1(e)), above what it owes, before the + invoice's date or in a closed period. Without `confirmed: true` it + answers `entry_id` 0 and the figures the write-off would leave, writing + nothing. A recovery is not a new command (D-49): the same tax year, + reverse the write-off (`books.reverse_entry`, the invoice reopens) and + record the payment; a later year, a plain entry Dr the deposit account / + Cr a "Bad debt recoveries" revenue account, the invoice staying written + off (§111). - `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of, kind, documents: [{ ref, party, date, due_date, total, outstanding, status, overdue_days }], outstanding_total }`. Invoices (`kind`