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-29 02:59:55 -06:00
commit 7ebeeed2e6
48 files changed
+2884 -75

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"
@@ -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) | — |
+234
View File
@@ -1212,3 +1212,237 @@ mod paying_on_terms {
assert!(total.ends_with("difference 0.00"), "{total}"); assert!(total.ends_with("difference 0.00"), "{total}");
} }
} }
// P4b S7: bad debts (merged from branch S7), in its own module.
mod bad_debts {
use super::*;
/// A call's structured result, or a panic naming the tool and the refusal.
fn ok(wire: &mut common::Wire, tool: &str, args: Value) -> Value {
common::answer_of(&wire.call(tool, args)).unwrap_or_else(|e| panic!("{tool}: {e}"))
}
/// A call's refusal, or a panic naming the tool.
fn refused(wire: &mut common::Wire, tool: &str, args: Value) -> String {
match common::answer_of(&wire.call(tool, args)) {
Err(e) => e,
Ok(v) => panic!("{tool} accepted: {v}"),
}
}
/// The books of every test here: a bank account 1000, a customer, and one
/// invoice for 100.00 dated 2026-01-05, due 2026-02-04.
fn invoice(wire: &mut common::Wire) -> u64 {
ok(
wire,
"books.add_account",
json!({
"code": "1000",
"name": "Checking",
"account_type": "asset",
"parent": null,
"placeholder": null,
}),
);
let customer = ok(
wire,
"books.add_customer",
json!({ "name": "Fernhill Labs" }),
)["id"]
.as_u64()
.unwrap();
ok(
wire,
"books.create_invoice",
json!({
"party_id": customer,
"date": "2026-01-05",
"due_date": "2026-02-04",
"lines": [{ "description": "Design", "amount": "100.00" }],
"confirmed": true,
}),
)["id"]
.as_u64()
.unwrap()
}
/// A collection letter, the evidence of worthlessness.
const LETTER: &[u8] = b"%PDF-1.4\nFinal demand, unpaid\n%%EOF\n";
/// P4b S7: an invoice half paid is written off for the rest with its
/// evidence in the same call, reads written off, and the same-year
/// recovery D-49 records (reverse the write-off, then a payment) reopens
/// and settles it.
#[test]
fn write_off_then_recovery() {
let books = common::Books::new("s7-recovery");
let mut wire = books.wire();
wire.initialize("s7-recovery");
let inv = invoice(&mut wire);
ok(
&mut wire,
"books.receive_payment",
json!({
"document_id": inv,
"date": "2026-01-20",
"amount": "40.00",
"cash_account": "1000",
}),
);
// The write-off's evidence, uploaded as an agent uploads it (D-46).
let upload = wire.upload(
LETTER,
"collection-letter.pdf",
"application/pdf",
"document",
);
let out = ok(
&mut wire,
"books.write_off_invoice",
json!({
"invoice": inv,
"date": "2026-01-31",
"reason": "collection exhausted",
"upload_id": upload,
"confirmed": true,
}),
);
let entry = out["entry_id"].as_u64().unwrap();
assert!(entry > 0, "{out}");
assert_eq!(out["document"]["ref"], json!(format!("invoice:{inv}")));
assert_eq!(out["document"]["status"], json!("written_off"));
assert_eq!(out["document"]["paid"], json!("40.00"));
assert_eq!(out["document"]["written_off"], json!("60.00"));
assert_eq!(out["document"]["outstanding"], json!("0.00"));
// The evidence is on the write-off's entry, as a receipt, and on the
// invoice: get_entry shows the one, the document's timeline the other.
let got = ok(&mut wire, "books.get_entry", json!({ "entry_id": entry }));
assert_eq!(got["receipts"].as_array().map(Vec::len), Some(1), "{got}");
assert_eq!(
got["receipts"][0]["file_name"],
json!("collection-letter.pdf")
);
let doc = ok(
&mut wire,
"books.document",
json!({ "ref": format!("invoice:{inv}"), "as_of": "2026-02-01" }),
);
assert_eq!(doc["status"], json!("written_off"));
assert_eq!(doc["written_off"], json!("60.00"));
let write_off_lines: Vec<&Value> = doc["timeline"]
.as_array()
.unwrap()
.iter()
.filter(|t| t["kind"] == json!("write_off"))
.collect();
assert_eq!(write_off_lines.len(), 1, "{doc}");
// The same tax year: reverse the write-off; the invoice reopens.
ok(
&mut wire,
"books.reverse_entry",
json!({
"entry_id": entry,
"date": "2026-03-01",
"description": "Paid after all",
"confirmed": true,
}),
);
let doc = ok(
&mut wire,
"books.document",
json!({ "ref": format!("invoice:{inv}"), "as_of": "2026-03-02" }),
);
assert_eq!(doc["status"], json!("partial"));
assert_eq!(doc["outstanding"], json!("60.00"));
ok(
&mut wire,
"books.receive_payment",
json!({
"document_id": inv,
"date": "2026-03-02",
"amount": "60.00",
"cash_account": "1000",
}),
);
let doc = ok(
&mut wire,
"books.document",
json!({ "ref": format!("invoice:{inv}"), "as_of": "2026-03-03" }),
);
assert_eq!(doc["status"], json!("paid"));
assert_eq!(doc["outstanding"], json!("0.00"));
// The control accounts still equal their documents.
let check = ok(
&mut wire,
"books.control_check",
json!({ "as_of": "2026-03-31" }),
);
assert_eq!(check["agrees"], json!(true), "{check}");
}
/// P4b S7: no evidence, no write-off (B-41 INVARIANT 1): the tool refuses
/// with neither or both, and an attachment already filed is linked
/// instead of a new file.
#[test]
fn write_off_needs_evidence() {
let books = common::Books::new("s7-evidence");
let mut wire = books.wire();
wire.initialize("s7-evidence");
let inv = invoice(&mut wire);
let args = |extra: Value| {
let mut v = json!({
"invoice": inv,
"date": "2026-01-31",
"reason": "uncollectible",
"confirmed": true,
});
v.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
v
};
let why = refused(&mut wire, "books.write_off_invoice", args(json!({})));
assert!(why.contains("upload_id"), "{why}");
assert!(why.contains("existing_attachment_id"), "{why}");
let why = refused(
&mut wire,
"books.write_off_invoice",
args(json!({ "upload_id": "up_nope", "existing_attachment_id": 1 })),
);
assert!(why.contains("exactly one"), "{why}");
// A filed attachment (not linked to an entry) is linked to the
// write-off's entry and the invoice.
let upload = wire.upload(LETTER, "letter.pdf", "application/pdf", "document");
let filed = ok(
&mut wire,
"books.file_document",
json!({
"category": "other",
"upload_id": upload,
"description": "Collection attempts",
}),
);
let attachment = filed["attachment_id"].as_u64().unwrap();
let out = ok(
&mut wire,
"books.write_off_invoice",
args(json!({ "existing_attachment_id": attachment })),
);
assert_eq!(out["document"]["status"], json!("written_off"));
assert_eq!(out["document"]["written_off"], json!("100.00"));
let receipt = ok(
&mut wire,
"books.get_receipt",
json!({ "attachment_id": attachment }),
);
let links = receipt["links"].as_array().cloned().unwrap_or_default();
assert!(
links.iter().any(|l| l["kind"] == json!("invoice")),
"{receipt}"
);
assert!(
links.iter().any(|l| l["kind"] == json!("entry")),
"{receipt}"
);
}
}
+30
View File
@@ -770,6 +770,28 @@ fn books_p2_served_cut_over_whole() {
) )
.unwrap()["id"] .unwrap()["id"]
.clone(); .clone();
// A receipt not yet on an entry (P2 reads the unlink; entry 7's bank
// fee is under the receipt policy's threshold, so taking its last
// receipt is allowed), and a legacy-shape invoice (no new field, so no
// feature): the evidence and the document P4b S7's write-off needs
// after the go-ahead.
a.call(
"books.unlink_receipt",
json!({"attachment_id": 7, "reason": "filed against the wrong entry"}),
)
.unwrap();
let customer = a
.call("books.add_customer", json!({"name": "Ridgeway Co"}))
.unwrap()["id"]
.clone();
let unpaid = a
.call(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-01-10", "due_date": "2026-02-09",
"lines": [{"description": "Survey", "amount": "300.00"}], "confirmed": true}),
)
.unwrap()["id"]
.clone();
let waiting = journal(&dir); let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![ let refused: Vec<(&str, Value)> = vec![
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and // An invoice carrying documents_v2's fields (terms: P4b S4.4) and
@@ -824,6 +846,14 @@ fn books_p2_served_cut_over_whole() {
"books.close_period", "books.close_period",
json!({"period_id": q2, "confirmed": true}), json!({"period_id": q2, "confirmed": true}),
), ),
// A bad-debt write-off (documents_v2's new event, P4b S7): its
// evidence filed and linked in the same call.
(
"books.write_off_invoice",
json!({"invoice": unpaid, "date": "2026-02-20",
"reason": "collection exhausted",
"existing_attachment_id": 7, "confirmed": true}),
),
]; ];
for (tool, args) in &refused { for (tool, args) in &refused {
let e = a.call(tool, args.clone()).unwrap_err(); let e = a.call(tool, args.clone()).unwrap_err();
+1 -1
View File
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 124, "expected 124 tools, got {}", tools.len()); assert_eq!(tools.len(), 125, "expected 125 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path. // Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
+15
View File
@@ -914,6 +914,21 @@ fn happy_path(name: &str) -> Mcp {
"by": "Avery Quill", "recipients": ["billing@client.example"], "by": "Avery Quill", "recipients": ["billing@client.example"],
"reference": "msg-1"}), "reference": "msg-1"}),
); );
// P4b S7: a bad debt, its evidence uploaded and filed with it.
let bad = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-03", "due_date": "2026-03-03",
"lines": [{"description": "Consulting, never paid", "amount": "400.00"}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap();
let letter = m.upload(b"Final demand", "letter.txt", "text/plain", "document");
m.ok(
"books.write_off_invoice",
json!({"invoice": bad, "date": "2026-03-01", "reason": "collection exhausted",
"upload_id": letter, "confirmed": true}),
);
// Fixed assets. // Fixed assets.
let registered = m.ok( let registered = m.ok(
+6 -5
View File
@@ -154,9 +154,10 @@ impl DocRef {
} }
/// Why a ledger line carries its document: what it does to what is owed. /// Why a ledger line carries its document: what it does to what is owed.
/// Only `Origin` and `Payment` are written today (P4b S1); the rest are /// `Origin`, `Payment` and `WriteOff` are written today (S1, S7); the rest
/// the movements later slices write (P4b.6), named here so a tag's kind /// are movements only a later unit may write (P4b.6), named here so a
/// never changes meaning. /// tag's kind never changes meaning. `Adopted` and `RoleMove` are
/// deferred (D-49).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Movement { pub enum Movement {
@@ -168,9 +169,9 @@ pub enum Movement {
Discount, Discount,
/// A bad debt written off (S7). /// A bad debt written off (S7).
WriteOff, WriteOff,
/// A legacy hand line adopted as the document (S7). /// A legacy hand line adopted as the document (deferred, D-49).
Adopted, Adopted,
/// The balance moved with its control role (S7). /// The balance moved with its control role (deferred, D-49).
RoleMove, RoleMove,
} }
+15
View File
@@ -859,6 +859,21 @@ pub enum LedgerError {
/// books' text rule). Plan P4b S5. /// books' text rule). Plan P4b S5.
#[error("{0}")] #[error("{0}")]
InvalidSend(String), InvalidSend(String),
/// A bad debt can't be written off on cash-basis books (rules.md
/// B-41 INVARIANT 3; IRC §166(a); Treas. Reg. §1.166-1(e)): the
/// invoice was never counted as income, so there is no basis to
/// deduct.
#[error(
"a bad debt can't be written off on cash-basis books: the invoice was never counted as \
income, so there is nothing to deduct (IRC §166; Treas. Reg. §1.166-1(e)); these books \
are on the cash basis (books.accounting_basis)"
)]
WriteOffOnCashBasis,
/// A write-off the books can't take: its amount, its day, or its
/// reason. The message says which, and what the invoice owes.
#[error("{0}")]
InvalidWriteOff(String),
/// Invoice/bill due date is before its date. /// Invoice/bill due date is before its date.
#[error("due date {0} is before document date")] #[error("due date {0} is before document date")]
+188
View File
@@ -119,6 +119,16 @@ pub const PAYMENT_PROCESSING_FEES: ControlAccount = control(
"payment processing fees", "payment processing fees",
AccountType::Expense, AccountType::Expense,
); );
/// A control account; see [`ControlAccount`]. Bad-debt expense (plan P4b
/// S7, 8.3.4; rules.md B-41): where a write-off's debit goes, created on
/// demand like the others.
pub const BAD_DEBTS: ControlAccount = control(
"bad_debts",
"6990",
"Bad Debt Expense",
"bad debt expense",
AccountType::Expense,
);
/// A control account; see [`ControlAccount`]. /// A control account; see [`ControlAccount`].
pub const RETAINED_EARNINGS: ControlAccount = control( pub const RETAINED_EARNINGS: ControlAccount = control(
"retained_earnings", "retained_earnings",
@@ -134,6 +144,7 @@ pub fn control_accounts() -> impl Iterator<Item = &'static ControlAccount> {
&REVENUE, &REVENUE,
&PAYABLES, &PAYABLES,
&GENERAL_EXPENSES, &GENERAL_EXPENSES,
&BAD_DEBTS,
&RETAINED_EARNINGS, &RETAINED_EARNINGS,
] ]
.into_iter() .into_iter()
@@ -6184,6 +6195,14 @@ impl Ledger {
/// least it owes on any day from then on (from its own date, for a /// least it owes on any day from then on (from its own date, for a
/// payment dated before it), so no later day is overpaid. /// payment dated before it), so no later day is overpaid.
fn payment_room(&self, doc: arap::DocRef, date: NaiveDate) -> Result<Decimal, LedgerError> { fn payment_room(&self, doc: arap::DocRef, date: NaiveDate) -> Result<Decimal, LedgerError> {
self.least_owed_from(doc, date)
}
/// The least `doc` owes on any day from `date` on (from its own date,
/// for a date before it): what a payment may be, and what a write-off
/// may be, so no later day ends out of bounds (rules.md B-38
/// INVARIANT 2, B-41 INVARIANT 1).
fn least_owed_from(&self, doc: arap::DocRef, date: NaiveDate) -> Result<Decimal, LedgerError> {
let (doc_date, ..) = self.doc_header(doc)?; let (doc_date, ..) = self.doc_header(doc)?;
let from = date.max(doc_date); let from = date.max(doc_date);
let lines = self.doc_lines(doc); let lines = self.doc_lines(doc);
@@ -6805,6 +6824,141 @@ impl Ledger {
}) })
} }
/// Write off an invoice as a bad debt (plan P4b S7; rules.md B-41).
///
/// ```text
/// GOAL: take an invoice off the receivables when it will not be
/// collected, and deduct it (the direct write-off method).
/// SOURCE: IRC §166(a) (a worthless debt is deductible); Treas. Reg.
/// §1.166-1(e) (a debt never counted as income has no basis),
/// §1.166-2(a) (worthlessness on all the pertinent evidence),
/// §1.166-3(a) (a partial write-off, to the extent charged
/// off); docs/spec/rules.md B-41; plan/served.md P4b S7,
/// A-5 (the direct write-off method); ASC 326-20 noted (an
/// allowance is the GAAP preference; a small company's CPA
/// accepts the direct method, stated).
/// INVARIANTS:
/// 1. MUST post one balanced entry: Dr the bad_debts role (6990 Bad
/// Debt Expense), Cr receivables, for the amount, dated the
/// write-off's day; the receivable line MUST carry the invoice
/// (WriteOff), so what it owes falls by the amount from that day
/// and its written-off figure rises by it.
/// 2. Refused on cash-basis books (WriteOffOnCashBasis): the
/// invoice was never income, so there is nothing to deduct.
/// 3. 0 < amount <= the least the invoice owes on any day from
/// `date` on (on the day, when nothing later lowers it): a
/// partial write-off is allowed (§1.166-3(a)), and no day may
/// owe less than nothing (B-38 INVARIANT 2).
/// 4. The day MUST be in the calendar, in an open period, and not
/// before the invoice.
/// 5. A refusal MUST leave the books unchanged.
/// SCENARIOS:
/// 1. IF the books are on the cash basis: SHOULD refuse
/// (WriteOffOnCashBasis).
/// 2. IF the amount is zero, negative or above what is owed: SHOULD
/// refuse (InvalidWriteOff), saying what is owed.
/// 3. IF a payment after the day leaves less to write off: SHOULD
/// refuse, saying how much can be.
/// 4. IF it is dated before the invoice or in a closed period:
/// SHOULD refuse.
/// 5. Half paid, the rest written off: Written off, with 50 paid and
/// 50 written off (B-38 SCENARIO 7).
/// ```
pub fn write_off(
&mut self,
invoice: InvoiceId,
date: NaiveDate,
amount: Decimal,
reason: &str,
) -> Result<EntryId, LedgerError> {
self.atomically(|l| l.write_off_steps(invoice, date, amount, reason))
}
/// The steps of [`Self::write_off`], run inside [`Self::atomically`].
fn write_off_steps(
&mut self,
invoice: InvoiceId,
date: NaiveDate,
amount: Decimal,
reason: &str,
) -> Result<EntryId, LedgerError> {
// A debt never counted as income has no basis to deduct: the cash
// basis collects as it goes, so nothing is receivable to write off.
if self
.settings
.company(self.base_currency.code())
.basis
.eq_ignore_ascii_case("Cash")
{
return Err(LedgerError::WriteOffOnCashBasis);
}
let reason = reason.trim();
if reason.is_empty()
|| reason.contains(['\n', '\r'])
|| reason.chars().count() > arap::MAX_REFERENCE_CHARS
{
return Err(LedgerError::InvalidWriteOff(format!(
"a write-off needs its reason: one line of at most {} characters",
arap::MAX_REFERENCE_CHARS
)));
}
if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidWriteOff(format!(
"a write-off's amount must be above zero, got {amount}"
)));
}
crate::calendar::check(date)?;
let doc = arap::DocRef::Invoice(invoice);
let (doc_date, ..) = self.doc_header(doc)?;
if date < doc_date {
return Err(LedgerError::InvalidWriteOff(format!(
"a write-off dated {date} can't be before invoice {invoice}, dated {doc_date}"
)));
}
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
let owed = self
.doc_state(doc, date)?
.map_or(Decimal::ZERO, |st| st.outstanding);
let room = self.least_owed_from(doc, date)?;
if amount > room {
let beyond = if room < owed {
format!(" ({room}, once the payments after it are counted)")
} else {
String::new()
};
return Err(LedgerError::InvalidWriteOff(format!(
"a write-off of {amount} is more than invoice {invoice} owes on {date}: {owed}{beyond}"
)));
}
self.check_controls(&[RECEIVABLES, BAD_DEBTS])?;
let ar = self.ensure_control(&RECEIVABLES);
let bad = self.ensure_control(&BAD_DEBTS);
let customer = self
.invoices
.get(&invoice)
.ok_or(LedgerError::UnknownInvoice(invoice))?
.customer_id;
let money = Money::new(amount, self.base_currency);
let entry = self.create_entry(date, &format!("Write off invoice {invoice}: {reason}"));
self.add_line(entry, bad, Side::Debit, money)?;
self.add_tagged_line(
entry,
ar,
Side::Credit,
money,
arap::DocTag {
doc,
movement: arap::Movement::WriteOff,
},
)?;
self.post_entry(entry)?;
self.tag_customer(entry, customer);
self.refresh_doc(doc);
Ok(entry)
}
/// Void a document: reverse its own entry (B-9), described "Void /// Void a document: reverse its own entry (B-9), described "Void
/// invoice 7: reason". It keeps its id (and, later, its number); its /// invoice 7: reason". It keeps its id (and, later, its number); its
/// status as of the void's date on is Void. Plain /// status as of the void's date on is Void. Plain
@@ -7377,6 +7531,40 @@ impl Ledger {
Ok(()) Ok(())
} }
/// Apply a write-off without posting: replay support for journaling
/// stores (plan P4b S7; rules.md B-41). The write-off's entry was
/// already rebuilt from its own entry events; this tags its
/// receivables line with the invoice (the first untagged control line
/// for `amount`, on the credit side), tags the entry with the
/// customer, and refreshes the invoice's figures. Nothing is bounded
/// on replay (rules.md B-28).
#[doc(hidden)]
pub fn replay_invoice_write_off(
&mut self,
invoice_id: InvoiceId,
amount: Decimal,
entry_id: EntryId,
) -> Result<(), LedgerError> {
let customer = self
.invoices
.get(&invoice_id)
.ok_or(LedgerError::UnknownInvoice(invoice_id))?
.customer_id;
let doc = arap::DocRef::Invoice(invoice_id);
self.tag_replayed(
entry_id,
&RECEIVABLES,
Some(amount),
arap::DocTag {
doc,
movement: arap::Movement::WriteOff,
},
);
self.tag_customer(entry_id, customer);
self.refresh_doc(doc);
Ok(())
}
/// Apply a bill payment without posting: replay support for journaling /// Apply a bill payment without posting: replay support for journaling
/// stores. See [`Self::replay_invoice_payment`]. /// stores. See [`Self::replay_invoice_payment`].
#[doc(hidden)] #[doc(hidden)]
+2 -2
View File
@@ -77,8 +77,8 @@ pub use instruments::{Instrument, InstrumentId, InstrumentKind};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use kernel::{EntryFlows, Flow}; pub use kernel::{EntryFlows, Flow};
pub use ledger::{ pub use ledger::{
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES, PAYROLL, BAD_DEBTS, ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES,
PURCHASE_DISCOUNTS, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport, PAYROLL, PURCHASE_DISCOUNTS, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport,
SALES_ALLOWANCES, UnclearedLine, control_account, control_accounts, SALES_ALLOWANCES, UnclearedLine, control_account, control_accounts,
}; };
pub use money::{Currency, Money}; pub use money::{Currency, Money};
+78 -6
View File
@@ -191,6 +191,7 @@ fn refusal(e: &LedgerError) -> String {
LedgerError::AssetAlreadyDisposed(_) => "already_disposed", LedgerError::AssetAlreadyDisposed(_) => "already_disposed",
LedgerError::InvalidDepreciationPeriod(_) => "invalid_depreciation_period", LedgerError::InvalidDepreciationPeriod(_) => "invalid_depreciation_period",
LedgerError::InvalidSchedule(_) => "invalid_schedule", LedgerError::InvalidSchedule(_) => "invalid_schedule",
LedgerError::UntaggedControlLine { .. } => "untagged_control_line",
other => return format!("another refusal: {other}"), other => return format!("another refusal: {other}"),
} }
.to_string() .to_string()
@@ -787,6 +788,7 @@ impl<'a> Books<'a> {
("close", e.close.is_some()), ("close", e.close.is_some()),
("bill", e.bill.is_some()), ("bill", e.bill.is_some()),
("invoice", e.invoice.is_some()), ("invoice", e.invoice.is_some()),
("write_off", e.write_off.is_some()),
("run_depreciation", e.run_depreciation.is_some()), ("run_depreciation", e.run_depreciation.is_some()),
("dispose_asset", e.dispose_asset.is_some()), ("dispose_asset", e.dispose_asset.is_some()),
("run_schedules", e.run_schedules.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<_>>()[..] { match kinds.iter().filter(|(_, given)| *given).collect::<Vec<_>>()[..] {
[(kind, _)] => kind, [(kind, _)] => kind,
_ => panic!( _ => 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 self.ex.id
), ),
} }
@@ -809,11 +811,12 @@ impl<'a> Books<'a> {
// disposal is dated; the other commands date their own entries, // disposal is dated; the other commands date their own entries,
// and every command describes its own. // and every command describes its own.
let own = matches!(kind, "lines" | "reverses" | "close" | "bill" | "invoice"); let own = matches!(kind, "lines" | "reverses" | "close" | "bill" | "invoice");
let dated = own || matches!(kind, "dispose_asset" | "write_off");
assert_eq!( assert_eq!(
e.date.is_some(), e.date.is_some(),
own || kind == "dispose_asset", dated,
"{ex} event {n}: {kind} {}", "{ex} event {n}: {kind} {}",
if own || kind == "dispose_asset" { if dated {
"is dated (date)" "is dated (date)"
} else { } else {
"dates its own entries: no date" "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" "{ex} event {n}: {kind} is tagged with no parties or payment method"
); );
assert!( assert!(
e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill" | "invoice"), e.refused.is_none() || !matches!(kind, "close" | "bill" | "invoice"),
"{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all" "{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" "{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"); assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry");
} }
} }
@@ -1023,6 +1034,30 @@ impl<'a> Books<'a> {
}; };
let day = || when.expect("checked: dated"); let day = || when.expect("checked: dated");
match kind { 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" => { "lines" => {
let post = |this: &mut Self| -> Result<(), StoreError> { let post = |this: &mut Self| -> Result<(), StoreError> {
let entry = this.store.create_entry(day(), description)?; let entry = this.store.create_entry(day(), description)?;
@@ -1123,6 +1158,43 @@ impl<'a> Books<'a> {
.create_invoice(customer, day(), day(), lines) .create_invoice(customer, day(), day(), lines)
.map(|_| ()) .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" => { "run_depreciation" => {
let r = e.run_depreciation.as_ref().expect("checked: a run"); let r = e.run_depreciation.as_ref().expect("checked: a run");
let asset = self.asset(&r.asset, what); let asset = self.asset(&r.asset, what);
+200
View File
@@ -1200,6 +1200,206 @@ fn a_chain_counts_in_order() {
assert!(b.l.stands_on(rr, d(2026, 1, 25))); assert!(b.l.stands_on(rr, d(2026, 1, 25)));
} }
// ---------------------------------------------------------------------------
// Write-offs (plan P4b S7; rules.md B-41)
// ---------------------------------------------------------------------------
/// B-38 SCENARIO 7 and B-41 INVARIANT 1: half of an invoice paid, the rest
/// written off, reads Written off with 50 paid and 50 written off, and
/// receivables falls by the write-off alone.
#[test]
fn half_paid_rest_written_off_is_written_off() {
let mut b = books();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 1, 5),
d(2026, 2, 4),
line("Design", 10_000),
)
.unwrap();
b.l.receive_payment(inv, d(2026, 1, 20), cents(5_000), b.bank)
.unwrap();
let ar = b.l.chart().find_by_code("1200").unwrap().id;
let before = b.l.account_balance(ar).unwrap();
let entry =
b.l.write_off(inv, d(2026, 1, 31), cents(5_000), "collection exhausted")
.unwrap();
// One balanced entry: Dr 6990 Bad Debt Expense / Cr 1200.
let e = b.l.get_entry(entry).unwrap();
assert_eq!(e.lines().len(), 2);
let bad = b.l.chart().find_by_code("6990").unwrap().id;
assert_eq!(
(
b.l.get_entry(entry).unwrap().lines()[0].account,
b.l.get_entry(entry).unwrap().lines()[0].side,
b.l.get_entry(entry).unwrap().lines()[0].amount.amount
),
(bad, Side::Debit, cents(5_000))
);
assert_eq!(
b.l.line_doc(entry, 1).map(|t| t.movement),
Some(Movement::WriteOff)
);
assert_eq!(
b.l.account_balance(ar).unwrap(),
before.checked_sub(cents(5_000)).unwrap()
);
let st = b.l.invoice_state(inv, d(2026, 2, 1)).unwrap().unwrap();
assert_eq!(st.status, DocStatus::WrittenOff);
assert_eq!(
(st.paid, st.written_off, st.outstanding),
(cents(5_000), cents(5_000), Decimal::ZERO)
);
// The write-off counts toward no payment: the customer paid 50.
assert_eq!(
b.l.payments_recorded(DocRef::Invoice(inv)),
Ok(cents(5_000))
);
// And the control account still equals its documents.
let check = b.l.control_check(d(2026, 2, 1)).unwrap();
assert!(check.iter().all(|c| c.difference.is_zero()));
}
/// B-41 INVARIANT 3: a cash-basis company never counted the invoice as
/// income, so it has no basis to deduct (IRC §166; Treas. Reg.
/// §1.166-1(e)).
#[test]
fn write_off_refused_on_cash_basis() {
use ledger_core::settings::{Change, Source};
let mut b = books();
let src = Source {
decided_by: "Owner".into(),
decided_on: d(2026, 1, 1),
citation: None,
note: None,
};
let changes =
b.l.check_settings(
&[Change {
key: "books.accounting_basis".into(),
value: Some(serde_json::json!("cash")),
}],
&src,
)
.unwrap();
b.l.apply_settings(&changes, &src, 1).unwrap();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 1, 5),
d(2026, 2, 4),
line("Design", 10_000),
)
.unwrap();
let before = ledger_core::reports::snapshot(&b.l).unwrap();
let err =
b.l.write_off(inv, d(2026, 1, 31), cents(1_000), "uncollectible")
.unwrap_err();
assert!(matches!(err, LedgerError::WriteOffOnCashBasis), "{err}");
// Nothing changed, no draft left behind.
assert_eq!(ledger_core::reports::snapshot(&b.l).unwrap(), before);
assert_eq!(
b.l.invoice_state(inv, d(2026, 2, 1))
.unwrap()
.unwrap()
.status,
DocStatus::Open
);
}
/// B-41 INVARIANT 1: at most what is owed, a partial write-off allowed
/// (Treas. Reg. §1.166-3(a)); more is refused, naming what is owed, and
/// nothing changes.
#[test]
fn write_off_above_outstanding_refused() {
let mut b = books();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 1, 5),
d(2026, 2, 4),
line("Design", 10_000),
)
.unwrap();
b.l.receive_payment(inv, d(2026, 1, 20), cents(4_000), b.bank)
.unwrap();
let before = ledger_core::reports::snapshot(&b.l).unwrap();
// 60 is owed on the day; 60.01 is not.
let err =
b.l.write_off(inv, d(2026, 1, 31), cents(6_001), "uncollectible")
.unwrap_err();
assert!(
matches!(&err, LedgerError::InvalidWriteOff(why) if why.contains("60")),
"{err}"
);
assert_eq!(ledger_core::reports::snapshot(&b.l).unwrap(), before);
// Zero and a negative amount are refused too.
for amount in [Decimal::ZERO, cents(-1)] {
assert!(matches!(
b.l.write_off(inv, d(2026, 1, 31), amount, "uncollectible"),
Err(LedgerError::InvalidWriteOff(_))
));
}
assert_eq!(ledger_core::reports::snapshot(&b.l).unwrap(), before);
// What is owed on the day is taken, and the invoice reads partly paid
// and partly written off.
b.l.write_off(inv, d(2026, 1, 31), cents(6_000), "uncollectible")
.unwrap();
let st = b.l.invoice_state(inv, d(2026, 2, 1)).unwrap().unwrap();
assert_eq!(st.status, DocStatus::WrittenOff);
assert_eq!((st.paid, st.written_off), (cents(4_000), cents(6_000)));
}
/// D-49's same-year recovery, and B-41 INVARIANT 2: reversing the
/// write-off re-instates the debt (it is not a new movement), and the
/// payment then recorded settles it; a recovery is income, never a
/// payment event.
#[test]
fn write_off_then_recovery_same_year() {
let mut b = books();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 1, 5),
d(2026, 2, 4),
line("Design", 10_000),
)
.unwrap();
let write_off =
b.l.write_off(inv, d(2026, 1, 31), cents(10_000), "uncollectible")
.unwrap();
assert_eq!(
b.l.invoice_state(inv, d(2026, 2, 1))
.unwrap()
.unwrap()
.status,
DocStatus::WrittenOff
);
// The recovery came in the same tax year: reverse the write-off, and
// the invoice reopens.
b.l.reverse_entry(write_off, d(2026, 3, 1), "paid after all")
.unwrap();
let st = b.l.invoice_state(inv, d(2026, 3, 2)).unwrap().unwrap();
assert_eq!(
(st.status, st.written_off, st.outstanding),
(DocStatus::Open, Decimal::ZERO, cents(10_000))
);
// Then the payment is recorded as any payment.
b.l.receive_payment(inv, d(2026, 3, 2), cents(10_000), b.bank)
.unwrap();
let st = b.l.invoice_state(inv, d(2026, 3, 3)).unwrap().unwrap();
assert_eq!(
(st.status, st.paid, st.written_off),
(DocStatus::Paid, cents(10_000), Decimal::ZERO)
);
// Receivables is back to nothing, and the write-off entry no longer
// stands (posted is forever: it is reversed, never deleted).
let ar = b.l.chart().find_by_code("1200").unwrap().id;
assert_eq!(b.l.account_balance(ar).unwrap(), Decimal::ZERO);
assert!(!b.l.in_effect(write_off));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Properties // Properties
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+8 -4
View File
@@ -48,8 +48,9 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
} }
} }
/// The twelve role keys, as the spec lists them (B-23 and `UnknownRole`). /// The thirteen role keys, as the spec lists them (B-23, B-41's
const KEYS: [&str; 12] = [ /// bad_debts, and `UnknownRole`).
const KEYS: [&str; 13] = [
"receivables", "receivables",
"revenue", "revenue",
"payables", "payables",
@@ -62,6 +63,7 @@ const KEYS: [&str; 12] = [
"social_security_payable", "social_security_payable",
"medicare_payable", "medicare_payable",
"other_deductions_payable", "other_deductions_payable",
"bad_debts",
]; ];
fn role(key: &str) -> &'static ControlAccount { fn role(key: &str) -> &'static ControlAccount {
@@ -248,8 +250,9 @@ fn alice() -> PayrollLine {
// ------------------------------------------------------------ the roles // ------------------------------------------------------------ the roles
/// Oracle: B-23 names the roles and their default codes; `control_account` /// Oracle: B-23 (and B-41 for bad_debts) names the roles and their default
/// finds a role by its `key`; `UnknownRole` lists the twelve keys. /// codes; `control_account` finds a role by its `key`; `UnknownRole` lists
/// the thirteen keys.
#[test] #[test]
fn every_role_has_its_key_default_code_and_type() { fn every_role_has_its_key_default_code_and_type() {
use AccountType::*; use AccountType::*;
@@ -258,6 +261,7 @@ fn every_role_has_its_key_default_code_and_type() {
("revenue", "4000", Revenue), ("revenue", "4000", Revenue),
("payables", "2000", Liability), ("payables", "2000", Liability),
("general_expense", "6999", Expense), ("general_expense", "6999", Expense),
("bad_debts", "6990", Expense),
("retained_earnings", "3999", Equity), ("retained_earnings", "3999", Equity),
("salary_expense", "6100", Expense), ("salary_expense", "6100", Expense),
("payroll_tax_expense", "6110", Expense), ("payroll_tax_expense", "6110", Expense),
+8
View File
@@ -830,6 +830,14 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Bill paid, {}", amount(p)), format!("Bill paid, {}", amount(p)),
entry_id.map(Route::Entry), entry_id.map(Route::Entry),
), ),
"invoice.written_off" => {
let invoice = p.get("invoice_id").and_then(|v| v.as_u64()).unwrap_or(id);
ev(
icon::HAND_COINS,
format!("Invoice {invoice} written off, {}", amount(p)),
entry_id.map(Route::Entry),
)
}
"period.created" => ev( "period.created" => ev(
icon::CALENDAR_PLUS, icon::CALENDAR_PLUS,
format!("Period {} created", s(p, "name")), format!("Period {} created", s(p, "name")),
+54
View File
@@ -336,3 +336,57 @@ fn an_overdue_invoice_with_an_old_send_is_asked_to_remind() {
assert!(item.detail.contains("14 days overdue"), "{}", item.detail); assert!(item.detail.contains("14 days overdue"), "{}", item.detail);
assert!(item.prompt.contains("books.mark_sent"), "{}", item.prompt); assert!(item.prompt.contains("books.mark_sent"), "{}", item.prompt);
} }
/// A written-off invoice (plan P4b S7): $500 issued, written off whole for
/// a bad debt with its evidence, so it reads Written off owing nothing.
fn write_off_books(dir: &TempDir) -> (u64, u64) {
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
s.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let client = s.add_customer("Harbor Client").unwrap();
let inv = s
.create_invoice(
client,
d(2026, 2, 1),
d(2026, 3, 1),
vec![line("Consulting", 50_000)],
)
.unwrap();
let (entry, _) = s
.write_off_invoice(
inv,
d(2026, 3, 10),
Decimal::new(50_000, 2),
"collection exhausted",
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("Collection attempts"),
}),
)
.unwrap();
(inv, entry)
}
/// The write-off is on its invoice's timeline, reads "Written off", and
/// opens its entry.
#[test]
fn a_write_off_shows_on_the_timeline() {
let dir = TempDir::new("write-off");
let (inv, entry) = write_off_books(&dir);
let mut h = open(&dir, Route::Invoice(inv));
shot(&mut h, "invoice-written-off");
assert!(
h.query_by_label_contains("Written off, entry #").is_some(),
"the timeline shows the write-off"
);
// The status chip reads its label ("Written off"), not "late".
assert!(h.query_by_label("Written off").is_some());
h.get_by_label_contains(&format!("Written off, entry #{entry},"))
.click();
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Entry(entry));
}
+93
View File
@@ -831,3 +831,96 @@ fn all_dates_holds_an_entry_dated_after_today() {
assert_eq!(RegisterRange::All.dates(&b), (first, later)); assert_eq!(RegisterRange::All.dates(&b), (first, later));
assert_eq!(Range::AllTime.dates(&b), (first, later)); assert_eq!(Range::AllTime.dates(&b), (first, later));
} }
/// A write-off this calendar year is one Needs-attention item, "Write-off
/// awaiting the CPA's note", through the viewer and through the server's
/// own read of the books alike (plan P4b S7; the one function in
/// ledger-package::attention).
#[test]
fn a_write_off_is_flagged_for_the_cpa() {
let d = dir("write-off");
let today = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let cust = s.add_customer("Ridgeway Co").unwrap();
let inv = s
.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
vec![DocumentLine {
description: "Survey".into(),
amount: Decimal::new(10_000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
let (entry, _) = s
.write_off_invoice(
inv,
NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
Decimal::new(10_000, 2),
"collection exhausted",
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("Collection attempts"),
}),
)
.unwrap();
// The server's list: the same function, over the Store.
let settings = s.ledger().settings();
let company = settings.company("USD");
let (cash, financing, investing) = (
settings.cash_accounts(),
settings.financing_accounts(),
settings.investing_accounts(),
);
let server = ledger_package::find(
&s,
today,
&ledger_package::Settings {
company: &company,
cash_accounts: &cash,
financing_accounts: &financing,
investing_accounts: &investing,
},
);
let want = format!("Write-off awaiting the CPA's note: Invoice {inv}");
let said = server.iter().find(|a| a.title == want).unwrap_or_else(|| {
panic!(
"the server's list has no {want:?}: {:?}",
server.iter().map(|a| &a.title).collect::<Vec<_>>()
)
});
assert_eq!(said.severity, ledger_package::Severity::Todo);
assert!(said.prompt.contains("wanderbooks"), "{}", said.prompt);
assert!(said.detail.contains("100.00"), "{}", said.detail);
drop(s);
// The viewer's list, over its snapshot, has the same item.
let books = data::load_once(&Source {
data_dir: d.join("books-data"),
today: Some(today),
})
.unwrap();
let shown = books
.attention
.iter()
.find(|a| a.title == want)
.unwrap_or_else(|| {
panic!(
"the viewer shows no {want:?}: {:?}",
books.attention.iter().map(|a| &a.title).collect::<Vec<_>>()
)
});
assert_eq!(shown.target, Some(ledger_gui::nav::Route::Documents));
// The viewer reads the same figures from the journal it replayed.
let st = books.ledger.invoice_state(inv, today).unwrap().unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert!(books.ledger.get_entry(entry).is_some());
}
@@ -64,6 +64,60 @@ without another copy to correct (B-38).
Discounts / Cr the asset account (D-49). Never split the discount across Discounts / Cr the asset account (D-49). Never split the discount across
the asset lines. the asset lines.
## 4. Void, write off, or reverse
One of these, and only one, fits what happened:
- **Void it** (`books.void_document`) when the document should never have
existed: a duplicate, the wrong customer or vendor, a test entry. It is
reversed, keeps its id (and, once numbered, its number) and reads void;
nothing is deleted. Refused while a payment stands on it: reverse that
payment first, and the refusal names it. Use a void for a mistake, never
for an invoice that was real.
- **Write it off** (`books.write_off_invoice`) when the invoice was real
and the work or goods went out, but the customer will not or cannot pay.
That is a bad debt: the books post Dr Bad Debt Expense 6990 / Cr Accounts
Receivable for what will not be collected, and what it owes falls from
that day. The evidence of worthlessness — the collection letters, the
bankruptcy or liquidation notice, the collection agency's report — is
filed with the write-off in the same call (`upload_id`, or an
`existing_attachment_id` already filed), because a bad debt is deducted
on all the pertinent evidence (Treas. Reg. §1.166-2(a)). Write off all of
it, or part of it with `amount`: a partial write-off is deductible to the
extent charged off (Treas. Reg. §1.166-3(a)), and the rest stays owed and
in the aging. The books must be on the accrual basis: a cash-basis
company never counted the invoice as income, so there is no basis to
deduct (IRC §166; Treas. Reg. §1.166-1(e)), and the tool refuses.
- **Reverse an entry** (`books.reverse_entry`) when a posted entry is
wrong: a payment against the wrong invoice, a wrong amount, a check that
bounced, a write-off made too soon. Posted is forever: the correction is
an opposite entry linked to the one it reverses, never an edit and never
a delete. Reversing a payment puts the invoice back to owing what it did;
reversing a write-off re-instates the debt.
A bill is never written off: a vendor releases you with a vendor credit or
by voiding its own invoice, and until then you owe it (ASC 405-20-40-1). If
a vendor's bill was wrong, ask for a corrected bill or record a credit when
one is built; void it only when it should never have been entered.
## Recovering a debt written off
- **The same tax year**: reverse the write-off — `books.reverse_entry` on
the write-off's entry. The invoice reopens, owing exactly what the
write-off took off; then record the payment with `books.receive_payment`
as usual. Nothing else is needed: within the year the deduction comes
back out, and the payment is a payment.
- **A later tax year**: do not reverse the write-off. Record a plain entry
— `books.draft_entry`, `books.add_line` on each side, `books.post_entry`
— debiting the deposit account and crediting a "Bad debt recoveries"
revenue account (tax category other income), with a memo naming the
invoice. The invoice stays written off, and the recovery is income in the
year it arrives (IRC §111); the CPA sees it apart from the year's sales.
Neither procedure invents a new kind of movement on the document: the books
record what happened, and the invoice's timeline shows the write-off (and,
if it was reversed, that it no longer stands).
## 5. A processor's fee ## 5. A processor's fee
- The books never charge or collect; a processor does, and keeps a fee. - The books never charge or collect; a processor does, and keeps a fee.
@@ -83,3 +137,21 @@ without another copy to correct (B-38).
3. Apply the parked money with `books.receive_payment`, 3. Apply the parked money with `books.receive_payment`,
`deposit_to: "2300"`, dated on or after the bounce, against the `deposit_to: "2300"`, dated on or after the bounce, against the
invoices it pays. The 2300 balance returns to zero. invoices it pays. The 2300 balance returns to zero.
## What the CPA will check
The agings tie to 1200 and 2000 at month and year end (they do by
construction: the control check says so). Every write-off carries its
reason and its evidence of collection efforts. Write-offs are not used in
place of a credit memo for a valid invoice, and never to hide a mistake
that a void would show. Whether a debt is worthless, and in which year, is
a tax judgment that stays with the CPA: the books flag each write-off made
this year in Needs attention, with the evidence beside it, until the year
ends.
## The evidence rule
Every entry needs its receipt ({evidence_rule}). A document the books post
themselves — an invoice, a bill, a payment, a write-off — carries its own:
the issued copy, the vendor's bill, the payment's reference, the evidence of
worthlessness. A reversal needs none of its own: its original carries it.
+7 -4
View File
@@ -73,16 +73,19 @@ pub const SECTIONS: &[(&str, &str)] = &[
"revenue-and-payouts", "revenue-and-payouts",
include_str!("../handbook/revenue-and-payouts.md"), include_str!("../handbook/revenue-and-payouts.md"),
), ),
// S4.5 gives invoices and bills their first three and fifth steps; S7
// adds step 4 (void, write off or reverse) and the recovery
// procedures (plan 8.2).
(
"invoices-and-bills",
include_str!("../handbook/invoices-and-bills.md"),
),
("projects", include_str!("../handbook/projects.md")), ("projects", include_str!("../handbook/projects.md")),
( (
"parties-and-tins", "parties-and-tins",
include_str!("../handbook/parties-and-tins.md"), include_str!("../handbook/parties-and-tins.md"),
), ),
("1099", include_str!("../handbook/1099.md")), ("1099", include_str!("../handbook/1099.md")),
(
"invoices-and-bills",
include_str!("../handbook/invoices-and-bills.md"),
),
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")), ("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
( (
"settings-and-conventions", "settings-and-conventions",
+3 -1
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules; const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1). /// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 124] = [ pub const TABLE: [(&str, Needs); 125] = [
("books.handbook", R), ("books.handbook", R),
("books.start", R), ("books.start", R),
// Settings are committed at once: the settings commit of the plan. // Settings are committed at once: the settings commit of the plan.
@@ -189,6 +189,8 @@ pub const TABLE: [(&str, Needs); 124] = [
("books.access_log", A), ("books.access_log", A),
("books.backup", R), ("books.backup", R),
("books.back_up_now", A), ("books.back_up_now", A),
// P4b S7: a bad-debt write-off (preview, then post).
("books.write_off_invoice", C),
]; ];
/// What `tool` needs, given its arguments; None for a tool this server /// What `tool` needs, given its arguments; None for a tool this server
+3
View File
@@ -581,6 +581,7 @@ impl BooksServer {
+ Self::payroll_router() + Self::payroll_router()
+ Self::payouts_router() + Self::payouts_router()
+ Self::tax_router() + Self::tax_router()
+ Self::write_offs_router()
} }
/// The settings in force for this request: the books' own, taken from /// The settings in force for this request: the books' own, taken from
@@ -1430,6 +1431,8 @@ mod settings_tools;
mod settlements; mod settlements;
#[path = "tools/tax.rs"] #[path = "tools/tax.rs"]
mod tax; mod tax;
#[path = "tools/write_offs.rs"]
mod write_offs;
// The shared helpers are re-exported so every group file reaches them // The shared helpers are re-exported so every group file reaches them
// through `use super::*`, and no group file reaches into another. // through `use super::*`, and no group file reaches into another.
+5 -3
View File
@@ -42,7 +42,7 @@ fn annotations_complete() {
const D: Hints = (Some(false), Some(true), None); const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true)); const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 124] = [ let table: [(&str, Hints); 125] = [
("books.handbook", R), ("books.handbook", R),
("books.start", R), ("books.start", R),
("books.set_setting", DI), ("books.set_setting", DI),
@@ -167,6 +167,8 @@ fn annotations_complete() {
("books.access_log", R), ("books.access_log", R),
("books.backup", R), ("books.backup", R),
("books.back_up_now", WI), ("books.back_up_now", WI),
// P4b S7: a bad-debt write-off.
("books.write_off_invoice", D),
]; ];
let tools = BooksServer::tool_router().list_all(); let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len()); assert_eq!(tools.len(), table.len());
@@ -206,8 +208,8 @@ fn annotations_complete() {
"{name}: openWorldHint" "{name}: openWorldHint"
); );
} }
// docs/mcp-interface.md names the 29 destructive tools. // docs/mcp-interface.md names the 30 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 30);
} }
/// Every refusal an agent can be given whose text is fixed here, or /// Every refusal an agent can be given whose text is fixed here, or
+133
View File
@@ -0,0 +1,133 @@
//! `books.write_off_invoice`'s group: bad debts (plan P4b S7; rules.md
//! B-41). A file of its own (plan 8.2 U0a) so S4.4's document tools and
//! this one land apart: a tool is added to its group's file and nowhere
//! else.
use super::*;
/// An invoice's figures after a write-off, as the tool answers them: read
/// from its tagged lines that stand (rules.md B-38), at the end of the
/// books.
fn write_off_out(
ledger: &Ledger,
invoice: u64,
entry_id: u64,
) -> Result<WriteOffInvoiceOut, String> {
let st = ledger
.doc_state(ledger_core::DocRef::Invoice(invoice), NaiveDate::MAX)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("invoice {invoice} not found"))?;
Ok(WriteOffInvoiceOut {
entry_id,
document: WriteOffDocumentOut {
r#ref: format!("invoice:{invoice}"),
status: st.status.key().to_string(),
paid: money(st.paid),
written_off: money(st.written_off),
outstanding: money(st.outstanding),
},
})
}
#[tool_router(router = write_offs_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.write_off_invoice",
title = "Write off an invoice",
description = "Write off an invoice that will not be collected (a bad debt): Dr Bad Debt Expense 6990 / Cr Accounts Receivable, the receivable line tagged with the invoice, so it reads written_off and its figures stay derived. The evidence of worthlessness (collection correspondence, a bankruptcy notice) is filed with it in the same call: upload_id, or existing_attachment_id for one already filed. amount (default: all it owes) may be partial. Refused on cash-basis books, above what it owes, before its date or in a closed period. To recover it: reverse the write-off (books.reverse_entry; the invoice reopens), then books.receive_payment. Without confirmed=true, a preview.",
annotations(
read_only_hint = false,
destructive_hint = true,
open_world_hint = false
)
)]
async fn write_off_invoice(
&self,
params: Parameters<WriteOffInvoiceParams>,
) -> Result<Json<WriteOffInvoiceOut>, String> {
let p = params.0;
let date = parse_date(&p.date)?;
let mut store = self.lock()?;
// The event type is new: books that haven't declared documents_v2
// refuse it with the upgrade remedy, journaling nothing (B-27).
store
.ensure_feature(ledger_store::format::DOCUMENTS_V2)
.map_err(|e| e.to_string())?;
let doc = ledger_core::DocRef::Invoice(p.invoice);
let owed = store
.ledger()
.doc_state(doc, date)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("invoice {} is dated after {date}", p.invoice))?
.outstanding;
let amount = match p.amount.as_deref() {
Some(a) => parse_amount(a)?,
None => owed,
};
// The evidence is the point of a write-off (B-41 INVARIANT 1): one
// of the two, and the preview says so too rather than posting
// without it later.
match (p.upload_id.is_some(), p.existing_attachment_id.is_some()) {
(true, true) | (false, false) => {
return Err(
"give the evidence once: upload_id (a file to file with the write-off) or \
existing_attachment_id (one already filed), exactly one"
.into(),
);
}
_ => {}
}
if !p.confirmed.unwrap_or(false) {
// The engine on a clone: every refusal the post would give, and
// the figures the write-off would leave. Nothing is written.
let mut probe = store.ledger().clone();
probe
.write_off(p.invoice, date, amount, &p.reason)
.map_err(|e| e.to_string())?;
return Ok(Json(write_off_out(&probe, p.invoice, 0)?));
}
let entry_id = match p.upload_id.as_deref() {
Some(upload_id) => {
let file = self.take_file(files::Given {
upload_id,
purposes: files::FILED,
})?;
let (file_name, mime_type) = named(None, None, &file)?;
let (bytes, claim) = (file.bytes, file.claim);
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
let (entry_id, _) = store
.write_off_invoice(
p.invoice,
date,
amount,
&p.reason,
Some(ledger_store::WriteOffEvidence::File {
file_name: &file_name,
mime_type: &mime_type,
bytes: &bytes,
description: Some("Evidence of worthlessness"),
}),
)
.map_err(|e| e.to_string())?;
files::filed(claim);
entry_id
}
None => {
let id = p
.existing_attachment_id
.expect("checked: one of the two is given");
let (entry_id, _) = store
.write_off_invoice(
p.invoice,
date,
amount,
&p.reason,
Some(ledger_store::WriteOffEvidence::Existing(id)),
)
.map_err(|e| e.to_string())?;
entry_id
}
};
Ok(Json(write_off_out(store.ledger(), p.invoice, entry_id)?))
}
}
+61 -1
View File
@@ -892,7 +892,7 @@ pub struct SettingsOut {
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct AssignRoleParams { pub struct AssignRoleParams {
/// The role, from books.roles: receivables, revenue, payables, /// The role, from books.roles: receivables, revenue, payables,
/// general_expense, retained_earnings, salary_expense, /// general_expense, bad_debts, retained_earnings, salary_expense,
/// payroll_tax_expense, federal_withholding, state_withholding, /// payroll_tax_expense, federal_withholding, state_withholding,
/// social_security_payable, medicare_payable, other_deductions_payable. /// social_security_payable, medicare_payable, other_deductions_payable.
pub role: String, pub role: String,
@@ -5293,3 +5293,63 @@ pub struct MarkSentOut {
/// Every send on it, in order, the newest last. /// Every send on it, in order, the newest last.
pub sends: Vec<SentOut>, pub sends: Vec<SentOut>,
} }
// P4b S7: bad debts (books.write_off_invoice)
// ---------------------------------------------------------------------------
/// Parameters for `books.write_off_invoice`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct WriteOffInvoiceParams {
/// The invoice's id (books.documents).
pub invoice: u64,
/// The write-off's day, YYYY-MM-DD: not before the invoice, and in an
/// open period.
pub date: String,
/// How much to write off, a decimal string (at most what the invoice
/// owes on the day); left out, all of it.
#[serde(default)]
pub amount: Option<String>,
/// Why it will not be collected: the collection attempts, a bankruptcy
/// notice, the customer's liquidation. One line, journaled with the
/// write-off.
pub reason: String,
/// The evidence's bytes: an upload_id from books.begin_upload (purpose
/// document or receipt) and the command it returns. Exactly one of
/// this and existing_attachment_id.
#[serde(default)]
pub upload_id: Option<String>,
/// An attachment already filed and not linked to an entry
/// (books.list_receipts): it is linked to the write-off's entry and
/// to the invoice, the same call. Exactly one of this and upload_id.
#[serde(default)]
pub existing_attachment_id: Option<u64>,
/// false or left out: a preview (the posting and the figures it would
/// leave), writing nothing; true: post it.
#[serde(default)]
pub confirmed: Option<bool>,
}
/// An invoice's figures after a write-off, derived from its tagged lines
/// that stand.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WriteOffDocumentOut {
/// "invoice:N", as books.document names it.
pub r#ref: String,
/// Its status after the write-off: written_off, partial, paid, ...
pub status: String,
/// Cash paid against it that stands, decimal string.
pub paid: String,
/// What was written off that stands, decimal string.
pub written_off: String,
/// What is still owed, decimal string.
pub outstanding: String,
}
/// Output of `books.write_off_invoice`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WriteOffInvoiceOut {
/// The write-off's journal entry's id (0 in a preview).
pub entry_id: u64,
/// The invoice's derived figures after it.
pub document: WriteOffDocumentOut,
}
+61 -1
View File
@@ -42,7 +42,11 @@
//! `invoicing.unsent_days` (Todo, its prompt naming books.mark_sent; //! `invoicing.unsent_days` (Todo, its prompt naming books.mark_sent;
//! a legacy invoice with no number is exempt); an overdue invoice //! a legacy invoice with no number is exempt); an overdue invoice
//! whose last send is older than `invoicing.reminder_days` (Todo, the //! whose last send is older than `invoicing.reminder_days` (Todo, the
//! same tool); money owed to //! same tool);
//! invoices (Problem, one each, with the total); a written-off
//! invoice whose write-off is dated this calendar year and stands
//! (Todo, one per invoice, "Write-off awaiting the CPA's note",
//! its prompt naming books.document); money owed to
//! owners (Info, per account with a balance); the database differing //! owners (Info, per account with a balance); the database differing
//! from the journal or unreadable, and damaged or missing stored //! from the journal or unreadable, and damaged or missing stored
//! files (Problem); depreciation due through the last finished month //! files (Problem); depreciation due through the last finished month
@@ -802,6 +806,62 @@ pub fn find_with(
}); });
} }
} }
// A write-off this year waits for the CPA's note: the deduction rests
// on evidence of worthlessness a human signs off, and §166's judgments
// stay with the CPA (plan P4b S7, 8.3.16; IRC §166(a); Treas. Reg.
// §1.166-2(a)). Listed until the year ends.
let mut written_off: Vec<(u64, Option<String>, NaiveDate, Decimal)> = Vec::new();
for inv in ledger.invoices() {
let doc = ledger_core::DocRef::Invoice(inv.id);
let state = ledger.invoice_state(inv.id, today).ok().flatten();
if state.map(|s| s.status) != Some(ledger_core::DocStatus::WrittenOff) {
continue;
}
let lines: Vec<ledger_core::DocLine> = ledger
.doc_lines(doc)
.into_iter()
.filter(|l| {
l.movement == ledger_core::Movement::WriteOff
&& l.date.year() == today.year()
&& ledger.stands_on(l.entry, today)
})
.collect();
let Some(last) = lines.last() else {
continue;
};
match lines
.iter()
.try_fold(Decimal::ZERO, |t, l| t.checked_sub(l.amount))
{
Some(amount) => written_off.push((inv.id, inv.number.clone(), last.date, amount)),
None => out.push(cant_compute(
format!("Invoice {}", inv.id),
&LedgerError::TooLargeToCompute("what was written off".into()),
Target::Documents,
)),
}
}
for (id, number, on, amount) in written_off {
let name = number.unwrap_or_else(|| format!("Invoice {id}"));
out.push(Attention {
severity: Severity::Todo,
kind: "write_off_note",
title: format!("Write-off awaiting the CPA's note: {name}"),
detail: format!(
"{} written off on {} (IRC §166; Treas. Reg. §1.166-2: the evidence of worthlessness is filed with the write-off).",
dollars(amount),
date(on)
),
target: Target::Documents,
prompt: format!(
"In wanderbooks, show me invoice {id}'s write-off (books.document), with its reason and the evidence filed with it, and note it for the CPA at year end."
),
tool: Some((
"books.document".into(),
serde_json::json!({ "ref": format!("invoice:{id}") }),
)),
});
}
for a in ledger.chart().all().filter(|a| { for a in ledger.chart().all().filter(|a| {
a.account_type == AccountType::Liability && { a.account_type == AccountType::Liability && {
+10
View File
@@ -144,6 +144,16 @@ pub enum StoreError {
#[error("store not found at {0}")] #[error("store not found at {0}")]
NotFound(String), NotFound(String),
/// A write-off with no evidence (rules.md B-41 INVARIANT 1): a bad
/// debt is deducted on evidence that it is worthless (Treas. Reg.
/// §1.166-2), so the books take the write-off only with its file.
#[error(
"a write-off needs its evidence: file the collection correspondence or the bankruptcy \
notice with upload_id, or name an attachment already filed with existing_attachment_id \
(Treas. Reg. §1.166-2: worthlessness on all the pertinent evidence)"
)]
WriteOffNeedsEvidence,
/// The requested base currency differs from the store's. /// The requested base currency differs from the store's.
#[error("base currency mismatch: store is {0}, requested {1}")] #[error("base currency mismatch: store is {0}, requested {1}")]
CurrencyMismatch(String, String), CurrencyMismatch(String, String),
+1
View File
@@ -244,6 +244,7 @@ pub const EVENT_TYPES: &[&str] = &[
"invoice.created", "invoice.created",
"invoice.payment_received", "invoice.payment_received",
"invoice.sent", "invoice.sent",
"invoice.written_off",
"instrument.added", "instrument.added",
FORMAT_SET, FORMAT_SET,
"journal.tail_repaired", "journal.tail_repaired",
+2 -2
View File
@@ -49,6 +49,6 @@ pub use snapshot::{DiskCheck, Snapshot};
pub use store::{ pub use store::{
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview, Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview,
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, PreviewPosting, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, PreviewPosting,
Reconciliation, Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for, Reconciliation, Statement, Store, Subject, VerifyReport, WriteOffEvidence, database_in_use,
is_database_file, db_file, db_file_for, is_database_file,
}; };
+48
View File
@@ -418,6 +418,54 @@ pub fn invoice_sent_of(line: &JournalLine) -> Result<(u64, Sent), StoreError> {
)) ))
} }
/// A bad-debt write-off as journaled (plan P4b S7; rules.md B-41):
/// `invoice.written_off {invoice_id, entry_id, date, amount, reason}`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WrittenOff {
/// The invoice written off.
pub invoice_id: u64,
/// The entry that posted it (already rebuilt from its own events).
pub entry_id: u64,
/// The write-off's day.
pub date: NaiveDate,
/// What was written off.
pub amount: rust_decimal::Decimal,
/// Why (it has no resolution: the evidence is the receipt).
pub reason: String,
}
/// `invoice.written_off {invoice_id, entry_id, date, amount, reason}`.
pub fn invoice_written_off(
invoice_id: u64,
entry_id: u64,
date: NaiveDate,
amount: rust_decimal::Decimal,
reason: &str,
) -> Value {
json!({
"invoice_id": invoice_id,
"entry_id": entry_id,
"date": date.to_string(),
"amount": amount.to_string(),
"reason": reason,
})
}
/// Read an `invoice.written_off` payload.
pub fn invoice_written_off_of(p: &Value) -> Result<WrittenOff, StoreError> {
const E: &str = "invoice.written_off";
let amount = str_of(p, E, "amount")?
.parse()
.map_err(|_| corrupt(E, "amount"))?;
Ok(WrittenOff {
invoice_id: u64_of(p, E, "invoice_id")?,
entry_id: u64_of(p, E, "entry_id")?,
date: date_of(&str_of(p, E, "date")?, E, "date")?,
amount,
reason: str_of(p, E, "reason")?,
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+138
View File
@@ -440,6 +440,31 @@ struct AttachRequest<'a> {
description: Option<&'a str>, description: Option<&'a str>,
} }
/// The evidence a bad-debt write-off files (plan P4b S7; rules.md B-41
/// INVARIANT 1): the file that shows the debt is worthless — collection
/// correspondence, a bankruptcy notice (Treas. Reg. §1.166-2). Given as
/// bytes to file in the same call, or as an attachment already in the
/// books that is linked to the write-off's entry and its invoice.
#[derive(Clone, Copy)]
pub enum WriteOffEvidence<'a> {
/// A file's bytes: filed against the write-off's entry (`receipt.
/// attached`) and linked to the invoice in the same call.
File {
/// Its name, e.g. "collection-letter.pdf".
file_name: &'a str,
/// Its MIME type.
mime_type: &'a str,
/// Its bytes.
bytes: &'a [u8],
/// What it is, in one line.
description: Option<&'a str>,
},
/// An attachment already filed and not linked to an entry (it was
/// filed for this): it is linked to the write-off's entry and the
/// invoice.
Existing(u64),
}
impl Store { impl Store {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Open / create // Open / create
@@ -3780,6 +3805,108 @@ impl Store {
Ok(live) Ok(live)
} }
/// Write off an invoice as a bad debt (plan P4b S7; rules.md B-41),
/// filing its evidence in the same call. The write-off's entry (Dr
/// 6990 Bad Debt Expense / Cr A/R) is journaled through the standard
/// entry flow, then `invoice.written_off`, then the evidence: a
/// `receipt.attached` on the write-off's entry and an
/// `attachment.linked` to the invoice. Returns the write-off's entry
/// and its evidence's attachment.
///
/// ```text
/// GOAL: a bad debt comes off the receivables with the proof it was
/// worthless, in one call that is all or nothing.
/// SOURCE: IRC §166(a); Treas. Reg. §1.166-1(e), -2(a), -3(a);
/// docs/spec/rules.md B-41; plan P4b S7, 8.3.6.
/// INVARIANTS:
/// 1. A write that needs journal feature documents_v2 (the event
/// type) MUST be refused in books that haven't declared it,
/// journaling nothing (FormatUpgradePending).
/// 2. The engine's whole check runs first (cash basis, the amount,
/// the day): a refusal MUST leave the journal, the ledger and
/// the projection unchanged.
/// 3. No evidence is no write-off (WriteOffNeedsEvidence): the file
/// is a receipt attached to the entry and linked to the invoice,
/// in this call.
/// 4. Applying the lines MUST replay to the same books
/// (`invoice.written_off` tags the invoice's line WriteOff).
/// ```
pub fn write_off_invoice(
&mut self,
invoice: InvoiceId,
date: NaiveDate,
amount: Decimal,
reason: &str,
evidence: Option<WriteOffEvidence<'_>>,
) -> Result<(EntryId, u64), StoreError> {
self.ensure_feature(crate::format::DOCUMENTS_V2)?;
self.rehearse(|l| l.write_off(invoice, date, amount, reason).map(drop))?;
self.approval_gate(
|l| l.write_off(invoice, date, amount, reason).map(drop),
false,
)?;
let Some(evidence) = evidence else {
return Err(StoreError::WriteOffNeedsEvidence);
};
// Every check on the evidence before anything is journaled: a bad
// attachment refuses the whole write-off.
if let WriteOffEvidence::Existing(id) = evidence {
self.check_linkable(id)?;
if self
.attachment_links(id)?
.contains(&Subject::Invoice(invoice))
{
return Err(StoreError::AlreadyLinked(
id,
Subject::Invoice(invoice).describe(),
));
}
}
// The bytes hit the content-addressed store before anything is
// journaled: an orphan blob from a later refusal is harmless
// (unreferenced, deduplicated), a journal line naming a blob that
// failed to store would not be (as the issued invoice's PDF, S4.3).
if let WriteOffEvidence::File { bytes, .. } = evidence {
self.blobs.put(bytes)?;
}
let bad = self.ensure_control_account(&ledger_core::BAD_DEBTS)?;
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
let description = format!("Write off invoice {invoice}: {}", reason.trim());
let (entry_id, mut journaled) = self.journal_auto_lines(
date,
&description,
&[
(bad, Side::Debit, amount, None),
(ar, Side::Credit, amount, None),
],
)?;
let written_off =
crate::meta::invoice_written_off(invoice, entry_id, date, amount, reason.trim());
journaled.push(self.append_journal("invoice.written_off", written_off)?);
let live = self.ledger.write_off(invoice, date, amount, reason)?;
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?;
}
let attachment_id = match evidence {
WriteOffEvidence::File {
file_name,
mime_type,
bytes,
description,
} => {
let (id, _, _) =
self.attach_receipt(Some(entry_id), file_name, mime_type, bytes, description)?;
id
}
WriteOffEvidence::Existing(id) => {
self.link_attachment(id, Subject::Entry(entry_id))?;
id
}
};
self.link_attachment(attachment_id, Subject::Invoice(invoice))?;
Ok((entry_id, attachment_id))
}
/// The journal features a settlement needs (rules.md B-27, B-42): /// The journal features a settlement needs (rules.md B-27, B-42):
/// `documents_v2` for more than one application, a discount or a fee /// `documents_v2` for more than one application, a discount or a fee
/// (an older version would drop them, and the balance would be wrong), /// (an older version would drop them, and the balance would be wrong),
@@ -7203,6 +7330,13 @@ fn replay_line(
get_opt_usize(p, "discount_line")?, get_opt_usize(p, "discount_line")?,
)?; )?;
} }
"invoice.written_off" => {
// The write-off's entry was already rebuilt; this only tags
// its receivable line with the invoice (WriteOff), which is
// what makes the written-off figure and the status derive.
let w = crate::meta::invoice_written_off_of(p)?;
ledger.replay_invoice_write_off(w.invoice_id, w.amount, w.entry_id)?;
}
"fx.rate_set" => { "fx.rate_set" => {
ledger.set_fx_rate( ledger.set_fx_rate(
Currency::new(get_str(p, "currency")?) Currency::new(get_str(p, "currency")?)
@@ -8188,6 +8322,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
// A payment moves no document row: what a document is paid is read // A payment moves no document row: what a document is paid is read
// from the ledger lines tagged with it (schema v15, rules.md B-38). // from the ledger lines tagged with it (schema v15, rules.md B-38).
"invoice.payment_received" => {} "invoice.payment_received" => {}
// A write-off moves no document row either: written_off and the
// status are derived from the tagged lines (the tag is set by
// replay; rules.md B-41).
"invoice.written_off" => {}
"bill.paid" => { "bill.paid" => {
conn.execute( conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \ "INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
+24
View File
@@ -67,6 +67,9 @@ enum Op {
/// Record a send of document `k` (an invoice; a bill is skipped) on /// Record a send of document `k` (an invoice; a bill is skipped) on
/// day `n`, a reminder after its first send. A send moves no figure. /// day `n`, a reminder after its first send. A send moves no figure.
MarkSent(usize, u8), MarkSent(usize, u8),
/// Write off an invoice `k` (mod the documents) on day `n` for `c`
/// cents, with its evidence filed in the same call.
WriteOff(usize, u8, i64),
/// Close month `m` (January to March), in order. /// Close month `m` (January to March), in order.
Close(u8), Close(u8),
/// A hand line on receivables (true) or payables: always refused. /// A hand line on receivables (true) or payables: always refused.
@@ -91,6 +94,8 @@ fn arb_op() -> impl Strategy<Value = Op> {
3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)), 3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)),
1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)), 1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)),
2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)), 2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)),
2 => (0usize..8, 0u8..90, 1i64..40_000)
.prop_map(|(k, n, c)| Op::WriteOff(k, n, c)),
1 => (1u8..4).prop_map(Op::Close), 1 => (1u8..4).prop_map(Op::Close),
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), 1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500) 2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500)
@@ -225,6 +230,25 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec<DocRef>) -> Result<(
.map(drop) .map(drop)
} }
}, },
Op::WriteOff(..) if docs.is_empty() => Ok(()),
Op::WriteOff(i, n, c) => match docs[i % docs.len()] {
// A bill is never written off: the API takes an invoice.
DocRef::Bill(_) => Ok(()),
DocRef::Invoice(id) => s
.write_off_invoice(
id,
day(n),
cents(c),
"uncollectible",
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("Collection attempts"),
}),
)
.map(drop),
},
Op::Close(m) => { Op::Close(m) => {
let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap(); let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap();
let end = start + chrono::Months::new(1) - chrono::Days::new(1); let end = start + chrono::Months::new(1) - chrono::Days::new(1);
+242 -1
View File
@@ -14,7 +14,7 @@ use ledger_core::LedgerError;
use ledger_core::arap; use ledger_core::arap;
use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia}; use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia};
use ledger_core::{AccountType, Currency, DocRef, Money, Side}; use ledger_core::{AccountType, Currency, DocRef, Money, Side};
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject}; use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject, WriteOffEvidence};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -1455,3 +1455,244 @@ fn legacy_payment_events_keep_the_positional_rule() {
DocRef::Invoice(3) DocRef::Invoice(3)
); );
} }
// ---------------------------------------------------------------------------
// Bad-debt write-offs (plan P4b S7; rules.md B-41)
// ---------------------------------------------------------------------------
/// The evidence a write-off test files: a collection letter.
fn collection_letter() -> &'static [u8] {
b"%PDF-1.4\nFinal demand, unpaid\n%%EOF\n"
}
/// A write-off needs journal feature documents_v2 (its event type is new;
/// an older version refuses the unknown type): refused, journaling
/// nothing, until the owner's upgrade; then it is taken and replays to the
/// same books (R-A, B-27).
#[test]
fn write_off_waits_for_documents_v2() {
let dir = fixture("write-off-v2");
let mut s = Store::open(&dir.0).unwrap();
assert!(
!s.journal_format()
.has_feature(ledger_store::format::DOCUMENTS_V2)
);
let head = s.head();
let len = journal_len(&dir.0);
let evidence = || {
Some(WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: collection_letter(),
description: Some("Collection attempts"),
})
};
match s.write_off_invoice(1, d(2026, 2, 1), cents(10_000), "uncollectible", evidence()) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature documents_v2")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(s.head(), head, "nothing journaled");
assert_eq!(journal_len(&dir.0), len);
assert!(s.upgrade_format().unwrap().is_some());
let (entry, attachment) = s
.write_off_invoice(1, d(2026, 2, 1), cents(10_000), "uncollectible", evidence())
.unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
let st = live.invoice_state(1, d(2026, 12, 31)).unwrap().unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert_eq!(st.written_off, cents(10_000));
// The evidence is on the write-off's entry and on its invoice.
let on_entry = s.attachments_for(Subject::Entry(entry)).unwrap();
assert_eq!(on_entry.len(), 1);
assert_eq!(on_entry[0].id, attachment);
let on_invoice = s.attachments_for(Subject::Invoice(1)).unwrap();
assert!(on_invoice.iter().any(|a| a.id == attachment));
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
s.verify().unwrap();
}
/// A write-off is journaled as its entry and one `invoice.written_off`,
/// and replay derives the same figures and the projection agrees: the
/// invoice reads Written off, the bad-debt expense is on 6990, and the
/// control account still equals its documents (B-41, B-38 INVARIANT 1).
#[test]
fn write_off_replays_and_projects() {
let dir = test_dir("write-off-replay");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(
c,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", 10_000)],
)
.unwrap();
// Half paid, the rest written off: the invoice reads Written off with
// 50 paid and 50 written off (B-38 SCENARIO 7).
s.receive_payment(inv, d(2026, 4, 20), cents(5_000), bank)
.unwrap();
let from = journal_len(&dir.0);
let (entry, attachment) = s
.write_off_invoice(
inv,
d(2026, 4, 30),
cents(5_000),
"collection exhausted",
Some(WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: collection_letter(),
description: Some("Collection attempts"),
}),
)
.unwrap();
// The event: one invoice.written_off with the five facts, right after
// the entry's own events.
let got = since(&dir.0, from);
let written: Vec<&(String, Value)> = got
.iter()
.filter(|(t, _)| t == "invoice.written_off")
.collect();
assert_eq!(written.len(), 1, "{got:?}");
let (_, p) = written[0];
assert_eq!(p["invoice_id"], json!(inv));
assert_eq!(p["entry_id"], json!(entry));
assert_eq!(p["date"], json!("2026-04-30"));
assert_eq!(p["amount"], json!("50.00"));
assert_eq!(p["reason"], json!("collection exhausted"));
// The evidence: a receipt.attached on the entry, then attachment.linked
// to the invoice.
assert!(got.iter().any(|(t, v)| t == "receipt.attached"
&& v["attachment_id"] == json!(attachment)
&& v["entry_id"] == json!(entry)));
assert!(
got.iter()
.any(|(t, v)| t == "attachment.linked" && v["attachment_id"] == json!(attachment))
);
// The figures, live.
let st = s
.ledger()
.invoice_state(inv, d(2026, 5, 1))
.unwrap()
.unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert_eq!(
(st.paid, st.written_off, st.outstanding),
(cents(5_000), cents(5_000), Decimal::ZERO)
);
let bad = s.ledger().chart().find_by_code("6990").unwrap().id;
assert_eq!(s.ledger().account_balance(bad).unwrap(), cents(5_000));
let ar = s.ledger().chart().find_by_code("1200").unwrap().id;
assert_eq!(s.ledger().account_balance(ar).unwrap(), Decimal::ZERO);
assert!(
s.ledger()
.control_check(d(2026, 5, 1))
.unwrap()
.iter()
.all(|c| c.difference.is_zero() && c.attention.is_empty())
);
// `verify` compares the projection with the journal (the write-off
// moves no document row: the figures are the tagged lines').
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
let replayed = s
.ledger()
.invoice_state(inv, d(2026, 5, 1))
.unwrap()
.unwrap();
assert_eq!(replayed, st);
s.verify().unwrap();
}
/// No evidence, no write-off (B-41 INVARIANT 1; Treas. Reg.
/// §1.166-2(a)): refused, journaling nothing; an attachment already filed
/// is linked instead, to the entry and the invoice.
#[test]
fn write_off_without_evidence_is_refused() {
let dir = test_dir("write-off-evidence");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(
c,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", 10_000)],
)
.unwrap();
let head = s.head();
let len = journal_len(&dir.0);
assert!(matches!(
s.write_off_invoice(inv, d(2026, 4, 30), cents(10_000), "uncollectible", None),
Err(StoreError::WriteOffNeedsEvidence)
));
assert_eq!(s.head(), head, "nothing journaled");
assert_eq!(journal_len(&dir.0), len);
// An attachment that isn't in the books refuses it too, before
// anything is written.
assert!(matches!(
s.write_off_invoice(
inv,
d(2026, 4, 30),
cents(10_000),
"uncollectible",
Some(WriteOffEvidence::Existing(999)),
),
Err(StoreError::UnknownAttachment(999))
));
assert_eq!(s.head(), head, "nothing journaled");
// One already filed (unlinked) is linked to the write-off's entry and
// its invoice.
let (attachment, _, _) = s
.file_document(
"other",
"collection-letter.pdf",
"application/pdf",
collection_letter(),
Some("Collection attempts"),
)
.unwrap();
let (entry, evidence) = s
.write_off_invoice(
inv,
d(2026, 4, 30),
cents(10_000),
"uncollectible",
Some(WriteOffEvidence::Existing(attachment)),
)
.unwrap();
assert_eq!(evidence, attachment);
assert!(
s.attachments_for(Subject::Entry(entry))
.unwrap()
.iter()
.any(|a| a.id == attachment)
);
assert!(
s.attachments_for(Subject::Invoice(inv))
.unwrap()
.iter()
.any(|a| a.id == attachment)
);
s.verify().unwrap();
}
@@ -0,0 +1,431 @@
{
"assets": [],
"bills": [
{
"amount_paid": "80.00",
"date": "2026-02-07",
"due_date": "2026-03-09",
"entry_id": 6,
"id": 1,
"lines": [
{
"amount": "80.00",
"description": "Paper"
}
],
"memo": "A4 paper",
"received_on": "2026-02-08",
"status": "Paid",
"terms": {
"kind": {
"net": 30
},
"text": "Net 30"
},
"vendor_id": 1,
"vendor_ref": "TOBIN-77"
}
],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-01",
"description": "Invoice 1",
"id": 2,
"lines": [
{
"account": 3,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-10",
"description": "Payment on invoice 1",
"id": 3,
"lines": [
{
"account": 1,
"amount": {
"amount": "200.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "200.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-05",
"description": "Invoice 2",
"id": 4,
"lines": [
{
"account": 3,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 4,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-06",
"description": "Void invoice 2: wrong customer",
"id": 5,
"lines": [
{
"account": 3,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 4,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
}
],
"reversal_of": 4,
"status": "Posted"
},
{
"date": "2026-02-07",
"description": "Bill 1",
"id": 6,
"lines": [
{
"account": 5,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 6,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-03-01",
"description": "Payment on bill 1",
"id": 7,
"lines": [
{
"account": 6,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [
[
2,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
3,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
4,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
5,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
6,
[
{
"party_id": 2,
"share": "derived"
}
]
],
[
7,
[
{
"party_id": 2,
"share": "derived"
}
]
]
],
"invoices": [
{
"amount_paid": "200.00",
"bill_to": {
"address": "9 Ridgeway Rd",
"email": "ap@fernhill.example",
"name": "Fernhill Labs"
},
"customer_id": 1,
"customer_ref": "PO-7",
"date": "2026-02-01",
"due_date": "2026-03-03",
"entry_id": 2,
"id": 1,
"lines": [
{
"amount": "500.00",
"description": "Design"
}
],
"memo": "Thanks",
"number": "INV-0001",
"seller": {
"address": "1 Main St",
"name": "Wanderbooks Test Co"
},
"status": "Partial",
"terms": {
"discount": {
"days": 10,
"percent_bp": 200
},
"kind": {
"net": 30
},
"text": "2/10 net 30"
}
},
{
"amount_paid": "0",
"customer_id": 1,
"date": "2026-02-05",
"due_date": "2026-03-07",
"entry_id": 4,
"id": 2,
"lines": [
{
"amount": "40.00",
"description": "Setup"
}
],
"status": "Open"
}
],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "1420",
"balanced": true,
"current_earnings": "420",
"equity": "1420",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "80",
"net_income": "420",
"revenue": "500"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "1120",
"name": "Checking"
},
{
"code": "1200",
"credit": "0",
"debit": "300",
"name": "Accounts Receivable"
},
{
"code": "3000",
"credit": "1000",
"debit": "0",
"name": "Capital"
},
{
"code": "4000",
"credit": "500",
"debit": "0",
"name": "Revenue"
},
{
"code": "6999",
"credit": "0",
"debit": "80",
"name": "General Expenses"
}
],
"total_credits": "1500",
"total_debits": "1500"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,40 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-27T17:42:15Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"853fa1a23a8e98ed4afffa7f01cb1c0bb458fd7c2218706cdb53a579308a6003","seq":2,"ts":"2026-10-27T17:42:15Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Checking","parent":null,"placeholder":false},"prev":"3dea41067629e227334a7e6b050ac103ca121872f2d912fb1d472eb903ae7f59","seq":3,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"ee5de65058a81e155528a5258852611d86c0e8ef41e2e97b40644685eb3250df","seq":4,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"26cb19b6f713e2269e2e16d040128daa6b69b354c70d51f0202185e84c8f5450","seq":5,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"597afb2305dfcd7a9c9ba11b22996a53a29d71f11895405c29f36d9d8eba26aa","seq":6,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"7ebce83af99c2a0be9b4626d5d4546c3392d11b96bebb9276597c0b1944cada2","seq":7,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1b4733efe3583fe881e92aabc2b1a8f319911a779e8d986edd427d6fc392b619","seq":8,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"17ef917991a9c0f46e6a28b108fc3cc181a61a7c0bd5827f123cceea993c19ad","seq":9,"ts":"2026-10-27T17:42:15Z","type":"customer.added"}
{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"6f1d020a91130ceedf8830af4f048b42e1c243636be9a70a7f1abf0568147836","seq":10,"ts":"2026-10-27T17:42:15Z","type":"vendor.added"}
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"fb7aeacef5825917e93fe9dea60e638c60207656b3e6bcfa79ea269eedd67eb3","seq":11,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"a6c35a0e3fe245e8bf455163a9599eca70df8295daa9f899ca5b1168815d42dc","seq":12,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2},"prev":"0d4ed5e9db70c993de38467a67ed80dde4185eaec240b390d062bd5102b86a6c","seq":13,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":3,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"b4b54695c5cb7b92c423cb0336eef51cd493516ea1c073986d53dea2bf38dedf","seq":14,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"79a2c8f3a4dce65d6cf6c6c13f0d7df4feeff4fa3543fa4732b64fb96c6c57c9","seq":15,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"500.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"a965e96d442a0ee5abb6878ca981583f4fe29da564a71217d331ec5fc642b701","seq":16,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"bill_to":{"address":"9 Ridgeway Rd","email":"ap@fernhill.example","name":"Fernhill Labs"},"customer_id":1,"customer_ref":"PO-7","date":"2026-02-01","due_date":"2026-03-03","entry_id":2,"id":1,"lines":[{"amount":"500.00","description":"Design"}],"memo":"Thanks","number":"INV-0001","seller":{"address":"1 Main St","name":"Wanderbooks Test Co"},"terms":{"discount":{"days":10,"percent_bp":200},"kind":{"net":30},"text":"2/10 net 30"}},"prev":"9d8f3a1a5a0161040cf9caf8b5710c9fd7e61d909cee685d990d9b5a309fa695","seq":17,"ts":"2026-10-27T17:42:15Z","type":"invoice.created"}
{"payload":{"date":"2026-02-10","description":"Payment on invoice 1","id":3},"prev":"5eac387cbd55eafd2637474b8958baf2e7ca7cacec73535b2310ab124e79e6a1","seq":18,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":1,"amount":"200.00","currency":"USD","id":3,"side":"debit"},"prev":"00b6089df07024fb077049c5206d3dd3812fa662b57af1ae50d42511b34ca0d9","seq":19,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"200.00","currency":"USD","id":3,"side":"credit"},"prev":"26e9ee32e801bedd995f28eee065e4915b654a55c8569fb3ec9327a577c81959","seq":20,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-10","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"200.00","currency":"USD","side":"debit"},{"account":3,"amount":"200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f9e36c8f805aeea8656182becc1b47e7947791ff6f4fe36fc469fd7aaa4354c8","seq":21,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"amount":"200.00","cash_account":1,"date":"2026-02-10","entry_id":3,"invoice_id":1},"prev":"a95f752a661405b4fe0904623b0a2b908b184af28a371f9833b9aa3232685dbb","seq":22,"ts":"2026-10-27T17:42:15Z","type":"invoice.payment_received"}
{"payload":{"date":"2026-02-05","description":"Invoice 2","id":4},"prev":"4b96e27f6bb06775b7d3f4b183ed16e5d7c07ab8dd5e202fafbb8aaa23915cc6","seq":23,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":3,"amount":"40.00","currency":"USD","id":4,"side":"debit"},"prev":"95674fd07333f129d8289f1287190f39f6a8235bdcd9bd1ce01fa7f3f81b2068","seq":24,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"40.00","currency":"USD","id":4,"side":"credit"},"prev":"6c7b09c9a08781eb0f424d05252b88194063b7d954d0a3eed24a7d102fc228e0","seq":25,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-05","description":"Invoice 2","id":4,"lines":[{"account":3,"amount":"40.00","currency":"USD","side":"debit"},{"account":4,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4aef53f197b1ad2b8287d1fbdd2cba6c5ee965421f77a889be5ba30541d4c2d9","seq":26,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"customer_id":1,"date":"2026-02-05","due_date":"2026-03-07","entry_id":4,"id":2,"lines":[{"amount":"40.00","description":"Setup"}]},"prev":"672d61478a6f83d099c7889f75880e5ce0d7a214838720536fcb2eb998ebc2d4","seq":27,"ts":"2026-10-27T17:42:15Z","type":"invoice.created"}
{"payload":{"date":"2026-02-06","description":"Void invoice 2: wrong customer","id":5,"lines":[{"account":3,"amount":"40.00","currency":"USD","side":"credit"},{"account":4,"amount":"40.00","currency":"USD","side":"debit"}],"reverses":4},"prev":"3f8b16f5949e56504750ee6ebfe1edf08a1a8afe74fc75aafe6b2bee883056be","seq":28,"ts":"2026-10-27T17:42:15Z","type":"entry.reversed"}
{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"57481256716e8ec09f325832cf98df9020505c58b8b2d7136f14d2f8cb4b57ea","seq":29,"ts":"2026-10-27T17:42:16Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"7e7dd770042b61627f9f6a528f38fcae1447eb6b5ad8f1ed378f851667ae51de","seq":30,"ts":"2026-10-27T17:42:16Z","type":"account.added"}
{"payload":{"date":"2026-02-07","description":"Bill 1","id":6},"prev":"f97abbb46d74293510e8b37f54a8c863287df3cb9775e8e159550508a6313160","seq":31,"ts":"2026-10-27T17:42:16Z","type":"entry.created"}
{"payload":{"account":5,"amount":"80.00","currency":"USD","id":6,"side":"debit"},"prev":"d7e394993994fd4e5a1d4017cd30b4e72e3b2ddac8d91ff86a97cbc4a517a667","seq":32,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"account":6,"amount":"80.00","currency":"USD","id":6,"side":"credit"},"prev":"048f99094bf692587a1f3463d663d42efd5681935b9cdb8b6ad187408b23c51d","seq":33,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-07","description":"Bill 1","id":6,"lines":[{"account":5,"amount":"80.00","currency":"USD","side":"debit"},{"account":6,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b38c0078bb78b62f9d565bad95519c243d0374d480b7f17f8f2d0c8a24f7945c","seq":34,"ts":"2026-10-27T17:42:16Z","type":"entry.posted"}
{"payload":{"date":"2026-02-07","due_date":"2026-03-09","entry_id":6,"id":1,"lines":[{"amount":"80.00","description":"Paper"}],"memo":"A4 paper","received_on":"2026-02-08","terms":{"kind":{"net":30},"text":"Net 30"},"vendor_id":1,"vendor_ref":"TOBIN-77"},"prev":"d4aef1e2609466fda52daf6f871bf77c6f3851f58f73e0dc4a59c2b50fb8b472","seq":35,"ts":"2026-10-27T17:42:16Z","type":"bill.created"}
{"payload":{"date":"2026-03-01","description":"Payment on bill 1","id":7},"prev":"cf87cbbda32520851e8a0e341235f24a6c33d095c2b619ff558549c6a200f2ce","seq":36,"ts":"2026-10-27T17:42:16Z","type":"entry.created"}
{"payload":{"account":6,"amount":"80.00","currency":"USD","id":7,"side":"debit"},"prev":"a7df5eacd102668f821c155ebca4caaf3811ed684e89ce76963fad08b02aa60c","seq":37,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"80.00","currency":"USD","id":7,"side":"credit"},"prev":"2969a6d12f0b3c81edd637d436d97e91ef41197c5d353b0d8a78b9b14396fe50","seq":38,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-01","description":"Payment on bill 1","id":7,"lines":[{"account":6,"amount":"80.00","currency":"USD","side":"debit"},{"account":1,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"fce397fd5ac8118392a5b46682afe929293c91753ac7ac3bffb9bbb67d692ef9","seq":39,"ts":"2026-10-27T17:42:16Z","type":"entry.posted"}
{"payload":{"amount":"80.00","bill_id":1,"cash_account":1,"date":"2026-03-01","entry_id":7},"prev":"a56a40dc3b3d9b1a5ff33ec8814ff8053bdd9c17f2453ece991eb930633d0e4f","seq":40,"ts":"2026-10-27T17:42:16Z","type":"bill.paid"}
+80 -2
View File
@@ -1877,6 +1877,7 @@ fn books_written_under_older_rules_still_open() {
(6, "6500", "Supplies", "Expense"), (6, "6500", "Supplies", "Expense"),
(7, "4100", "Refunds and Allowances", "Revenue"), (7, "4100", "Refunds and Allowances", "Revenue"),
(8, "1020", "Bank", "Asset"), (8, "1020", "Bank", "Asset"),
(9, "6990", "Bad Debt Expense", "Expense"),
] { ] {
w.append( w.append(
"account.added", "account.added",
@@ -1990,6 +1991,50 @@ fn books_written_under_older_rules_still_open() {
) )
.unwrap(); .unwrap();
} }
// A second invoice (50.00) for P4b S7's write-off below, so S6's full
// payment of invoice 1 and S7's write-off never touch the same document.
w.append(
"entry.created",
json!({"id": 5, "date": "2026-04-03", "description": "Invoice 2"}),
)
.unwrap();
for (account, side) in [(3, "debit"), (4, "credit")] {
w.append(
"entry.line_added",
json!({"id": 5, "account": account, "side": side, "amount": "50.00", "currency": "USD"}),
)
.unwrap();
}
w.append(
"entry.posted",
json!({"id": 5, "date": "2026-04-03", "description": "Invoice 2", "reversal_of": null,
"lines": [
{"account": 3, "side": "debit", "amount": "50.00", "currency": "USD"},
{"account": 4, "side": "credit", "amount": "50.00", "currency": "USD"}]}),
)
.unwrap();
w.append(
"invoice.created",
json!({"id": 2, "customer_id": 1, "date": "2026-04-03", "due_date": "2026-05-03",
"lines": [{"description": "Survey", "amount": "50.00"}], "entry_id": 5}),
)
.unwrap();
// P4b S7's new shape, hand-written as this version writes it: a
// bad-debt write-off of invoice 1 (its entry, then the event). This
// reader tags the receivable line WriteOff and derives the figures;
// the version before it stopped at the declaration.
w.append(
"entry.created",
json!({"id": 6, "date": "2026-04-10", "description": "Write off invoice 2: uncollectible"}),
)
.unwrap();
for (account, side) in [(9, "debit"), (3, "credit")] {
w.append(
"entry.line_added",
json!({"id": 6, "account": account, "side": side, "amount": "30.00", "currency": "USD"}),
)
.unwrap();
}
w.append( w.append(
"entry.posted", "entry.posted",
json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1", json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1",
@@ -2010,13 +2055,28 @@ fn books_written_under_older_rules_still_open() {
"discount_reason": "the customer paid the rest late"}), "discount_reason": "the customer paid the rest late"}),
) )
.unwrap(); .unwrap();
w.append(
"entry.posted",
json!({"id": 6, "date": "2026-04-10",
"description": "Write off invoice 2: uncollectible", "reversal_of": null,
"lines": [
{"account": 9, "side": "debit", "amount": "30.00", "currency": "USD"},
{"account": 3, "side": "credit", "amount": "30.00", "currency": "USD"}]}),
)
.unwrap();
w.append(
"invoice.written_off",
json!({"invoice_id": 2, "entry_id": 6, "date": "2026-04-10",
"amount": "30.00", "reason": "uncollectible"}),
)
.unwrap();
drop(w); drop(w);
// Opening rebuilds from the journal; it must accept all of it. // Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir).expect("older books still open"); let store = Store::open(&dir).expect("older books still open");
store.verify().unwrap(); store.verify().unwrap();
assert_eq!(store.journal_format().format, 1); assert_eq!(store.journal_format().format, 1);
assert_eq!(store.ledger().chart().len(), 8); assert_eq!(store.ledger().chart().len(), 9);
assert_eq!(store.ledger().list_periods().len(), 2); assert_eq!(store.ledger().list_periods().len(), 2);
let lines = store.ledger().get_entry(1).unwrap().lines(); let lines = store.ledger().get_entry(1).unwrap().lines();
assert_eq!(lines.len(), 2); assert_eq!(lines.len(), 2);
@@ -2108,6 +2168,20 @@ fn books_written_under_older_rules_still_open() {
store.ledger().line_doc(4, 1).unwrap().movement, store.ledger().line_doc(4, 1).unwrap().movement,
ledger_core::Movement::Discount ledger_core::Movement::Discount
); );
// The write-off replays as written: its line is the invoice's
// (WriteOff), so what it owes fell by 30.00 and what was written off
// rose by it (B-41, B-38).
let st = store
.ledger()
.invoice_state(2, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap())
.unwrap()
.unwrap();
assert_eq!(st.written_off, "30.00".parse::<Decimal>().unwrap());
assert_eq!(st.outstanding, "20.00".parse::<Decimal>().unwrap());
assert_eq!(
store.ledger().line_doc(6, 1).map(|t| t.movement),
Some(ledger_core::Movement::WriteOff)
);
} }
/// The books an earlier wanderbooks wrote, one directory each under /// The books an earlier wanderbooks wrote, one directory each under
@@ -2123,7 +2197,11 @@ fn books_written_under_older_rules_still_open() {
/// 10,000 years, schedules and assets running into chrono's last month, /// 10,000 years, schedules and assets running into chrono's last month,
/// depreciation posted for 10000-01, one recurring run of 1,044 drafts. /// depreciation posted for 10000-01, one recurring run of 1,044 drafts.
/// tests/fixtures/old-books-writer is the harness that wrote them; each /// tests/fixtures/old-books-writer is the harness that wrote them; each
/// later phase adds the books its version writes. /// later phase adds the books its version writes. `p4_s7_before` is P4b
/// S7's: the books main 4cad2cfc (the version before it) wrote — a
/// numbered invoice with terms and snapshots, a partial payment, a voided
/// invoice and a paid bill, no write-off — with that version's
/// expected.json.
fn old_books() -> Vec<PathBuf> { fn old_books() -> Vec<PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books"); let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root) let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
+3
View File
@@ -184,6 +184,9 @@ groups are:
- `parties` — parties, vendors, customers, roles, W-9s, the TIN vault, - `parties` — parties, vendors, customers, roles, W-9s, the TIN vault,
1099s, entry parties; 1099s, entry parties;
- `attachments` — receipts, filed documents, and their links; - `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 - `settings` — the handbook, `books.settings` and the recorded FX rates
(the module is `settings_tools`, since `crate::settings` is the live (the module is `settings_tools`, since `crate::settings` is the live
settings); settings);
+25 -3
View File
@@ -354,7 +354,7 @@ Tool descriptions are at most 700 characters; eight longer ones are listed
in `tool_descriptions_fit_700_characters`, to be shortened in P5. in `tool_descriptions_fit_700_characters`, to be shortened in P5.
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
that changes anything also has a `destructiveHint`, true for these 29: that changes anything also has a `destructiveHint`, true for these 30:
`books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`,
`books.unlink_attachment`, `books.withdraw_attachment`, `books.unlink_attachment`, `books.withdraw_attachment`,
`books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`,
@@ -364,8 +364,8 @@ that changes anything also has a `destructiveHint`, true for these 29:
`books.void_statement`, `books.void_reconciliation`, `books.set_w9`, `books.void_statement`, `books.void_reconciliation`, `books.set_w9`,
`books.set_tin` (both overwrite a vault record), `books.purge_tin`, `books.set_tin` (both overwrite a vault record), `books.purge_tin`,
`books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`,
`books.pay_bill`, `books.void_document`, `books.run_payroll`, `books.pay_bill`, `books.void_document`, `books.write_off_invoice`,
`books.remit_payroll_taxes`; and an `books.run_payroll`, `books.remit_payroll_taxes`; and an
`idempotentHint` on the ten that a repeat with the same arguments leaves `idempotentHint` on the ten that a repeat with the same arguments leaves
the journal and the vault as they were: `books.verify`, the journal and the vault as they were: `books.verify`,
`books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`,
@@ -2142,6 +2142,28 @@ snapshots; see plan 8.2 S4.
recipients, or while the books wait for `documents_v2` recipients, or while the books wait for `documents_v2`
(`format_upgrade_pending`). `sends` is every send on the invoice, in (`format_upgrade_pending`). `sends` is every send on the invoice, in
order, the new one last. order, the new one last.
- `books.write_off_invoice { invoice, date, amount?, reason, upload_id |
existing_attachment_id, confirmed? }` → `{ entry_id, document: { ref,
status, paid, written_off, outstanding } }`. Writes off an invoice that
will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr
`6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable
line tagged with the invoice (`WriteOff`), so the invoice reads
`written_off` and its figures stay derived (B-38). `amount` (a decimal
string, default all it owes) is at most what is owed and may be partial
(§1.166-3(a)). Its evidence of worthlessness (collection
correspondence, a bankruptcy notice: §1.166-2(a)) is filed in the same
call, as `upload_id` (books.begin_upload, purpose `document` or
`receipt`, then `wanderbooks upload`) or as an
`existing_attachment_id` already filed and not linked; it becomes a
receipt on the write-off's entry and a link to the invoice. Refused on
cash-basis books (§1.166-1(e)), above what it owes, before the
invoice's date or in a closed period. Without `confirmed: true` it
answers `entry_id` 0 and the figures the write-off would leave, writing
nothing. A recovery is not a new command (D-49): the same tax year,
reverse the write-off (`books.reverse_entry`, the invoice reopens) and
record the payment; a later year, a plain entry Dr the deposit account /
Cr a "Bad debt recoveries" revenue account, the invoice staying written
off (§111).
- `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of, - `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of,
kind, documents: [{ ref, party, date, due_date, total, outstanding, kind, documents: [{ ref, party, date, due_date, total, outstanding,
status, overdue_days, last_sent? }], outstanding_total }`. Invoices (`kind` status, overdue_days, last_sent? }], outstanding_total }`. Invoices (`kind`
+10
View File
@@ -163,3 +163,13 @@ posted gross (A-4), and the bills-due list with the agings' detail,
tie-out and exports. The pre-S6 fixture's figures still hold (no figure tie-out and exports. The pre-S6 fixture's figures still hold (no figure
moves), and every new write needs the journal feature `documents_v2`, moves), and every new write needs the journal feature `documents_v2`,
which the owner's books declare at their next upgrade (D-52). which the owner's books declare at their next upgrade (D-52).
## 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).
+10 -1
View File
@@ -62,6 +62,9 @@ apply is safe — on startup the applier replays every journal line with
send: a document's record, never a ledger line or a figure; P4b S5) send: a document's record, never a ledger line or a figure; P4b S5)
(plus the A/R, A/P, asset, period, payroll, and class events listed on (plus the A/R, A/P, asset, period, payroll, and class events listed on
`JournalLine`). `JournalLine`).
`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 - `payload`: the full domain facts (account fields, entry with lines, blob
sha, ...). Everything the applier needs; nothing it must look up elsewhere. sha, ...). Everything the applier needs; nothing it must look up elsewhere.
An `invoice.created` or `bill.created` written after the `documents_v2` An `invoice.created` or `bill.created` written after the `documents_v2`
@@ -76,7 +79,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 entry exists): it is a `receipt.attached` on the invoice's own entry
(`INV-0007.pdf`, `application/pdf`, "Issued invoice"), its SHA-256 the (`INV-0007.pdf`, `application/pdf`, "Issued invoice"), its SHA-256 the
`blob_sha`, linked to the invoice with `attachment.linked` and found `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 - `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
whitespace). The genesis line uses `"prev": "000...0"` (64 zeros). whitespace). The genesis line uses `"prev": "000...0"` (64 zeros).
+102
View File
@@ -3120,6 +3120,108 @@ coding of `DocRef`), and an account becoming a control account by its
first document, holding lines from before, is outside it (those lines are first document, holding lines from before, is outside it (those lines are
listed as unattached). 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`.
## B-42 A settlement is exact and names its lines ## B-42 A settlement is exact and names its lines
**bookspec:** `BS-arap-6` **bookspec:** `BS-arap-6`
+80 -3
View File
@@ -2675,7 +2675,7 @@ it calls what part A proved and tested.
check, so both output schemas are exercised on every run. check, so both output schemas are exercised on every run.
- **The tool surface, `crates/ledger-mcp/src/server.rs`'s tests:** - **The tool surface, `crates/ledger-mcp/src/server.rs`'s tests:**
`annotations_complete` pins `books.void_document` destructive and `annotations_complete` pins `books.void_document` destructive and
posting, `books.control_check` read-only, and counts the 29 posting, `books.control_check` read-only, and counts the 30
destructive tools the doc names; `period_tools_describe_the_one_default` destructive tools the doc names; `period_tools_describe_the_one_default`
holds `books.control_check` to the one `as_of` sentence. The generic holds `books.control_check` to the one `as_of` sentence. The generic
lints cover the rest without a change of their own: lints cover the rest without a change of their own:
@@ -2726,11 +2726,11 @@ strikethrough, and the test asserts the labels.
The happy-path journey lists and reads one, so both output schemas are The happy-path journey lists and reads one, so both output schemas are
exercised on every run. exercised on every run.
- **The tool surface:** `annotations_complete` pins both read-only - **The tool surface:** `annotations_complete` pins both read-only
(table, now 122 tools); `period_tools_describe_the_one_default` holds (table, now 123 tools); `period_tools_describe_the_one_default` holds
both to the `as_of` sentence; both to the `as_of` sentence;
`every_tool_is_documented_and_every_documented_tool_exists` and the `every_tool_is_documented_and_every_documented_tool_exists` and the
text lints cover the doc; `scopes.rs`'s table and text lints cover the doc; `scopes.rs`'s table and
`mcp_journeys.rs::mcp_end_to_end` count 122. `mcp_journeys.rs::mcp_end_to_end` count 123.
- **The viewer, `crates/ledger-gui/tests/invoices.rs`:** - **The viewer, `crates/ledger-gui/tests/invoices.rs`:**
`the_list_shows_the_ledgers_figures` (the reversed payment no longer `the_list_shows_the_ledgers_figures` (the reversed payment no longer
counts: $580 owed, $500 of it late; "Invoice 1, Harbor Client, 14 days counts: $580 owed, $500 of it late; "Invoice 1, Harbor Client, 14 days
@@ -3192,3 +3192,80 @@ arithmetic; `bills_due`'s `through` filter, the discount-window test and
the running-total grouping; `aging_documents`'s outstanding filter and the running-total grouping; `aging_documents`'s outstanding filter and
bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the
viewer's `discount_by` and `days_overdue` flags. viewer's `discount_by` and `days_overdue` flags.
## 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
@@ -579,6 +579,35 @@ come from the kernel:
`Documents.settle_posts_a_document_step`, `Documents.settle_posts_a_document_step`,
`Documents.settle_unattached`. `Documents.settle_unattached`.
### 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).
37. **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 ## What the proofs found
The engine already had 190+ tests, property tests, mutation testing, The engine already had 190+ tests, property tests, mutation testing,
@@ -667,7 +696,7 @@ Be precise about the claims:
`tests/properties.rs` (trial balance, balance sheet, reversals, `tests/properties.rs` (trial balance, balance sheet, reversals,
immutability) and the golden files. immutability) and the golden files.
- **Out of scope:** currency (the ledger is single-currency, enforced - **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`, the proration kernel (`calendar::month_parts`, `assets::depreciation_schedule`,
`schedules::daily_plan`: property tests, `tests/properties.rs`, P3), and `schedules::daily_plan`: property tests, `tests/properties.rs`, P3), and
persistence (the journal store's hash chain is tested in `ledger-store`). persistence (the journal store's hash chain is tested in `ledger-store`).
+9 -1
View File
@@ -1408,7 +1408,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
5. **Files:** owns `Ledger::write_off`, the event's arms, `books.write_off_invoice`, `Step.writeOff`; touches the 8.0.3 files. 5. **Files:** owns `Ledger::write_off`, the event's arms, `books.write_off_invoice`, `Step.writeOff`; touches the 8.0.3 files.
6. **Tests and proofs:** named above; Lean `writeOff_within_bounds`; no kernel change. 6. **Tests and proofs:** named above; Lean `writeOff_within_bounds`; no kernel change.
7. **Done when:** (1) the tests pass; (2) `just prove` green with the new theorem in `Audit.lean`; (3) 8.0.6 in the PR; (4) the handbook says when to void, write off or reverse; (5) `just check` green. 7. **Done when:** (1) the tests pass; (2) `just prove` green with the new theorem in `Audit.lean`; (3) 8.0.6 in the PR; (4) the handbook says when to void, write off or reverse; (5) `just check` green.
8. **As built:** none yet. 8. **As built** (2026-10-27, `d5af44b9`, `62f2fb05`, `4e71cf63`, `0f1c7786`, `b731d7ed`, `03227786`, `e801908a`, `f31dd264`, `22cdfcf3`, `176baa52`): departures only, the Build text above untouched.
- The `bad_debts` role had no engine constant (8.3.4's table names it, but only S7 needed it): `ledger_core::BAD_DEBTS` (6990 Bad Debt Expense, an Expense control role) is added and joined to `control_accounts()`, so `books.roles` and `books.assign_role` see it.
- The amount bound is the least the invoice owes on any day from the write-off's on (`least_owed_from`, the helper `payment_room` now shares), not only its outstanding on the day: a write-off dated before a payment that later settles it would otherwise take the document below nothing at that later day (B-38 INVARIANT 2). The refusal says what is owed and, when a later payment lowers it, what can be written off.
- The evidence given as `existing_attachment_id` must be an attachment not yet on an entry: one already filed against another entry would be one receipt counted twice (the refusal `link_receipt` gives, B-38's own invariant 5). An upload's bytes are put in the content-addressed store before the first journal line, as the issued invoice's PDF is (S4.3).
- The handbook section `invoices-and-bills` did not exist yet (S4.5 owns it and has not landed), so this unit created it with step 4 and the two recovery procedures; S4.5 adds steps 1-3 and 5 around them, in its own reading position.
- bookspec had no way to state a write-off: the example schema gained the dated `write_off = { invoice, amount?, reason }` event (the invoice named by its event, as `reverses` names one), and a `refused` `lines` entry, which the adapter rehearses on a clone so a refusal journals nothing; `older` and `refused` never combine. The loader's refusal vocabulary gained `untagged_control_line` (B-38), as the unit says. `bad-debt-direct-write-off` states B-41 (its bookspec status moves from "allocated" to CHOICE); `arap-hand-control-posting-refused` states B-38's control guard and its legacy-line replay.
- The store tests went into `tests/arap_standing.rs` (where R-A puts the feature-wait test) and the over-the-wire tests opened `crates/ledger-cli/tests/documents.rs` (its allocated file); `arap_props.rs` gained `Op::WriteOff`.
- `docs/verification.md`'s "out of scope" list no longer says A/R and A/P documents are unproved: S1's `Documents` model exists, and S7 adds the write-off step to it.
- There is no `books.adopt_control_line`, no `books.assign_role { move_balance }` and no `Movement::Recovery` (D-49, 8.4), as the unit says.
**Deferred from S7 (D-49, recorded in 8.4):** `books.adopt_control_line` and `books.assign_role { move_balance }`. 8.3.14 item 4 already gives two remedies for a legacy hand line on 1200 or 2000 that need no code: reverse it and re-enter it as a document while its period is open; or leave it, record a decision, and let the CPA README footnote it (the identity's `unattached` term exists for exactly this). Build the tools only if `books.control_check` on the owner's books lists a line neither remedy can take; then the EM writes a unit for them with a Lean `Step.adopt` and a per-account `owed_on` identity (8.4 has the notes). **Deferred from S7 (D-49, recorded in 8.4):** `books.adopt_control_line` and `books.assign_role { move_balance }`. 8.3.14 item 4 already gives two remedies for a legacy hand line on 1200 or 2000 that need no code: reverse it and re-enter it as a document while its period is open; or leave it, record a decision, and let the CPA README footnote it (the identity's `unattached` term exists for exactly this). Build the tools only if `books.control_check` on the owner's books lists a line neither remedy can take; then the EM writes a unit for them with a Lean `Step.adopt` and a per-account `owed_on` identity (8.4 has the notes).
+2
View File
@@ -99,3 +99,5 @@ open Wanderbooks
#print axioms Documents.Step.markSent_changes_no_figure #print axioms Documents.Step.markSent_changes_no_figure
#print axioms Documents.Reachable.unattached_zero #print axioms Documents.Reachable.unattached_zero
#print axioms Documents.Reachable.control_is_documents #print axioms Documents.Reachable.control_is_documents
#print axioms Documents.Step.writeOff_unattached
#print axioms Documents.writeOff_within_bounds
+79 -15
View File
@@ -18,12 +18,17 @@ lines, so it is an identity, not a reconciliation, and it holds through
any date, since a date is a filter on the lines. A document is one `Nat` any date, since a date is a filter on the lines. A document is one `Nat`
here: an injective coding of `DocRef` (invoices and bills in one id here: an injective coding of `DocRef` (invoices and bills in one id
space), which is all the sums need. Then, over the commands: space), which is all the sums need. Then, over the commands:
a document step (an invoice, a bill, a payment, or any entry whose a document step (an invoice, a bill, a payment, a write-off, or any entry
control lines are tagged) never moves what is unattached; a reversal whose control lines are tagged) never moves what is unattached; a reversal
moves each document, and what is unattached, by exactly the opposite of moves each document, and what is unattached, by exactly the opposite of
the lines it mirrors (B-38 INVARIANT 3); so books built by these steps the lines it mirrors (B-38 INVARIANT 3); so books built by these steps
alone have nothing unattached on a control account, ever alone have nothing unattached on a control account, ever
(`Reachable.unattached_zero`). (`Reachable.unattached_zero`).
A write-off (S7, B-41) is a document step too: its control line carries the
document and its expense line is off the control accounts. And it never
takes a document below zero: `writeOff_within_bounds` reads the date-sum
before and after it.
-/ -/
namespace Wanderbooks.Documents namespace Wanderbooks.Documents
@@ -117,6 +122,10 @@ theorem control_agrees (a : Nat) (ds : List Nat) (hd : Distinct ds) :
/-- The lines dated on or before day `D`. -/ /-- The lines dated on or before day `D`. -/
def through (D : Nat) (ls : List TLine) : List TLine := ls.filter (fun l => decide (l.date ≤ D)) def through (D : Nat) (ls : List TLine) : List TLine := ls.filter (fun l => decide (l.date ≤ D))
theorem through_append (D : Nat) (xs ys : List TLine) :
through D (xs ++ ys) = through D xs ++ through D ys := by
simp [through, List.filter_append]
theorem Covers.through {ds : List Nat} {ls : List TLine} (h : Covers ds ls) (D : Nat) : theorem Covers.through {ds : List Nat} {ls : List TLine} (h : Covers ds ls) (D : Nat) :
Covers ds (through D ls) := fun l hl => h l (List.mem_filter.1 hl).1 Covers ds (through D ls) := fun l hl => h l (List.mem_filter.1 hl).1
@@ -171,6 +180,30 @@ def Tagged (control : List Nat) (ls : List TLine) : Prop :=
/-- The books: the control accounts (the receivables and payables roles), /-- The books: the control accounts (the receivables and payables roles),
every posted line in order, and the sends recorded (each on its invoice; every posted line in order, and the sends recorded (each on its invoice;
plan P4b S5). -/ plan P4b S5). -/
/-- A tagged list moves nothing that is unattached on a control account:
no line it adds on one carries no document. -/
theorem unattached_zero_of_tagged {control : List Nat} {e : List TLine} (h : Tagged control e)
{a : Nat} (ha : a ∈ control) : unattached a e = 0 := by
induction e with
| nil => rfl
| cons l ls ih =>
simp only [unattached]
rw [ih (fun m hm => h m (List.mem_cons_of_mem _ hm))]
by_cases hn : l.doc = none
· have hacc : l.line.account ≠ a := fun e => h l (List.mem_cons_self ..) (e ▸ ha) hn
simp [hn, onAcct, hacc]
· simp [hn]
/-- The two lines a write-off of `amount` posts on day `D` (plan P4b S7):
the document's control line credited, tagged with the document (the
receivable falls), and the same amount of bad-debt expense debited,
untagged (the deduction). -/
def writeOffLines (control expense d D : Nat) (amount : Int) : List TLine :=
[ { line := { account := control, side := .credit, amount := amount }, date := D, doc := some d },
{ line := { account := expense, side := .debit, amount := amount }, date := D, doc := none } ]
/-- The books: the control accounts (the receivables and payables roles)
and every posted line, in order. -/
structure Books where structure Books where
control : List Nat control : List Nat
lines : List TLine lines : List TLine
@@ -183,8 +216,9 @@ def Books.docs (B : Books) : List Nat :=
/-- What the engine allows (S1): a document command, or any entry whose /-- What the engine allows (S1): a document command, or any entry whose
control lines are all tagged (`Step.document`); a reversal of posted control lines are all tagged (`Step.document`); a reversal of posted
lines, mirror for mirror, each keeping its tag (`Step.reverse`), dated on lines, mirror for mirror, each keeping its tag (`Step.reverse`), dated on
or after them (B-38, "Dating rules"); assigning a document role an or after them (B-38, "Dating rules"); writing off a bad debt, for at most
account all of whose lines are documents' (`Step.assign`: what the document is owed (`Step.writeOff`, S7); assigning a document
role an account all of whose lines are documents' (`Step.assign`:
`check_assign_role` SCENARIO 6 refuses one holding an untagged line); and `check_assign_role` SCENARIO 6 refuses one holding an untagged line); and
an account leaving the control accounts once it holds no document's line an account leaving the control accounts once it holds no document's line
(`Step.release`: an assigned role moved off an account no document posted (`Step.release`: an assigned role moved off an account no document posted
@@ -195,6 +229,11 @@ inductive Step : Books → Books → Prop
Step B { B with lines := B.lines ++ e } Step B { B with lines := B.lines ++ e }
| reverse (B : Books) (e : List TLine) (he : ∀ l ∈ e, l ∈ B.lines) (D : Nat) : | reverse (B : Books) (e : List TLine) (he : ∀ l ∈ e, l ∈ B.lines) (D : Nat) :
Step B { B with lines := B.lines ++ e.map (mirror D) } Step B { B with lines := B.lines ++ e.map (mirror D) }
| writeOff (B : Books) (control expense d D : Nat) (amount : Int)
(hc : control ∈ B.control) (hne : control ≠ expense)
(h : Tagged B.control (writeOffLines control expense d D amount))
(hb : amount ≤ docSum control d (through D B.lines)) :
Step B { B with lines := B.lines ++ writeOffLines control expense d D amount }
| assign (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc ≠ none) : | assign (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc ≠ none) :
Step B { B with control := c :: B.control } Step B { B with control := c :: B.control }
| release (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc = none) : | release (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc = none) :
@@ -209,17 +248,16 @@ inductive Step : Books → Books → Prop
account: no command adds an untagged control line. -/ account: no command adds an untagged control line. -/
theorem Step.document_unattached (B : Books) (e : List TLine) (h : Tagged B.control e) theorem Step.document_unattached (B : Books) (e : List TLine) (h : Tagged B.control e)
(a : Nat) (ha : a ∈ B.control) : unattached a (B.lines ++ e) = unattached a B.lines := by (a : Nat) (ha : a ∈ B.control) : unattached a (B.lines ++ e) = unattached a B.lines := by
rw [unattached_append] rw [unattached_append, unattached_zero_of_tagged h ha]; omega
suffices unattached a e = 0 by omega
induction e with /-- **A write-off never moves what is unattached**: its control line carries
| nil => rfl the document (a `Step.document` in shape), and its expense line is off the
| cons l ls ih => control accounts, so no control account gains an untagged line (B-41). -/
simp only [unattached] theorem Step.writeOff_unattached (B : Books) (control expense d D : Nat) (amount : Int)
rw [ih (fun m hm => h m (List.mem_cons_of_mem _ hm))] (h : Tagged B.control (writeOffLines control expense d D amount)) (a : Nat)
by_cases hn : l.doc = none (ha : a ∈ B.control) :
· have hacc : l.line.account ≠ a := fun e => h l (List.mem_cons_self ..) (e ▸ ha) hn unattached a (B.lines ++ writeOffLines control expense d D amount) = unattached a B.lines := by
simp [hn, onAcct, hacc] rw [unattached_append, unattached_zero_of_tagged h ha]; omega
· simp [hn]
/-- **A settlement is a document step**: its entry's every control line /-- **A settlement is a document step**: its entry's every control line
carries its document (a payment or a discount), so the books' rules for carries its document (a payment or a discount), so the books' rules for
@@ -287,6 +325,11 @@ theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.line
· exact ih l hl hc · exact ih l hl hc
· obtain ⟨m, hm, rfl⟩ := List.mem_map.1 hl · obtain ⟨m, hm, rfl⟩ := List.mem_map.1 hl
exact ih m (he m hm) (by simpa [mirror, Line.reverse] using hc) exact ih m (he m hm) (by simpa [mirror, Line.reverse] using hc)
| writeOff control expense d D amount hc hne h hb =>
intro l hl hc'
rcases List.mem_append.1 hl with hl | hl
· exact ih l hl hc'
· exact h l hl hc'
| assign c h => | assign c h =>
intro l hl hc intro l hl hc
rcases List.mem_cons.1 hc with hc | hc rcases List.mem_cons.1 hc with hc | hc
@@ -330,4 +373,25 @@ theorem Reachable.control_is_documents {B : Books} (r : Reachable B) (a : Nat)
gl a (through D B.lines) = sumOver (fun d => docSum a d (through D B.lines)) ds := by gl a (through D B.lines) = sumOver (fun d => docSum a d (through D B.lines)) ds := by
rw [control_agrees_through a ds hd B.lines hc D, r.unattached_zero a ha D]; omega rw [control_agrees_through a ds hd B.lines hc D, r.unattached_zero a ha D]; omega
/-! ## Writing off a bad debt (B-41, plan P4b S7) -/
/-- What a write-off of `amount` does to the document's own sum through
`D`: the control line it posts there is a credit tagged with the document,
and the expense line carries none and is on another account. -/
theorem docSum_writeOffLines (control expense d D : Nat) (amount : Int) :
docSum control d (through D (writeOffLines control expense d D amount)) = -amount := by
simp [writeOffLines, through, docSum, onAcct, Line.signed]
/-- **A write-off of at most what is owed leaves nothing owed below zero**
(B-41 INVARIANT 1, over the date-sum `docSum … through D`): the credit it
posts on the control line is the amount, so what the document owes through
the write-off's day is what it owed less the amount, and that is never
below zero. Reversing the write-off adds the amount back
(`reverse_twice`), which is the same-year recovery (D-49). -/
theorem writeOff_within_bounds (B : Books) (control expense d D : Nat) (amount : Int)
(h : amount ≤ docSum control d (through D B.lines)) :
0 ≤ docSum control d (through D (B.lines ++ writeOffLines control expense d D amount)) := by
rw [through_append, docSum_append, docSum_writeOffLines]
omega
end Wanderbooks.Documents end Wanderbooks.Documents