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 b731d7ed04
commit 0322778683
13 files changed
+547 -25

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
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 = "<reason>"` 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 = "<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?
}`: 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]`.
+5 -4
View File
@@ -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,
+59 -3
View File
@@ -367,6 +367,9 @@ pub struct Event {
/// An invoice.
#[serde(default)]
pub invoice: Option<InvoiceSpec>,
/// A bad-debt write-off of an earlier invoice.
#[serde(default)]
pub write_off: Option<WriteOffSpec>,
/// A depreciation run.
#[serde(default)]
pub run_depreciation: Option<RunSpec>,
@@ -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<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.
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -973,6 +1001,7 @@ pub fn refusal_reasons() -> impl Iterator<Item = &'static str> {
"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) {
@@ -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"
@@ -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:
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) | — |
+77 -6
View File
@@ -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::<Vec<_>>()[..] {
[(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);
+3
View File
@@ -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);
+11
View File
@@ -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).
+8 -2
View File
@@ -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).
+102
View File
@@ -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`.
+77
View File
@@ -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.
+30 -1
View File
@@ -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`).