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`).