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

+230
View File
@@ -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}");
}
+1 -1
View File
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); 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. // 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.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
+3 -1
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules; const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1). /// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 122] = [ pub const TABLE: [(&str, Needs); 123] = [
("books.handbook", R), ("books.handbook", R),
("books.start", R), ("books.start", R),
// Settings are committed at once: the settings commit of the plan. // 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.access_log", A),
("books.backup", R), ("books.backup", R),
("books.back_up_now", A), ("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 /// 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::payroll_router()
+ Self::payouts_router() + Self::payouts_router()
+ Self::tax_router() + Self::tax_router()
+ Self::write_offs_router()
} }
/// The settings in force for this request: the books' own, taken from /// The settings in force for this request: the books' own, taken from
@@ -1424,6 +1425,8 @@ mod schedules;
mod settings_tools; mod settings_tools;
#[path = "tools/tax.rs"] #[path = "tools/tax.rs"]
mod tax; mod tax;
#[path = "tools/write_offs.rs"]
mod write_offs;
// The shared helpers are re-exported so every group file reaches them // The shared helpers are re-exported so every group file reaches them
// through `use super::*`, and no group file reaches into another. // 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 D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true)); const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), 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.handbook", R),
("books.start", R), ("books.start", R),
("books.set_setting", DI), ("books.set_setting", DI),
@@ -165,6 +165,8 @@ fn annotations_complete() {
("books.access_log", R), ("books.access_log", R),
("books.backup", R), ("books.backup", R),
("books.back_up_now", WI), ("books.back_up_now", WI),
// P4b S7: a bad-debt write-off.
("books.write_off_invoice", D),
]; ];
let tools = BooksServer::tool_router().list_all(); let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len()); assert_eq!(tools.len(), table.len());
@@ -204,8 +206,8 @@ fn annotations_complete() {
"{name}: openWorldHint" "{name}: openWorldHint"
); );
} }
// docs/mcp-interface.md names the 29 destructive tools. // docs/mcp-interface.md names the 30 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); 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 /// 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)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct AssignRoleParams { pub struct AssignRoleParams {
/// The role, from books.roles: receivables, revenue, payables, /// 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, /// payroll_tax_expense, federal_withholding, state_withholding,
/// social_security_payable, medicare_payable, other_deductions_payable. /// social_security_payable, medicare_payable, other_deductions_payable.
pub role: String, pub role: String,
@@ -4719,7 +4719,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`. /// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)] #[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams { 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)] #[serde(default)]
pub section: Option<String>, pub section: Option<String>,
} }
@@ -4758,3 +4758,64 @@ pub struct HandbookOut {
/// The section asked for (null: none was). /// The section asked for (null: none was).
pub section: Option<HandbookTextOut>, 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,
}
+22
View File
@@ -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 number and reads `void`. Refused while a payment stands on it (the
refusal names the payment entry to reverse first), when void already, refusal names the payment entry to reverse first), when void already,
before its date or in a closed period (P4b S1, B-38). 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, - `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of,
kind, documents: [{ ref, party, date, due_date, total, outstanding, kind, documents: [{ ref, party, date, due_date, total, outstanding,
status, overdue_days }], outstanding_total }`. Invoices (`kind` status, overdue_days }], outstanding_total }`. Invoices (`kind`