merge: P4b S7, bad debts (branch S7, 4b4fecb9)

The fourth of the paused branches: Ledger::write_off and the bad_debts
control role, invoice.written_off with its evidence filed in the same
store call, books.write_off_invoice, the write-off on the timeline and
in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the
bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and
S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with
their seams restored, and: the scope table at 125; S7's law numbered 37;
B-41 placed before B-42; the handbook's step 4 and recovery procedures
folded into the one invoices-and-bills section; S7's wire tests in their
own module with the confirm S4.4's contract needs; S7's copy of the
payment steps dropped (S6 replaced them with Ledger::settle) and its
write-off journaled through S6's journal_auto_lines; in the old-books
fixture S7's hand-written write-off moved to a second invoice, since
S6's hand-written payment settles invoice 1 in full.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:47:33 -06:00
commit 335a3eaa7e
47 files changed
+2883 -74

No files matched your search

+234
View File
@@ -1212,3 +1212,237 @@ mod paying_on_terms {
assert!(total.ends_with("difference 0.00"), "{total}");
}
}
// P4b S7: bad debts (merged from branch S7), in its own module.
mod bad_debts {
use super::*;
/// 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" }],
"confirmed": true,
}),
)["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}"
);
}
}
+30
View File
@@ -770,6 +770,28 @@ fn books_p2_served_cut_over_whole() {
)
.unwrap()["id"]
.clone();
// A receipt not yet on an entry (P2 reads the unlink; entry 7's bank
// fee is under the receipt policy's threshold, so taking its last
// receipt is allowed), and a legacy-shape invoice (no new field, so no
// feature): the evidence and the document P4b S7's write-off needs
// after the go-ahead.
a.call(
"books.unlink_receipt",
json!({"attachment_id": 7, "reason": "filed against the wrong entry"}),
)
.unwrap();
let customer = a
.call("books.add_customer", json!({"name": "Ridgeway Co"}))
.unwrap()["id"]
.clone();
let unpaid = a
.call(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-01-10", "due_date": "2026-02-09",
"lines": [{"description": "Survey", "amount": "300.00"}], "confirmed": true}),
)
.unwrap()["id"]
.clone();
let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and
@@ -824,6 +846,14 @@ fn books_p2_served_cut_over_whole() {
"books.close_period",
json!({"period_id": q2, "confirmed": true}),
),
// A bad-debt write-off (documents_v2's new event, P4b S7): its
// evidence filed and linked in the same call.
(
"books.write_off_invoice",
json!({"invoice": unpaid, "date": "2026-02-20",
"reason": "collection exhausted",
"existing_attachment_id": 7, "confirmed": true}),
),
];
for (tool, args) in &refused {
let e = a.call(tool, args.clone()).unwrap_err();
+15
View File
@@ -914,6 +914,21 @@ fn happy_path(name: &str) -> Mcp {
"by": "Avery Quill", "recipients": ["billing@client.example"],
"reference": "msg-1"}),
);
// P4b S7: a bad debt, its evidence uploaded and filed with it.
let bad = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-03", "due_date": "2026-03-03",
"lines": [{"description": "Consulting, never paid", "amount": "400.00"}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap();
let letter = m.upload(b"Final demand", "letter.txt", "text/plain", "document");
m.ok(
"books.write_off_invoice",
json!({"invoice": bad, "date": "2026-03-01", "reason": "collection exhausted",
"upload_id": letter, "confirmed": true}),
);
// Fixed assets.
let registered = m.ok(