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

+138
View File
@@ -440,6 +440,31 @@ struct AttachRequest<'a> {
description: Option<&'a str>,
}
/// The evidence a bad-debt write-off files (plan P4b S7; rules.md B-41
/// INVARIANT 1): the file that shows the debt is worthless — collection
/// correspondence, a bankruptcy notice (Treas. Reg. §1.166-2). Given as
/// bytes to file in the same call, or as an attachment already in the
/// books that is linked to the write-off's entry and its invoice.
#[derive(Clone, Copy)]
pub enum WriteOffEvidence<'a> {
/// A file's bytes: filed against the write-off's entry (`receipt.
/// attached`) and linked to the invoice in the same call.
File {
/// Its name, e.g. "collection-letter.pdf".
file_name: &'a str,
/// Its MIME type.
mime_type: &'a str,
/// Its bytes.
bytes: &'a [u8],
/// What it is, in one line.
description: Option<&'a str>,
},
/// An attachment already filed and not linked to an entry (it was
/// filed for this): it is linked to the write-off's entry and the
/// invoice.
Existing(u64),
}
impl Store {
// ------------------------------------------------------------------
// Open / create
@@ -3780,6 +3805,108 @@ impl Store {
Ok(live)
}
/// Write off an invoice as a bad debt (plan P4b S7; rules.md B-41),
/// filing its evidence in the same call. The write-off's entry (Dr
/// 6990 Bad Debt Expense / Cr A/R) is journaled through the standard
/// entry flow, then `invoice.written_off`, then the evidence: a
/// `receipt.attached` on the write-off's entry and an
/// `attachment.linked` to the invoice. Returns the write-off's entry
/// and its evidence's attachment.
///
/// ```text
/// GOAL: a bad debt comes off the receivables with the proof it was
/// worthless, in one call that is all or nothing.
/// SOURCE: IRC §166(a); Treas. Reg. §1.166-1(e), -2(a), -3(a);
/// docs/spec/rules.md B-41; plan P4b S7, 8.3.6.
/// INVARIANTS:
/// 1. A write that needs journal feature documents_v2 (the event
/// type) MUST be refused in books that haven't declared it,
/// journaling nothing (FormatUpgradePending).
/// 2. The engine's whole check runs first (cash basis, the amount,
/// the day): a refusal MUST leave the journal, the ledger and
/// the projection unchanged.
/// 3. No evidence is no write-off (WriteOffNeedsEvidence): the file
/// is a receipt attached to the entry and linked to the invoice,
/// in this call.
/// 4. Applying the lines MUST replay to the same books
/// (`invoice.written_off` tags the invoice's line WriteOff).
/// ```
pub fn write_off_invoice(
&mut self,
invoice: InvoiceId,
date: NaiveDate,
amount: Decimal,
reason: &str,
evidence: Option<WriteOffEvidence<'_>>,
) -> Result<(EntryId, u64), StoreError> {
self.ensure_feature(crate::format::DOCUMENTS_V2)?;
self.rehearse(|l| l.write_off(invoice, date, amount, reason).map(drop))?;
self.approval_gate(
|l| l.write_off(invoice, date, amount, reason).map(drop),
false,
)?;
let Some(evidence) = evidence else {
return Err(StoreError::WriteOffNeedsEvidence);
};
// Every check on the evidence before anything is journaled: a bad
// attachment refuses the whole write-off.
if let WriteOffEvidence::Existing(id) = evidence {
self.check_linkable(id)?;
if self
.attachment_links(id)?
.contains(&Subject::Invoice(invoice))
{
return Err(StoreError::AlreadyLinked(
id,
Subject::Invoice(invoice).describe(),
));
}
}
// The bytes hit the content-addressed store before anything is
// journaled: an orphan blob from a later refusal is harmless
// (unreferenced, deduplicated), a journal line naming a blob that
// failed to store would not be (as the issued invoice's PDF, S4.3).
if let WriteOffEvidence::File { bytes, .. } = evidence {
self.blobs.put(bytes)?;
}
let bad = self.ensure_control_account(&ledger_core::BAD_DEBTS)?;
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
let description = format!("Write off invoice {invoice}: {}", reason.trim());
let (entry_id, mut journaled) = self.journal_auto_lines(
date,
&description,
&[
(bad, Side::Debit, amount, None),
(ar, Side::Credit, amount, None),
],
)?;
let written_off =
crate::meta::invoice_written_off(invoice, entry_id, date, amount, reason.trim());
journaled.push(self.append_journal("invoice.written_off", written_off)?);
let live = self.ledger.write_off(invoice, date, amount, reason)?;
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?;
}
let attachment_id = match evidence {
WriteOffEvidence::File {
file_name,
mime_type,
bytes,
description,
} => {
let (id, _, _) =
self.attach_receipt(Some(entry_id), file_name, mime_type, bytes, description)?;
id
}
WriteOffEvidence::Existing(id) => {
self.link_attachment(id, Subject::Entry(entry_id))?;
id
}
};
self.link_attachment(attachment_id, Subject::Invoice(invoice))?;
Ok((entry_id, attachment_id))
}
/// The journal features a settlement needs (rules.md B-27, B-42):
/// `documents_v2` for more than one application, a discount or a fee
/// (an older version would drop them, and the balance would be wrong),
@@ -7203,6 +7330,13 @@ fn replay_line(
get_opt_usize(p, "discount_line")?,
)?;
}
"invoice.written_off" => {
// The write-off's entry was already rebuilt; this only tags
// its receivable line with the invoice (WriteOff), which is
// what makes the written-off figure and the status derive.
let w = crate::meta::invoice_written_off_of(p)?;
ledger.replay_invoice_write_off(w.invoice_id, w.amount, w.entry_id)?;
}
"fx.rate_set" => {
ledger.set_fx_rate(
Currency::new(get_str(p, "currency")?)
@@ -8188,6 +8322,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
// A payment moves no document row: what a document is paid is read
// from the ledger lines tagged with it (schema v15, rules.md B-38).
"invoice.payment_received" => {}
// A write-off moves no document row either: written_off and the
// status are derived from the tagged lines (the tag is set by
// replay; rules.md B-41).
"invoice.written_off" => {}
"bill.paid" => {
conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \