merge: P4b S7, bad debts (branch S7, 4b4fecb9)

The fourth of the paused branches: Ledger::write_off and the bad_debts
control role, invoice.written_off with its evidence filed in the same
store call, books.write_off_invoice, the write-off on the timeline and
in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the
bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and
S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with
their seams restored, and: the scope table at 125; S7's law numbered 37;
B-41 placed before B-42; the handbook's step 4 and recovery procedures
folded into the one invoices-and-bills section; S7's wire tests in their
own module with the confirm S4.4's contract needs; S7's copy of the
payment steps dropped (S6 replaced them with Ledger::settle) and its
write-off journaled through S6's journal_auto_lines; in the old-books
fixture S7's hand-written write-off moved to a second invoice, since
S6's hand-written payment settles invoice 1 in full.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:47:33 -06:00
commit 1e42d8f265
6 files changed
+241 -18

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"
+6 -6
View File
@@ -6,10 +6,10 @@ 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-43
allocated in `plan/served.md` 8.0.3; those not yet stated keep only to B-45 are allocated in `plan/served.md` 8.0.3 and not yet stated: their
their identifier and area here, so a later unit fills them in without identifier and area are fixed here so a later unit fills them in
moving anyone else's number. without moving anyone else's number.
| B | bookspec id | area | status | rule | examples | | B | bookspec id | area | status | rule | examples |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@@ -50,10 +50,10 @@ 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 | CONVENTION | A settlement is exact, one party, and names its lines; the gross discount and the gross fee | — | | B-42 | `BS-arap-6` | arap | CONVENTION | A settlement is exact, one party, and names its lines; the gross discount and the gross fee | — |
| 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) | — |