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 c8c2063..ae05c57 100644 Binary files a/crates/ledger-gui/tests/goldens/sidenav-1440x920.png and b/crates/ledger-gui/tests/goldens/sidenav-1440x920.png differ diff --git a/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes b/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes index dfa893b..cb1dc9c 100644 --- a/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes +++ b/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes @@ -1 +1 @@ -05381e0e275497fa +5ccd68030ec4a54b diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs new file mode 100644 index 0000000..50d12fb --- /dev/null +++ b/crates/ledger-gui/tests/invoices.rs @@ -0,0 +1,205 @@ +//! 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, + } +} + +/// 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.