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:
1 parent
4e71cf6320
commit
0f1c7786ab
8 files changed
+464
-7
No files matched your search
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
|||||||
Reference in new issue
Block a user