feat(arap): a write-off on the timeline, and the CPA's note (P4b S7)
The viewer's invoice page already drew a WriteOff movement; now the fixture proves it: a written-off invoice reads Written off, owes nothing, and its timeline's "Written off" row opens the entry (the screenshot invoice-written-off). Needs attention gains the one item the tax rule asks for: a write-off made this calendar year is listed, one per invoice, as "Write-off awaiting the CPA's note", with what was written off, the day, and the prompt that opens the document. Whether a debt is worthless, and in which year, is a tax judgment that stays with the CPA (plan 8.3.16); the books flag it and keep the evidence beside it until the year ends. The item is computed in ledger-package's one attention function, so the viewer and books.start see it alike; view_model.rs compares the two reads on the same books. The handbook's invoices-and-bills section starts here (S4.5 adds the issue, terms and payment steps around it): step 4 says when to void, to write off, or to reverse, and the recovery procedures D-49 records — the same year, reverse the write-off and record the payment; a later year, a plain entry to a bad-debt-recoveries revenue account, the invoice staying written off (IRC §111). Tests: a_write_off_shows_on_the_timeline, a_write_off_is_flagged_for_the_cpa.
This commit is contained in:
1 parent
0f1c7786ab
commit
b731d7ed04
5 files changed
+294
-1
No files matched your search
@@ -208,3 +208,58 @@ fn books_without_documents_show_no_overview_tiles_for_them() {
|
||||
shot(&mut h, "receivables-empty");
|
||||
assert!(h.query_by_label("No invoices").is_some());
|
||||
}
|
||||
|
||||
/// 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,93 @@ fn all_dates_holds_an_entry_dated_after_today() {
|
||||
assert_eq!(RegisterRange::All.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());
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# Invoices and bills
|
||||
|
||||
An invoice is money owed to you (Accounts Receivable, 1200); a bill is money
|
||||
you owe (Accounts Payable, 2000). Each is a document whose figures come from
|
||||
the ledger lines tagged with it: what is paid, discounted, written off and
|
||||
still owed is read from those lines, never kept beside them, so a reversal,
|
||||
a void or a bounced payment is right by construction. `books.documents`
|
||||
lists them, `books.document` shows one with its timeline, and
|
||||
`books.control_check` proves the control accounts equal their documents.
|
||||
|
||||
## Step 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).
|
||||
|
||||
## 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,6 +73,13 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
||||
"revenue-and-payouts",
|
||||
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")),
|
||||
(
|
||||
"parties-and-tins",
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
//! books.set_entry_instrument; a year whose worksheet can't be made
|
||||
//! for want of named cash accounts while vendors were paid in it
|
||||
//! (Todo); overdue bills, and overdue
|
||||
//! invoices (Problem, one each, with the total); money owed to
|
||||
//! 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
|
||||
//! from the journal or unreadable, and damaged or missing stored
|
||||
//! files (Problem); depreciation due through the last finished month
|
||||
@@ -717,6 +720,63 @@ 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| {
|
||||
a.account_type == AccountType::Liability && {
|
||||
let n = a.name.to_lowercase();
|
||||
|
||||
Reference in new issue
Block a user