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:
commit
335a3eaa7e
47 files changed
+2883
-74
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user