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
393 lines
13 KiB
Rust
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));
|
|
}
|