diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index f51ad3c..d0d1b3f 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -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)); +} diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index f184b07..0781605 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -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::>())); + 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::>())); + 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()); +} diff --git a/crates/ledger-mcp/handbook/invoices-and-bills.md b/crates/ledger-mcp/handbook/invoices-and-bills.md new file mode 100644 index 0000000..9243419 --- /dev/null +++ b/crates/ledger-mcp/handbook/invoices-and-bills.md @@ -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. diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index beb69b2..cb1849f 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -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", diff --git a/crates/ledger-package/src/attention.rs b/crates/ledger-package/src/attention.rs index ab7f33f..9a0463a 100644 --- a/crates/ledger-package/src/attention.rs +++ b/crates/ledger-package/src/attention.rs @@ -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, 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 + .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();