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
335a3eaa7e
47 files changed
+2883
-74
No files matched your search
@@ -191,6 +191,7 @@ fn refusal(e: &LedgerError) -> String {
|
||||
LedgerError::AssetAlreadyDisposed(_) => "already_disposed",
|
||||
LedgerError::InvalidDepreciationPeriod(_) => "invalid_depreciation_period",
|
||||
LedgerError::InvalidSchedule(_) => "invalid_schedule",
|
||||
LedgerError::UntaggedControlLine { .. } => "untagged_control_line",
|
||||
other => return format!("another refusal: {other}"),
|
||||
}
|
||||
.to_string()
|
||||
@@ -787,6 +788,7 @@ impl<'a> Books<'a> {
|
||||
("close", e.close.is_some()),
|
||||
("bill", e.bill.is_some()),
|
||||
("invoice", e.invoice.is_some()),
|
||||
("write_off", e.write_off.is_some()),
|
||||
("run_depreciation", e.run_depreciation.is_some()),
|
||||
("dispose_asset", e.dispose_asset.is_some()),
|
||||
("run_schedules", e.run_schedules.is_some()),
|
||||
@@ -795,7 +797,7 @@ impl<'a> Books<'a> {
|
||||
match kinds.iter().filter(|(_, given)| *given).collect::<Vec<_>>()[..] {
|
||||
[(kind, _)] => kind,
|
||||
_ => panic!(
|
||||
"{} event {n}: an event is one of lines, reverses, close, bill, invoice, run_depreciation, dispose_asset, run_schedules or register_schedule",
|
||||
"{} event {n}: an event is one of lines, reverses, close, bill, invoice, write_off, run_depreciation, dispose_asset, run_schedules or register_schedule",
|
||||
self.ex.id
|
||||
),
|
||||
}
|
||||
@@ -809,11 +811,12 @@ impl<'a> Books<'a> {
|
||||
// disposal is dated; the other commands date their own entries,
|
||||
// and every command describes its own.
|
||||
let own = matches!(kind, "lines" | "reverses" | "close" | "bill" | "invoice");
|
||||
let dated = own || matches!(kind, "dispose_asset" | "write_off");
|
||||
assert_eq!(
|
||||
e.date.is_some(),
|
||||
own || kind == "dispose_asset",
|
||||
dated,
|
||||
"{ex} event {n}: {kind} {}",
|
||||
if own || kind == "dispose_asset" {
|
||||
if dated {
|
||||
"is dated (date)"
|
||||
} else {
|
||||
"dates its own entries: no date"
|
||||
@@ -846,8 +849,16 @@ impl<'a> Books<'a> {
|
||||
"{ex} event {n}: {kind} is tagged with no parties or payment method"
|
||||
);
|
||||
assert!(
|
||||
e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill" | "invoice"),
|
||||
"{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all"
|
||||
e.refused.is_none() || !matches!(kind, "close" | "bill" | "invoice"),
|
||||
"{ex} event {n}: refused is for a reversal, an entry or a command, which the books take whole or not at all"
|
||||
);
|
||||
assert!(
|
||||
!(e.older && e.refused.is_some()),
|
||||
"{ex} event {n}: an entry posted as an earlier version wrote it (older) can't also be refused"
|
||||
);
|
||||
assert!(
|
||||
e.write_off.is_none() || kind == "write_off",
|
||||
"{ex} event {n}: a write_off is its own event"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -957,7 +968,7 @@ impl<'a> Books<'a> {
|
||||
"{what}: entry {id}, which it made, is posted"
|
||||
);
|
||||
}
|
||||
if own && e.refused.is_none() {
|
||||
if (own || kind == "write_off") && e.refused.is_none() {
|
||||
assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry");
|
||||
}
|
||||
}
|
||||
@@ -1023,6 +1034,30 @@ impl<'a> Books<'a> {
|
||||
};
|
||||
let day = || when.expect("checked: dated");
|
||||
match kind {
|
||||
"lines" if e.refused.is_some() => {
|
||||
// The books take an entry whole or not at all, so a
|
||||
// refused one leaves no draft behind: it is tried on a
|
||||
// clone, which is how the engine refuses it.
|
||||
let mut probe = self.store.ledger().clone();
|
||||
let entry = probe.create_entry(day(), description);
|
||||
for (i, line) in e.lines.iter().enumerate() {
|
||||
let side = match line.side.as_str() {
|
||||
"debit" => Side::Debit,
|
||||
"credit" => Side::Credit,
|
||||
other => panic!("{what}: side {other:?}"),
|
||||
};
|
||||
let amount = Money::new(money(&line.amount), self.currency);
|
||||
let account = self.id(&line.account);
|
||||
probe.add_line(entry, account, side, amount)?;
|
||||
if let Some(p) = &line.project {
|
||||
let class = *self.projects.get(p).unwrap_or_else(|| {
|
||||
panic!("{what}: project {p:?} not in setup.projects")
|
||||
});
|
||||
probe.set_line_class(entry, i, Some(class))?;
|
||||
}
|
||||
}
|
||||
Ok(probe.post_entry(entry)?)
|
||||
}
|
||||
"lines" => {
|
||||
let post = |this: &mut Self| -> Result<(), StoreError> {
|
||||
let entry = this.store.create_entry(day(), description)?;
|
||||
@@ -1123,6 +1158,43 @@ impl<'a> Books<'a> {
|
||||
.create_invoice(customer, day(), day(), lines)
|
||||
.map(|_| ())
|
||||
}
|
||||
"write_off" => {
|
||||
let w = e.write_off.as_ref().expect("checked: a write-off");
|
||||
let invoice_entry = self.entry_of(w.invoice, what);
|
||||
let invoice = self
|
||||
.store
|
||||
.ledger()
|
||||
.invoices()
|
||||
.into_iter()
|
||||
.find(|i| i.entry_id == invoice_entry)
|
||||
.map(|i| i.id)
|
||||
.unwrap_or_else(|| panic!("{what}: event {} posted no invoice", w.invoice));
|
||||
let amount = match &w.amount {
|
||||
Some(a) => exact(a, what),
|
||||
None => {
|
||||
self.store
|
||||
.ledger()
|
||||
.invoice_state(invoice, day())
|
||||
.expect("the invoice's figures")
|
||||
.expect("dated by its own day")
|
||||
.outstanding
|
||||
}
|
||||
};
|
||||
self.store
|
||||
.write_off_invoice(
|
||||
invoice,
|
||||
day(),
|
||||
amount,
|
||||
&w.reason,
|
||||
Some(ledger_store::WriteOffEvidence::File {
|
||||
file_name: "collection-letter.pdf",
|
||||
mime_type: "application/pdf",
|
||||
bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n",
|
||||
description: Some("Evidence of worthlessness"),
|
||||
}),
|
||||
)
|
||||
.map(drop)
|
||||
}
|
||||
"run_depreciation" => {
|
||||
let r = e.run_depreciation.as_ref().expect("checked: a run");
|
||||
let asset = self.asset(&r.asset, what);
|
||||
|
||||
@@ -1200,6 +1200,206 @@ fn a_chain_counts_in_order() {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -48,8 +48,9 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The twelve role keys, as the spec lists them (B-23 and `UnknownRole`).
|
||||
const KEYS: [&str; 12] = [
|
||||
/// The thirteen role keys, as the spec lists them (B-23, B-41's
|
||||
/// bad_debts, and `UnknownRole`).
|
||||
const KEYS: [&str; 13] = [
|
||||
"receivables",
|
||||
"revenue",
|
||||
"payables",
|
||||
@@ -62,6 +63,7 @@ const KEYS: [&str; 12] = [
|
||||
"social_security_payable",
|
||||
"medicare_payable",
|
||||
"other_deductions_payable",
|
||||
"bad_debts",
|
||||
];
|
||||
|
||||
fn role(key: &str) -> &'static ControlAccount {
|
||||
@@ -248,8 +250,9 @@ fn alice() -> PayrollLine {
|
||||
|
||||
// ------------------------------------------------------------ the roles
|
||||
|
||||
/// Oracle: B-23 names the roles and their default codes; `control_account`
|
||||
/// finds a role by its `key`; `UnknownRole` lists the twelve keys.
|
||||
/// Oracle: B-23 (and B-41 for bad_debts) names the roles and their default
|
||||
/// codes; `control_account` finds a role by its `key`; `UnknownRole` lists
|
||||
/// the thirteen keys.
|
||||
#[test]
|
||||
fn every_role_has_its_key_default_code_and_type() {
|
||||
use AccountType::*;
|
||||
@@ -258,6 +261,7 @@ fn every_role_has_its_key_default_code_and_type() {
|
||||
("revenue", "4000", Revenue),
|
||||
("payables", "2000", Liability),
|
||||
("general_expense", "6999", Expense),
|
||||
("bad_debts", "6990", Expense),
|
||||
("retained_earnings", "3999", Equity),
|
||||
("salary_expense", "6100", Expense),
|
||||
("payroll_tax_expense", "6110", Expense),
|
||||
|
||||
Reference in new issue
Block a user