From d5af44b9cb3a77f983178fec44257476f35973fd Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:01:03 -0600 Subject: [PATCH 01/12] feat(arap): write off a bad debt in the engine (P4b S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invoice that will not be collected comes off the receivables and is deducted, the direct write-off method the owner chose (A-5; IRC §166(a); Treas. Reg. §1.166-1(e), -2(a), -3(a); rules.md B-41). Ledger::write_off posts one balanced entry, Dr 6990 Bad Debt Expense / Cr 1200 Accounts Receivable, its receivable line tagged WriteOff so the figures stay derived: what the invoice owes falls and what was written off rises, and a reversal re-instates it without a new movement (D-49). The amount is at most what is owed, and at most the least owed on any day from the write-off's on: a later payment can't leave the invoice owing less than nothing (B-38 INVARIANT 2). A partial write-off is allowed, as §1.166-3(a) allows to the extent charged off. Cash-basis books refuse it: an invoice never counted as income has no basis to deduct (§1.166-1(e)). The bad_debts role (6990) is a control role, so the account is created on demand and can be assigned like the others. The same-year recovery D-49 records is a reversal of the write-off and then a payment; a later year is a plain entry, and needs no movement. Tests: half_paid_rest_written_off_is_written_off, write_off_refused_on_cash_basis, write_off_above_outstanding_refused, write_off_then_recovery_same_year. --- crates/ledger-core/src/arap.rs | 11 +- crates/ledger-core/src/error.rs | 16 ++ crates/ledger-core/src/ledger.rs | 188 +++++++++++++++++++ crates/ledger-core/tests/spec_documents.rs | 200 +++++++++++++++++++++ 4 files changed, 410 insertions(+), 5 deletions(-) diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index cd8ebd5..333ab49 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -154,9 +154,10 @@ impl DocRef { } /// Why a ledger line carries its document: what it does to what is owed. -/// Only `Origin` and `Payment` are written today (P4b S1); the rest are -/// the movements later slices write (P4b.6), named here so a tag's kind -/// never changes meaning. +/// `Origin`, `Payment` and `WriteOff` are written today (S1, S7); the rest +/// are movements only a later unit may write (P4b.6), named here so a +/// tag's kind never changes meaning. `Adopted` and `RoleMove` are +/// deferred (D-49). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Movement { @@ -168,9 +169,9 @@ pub enum Movement { Discount, /// A bad debt written off (S7). WriteOff, - /// A legacy hand line adopted as the document (S7). + /// A legacy hand line adopted as the document (deferred, D-49). Adopted, - /// The balance moved with its control role (S7). + /// The balance moved with its control role (deferred, D-49). RoleMove, } diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 28212b3..748808a 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -778,6 +778,22 @@ pub enum LedgerError { #[error("{0}")] InvalidVoid(String), + /// A bad debt can't be written off on cash-basis books (rules.md + /// B-41 INVARIANT 3; IRC §166(a); Treas. Reg. §1.166-1(e)): the + /// invoice was never counted as income, so there is no basis to + /// deduct. + #[error( + "a bad debt can't be written off on cash-basis books: the invoice was never counted as \ + income, so there is nothing to deduct (IRC §166; Treas. Reg. §1.166-1(e)); these books \ + are on the cash basis (books.accounting_basis)" + )] + WriteOffOnCashBasis, + + /// A write-off the books can't take: its amount, its day, or its + /// reason. The message says which, and what the invoice owes. + #[error("{0}")] + InvalidWriteOff(String), + /// Invoice/bill due date is before its date. #[error("due date {0} is before document date")] DueBeforeDate(String), diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index a24a24c..b1b52d0 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -91,6 +91,16 @@ pub const GENERAL_EXPENSES: ControlAccount = control( "bill expense", AccountType::Expense, ); +/// A control account; see [`ControlAccount`]. Bad-debt expense (plan P4b +/// S7, 8.3.4; rules.md B-41): where a write-off's debit goes, created on +/// demand like the others. +pub const BAD_DEBTS: ControlAccount = control( + "bad_debts", + "6990", + "Bad Debt Expense", + "bad debt expense", + AccountType::Expense, +); /// A control account; see [`ControlAccount`]. pub const RETAINED_EARNINGS: ControlAccount = control( "retained_earnings", @@ -106,6 +116,7 @@ pub fn control_accounts() -> impl Iterator { &REVENUE, &PAYABLES, &GENERAL_EXPENSES, + &BAD_DEBTS, &RETAINED_EARNINGS, ] .into_iter() @@ -6027,6 +6038,14 @@ impl Ledger { /// least it owes on any day from then on (from its own date, for a /// payment dated before it), so no later day is overpaid. fn payment_room(&self, doc: arap::DocRef, date: NaiveDate) -> Result { + self.least_owed_from(doc, date) + } + + /// The least `doc` owes on any day from `date` on (from its own date, + /// for a date before it): what a payment may be, and what a write-off + /// may be, so no later day ends out of bounds (rules.md B-38 + /// INVARIANT 2, B-41 INVARIANT 1). + fn least_owed_from(&self, doc: arap::DocRef, date: NaiveDate) -> Result { let (doc_date, ..) = self.doc_header(doc)?; let from = date.max(doc_date); let lines = self.doc_lines(doc); @@ -6300,6 +6319,141 @@ impl Ledger { Ok(entry) } + /// Write off an invoice as a bad debt (plan P4b S7; rules.md B-41). + /// + /// ```text + /// GOAL: take an invoice off the receivables when it will not be + /// collected, and deduct it (the direct write-off method). + /// SOURCE: IRC §166(a) (a worthless debt is deductible); Treas. Reg. + /// §1.166-1(e) (a debt never counted as income has no basis), + /// §1.166-2(a) (worthlessness on all the pertinent evidence), + /// §1.166-3(a) (a partial write-off, to the extent charged + /// off); docs/spec/rules.md B-41; plan/served.md P4b S7, + /// A-5 (direct write-off), A-6; ASC 326-20 noted (an allowance + /// is the GAAP preference; a small company's CPA accepts the + /// direct method, stated). + /// INVARIANTS: + /// 1. MUST post one balanced entry: Dr the bad_debts role (6990 Bad + /// Debt Expense), Cr receivables, for the amount, dated the + /// write-off's day; the receivable line MUST carry the invoice + /// (WriteOff), so what it owes falls by the amount from that day + /// and its written-off figure rises by it. + /// 2. Refused on cash-basis books (WriteOffOnCashBasis): the + /// invoice was never income, so there is nothing to deduct. + /// 3. 0 < amount <= the least the invoice owes on any day from + /// `date` on (on the day, when nothing later lowers it): a + /// partial write-off is allowed (§1.166-3(a)), and no day may + /// owe less than nothing (B-38 INVARIANT 2). + /// 4. The day MUST be in the calendar, in an open period, and not + /// before the invoice. + /// 5. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the books are on the cash basis: SHOULD refuse + /// (WriteOffOnCashBasis). + /// 2. IF the amount is zero, negative or above what is owed: SHOULD + /// refuse (InvalidWriteOff), saying what is owed. + /// 3. IF a payment after the day leaves less to write off: SHOULD + /// refuse, saying how much can be. + /// 4. IF it is dated before the invoice or in a closed period: + /// SHOULD refuse. + /// 5. Half paid, the rest written off: Written off, with 50 paid and + /// 50 written off (B-38 SCENARIO 7). + /// ``` + pub fn write_off( + &mut self, + invoice: InvoiceId, + date: NaiveDate, + amount: Decimal, + reason: &str, + ) -> Result { + self.atomically(|l| l.write_off_steps(invoice, date, amount, reason)) + } + + /// The steps of [`Self::write_off`], run inside [`Self::atomically`]. + fn write_off_steps( + &mut self, + invoice: InvoiceId, + date: NaiveDate, + amount: Decimal, + reason: &str, + ) -> Result { + // A debt never counted as income has no basis to deduct: the cash + // basis collects as it goes, so nothing is receivable to write off. + if self + .settings + .company(self.base_currency.code()) + .basis + .eq_ignore_ascii_case("Cash") + { + return Err(LedgerError::WriteOffOnCashBasis); + } + let reason = reason.trim(); + if reason.is_empty() + || reason.contains(['\n', '\r']) + || reason.chars().count() > arap::MAX_REFERENCE_CHARS + { + return Err(LedgerError::InvalidWriteOff(format!( + "a write-off needs its reason: one line of at most {} characters", + arap::MAX_REFERENCE_CHARS + ))); + } + if amount <= Decimal::ZERO { + return Err(LedgerError::InvalidWriteOff(format!( + "a write-off's amount must be above zero, got {amount}" + ))); + } + crate::calendar::check(date)?; + let doc = arap::DocRef::Invoice(invoice); + let (doc_date, ..) = self.doc_header(doc)?; + if date < doc_date { + return Err(LedgerError::InvalidWriteOff(format!( + "a write-off dated {date} can't be before invoice {invoice}, dated {doc_date}" + ))); + } + if let Some(pid) = self.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string())); + } + let owed = self + .doc_state(doc, date)? + .map_or(Decimal::ZERO, |st| st.outstanding); + let room = self.least_owed_from(doc, date)?; + if amount > room { + let beyond = if room < owed { + format!(" ({room}, once the payments after it are counted)") + } else { + String::new() + }; + return Err(LedgerError::InvalidWriteOff(format!( + "a write-off of {amount} is more than invoice {invoice} owes on {date}: {owed}{beyond}" + ))); + } + self.check_controls(&[RECEIVABLES, BAD_DEBTS])?; + let ar = self.ensure_control(&RECEIVABLES); + let bad = self.ensure_control(&BAD_DEBTS); + let customer = self + .invoices + .get(&invoice) + .ok_or(LedgerError::UnknownInvoice(invoice))? + .customer_id; + let money = Money::new(amount, self.base_currency); + let entry = self.create_entry(date, &format!("Write off invoice {invoice}: {reason}")); + self.add_line(entry, bad, Side::Debit, money)?; + self.add_tagged_line( + entry, + ar, + Side::Credit, + money, + arap::DocTag { + doc, + movement: arap::Movement::WriteOff, + }, + )?; + self.post_entry(entry)?; + self.tag_customer(entry, customer); + self.refresh_doc(doc); + Ok(entry) + } + /// Void a document: reverse its own entry (B-9), described "Void /// invoice 7: reason". It keeps its id (and, later, its number); its /// status as of the void's date on is Void. Plain @@ -6810,6 +6964,40 @@ impl Ledger { Ok(()) } + /// Apply a write-off without posting: replay support for journaling + /// stores (plan P4b S7; rules.md B-41). The write-off's entry was + /// already rebuilt from its own entry events; this tags its + /// receivables line with the invoice (the first untagged control line + /// for `amount`, on the credit side), tags the entry with the + /// customer, and refreshes the invoice's figures. Nothing is bounded + /// on replay (rules.md B-28). + #[doc(hidden)] + pub fn replay_invoice_write_off( + &mut self, + invoice_id: InvoiceId, + amount: Decimal, + entry_id: EntryId, + ) -> Result<(), LedgerError> { + let customer = self + .invoices + .get(&invoice_id) + .ok_or(LedgerError::UnknownInvoice(invoice_id))? + .customer_id; + let doc = arap::DocRef::Invoice(invoice_id); + self.tag_replayed( + entry_id, + &RECEIVABLES, + Some(amount), + arap::DocTag { + doc, + movement: arap::Movement::WriteOff, + }, + ); + self.tag_customer(entry_id, customer); + self.refresh_doc(doc); + Ok(()) + } + /// Apply a bill payment without posting: replay support for journaling /// stores. See [`Self::replay_invoice_payment`]. #[doc(hidden)] diff --git a/crates/ledger-core/tests/spec_documents.rs b/crates/ledger-core/tests/spec_documents.rs index 1e02949..3624f99 100644 --- a/crates/ledger-core/tests/spec_documents.rs +++ b/crates/ledger-core/tests/spec_documents.rs @@ -1200,6 +1200,206 @@ fn a_chain_counts_in_order() { assert!(b.l.stands_on(rr, d(2026, 1, 25))); } +// --------------------------------------------------------------------------- +// Write-offs (plan P4b S7; rules.md B-41) +// --------------------------------------------------------------------------- + +/// B-38 SCENARIO 7 and B-41 INVARIANT 1: half of an invoice paid, the rest +/// written off, reads Written off with 50 paid and 50 written off, and +/// receivables falls by the write-off alone. +#[test] +fn half_paid_rest_written_off_is_written_off() { + let mut b = books(); + let inv = + b.l.create_invoice( + b.customer, + d(2026, 1, 5), + d(2026, 2, 4), + line("Design", 10_000), + ) + .unwrap(); + b.l.receive_payment(inv, d(2026, 1, 20), cents(5_000), b.bank) + .unwrap(); + let ar = b.l.chart().find_by_code("1200").unwrap().id; + let before = b.l.account_balance(ar).unwrap(); + let entry = + b.l.write_off(inv, d(2026, 1, 31), cents(5_000), "collection exhausted") + .unwrap(); + // One balanced entry: Dr 6990 Bad Debt Expense / Cr 1200. + let e = b.l.get_entry(entry).unwrap(); + assert_eq!(e.lines().len(), 2); + let bad = b.l.chart().find_by_code("6990").unwrap().id; + assert_eq!( + ( + b.l.get_entry(entry).unwrap().lines()[0].account, + b.l.get_entry(entry).unwrap().lines()[0].side, + b.l.get_entry(entry).unwrap().lines()[0].amount.amount + ), + (bad, Side::Debit, cents(5_000)) + ); + assert_eq!( + b.l.line_doc(entry, 1).map(|t| t.movement), + Some(Movement::WriteOff) + ); + assert_eq!( + b.l.account_balance(ar).unwrap(), + before.checked_sub(cents(5_000)).unwrap() + ); + let st = b.l.invoice_state(inv, d(2026, 2, 1)).unwrap().unwrap(); + assert_eq!(st.status, DocStatus::WrittenOff); + assert_eq!( + (st.paid, st.written_off, st.outstanding), + (cents(5_000), cents(5_000), Decimal::ZERO) + ); + // The write-off counts toward no payment: the customer paid 50. + assert_eq!( + b.l.payments_recorded(DocRef::Invoice(inv)), + Ok(cents(5_000)) + ); + // And the control account still equals its documents. + let check = b.l.control_check(d(2026, 2, 1)).unwrap(); + assert!(check.iter().all(|c| c.difference.is_zero())); +} + +/// B-41 INVARIANT 3: a cash-basis company never counted the invoice as +/// income, so it has no basis to deduct (IRC §166; Treas. Reg. +/// §1.166-1(e)). +#[test] +fn write_off_refused_on_cash_basis() { + use ledger_core::settings::{Change, Source}; + let mut b = books(); + let src = Source { + decided_by: "Owner".into(), + decided_on: d(2026, 1, 1), + citation: None, + note: None, + }; + let changes = + b.l.check_settings( + &[Change { + key: "books.accounting_basis".into(), + value: Some(serde_json::json!("cash")), + }], + &src, + ) + .unwrap(); + b.l.apply_settings(&changes, &src, 1).unwrap(); + let inv = + b.l.create_invoice( + b.customer, + d(2026, 1, 5), + d(2026, 2, 4), + line("Design", 10_000), + ) + .unwrap(); + let before = ledger_core::reports::snapshot(&b.l).unwrap(); + let err = + b.l.write_off(inv, d(2026, 1, 31), cents(1_000), "uncollectible") + .unwrap_err(); + assert!(matches!(err, LedgerError::WriteOffOnCashBasis), "{err}"); + // Nothing changed, no draft left behind. + assert_eq!(ledger_core::reports::snapshot(&b.l).unwrap(), before); + assert_eq!( + b.l.invoice_state(inv, d(2026, 2, 1)) + .unwrap() + .unwrap() + .status, + DocStatus::Open + ); +} + +/// B-41 INVARIANT 1: at most what is owed, a partial write-off allowed +/// (Treas. Reg. §1.166-3(a)); more is refused, naming what is owed, and +/// nothing changes. +#[test] +fn write_off_above_outstanding_refused() { + let mut b = books(); + let inv = + b.l.create_invoice( + b.customer, + d(2026, 1, 5), + d(2026, 2, 4), + line("Design", 10_000), + ) + .unwrap(); + b.l.receive_payment(inv, d(2026, 1, 20), cents(4_000), b.bank) + .unwrap(); + let before = ledger_core::reports::snapshot(&b.l).unwrap(); + // 60 is owed on the day; 60.01 is not. + let err = + b.l.write_off(inv, d(2026, 1, 31), cents(6_001), "uncollectible") + .unwrap_err(); + assert!( + matches!(&err, LedgerError::InvalidWriteOff(why) if why.contains("60")), + "{err}" + ); + assert_eq!(ledger_core::reports::snapshot(&b.l).unwrap(), before); + // Zero and a negative amount are refused too. + for amount in [Decimal::ZERO, cents(-1)] { + assert!(matches!( + b.l.write_off(inv, d(2026, 1, 31), amount, "uncollectible"), + Err(LedgerError::InvalidWriteOff(_)) + )); + } + assert_eq!(ledger_core::reports::snapshot(&b.l).unwrap(), before); + // What is owed on the day is taken, and the invoice reads partly paid + // and partly written off. + b.l.write_off(inv, d(2026, 1, 31), cents(6_000), "uncollectible") + .unwrap(); + let st = b.l.invoice_state(inv, d(2026, 2, 1)).unwrap().unwrap(); + assert_eq!(st.status, DocStatus::WrittenOff); + assert_eq!((st.paid, st.written_off), (cents(4_000), cents(6_000))); +} + +/// D-49's same-year recovery, and B-41 INVARIANT 2: reversing the +/// write-off re-instates the debt (it is not a new movement), and the +/// payment then recorded settles it; a recovery is income, never a +/// payment event. +#[test] +fn write_off_then_recovery_same_year() { + let mut b = books(); + let inv = + b.l.create_invoice( + b.customer, + d(2026, 1, 5), + d(2026, 2, 4), + line("Design", 10_000), + ) + .unwrap(); + let write_off = + b.l.write_off(inv, d(2026, 1, 31), cents(10_000), "uncollectible") + .unwrap(); + assert_eq!( + b.l.invoice_state(inv, d(2026, 2, 1)) + .unwrap() + .unwrap() + .status, + DocStatus::WrittenOff + ); + // The recovery came in the same tax year: reverse the write-off, and + // the invoice reopens. + b.l.reverse_entry(write_off, d(2026, 3, 1), "paid after all") + .unwrap(); + let st = b.l.invoice_state(inv, d(2026, 3, 2)).unwrap().unwrap(); + assert_eq!( + (st.status, st.written_off, st.outstanding), + (DocStatus::Open, Decimal::ZERO, cents(10_000)) + ); + // Then the payment is recorded as any payment. + b.l.receive_payment(inv, d(2026, 3, 2), cents(10_000), b.bank) + .unwrap(); + let st = b.l.invoice_state(inv, d(2026, 3, 3)).unwrap().unwrap(); + assert_eq!( + (st.status, st.paid, st.written_off), + (DocStatus::Paid, cents(10_000), Decimal::ZERO) + ); + // Receivables is back to nothing, and the write-off entry no longer + // stands (posted is forever: it is reversed, never deleted). + let ar = b.l.chart().find_by_code("1200").unwrap().id; + assert_eq!(b.l.account_balance(ar).unwrap(), Decimal::ZERO); + assert!(!b.l.in_effect(write_off)); +} + // --------------------------------------------------------------------------- // Properties // --------------------------------------------------------------------------- From 62f2fb058eab5a3ca1ed88961b8382b560518e1a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:05:04 -0600 Subject: [PATCH 02/12] feat(verify): prove a write-off never takes a document below zero (P4b S7) Ledger::write_off is a new command, so it is a new Step: Step.writeOff, whose two lines are a tagged control line (the receivable falls) and an untagged expense line (the deduction). Being a document step in shape, it moves nothing unattached, so control_agrees and Reachable.control_is_documents still hold with no new proof: Step.writeOff_unattached states its half of that. writeOff_within_bounds is the law the command's refusal enforces: a write-off of at most what the document is owed, read over the date-sum docSum ... through D (B-38 fixed that sum, not standing), leaves what is owed through the write-off's day at or above zero. through_append lets the write-off's own two lines be read separately. Both are listed in Audit.lean; just prove holds (standard axioms only, no sorry). --- verify/lean/Audit.lean | 2 + verify/lean/Wanderbooks/Documents.lean | 92 +++++++++++++++++++++----- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index c015231..60f4c03 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -96,3 +96,5 @@ open Wanderbooks #print axioms Documents.reverse_twice #print axioms Documents.Reachable.unattached_zero #print axioms Documents.Reachable.control_is_documents +#print axioms Documents.Step.writeOff_unattached +#print axioms Documents.writeOff_within_bounds diff --git a/verify/lean/Wanderbooks/Documents.lean b/verify/lean/Wanderbooks/Documents.lean index 4575fc3..d7be039 100644 --- a/verify/lean/Wanderbooks/Documents.lean +++ b/verify/lean/Wanderbooks/Documents.lean @@ -18,12 +18,17 @@ lines, so it is an identity, not a reconciliation, and it holds through any date, since a date is a filter on the lines. A document is one `Nat` here: an injective coding of `DocRef` (invoices and bills in one id space), which is all the sums need. Then, over the commands: -a document step (an invoice, a bill, a payment, or any entry whose -control lines are tagged) never moves what is unattached; a reversal +a document step (an invoice, a bill, a payment, a write-off, or any entry +whose control lines are tagged) never moves what is unattached; a reversal moves each document, and what is unattached, by exactly the opposite of the lines it mirrors (B-38 INVARIANT 3); so books built by these steps alone have nothing unattached on a control account, ever (`Reachable.unattached_zero`). + +A write-off (S7, B-41) is a document step too: its control line carries the +document and its expense line is off the control accounts. And it never +takes a document below zero: `writeOff_within_bounds` reads the date-sum +before and after it. -/ namespace Wanderbooks.Documents @@ -108,6 +113,10 @@ theorem control_agrees (a : Nat) (ds : List Nat) (hd : Distinct ds) : /-- The lines dated on or before day `D`. -/ def through (D : Nat) (ls : List TLine) : List TLine := ls.filter (fun l => decide (l.date ≤ D)) +theorem through_append (D : Nat) (xs ys : List TLine) : + through D (xs ++ ys) = through D xs ++ through D ys := by + simp [through, List.filter_append] + theorem Covers.through {ds : List Nat} {ls : List TLine} (h : Covers ds ls) (D : Nat) : Covers ds (through D ls) := fun l hl => h l (List.mem_filter.1 hl).1 @@ -159,6 +168,28 @@ control guard requires of every entry a command posts. -/ def Tagged (control : List Nat) (ls : List TLine) : Prop := ∀ l ∈ ls, l.line.account ∈ control → l.doc ≠ none +/-- A tagged list moves nothing that is unattached on a control account: +no line it adds on one carries no document. -/ +theorem unattached_zero_of_tagged {control : List Nat} {e : List TLine} (h : Tagged control e) + {a : Nat} (ha : a ∈ control) : unattached a e = 0 := by + induction e with + | nil => rfl + | cons l ls ih => + simp only [unattached] + rw [ih (fun m hm => h m (List.mem_cons_of_mem _ hm))] + by_cases hn : l.doc = none + · have hacc : l.line.account ≠ a := fun e => h l (List.mem_cons_self ..) (e ▸ ha) hn + simp [hn, onAcct, hacc] + · simp [hn] + +/-- The two lines a write-off of `amount` posts on day `D` (plan P4b S7): +the document's control line credited, tagged with the document (the +receivable falls), and the same amount of bad-debt expense debited, +untagged (the deduction). -/ +def writeOffLines (control expense d D : Nat) (amount : Int) : List TLine := + [ { line := { account := control, side := .credit, amount := amount }, date := D, doc := some d }, + { line := { account := expense, side := .debit, amount := amount }, date := D, doc := none } ] + /-- The books: the control accounts (the receivables and payables roles) and every posted line, in order. -/ structure Books where @@ -168,8 +199,9 @@ structure Books where /-- What the engine allows (S1): a document command, or any entry whose control lines are all tagged (`Step.document`); a reversal of posted lines, mirror for mirror, each keeping its tag (`Step.reverse`), dated on -or after them (B-38, "Dating rules"); assigning a document role an -account all of whose lines are documents' (`Step.assign`: +or after them (B-38, "Dating rules"); writing off a bad debt, for at most +what the document is owed (`Step.writeOff`, S7); assigning a document +role an account all of whose lines are documents' (`Step.assign`: `check_assign_role` SCENARIO 6 refuses one holding an untagged line); and an account leaving the control accounts once it holds no document's line (`Step.release`: an assigned role moved off an account no document posted @@ -180,6 +212,11 @@ inductive Step : Books → Books → Prop Step B { B with lines := B.lines ++ e } | reverse (B : Books) (e : List TLine) (he : ∀ l ∈ e, l ∈ B.lines) (D : Nat) : Step B { B with lines := B.lines ++ e.map (mirror D) } + | writeOff (B : Books) (control expense d D : Nat) (amount : Int) + (hc : control ∈ B.control) (hne : control ≠ expense) + (h : Tagged B.control (writeOffLines control expense d D amount)) + (hb : amount ≤ docSum control d (through D B.lines)) : + Step B { B with lines := B.lines ++ writeOffLines control expense d D amount } | assign (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc ≠ none) : Step B { B with control := c :: B.control } | release (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc = none) : @@ -189,17 +226,16 @@ inductive Step : Books → Books → Prop account: no command adds an untagged control line. -/ theorem Step.document_unattached (B : Books) (e : List TLine) (h : Tagged B.control e) (a : Nat) (ha : a ∈ B.control) : unattached a (B.lines ++ e) = unattached a B.lines := by - rw [unattached_append] - suffices unattached a e = 0 by omega - induction e with - | nil => rfl - | cons l ls ih => - simp only [unattached] - rw [ih (fun m hm => h m (List.mem_cons_of_mem _ hm))] - by_cases hn : l.doc = none - · have hacc : l.line.account ≠ a := fun e => h l (List.mem_cons_self ..) (e ▸ ha) hn - simp [hn, onAcct, hacc] - · simp [hn] + rw [unattached_append, unattached_zero_of_tagged h ha]; omega + +/-- **A write-off never moves what is unattached**: its control line carries +the document (a `Step.document` in shape), and its expense line is off the +control accounts, so no control account gains an untagged line (B-41). -/ +theorem Step.writeOff_unattached (B : Books) (control expense d D : Nat) (amount : Int) + (h : Tagged B.control (writeOffLines control expense d D amount)) (a : Nat) + (ha : a ∈ B.control) : + unattached a (B.lines ++ writeOffLines control expense d D amount) = unattached a B.lines := by + rw [unattached_append, unattached_zero_of_tagged h ha]; omega /-- **A reversal moves each document by exactly the opposite of its lines it mirrors** (B-38 INVARIANT 3), and what is unattached likewise: only @@ -237,6 +273,11 @@ theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.line · exact ih l hl hc · obtain ⟨m, hm, rfl⟩ := List.mem_map.1 hl exact ih m (he m hm) (by simpa [mirror, Line.reverse] using hc) + | writeOff control expense d D amount hc hne h hb => + intro l hl hc' + rcases List.mem_append.1 hl with hl | hl + · exact ih l hl hc' + · exact h l hl hc' | assign c h => intro l hl hc rcases List.mem_cons.1 hc with hc | hc @@ -277,4 +318,25 @@ theorem Reachable.control_is_documents {B : Books} (r : Reachable B) (a : Nat) gl a (through D B.lines) = sumOver (fun d => docSum a d (through D B.lines)) ds := by rw [control_agrees_through a ds hd B.lines hc D, r.unattached_zero a ha D]; omega +/-! ## Writing off a bad debt (B-41, plan P4b S7) -/ + +/-- What a write-off of `amount` does to the document's own sum through +`D`: the control line it posts there is a credit tagged with the document, +and the expense line carries none and is on another account. -/ +theorem docSum_writeOffLines (control expense d D : Nat) (amount : Int) : + docSum control d (through D (writeOffLines control expense d D amount)) = -amount := by + simp [writeOffLines, through, docSum, onAcct, Line.signed] + +/-- **A write-off of at most what is owed leaves nothing owed below zero** +(B-41 INVARIANT 1, over the date-sum `docSum … through D`): the credit it +posts on the control line is the amount, so what the document owes through +the write-off's day is what it owed less the amount, and that is never +below zero. Reversing the write-off adds the amount back +(`reverse_twice`), which is the same-year recovery (D-49). -/ +theorem writeOff_within_bounds (B : Books) (control expense d D : Nat) (amount : Int) + (h : amount ≤ docSum control d (through D B.lines)) : + 0 ≤ docSum control d (through D (B.lines ++ writeOffLines control expense d D amount)) := by + rw [through_append, docSum_append, docSum_writeOffLines] + omega + end Wanderbooks.Documents From 4e71cf6320f00e06ce693713171606b4d99d2f5d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:49:19 -0600 Subject: [PATCH 03/12] feat(store): journal a write-off with its evidence (P4b S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bad debt is deducted on evidence it is worthless (Treas. Reg. §1.166-2), so the store will not journal one without it: Store::write_off_invoice takes the evidence as bytes to file or as an attachment already filed, and files it in the same call — a receipt.attached on the write-off's entry and an attachment.linked to the invoice (rules.md B-41). With no evidence it refuses, journaling nothing. The entry (Dr 6990 Bad Debt Expense / Cr 1200) and its evidence are journaled after the engine's whole check and after the feature gate: the new event type invoice.written_off needs documents_v2, so an older version stops at the declaration rather than reading a written-off invoice as still owed (B-27, D-52). Its payload is the invoice, the entry, the day, the amount and the reason; replay tags the receivable line WriteOff, and the projection keeps no written-off figure — it is the tagged lines' sum (B-38). `meta::invoice_written_off_of` reads and writes the payload; the replay fuzzer draws the type. 8.0.6: (1) `books_written_under_older_rules_still_open` hand-writes the new event and its entry through JournalWriter and still opens, with the figures tailing; (2) a new fixture old-books/p4_s7_before was written by main 4cad2cfc (the version before this unit) through the old-books-writer harness — a numbered invoice with terms and snapshots, a partial payment, a voided invoice and a paid bill — with its expected.json; this version opens it and reports the same figures, the viewer included; (3) the cut-over entry is in format_upgrade.rs's books_p2_served_cut_over_whole; (4) documents_v2_is_its_own_feature is green and the event gate's arms pass; (5) no figure moves: no golden changed (the previous version's expected.json is the comparison); (6) merge does not need the owner's go-ahead; the live books refuse the new write until he gives it. Tests: write_off_waits_for_documents_v2, write_off_replays_and_projects, write_off_without_evidence_is_refused; arap_props.rs gains WriteOff. --- crates/ledger-cli/tests/format_upgrade.rs | 30 ++ crates/ledger-core/src/lib.rs | 5 +- crates/ledger-store/src/error.rs | 10 + crates/ledger-store/src/format.rs | 1 + crates/ledger-store/src/lib.rs | 2 +- crates/ledger-store/src/meta.rs | 48 ++ crates/ledger-store/src/store.rs | 138 ++++++ crates/ledger-store/tests/arap_props.rs | 24 + crates/ledger-store/tests/arap_standing.rs | 226 ++++++++- .../old-books/p4_s7_before/expected.json | 431 ++++++++++++++++++ .../old-books/p4_s7_before/journal.jsonl | 40 ++ crates/ledger-store/tests/store.rs | 54 ++- fuzz/fuzz_targets/fuzz_store_replay.rs | 3 +- 13 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 crates/ledger-store/tests/fixtures/old-books/p4_s7_before/expected.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/p4_s7_before/journal.jsonl diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index 375e3c7..f87d7af 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -703,6 +703,28 @@ fn books_p2_served_cut_over_whole() { // A project by name alone is a plain class, which P2 reads. a.call("books.add_project", json!({"name": "Lighthouse"})) .unwrap(); + // 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"}]}), + ) + .unwrap()["id"] + .clone(); let waiting = journal(&dir); let refused: Vec<(&str, Value)> = vec![ // An invoice's or bill's line naming its project @@ -743,6 +765,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(); diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index a52639e..50039f9 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -76,8 +76,9 @@ pub use instruments::{Instrument, InstrumentId, InstrumentKind}; pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use kernel::{EntryFlows, Flow}; pub use ledger::{ - ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS, - REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts, + BAD_DEBTS, ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, + RETAINED_EARNINGS, REVENUE, ReconciliationReport, UnclearedLine, control_account, + control_accounts, }; pub use money::{Currency, Money}; pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts}; diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 5affe43..26c27dd 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -144,6 +144,16 @@ pub enum StoreError { #[error("store not found at {0}")] NotFound(String), + /// A write-off with no evidence (rules.md B-41 INVARIANT 1): a bad + /// debt is deducted on evidence that it is worthless (Treas. Reg. + /// §1.166-2), so the books take the write-off only with its file. + #[error( + "a write-off needs its evidence: file the collection correspondence or the bankruptcy \ + notice with upload_id, or name an attachment already filed with existing_attachment_id \ + (Treas. Reg. §1.166-2: worthlessness on all the pertinent evidence)" + )] + WriteOffNeedsEvidence, + /// The requested base currency differs from the store's. #[error("base currency mismatch: store is {0}, requested {1}")] CurrencyMismatch(String, String), diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 500845d..d0ba57f 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -243,6 +243,7 @@ pub const EVENT_TYPES: &[&str] = &[ "fx.rate_set", "invoice.created", "invoice.payment_received", + "invoice.written_off", "instrument.added", FORMAT_SET, "journal.tail_repaired", diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 593156c..625890c 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -49,6 +49,6 @@ pub use snapshot::{DiskCheck, Snapshot}; pub use store::{ Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation, - Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for, + Statement, Store, Subject, VerifyReport, WriteOffEvidence, database_in_use, db_file, db_file_for, is_database_file, }; diff --git a/crates/ledger-store/src/meta.rs b/crates/ledger-store/src/meta.rs index 8205453..1fd184b 100644 --- a/crates/ledger-store/src/meta.rs +++ b/crates/ledger-store/src/meta.rs @@ -361,6 +361,54 @@ pub fn entry_approved_of(p: &Value, seq: u64) -> Result<(u64, Approval), StoreEr )) } +/// A bad-debt write-off as journaled (plan P4b S7; rules.md B-41): +/// `invoice.written_off {invoice_id, entry_id, date, amount, reason}`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WrittenOff { + /// The invoice written off. + pub invoice_id: u64, + /// The entry that posted it (already rebuilt from its own events). + pub entry_id: u64, + /// The write-off's day. + pub date: NaiveDate, + /// What was written off. + pub amount: rust_decimal::Decimal, + /// Why (it has no resolution: the evidence is the receipt). + pub reason: String, +} + +/// `invoice.written_off {invoice_id, entry_id, date, amount, reason}`. +pub fn invoice_written_off( + invoice_id: u64, + entry_id: u64, + date: NaiveDate, + amount: rust_decimal::Decimal, + reason: &str, +) -> Value { + json!({ + "invoice_id": invoice_id, + "entry_id": entry_id, + "date": date.to_string(), + "amount": amount.to_string(), + "reason": reason, + }) +} + +/// Read an `invoice.written_off` payload. +pub fn invoice_written_off_of(p: &Value) -> Result { + const E: &str = "invoice.written_off"; + let amount = str_of(p, E, "amount")? + .parse() + .map_err(|_| corrupt(E, "amount"))?; + Ok(WrittenOff { + invoice_id: u64_of(p, E, "invoice_id")?, + entry_id: u64_of(p, E, "entry_id")?, + date: date_of(&str_of(p, E, "date")?, E, "date")?, + amount, + reason: str_of(p, E, "reason")?, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 055ff0b..87154b6 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -385,6 +385,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 @@ -3653,6 +3678,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>, + ) -> 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_entry(date, &description, bad, ar, amount)?; + 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 payment of `amount` on `doc` needs /// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing /// when more than an older version thinks is still owed (the @@ -6999,6 +7126,13 @@ fn replay_line( get_u64(p, "entry_id")?, )?; } + "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")?) @@ -7964,6 +8098,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) \ diff --git a/crates/ledger-store/tests/arap_props.rs b/crates/ledger-store/tests/arap_props.rs index a014f20..eda9f1b 100644 --- a/crates/ledger-store/tests/arap_props.rs +++ b/crates/ledger-store/tests/arap_props.rs @@ -64,6 +64,9 @@ enum Op { Reverse(usize, u8), /// Void document `k` on day `n`. Void(usize, u8), + /// Write off an invoice `k` (mod the documents) on day `n` for `c` + /// cents, with its evidence filed in the same call. + WriteOff(usize, u8, i64), /// Close month `m` (January to March), in order. Close(u8), /// A hand line on receivables (true) or payables: always refused. @@ -77,6 +80,8 @@ fn arb_op() -> impl Strategy { .prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)), 3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)), 1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)), + 2 => (0usize..8, 0u8..90, 1i64..40_000) + .prop_map(|(k, n, c)| Op::WriteOff(k, n, c)), 1 => (1u8..4).prop_map(Op::Close), 1 => (any::(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), ] @@ -173,6 +178,25 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec) -> Result<( Op::Void(i, n) => s .void_document(docs[i % docs.len()], day(n), "duplicate") .map(drop), + Op::WriteOff(..) if docs.is_empty() => Ok(()), + Op::WriteOff(i, n, c) => match docs[i % docs.len()] { + // A bill is never written off: the API takes an invoice. + DocRef::Bill(_) => Ok(()), + DocRef::Invoice(id) => s + .write_off_invoice( + id, + day(n), + cents(c), + "uncollectible", + Some(ledger_store::WriteOffEvidence::File { + file_name: "collection-letter.pdf", + mime_type: "application/pdf", + bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n", + description: Some("Collection attempts"), + }), + ) + .map(drop), + }, Op::Close(m) => { let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap(); let end = start + chrono::Months::new(1) - chrono::Days::new(1); diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index eec3711..8bff100 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -14,7 +14,7 @@ use ledger_core::LedgerError; use ledger_core::arap; use ledger_core::arap::DocumentLine; use ledger_core::{AccountType, Currency, 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}; @@ -1054,3 +1054,227 @@ fn preview_invoice_and_bill_journal_nothing() { ); s.verify().unwrap(); } + +// --------------------------------------------------------------------------- +// 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(); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/expected.json b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/expected.json new file mode 100644 index 0000000..ca30141 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/expected.json @@ -0,0 +1,431 @@ +{ + "assets": [], + "bills": [ + { + "amount_paid": "80.00", + "date": "2026-02-07", + "due_date": "2026-03-09", + "entry_id": 6, + "id": 1, + "lines": [ + { + "amount": "80.00", + "description": "Paper" + } + ], + "memo": "A4 paper", + "received_on": "2026-02-08", + "status": "Paid", + "terms": { + "kind": { + "net": 30 + }, + "text": "Net 30" + }, + "vendor_id": 1, + "vendor_ref": "TOBIN-77" + } + ], + "entries": [ + { + "date": "2026-01-01", + "description": "Opening capital", + "id": 1, + "lines": [ + { + "account": 1, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-01", + "description": "Invoice 1", + "id": 2, + "lines": [ + { + "account": 3, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 4, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-10", + "description": "Payment on invoice 1", + "id": 3, + "lines": [ + { + "account": 1, + "amount": { + "amount": "200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-05", + "description": "Invoice 2", + "id": 4, + "lines": [ + { + "account": 3, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 4, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-06", + "description": "Void invoice 2: wrong customer", + "id": 5, + "lines": [ + { + "account": 3, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 4, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": 4, + "status": "Posted" + }, + { + "date": "2026-02-07", + "description": "Bill 1", + "id": 6, + "lines": [ + { + "account": 5, + "amount": { + "amount": "80.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 6, + "amount": { + "amount": "80.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-03-01", + "description": "Payment on bill 1", + "id": 7, + "lines": [ + { + "account": 6, + "amount": { + "amount": "80.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "80.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [ + [ + 2, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 3, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 4, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 5, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 6, + [ + { + "party_id": 2, + "share": "derived" + } + ] + ], + [ + 7, + [ + { + "party_id": 2, + "share": "derived" + } + ] + ] + ], + "invoices": [ + { + "amount_paid": "200.00", + "bill_to": { + "address": "9 Ridgeway Rd", + "email": "ap@fernhill.example", + "name": "Fernhill Labs" + }, + "customer_id": 1, + "customer_ref": "PO-7", + "date": "2026-02-01", + "due_date": "2026-03-03", + "entry_id": 2, + "id": 1, + "lines": [ + { + "amount": "500.00", + "description": "Design" + } + ], + "memo": "Thanks", + "number": "INV-0001", + "seller": { + "address": "1 Main St", + "name": "Wanderbooks Test Co" + }, + "status": "Partial", + "terms": { + "discount": { + "days": 10, + "percent_bp": 200 + }, + "kind": { + "net": 30 + }, + "text": "2/10 net 30" + } + }, + { + "amount_paid": "0", + "customer_id": 1, + "date": "2026-02-05", + "due_date": "2026-03-07", + "entry_id": 4, + "id": 2, + "lines": [ + { + "amount": "40.00", + "description": "Setup" + } + ], + "status": "Open" + } + ], + "payroll_runs": [], + "periods": [], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "1420", + "balanced": true, + "current_earnings": "420", + "equity": "1420", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "80", + "net_income": "420", + "revenue": "500" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "0", + "debit": "1120", + "name": "Checking" + }, + { + "code": "1200", + "credit": "0", + "debit": "300", + "name": "Accounts Receivable" + }, + { + "code": "3000", + "credit": "1000", + "debit": "0", + "name": "Capital" + }, + { + "code": "4000", + "credit": "500", + "debit": "0", + "name": "Revenue" + }, + { + "code": "6999", + "credit": "0", + "debit": "80", + "name": "General Expenses" + } + ], + "total_credits": "1500", + "total_debits": "1500" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/journal.jsonl new file mode 100644 index 0000000..3647e1b --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/journal.jsonl @@ -0,0 +1,40 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-27T17:42:15Z","type":"store.created"} +{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"853fa1a23a8e98ed4afffa7f01cb1c0bb458fd7c2218706cdb53a579308a6003","seq":2,"ts":"2026-10-27T17:42:15Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Checking","parent":null,"placeholder":false},"prev":"3dea41067629e227334a7e6b050ac103ca121872f2d912fb1d472eb903ae7f59","seq":3,"ts":"2026-10-27T17:42:15Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"ee5de65058a81e155528a5258852611d86c0e8ef41e2e97b40644685eb3250df","seq":4,"ts":"2026-10-27T17:42:15Z","type":"account.added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"26cb19b6f713e2269e2e16d040128daa6b69b354c70d51f0202185e84c8f5450","seq":5,"ts":"2026-10-27T17:42:15Z","type":"entry.created"} +{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"597afb2305dfcd7a9c9ba11b22996a53a29d71f11895405c29f36d9d8eba26aa","seq":6,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"7ebce83af99c2a0be9b4626d5d4546c3392d11b96bebb9276597c0b1944cada2","seq":7,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1b4733efe3583fe881e92aabc2b1a8f319911a779e8d986edd427d6fc392b619","seq":8,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"} +{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"17ef917991a9c0f46e6a28b108fc3cc181a61a7c0bd5827f123cceea993c19ad","seq":9,"ts":"2026-10-27T17:42:15Z","type":"customer.added"} +{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"6f1d020a91130ceedf8830af4f048b42e1c243636be9a70a7f1abf0568147836","seq":10,"ts":"2026-10-27T17:42:15Z","type":"vendor.added"} +{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"fb7aeacef5825917e93fe9dea60e638c60207656b3e6bcfa79ea269eedd67eb3","seq":11,"ts":"2026-10-27T17:42:15Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"a6c35a0e3fe245e8bf455163a9599eca70df8295daa9f899ca5b1168815d42dc","seq":12,"ts":"2026-10-27T17:42:15Z","type":"account.added"} +{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2},"prev":"0d4ed5e9db70c993de38467a67ed80dde4185eaec240b390d062bd5102b86a6c","seq":13,"ts":"2026-10-27T17:42:15Z","type":"entry.created"} +{"payload":{"account":3,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"b4b54695c5cb7b92c423cb0336eef51cd493516ea1c073986d53dea2bf38dedf","seq":14,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"79a2c8f3a4dce65d6cf6c6c13f0d7df4feeff4fa3543fa4732b64fb96c6c57c9","seq":15,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"500.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"a965e96d442a0ee5abb6878ca981583f4fe29da564a71217d331ec5fc642b701","seq":16,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"} +{"payload":{"bill_to":{"address":"9 Ridgeway Rd","email":"ap@fernhill.example","name":"Fernhill Labs"},"customer_id":1,"customer_ref":"PO-7","date":"2026-02-01","due_date":"2026-03-03","entry_id":2,"id":1,"lines":[{"amount":"500.00","description":"Design"}],"memo":"Thanks","number":"INV-0001","seller":{"address":"1 Main St","name":"Wanderbooks Test Co"},"terms":{"discount":{"days":10,"percent_bp":200},"kind":{"net":30},"text":"2/10 net 30"}},"prev":"9d8f3a1a5a0161040cf9caf8b5710c9fd7e61d909cee685d990d9b5a309fa695","seq":17,"ts":"2026-10-27T17:42:15Z","type":"invoice.created"} +{"payload":{"date":"2026-02-10","description":"Payment on invoice 1","id":3},"prev":"5eac387cbd55eafd2637474b8958baf2e7ca7cacec73535b2310ab124e79e6a1","seq":18,"ts":"2026-10-27T17:42:15Z","type":"entry.created"} +{"payload":{"account":1,"amount":"200.00","currency":"USD","id":3,"side":"debit"},"prev":"00b6089df07024fb077049c5206d3dd3812fa662b57af1ae50d42511b34ca0d9","seq":19,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"200.00","currency":"USD","id":3,"side":"credit"},"prev":"26e9ee32e801bedd995f28eee065e4915b654a55c8569fb3ec9327a577c81959","seq":20,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-10","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"200.00","currency":"USD","side":"debit"},{"account":3,"amount":"200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f9e36c8f805aeea8656182becc1b47e7947791ff6f4fe36fc469fd7aaa4354c8","seq":21,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"} +{"payload":{"amount":"200.00","cash_account":1,"date":"2026-02-10","entry_id":3,"invoice_id":1},"prev":"a95f752a661405b4fe0904623b0a2b908b184af28a371f9833b9aa3232685dbb","seq":22,"ts":"2026-10-27T17:42:15Z","type":"invoice.payment_received"} +{"payload":{"date":"2026-02-05","description":"Invoice 2","id":4},"prev":"4b96e27f6bb06775b7d3f4b183ed16e5d7c07ab8dd5e202fafbb8aaa23915cc6","seq":23,"ts":"2026-10-27T17:42:15Z","type":"entry.created"} +{"payload":{"account":3,"amount":"40.00","currency":"USD","id":4,"side":"debit"},"prev":"95674fd07333f129d8289f1287190f39f6a8235bdcd9bd1ce01fa7f3f81b2068","seq":24,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"40.00","currency":"USD","id":4,"side":"credit"},"prev":"6c7b09c9a08781eb0f424d05252b88194063b7d954d0a3eed24a7d102fc228e0","seq":25,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-05","description":"Invoice 2","id":4,"lines":[{"account":3,"amount":"40.00","currency":"USD","side":"debit"},{"account":4,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4aef53f197b1ad2b8287d1fbdd2cba6c5ee965421f77a889be5ba30541d4c2d9","seq":26,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"} +{"payload":{"customer_id":1,"date":"2026-02-05","due_date":"2026-03-07","entry_id":4,"id":2,"lines":[{"amount":"40.00","description":"Setup"}]},"prev":"672d61478a6f83d099c7889f75880e5ce0d7a214838720536fcb2eb998ebc2d4","seq":27,"ts":"2026-10-27T17:42:15Z","type":"invoice.created"} +{"payload":{"date":"2026-02-06","description":"Void invoice 2: wrong customer","id":5,"lines":[{"account":3,"amount":"40.00","currency":"USD","side":"credit"},{"account":4,"amount":"40.00","currency":"USD","side":"debit"}],"reverses":4},"prev":"3f8b16f5949e56504750ee6ebfe1edf08a1a8afe74fc75aafe6b2bee883056be","seq":28,"ts":"2026-10-27T17:42:15Z","type":"entry.reversed"} +{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"57481256716e8ec09f325832cf98df9020505c58b8b2d7136f14d2f8cb4b57ea","seq":29,"ts":"2026-10-27T17:42:16Z","type":"account.added"} +{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"7e7dd770042b61627f9f6a528f38fcae1447eb6b5ad8f1ed378f851667ae51de","seq":30,"ts":"2026-10-27T17:42:16Z","type":"account.added"} +{"payload":{"date":"2026-02-07","description":"Bill 1","id":6},"prev":"f97abbb46d74293510e8b37f54a8c863287df3cb9775e8e159550508a6313160","seq":31,"ts":"2026-10-27T17:42:16Z","type":"entry.created"} +{"payload":{"account":5,"amount":"80.00","currency":"USD","id":6,"side":"debit"},"prev":"d7e394993994fd4e5a1d4017cd30b4e72e3b2ddac8d91ff86a97cbc4a517a667","seq":32,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"} +{"payload":{"account":6,"amount":"80.00","currency":"USD","id":6,"side":"credit"},"prev":"048f99094bf692587a1f3463d663d42efd5681935b9cdb8b6ad187408b23c51d","seq":33,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-07","description":"Bill 1","id":6,"lines":[{"account":5,"amount":"80.00","currency":"USD","side":"debit"},{"account":6,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b38c0078bb78b62f9d565bad95519c243d0374d480b7f17f8f2d0c8a24f7945c","seq":34,"ts":"2026-10-27T17:42:16Z","type":"entry.posted"} +{"payload":{"date":"2026-02-07","due_date":"2026-03-09","entry_id":6,"id":1,"lines":[{"amount":"80.00","description":"Paper"}],"memo":"A4 paper","received_on":"2026-02-08","terms":{"kind":{"net":30},"text":"Net 30"},"vendor_id":1,"vendor_ref":"TOBIN-77"},"prev":"d4aef1e2609466fda52daf6f871bf77c6f3851f58f73e0dc4a59c2b50fb8b472","seq":35,"ts":"2026-10-27T17:42:16Z","type":"bill.created"} +{"payload":{"date":"2026-03-01","description":"Payment on bill 1","id":7},"prev":"cf87cbbda32520851e8a0e341235f24a6c33d095c2b619ff558549c6a200f2ce","seq":36,"ts":"2026-10-27T17:42:16Z","type":"entry.created"} +{"payload":{"account":6,"amount":"80.00","currency":"USD","id":7,"side":"debit"},"prev":"a7df5eacd102668f821c155ebca4caaf3811ed684e89ce76963fad08b02aa60c","seq":37,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"80.00","currency":"USD","id":7,"side":"credit"},"prev":"2969a6d12f0b3c81edd637d436d97e91ef41197c5d353b0d8a78b9b14396fe50","seq":38,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"} +{"payload":{"date":"2026-03-01","description":"Payment on bill 1","id":7,"lines":[{"account":6,"amount":"80.00","currency":"USD","side":"debit"},{"account":1,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"fce397fd5ac8118392a5b46682afe929293c91753ac7ac3bffb9bbb67d692ef9","seq":39,"ts":"2026-10-27T17:42:16Z","type":"entry.posted"} +{"payload":{"amount":"80.00","bill_id":1,"cash_account":1,"date":"2026-03-01","entry_id":7},"prev":"a56a40dc3b3d9b1a5ff33ec8814ff8053bdd9c17f2453ece991eb930633d0e4f","seq":40,"ts":"2026-10-27T17:42:16Z","type":"bill.paid"} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 412fecc..c0d8a5f 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1864,6 +1864,7 @@ fn books_written_under_older_rules_still_open() { (4, "4000", "Revenue", "Revenue"), (5, "2000", "Accounts Payable", "Liability"), (6, "6500", "Supplies", "Expense"), + (7, "6990", "Bad Debt Expense", "Expense"), ] { w.append( "account.added", @@ -1946,13 +1947,44 @@ fn books_written_under_older_rules_still_open() { "memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}), ) .unwrap(); + // P4b S7's new shape, hand-written as this version writes it: a + // bad-debt write-off of invoice 1 (its entry, then the event). This + // reader tags the receivable line WriteOff and derives the figures; + // the version before it stopped at the declaration. + w.append( + "entry.created", + json!({"id": 4, "date": "2026-04-10", "description": "Write off invoice 1: uncollectible"}), + ) + .unwrap(); + for (account, side) in [(7, "debit"), (3, "credit")] { + w.append( + "entry.line_added", + json!({"id": 4, "account": account, "side": side, "amount": "30.00", "currency": "USD"}), + ) + .unwrap(); + } + w.append( + "entry.posted", + json!({"id": 4, "date": "2026-04-10", + "description": "Write off invoice 1: uncollectible", "reversal_of": null, + "lines": [ + {"account": 7, "side": "debit", "amount": "30.00", "currency": "USD"}, + {"account": 3, "side": "credit", "amount": "30.00", "currency": "USD"}]}), + ) + .unwrap(); + w.append( + "invoice.written_off", + json!({"invoice_id": 1, "entry_id": 4, "date": "2026-04-10", + "amount": "30.00", "reason": "uncollectible"}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); let store = Store::open(&dir).expect("older books still open"); store.verify().unwrap(); assert_eq!(store.journal_format().format, 1); - assert_eq!(store.ledger().chart().len(), 6); + assert_eq!(store.ledger().chart().len(), 7); assert_eq!(store.ledger().list_periods().len(), 2); let lines = store.ledger().get_entry(1).unwrap().lines(); assert_eq!(lines.len(), 2); @@ -2007,6 +2039,20 @@ fn books_written_under_older_rules_still_open() { ); assert_eq!(bill.memo.as_deref(), Some("A4 paper")); assert_eq!(bill.lines[0].account, Some(6)); + // The write-off replays as written: its line is the invoice's + // (WriteOff), so what it owes fell by 30.00 and what was written off + // rose by it (B-41, B-38). + let st = store + .ledger() + .invoice_state(1, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap()) + .unwrap() + .unwrap(); + assert_eq!(st.written_off, "30.00".parse::().unwrap()); + assert_eq!(st.outstanding, "69.99".parse::().unwrap()); + assert_eq!( + store.ledger().line_doc(4, 1).map(|t| t.movement), + Some(ledger_core::Movement::WriteOff) + ); } /// The books an earlier wanderbooks wrote, one directory each under @@ -2022,7 +2068,11 @@ fn books_written_under_older_rules_still_open() { /// 10,000 years, schedules and assets running into chrono's last month, /// depreciation posted for 10000-01, one recurring run of 1,044 drafts. /// tests/fixtures/old-books-writer is the harness that wrote them; each -/// later phase adds the books its version writes. +/// later phase adds the books its version writes. `p4_s7_before` is P4b +/// S7's: the books main 4cad2cfc (the version before it) wrote — a +/// numbered invoice with terms and snapshots, a partial payment, a voided +/// invoice and a paid bill, no write-off — with that version's +/// expected.json. fn old_books() -> Vec { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books"); let mut dirs: Vec = std::fs::read_dir(&root) diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index 3175d3a..c2bc603 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -27,7 +27,8 @@ const TYPES: &[&str] = &[ "asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added", "customer.added", "document.filed", "entry.created", "entry.line_added", "entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set", - "fx.rate_set", "invoice.created", "invoice.payment_received", "journal.tail_repaired", + "fx.rate_set", "invoice.created", "invoice.payment_received", "invoice.written_off", + "journal.tail_repaired", "line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached", "receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered", "schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added", From 0f1c7786aba82de5a897fa5eb2a148bf1f8141ae Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:49:35 -0600 Subject: [PATCH 04/12] feat(mcp): books.write_off_invoice (P4b S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ledger-cli/tests/documents.rs | 230 ++++++++++++++++++++++ crates/ledger-cli/tests/mcp_journeys.rs | 2 +- crates/ledger-mcp/src/scopes.rs | 4 +- crates/ledger-mcp/src/server.rs | 3 + crates/ledger-mcp/src/server_tests.rs | 8 +- crates/ledger-mcp/src/tools/write_offs.rs | 137 +++++++++++++ crates/ledger-mcp/src/types.rs | 65 +++++- docs/mcp-interface.md | 22 +++ 8 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 crates/ledger-cli/tests/documents.rs create mode 100644 crates/ledger-mcp/src/tools/write_offs.rs diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs new file mode 100644 index 0000000..b4d5cab --- /dev/null +++ b/crates/ledger-cli/tests/documents.rs @@ -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}"); +} diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index c8f33cc..5e7cf89 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -125,7 +125,7 @@ fn mcp_end_to_end() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); 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. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 2814e5d..c36b7a1 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 122] = [ +pub const TABLE: [(&str, Needs); 123] = [ ("books.handbook", R), ("books.start", R), // 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.backup", R), ("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 diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index dce5b8b..80c10ff 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -579,6 +579,7 @@ impl BooksServer { + Self::payroll_router() + Self::payouts_router() + Self::tax_router() + + Self::write_offs_router() } /// The settings in force for this request: the books' own, taken from @@ -1424,6 +1425,8 @@ mod schedules; mod settings_tools; #[path = "tools/tax.rs"] mod tax; +#[path = "tools/write_offs.rs"] +mod write_offs; // The shared helpers are re-exported so every group file reaches them // through `use super::*`, and no group file reaches into another. diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index 2f12cfb..e472ce8 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -42,7 +42,7 @@ fn annotations_complete() { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), 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.start", R), ("books.set_setting", DI), @@ -165,6 +165,8 @@ fn annotations_complete() { ("books.access_log", R), ("books.backup", R), ("books.back_up_now", WI), + // P4b S7: a bad-debt write-off. + ("books.write_off_invoice", D), ]; let tools = BooksServer::tool_router().list_all(); assert_eq!(tools.len(), table.len()); @@ -204,8 +206,8 @@ fn annotations_complete() { "{name}: openWorldHint" ); } - // docs/mcp-interface.md names the 29 destructive tools. - assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); + // docs/mcp-interface.md names the 30 destructive tools. + 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 diff --git a/crates/ledger-mcp/src/tools/write_offs.rs b/crates/ledger-mcp/src/tools/write_offs.rs new file mode 100644 index 0000000..eda005e --- /dev/null +++ b/crates/ledger-mcp/src/tools/write_offs.rs @@ -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 { + 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, + ) -> Result, 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, + )?)) + } +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 7a4ef1b..bbcca71 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -892,7 +892,7 @@ pub struct SettingsOut { #[derive(Debug, Deserialize, JsonSchema)] pub struct AssignRoleParams { /// 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, /// social_security_payable, medicare_payable, other_deductions_payable. pub role: String, @@ -4719,7 +4719,7 @@ pub struct ReconciliationsOut { /// Parameters for `books.handbook`. #[derive(Debug, Default, Deserialize, JsonSchema)] 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)] pub section: Option, } @@ -4758,3 +4758,64 @@ pub struct HandbookOut { /// The section asked for (null: none was). pub section: Option, } + +// --------------------------------------------------------------------------- +// 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, + /// 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, + /// 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, + /// false or left out: a preview (the posting and the figures it would + /// leave), writing nothing; true: post it. + #[serde(default)] + pub confirmed: Option, +} + +/// 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, +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index d3734d5..7010737 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -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 refusal names the payment entry to reverse first), when void already, 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, kind, documents: [{ ref, party, date, due_date, total, outstanding, status, overdue_days }], outstanding_total }`. Invoices (`kind` From b731d7ed040aaa67ca355cb1c0ac5689feac28b4 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:49:47 -0600 Subject: [PATCH 05/12] feat(arap): a write-off on the timeline, and the CPA's note (P4b S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer's invoice page already drew a WriteOff movement; now the fixture proves it: a written-off invoice reads Written off, owes nothing, and its timeline's "Written off" row opens the entry (the screenshot invoice-written-off). Needs attention gains the one item the tax rule asks for: a write-off made this calendar year is listed, one per invoice, as "Write-off awaiting the CPA's note", with what was written off, the day, and the prompt that opens the document. Whether a debt is worthless, and in which year, is a tax judgment that stays with the CPA (plan 8.3.16); the books flag it and keep the evidence beside it until the year ends. The item is computed in ledger-package's one attention function, so the viewer and books.start see it alike; view_model.rs compares the two reads on the same books. The handbook's invoices-and-bills section starts here (S4.5 adds the issue, terms and payment steps around it): step 4 says when to void, to write off, or to reverse, and the recovery procedures D-49 records — the same year, reverse the write-off and record the payment; a later year, a plain entry to a bad-debt-recoveries revenue account, the invoice staying written off (IRC §111). Tests: a_write_off_shows_on_the_timeline, a_write_off_is_flagged_for_the_cpa. --- crates/ledger-gui/tests/invoices.rs | 55 ++++++++++++ crates/ledger-gui/tests/view_model.rs | 90 +++++++++++++++++++ .../ledger-mcp/handbook/invoices-and-bills.md | 81 +++++++++++++++++ crates/ledger-mcp/src/handbook.rs | 7 ++ crates/ledger-package/src/attention.rs | 62 ++++++++++++- 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 crates/ledger-mcp/handbook/invoices-and-bills.md diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index f51ad3c..d0d1b3f 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -208,3 +208,58 @@ fn books_without_documents_show_no_overview_tiles_for_them() { shot(&mut h, "receivables-empty"); assert!(h.query_by_label("No invoices").is_some()); } + +/// A written-off invoice (plan P4b S7): $500 issued, written off whole for +/// a bad debt with its evidence, so it reads Written off owing nothing. +fn write_off_books(dir: &TempDir) -> (u64, u64) { + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + s.add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + s.add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let client = s.add_customer("Harbor Client").unwrap(); + let inv = s + .create_invoice( + client, + d(2026, 2, 1), + d(2026, 3, 1), + vec![line("Consulting", 50_000)], + ) + .unwrap(); + let (entry, _) = s + .write_off_invoice( + inv, + d(2026, 3, 10), + Decimal::new(50_000, 2), + "collection exhausted", + Some(ledger_store::WriteOffEvidence::File { + file_name: "collection-letter.pdf", + mime_type: "application/pdf", + bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n", + description: Some("Collection attempts"), + }), + ) + .unwrap(); + (inv, entry) +} + +/// The write-off is on its invoice's timeline, reads "Written off", and +/// opens its entry. +#[test] +fn a_write_off_shows_on_the_timeline() { + let dir = TempDir::new("write-off"); + let (inv, entry) = write_off_books(&dir); + let mut h = open(&dir, Route::Invoice(inv)); + shot(&mut h, "invoice-written-off"); + assert!( + h.query_by_label_contains("Written off, entry #") + .is_some(), + "the timeline shows the write-off" + ); + // The status chip reads its label ("Written off"), not "late". + assert!(h.query_by_label("Written off").is_some()); + h.get_by_label_contains(&format!("Written off, entry #{entry},")) + .click(); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Entry(entry)); +} diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index f184b07..0781605 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -831,3 +831,93 @@ fn all_dates_holds_an_entry_dated_after_today() { assert_eq!(RegisterRange::All.dates(&b), (first, later)); assert_eq!(Range::AllTime.dates(&b), (first, later)); } + +/// A write-off this calendar year is one Needs-attention item, "Write-off +/// awaiting the CPA's note", through the viewer and through the server's +/// own read of the books alike (plan P4b S7; the one function in +/// ledger-package::attention). +#[test] +fn a_write_off_is_flagged_for_the_cpa() { + let d = dir("write-off"); + let today = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap(); + let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); + s.add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let cust = s.add_customer("Ridgeway Co").unwrap(); + let inv = s + .create_invoice( + cust, + NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + vec![DocumentLine { + description: "Survey".into(), + amount: Decimal::new(10_000, 2), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }], + ) + .unwrap(); + let (entry, _) = s + .write_off_invoice( + inv, + NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(), + Decimal::new(10_000, 2), + "collection exhausted", + Some(ledger_store::WriteOffEvidence::File { + file_name: "collection-letter.pdf", + mime_type: "application/pdf", + bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n", + description: Some("Collection attempts"), + }), + ) + .unwrap(); + // The server's list: the same function, over the Store. + let settings = s.ledger().settings(); + let company = settings.company("USD"); + let (cash, financing, investing) = ( + settings.cash_accounts(), + settings.financing_accounts(), + settings.investing_accounts(), + ); + let server = ledger_package::find( + &s, + today, + &ledger_package::Settings { + company: &company, + cash_accounts: &cash, + financing_accounts: &financing, + investing_accounts: &investing, + }, + ); + let want = format!("Write-off awaiting the CPA's note: Invoice {inv}"); + let said = server + .iter() + .find(|a| a.title == want) + .unwrap_or_else(|| panic!("the server's list has no {want:?}: {:?}", server.iter().map(|a| &a.title).collect::>())); + assert_eq!(said.severity, ledger_package::Severity::Todo); + assert!(said.prompt.contains("wanderbooks"), "{}", said.prompt); + assert!(said.detail.contains("100.00"), "{}", said.detail); + drop(s); + // The viewer's list, over its snapshot, has the same item. + let books = data::load_once(&Source { + data_dir: d.join("books-data"), + today: Some(today), + }) + .unwrap(); + let shown = books + .attention + .iter() + .find(|a| a.title == want) + .unwrap_or_else(|| panic!("the viewer shows no {want:?}: {:?}", books.attention.iter().map(|a| &a.title).collect::>())); + assert_eq!(shown.target, Some(ledger_gui::nav::Route::Documents)); + // The viewer reads the same figures from the journal it replayed. + let st = books + .ledger + .invoice_state(inv, today) + .unwrap() + .unwrap(); + assert_eq!(st.status, ledger_core::DocStatus::WrittenOff); + assert!(books.ledger.get_entry(entry).is_some()); +} diff --git a/crates/ledger-mcp/handbook/invoices-and-bills.md b/crates/ledger-mcp/handbook/invoices-and-bills.md new file mode 100644 index 0000000..9243419 --- /dev/null +++ b/crates/ledger-mcp/handbook/invoices-and-bills.md @@ -0,0 +1,81 @@ +# Invoices and bills + +An invoice is money owed to you (Accounts Receivable, 1200); a bill is money +you owe (Accounts Payable, 2000). Each is a document whose figures come from +the ledger lines tagged with it: what is paid, discounted, written off and +still owed is read from those lines, never kept beside them, so a reversal, +a void or a bounced payment is right by construction. `books.documents` +lists them, `books.document` shows one with its timeline, and +`books.control_check` proves the control accounts equal their documents. + +## Step 4: void, write off, or reverse + +One of these, and only one, fits what happened: + +- **Void it** (`books.void_document`) when the document should never have + existed: a duplicate, the wrong customer or vendor, a test entry. It is + reversed, keeps its id (and, once numbered, its number) and reads void; + nothing is deleted. Refused while a payment stands on it: reverse that + payment first, and the refusal names it. Use a void for a mistake, never + for an invoice that was real. +- **Write it off** (`books.write_off_invoice`) when the invoice was real + and the work or goods went out, but the customer will not or cannot pay. + That is a bad debt: the books post Dr Bad Debt Expense 6990 / Cr Accounts + Receivable for what will not be collected, and what it owes falls from + that day. The evidence of worthlessness — the collection letters, the + bankruptcy or liquidation notice, the collection agency's report — is + filed with the write-off in the same call (`upload_id`, or an + `existing_attachment_id` already filed), because a bad debt is deducted + on all the pertinent evidence (Treas. Reg. §1.166-2(a)). Write off all of + it, or part of it with `amount`: a partial write-off is deductible to the + extent charged off (Treas. Reg. §1.166-3(a)), and the rest stays owed and + in the aging. The books must be on the accrual basis: a cash-basis + company never counted the invoice as income, so there is no basis to + deduct (IRC §166; Treas. Reg. §1.166-1(e)), and the tool refuses. +- **Reverse an entry** (`books.reverse_entry`) when a posted entry is + wrong: a payment against the wrong invoice, a wrong amount, a check that + bounced, a write-off made too soon. Posted is forever: the correction is + an opposite entry linked to the one it reverses, never an edit and never + a delete. Reversing a payment puts the invoice back to owing what it did; + reversing a write-off re-instates the debt. + +A bill is never written off: a vendor releases you with a vendor credit or +by voiding its own invoice, and until then you owe it (ASC 405-20-40-1). If +a vendor's bill was wrong, ask for a corrected bill or record a credit when +one is built; void it only when it should never have been entered. + +## Recovering a debt written off + +- **The same tax year**: reverse the write-off — `books.reverse_entry` on + the write-off's entry. The invoice reopens, owing exactly what the + write-off took off; then record the payment with `books.receive_payment` + as usual. Nothing else is needed: within the year the deduction comes + back out, and the payment is a payment. +- **A later tax year**: do not reverse the write-off. Record a plain entry + — `books.draft_entry`, `books.add_line` on each side, `books.post_entry` + — debiting the deposit account and crediting a "Bad debt recoveries" + revenue account (tax category other income), with a memo naming the + invoice. The invoice stays written off, and the recovery is income in the + year it arrives (IRC §111); the CPA sees it apart from the year's sales. + +Neither procedure invents a new kind of movement on the document: the books +record what happened, and the invoice's timeline shows the write-off (and, +if it was reversed, that it no longer stands). + +## What the CPA will check + +The agings tie to 1200 and 2000 at month and year end (they do by +construction: the control check says so). Every write-off carries its +reason and its evidence of collection efforts. Write-offs are not used in +place of a credit memo for a valid invoice, and never to hide a mistake +that a void would show. Whether a debt is worthless, and in which year, is +a tax judgment that stays with the CPA: the books flag each write-off made +this year in Needs attention, with the evidence beside it, until the year +ends. + +## The evidence rule + +Every entry needs its receipt ({evidence_rule}). A document the books post +themselves — an invoice, a bill, a payment, a write-off — carries its own: +the issued copy, the vendor's bill, the payment's reference, the evidence of +worthlessness. A reversal needs none of its own: its original carries it. diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index beb69b2..cb1849f 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -73,6 +73,13 @@ pub const SECTIONS: &[(&str, &str)] = &[ "revenue-and-payouts", include_str!("../handbook/revenue-and-payouts.md"), ), + // S4.5 gives invoices and bills their first three and fifth steps; S7 + // adds step 4 (void, write off or reverse) and the recovery + // procedures (plan 8.2). + ( + "invoices-and-bills", + include_str!("../handbook/invoices-and-bills.md"), + ), ("projects", include_str!("../handbook/projects.md")), ( "parties-and-tins", diff --git a/crates/ledger-package/src/attention.rs b/crates/ledger-package/src/attention.rs index ab7f33f..9a0463a 100644 --- a/crates/ledger-package/src/attention.rs +++ b/crates/ledger-package/src/attention.rs @@ -37,7 +37,10 @@ //! books.set_entry_instrument; a year whose worksheet can't be made //! for want of named cash accounts while vendors were paid in it //! (Todo); overdue bills, and overdue -//! invoices (Problem, one each, with the total); money owed to +//! invoices (Problem, one each, with the total); a written-off +//! invoice whose write-off is dated this calendar year and stands +//! (Todo, one per invoice, "Write-off awaiting the CPA's note", +//! its prompt naming books.document); money owed to //! owners (Info, per account with a balance); the database differing //! from the journal or unreadable, and damaged or missing stored //! files (Problem); depreciation due through the last finished month @@ -717,6 +720,63 @@ pub fn find_with( }); } + // A write-off this year waits for the CPA's note: the deduction rests + // on evidence of worthlessness a human signs off, and §166's judgments + // stay with the CPA (plan P4b S7, 8.3.16; IRC §166(a); Treas. Reg. + // §1.166-2(a)). Listed until the year ends. + let mut written_off: Vec<(u64, Option, NaiveDate, Decimal)> = Vec::new(); + for inv in ledger.invoices() { + let doc = ledger_core::DocRef::Invoice(inv.id); + let state = ledger.invoice_state(inv.id, today).ok().flatten(); + if state.map(|s| s.status) != Some(ledger_core::DocStatus::WrittenOff) { + continue; + } + let lines: Vec = ledger + .doc_lines(doc) + .into_iter() + .filter(|l| { + l.movement == ledger_core::Movement::WriteOff + && l.date.year() == today.year() + && ledger.stands_on(l.entry, today) + }) + .collect(); + let Some(last) = lines.last() else { + continue; + }; + match lines + .iter() + .try_fold(Decimal::ZERO, |t, l| t.checked_sub(l.amount)) + { + Some(amount) => written_off.push((inv.id, inv.number.clone(), last.date, amount)), + None => out.push(cant_compute( + format!("Invoice {}", inv.id), + &LedgerError::TooLargeToCompute("what was written off".into()), + Target::Documents, + )), + } + } + for (id, number, on, amount) in written_off { + let name = number.unwrap_or_else(|| format!("Invoice {id}")); + out.push(Attention { + severity: Severity::Todo, + kind: "write_off_note", + title: format!("Write-off awaiting the CPA's note: {name}"), + detail: format!( + "{} written off on {} (IRC §166; Treas. Reg. §1.166-2: the evidence of worthlessness is filed with the write-off).", + dollars(amount), + date(on) + ), + target: Target::Documents, + prompt: format!( + "In wanderbooks, show me invoice {id}'s write-off (books.document), with its reason and the evidence filed with it, and note it for the CPA at year end." + ), + tool: Some(( + "books.document".into(), + serde_json::json!({ "ref": format!("invoice:{id}") }), + )), + }); + } + for a in ledger.chart().all().filter(|a| { a.account_type == AccountType::Liability && { let n = a.name.to_lowercase(); From 03227786834820ffa42aa19719b76a361ded349d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:50:00 -0600 Subject: [PATCH 06/12] docs(arap): B-41, the bookspec examples, and the four registers (P4b S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rules.md B-41 states the write-off: the direct method the owner chose (A-5), Dr 6990 / Cr 1200 with the receivable tagged, at most what is owed and at most the least owed from that day on (a partial write-off as §1.166-3(a) allows), refused on cash-basis books (§1.166-1(e)), its evidence filed in the same call (§1.166-2(a)); bills are never written off (ASC 405-20-40-1); and a recovery is not a new movement (D-49): the same year, reverse the write-off and record the payment; a later year, a plain entry to a bad-debt-recoveries revenue account, the invoice staying written off (IRC §111). bookspec states B-41 for the first time and gives B-38 its example: two new examples, bad-debt-direct-write-off (two partial write-offs of one invoice, the balances and the trial balance worked by hand) and arap-hand-control-posting-refused (a hand line an earlier version wrote replays, the same line written today is refused). The example schema gains the dated write_off event, lets a refused entry say so (an entry posts whole or not at all; older and refused never combine), and its refusal vocabulary gains untagged_control_line, B-38's control guard. The registers: docs/architecture.md names the new tool group; docs/receipt-store.md lists invoice.written_off and what replay does with it; docs/parity.md records the slice (no row changes status, the floor stands); docs/testing.md records every test and what P12 must mutate; docs/verification.md the two Lean theorems and what they cover. No figure moves: no golden file changed. --- bookspec/README.md | 23 +++- bookspec/checker/src/rules.rs | 9 +- bookspec/checker/src/schema.rs | 62 ++++++++++- .../arap-hand-control-posting-refused.toml | 77 +++++++++++++ .../examples/bad-debt-direct-write-off.toml | 76 +++++++++++++ bookspec/spec/rules-index.md | 8 +- crates/ledger-core/tests/bookspec.rs | 83 ++++++++++++-- docs/architecture.md | 3 + docs/parity.md | 11 ++ docs/receipt-store.md | 10 +- docs/spec/rules.md | 102 ++++++++++++++++++ docs/testing.md | 77 +++++++++++++ docs/verification.md | 31 +++++- 13 files changed, 547 insertions(+), 25 deletions(-) create mode 100644 bookspec/examples/arap-hand-control-posting-refused.toml create mode 100644 bookspec/examples/bad-debt-direct-write-off.toml diff --git a/bookspec/README.md b/bookspec/README.md index 435439e..f1a6398 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -264,6 +264,8 @@ the side gives the direction. on a receivables or payables account, where only an invoice, a bill or their payments post now (rules.md B-38, the control guard). Books that hold one must still read right, so the books take it as replay does. + The same line written today is a `refused = "untagged_control_line"` + event instead. An event cannot be both. - `close = { start, to, rounding? }` as an event: closes the period from `start` through the event's `date` (rules.md B-11, B-13), `to` `"members"` (split by share, in the order listed) or @@ -303,9 +305,18 @@ the side gives the direction. the disposal entry. - `run_schedules = { through }` as an event: every schedule's months due through `through` posted (B-16). -- `refused = ""` on a reversal or a command: the books refuse it, - for that reason (below); it changes nothing, posts nothing, and the - example goes on without it. +- `write_off = { invoice, amount?, reason }` as an event, dated: writes + off the invoice event `invoice` (1-based; an `invoice` event before it) + posted, as a bad debt (rules.md B-41): Bad Debt Expense (6990) is + debited and Accounts Receivable credited, the receivable line tagged + with the invoice, and the evidence of worthlessness filed in the same + call. `amount` (an exact decimal above zero; left out, all the invoice + owes on the day) makes it partial. Refused on cash-basis books and + above what is owed. +- `refused = ""` on a reversal, an entry or a command: the books + refuse it, for that reason (below); it changes nothing, posts nothing, + and the example goes on without it. An entry posts whole or not at all, + so a refused one leaves no draft behind. - `[[changes]]` `{ after, rename?, require_tax_category?, correct_asset? }`: a change that posts no entry, exactly one of `rename = { code, name }` (rename the account `code`, B-31), `require_tax_category = "warn"` @@ -405,8 +416,10 @@ stands, or a run's change dated after the disposal's month isn't undone), disposal, a disposed asset's depreciation, a run re-instated twice or past its schedule, or a reversal dated before its run), `already_disposed`, `invalid_depreciation_period` (a month that ends before the asset is in -service) and `invalid_schedule` (an equal-months schedule not starting on -the 1st, among others). +service), `invalid_schedule` (an equal-months schedule not starting on +the 1st, among others) and `untagged_control_line` (a hand line on a +receivables or payables control account, which only a document may post +to: rules.md B-38's control guard). `cash_flow` (rules.md B-14): the statement of cash flows for `[from, to]`. diff --git a/bookspec/checker/src/rules.rs b/bookspec/checker/src/rules.rs index dc59220..be1209e 100644 --- a/bookspec/checker/src/rules.rs +++ b/bookspec/checker/src/rules.rs @@ -165,6 +165,7 @@ pub fn rule_for_refusal(reason: &str) -> Option<&'static Rule> { | "already_disposed" | "invalid_depreciation_period" => 15, "invalid_schedule" => 16, + "untagged_control_line" => 38, _ => return None, }; rule(b) @@ -529,7 +530,7 @@ const SPEC: Spec = &[ Area::Arap, "A document's figures are its ledger lines that stand; a control account is its documents", Some(Status::Choice), - &[], + &["arap-hand-control-posting-refused"], ), ( 39, @@ -548,9 +549,9 @@ const SPEC: Spec = &[ ( 41, Area::Arap, - "Write-offs (allocated, not yet stated)", - None, - &[], + "A debt that will not be collected is written off, and a recovery is income", + Some(Status::Choice), + &["bad-debt-direct-write-off"], ), ( 42, diff --git a/bookspec/checker/src/schema.rs b/bookspec/checker/src/schema.rs index 29d3936..ac4c9d7 100644 --- a/bookspec/checker/src/schema.rs +++ b/bookspec/checker/src/schema.rs @@ -367,6 +367,9 @@ pub struct Event { /// An invoice. #[serde(default)] pub invoice: Option, + /// A bad-debt write-off of an earlier invoice. + #[serde(default)] + pub write_off: Option, /// A depreciation run. #[serde(default)] pub run_depreciation: Option, @@ -409,6 +412,7 @@ impl Event { (EventKind::Close, self.close.is_some()), (EventKind::Bill, self.bill.is_some()), (EventKind::Invoice, self.invoice.is_some()), + (EventKind::WriteOff, self.write_off.is_some()), (EventKind::RunDepreciation, self.run_depreciation.is_some()), (EventKind::DisposeAsset, self.dispose_asset.is_some()), (EventKind::RunSchedules, self.run_schedules.is_some()), @@ -445,6 +449,8 @@ pub enum EventKind { Bill, /// An invoice. Invoice, + /// A bad-debt write-off of an earlier invoice (rules.md B-41). + WriteOff, /// A depreciation run. RunDepreciation, /// A fixed asset's disposal. @@ -464,6 +470,7 @@ impl EventKind { EventKind::Close => "close", EventKind::Bill => "bill", EventKind::Invoice => "invoice", + EventKind::WriteOff => "write_off", EventKind::RunDepreciation => "run_depreciation", EventKind::DisposeAsset => "dispose_asset", EventKind::RunSchedules => "run_schedules", @@ -481,6 +488,7 @@ impl EventKind { | EventKind::Close | EventKind::Bill | EventKind::Invoice + | EventKind::WriteOff | EventKind::DisposeAsset ) } @@ -499,12 +507,14 @@ impl EventKind { } /// True when the books take it whole or not at all, so `refused` may - /// be given: a reversal or a command, never an entry, a close, a bill - /// or an invoice. + /// be given: a reversal, an entry or a command, never a close, a bill + /// or an invoice (those are corrected by a reversal). An entry posts + /// whole or not at all, so a hand line the control guard refuses is + /// one too. pub fn may_refuse(self) -> bool { !matches!( self, - EventKind::Lines | EventKind::Close | EventKind::Bill | EventKind::Invoice + EventKind::Close | EventKind::Bill | EventKind::Invoice ) } @@ -513,6 +523,7 @@ impl EventKind { !matches!( self, EventKind::Reverses + | EventKind::WriteOff | EventKind::RunDepreciation | EventKind::DisposeAsset | EventKind::RunSchedules @@ -582,6 +593,23 @@ pub struct InvoiceSpec { pub lines: Vec, } +/// A write-off of the invoice an earlier event posted (rules.md B-41; +/// plan P4b S7): the invoice's event, what to write off and why. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WriteOffSpec { + /// The event (1-based) whose invoice this writes off: an `invoice` + /// event before it. + pub invoice: usize, + /// How much to write off, an exact decimal string above zero; left + /// out, all the invoice owes on the day. + #[serde(default)] + pub amount: Option, + /// Why it will not be collected (collection correspondence, a + /// bankruptcy), one line. + pub reason: String, +} + /// One line of an invoice or a bill. #[derive(Clone, Debug, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] @@ -973,6 +1001,7 @@ pub fn refusal_reasons() -> impl Iterator { "already_disposed", "invalid_depreciation_period", "invalid_schedule", + "untagged_control_line", ] .into_iter() } @@ -1105,6 +1134,13 @@ impl Example { "older is an entry's lines, as an earlier version wrote them".into(), )); } + if ev.older && ev.refused.is_some() { + return Err(what( + "an entry posted as an earlier version wrote it (older) can't also be \ + refused" + .into(), + )); + } if ev.payee.is_some() && kind != EventKind::Lines { return Err(what( "a payee is an entry's: a reversal takes its original's, a bill names \ @@ -1198,6 +1234,26 @@ impl Example { return Err(what("an invoice names its customer".into())); } } + if let Some(w) = &ev.write_off { + if w.invoice < 1 || w.invoice >= n { + return Err(what(format!( + "write_off names event {}: an earlier invoice", + w.invoice + ))); + } + if self.events[w.invoice - 1].invoice.is_none() { + return Err(what(format!( + "write_off names event {}, which is no invoice", + w.invoice + ))); + } + if let Some(a) = &w.amount { + check_amount(a, true, &what)?; + } + if w.reason.trim().is_empty() { + return Err(what("a write-off says why it will not be collected".into())); + } + } if let Some(r) = &ev.run_depreciation { check_month(&r.period, &what)?; if !self.setup.assets.iter().any(|a| a.id == r.asset) { diff --git a/bookspec/examples/arap-hand-control-posting-refused.toml b/bookspec/examples/arap-hand-control-posting-refused.toml new file mode 100644 index 0000000..a788146 --- /dev/null +++ b/bookspec/examples/arap-hand-control-posting-refused.toml @@ -0,0 +1,77 @@ +id = "arap-hand-control-posting-refused" +title = "A hand line on receivables is refused; one an earlier version wrote still reads right" +rule = "A line on a receivables or payables control account MUST carry its document, so a hand entry MUST be refused; an entry an earlier version wrote is replayed as it stands." +status = "CHOICE" +sources = [ + "plan/served.md 8.3.1 A-6 (the owner: hand entries to 1200 and 2000 are refused)", + "plan/served.md D-45", + "ASC 310-10 (receivables)", + "ASC 405-20-40-1 (a liability is removed only when paid or released)", +] +tags = ["accrual", "book", "us", "receivables", "legacy"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "1200" +name = "Accounts Receivable" +type = "asset" + +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" + +# The invoice makes 1200 a control account: its balance is now its +# documents'. The work was billed and the income counted. +[[events]] +date = "2026-01-10" +description = "Survey work" +invoice = { customer = "Ridgeway Co", amount = "100.00" } + +# A line an earlier version wrote by hand on 1200: this version's commands +# refuse one, but books that hold it must still read right, so it is +# posted as replay posts it. +[[events]] +date = "2026-01-15" +description = "Receivable entered by hand" +older = true +lines = [ + { account = "1200", side = "debit", amount = "50.00" }, + { account = "4000", side = "credit", amount = "50.00" }, +] + +# The same line written today is refused: no document carries it. +[[events]] +date = "2026-01-20" +description = "Another hand line" +refused = "untagged_control_line" +lines = [ + { account = "1200", side = "debit", amount = "25.00" }, + { account = "4000", side = "credit", amount = "25.00" }, +] + +[[expect]] +query = "balances" +as_of = "2026-12-31" +[expect.values] +"1000" = "0.00" +"1200" = "150.00" +"4000" = "150.00" + +[[expect]] +query = "trial_balance" +as_of = "2026-12-31" +[expect.values] +"1000" = "0.00" +"1200" = "150.00" +"4000" = "-150.00" diff --git a/bookspec/examples/bad-debt-direct-write-off.toml b/bookspec/examples/bad-debt-direct-write-off.toml new file mode 100644 index 0000000..7d706f0 --- /dev/null +++ b/bookspec/examples/bad-debt-direct-write-off.toml @@ -0,0 +1,76 @@ +id = "bad-debt-direct-write-off" +title = "An invoice that will not be collected is written off, in parts" +rule = "A debt that becomes worthless MUST be written off in the year it becomes worthless, a partial write-off MUST be deductible to the extent charged off, and no write-off may exceed what is owed." +status = "CHOICE" +sources = [ + "IRC §166(a)", + "IRC §111", + "Treas. Reg. §1.166-1(e)", + "Treas. Reg. §1.166-2(a)", + "Treas. Reg. §1.166-3(a)", + "plan/served.md 8.3.1 A-5: the direct write-off method", + "ASC 326-20 (noted: an allowance is the GAAP preference)", +] +tags = ["accrual", "book", "us", "receivables"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "1200" +name = "Accounts Receivable" +type = "asset" + +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" + +[[setup.accounts]] +code = "6990" +name = "Bad Debt Expense" +type = "expense" +tax_category = "bad_debts" + +# The work was done and billed: the invoice is income already counted. +[[events]] +date = "2026-01-10" +description = "Survey work" +invoice = { customer = "Ridgeway Co", amount = "100.00" } + +# Part of it is charged off when the collection efforts end; the rest +# stays owed and aged. +[[events]] +date = "2026-03-15" +write_off = { invoice = 1, amount = "40.00", reason = "No answer; the collection agency closed the file" } + +# The liquidation notice makes the rest worthless, and it is written off. +[[events]] +date = "2026-04-20" +write_off = { invoice = 1, amount = "60.00", reason = "Liquidation notice received" } + +[[expect]] +query = "balances" +as_of = "2026-12-31" +[expect.values] +"1000" = "0.00" +"1200" = "0.00" +"4000" = "100.00" +"6990" = "100.00" + +[[expect]] +query = "trial_balance" +as_of = "2026-12-31" +[expect.values] +"1000" = "0.00" +"1200" = "0.00" +"4000" = "-100.00" +"6990" = "100.00" diff --git a/bookspec/spec/rules-index.md b/bookspec/spec/rules-index.md index 436f328..607884c 100644 --- a/bookspec/spec/rules-index.md +++ b/bookspec/spec/rules-index.md @@ -6,8 +6,8 @@ what its statement rests on and the examples that demonstrate it. A rule whose only source is a human choice carries rules.md's **CHOICE**, bookspec's status of the same name: the specification names the options, an engine declares its pick -(`claims.choices`, `bookspec_checker::Claims`). Rules B-39 to B-45 are -allocated in `plan/served.md` 8.0.3 and not yet stated: their +(`claims.choices`, `bookspec_checker::Claims`). Rules B-39, B-40 and B-42 +to B-45 are allocated in `plan/served.md` 8.0.3 and not yet stated: their identifier and area are fixed here so a later unit fills them in without moving anyone else's number. @@ -50,10 +50,10 @@ without moving anyone else's number. | B-35 | `BS-partner-3` | partner | CONVENTION | A party's part of an entry, and a party's totals | `p3-party-accrual-counts-once`, `p3-party-distribution-still-owed`, `p3-party-gift-card-is-a-promise`, `p3-party-members-equity-and-k1`, `p3-party-own-liability-never-pays`, `p3-party-reversed-card-payment`, `p3-party-signs-and-reversals` | | B-36 | `BS-partner-4` | partner | AUTHORITY | The K-1 worksheet reports tax-basis capital beside book capital | `partnership-k1-tax-basis-capital`, `partnership-k1-tax-basis-odd-cents` | | B-37 | `BS-statement-5` | statement | CHOICE | A project is a class with its facts; its figures are its tagged lines | `projects-invoice-and-bill-lines-count-by-project`, `projects-net-is-its-tagged-lines`, `projects-on-spec-costs-are-expenses` | -| B-38 | `BS-arap-2` | arap | CHOICE | A document's figures are its ledger lines that stand; a control account is its documents | — | +| B-38 | `BS-arap-2` | arap | CHOICE | A document's figures are its ledger lines that stand; a control account is its documents | `arap-hand-control-posting-refused` | | B-39 | `BS-arap-3` | arap | — | Terms: when a document is due (allocated, not yet stated) | — | | B-40 | `BS-arap-4` | arap | — | Numbers and the issued copy (allocated, not yet stated) | — | -| B-41 | `BS-arap-5` | arap | — | Write-offs (allocated, not yet stated) | — | +| B-41 | `BS-arap-5` | arap | CHOICE | A debt that will not be collected is written off, and a recovery is income | `bad-debt-direct-write-off` | | B-42 | `BS-arap-6` | arap | — | Settlements and the fee (allocated, not yet stated) | — | | B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — | | B-44 | `BS-ledger-12` | ledger | — | Extraction and dedup (allocated, not yet stated) | — | diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index d1d0f48..d881306 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -191,6 +191,7 @@ fn refusal(e: &LedgerError) -> String { LedgerError::AssetAlreadyDisposed(_) => "already_disposed", LedgerError::InvalidDepreciationPeriod(_) => "invalid_depreciation_period", LedgerError::InvalidSchedule(_) => "invalid_schedule", + LedgerError::UntaggedControlLine { .. } => "untagged_control_line", other => return format!("another refusal: {other}"), } .to_string() @@ -787,6 +788,7 @@ impl<'a> Books<'a> { ("close", e.close.is_some()), ("bill", e.bill.is_some()), ("invoice", e.invoice.is_some()), + ("write_off", e.write_off.is_some()), ("run_depreciation", e.run_depreciation.is_some()), ("dispose_asset", e.dispose_asset.is_some()), ("run_schedules", e.run_schedules.is_some()), @@ -795,7 +797,7 @@ impl<'a> Books<'a> { match kinds.iter().filter(|(_, given)| *given).collect::>()[..] { [(kind, _)] => kind, _ => panic!( - "{} event {n}: an event is one of lines, reverses, close, bill, invoice, run_depreciation, dispose_asset, run_schedules or register_schedule", + "{} event {n}: an event is one of lines, reverses, close, bill, invoice, write_off, run_depreciation, dispose_asset, run_schedules or register_schedule", self.ex.id ), } @@ -809,11 +811,12 @@ impl<'a> Books<'a> { // disposal is dated; the other commands date their own entries, // and every command describes its own. let own = matches!(kind, "lines" | "reverses" | "close" | "bill" | "invoice"); + let dated = own || matches!(kind, "dispose_asset" | "write_off"); assert_eq!( e.date.is_some(), - own || kind == "dispose_asset", + dated, "{ex} event {n}: {kind} {}", - if own || kind == "dispose_asset" { + if dated { "is dated (date)" } else { "dates its own entries: no date" @@ -846,8 +849,16 @@ impl<'a> Books<'a> { "{ex} event {n}: {kind} is tagged with no parties or payment method" ); assert!( - e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill" | "invoice"), - "{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all" + e.refused.is_none() || !matches!(kind, "close" | "bill" | "invoice"), + "{ex} event {n}: refused is for a reversal, an entry or a command, which the books take whole or not at all" + ); + assert!( + !(e.older && e.refused.is_some()), + "{ex} event {n}: an entry posted as an earlier version wrote it (older) can't also be refused" + ); + assert!( + e.write_off.is_none() || kind == "write_off", + "{ex} event {n}: a write_off is its own event" ); } @@ -957,7 +968,7 @@ impl<'a> Books<'a> { "{what}: entry {id}, which it made, is posted" ); } - if own && e.refused.is_none() { + if (own || kind == "write_off") && e.refused.is_none() { assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry"); } } @@ -1023,6 +1034,30 @@ impl<'a> Books<'a> { }; let day = || when.expect("checked: dated"); match kind { + "lines" if e.refused.is_some() => { + // The books take an entry whole or not at all, so a + // refused one leaves no draft behind: it is tried on a + // clone, which is how the engine refuses it. + let mut probe = self.store.ledger().clone(); + let entry = probe.create_entry(day(), description); + for (i, line) in e.lines.iter().enumerate() { + let side = match line.side.as_str() { + "debit" => Side::Debit, + "credit" => Side::Credit, + other => panic!("{what}: side {other:?}"), + }; + let amount = Money::new(money(&line.amount), self.currency); + let account = self.id(&line.account); + probe.add_line(entry, account, side, amount)?; + if let Some(p) = &line.project { + let class = *self.projects.get(p).unwrap_or_else(|| { + panic!("{what}: project {p:?} not in setup.projects") + }); + probe.set_line_class(entry, i, Some(class))?; + } + } + Ok(probe.post_entry(entry)?) + } "lines" => { let post = |this: &mut Self| -> Result<(), StoreError> { let entry = this.store.create_entry(day(), description)?; @@ -1123,6 +1158,42 @@ impl<'a> Books<'a> { .create_invoice(customer, day(), day(), lines) .map(|_| ()) } + "write_off" => { + let w = e.write_off.as_ref().expect("checked: a write-off"); + let invoice_entry = self.entry_of(w.invoice, what); + let invoice = self + .store + .ledger() + .invoices() + .into_iter() + .find(|i| i.entry_id == invoice_entry) + .map(|i| i.id) + .unwrap_or_else(|| panic!("{what}: event {} posted no invoice", w.invoice)); + let amount = match &w.amount { + Some(a) => exact(a, what), + None => self + .store + .ledger() + .invoice_state(invoice, day()) + .expect("the invoice's figures") + .expect("dated by its own day") + .outstanding, + }; + self.store + .write_off_invoice( + invoice, + day(), + amount, + &w.reason, + Some(ledger_store::WriteOffEvidence::File { + file_name: "collection-letter.pdf", + mime_type: "application/pdf", + bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n", + description: Some("Evidence of worthlessness"), + }), + ) + .map(drop) + } "run_depreciation" => { let r = e.run_depreciation.as_ref().expect("checked: a run"); let asset = self.asset(&r.asset, what); diff --git a/docs/architecture.md b/docs/architecture.md index 13ce3bb..2dec750 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -160,6 +160,9 @@ groups are: - `parties` — parties, vendors, customers, roles, W-9s, the TIN vault, 1099s, entry parties; - `attachments` — receipts, filed documents, and their links; +- `write_offs` — `books.write_off_invoice`, the bad debt (P4b S7; rules.md + B-41), kept apart from `documents` so S4.4's document tools and this one + land in different files; - `settings` — the handbook, `books.settings` and the recorded FX rates (the module is `settings_tools`, since `crate::settings` is the live settings); diff --git a/docs/parity.md b/docs/parity.md index e27be0b..8cb6634 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -104,3 +104,14 @@ door on loopback for Caddy or a tunnel. `crates/ledger-cli/tests/two_user.rs` proves the account boundary and the move's head in CI (`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`. The cutover rule in `docs/capability-parity.md` is unchanged. + +## P4b S7: bad debts (write-offs), 2026-10-27 + +A/R gained the direct write-off the owner chose (A-5; plan 8.2 S7): an +invoice that will not be collected comes off 1200 with Dr 6990 Bad Debt +Expense, its receivable line tagged `WriteOff` so the receipt's figures +stay derived, and its evidence of worthlessness filed in the same call +(rules.md B-41; IRC §166, §111; Treas. Reg. §1.166-1(e), (f), -2(a), +-3(a)). A same-year recovery is the reversal D-49 records, not a new +movement; bills are never written off. No row above changed status (the +A/R and A/P rows already say `done`), so the floor stands (10 done). diff --git a/docs/receipt-store.md b/docs/receipt-store.md index bde7404..3811511 100644 --- a/docs/receipt-store.md +++ b/docs/receipt-store.md @@ -57,7 +57,7 @@ apply is safe — on startup the applier replays every journal line with - `seq`: 1-based, gapless, assigned by the writer. - `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders). - `type`: `account.added`, `entry.posted`, `entry.reversed`, `invoice.created`, - `bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`, + `invoice.written_off`, `bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`, `document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset, period, payroll, and class events listed on `JournalLine`). - `payload`: the full domain facts (account fields, entry with lines, blob @@ -74,7 +74,13 @@ apply is safe — on startup the applier replays every journal line with entry exists): it is a `receipt.attached` on the invoice's own entry (`INV-0007.pdf`, `application/pdf`, "Issued invoice"), its SHA-256 the `blob_sha`, linked to the invoice with `attachment.linked` and found - through that link (plan 8.3.11). + through that link (plan 8.3.11). An `invoice.written_off` (plan 8.3.6, + P4b S7) carries `{invoice_id, entry_id, date, amount, reason}`; replay + tags the entry's receivables line `WriteOff`, and the projection keeps + no written-off figure either: it is the tagged lines' sum (rules.md + B-38, B-41). Its evidence of worthlessness rides beside it, as a + `receipt.attached` on the write-off's entry and an `attachment.linked` + to the invoice, filed in the same call. - `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no whitespace). The genesis line uses `"prev": "000...0"` (64 zeros). diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 161e093..d331926 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -3114,3 +3114,105 @@ starts from empty books (legacy lines are covered by the identity coding of `DocRef`), and an account becoming a control account by its first document, holding lines from before, is outside it (those lines are listed as unattached). + +## B-41 A debt that will not be collected is written off, and a recovery is income + +**bookspec:** `BS-arap-5` + +**Status:** CHOICE (the owner, 2026-10-23, plan 8.3.1 A-5: bad debts by +the **direct write-off** method, and 8.3.2: for a small company's +receivables a CPA accepts the direct method and notes it, where GAAP +prefers an allowance under ASC 326-20). AUTHORITY for what the rule +rests on: IRC §166(a) (a debt that becomes worthless is deductible in +that year), Treas. Reg. §1.166-1(e) (a debt never counted as income has +no basis to deduct), §1.166-2(a) (worthlessness on all the pertinent +evidence), §1.166-3(a) (a partial write-off, to the extent charged off), +and IRC §111 (a recovery of an amount deducted is income in the year +received). ASC 405-20-40-1 for a bill's release. + +1. **The write-off.** Writing off an invoice posts one balanced entry: + Dr the bad-debt expense role (`bad_debts`, 6990) and Cr receivables + for the amount, the receivable line carrying the invoice with the + movement `WriteOff`, so what the invoice owes falls by the amount from + the write-off's day and what was written off rises by it. The amount + is above zero and at most what the invoice owes, and at most the least + it owes on any day from the write-off's on (so no later day, a payment + after it included, takes the document below nothing: B-38 INVARIANT 2). + A partial write-off is allowed (§1.166-3(a)); the rest stays owed and + aged. +2. **The day.** The write-off's day is in the calendar, in an open period + (B-12), and not before the invoice's. A document's entry is reversed, + never edited (B-8, B-9): a write-off is not a void and a void is not a + write-off. +3. **Cash basis refused.** Books on the cash basis never counted the + invoice as income, so the debt has no basis to deduct (§1.166-1(e)): + the command refuses, changing nothing. +4. **Bills are never written off** (ASC 405-20-40-1): a liability leaves + the books only when paid or when the creditor releases it, which is a + vendor credit (not scheduled). The command takes an invoice. +5. **A recovery is not a new movement** (plan D-49). In the same tax year, + reverse the write-off: the invoice reopens and owes what the write-off + took off, and the payment is recorded as any payment. In a later year, + record a plain entry Dr the deposit account / Cr a "Bad debt + recoveries" revenue account (tax category other income), its memo + naming the invoice: the recovery is income (§111) and the invoice stays + written off. There is no `Movement::Recovery` and no bad-debt-recoveries + role. +6. **Evidence.** The write-off's evidence of worthlessness — collection + correspondence, a bankruptcy or liquidation notice (§1.166-2(a)) — is + filed in the same call: a `receipt.attached` on the write-off's entry, + linked to the invoice. A write-off with no evidence is refused. The tax + judgments (whether the debt is worthless, and in which year) stay with + the CPA: the books flag each write-off made this year for the CPA's + note. +7. **Journal.** `invoice.written_off {invoice_id, entry_id, date, amount, + reason}` is a new event type and needs journal feature + `documents_v2` (B-27, D-52): an older version refuses it rather than + misreading a document as still owed. Replay tags the receivable line + `WriteOff`; the projection keeps no document figure (B-38). + +```text +GOAL: write off what will not be collected, as the tax rules require. +SOURCE: IRC §166(a), §111; Treas. Reg. §1.166-1(e), (f), -2(a), -3(a); + ASC 326-20 and ASC 405-20-40-1 noted; CHOICE: the direct + write-off method (plan 8.3.1 A-5). +INVARIANTS: + 1. amount <= outstanding on the write-off's day and on every day from + it; Dr bad_debts / Cr receivables [d, WriteOff]; the evidence of + worthlessness filed in the same call. + 2. Its reversal re-instates the debt; bills are never written off. + 3. Refused on cash-basis books. + 4. A refusal leaves the books and the journal unchanged; NEVER panics. +SCENARIOS: + 1. Half paid, the rest written off: Written off, with 50 paid / 50 + written off. + 2. A write-off of 0, a negative amount, or more than is owed: SHOULD + refuse, naming what is owed. + 3. Cash-basis books: SHOULD refuse (no basis to deduct). + 4. The write-off reversed in the same year and the payment recorded: + Paid; receivables back to zero. + 5. A later year's recovery: a plain entry to a bad-debt-recoveries + revenue account; the invoice stays written off. +``` + +**Enforced:** `ledger_core::Ledger::write_off` (and its replay +`replay_invoice_write_off`), `LedgerError::{WriteOffOnCashBasis, +InvalidWriteOff}`, the `bad_debts` control role (`BAD_DEBTS`, 6990); +`ledger_store::Store::write_off_invoice` (feature `documents_v2`; the +evidence filed and linked in the same call), `WriteOffEvidence`, +`StoreError::WriteOffNeedsEvidence`; `books.write_off_invoice` +(`crates/ledger-mcp/src/tools/write_offs.rs`); the viewer's timeline +(`Movement::WriteOff`); Needs attention's write-off item. +**Checked:** `ledger-core` `tests/spec_documents.rs` +(`half_paid_rest_written_off_is_written_off`, +`write_off_refused_on_cash_basis`, `write_off_above_outstanding_refused`, +`write_off_then_recovery_same_year`); `ledger-store` +`tests/arap_standing.rs` (`write_off_waits_for_documents_v2`, +`write_off_replays_and_projects`, `write_off_without_evidence_is_refused`), +`tests/arap_props.rs` (`status_is_a_function_of_journal_and_date` with +write-offs); over the wire `crates/ledger-cli/tests/documents.rs` +(`write_off_then_recovery`, `write_off_needs_evidence`); bookspec +`bad-debt-direct-write-off`; Lean `Documents.Step.writeOff_unattached`, +`Documents.writeOff_within_bounds`; the viewer +`a_write_off_shows_on_the_timeline`; the item +`a_write_off_is_flagged_for_the_cpa`. diff --git a/docs/testing.md b/docs/testing.md index c1f814b..c7bcc6f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3050,3 +3050,80 @@ and `BooksServer::new`'s copy (a dropped field must fail `isolation_label_is_enforced_under_the_unit`); the host's `server_flags` extension and `parse_args`' passthrough of `--isolation` and its optional `--http` value. + +## P4b S7: bad debts (write-offs), 2026-10-27 + +The write-off (plan 8.2 S7; rules.md B-41): an invoice that will not be +collected leaves the receivables as a deduction, with its evidence filed +in the same call. + +- **ledger-core, `tests/spec_documents.rs`:** + `half_paid_rest_written_off_is_written_off` (B-38 SCENARIO 7: 50 paid, + 50 written off, one balanced entry Dr 6990 / Cr 1200, the receivable + line tagged `WriteOff`, receivables down by exactly the write-off); + `write_off_refused_on_cash_basis` (B-41 INVARIANT 3: refused, the + snapshot unchanged, no draft left); `write_off_above_outstanding_refused` + (the bound at the day, a zero and a negative amount, then the exact + amount owed taken); `write_off_then_recovery_same_year` (D-49: the + reversal re-instates the debt, the payment settles it, the invoice reads + Paid and the write-off entry no longer stands). The first test's book + is also the identity's: `control_check` agrees after the write-off. +- **ledger-store, `tests/arap_standing.rs`:** + `write_off_waits_for_documents_v2` (R-A: refused with + `FormatUpgradePending`, journal and head unchanged, in the old-books + fixture; taken after the upgrade; the evidence filed on the entry and + linked to the invoice; replay equals live); + `write_off_replays_and_projects` (the journal holds one + `invoice.written_off` with its five facts, `receipt.attached` then + `attachment.linked`; 6990 carries the expense, 1200 is zero, the + control check agrees, `verify` passes and a reopened store has the same + books and the same figures); `write_off_without_evidence_is_refused` + (no evidence, and an attachment that isn't there, journal nothing; one + already filed is linked to the entry and the invoice). + `tests/arap_props.rs` gains `Op::WriteOff`: random write-offs (partial + and over-large, refused ones included) run through the store, which is + then reopened and read by a `Snapshot`; `status_is_a_function_of_journal_and_date` + still holds at every date. +- **Over the wire, `crates/ledger-cli/tests/documents.rs`:** + `write_off_then_recovery` (an upload as the evidence, `confirmed`, the + invoice reading written_off with its figures and its timeline, the + receipt on the entry, the reversal and the payment, the control check + agreeing) and `write_off_needs_evidence` (neither or both evidence + arguments refused; an already-filed attachment linked to the entry and + the invoice). +- **The viewer, `crates/ledger-gui/tests/invoices.rs`:** + `a_write_off_shows_on_the_timeline` (the shipped screenshot + `invoice-written-off`: status Written off, still owed $0.00, the + timeline's "Written off" row opening its entry). + `crates/ledger-gui/tests/view_model.rs`: `a_write_off_is_flagged_for_the_cpa` + (one item, "Write-off awaiting the CPA's note", the same through the + server's read of the store and the viewer's snapshot). +- **Lean, `Wanderbooks/Documents.lean`:** `Step.writeOff` (a tagged + control line and an untagged expense line: a `Step.document` in shape), + `Step.writeOff_unattached` (it moves nothing unattached, so + `control_agrees` and `Reachable.control_is_documents` still hold), + `through_append`, `docSum_writeOffLines`, and `writeOff_within_bounds` + (a write-off of at most what is owed leaves nothing owed below zero over + the date-sum). Both theorems are in `Audit.lean`. No kernel function + was added, so no Kani harness. +- **bookspec:** `bad-debt-direct-write-off` (B-41, CHOICE: invoice 100, + two partial write-offs, the balances and the trial balance) and + `arap-hand-control-posting-refused` (B-38: an entry an earlier version + wrote on receivables replays, attempts the control guard now refuses, + and the books still tie out). The example schema gained the `write_off` + event and lets a refused `lines` event say so (an entry posts whole or + not at all); the refusal vocabulary gained `untagged_control_line`, + which belongs to B-38. + +**For P12 (mutation targets, none run, D-33):** `Ledger::write_off_steps`' +cash-basis comparison (the `eq_ignore_ascii_case`, the early return), its +order of checks (amount before the day, the day before the room), the +`amount > room` bound and its `room < owed` message arm, the `WriteOff` +tag, `least_owed_from`'s `max` and its `min` over later days (the refactor +`payment_room` now shares), `replay_invoice_write_off`'s movement, and +`doc_state`'s `written_off` arm; `Store::write_off_invoice`'s evidence +arms (the `check_linkable` call, the already-linked-to-the-invoice check, +the blob-before-journal order, the `File`/`Existing` link steps) and the +feature gate; the MCP handler's amount default, the exactly-one evidence +check, and the preview's clone; attention's year comparison and its +`stands_on` filter; the bookspec loader's `may_refuse` arm. diff --git a/docs/verification.md b/docs/verification.md index 9d03108..195a55e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -504,6 +504,35 @@ come from the kernel: No Lean model: these are two arithmetic laws, not a new `Ledger` command (S4.1 adds fields to the existing document commands). +### Bad debts (P4b S7) + +A write-off (rules.md B-41) is `Ledger::write_off`: a new command, so a new +Lean step, and no new kernel function (its two lines are one debit and one +credit, and the bounds are the existing date-sum's). + +34. **A write-off of at most what is owed leaves nothing owed below + zero.** The step posts the document's control line credited (tagged + with it, so it is the same sum B-38 fixed) and the amount of + bad-debt expense debited (untagged, off the control accounts). + `through_append` reads the write-off's own two lines through the day + separately; `docSum_writeOffLines` gives the control line's −amount; + `writeOff_within_bounds` then follows by arithmetic from the command's + own premise (the amount is at most the date-sum through the day). + `Step.writeOff_unattached` shows the step moves nothing unattached, so + `control_agrees` and `Reachable.control_is_documents` hold for it with + no new proof. A reversal re-instates: the same `reverse_twice` that + covers payments (`Step.reverse` mirrors every tagged line). + - Lean `Documents.Step.writeOff`, `Documents.Step.writeOff_unattached`, + `Documents.through_append`, `Documents.docSum_writeOffLines`, + `Documents.writeOff_within_bounds` (in `Audit.lean`) · tests + `half_paid_rest_written_off_is_written_off`, + `write_off_above_outstanding_refused`, + `write_off_then_recovery_same_year`, and the store's + `write_off_replays_and_projects` (live, replay and snapshot agree). + The model's limits are the documents model's (one `Nat` id space, + books built from empty); the cash-basis refusal is a policy on the + books' settings, not arithmetic, and is tested, not modelled. + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, @@ -592,7 +621,7 @@ Be precise about the claims: `tests/properties.rs` (trial balance, balance sheet, reversals, immutability) and the golden files. - **Out of scope:** currency (the ledger is single-currency, enforced - structurally), A/R and A/P documents, payroll, the calendar walk around + structurally), payroll, the calendar walk around the proration kernel (`calendar::month_parts`, `assets::depreciation_schedule`, `schedules::daily_plan`: property tests, `tests/properties.rs`, P3), and persistence (the journal store's hash chain is tested in `ledger-store`). From e801908a880fd0e41f082dac1a10f17dc9797107 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 12:15:51 -0600 Subject: [PATCH 07/12] test(mcp): exercise the write-off in the happy path (P4b S7) spec_mcp's inv1 walks every tool once and fails on any the books never answered: the happy path now creates an invoice that will not be paid, uploads the collection letter, and writes it off with the evidence in the same call, so books.write_off_invoice's output schema is checked like every other tool's. --- crates/ledger-cli/tests/spec_mcp.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index ede855a..4a3bc23 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -896,6 +896,20 @@ fn happy_path(name: &str) -> Mcp { json!({"kind": "invoices", "status": "all"}), ); m.ok("books.document", json!({"ref": format!("invoice:{inv}")})); + // 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"}]}), + )["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( From f31dd2640b738559df20dad0c4d2c812fdd2491a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 12:15:51 -0600 Subject: [PATCH 08/12] docs: the write-off's place among the destructive tools and the tool counts (P4b S7) --- docs/mcp-interface.md | 6 +++--- docs/testing.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 7010737..d74e886 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -350,7 +350,7 @@ Tool descriptions are at most 700 characters; eight longer ones are listed in `tool_descriptions_fit_700_characters`, to be shortened in P5. **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool -that changes anything also has a `destructiveHint`, true for these 29: +that changes anything also has a `destructiveHint`, true for these 30: `books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.unlink_attachment`, `books.withdraw_attachment`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, @@ -360,8 +360,8 @@ that changes anything also has a `destructiveHint`, true for these 29: `books.void_statement`, `books.void_reconciliation`, `books.set_w9`, `books.set_tin` (both overwrite a vault record), `books.purge_tin`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, -`books.pay_bill`, `books.void_document`, `books.run_payroll`, -`books.remit_payroll_taxes`; and an +`books.pay_bill`, `books.void_document`, `books.write_off_invoice`, +`books.run_payroll`, `books.remit_payroll_taxes`; and an `idempotentHint` on the ten that a repeat with the same arguments leaves the journal and the vault as they were: `books.verify`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, diff --git a/docs/testing.md b/docs/testing.md index c7bcc6f..c2fa7f6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2674,7 +2674,7 @@ it calls what part A proved and tested. check, so both output schemas are exercised on every run. - **The tool surface, `crates/ledger-mcp/src/server.rs`'s tests:** `annotations_complete` pins `books.void_document` destructive and - posting, `books.control_check` read-only, and counts the 29 + posting, `books.control_check` read-only, and counts the 30 destructive tools the doc names; `period_tools_describe_the_one_default` holds `books.control_check` to the one `as_of` sentence. The generic lints cover the rest without a change of their own: @@ -2725,11 +2725,11 @@ strikethrough, and the test asserts the labels. The happy-path journey lists and reads one, so both output schemas are exercised on every run. - **The tool surface:** `annotations_complete` pins both read-only - (table, now 122 tools); `period_tools_describe_the_one_default` holds + (table, now 123 tools); `period_tools_describe_the_one_default` holds both to the `as_of` sentence; `every_tool_is_documented_and_every_documented_tool_exists` and the text lints cover the doc; `scopes.rs`'s table and - `mcp_journeys.rs::mcp_end_to_end` count 122. + `mcp_journeys.rs::mcp_end_to_end` count 123. - **The viewer, `crates/ledger-gui/tests/invoices.rs`:** `the_list_shows_the_ledgers_figures` (the reversed payment no longer counts: $580 owed, $500 of it late; "Invoice 1, Harbor Client, 14 days From 22cdfcf34b720fb0b0c961126468232bfb3b0238 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 12:16:23 -0600 Subject: [PATCH 09/12] refactor(arap): rustfmt the unit's files, and the write-off's SOURCE line (P4b S7) No behaviour: the files as rustfmt leaves them after the unit's edits (the commits before this one were made before the formatter ran), and the Ledger::write_off spec block cites A-5 alone for the direct write-off method (A-6 is the hand-entry refusal, not this command). --- crates/ledger-cli/tests/documents.rs | 22 ++++++++++--- crates/ledger-core/src/ledger.rs | 6 ++-- crates/ledger-core/tests/bookspec.rs | 15 +++++---- crates/ledger-gui/tests/invoices.rs | 3 +- crates/ledger-gui/tests/view_model.rs | 23 ++++++++------ crates/ledger-mcp/src/tools/write_offs.rs | 6 +--- crates/ledger-store/src/lib.rs | 4 +-- crates/ledger-store/src/store.rs | 9 ++---- crates/ledger-store/tests/arap_standing.rs | 37 ++++++++++++++++------ 9 files changed, 75 insertions(+), 50 deletions(-) diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs index b4d5cab..fc95e22 100644 --- a/crates/ledger-cli/tests/documents.rs +++ b/crates/ledger-cli/tests/documents.rs @@ -87,7 +87,12 @@ fn write_off_then_recovery() { }), ); // 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 upload = wire.upload( + LETTER, + "collection-letter.pdf", + "application/pdf", + "document", + ); let out = ok( &mut wire, "books.write_off_invoice", @@ -110,7 +115,10 @@ fn write_off_then_recovery() { // 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")); + assert_eq!( + got["receipts"][0]["file_name"], + json!("collection-letter.pdf") + ); let doc = ok( &mut wire, "books.document", @@ -225,6 +233,12 @@ fn write_off_needs_evidence() { 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}"); + assert!( + links.iter().any(|l| l["kind"] == json!("invoice")), + "{receipt}" + ); + assert!( + links.iter().any(|l| l["kind"] == json!("entry")), + "{receipt}" + ); } diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index b1b52d0..4e4dfb4 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -6329,9 +6329,9 @@ impl Ledger { /// §1.166-2(a) (worthlessness on all the pertinent evidence), /// §1.166-3(a) (a partial write-off, to the extent charged /// off); docs/spec/rules.md B-41; plan/served.md P4b S7, - /// A-5 (direct write-off), A-6; ASC 326-20 noted (an allowance - /// is the GAAP preference; a small company's CPA accepts the - /// direct method, stated). + /// A-5 (the direct write-off method); ASC 326-20 noted (an + /// allowance is the GAAP preference; a small company's CPA + /// accepts the direct method, stated). /// INVARIANTS: /// 1. MUST post one balanced entry: Dr the bad_debts role (6990 Bad /// Debt Expense), Cr receivables, for the amount, dated the diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index d881306..643c24f 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -1171,13 +1171,14 @@ impl<'a> Books<'a> { .unwrap_or_else(|| panic!("{what}: event {} posted no invoice", w.invoice)); let amount = match &w.amount { Some(a) => exact(a, what), - None => self - .store - .ledger() - .invoice_state(invoice, day()) - .expect("the invoice's figures") - .expect("dated by its own day") - .outstanding, + None => { + self.store + .ledger() + .invoice_state(invoice, day()) + .expect("the invoice's figures") + .expect("dated by its own day") + .outstanding + } }; self.store .write_off_invoice( diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index d0d1b3f..dcba52e 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -252,8 +252,7 @@ fn a_write_off_shows_on_the_timeline() { let mut h = open(&dir, Route::Invoice(inv)); shot(&mut h, "invoice-written-off"); assert!( - h.query_by_label_contains("Written off, entry #") - .is_some(), + h.query_by_label_contains("Written off, entry #").is_some(), "the timeline shows the write-off" ); // The status chip reads its label ("Written off"), not "late". diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index 0781605..d6c0c4d 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -892,10 +892,12 @@ fn a_write_off_is_flagged_for_the_cpa() { }, ); let want = format!("Write-off awaiting the CPA's note: Invoice {inv}"); - let said = server - .iter() - .find(|a| a.title == want) - .unwrap_or_else(|| panic!("the server's list has no {want:?}: {:?}", server.iter().map(|a| &a.title).collect::>())); + let said = server.iter().find(|a| a.title == want).unwrap_or_else(|| { + panic!( + "the server's list has no {want:?}: {:?}", + server.iter().map(|a| &a.title).collect::>() + ) + }); assert_eq!(said.severity, ledger_package::Severity::Todo); assert!(said.prompt.contains("wanderbooks"), "{}", said.prompt); assert!(said.detail.contains("100.00"), "{}", said.detail); @@ -910,14 +912,15 @@ fn a_write_off_is_flagged_for_the_cpa() { .attention .iter() .find(|a| a.title == want) - .unwrap_or_else(|| panic!("the viewer shows no {want:?}: {:?}", books.attention.iter().map(|a| &a.title).collect::>())); + .unwrap_or_else(|| { + panic!( + "the viewer shows no {want:?}: {:?}", + books.attention.iter().map(|a| &a.title).collect::>() + ) + }); assert_eq!(shown.target, Some(ledger_gui::nav::Route::Documents)); // The viewer reads the same figures from the journal it replayed. - let st = books - .ledger - .invoice_state(inv, today) - .unwrap() - .unwrap(); + let st = books.ledger.invoice_state(inv, today).unwrap().unwrap(); assert_eq!(st.status, ledger_core::DocStatus::WrittenOff); assert!(books.ledger.get_entry(entry).is_some()); } diff --git a/crates/ledger-mcp/src/tools/write_offs.rs b/crates/ledger-mcp/src/tools/write_offs.rs index eda005e..62b137b 100644 --- a/crates/ledger-mcp/src/tools/write_offs.rs +++ b/crates/ledger-mcp/src/tools/write_offs.rs @@ -128,10 +128,6 @@ impl BooksServer { entry_id } }; - Ok(Json(write_off_out( - store.ledger(), - p.invoice, - entry_id, - )?)) + Ok(Json(write_off_out(store.ledger(), p.invoice, entry_id)?)) } } diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 625890c..95fb2c9 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -49,6 +49,6 @@ pub use snapshot::{DiskCheck, Snapshot}; pub use store::{ Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation, - Statement, Store, Subject, VerifyReport, WriteOffEvidence, database_in_use, db_file, db_file_for, - is_database_file, + Statement, Store, Subject, VerifyReport, WriteOffEvidence, database_in_use, db_file, + db_file_for, is_database_file, }; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 87154b6..1c9d50a 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -3747,13 +3747,8 @@ impl Store { let description = format!("Write off invoice {invoice}: {}", reason.trim()); let (entry_id, mut journaled) = self.journal_auto_entry(date, &description, bad, ar, amount)?; - let written_off = crate::meta::invoice_written_off( - invoice, - entry_id, - date, - amount, - reason.trim(), - ); + 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"); diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index 8bff100..62b6eaf 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -1100,10 +1100,7 @@ fn write_off_waits_for_documents_v2() { .unwrap(); s.verify().unwrap(); let live = s.ledger().clone(); - let st = live - .invoice_state(1, d(2026, 12, 31)) - .unwrap() - .unwrap(); + 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. @@ -1135,7 +1132,12 @@ fn write_off_replays_and_projects() { .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)]) + .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). @@ -1175,10 +1177,16 @@ fn write_off_replays_and_projects() { 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))); + 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(); + 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), @@ -1206,7 +1214,11 @@ fn write_off_replays_and_projects() { "{:?}", live.differs_from(s.ledger()) ); - let replayed = s.ledger().invoice_state(inv, d(2026, 5, 1)).unwrap().unwrap(); + let replayed = s + .ledger() + .invoice_state(inv, d(2026, 5, 1)) + .unwrap() + .unwrap(); assert_eq!(replayed, st); s.verify().unwrap(); } @@ -1220,7 +1232,12 @@ fn write_off_without_evidence_is_refused() { 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)]) + .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); From 176baa5250d57d838cb7cc24f55f59ac2841fdb0 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 12:44:38 -0600 Subject: [PATCH 10/12] test(arap): the role inventory gains bad_debts (P4b S7) B-41 gives the write-off its own expense role (6990 Bad Debt Expense, 8.3.4), so the pinned inventory of control roles has thirteen keys, not twelve; the new role's key, default code and type are checked with the rest. No other role changes. --- crates/ledger-core/tests/spec_roles.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ledger-core/tests/spec_roles.rs b/crates/ledger-core/tests/spec_roles.rs index 09d6fb5..bb37b57 100644 --- a/crates/ledger-core/tests/spec_roles.rs +++ b/crates/ledger-core/tests/spec_roles.rs @@ -48,8 +48,9 @@ fn line(desc: &str, amount: &str) -> DocumentLine { } } -/// The twelve role keys, as the spec lists them (B-23 and `UnknownRole`). -const KEYS: [&str; 12] = [ +/// The thirteen role keys, as the spec lists them (B-23, B-41's +/// bad_debts, and `UnknownRole`). +const KEYS: [&str; 13] = [ "receivables", "revenue", "payables", @@ -62,6 +63,7 @@ const KEYS: [&str; 12] = [ "social_security_payable", "medicare_payable", "other_deductions_payable", + "bad_debts", ]; fn role(key: &str) -> &'static ControlAccount { @@ -248,8 +250,9 @@ fn alice() -> PayrollLine { // ------------------------------------------------------------ the roles -/// Oracle: B-23 names the roles and their default codes; `control_account` -/// finds a role by its `key`; `UnknownRole` lists the twelve keys. +/// Oracle: B-23 (and B-41 for bad_debts) names the roles and their default +/// codes; `control_account` finds a role by its `key`; `UnknownRole` lists +/// the thirteen keys. #[test] fn every_role_has_its_key_default_code_and_type() { use AccountType::*; @@ -258,6 +261,7 @@ fn every_role_has_its_key_default_code_and_type() { ("revenue", "4000", Revenue), ("payables", "2000", Liability), ("general_expense", "6999", Expense), + ("bad_debts", "6990", Expense), ("retained_earnings", "3999", Equity), ("salary_expense", "6100", Expense), ("payroll_tax_expense", "6110", Expense), From d74ee1e9620f45b1d21b7c74b54cfb72a35741dc Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 12:45:01 -0600 Subject: [PATCH 11/12] docs(plan): P4b S7 as built (P4b S7) The unit's As-built block: where the build departed from its text, and why (the bad_debts role, the least-owed bound over the date-sum, the evidence rule, the handbook section it had to create, the bookspec schema's write_off event, and where the tests live). The Build text is untouched; the EM moves the status row. --- plan/served.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plan/served.md b/plan/served.md index 65948c9..8a15c12 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1375,7 +1375,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `Ledger::write_off`, the event's arms, `books.write_off_invoice`, `Step.writeOff`; touches the 8.0.3 files. 6. **Tests and proofs:** named above; Lean `writeOff_within_bounds`; no kernel change. 7. **Done when:** (1) the tests pass; (2) `just prove` green with the new theorem in `Audit.lean`; (3) 8.0.6 in the PR; (4) the handbook says when to void, write off or reverse; (5) `just check` green. -8. **As built:** none yet. +8. **As built** (2026-10-27, `d5af44b9`, `62f2fb05`, `4e71cf63`, `0f1c7786`, `b731d7ed`, `03227786`, `e801908a`, `f31dd264`, `22cdfcf3`, `176baa52`): departures only, the Build text above untouched. + - The `bad_debts` role had no engine constant (8.3.4's table names it, but only S7 needed it): `ledger_core::BAD_DEBTS` (6990 Bad Debt Expense, an Expense control role) is added and joined to `control_accounts()`, so `books.roles` and `books.assign_role` see it. + - The amount bound is the least the invoice owes on any day from the write-off's on (`least_owed_from`, the helper `payment_room` now shares), not only its outstanding on the day: a write-off dated before a payment that later settles it would otherwise take the document below nothing at that later day (B-38 INVARIANT 2). The refusal says what is owed and, when a later payment lowers it, what can be written off. + - The evidence given as `existing_attachment_id` must be an attachment not yet on an entry: one already filed against another entry would be one receipt counted twice (the refusal `link_receipt` gives, B-38's own invariant 5). An upload's bytes are put in the content-addressed store before the first journal line, as the issued invoice's PDF is (S4.3). + - The handbook section `invoices-and-bills` did not exist yet (S4.5 owns it and has not landed), so this unit created it with step 4 and the two recovery procedures; S4.5 adds steps 1-3 and 5 around them, in its own reading position. + - bookspec had no way to state a write-off: the example schema gained the dated `write_off = { invoice, amount?, reason }` event (the invoice named by its event, as `reverses` names one), and a `refused` `lines` entry, which the adapter rehearses on a clone so a refusal journals nothing; `older` and `refused` never combine. The loader's refusal vocabulary gained `untagged_control_line` (B-38), as the unit says. `bad-debt-direct-write-off` states B-41 (its bookspec status moves from "allocated" to CHOICE); `arap-hand-control-posting-refused` states B-38's control guard and its legacy-line replay. + - The store tests went into `tests/arap_standing.rs` (where R-A puts the feature-wait test) and the over-the-wire tests opened `crates/ledger-cli/tests/documents.rs` (its allocated file); `arap_props.rs` gained `Op::WriteOff`. + - `docs/verification.md`'s "out of scope" list no longer says A/R and A/P documents are unproved: S1's `Documents` model exists, and S7 adds the write-off step to it. + - There is no `books.adopt_control_line`, no `books.assign_role { move_balance }` and no `Movement::Recovery` (D-49, 8.4), as the unit says. **Deferred from S7 (D-49, recorded in 8.4):** `books.adopt_control_line` and `books.assign_role { move_balance }`. 8.3.14 item 4 already gives two remedies for a legacy hand line on 1200 or 2000 that need no code: reverse it and re-enter it as a document while its period is open; or leave it, record a decision, and let the CPA README footnote it (the identity's `unattached` term exists for exactly this). Build the tools only if `books.control_check` on the owner's books lists a line neither remedy can take; then the EM writes a unit for them with a Lean `Step.adopt` and a per-account `owed_on` identity (8.4 has the notes). From 4b4fecb98e2e360f087ea73d34d3587ee80d4214 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 13:27:27 -0600 Subject: [PATCH 12/12] feat(gui): the journal says a write-off in plain words (P4b S7) R-B: a new event type gets a describe arm, so the journal's list reads "Invoice 7 written off, 60.00" and opens the entry, rather than printing the type at a human. every_journal_event_has_plain_language_text scans store.rs for the types and fails on any without text. --- crates/ledger-gui/src/pages/journal.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 67654c5..2424e0a 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -812,6 +812,14 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { format!("Bill paid, {}", amount(p)), entry_id.map(Route::Entry), ), + "invoice.written_off" => { + let invoice = p.get("invoice_id").and_then(|v| v.as_u64()).unwrap_or(id); + ev( + icon::HAND_COINS, + format!("Invoice {invoice} written off, {}", amount(p)), + entry_id.map(Route::Entry), + ) + } "period.created" => ev( icon::CALENDAR_PLUS, format!("Period {} created", s(p, "name")),