docs(arap): B-41, the bookspec examples, and the four registers (P4b S7)

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.
This commit is contained in:
swaits committed 2026-10-27 12:44:45 -06:00
1 parent a4c70dd3b1
commit 73fc712647
6 files changed
+239 -16

No files matched your search

+18 -5
View File
@@ -264,6 +264,8 @@ the side gives the direction.
on a receivables or payables account, where only an invoice, a bill or 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 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. 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 - `close = { start, to, rounding? }` as an event: closes the period from
`start` through the event's `date` (rules.md B-11, B-13), `to` `start` through the event's `date` (rules.md B-11, B-13), `to`
`"members"` (split by share, in the order listed) or `"members"` (split by share, in the order listed) or
@@ -303,9 +305,18 @@ the side gives the direction.
the disposal entry. the disposal entry.
- `run_schedules = { through }` as an event: every schedule's months due - `run_schedules = { through }` as an event: every schedule's months due
through `through` posted (B-16). through `through` posted (B-16).
- `refused = "<reason>"` on a reversal or a command: the books refuse it, - `write_off = { invoice, amount?, reason }` as an event, dated: writes
for that reason (below); it changes nothing, posts nothing, and the off the invoice event `invoice` (1-based; an `invoice` event before it)
example goes on without 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 = "<reason>"` 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? - `[[changes]]` `{ after, rename?, require_tax_category?, correct_asset?
}`: a change that posts no entry, exactly one of `rename = { code, name }`: a change that posts no entry, exactly one of `rename = { code, name
}` (rename the account `code`, B-31), `require_tax_category = "warn"` }` (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 disposal, a disposed asset's depreciation, a run re-instated twice or past
its schedule, or a reversal dated before its run), `already_disposed`, its schedule, or a reversal dated before its run), `already_disposed`,
`invalid_depreciation_period` (a month that ends before the asset is in `invalid_depreciation_period` (a month that ends before the asset is in
service) and `invalid_schedule` (an equal-months schedule not starting on service), `invalid_schedule` (an equal-months schedule not starting on
the 1st, among others). 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]`. `cash_flow` (rules.md B-14): the statement of cash flows for `[from, to]`.
+5 -4
View File
@@ -165,6 +165,7 @@ pub fn rule_for_refusal(reason: &str) -> Option<&'static Rule> {
| "already_disposed" | "already_disposed"
| "invalid_depreciation_period" => 15, | "invalid_depreciation_period" => 15,
"invalid_schedule" => 16, "invalid_schedule" => 16,
"untagged_control_line" => 38,
_ => return None, _ => return None,
}; };
rule(b) rule(b)
@@ -529,7 +530,7 @@ const SPEC: Spec = &[
Area::Arap, Area::Arap,
"A document's figures are its ledger lines that stand; a control account is its documents", "A document's figures are its ledger lines that stand; a control account is its documents",
Some(Status::Choice), Some(Status::Choice),
&[], &["arap-hand-control-posting-refused"],
), ),
( (
39, 39,
@@ -548,9 +549,9 @@ const SPEC: Spec = &[
( (
41, 41,
Area::Arap, Area::Arap,
"Write-offs (allocated, not yet stated)", "A debt that will not be collected is written off, and a recovery is income",
None, Some(Status::Choice),
&[], &["bad-debt-direct-write-off"],
), ),
( (
42, 42,
+59 -3
View File
@@ -367,6 +367,9 @@ pub struct Event {
/// An invoice. /// An invoice.
#[serde(default)] #[serde(default)]
pub invoice: Option<InvoiceSpec>, pub invoice: Option<InvoiceSpec>,
/// A bad-debt write-off of an earlier invoice.
#[serde(default)]
pub write_off: Option<WriteOffSpec>,
/// A depreciation run. /// A depreciation run.
#[serde(default)] #[serde(default)]
pub run_depreciation: Option<RunSpec>, pub run_depreciation: Option<RunSpec>,
@@ -409,6 +412,7 @@ impl Event {
(EventKind::Close, self.close.is_some()), (EventKind::Close, self.close.is_some()),
(EventKind::Bill, self.bill.is_some()), (EventKind::Bill, self.bill.is_some()),
(EventKind::Invoice, self.invoice.is_some()), (EventKind::Invoice, self.invoice.is_some()),
(EventKind::WriteOff, self.write_off.is_some()),
(EventKind::RunDepreciation, self.run_depreciation.is_some()), (EventKind::RunDepreciation, self.run_depreciation.is_some()),
(EventKind::DisposeAsset, self.dispose_asset.is_some()), (EventKind::DisposeAsset, self.dispose_asset.is_some()),
(EventKind::RunSchedules, self.run_schedules.is_some()), (EventKind::RunSchedules, self.run_schedules.is_some()),
@@ -445,6 +449,8 @@ pub enum EventKind {
Bill, Bill,
/// An invoice. /// An invoice.
Invoice, Invoice,
/// A bad-debt write-off of an earlier invoice (rules.md B-41).
WriteOff,
/// A depreciation run. /// A depreciation run.
RunDepreciation, RunDepreciation,
/// A fixed asset's disposal. /// A fixed asset's disposal.
@@ -464,6 +470,7 @@ impl EventKind {
EventKind::Close => "close", EventKind::Close => "close",
EventKind::Bill => "bill", EventKind::Bill => "bill",
EventKind::Invoice => "invoice", EventKind::Invoice => "invoice",
EventKind::WriteOff => "write_off",
EventKind::RunDepreciation => "run_depreciation", EventKind::RunDepreciation => "run_depreciation",
EventKind::DisposeAsset => "dispose_asset", EventKind::DisposeAsset => "dispose_asset",
EventKind::RunSchedules => "run_schedules", EventKind::RunSchedules => "run_schedules",
@@ -481,6 +488,7 @@ impl EventKind {
| EventKind::Close | EventKind::Close
| EventKind::Bill | EventKind::Bill
| EventKind::Invoice | EventKind::Invoice
| EventKind::WriteOff
| EventKind::DisposeAsset | EventKind::DisposeAsset
) )
} }
@@ -499,12 +507,14 @@ impl EventKind {
} }
/// True when the books take it whole or not at all, so `refused` may /// 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 /// be given: a reversal, an entry or a command, never a close, a bill
/// or an invoice. /// 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 { pub fn may_refuse(self) -> bool {
!matches!( !matches!(
self, self,
EventKind::Lines | EventKind::Close | EventKind::Bill | EventKind::Invoice EventKind::Close | EventKind::Bill | EventKind::Invoice
) )
} }
@@ -513,6 +523,7 @@ impl EventKind {
!matches!( !matches!(
self, self,
EventKind::Reverses EventKind::Reverses
| EventKind::WriteOff
| EventKind::RunDepreciation | EventKind::RunDepreciation
| EventKind::DisposeAsset | EventKind::DisposeAsset
| EventKind::RunSchedules | EventKind::RunSchedules
@@ -582,6 +593,23 @@ pub struct InvoiceSpec {
pub lines: Vec<DocumentLineSpec>, pub lines: Vec<DocumentLineSpec>,
} }
/// 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<String>,
/// Why it will not be collected (collection correspondence, a
/// bankruptcy), one line.
pub reason: String,
}
/// One line of an invoice or a bill. /// One line of an invoice or a bill.
#[derive(Clone, Debug, PartialEq, Deserialize)] #[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -973,6 +1001,7 @@ pub fn refusal_reasons() -> impl Iterator<Item = &'static str> {
"already_disposed", "already_disposed",
"invalid_depreciation_period", "invalid_depreciation_period",
"invalid_schedule", "invalid_schedule",
"untagged_control_line",
] ]
.into_iter() .into_iter()
} }
@@ -1105,6 +1134,13 @@ impl Example {
"older is an entry's lines, as an earlier version wrote them".into(), "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 { if ev.payee.is_some() && kind != EventKind::Lines {
return Err(what( return Err(what(
"a payee is an entry's: a reversal takes its original's, a bill names \ "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())); 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 { if let Some(r) = &ev.run_depreciation {
check_month(&r.period, &what)?; check_month(&r.period, &what)?;
if !self.setup.assets.iter().any(|a| a.id == r.asset) { if !self.setup.assets.iter().any(|a| a.id == r.asset) {
@@ -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"
+76
View File
@@ -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"
+4 -4
View File
@@ -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: 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 the specification names the options, an engine declares its pick
(`claims.choices`, `bookspec_checker::Claims`). Rules B-39 to B-45 are (`claims.choices`, `bookspec_checker::Claims`). Rules B-39, B-40 and B-42
allocated in `plan/served.md` 8.0.3 and not yet stated: their 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 identifier and area are fixed here so a later unit fills them in
without moving anyone else's number. 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-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-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-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-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-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-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-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) | — | | B-44 | `BS-ledger-12` | ledger | — | Extraction and dedup (allocated, not yet stated) | — |