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

+242 -1
View File
@@ -14,7 +14,7 @@ use ledger_core::LedgerError;
use ledger_core::arap;
use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia};
use ledger_core::{AccountType, Currency, DocRef, Money, Side};
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject};
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject, WriteOffEvidence};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -1455,3 +1455,244 @@ fn legacy_payment_events_keep_the_positional_rule() {
DocRef::Invoice(3)
);
}
// ---------------------------------------------------------------------------
// Bad-debt write-offs (plan P4b S7; rules.md B-41)
// ---------------------------------------------------------------------------
/// The evidence a write-off test files: a collection letter.
fn collection_letter() -> &'static [u8] {
b"%PDF-1.4\nFinal demand, unpaid\n%%EOF\n"
}
/// A write-off needs journal feature documents_v2 (its event type is new;
/// an older version refuses the unknown type): refused, journaling
/// nothing, until the owner's upgrade; then it is taken and replays to the
/// same books (R-A, B-27).
#[test]
fn write_off_waits_for_documents_v2() {
let dir = fixture("write-off-v2");
let mut s = Store::open(&dir.0).unwrap();
assert!(
!s.journal_format()
.has_feature(ledger_store::format::DOCUMENTS_V2)
);
let head = s.head();
let len = journal_len(&dir.0);
let evidence = || {
Some(WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: collection_letter(),
description: Some("Collection attempts"),
})
};
match s.write_off_invoice(1, d(2026, 2, 1), cents(10_000), "uncollectible", evidence()) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature documents_v2")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(s.head(), head, "nothing journaled");
assert_eq!(journal_len(&dir.0), len);
assert!(s.upgrade_format().unwrap().is_some());
let (entry, attachment) = s
.write_off_invoice(1, d(2026, 2, 1), cents(10_000), "uncollectible", evidence())
.unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
let st = live.invoice_state(1, d(2026, 12, 31)).unwrap().unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert_eq!(st.written_off, cents(10_000));
// The evidence is on the write-off's entry and on its invoice.
let on_entry = s.attachments_for(Subject::Entry(entry)).unwrap();
assert_eq!(on_entry.len(), 1);
assert_eq!(on_entry[0].id, attachment);
let on_invoice = s.attachments_for(Subject::Invoice(1)).unwrap();
assert!(on_invoice.iter().any(|a| a.id == attachment));
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
s.verify().unwrap();
}
/// A write-off is journaled as its entry and one `invoice.written_off`,
/// and replay derives the same figures and the projection agrees: the
/// invoice reads Written off, the bad-debt expense is on 6990, and the
/// control account still equals its documents (B-41, B-38 INVARIANT 1).
#[test]
fn write_off_replays_and_projects() {
let dir = test_dir("write-off-replay");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(
c,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", 10_000)],
)
.unwrap();
// Half paid, the rest written off: the invoice reads Written off with
// 50 paid and 50 written off (B-38 SCENARIO 7).
s.receive_payment(inv, d(2026, 4, 20), cents(5_000), bank)
.unwrap();
let from = journal_len(&dir.0);
let (entry, attachment) = s
.write_off_invoice(
inv,
d(2026, 4, 30),
cents(5_000),
"collection exhausted",
Some(WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: collection_letter(),
description: Some("Collection attempts"),
}),
)
.unwrap();
// The event: one invoice.written_off with the five facts, right after
// the entry's own events.
let got = since(&dir.0, from);
let written: Vec<&(String, Value)> = got
.iter()
.filter(|(t, _)| t == "invoice.written_off")
.collect();
assert_eq!(written.len(), 1, "{got:?}");
let (_, p) = written[0];
assert_eq!(p["invoice_id"], json!(inv));
assert_eq!(p["entry_id"], json!(entry));
assert_eq!(p["date"], json!("2026-04-30"));
assert_eq!(p["amount"], json!("50.00"));
assert_eq!(p["reason"], json!("collection exhausted"));
// The evidence: a receipt.attached on the entry, then attachment.linked
// to the invoice.
assert!(got.iter().any(|(t, v)| t == "receipt.attached"
&& v["attachment_id"] == json!(attachment)
&& v["entry_id"] == json!(entry)));
assert!(
got.iter()
.any(|(t, v)| t == "attachment.linked" && v["attachment_id"] == json!(attachment))
);
// The figures, live.
let st = s
.ledger()
.invoice_state(inv, d(2026, 5, 1))
.unwrap()
.unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert_eq!(
(st.paid, st.written_off, st.outstanding),
(cents(5_000), cents(5_000), Decimal::ZERO)
);
let bad = s.ledger().chart().find_by_code("6990").unwrap().id;
assert_eq!(s.ledger().account_balance(bad).unwrap(), cents(5_000));
let ar = s.ledger().chart().find_by_code("1200").unwrap().id;
assert_eq!(s.ledger().account_balance(ar).unwrap(), Decimal::ZERO);
assert!(
s.ledger()
.control_check(d(2026, 5, 1))
.unwrap()
.iter()
.all(|c| c.difference.is_zero() && c.attention.is_empty())
);
// `verify` compares the projection with the journal (the write-off
// moves no document row: the figures are the tagged lines').
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
let replayed = s
.ledger()
.invoice_state(inv, d(2026, 5, 1))
.unwrap()
.unwrap();
assert_eq!(replayed, st);
s.verify().unwrap();
}
/// No evidence, no write-off (B-41 INVARIANT 1; Treas. Reg.
/// §1.166-2(a)): refused, journaling nothing; an attachment already filed
/// is linked instead, to the entry and the invoice.
#[test]
fn write_off_without_evidence_is_refused() {
let dir = test_dir("write-off-evidence");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(
c,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", 10_000)],
)
.unwrap();
let head = s.head();
let len = journal_len(&dir.0);
assert!(matches!(
s.write_off_invoice(inv, d(2026, 4, 30), cents(10_000), "uncollectible", None),
Err(StoreError::WriteOffNeedsEvidence)
));
assert_eq!(s.head(), head, "nothing journaled");
assert_eq!(journal_len(&dir.0), len);
// An attachment that isn't in the books refuses it too, before
// anything is written.
assert!(matches!(
s.write_off_invoice(
inv,
d(2026, 4, 30),
cents(10_000),
"uncollectible",
Some(WriteOffEvidence::Existing(999)),
),
Err(StoreError::UnknownAttachment(999))
));
assert_eq!(s.head(), head, "nothing journaled");
// One already filed (unlinked) is linked to the write-off's entry and
// its invoice.
let (attachment, _, _) = s
.file_document(
"other",
"collection-letter.pdf",
"application/pdf",
collection_letter(),
Some("Collection attempts"),
)
.unwrap();
let (entry, evidence) = s
.write_off_invoice(
inv,
d(2026, 4, 30),
cents(10_000),
"uncollectible",
Some(WriteOffEvidence::Existing(attachment)),
)
.unwrap();
assert_eq!(evidence, attachment);
assert!(
s.attachments_for(Subject::Entry(entry))
.unwrap()
.iter()
.any(|a| a.id == attachment)
);
assert!(
s.attachments_for(Subject::Invoice(inv))
.unwrap()
.iter()
.any(|a| a.id == attachment)
);
s.verify().unwrap();
}