Files
wanderbooks/crates/ledger-gui/src/pages/invoices.rs
T
swaitsandClaude Fable 5.1 400d0cdcab feat(pdf): the issued invoice carries what the customer pays from (P4b S4.5)
The page a customer pays from must show what the books issued, and only
that. InvoiceDoc gains each line's quantity and unit price ("3 x $33.33"
beside the line's own amount, never recomputed), the customer's PO, memo,
footer, the ways to pay (a QR code through labels::qr_svg for a method
with a link that asks for one), the seller's email and website, and a
Ship-to block. InvoiceVersion separates the issued copy (no stamp, no
settled rows, no "re-created" note) from the current copy, whose
"Re-created on" note prints the as_of it was given, never the wall clock,
so the bytes stay deterministic (B-40 item 3). Every amount reaches the
page through report::amount_parts; every string is data.

ledger_package builds all three copies (issued, DRAFT, current) from one
function over the invoice's own snapshots (A-13, D-55: number, terms,
bill-to contact block, shipping address), falling back to the party record
only for a document made before snapshots, so the three cannot disagree.
invoice_document drops its now-unused `today` argument.

rules.md gains B-39 (terms) and B-40 (numbers and the issued copy) from
the 8.3.13 spec blocks, CONVENTION until their sources are vendored.
No golden moved: the render test never held a hash, it compares two
renders for equal bytes, and still does.

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

689 lines
25 KiB
Rust

//! 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,
terms: Option<ledger_core::Terms>,
state: Result<Option<DocState>, 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<Item> {
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,
terms: d.terms.clone(),
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,
terms: d.terms.clone(),
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 + if bills { 104.0 } else { 0.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);
let mut header = vec![
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),
];
if bills {
header.push(head("Discount by", 104.0, false));
}
row(ui, w, &header, 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 discount_by = it.terms.as_ref().and_then(|t| t.discount_by(it.date));
let discount_open = bills && discount_by.is_some_and(|d| d >= b.today);
let discount_soon = discount_open
&& discount_by.is_some_and(|d| d <= b.today + chrono::Days::new(3));
let due_soon = bills
&& matches!(&it.state, Ok(Some(s))
if !s.outstanding.is_zero() && s.status != DocStatus::Void)
&& it.due <= b.today + chrono::Days::new(7);
let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed);
let mut cells = vec![
(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 || due_soon { p.warning } else { p.text },
),
(
status.clone(),
120.0,
false,
if late { p.warning } else { p.muted },
),
(total, amt, true, p.text),
(owed, amt, true, p.text),
];
if bills {
cells.push((
discount_by.map(fmt::date).unwrap_or_default(),
104.0,
false,
if discount_soon {
p.warning
} else if discount_open {
p.text
} else {
p.muted
},
));
}
let resp = row(ui, w, &cells, 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<String> = 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<DocumentLine>,
) = 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 let DocRef::Invoice(id) = doc {
pdf_actions(ui, b, id);
ui.add_space(4.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);
}
}
/// "Save invoice PDF" and "Open": the invoice as of today, re-created
/// from the books (ledger_package::invoice_document, the same data the
/// server prints), saved in Downloads. Writes nothing to the books.
fn pdf_actions(ui: &mut Ui, b: &Books, id: ledger_core::InvoiceId) {
let p = pal(ui.ctx());
let said = ui.id().with(("invoice-pdf-said", id));
ui.horizontal(|ui| {
let save = ui
.button(format!("{} Save invoice PDF", icon::FILE_PDF))
.on_hover_text("As of today, in your Downloads folder");
let open = ui
.button(format!("{} Open", icon::ARROW_SQUARE_OUT))
.on_hover_text("As of today, in your Downloads folder, opened with its app");
if save.clicked() || open.clicked() {
let result = ledger_package::invoice_document(
&b.ledger,
&b.settings.company,
id,
b.today,
)
.and_then(|doc| {
ledger_import::invoice_pdf::invoice_pdf(&doc).map_err(|e| e.to_string())
})
.and_then(|bytes| {
let stem = format!("invoice-{id}");
if open.clicked() {
crate::export::save_and_open(&stem, "pdf", &bytes)
} else {
crate::export::save(&stem, "pdf", &bytes)
}
})
.map(|path| path.display().to_string());
ui.data_mut(|d| d.insert_temp(said, result));
}
});
if let Some(result) = ui.data(|d| d.get_temp::<Result<String, String>>(said)) {
let (text, color) = match result {
Ok(path) => (format!("Saved {path}"), p.muted),
Err(why) => (format!("Couldn't save it: {why}"), p.warning),
};
ui.add(egui::Label::new(egui::RichText::new(text).size(12.0).color(color)).wrap());
}
}
fn missing(ui: &mut Ui, title: &str) {
widgets::card(ui, |ui| widgets::empty(ui, icon::RECEIPT, title, ""));
}