Files
wanderbooks/crates/ledger-gui/tests/invoices.rs
T
swaitsandClaude Fable 5.1 335a3eaa7e 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
2026-10-28 21:47:33 -06:00

393 lines
13 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::arap::{InvoiceFields, SendKind, Sent, SentVia};
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");
// The Money out list carries the discount-deadline column (blank
// until terms are journaled, S4.3).
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());
}
/// Books with one issued invoice (a number, so the sent-tracking items
/// apply) and a checking account, for the attention items (plan P4b S5).
fn issued_invoice(dir: &TempDir, due: NaiveDate) -> u64 {
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let client = s.add_customer("Harbor Client").unwrap();
s.create_invoice_with(
client,
d(2026, 2, 1),
due,
vec![line("Consulting", 50_000)],
InvoiceFields {
number: Some("INV-0001".into()),
..Default::default()
},
)
.unwrap()
}
/// A send as these tests record one.
fn sent(on: NaiveDate) -> Sent {
Sent {
on,
by: "agent".into(),
via: SentVia::Email,
to: vec!["billing@harbor.example".into()],
kind: SendKind::Original,
reference: None,
note: None,
}
}
fn attention_titles(dir: &TempDir) -> Vec<String> {
data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(d(2026, 3, 15)),
})
.unwrap()
.attention
.into_iter()
.map(|a| a.title)
.collect()
}
/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent, past
/// `invoicing.unsent_days` (2 by default), is a to-do that opens the
/// invoice; recording a send clears it. The overview draws it (the
/// screenshot is the R-F pattern's).
#[test]
fn an_unsent_invoice_is_listed_until_a_send_clears_it() {
let dir = TempDir::new("attention-unsent");
let id = issued_invoice(&dir, d(2026, 4, 30));
let titles = attention_titles(&dir);
assert!(
titles
.iter()
.any(|t| t == "Invoice INV-0001 was never sent"),
"{titles:?}"
);
let mut h = open(&dir, Route::Overview);
assert!(
h.query_by_label_contains("Invoice INV-0001 was never sent")
.is_some()
);
shot(&mut h, "attention-unsent-invoice");
let mut s = Store::open(&dir.0.join("books-data")).unwrap();
s.mark_sent(id, sent(d(2026, 2, 10))).unwrap();
drop(s);
let titles = attention_titles(&dir);
assert!(
!titles.iter().any(|t| t.contains("never sent")),
"a send clears the item: {titles:?}"
);
}
/// The legacy case: an invoice with no number (issued before
/// `documents_v2`) is exempt from "never sent", however old.
#[test]
fn a_legacy_invoice_with_no_number_is_never_listed_as_unsent() {
let dir = TempDir::new("attention-legacy");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let client = s.add_customer("Harbor Client").unwrap();
s.create_invoice(
client,
d(2026, 1, 5),
d(2026, 4, 30),
vec![line("Consulting", 50_000)],
)
.unwrap();
drop(s);
let titles = attention_titles(&dir);
assert!(
!titles.iter().any(|t| t.contains("never sent")),
"{titles:?}"
);
}
/// An overdue invoice whose last send is older than
/// `invoicing.reminder_days` (14 by default) asks for a reminder, on the
/// invoice's page.
#[test]
fn an_overdue_invoice_with_an_old_send_is_asked_to_remind() {
let dir = TempDir::new("attention-remind");
let id = issued_invoice(&dir, d(2026, 3, 1));
let mut s = Store::open(&dir.0.join("books-data")).unwrap();
s.mark_sent(id, sent(d(2026, 2, 20))).unwrap();
drop(s);
let books = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(d(2026, 3, 15)),
})
.unwrap();
let item = books
.attention
.iter()
.find(|a| a.title.contains("remind the customer"))
.unwrap_or_else(|| panic!("no reminder item: {:?}", attention_titles(&dir)));
assert_eq!(item.target, Some(Route::Invoice(id)));
assert!(item.detail.contains("14 days overdue"), "{}", item.detail);
assert!(item.prompt.contains("books.mark_sent"), "{}", item.prompt);
}
/// 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));
}