feat(viewer): the invoice page shows its number, terms, bill-to and PO (P4b S4.5)

The viewer reads the same invoice the server prints. The Money in list and
the page title name an invoice by its issued number (a legacy invoice keeps
"Invoice {id}"); a Details card shows the number, terms, the bill-to as it
was at issue (contact, address, phone, email), Ship-to, the customer's PO
and memo, and a bill's vendor number, day received, terms and memo. Save
invoice PDF still re-creates the current copy; when the issued copy is
filed (found through its link to the invoice, never by name) Save issued
copy and Open issued copy hand over those very bytes, checked against
their SHA-256. Nothing is written to the books.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 12:55:10 -06:00
1 parent 400d0cdcab
commit df93d8b951
3 files changed
+349 -22

No files matched your search

+153 -22
View File
@@ -59,6 +59,94 @@ struct Item {
state: Result<Option<DocState>, ledger_core::LedgerError>,
}
/// What an invoice is called: its number, else "Invoice {id}" (a document
/// made before numbers has none, A-2).
fn invoice_name(d: &ledger_core::Invoice) -> String {
d.number
.clone()
.unwrap_or_else(|| format!("Invoice {}", d.id))
}
/// A document's facts beyond its figures, as label and value, in the
/// order the page shows them (plan P4b S4.5): an invoice's number, terms,
/// bill-to (the snapshot at issue), ship-to, the customer's PO and memo; a
/// bill's vendor number, received day, terms and memo. Only what the
/// document has.
pub fn details(l: &ledger_core::Ledger, doc: DocRef) -> Vec<(&'static str, String)> {
let one_line = |s: &str| {
s.lines()
.map(str::trim)
.filter(|x| !x.is_empty())
.collect::<Vec<_>>()
.join(", ")
};
let mut out: Vec<(&'static str, String)> = Vec::new();
match doc {
DocRef::Invoice(id) => {
let Some(inv) = l.get_invoice(id) else {
return out;
};
if let Some(n) = &inv.number {
out.push(("Number", n.clone()));
}
if let Some(t) = &inv.terms {
out.push(("Terms", t.describe()));
}
if let Some(b) = &inv.bill_to {
let mut parts = vec![b.name.clone()];
for x in [&b.contact, &b.address, &b.phone, &b.email]
.into_iter()
.flatten()
{
let x = one_line(x);
if !x.is_empty() {
parts.push(x);
}
}
out.push(("Bill to", parts.join(", ")));
if let Some(ship) = &b.shipping_address {
out.push(("Ship to", one_line(ship)));
}
}
if let Some(r) = &inv.customer_ref {
out.push(("PO", r.clone()));
}
if let Some(m) = &inv.memo {
out.push(("Memo", m.clone()));
}
}
DocRef::Bill(id) => {
let Some(b) = l.get_bill(id) else {
return out;
};
if let Some(n) = &b.vendor_ref {
out.push(("Vendor's number", n.clone()));
}
if let Some(d) = b.received_on {
out.push(("Received", fmt::date(d)));
}
if let Some(t) = &b.terms {
out.push(("Terms", t.describe()));
}
if let Some(m) = &b.memo {
out.push(("Memo", m.clone()));
}
}
}
out
}
/// The copy of invoice `id` that was issued, when one is filed: found
/// through its link to the invoice (a PDF described "Issued invoice"),
/// never by guessing at names. The earliest, should there be several.
fn issued_copy(b: &Books, id: ledger_core::InvoiceId) -> Option<&ledger_store::Attachment> {
b.documents_for(&[ledger_store::Subject::Invoice(id)])
.into_iter()
.find(|a| {
a.mime_type == "application/pdf" && a.description.as_deref() == Some("Issued invoice")
})
}
/// 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> {
@@ -84,7 +172,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
.into_iter()
.map(|d| Item {
doc: DocRef::Invoice(d.id),
name: format!("Invoice {}", d.id),
name: invoice_name(d),
party: l
.get_customer(d.customer_id)
.map(|c| c.name.clone())
@@ -428,7 +516,7 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) {
) = match doc {
DocRef::Invoice(id) => match l.get_invoice(id) {
Some(d) => (
format!("Invoice {id}"),
invoice_name(d),
l.get_customer(d.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default(),
@@ -501,6 +589,27 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) {
);
});
ui.add_space(8.0);
let facts = details(l, doc);
if !facts.is_empty() {
widgets::titled_card(
ui,
"Details",
|_| {},
|ui| {
egui::Grid::new("invoice_details")
.num_columns(2)
.spacing([16.0, 4.0])
.show(ui, |ui| {
for (label, value) in &facts {
ui.label(egui::RichText::new(*label).color(p.muted));
ui.add(egui::Label::new(value.as_str()).wrap());
ui.end_row();
}
});
},
);
ui.add_space(8.0);
}
let amt = widgets::AMOUNT_MIN + 16.0;
widgets::titled_card(
ui,
@@ -641,38 +750,60 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) {
/// "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.
/// server prints), saved in Downloads; and, when the invoice has its
/// issued copy filed, "Save issued copy" and "Open issued copy", the very
/// bytes the customer got (read from the blob store, checked against its
/// SHA-256). 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));
let issued = issued_copy(b, 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");
.on_hover_text("As of today, re-created from the books, 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());
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(a) = issued {
let save = ui
.button(format!("{} Save issued copy", icon::FILE_PDF))
.on_hover_text("The copy that was sent, as it was filed, in your Downloads folder");
let open = ui
.button(format!("{} Open issued copy", icon::ARROW_SQUARE_OUT))
.on_hover_text("The copy that was sent, opened with its app");
if save.clicked() || open.clicked() {
let (stem, ext) = crate::export::split_name(&a.file_name);
let result = b
.attachment_bytes(a)
.and_then(|bytes| {
if open.clicked() {
crate::export::save_and_open(stem, ext, &bytes)
} else {
crate::export::save(stem, ext, &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 {
+135
View File
@@ -390,3 +390,138 @@ fn a_write_off_shows_on_the_timeline() {
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Entry(entry));
}
/// Books with an invoice issued the way `books.create_invoice` issues it
/// (plan P4b S4.5): numbered, on terms, the bill-to kept as a snapshot
/// with its contact block, the customer's PO and a memo, and its issued
/// PDF filed with it. A second invoice is a legacy one: no number, no
/// issued copy.
fn numbered_books(dir: &TempDir) -> (u64, u64) {
use ledger_core::{PartySnapshot, Terms};
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let client = s.add_customer("Harbor Coffee").unwrap();
let fields = InvoiceFields {
number: None,
terms: Some(Terms::parse("Net 30").unwrap()),
bill_to: Some(PartySnapshot {
name: "Harbor Coffee Roasters, Inc.".into(),
address: Some("455 Wharf Street\nPortland, OR 97209".into()),
email: Some("ap@harbor.example".into()),
phone: Some("503-555-0100".into()),
contact: Some("Ana Reyes".into()),
billing_address: None,
shipping_address: Some("Dock 4, Harbor Road".into()),
billing_email: None,
}),
seller: None,
customer_ref: Some("PO-4471".into()),
memo: Some("Thank you for the quick turnaround.".into()),
};
let lines = vec![line("Consulting", 50_000)];
let preview = s
.preview_invoice(
client,
d(2026, 3, 1),
d(2026, 3, 31),
lines.clone(),
fields.clone(),
)
.unwrap();
let fields = InvoiceFields {
number: preview.number.clone(),
..fields
};
let page = ledger_package::issued_invoice_document(
s.ledger(),
&ledger_core::settings::Company::default(),
&preview.invoice,
)
.unwrap();
let pdf = ledger_import::invoice_pdf(&page).unwrap();
let (numbered, _, _) = s
.create_invoice_issued(client, d(2026, 3, 1), d(2026, 3, 31), lines, fields, pdf)
.unwrap();
let legacy = s
.create_invoice(
client,
d(2026, 3, 2),
d(2026, 4, 1),
vec![line("Support", 8_000)],
)
.unwrap();
(numbered, legacy)
}
/// Plan P4b S4.5: the invoice's page shows its number, terms, bill-to (the
/// snapshot at issue, contact block included), ship-to and PO, and offers
/// the issued copy beside the re-created one; the Money in list names it
/// by its number. A legacy invoice keeps "Invoice {id}", shows no details
/// card, and has no issued copy to save.
#[test]
fn a_numbered_invoice_shows_its_number_terms_bill_to_and_po() {
let dir = TempDir::new("numbered");
let (numbered, legacy) = numbered_books(&dir);
let number = {
let books = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(d(2026, 3, 15)),
})
.unwrap();
let inv = books.ledger.get_invoice(numbered).unwrap();
let n = inv.number.clone().expect("the issue numbered it");
// The page's facts are the invoice's own (view_model parity).
let facts = ledger_gui::pages::invoices::details(
&books.ledger,
ledger_core::DocRef::Invoice(numbered),
);
let get = |label: &str| {
facts
.iter()
.find(|(l, _)| *l == label)
.map(|(_, v)| v.clone())
};
assert_eq!(get("Number").as_deref(), Some(n.as_str()));
assert_eq!(get("Terms").as_deref(), Some("Net 30"));
assert_eq!(get("PO").as_deref(), Some("PO-4471"));
assert_eq!(
get("Bill to").as_deref(),
Some(
"Harbor Coffee Roasters, Inc., Ana Reyes, 455 Wharf Street, Portland, OR 97209, 503-555-0100, ap@harbor.example"
)
);
assert_eq!(get("Ship to").as_deref(), Some("Dock 4, Harbor Road"));
assert_eq!(
get("Memo").as_deref(),
Some("Thank you for the quick turnaround.")
);
n
};
let mut h = open(&dir, Route::Invoice(numbered));
shot(&mut h, "invoice-numbered");
assert!(h.query_all_by_label_contains(&number).count() >= 1);
assert!(h.query_by_label("Net 30").is_some());
assert!(h.query_by_label("PO-4471").is_some());
assert!(
h.query_by_label_contains("Harbor Coffee Roasters, Inc., Ana Reyes")
.is_some()
);
assert!(h.query_by_label_contains("Save invoice PDF").is_some());
assert!(h.query_by_label_contains("Save issued copy").is_some());
// The list names it by number; the legacy one keeps its old name.
let h = open(&dir, Route::Receivables);
assert!(h.query_all_by_label_contains(&number).count() >= 1);
assert!(
h.query_all_by_label_contains(&format!("Invoice {legacy}"))
.count()
>= 1
);
// A legacy invoice: no details, and no issued copy to save.
let h = open(&dir, Route::Invoice(legacy));
assert!(h.query_by_label("Details").is_none());
assert!(h.query_by_label_contains("Save invoice PDF").is_some());
assert!(h.query_by_label_contains("Save issued copy").is_none());
}
+61
View File
@@ -924,3 +924,64 @@ fn a_write_off_is_flagged_for_the_cpa() {
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert!(books.ledger.get_entry(entry).is_some());
}
/// Plan P4b S4.5: the viewer, which replays the journal itself, holds a
/// numbered invoice exactly as the store that wrote it does: its number,
/// terms, bill-to snapshot, the customer's PO and memo, and its lines'
/// quantity and unit price. What the page shows is read from those, so
/// the viewer and the server cannot disagree about them.
#[test]
fn the_viewer_holds_a_numbered_invoice_as_the_store_does() {
use ledger_core::arap::InvoiceFields;
use ledger_core::{PartySnapshot, Terms};
let d = dir("numbered-parity");
let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let cust = s.add_customer("Harbor Coffee").unwrap();
let inv = s
.create_invoice_with(
cust,
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
vec![DocumentLine {
description: "Beans".into(),
amount: Decimal::new(9_999, 2),
class_id: None,
account: None,
quantity: Some(Decimal::new(3, 0)),
unit_price: Some(Decimal::new(3_333, 2)),
}],
InvoiceFields {
number: Some("INV-0007".into()),
terms: Some(Terms::parse("2/10 net 30").unwrap()),
bill_to: Some(PartySnapshot {
name: "Harbor Coffee Roasters, Inc.".into(),
address: Some("455 Wharf Street".into()),
email: Some("ap@harbor.example".into()),
phone: None,
contact: Some("Ana Reyes".into()),
billing_address: None,
shipping_address: Some("Dock 4".into()),
billing_email: None,
}),
seller: None,
customer_ref: Some("PO-4471".into()),
memo: Some("Thanks".into()),
},
)
.unwrap();
let stored = s.ledger().get_invoice(inv).unwrap().clone();
drop(s);
let books = data::load_once(&Source {
data_dir: d.join("books-data"),
today: Some(today),
})
.unwrap();
let shown = books.ledger.get_invoice(inv).unwrap();
assert_eq!(shown, &stored);
assert_eq!(shown.number.as_deref(), Some("INV-0007"));
assert_eq!(shown.terms.as_ref().unwrap().describe(), "2/10 net 30");
assert_eq!(shown.lines[0].quantity, Some(Decimal::new(3, 0)));
}