feat(arap): write off a bad debt in the engine (P4b S7)
An invoice that will not be collected comes off the receivables and is deducted, the direct write-off method the owner chose (A-5; IRC §166(a); Treas. Reg. §1.166-1(e), -2(a), -3(a); rules.md B-41). Ledger::write_off posts one balanced entry, Dr 6990 Bad Debt Expense / Cr 1200 Accounts Receivable, its receivable line tagged WriteOff so the figures stay derived: what the invoice owes falls and what was written off rises, and a reversal re-instates it without a new movement (D-49). The amount is at most what is owed, and at most the least owed on any day from the write-off's on: a later payment can't leave the invoice owing less than nothing (B-38 INVARIANT 2). A partial write-off is allowed, as §1.166-3(a) allows to the extent charged off. Cash-basis books refuse it: an invoice never counted as income has no basis to deduct (§1.166-1(e)). The bad_debts role (6990) is a control role, so the account is created on demand and can be assigned like the others. The same-year recovery D-49 records is a reversal of the write-off and then a payment; a later year is a plain entry, and needs no movement. Tests: 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.
This commit is contained in:
1 parent
ed637f0e27
commit
d5af44b9cb
4 files changed
+410
-5
No files matched your search
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -778,6 +778,22 @@ pub enum LedgerError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
InvalidVoid(String),
|
InvalidVoid(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")]
|
||||||
DueBeforeDate(String),
|
DueBeforeDate(String),
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ pub const GENERAL_EXPENSES: ControlAccount = control(
|
|||||||
"bill expense",
|
"bill expense",
|
||||||
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",
|
||||||
@@ -106,6 +116,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()
|
||||||
@@ -6027,6 +6038,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);
|
||||||
@@ -6300,6 +6319,141 @@ impl Ledger {
|
|||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 (direct write-off), A-6; 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
|
||||||
@@ -6810,6 +6964,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)]
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user