From b7b7f17163c6129f9d675c0b923f0dd43c9615af Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sat, 24 Oct 2026 14:03:18 -0600 Subject: [PATCH] feat(viewer): Invoices & Bills, a document's page, overview tiles (P4b S2) One sidebar item, Invoices & Bills (the owner's choice, 2026-10-24), with Money in (invoices) and Money out (bills) tabs: what is owed and overdue, and the list tied to its receivables or payables account (Agrees, or what to look at); filters Open, Overdue, Paid, Void, All; rows by due date, an overdue one saying how many days late, each named in full for assistive tech. A document's page: total, received or paid, still owed, status; its lines with their projects; its timeline, a payment later reversed marked so, each row opening its entry. The overview adds Owed to you and You owe on bills once the books have documents. Every figure is the document's derived state (rules.md B-38). The sidebar's rows (and the rail's) give way to 22 points, not 24, so 13 links still fit the owner's 1479x519 window unscrolled; the sidebar's tests compute heights from the link count; the 1440x920 golden is re-blessed for the new link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- crates/ledger-gui/src/app.rs | 37 +- crates/ledger-gui/src/nav.rs | 18 +- crates/ledger-gui/src/pages/invoices.rs | 619 ++++++++++++++++++ crates/ledger-gui/src/pages/mod.rs | 5 + crates/ledger-gui/src/pages/overview.rs | 38 +- crates/ledger-gui/src/palette.rs | 10 + crates/ledger-gui/tests/edges.rs | 3 +- .../tests/goldens/sidenav-1440x920.png | Bin 43860 -> 46172 bytes .../tests/goldens/sidenav-1440x920.shapes | 2 +- crates/ledger-gui/tests/invoices.rs | 205 ++++++ crates/ledger-gui/tests/sidenav.rs | 3 +- 11 files changed, 927 insertions(+), 13 deletions(-) create mode 100644 crates/ledger-gui/src/pages/invoices.rs create mode 100644 crates/ledger-gui/tests/invoices.rs diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 3c2600f..8aa63b2 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -24,6 +24,7 @@ pub struct PageState { pub parties: pages::parties::State, pub projects: pages::projects::State, pub documents: pages::documents::State, + pub invoices: pages::invoices::State, pub journal: pages::journal::State, } @@ -1039,7 +1040,9 @@ const SIDEBAR_ROWS: Rows = Rows { n: Section::ALL.len(), row: 32.0, gap: 6.0, - row_min: 24.0, + // 22, not 24, since Invoices & Bills made 13 links (2026-10-24): so + // they still fit the owner's 1479x519 window unscrolled. + row_min: 22.0, head_gap: 18.0, foldable: true, }; @@ -1049,8 +1052,8 @@ const RAIL_ROWS: Rows = Rows { row: 36.0, gap: 6.0, // As low as the sidebar's: at 28 the rail scrolled in the owner's - // window (review, 2026-10-22). - row_min: 24.0, + // window (review, 2026-10-22), and at 24 once there were 13 links. + row_min: 22.0, head_gap: 18.0, foldable: false, }; @@ -1733,18 +1736,36 @@ mod tests { #[test] fn the_sidebar_gives_way_in_order() { + // The links' height at a row and gap, however many sections there + // are (12 until 2026-10-24, when Invoices & Bills made 13). + let links = |row: f32, gap: f32| SIDEBAR_ROWS.height(row, gap); // Card folds first. - let f = fit(18.0 + 450.0 + 8.0 + 147.0, FOOTER, &SIDEBAR_ROWS, 0.5); + let f = fit( + 18.0 + links(32.0, 6.0) + 8.0 + 147.0, + FOOTER, + &SIDEBAR_ROWS, + 0.5, + ); assert!(f.folded && !f.tight && f.row == 32.0 && f.gap == 6.0); // Then the spacing goes, before the rows. - let f = fit(8.0 + 410.0 + 8.0 + 112.0, FOOTER, &SIDEBAR_ROWS, 0.5); + let f = fit( + 8.0 + links(32.0, 4.0) + 8.0 + 112.0, + FOOTER, + &SIDEBAR_ROWS, + 0.5, + ); assert!(f.folded && f.tight && f.row == 32.0 && f.gap < 6.0 && f.gap >= GAP_MIN); // Then the rows, down to their least. - let f = fit(8.0 + 330.0 + 8.0 + 112.0, FOOTER, &SIDEBAR_ROWS, 0.5); - assert!(f.tight && !f.scroll && f.gap == GAP_MIN && f.row < 32.0 && f.row >= 24.0); + let f = fit( + 8.0 + links(28.0, GAP_MIN) + 8.0 + 112.0, + FOOTER, + &SIDEBAR_ROWS, + 0.5, + ); + assert!(f.tight && !f.scroll && f.gap == GAP_MIN && f.row < 32.0 && f.row >= 22.0); // Then they scroll. let f = fit(300.0, FOOTER, &SIDEBAR_ROWS, 0.5); - assert!(f.scroll && f.row == 24.0 && f.gap == GAP_MIN); + assert!(f.scroll && f.row == 22.0 && f.gap == GAP_MIN); } #[test] diff --git a/crates/ledger-gui/src/nav.rs b/crates/ledger-gui/src/nav.rs index ca6e2c3..60f99a6 100644 --- a/crates/ledger-gui/src/nav.rs +++ b/crates/ledger-gui/src/nav.rs @@ -74,6 +74,14 @@ pub enum Route { Projects, /// One project. Project(ledger_core::ClassId), + /// Invoices: what customers owe the company (money in). + Receivables, + /// Bills: what the company owes vendors (money out). + Payables, + /// One invoice. + Invoice(ledger_core::InvoiceId), + /// One bill. + Bill(ledger_core::BillId), Documents, Document(u64), Company, @@ -94,6 +102,9 @@ impl Route { Route::Assets | Route::Asset(_) => Section::Assets, Route::Parties | Route::Party(_) => Section::Parties, Route::Projects | Route::Project(_) => Section::Projects, + Route::Receivables | Route::Payables | Route::Invoice(_) | Route::Bill(_) => { + Section::Invoices + } Route::Documents | Route::Document(_) => Section::Documents, Route::Company => Section::Company, Route::Journal | Route::JournalEvent(_) => Section::Journal, @@ -112,13 +123,14 @@ pub enum Section { Assets, Parties, Projects, + Invoices, Documents, Company, Journal, } impl Section { - pub const ALL: [Section; 12] = [ + pub const ALL: [Section; 13] = [ Section::Overview, Section::Cash, Section::Profit, @@ -128,6 +140,7 @@ impl Section { Section::Assets, Section::Parties, Section::Projects, + Section::Invoices, Section::Documents, Section::Company, Section::Journal, @@ -144,6 +157,7 @@ impl Section { Section::Assets => "Assets", Section::Parties => "Parties", Section::Projects => "Projects", + Section::Invoices => "Invoices & Bills", Section::Documents => "Documents", Section::Company => "Company", Section::Journal => "Journal", @@ -162,6 +176,7 @@ impl Section { Section::Assets => i::PACKAGE, Section::Parties => i::ADDRESS_BOOK, Section::Projects => i::BRIEFCASE, + Section::Invoices => i::RECEIPT, Section::Documents => i::FILES, Section::Company => i::BUILDINGS, Section::Journal => i::SCROLL, @@ -179,6 +194,7 @@ impl Section { Section::Assets => Route::Assets, Section::Parties => Route::Parties, Section::Projects => Route::Projects, + Section::Invoices => Route::Receivables, Section::Documents => Route::Documents, Section::Company => Route::Company, Section::Journal => Route::Journal, diff --git a/crates/ledger-gui/src/pages/invoices.rs b/crates/ledger-gui/src/pages/invoices.rs new file mode 100644 index 0000000..6641428 --- /dev/null +++ b/crates/ledger-gui/src/pages/invoices.rs @@ -0,0 +1,619 @@ +//! Invoices and bills: what customers owe the company (money in) and what +//! it owes vendors (money out), each figure derived from the document's +//! ledger lines that stand (rules.md B-38), the list tied to the +//! receivables or payables account (B-23); and one document's page, with +//! its lines and its timeline. Read only (D-28): the agent issues, pays +//! and voids them. + +use super::assets::row; +use super::page; +use crate::app::Shell; +use crate::books::Books; +use crate::fmt; +use crate::nav::Route; +use crate::theme::pal; +use crate::widgets; +use chrono::NaiveDate; +use egui::Ui; +use egui_phosphor::regular as icon; +use ledger_core::{DocRef, DocState, DocStatus, DocumentLine}; +use rust_decimal::Decimal; + +/// Which documents the list shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Filter { + /// Anything still owed (partly paid and overdue included). + #[default] + Open, + Overdue, + Paid, + Void, + All, +} + +impl Filter { + fn keeps(self, st: &DocState) -> bool { + match self { + Filter::Open => !st.outstanding.is_zero() && st.status != DocStatus::Void, + Filter::Overdue => st.overdue_days > 0, + Filter::Paid => st.status == DocStatus::Paid, + Filter::Void => st.status == DocStatus::Void, + Filter::All => true, + } + } +} + +#[derive(Default)] +pub struct State { + pub filter: Filter, +} + +/// One document as the list shows it. +struct Item { + doc: DocRef, + name: String, + party: String, + date: NaiveDate, + due: NaiveDate, + state: Result, ledger_core::LedgerError>, +} + +/// Every invoice (or bill) in the books, read as of today (one dated +/// ahead, as of its own date: Books::doc_today). +fn items(b: &Books, bills: bool) -> Vec { + let l = &b.ledger; + if bills { + l.bills() + .into_iter() + .map(|d| Item { + doc: DocRef::Bill(d.id), + name: format!("Bill {}", d.id), + party: l + .get_vendor(d.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(), + date: d.date, + due: d.due_date, + state: b.doc_today(DocRef::Bill(d.id)), + }) + .collect() + } else { + l.invoices() + .into_iter() + .map(|d| Item { + doc: DocRef::Invoice(d.id), + name: format!("Invoice {}", d.id), + party: l + .get_customer(d.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(), + date: d.date, + due: d.due_date, + state: b.doc_today(DocRef::Invoice(d.id)), + }) + .collect() + } +} + +/// The overview's figure for invoices (or bills): what the open ones +/// still owe, how many are overdue, and how many fall due within a week; +/// None when the books have none. +pub fn summary(b: &Books, bills: bool) -> Option<(Decimal, usize, usize)> { + let all = items(b, bills); + if all.is_empty() { + return None; + } + let week = b.today + chrono::Duration::days(7); + let (mut owed, mut late, mut soon) = (Decimal::ZERO, 0, 0); + for it in &all { + if let Ok(Some(st)) = &it.state + && Filter::Open.keeps(st) + { + owed += st.outstanding; + if st.overdue_days > 0 { + late += 1; + } else if it.due <= week { + soon += 1; + } + } + } + Some((owed, late, soon)) +} + +fn route_of(doc: DocRef) -> Route { + match doc { + DocRef::Invoice(id) => Route::Invoice(id), + DocRef::Bill(id) => Route::Bill(id), + } +} + +/// A status for people: "12 days late" while overdue, else its label; +/// "Dated ahead" for one dated after today. +fn status_text(st: &DocState, date: NaiveDate, today: NaiveDate) -> String { + if date > today { + "Dated ahead".into() + } else if st.overdue_days > 0 { + format!( + "{} late", + fmt::count(st.overdue_days as usize, "day", "days") + ) + } else { + st.status.label().into() + } +} + +/// Money in (invoices) or money out (bills). +pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "invoices", |ui| { + widgets::page_header( + ui, + "Invoices & Bills", + "What customers owe the company and what it owes vendors, each figure read from the ledger, tied to its account. The bookkeeper issues, records and voids them.", + ); + let mut side = bills; + if widgets::segmented( + ui, + &mut side, + &[(false, "Money in (invoices)"), (true, "Money out (bills)")], + ) { + shell.go(if side { + Route::Payables + } else { + Route::Receivables + }); + } + ui.add_space(10.0); + let all = items(b, bills); + // The tiles count what is owed now, whatever the filter. + let mut owed = Decimal::ZERO; + let mut late = Decimal::ZERO; + let mut late_n = 0usize; + let mut open_n = 0usize; + let mut unknown = false; + for it in &all { + match &it.state { + Ok(Some(st)) if Filter::Open.keeps(st) => { + owed += st.outstanding; + open_n += 1; + if st.overdue_days > 0 { + late += st.outstanding; + late_n += 1; + } + } + Ok(_) => {} + Err(_) => unknown = true, + } + } + let (what, who) = if bills { + ("You owe", "bills") + } else { + ("Owed to you", "invoices") + }; + let tie = tie_out(b, bills); + super::grid(ui, 3, 200.0, 3, |i, ui| { + let (label, value, sub, how) = match i { + 0 => ( + what, + if unknown { + "Can't compute".to_string() + } else { + fmt::dollars(owed) + }, + Some(( + fmt::count( + open_n, + &format!("open {}", &who[..who.len() - 1]), + &format!("open {who}"), + ), + p.muted, + )), + "What the open documents still owe, together, as of today.", + ), + 1 => ( + "Overdue", + fmt::dollars(late), + Some(( + fmt::count(late_n, "past its due date", "past their due date"), + if late_n > 0 { p.warning } else { p.muted }, + )), + "Of that, what is past its due date.", + ), + _ => ( + "Ledger", + tie.0.clone(), + Some((tie.1.clone(), if tie.2 { p.muted } else { p.warning })), + "The receivables (or payables) account against its documents: they agree unless a line on it is no document's.", + ), + }; + widgets::kpi( + ui, + widgets::Kpi { + label, + value, + sub, + spark: None, + how, + }, + ); + }); + ui.add_space(8.0); + let st = &mut shell.state.invoices; + ui.horizontal_wrapped(|ui| { + for (f, label) in [ + (Filter::Open, "Open"), + (Filter::Overdue, "Overdue"), + (Filter::Paid, "Paid"), + (Filter::Void, "Void"), + (Filter::All, "All"), + ] { + if ui.selectable_label(st.filter == f, label).clicked() { + st.filter = f; + } + } + }); + ui.add_space(8.0); + let filter = st.filter; + let mut rows: Vec<&Item> = all + .iter() + .filter(|it| match &it.state { + Ok(Some(s)) => filter.keeps(s), + Ok(None) => filter == Filter::All, + Err(_) => true, + }) + .collect(); + rows.sort_by_key(|it| (it.due, it.date, it.name.clone())); + let mut go = None; + widgets::card(ui, |ui| { + if rows.is_empty() { + let (title, text) = if all.is_empty() { + ( + if bills { "No bills" } else { "No invoices" }, + if bills { + "A vendor's bill appears here once the bookkeeper records it (books.create_bill)." + } else { + "An invoice appears here once the bookkeeper issues it (books.create_invoice)." + }, + ) + } else { + ("Nothing here", "No document matches this filter.") + }; + widgets::empty(ui, icon::RECEIPT, title, text); + return; + } + let amt = widgets::AMOUNT_MIN + 16.0; + let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0; + let w = ui.available_width().max(fixed + 160.0); + widgets::min_width(ui, "invoice_list", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Document", 110.0, false), + head(if bills { "Vendor" } else { "Customer" }, 0.0, false), + head("Date", 104.0, false), + head("Due", 104.0, false), + head("Status", 120.0, false), + head("Total", amt, true), + head("Still owed", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for it in rows { + let (status, total, owed, late) = match &it.state { + Ok(Some(s)) => ( + status_text(s, it.date, b.today), + fmt::money(s.total), + fmt::money(s.outstanding), + s.overdue_days > 0, + ), + Ok(None) => ("Unknown".into(), String::new(), String::new(), false), + Err(_) => ("Can't compute".into(), String::new(), String::new(), false), + }; + let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed); + let resp = row( + ui, + w, + &[ + (it.name.clone(), 110.0, false, p.text), + (it.party.clone(), 0.0, false, p.text), + (fmt::date(it.date), 104.0, false, p.text), + ( + fmt::date(it.due), + 104.0, + false, + if late { p.warning } else { p.text }, + ), + (status, 120.0, false, if late { p.warning } else { p.muted }), + (total, amt, true, p.text), + (owed, amt, true, p.text), + ], + None, + false, + true, + ); + // Named in full, for assistive tech (and the tests). + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name) + }); + if resp.clicked() { + go = Some(route_of(it.doc)); + } + } + }); + }); + if let Some(r) = go { + shell.go(r); + } + }); +} + +/// The tie-out of receivables (or payables) as of today: its value, a +/// line under it, and whether it agrees. +fn tie_out(b: &Books, bills: bool) -> (String, String, bool) { + let role = if bills { "payables" } else { "receivables" }; + match b.ledger.control_check(b.today) { + Err(e) => ("Can't compute".into(), e.to_string(), false), + Ok(checks) => { + let mine: Vec<_> = checks.iter().filter(|c| c.role == role).collect(); + if mine.is_empty() { + return ("Agrees".into(), "No account holds them yet".into(), true); + } + let names: Vec = mine.iter().map(|c| b.account_label(c.account)).collect(); + let loose: usize = mine.iter().map(|c| c.unattached.len()).sum(); + let odd: usize = mine.iter().map(|c| c.attention.len()).sum(); + let off = mine.iter().any(|c| !c.difference.is_zero()); + if off { + ("Differs".into(), "An engine error: report it".into(), false) + } else if loose > 0 || odd > 0 { + let mut why = Vec::new(); + if loose > 0 { + why.push(fmt::count( + loose, + "line no document's", + "lines no document's", + )); + } + if odd > 0 { + why.push(fmt::count( + odd, + "document to look at", + "documents to look at", + )); + } + ("Look at".into(), why.join(", "), false) + } else { + ("Agrees".into(), format!("with {}", names.join(", ")), true) + } + } + } +} + +/// One invoice or bill: its figures, lines and timeline. +pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let l = &b.ledger; + let (name, party, date, due, entry, lines): ( + String, + String, + NaiveDate, + NaiveDate, + u64, + Vec, + ) = match doc { + DocRef::Invoice(id) => match l.get_invoice(id) { + Some(d) => ( + format!("Invoice {id}"), + l.get_customer(d.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(), + d.date, + d.due_date, + d.entry_id, + d.lines.clone(), + ), + None => return missing(ui, "No such invoice"), + }, + DocRef::Bill(id) => match l.get_bill(id) { + Some(d) => ( + format!("Bill {id}"), + l.get_vendor(d.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(), + d.date, + d.due_date, + d.entry_id, + d.lines.clone(), + ), + None => return missing(ui, "No such bill"), + }, + }; + let mut go = None; + page(ui, "invoice", |ui| { + widgets::page_header( + ui, + &name, + &format!( + "{party} · dated {} · due {}", + fmt::date(date), + fmt::date(due) + ), + ); + let st = match b.doc_today(doc) { + Ok(Some(st)) => st, + Ok(None) => return missing(ui, "Not in the books as of today"), + Err(e) => return missing(ui, &format!("Its figures can't be computed: {e}")), + }; + let status = status_text(&st, date, b.today); + super::grid(ui, 4, 180.0, 4, |i, ui| { + let (label, value, how) = match i { + 0 => ("Total", fmt::dollars(st.total), "The sum of its lines."), + 1 => ( + if matches!(doc, DocRef::Invoice(_)) { + "Received" + } else { + "Paid" + }, + fmt::dollars(st.paid), + "Payments against it that still stand: a reversed payment no longer counts.", + ), + 2 => ( + "Still owed", + fmt::dollars(st.outstanding), + "Its total less what stands against it.", + ), + _ => ("Status", status.clone(), "As of today."), + }; + widgets::kpi( + ui, + widgets::Kpi { + label, + value, + sub: None, + spark: None, + how, + }, + ); + }); + ui.add_space(8.0); + let amt = widgets::AMOUNT_MIN + 16.0; + widgets::titled_card( + ui, + "Lines", + |_| {}, + |ui| { + let w = ui.available_width().max(160.0 + 200.0 + amt); + widgets::min_width(ui, "invoice_lines", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Description", 0.0, false), + head("Project", 200.0, false), + head("Amount", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for line in &lines { + let project = line + .class_id + .and_then(|c| l.get_class(c)) + .map(|c| c.name.clone()) + .unwrap_or_default(); + let resp = row( + ui, + w, + &[ + (line.description.clone(), 0.0, false, p.text), + (project, 200.0, false, p.muted), + (fmt::money(line.amount), amt, true, p.text), + ], + None, + false, + line.class_id.is_some(), + ); + if let Some(c) = line.class_id + && resp.clicked() + { + go = Some(Route::Project(c)); + } + } + }); + }, + ); + ui.add_space(8.0); + widgets::titled_card( + ui, + "Timeline", + |_| {}, + |ui| { + let w = ui.available_width().max(104.0 + 220.0 + 90.0 + amt); + widgets::min_width(ui, "invoice_timeline", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Date", 104.0, false), + head("What", 0.0, false), + head("Entry", 90.0, false), + head("Amount", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for t in l.doc_lines(doc) { + let what = match (t.movement, t.reversal) { + (ledger_core::Movement::Origin, false) => "Issued", + (ledger_core::Movement::Origin, true) => "Voided", + (ledger_core::Movement::Payment, false) => "Payment", + (ledger_core::Movement::Payment, true) => "Payment reversed", + (ledger_core::Movement::Discount, _) => "Discount", + (ledger_core::Movement::WriteOff, _) => "Written off", + (ledger_core::Movement::Adopted, _) => "Adopted", + (ledger_core::Movement::RoleMove, _) => "Moved with its account", + }; + // A payment later reversed reads as such. + let what = if !t.reversal && !l.stands_on(t.entry, b.today) { + format!("{what} (reversed later)") + } else { + what.to_string() + }; + let name = format!( + "{}: {what}, entry #{}, {}", + fmt::date(t.date), + t.entry, + fmt::money(t.amount) + ); + let resp = row( + ui, + w, + &[ + (fmt::date(t.date), 104.0, false, p.text), + (what, 0.0, false, p.text), + (format!("#{}", t.entry), 90.0, false, p.muted), + (fmt::money(t.amount), amt, true, p.text), + ], + None, + false, + true, + ); + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name) + }); + if resp.on_hover_text("Open its entry").clicked() { + go = Some(Route::Entry(t.entry)); + } + } + }); + }, + ); + ui.add_space(8.0); + if ui + .link(format!( + "{} Open the entry that recorded it (#{entry})", + icon::ARROW_SQUARE_OUT + )) + .clicked() + { + go = Some(Route::Entry(entry)); + } + }); + if let Some(r) = go { + shell.go(r); + } +} + +fn missing(ui: &mut Ui, title: &str) { + widgets::card(ui, |ui| widgets::empty(ui, icon::RECEIPT, title, "")); +} diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs index ca18e4d..5068ab9 100644 --- a/crates/ledger-gui/src/pages/mod.rs +++ b/crates/ledger-gui/src/pages/mod.rs @@ -7,6 +7,7 @@ pub mod cash; pub mod company; pub mod documents; pub mod entry; +pub mod invoices; pub mod journal; pub mod overview; pub mod parties; @@ -64,6 +65,10 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) { Route::Party(id) => parties::show_party(ui, b, *id, shell), Route::Projects => projects::show_list(ui, b, shell), Route::Project(id) => projects::show_project(ui, b, *id, shell), + Route::Receivables => invoices::show_list(ui, b, false, shell), + Route::Payables => invoices::show_list(ui, b, true, shell), + Route::Invoice(id) => invoices::show_doc(ui, b, ledger_core::DocRef::Invoice(*id), shell), + Route::Bill(id) => invoices::show_doc(ui, b, ledger_core::DocRef::Bill(*id), shell), Route::Documents => documents::show(ui, b, None, shell), Route::Document(id) => documents::show(ui, b, Some(*id), shell), Route::Company => company::show(ui, b, shell), diff --git a/crates/ledger-gui/src/pages/overview.rs b/crates/ledger-gui/src/pages/overview.rs index c0ab1ee..7754ce7 100644 --- a/crates/ledger-gui/src/pages/overview.rs +++ b/crates/ledger-gui/src/pages/overview.rs @@ -148,7 +148,7 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ), }; let ytd = b.fiscal_ytd(); - let tiles: Vec<(Kpi, Route)> = vec![ + let mut tiles: Vec<(Kpi, Route)> = vec![ ( Kpi { label: "Cash on hand", @@ -222,6 +222,42 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { Route::Cash, ), ]; + // Invoices and bills, once the books have any (P4b S2). + if let Some((owed, late, _)) = super::invoices::summary(b, false) { + tiles.push(( + Kpi { + label: "Owed to you", + value: fmt::dollars(owed), + sub: Some(if late > 0 { + ( + fmt::count(late, "invoice overdue", "invoices overdue"), + p.warning, + ) + } else { + ("none overdue".into(), p.muted) + }), + spark: None, + how: "What customers' open invoices still owe, read from the ledger.", + }, + Route::Receivables, + )); + } + if let Some((owed, late, soon)) = super::invoices::summary(b, true) { + tiles.push(( + Kpi { + label: "You owe on bills", + value: fmt::dollars(owed), + sub: Some(if late > 0 { + (fmt::count(late, "bill overdue", "bills overdue"), p.warning) + } else { + (format!("{} due this week", soon), p.muted) + }), + spark: None, + how: "What vendors' open bills still owe, read from the ledger.", + }, + Route::Payables, + )); + } super::grid(ui, tiles.len(), 150.0, 6, |i, ui| { let (tile, route) = &tiles[i]; let r = widgets::kpi( diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index a693232..bd7bfab 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -533,6 +533,16 @@ pub fn describe(b: &Books, r: &Route) -> String { .map(|a| a.file_name.clone()) .unwrap_or_default(), Route::Asset(id) => b.asset_label(*id), + Route::Invoice(id) => b + .ledger + .get_invoice(*id) + .map(|_| format!("Invoice {id}")) + .unwrap_or_default(), + Route::Bill(id) => b + .ledger + .get_bill(*id) + .map(|_| format!("Bill {id}")) + .unwrap_or_default(), Route::Party(id) => b.party_label(*id), Route::Project(id) => b.project_label(*id), Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)), diff --git a/crates/ledger-gui/tests/edges.rs b/crates/ledger-gui/tests/edges.rs index e039066..c7bf604 100644 --- a/crates/ledger-gui/tests/edges.rs +++ b/crates/ledger-gui/tests/edges.rs @@ -352,7 +352,8 @@ fn a_very_short_window_keeps_one_link() { h.run_steps(4); let first = sidebar_link(&h, "Overview"); assert!( - first.height() >= 23.0 && first.bottom() <= h_px, + // 22: the rows' least since there are 13 links (2026-10-24). + first.height() >= 22.0 && first.bottom() <= h_px, "{tag}: Overview at {first:?}" ); if let Some(status) = h.query_by_label_contains("event ") { diff --git a/crates/ledger-gui/tests/goldens/sidenav-1440x920.png b/crates/ledger-gui/tests/goldens/sidenav-1440x920.png index c8c2063b0d668c4ed9db92fb607cd380ca9f77ed..ae05c578d7ce4d9743f9c02bfabb930102e216d3 100644 GIT binary patch delta 21624 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 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, + } +} + +/// 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()); + // 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()); +} diff --git a/crates/ledger-gui/tests/sidenav.rs b/crates/ledger-gui/tests/sidenav.rs index f51f6fd..76193dd 100644 --- a/crates/ledger-gui/tests/sidenav.rs +++ b/crates/ledger-gui/tests/sidenav.rs @@ -423,7 +423,8 @@ fn at_1479x519_every_link_shows_unscrolled() { "{footer:?}: {} at {r:?} isn't whole in {view:?}", s.title() ); - assert!(r.height() >= 24.0, "{footer:?}: {} too tight", s.title()); + // 22: the rows' least since there are 13 links (2026-10-24). + assert!(r.height() >= 22.0, "{footer:?}: {} too tight", s.title()); } assert_clear(&h, Pane::Sidebar, &format!("{footer:?}")); // Nothing moves when the last link is asked into view.