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:
1 parent
b731d7ed04
commit
0322778683
13 files changed
+547
-25
No files matched your search
+18
-5
@@ -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]`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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) | — |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user