From d5af44b9cb3a77f983178fec44257476f35973fd Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:01:03 -0600 Subject: [PATCH] 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 // ---------------------------------------------------------------------------