From 73fc7126475cca56e2a76db88c2b2ef11dd7ebde Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 11:50:00 -0600 Subject: [PATCH] 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. --- README.md | 23 ++++-- checker/src/rules.rs | 9 ++- checker/src/schema.rs | 62 ++++++++++++++- .../arap-hand-control-posting-refused.toml | 77 +++++++++++++++++++ examples/bad-debt-direct-write-off.toml | 76 ++++++++++++++++++ spec/rules-index.md | 8 +- 6 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 examples/arap-hand-control-posting-refused.toml create mode 100644 examples/bad-debt-direct-write-off.toml diff --git a/README.md b/README.md index 435439e..f1a6398 100644 --- a/README.md +++ b/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/checker/src/rules.rs b/checker/src/rules.rs index dc59220..be1209e 100644 --- a/checker/src/rules.rs +++ b/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/checker/src/schema.rs b/checker/src/schema.rs index 29d3936..ac4c9d7 100644 --- a/checker/src/schema.rs +++ b/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/examples/arap-hand-control-posting-refused.toml b/examples/arap-hand-control-posting-refused.toml new file mode 100644 index 0000000..a788146 --- /dev/null +++ b/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/examples/bad-debt-direct-write-off.toml b/examples/bad-debt-direct-write-off.toml new file mode 100644 index 0000000..7d706f0 --- /dev/null +++ b/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/spec/rules-index.md b/spec/rules-index.md index 436f328..607884c 100644 --- a/spec/rules-index.md +++ b/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) | — |