diff --git a/crates/ledger-gui/src/pages/invoices.rs b/crates/ledger-gui/src/pages/invoices.rs index b82ec5d..35abff9 100644 --- a/crates/ledger-gui/src/pages/invoices.rs +++ b/crates/ledger-gui/src/pages/invoices.rs @@ -59,6 +59,94 @@ struct Item { state: Result, 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::>() + .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 { @@ -84,7 +172,7 @@ fn items(b: &Books, bills: bool) -> Vec { .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::>(said)) { let (text, color) = match result { diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index 9fcbc82..77e96ee 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -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()); +} diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index d6c0c4d..83389aa 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -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))); +}