merge: P4b S7, bad debts (branch S7, 4b4fecb9)

The fourth of the paused branches: Ledger::write_off and the bad_debts
control role, invoice.written_off with its evidence filed in the same
store call, books.write_off_invoice, the write-off on the timeline and
in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the
bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and
S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with
their seams restored, and: the scope table at 125; S7's law numbered 37;
B-41 placed before B-42; the handbook's step 4 and recovery procedures
folded into the one invoices-and-bills section; S7's wire tests in their
own module with the confirm S4.4's contract needs; S7's copy of the
payment steps dropped (S6 replaced them with Ledger::settle) and its
write-off journaled through S6's journal_auto_lines; in the old-books
fixture S7's hand-written write-off moved to a second invoice, since
S6's hand-written payment settles invoice 1 in full.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:47:33 -06:00
commit 335a3eaa7e
47 files changed
+2883 -74

No files matched your search

+6 -5
View File
@@ -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,
}
+15
View File
@@ -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")]
+188
View File
@@ -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)]
+2 -2
View File
@@ -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};
+78 -6
View File
@@ -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);
+200
View File
@@ -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
// ---------------------------------------------------------------------------
+8 -4
View File
@@ -48,8 +48,9 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
}
}
/// The twelve role keys, as the spec lists them (B-23 and `UnknownRole`).
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),