Files
wanderbooks/crates/ledger-gui/src/pages/invoices.rs
T
swaitsandClaude Opus 5.5 c1f9001cfe
check / check (push) Canceled after 0s
feat: the invoice PDF, with stamps and each payment (P4b S3)
ledger-import invoice_pdf (templates/invoice.typ): seller, bill-to,
number, dates, terms, lines, total, each settled line and the balance
due; money formatted in Rust (the template does no arithmetic), text as
data, deterministic bytes. Stamps as a rubber stamp would mark it, in
red: PAID (with the day it was paid in full: the invoice is then the
customer's receipt), PARTIALLY PAID, PAST DUE, VOID; DRAFT in gray for
S4's preview. ledger-package invoice_document builds it from the books
as of a day: the derived figures (B-38), each payment that stands on its
own line with its date, payment method and reference; re-created copies
say so (the issued copy is kept from S4 on and never changes).
books.document pdf hands it over as a download; the viewer's invoice
page saves or opens it. The owner asked for the paid stamp with the
payment's date and details (2026-10-24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 18:20:55 -06:00

669 lines
24 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,
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,
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<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,
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, ""));
}