Files
wanderbooks/crates/ledger-gui/tests/invoices.rs
T
swaits b731d7ed04 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.
2026-10-27 12:44:45 -06:00

266 lines
8.5 KiB
Rust

//! Invoices & Bills in the viewer (plan P4b S2): the list shows each
//! document's figures as the ledger has them (a reversed payment no
//! longer counts), overdue ones say how late, the tiles add up the open
//! ones and tie the list to its account; a document's page shows its
//! lines and its timeline; the overview shows what is owed both ways once
//! the books have documents.
use chrono::NaiveDate;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::{AccountType, Currency, DocumentLine};
use ledger_gui::nav::Route;
use ledger_gui::{App, Source, data};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
let d =
std::env::temp_dir().join(format!("ledger-gui-invoices-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TempDir(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn line(description: &str, cents: i64) -> DocumentLine {
DocumentLine {
description: description.into(),
amount: Decimal::new(cents, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}
}
/// Today is 2026-03-15. Invoice 1 ($500, due Mar 1): $200 paid, then the
/// payment reversed, so it owes $500 and is 14 days late. Invoice 2 ($80,
/// due Apr 30): open. Invoice 3 ($40): paid in full. Bill 1 ($300, due
/// Mar 20): open.
fn books(dir: &TempDir) -> (u64, u64) {
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
for (code, name, t) in [
("1000", "Checking", AccountType::Asset),
("3000", "Capital", AccountType::Equity),
] {
s.add_account(code, name, t, None, false).unwrap();
}
let checking = s.ledger().chart().find_by_code("1000").unwrap().id;
let client = s.add_customer("Harbor Client").unwrap();
let late = s
.create_invoice(
client,
d(2026, 2, 1),
d(2026, 3, 1),
vec![line("Consulting", 50_000)],
)
.unwrap();
s.create_invoice(
client,
d(2026, 2, 5),
d(2026, 4, 30),
vec![line("Support", 8_000)],
)
.unwrap();
let paid = s
.create_invoice(
client,
d(2026, 2, 6),
d(2026, 3, 6),
vec![line("Setup", 4_000)],
)
.unwrap();
let pay = s
.receive_payment(late, d(2026, 2, 10), Decimal::new(20_000, 2), checking)
.unwrap();
s.reverse_entry(pay, d(2026, 2, 12), "the transfer bounced")
.unwrap();
s.receive_payment(paid, d(2026, 2, 20), Decimal::new(4_000, 2), checking)
.unwrap();
let vendor = s.add_vendor("Clay Co").unwrap();
s.create_bill(
vendor,
d(2026, 3, 2),
d(2026, 3, 20),
vec![line("Glaze", 30_000)],
)
.unwrap();
(late, pay)
}
fn open(dir: &TempDir, route: Route) -> Harness<'static, App> {
let books = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(d(2026, 3, 15)),
})
.unwrap();
let mut h = Harness::builder()
.with_size(egui::vec2(1200.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route));
h.run_steps(4);
h
}
/// SHOT_DIR set: the page as drawn, to look at.
fn shot(h: &mut Harness<'static, App>, name: &str) {
if let Ok(out) = std::env::var("SHOT_DIR") {
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
h.render()
.unwrap()
.save(out.join(format!("{name}.png")))
.unwrap();
}
}
#[test]
fn the_list_shows_the_ledgers_figures() {
let dir = TempDir::new("list");
books(&dir);
let mut h = open(&dir, Route::Receivables);
shot(&mut h, "receivables");
// Open: invoices 1 and 2 (3 is paid): $580 owed, $500 of it late.
assert!(h.query_by_label("$580.00").is_some());
assert!(h.query_by_label("$500.00").is_some());
assert!(
h.query_by_label("Invoice 1, Harbor Client, 14 days late, owes 500.00")
.is_some()
);
assert!(
h.query_by_label_contains("Invoice 2, Harbor Client, Open")
.is_some()
);
assert!(h.query_by_label_contains("Invoice 3,").is_none());
assert!(h.query_by_label_contains("with 1200").is_some());
// All shows the paid one too.
h.get_by_label("All").click();
h.run_steps(2);
assert!(
h.query_by_label_contains("Invoice 3, Harbor Client, Paid")
.is_some()
);
let mut h = open(&dir, Route::Payables);
shot(&mut h, "payables");
assert!(
h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00")
.is_some()
);
}
#[test]
fn a_documents_page_shows_its_timeline() {
let dir = TempDir::new("doc");
let (late, pay) = books(&dir);
let mut h = open(&dir, Route::Invoice(late));
shot(&mut h, "invoice");
assert!(
h.query_by_label_contains("Payment (reversed later), entry #")
.is_some()
);
assert!(
h.query_by_label_contains("Payment reversed, entry #")
.is_some()
);
// Received: nothing stands.
assert!(h.query_by_label("$0.00").is_some());
// An invoice can be saved as PDF (a bill can't: it is the vendor's).
assert!(h.query_by_label_contains("Save invoice PDF").is_some());
// A timeline row opens its entry.
h.get_by_label_contains(&format!("Payment (reversed later), entry #{pay},"))
.click();
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Entry(pay));
}
#[test]
fn the_overview_shows_what_is_owed_both_ways() {
let dir = TempDir::new("overview");
books(&dir);
let mut h = open(&dir, Route::Overview);
shot(&mut h, "overview");
assert!(h.query_by_label("Owed to you").is_some());
assert!(h.query_by_label("You owe on bills").is_some());
assert!(h.query_by_label("1 invoice overdue").is_some());
}
#[test]
fn books_without_documents_show_no_overview_tiles_for_them() {
let dir = TempDir::new("none");
Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let h = open(&dir, Route::Overview);
assert!(h.query_by_label("Owed to you").is_none());
let mut h = open(&dir, Route::Receivables);
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));
}