Files
wanderbooks/crates/ledger-gui/tests/invoices.rs
T
swaitsandClaude Fable 5.1 df93d8b951 feat(viewer): the invoice page shows its number, terms, bill-to and PO (P4b S4.5)
The viewer reads the same invoice the server prints. The Money in list and
the page title name an invoice by its issued number (a legacy invoice keeps
"Invoice {id}"); a Details card shows the number, terms, the bill-to as it
was at issue (contact, address, phone, email), Ship-to, the customer's PO
and memo, and a bill's vendor number, day received, terms and memo. Save
invoice PDF still re-creates the current copy; when the issued copy is
filed (found through its link to the invoice, never by name) Save issued
copy and Open issued copy hand over those very bytes, checked against
their SHA-256. Nothing is written to the books.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:55:10 -06:00

528 lines
18 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));
}
/// Books with an invoice issued the way `books.create_invoice` issues it
/// (plan P4b S4.5): numbered, on terms, the bill-to kept as a snapshot
/// with its contact block, the customer's PO and a memo, and its issued
/// PDF filed with it. A second invoice is a legacy one: no number, no
/// issued copy.
fn numbered_books(dir: &TempDir) -> (u64, u64) {
use ledger_core::{PartySnapshot, Terms};
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 Coffee").unwrap();
let fields = InvoiceFields {
number: None,
terms: Some(Terms::parse("Net 30").unwrap()),
bill_to: Some(PartySnapshot {
name: "Harbor Coffee Roasters, Inc.".into(),
address: Some("455 Wharf Street\nPortland, OR 97209".into()),
email: Some("ap@harbor.example".into()),
phone: Some("503-555-0100".into()),
contact: Some("Ana Reyes".into()),
billing_address: None,
shipping_address: Some("Dock 4, Harbor Road".into()),
billing_email: None,
}),
seller: None,
customer_ref: Some("PO-4471".into()),
memo: Some("Thank you for the quick turnaround.".into()),
};
let lines = vec![line("Consulting", 50_000)];
let preview = s
.preview_invoice(
client,
d(2026, 3, 1),
d(2026, 3, 31),
lines.clone(),
fields.clone(),
)
.unwrap();
let fields = InvoiceFields {
number: preview.number.clone(),
..fields
};
let page = ledger_package::issued_invoice_document(
s.ledger(),
&ledger_core::settings::Company::default(),
&preview.invoice,
)
.unwrap();
let pdf = ledger_import::invoice_pdf(&page).unwrap();
let (numbered, _, _) = s
.create_invoice_issued(client, d(2026, 3, 1), d(2026, 3, 31), lines, fields, pdf)
.unwrap();
let legacy = s
.create_invoice(
client,
d(2026, 3, 2),
d(2026, 4, 1),
vec![line("Support", 8_000)],
)
.unwrap();
(numbered, legacy)
}
/// Plan P4b S4.5: the invoice's page shows its number, terms, bill-to (the
/// snapshot at issue, contact block included), ship-to and PO, and offers
/// the issued copy beside the re-created one; the Money in list names it
/// by its number. A legacy invoice keeps "Invoice {id}", shows no details
/// card, and has no issued copy to save.
#[test]
fn a_numbered_invoice_shows_its_number_terms_bill_to_and_po() {
let dir = TempDir::new("numbered");
let (numbered, legacy) = numbered_books(&dir);
let number = {
let books = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(d(2026, 3, 15)),
})
.unwrap();
let inv = books.ledger.get_invoice(numbered).unwrap();
let n = inv.number.clone().expect("the issue numbered it");
// The page's facts are the invoice's own (view_model parity).
let facts = ledger_gui::pages::invoices::details(
&books.ledger,
ledger_core::DocRef::Invoice(numbered),
);
let get = |label: &str| {
facts
.iter()
.find(|(l, _)| *l == label)
.map(|(_, v)| v.clone())
};
assert_eq!(get("Number").as_deref(), Some(n.as_str()));
assert_eq!(get("Terms").as_deref(), Some("Net 30"));
assert_eq!(get("PO").as_deref(), Some("PO-4471"));
assert_eq!(
get("Bill to").as_deref(),
Some(
"Harbor Coffee Roasters, Inc., Ana Reyes, 455 Wharf Street, Portland, OR 97209, 503-555-0100, ap@harbor.example"
)
);
assert_eq!(get("Ship to").as_deref(), Some("Dock 4, Harbor Road"));
assert_eq!(
get("Memo").as_deref(),
Some("Thank you for the quick turnaround.")
);
n
};
let mut h = open(&dir, Route::Invoice(numbered));
shot(&mut h, "invoice-numbered");
assert!(h.query_all_by_label_contains(&number).count() >= 1);
assert!(h.query_by_label("Net 30").is_some());
assert!(h.query_by_label("PO-4471").is_some());
assert!(
h.query_by_label_contains("Harbor Coffee Roasters, Inc., Ana Reyes")
.is_some()
);
assert!(h.query_by_label_contains("Save invoice PDF").is_some());
assert!(h.query_by_label_contains("Save issued copy").is_some());
// The list names it by number; the legacy one keeps its old name.
let h = open(&dir, Route::Receivables);
assert!(h.query_all_by_label_contains(&number).count() >= 1);
assert!(
h.query_all_by_label_contains(&format!("Invoice {legacy}"))
.count()
>= 1
);
// A legacy invoice: no details, and no issued copy to save.
let h = open(&dir, Route::Invoice(legacy));
assert!(h.query_by_label("Details").is_none());
assert!(h.query_by_label_contains("Save invoice PDF").is_some());
assert!(h.query_by_label_contains("Save issued copy").is_none());
}