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
@@ -154,9 +154,10 @@ impl DocRef {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the movements later slices write (P4b.6), named here so a tag's kind
|
||||
/// never changes meaning.
|
||||
/// `Origin`, `Payment` and `WriteOff` are written today (S1, S7); the rest
|
||||
/// are movements only a later unit may write (P4b.6), named here so a
|
||||
/// tag's kind never changes meaning. `Adopted` and `RoleMove` are
|
||||
/// deferred (D-49).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Movement {
|
||||
@@ -168,9 +169,9 @@ pub enum Movement {
|
||||
Discount,
|
||||
/// A bad debt written off (S7).
|
||||
WriteOff,
|
||||
/// A legacy hand line adopted as the document (S7).
|
||||
/// A legacy hand line adopted as the document (deferred, D-49).
|
||||
Adopted,
|
||||
/// The balance moved with its control role (S7).
|
||||
/// The balance moved with its control role (deferred, D-49).
|
||||
RoleMove,
|
||||
}
|
||||
|
||||
|
||||
@@ -859,6 +859,21 @@ pub enum LedgerError {
|
||||
/// books' text rule). Plan P4b S5.
|
||||
#[error("{0}")]
|
||||
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.
|
||||
#[error("due date {0} is before document date")]
|
||||
|
||||
@@ -119,6 +119,16 @@ pub const PAYMENT_PROCESSING_FEES: ControlAccount = control(
|
||||
"payment processing fees",
|
||||
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`].
|
||||
pub const RETAINED_EARNINGS: ControlAccount = control(
|
||||
"retained_earnings",
|
||||
@@ -134,6 +144,7 @@ pub fn control_accounts() -> impl Iterator<Item = &'static ControlAccount> {
|
||||
&REVENUE,
|
||||
&PAYABLES,
|
||||
&GENERAL_EXPENSES,
|
||||
&BAD_DEBTS,
|
||||
&RETAINED_EARNINGS,
|
||||
]
|
||||
.into_iter()
|
||||
@@ -6184,6 +6195,14 @@ impl Ledger {
|
||||
/// 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.
|
||||
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 from = date.max(doc_date);
|
||||
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
|
||||
/// invoice 7: reason". It keeps its id (and, later, its number); its
|
||||
/// status as of the void's date on is Void. Plain
|
||||
@@ -7377,6 +7531,40 @@ impl Ledger {
|
||||
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
|
||||
/// stores. See [`Self::replay_invoice_payment`].
|
||||
#[doc(hidden)]
|
||||
|
||||
@@ -77,8 +77,8 @@ pub use instruments::{Instrument, InstrumentId, InstrumentKind};
|
||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use kernel::{EntryFlows, Flow};
|
||||
pub use ledger::{
|
||||
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES, PAYROLL,
|
||||
PURCHASE_DISCOUNTS, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport,
|
||||
BAD_DEBTS, ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES,
|
||||
PAYROLL, PURCHASE_DISCOUNTS, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport,
|
||||
SALES_ALLOWANCES, UnclearedLine, control_account, control_accounts,
|
||||
};
|
||||
pub use money::{Currency, Money};
|
||||
|
||||
Reference in new issue
Block a user