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:
commit
7ebeeed2e6
48 files changed
+2884
-75
No files matched your search
+18
-5
@@ -264,6 +264,8 @@ the side gives the direction.
|
|||||||
on a receivables or payables account, where only an invoice, a bill or
|
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]`.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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) | — |
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 && {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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) \
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user