diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index 9867644..ede855a 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -2727,6 +2727,18 @@ fn documents_list_and_timeline_over_the_wire() { ], "{one}" ); + // Its PDF, by download (P4b S3): re-created from the books. + let with_pdf = m.ok( + "books.document", + json!({"ref": format!("invoice:{late}"), "as_of": "2026-03-15", "pdf": true}), + ); + let bytes = m.wire.fetch(&with_pdf["pdf"]); + assert!(bytes.starts_with(b"%PDF"), "{with_pdf}"); + assert_eq!(with_pdf["pdf"]["file_name"], format!("invoice-{late}.pdf")); + assert!( + m.tool("books.document", json!({"ref": "bill:1", "pdf": true})) + .refused() + ); assert!( m.tool("books.document", json!({"ref": "invoice"})) .refused() diff --git a/crates/ledger-gui/src/pages/invoices.rs b/crates/ledger-gui/src/pages/invoices.rs index 6641428..6e903a9 100644 --- a/crates/ledger-gui/src/pages/invoices.rs +++ b/crates/ledger-gui/src/pages/invoices.rs @@ -599,6 +599,10 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) { }, ); 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})", @@ -614,6 +618,51 @@ 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. +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::>(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, "")); } diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index 50d12fb..f1025c7 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -175,6 +175,8 @@ fn a_documents_page_shows_its_timeline() { ); // Received: nothing stands. assert!(h.query_by_label("$0.00").is_some()); + // An invoice can be saved as PDF (a bill can't: it is the vendor's). + assert!(h.query_by_label_contains("Save invoice PDF").is_some()); // A timeline row opens its entry. h.get_by_label_contains(&format!("Payment (reversed later), entry #{pay},")) .click(); diff --git a/crates/ledger-import/src/invoice_pdf.rs b/crates/ledger-import/src/invoice_pdf.rs new file mode 100644 index 0000000..4158217 --- /dev/null +++ b/crates/ledger-import/src/invoice_pdf.rs @@ -0,0 +1,267 @@ +//! An invoice as PDF (plan P4b.11): what the company billed a customer, +//! typeset by `templates/invoice.typ` with the bundled fonts of +//! [`crate::pdf`]. +//! +//! ```text +//! GOAL: an invoice the customer can pay from, and the books can keep as +//! the copy that was sent. +//! SOURCE: plan/served.md P4b.11; IRS Pub. 583 (invoices are supporting +//! documents); CONVENTION (what a US small-business invoice +//! shows: seller, bill-to, number, dates, lines, total, balance +//! due). +//! INVARIANTS: +//! 1. Deterministic: the same document renders to the same bytes (no +//! creation time; fonts bundled; typst pinned). +//! 2. Text is data: every string reaches Typst as JSON +//! (`sys.inputs.data`) and prints literally, Typst syntax included. +//! 3. Money is formatted here ([`format_amount`], with "$"); the template +//! does no arithmetic, so the printed total is the total given. +//! 4. Refused: no lines. +//! SCENARIOS: +//! - An open invoice: total, nothing settled, balance due = total. +//! - Part paid: a "Payments received" line, the balance due the rest. +//! - Paid in full: balance due $0.00 and a PAID stamp with the day; void: +//! a VOID stamp; past due: PAST DUE; a preview: DRAFT (gray; the rest +//! are red, as a rubber stamp). +//! ``` + +use crate::pdf::PdfError; +use crate::report::format_amount; +use rust_decimal::Decimal; +use std::sync::OnceLock; +use typst::foundations::{Dict, IntoValue}; +use typst_as_lib::{TypstEngine, TypstTemplateMainFile}; +use typst_layout::PagedDocument; + +/// A party on the invoice: its name and the lines under it (an address). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct InvoiceParty { + /// Its name. + pub name: String, + /// The lines under it, in order. + pub lines: Vec, +} + +/// One line of the invoice. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InvoiceItem { + /// What it is for. + pub description: String, + /// Its amount. + pub amount: Decimal, +} + +/// A stamp across the invoice, as a rubber stamp would mark it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvoiceStamp { + /// Paid in full: the invoice is now the customer's receipt. + Paid, + /// Some paid, a balance still due. + PartiallyPaid, + /// Past its due date with a balance still due. + PastDue, + /// Voided: issued in error, cancelled. + Void, + /// Not issued yet: a preview. + Draft, +} + +impl InvoiceStamp { + /// What the stamp says. + pub fn text(self) -> &'static str { + match self { + InvoiceStamp::Paid => "PAID", + InvoiceStamp::PartiallyPaid => "PARTIALLY PAID", + InvoiceStamp::PastDue => "PAST DUE", + InvoiceStamp::Void => "VOID", + InvoiceStamp::Draft => "DRAFT", + } + } +} + +/// Everything an invoice prints. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InvoiceDoc { + /// Its number, as printed ("Invoice 7"; "INV-0007" once numbered). + pub number: String, + /// The issue date, as printed. + pub issue_date: String, + /// The due date, as printed. + pub due_date: String, + /// The payment terms, as printed ("Net 30"), when known. + pub terms: Option, + /// The company. + pub seller: InvoiceParty, + /// The customer. + pub bill_to: InvoiceParty, + /// Its lines. + pub items: Vec, + /// The sum of its lines. + pub total: Decimal, + /// What was settled against it, each with its label (a payment's + /// date, method and reference: "Payment Feb 10, 2026 · ACH · ref + /// tr_8812"), positive. + pub settled: Vec<(String, Decimal)>, + /// What is still due. + pub balance: Decimal, + /// A stamp, when there is one. + pub stamp: Option, + /// A line under the stamp's word: for PAID, the day it was paid in + /// full. + pub stamp_line: Option, + /// A note under it (for a re-created copy, saying so). + pub note: Option, +} + +static TEMPLATE: &str = include_str!("../templates/invoice.typ"); + +fn engine() -> &'static TypstEngine { + static ENGINE: OnceLock> = OnceLock::new(); + ENGINE.get_or_init(|| { + TypstEngine::builder() + .main_file(TEMPLATE) + .fonts(crate::fonts::typst_fonts()) + .build() + }) +} + +/// An amount as printed: "$1,234.56". +fn dollars(d: Decimal) -> String { + let s = format_amount(d); + match s.strip_prefix('(').and_then(|s| s.strip_suffix(')')) { + Some(inner) => format!("-${inner}"), + None => format!("${s}"), + } +} + +/// The template's input. +pub fn template_data(doc: &InvoiceDoc) -> Result { + if doc.items.is_empty() { + return Err(PdfError::Compile( + "an invoice needs at least one line".into(), + )); + } + let party = |p: &InvoiceParty| serde_json::json!({"name": p.name, "lines": p.lines}); + Ok(serde_json::json!({ + "title": "INVOICE", + "number": doc.number, + "issue_date": doc.issue_date, + "due_date": doc.due_date, + "terms": doc.terms, + "seller": party(&doc.seller), + "bill_to": party(&doc.bill_to), + "lines": doc.items.iter().map(|i| serde_json::json!({ + "description": i.description, + "amount": dollars(i.amount), + })).collect::>(), + "total": dollars(doc.total), + "settled": doc.settled.iter().map(|(label, a)| serde_json::json!({ + "label": label, + "amount": dollars(-*a), + })).collect::>(), + "balance_label": "Balance due", + "balance": dollars(doc.balance), + "stamp": doc.stamp.map(InvoiceStamp::text), + "stamp_draft": doc.stamp == Some(InvoiceStamp::Draft), + "stamp_line": doc.stamp_line, + "note": doc.note, + })) +} + +/// Typeset an invoice into pages, before PDF encoding (for inspection). +pub fn typeset_invoice(doc: &InvoiceDoc) -> Result { + let mut inputs = Dict::new(); + inputs.insert("data".into(), template_data(doc)?.to_string().into_value()); + engine() + .compile_with_input(inputs) + .output + .map_err(|e| PdfError::Compile(format!("{e:?}"))) +} + +/// An invoice as PDF bytes. See the module documentation for the spec. +pub fn invoice_pdf(doc: &InvoiceDoc) -> Result, PdfError> { + let pages = typeset_invoice(doc)?; + typst_pdf::pdf(&pages, &typst_pdf::PdfOptions::default()) + .map_err(|e| PdfError::Save(format!("{e:?}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc() -> InvoiceDoc { + InvoiceDoc { + number: "Invoice 7".into(), + issue_date: "Feb 1, 2026".into(), + due_date: "Mar 3, 2026".into(), + terms: Some("Net 30".into()), + seller: InvoiceParty { + name: "Fernhill Labs LLC".into(), + lines: vec!["12 Orchard Lane".into(), "Springfield, UT 84000".into()], + }, + bill_to: InvoiceParty { + name: "Harbor Client".into(), + lines: vec!["1 Pier Way".into()], + }, + items: vec![ + InvoiceItem { + description: "Consulting, January".into(), + amount: Decimal::new(120_000, 2), + }, + InvoiceItem { + description: "#panic(\"no\") *not bold* $x$".into(), + amount: Decimal::new(3_450, 2), + }, + ], + total: Decimal::new(123_450, 2), + settled: vec![("Payments received".into(), Decimal::new(20_000, 2))], + balance: Decimal::new(103_450, 2), + stamp: None, + stamp_line: None, + note: None, + } + } + + #[test] + fn an_invoice_renders_deterministically() { + let a = invoice_pdf(&doc()).unwrap(); + assert!(a.starts_with(b"%PDF")); + // SHOT_DIR set: the invoice, to look at. + if let Ok(dir) = std::env::var("SHOT_DIR") { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(std::path::Path::new(&dir).join("invoice.pdf"), &a).unwrap(); + } + assert_eq!(a, invoice_pdf(&doc()).unwrap()); + } + + #[test] + fn text_is_data_and_money_is_formatted_here() { + let data = template_data(&doc()).unwrap(); + assert_eq!(data["total"], "$1,234.50"); + assert_eq!(data["balance"], "$1,034.50"); + assert_eq!(data["settled"][0]["amount"], "-$200.00"); + // The Typst-looking description renders (it would fail to compile + // as markup: #panic stops the document). + assert!(typeset_invoice(&doc()).is_ok()); + } + + #[test] + fn stamps_render_and_no_lines_is_refused() { + let mut d = doc(); + for st in [ + InvoiceStamp::Paid, + InvoiceStamp::PartiallyPaid, + InvoiceStamp::PastDue, + InvoiceStamp::Draft, + ] { + d.stamp = Some(st); + d.stamp_line = Some("Feb 20, 2026".into()); + assert!(invoice_pdf(&d).is_ok(), "{st:?}"); + } + d.stamp = Some(InvoiceStamp::Void); + d.note = Some("Re-created from the books; not the copy sent.".into()); + assert!(invoice_pdf(&d).is_ok()); + d.items.clear(); + assert!(invoice_pdf(&d).is_err()); + } +} diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index f058ebd..53cb709 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -8,6 +8,7 @@ //! - [`pdf`]: generate PDF financial statements (CPA handoff). //! - [`asset_register`]: the fixed asset register as XLSX and PDF. //! - [`labels`]: print asset label sheets with QR codes. +//! - [`invoice_pdf`](mod@invoice_pdf): an invoice as PDF. //! - [`fonts`]: the bundled fonts the PDFs and the viewer share. //! //! All money stays `Decimal`; no floats in money paths. @@ -17,6 +18,7 @@ pub mod bank_csv; pub mod chart_csv; pub mod fonts; pub mod iif; +pub mod invoice_pdf; pub mod labels; pub mod pdf; pub mod report; @@ -30,6 +32,7 @@ pub use iif::{ Iif, IifAccount, IifChange, IifClass, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX, QbAccountType, iif_accounts, iif_classes, journal_to_iif, }; +pub use invoice_pdf::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, invoice_pdf}; pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf}; pub use pdf::{PdfError, statement_pdf}; pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount}; diff --git a/crates/ledger-import/templates/invoice.typ b/crates/ledger-import/templates/invoice.typ new file mode 100644 index 0000000..8cd371c --- /dev/null +++ b/crates/ledger-import/templates/invoice.typ @@ -0,0 +1,128 @@ +// An invoice: what the company billed a customer, one US-letter page or +// more. +// +// Everything arrives as data in sys.inputs.data (JSON), built by +// ledger-import::invoice_pdf, every amount already formatted. The template +// only typesets: it does no arithmetic, and no text is ever parsed as +// markup (a description holding Typst syntax prints literally). +// +// Shape: { title: "INVOICE", number, issue_date, due_date, terms: null | str, +// seller: { name, lines: [str] }, bill_to: { name, lines: [str] }, +// lines: [{ description, amount }], total, +// settled: [{ label, amount }], balance_label, balance, +// stamp: null | str, stamp_draft: bool, stamp_line: null | str, +// note: null | str } + +#let d = json(bytes(sys.inputs.data)) +#let ink = luma(25) +#let quiet = luma(110) +#let hair = 0.5pt + luma(170) +#let firm = 0.8pt + ink + +#set document(title: d.seller.name + " — " + d.number, author: d.seller.name) +#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 10pt, fill: ink, number-width: "tabular") +#set par(leading: 0.45em, spacing: 0.6em) +#set page( + paper: "us-letter", + margin: (x: 0.8in, top: 0.8in, bottom: 0.9in), + footer: context { + set text(7.5pt, fill: quiet) + [#d.seller.name · #d.number #h(1fr) Page #counter(page).display() of #counter(page).final().first()] + }, +) + +// A block of lines, each set as a string (always literal). +#let lines(ls) = for l in ls [#l \ ] + +#grid( + columns: (1fr, auto), + align: (left + top, right + top), + [ + #text(15pt, weight: "bold", d.seller.name) \ + #set text(fill: quiet) + #lines(d.seller.lines) + ], + [ + #text(22pt, weight: "bold", tracking: 0.08em, d.title) \ + #v(2pt) + #text(11pt, weight: "semibold", d.number) + ], +) + +#v(0.35in) +#grid( + columns: (1fr, auto), + column-gutter: 24pt, + [ + #text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "BILL TO") \ + #v(2pt) + #text(weight: "semibold", d.bill_to.name) \ + #lines(d.bill_to.lines) + ], + grid( + columns: (auto, auto), + column-gutter: 14pt, + row-gutter: 6pt, + align: (right, left), + text(fill: quiet, "Invoice date"), d.issue_date, + text(fill: quiet, "Due date"), text(weight: "semibold", d.due_date), + ..if d.terms != none { (text(fill: quiet, "Terms"), d.terms) } else { () }, + ), +) + +#v(0.3in) +#table( + columns: (1fr, auto), + stroke: none, + inset: (x: 0pt, y: 7pt), + align: (left, right), + table.hline(stroke: firm), + table.header( + text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "DESCRIPTION"), + text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "AMOUNT"), + ), + table.hline(stroke: hair), + ..d.lines.map(l => (l.description, l.amount)).flatten(), + table.hline(stroke: firm), +) + +// The stamp, if any, sits in the space left of the totals, as a rubber +// stamp would. +#let stamp = if d.stamp == none { none } else { + let ink-stamp = if d.stamp_draft { luma(130) } else { rgb("#c62828") } + rotate(-10deg, reflow: true, box( + stroke: 2.5pt + ink-stamp, + inset: (x: 12pt, y: 6pt), + radius: 4pt, + align(center, { + text(24pt, weight: "bold", tracking: 0.1em, fill: ink-stamp, d.stamp) + if d.stamp_line != none { + linebreak() + text(9pt, weight: "semibold", fill: ink-stamp, d.stamp_line) + } + }), + )) +} + +#v(6pt) +#grid( + columns: (1fr, auto), + column-gutter: 16pt, + align: (center + horizon, right + top), + stamp, + grid( + columns: (auto, 1.2in), + column-gutter: 16pt, + row-gutter: 8pt, + align: (right, right), + text(fill: quiet, "Total"), d.total, + ..d.settled.map(s => (text(fill: quiet, s.label), s.amount)).flatten(), + grid.hline(stroke: hair), + text(weight: "bold", d.balance_label), text(12pt, weight: "bold", d.balance), + ), +) + +#if d.note != none { + v(0.3in) + text(8.5pt, fill: quiet, d.note) +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 39078b7..4b236dd 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -8826,7 +8826,7 @@ impl BooksServer { name = "books.document", title = "An invoice or bill", description = described( - "One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents): party, dates, lines with their projects, total, what was paid, discounted and written off, what it still owes and its status, all as of a day; and its timeline: every ledger line tagged with it in posting order (its own entry, each payment, each reversal), each saying whether its entry still stands. A reversed payment shows twice: the payment (no longer standing) and its reversal (standing), which net to nothing.", + "One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents) as of a day: party, dates, lines with projects, total, paid, discounted, written off, still owed, status; and its timeline, every ledger line tagged with it in posting order, each saying whether its entry still stands (a reversed payment shows as the payment, not standing, and its reversal, standing: they net to nothing). pdf: true also hands over an invoice's PDF to download, re-created from the books.", ledger_core::calendar::PeriodKind::AsOf ), annotations(read_only_hint = true, open_world_hint = false) @@ -8866,6 +8866,33 @@ impl BooksServer { .doc_state(doc, as_of) .map_err(|e| e.to_string())? .ok_or_else(|| format!("{} is dated after {as_of}", doc_ref_text(doc)))?; + let pdf = match (p.pdf, doc) { + (false, _) => None, + (true, ledger_core::DocRef::Bill(_)) => { + return Err( + "a bill is the vendor's document: its PDF is the file filed with it (books.list_receipts)" + .into(), + ); + } + (true, ledger_core::DocRef::Invoice(id)) => { + let settings = self.settings(); + let printed = ledger_package::invoice_document( + ledger, + &settings.company, + id, + as_of, + today(), + )?; + let bytes = + ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?; + Some(self.hand_over( + &book_name(&store), + &format!("invoice-{id}.pdf"), + "application/pdf", + bytes, + )?) + } + }; let timeline = ledger .doc_lines(doc) .into_iter() @@ -8904,6 +8931,7 @@ impl BooksServer { status: st.status.key().to_string(), overdue_days: st.overdue_days, timeline, + pdf, })) } diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index f6587ae..0698e5c 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3195,6 +3195,10 @@ pub struct DocumentParams { #[serde(default)] #[schemars(description = AS_OF_DOC)] pub as_of: Option, + /// For an invoice: also hand over its PDF (as of the day, re-created + /// from the books) to download. + #[serde(default)] + pub pdf: bool, } /// One line of a document. @@ -3259,6 +3263,9 @@ pub struct DocumentOut { pub overdue_days: i64, /// Every ledger line tagged with it, in posting order. pub timeline: Vec, + /// The invoice's PDF, handed over (with `pdf`): run its download. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pdf: Option, } /// Parameters for `books.void_document`. diff --git a/crates/ledger-package/src/invoice.rs b/crates/ledger-package/src/invoice.rs new file mode 100644 index 0000000..60647fc --- /dev/null +++ b/crates/ledger-package/src/invoice.rs @@ -0,0 +1,329 @@ +//! An invoice as the books hold it, ready to print (plan P4b.11): the +//! data `ledger_import::invoice_pdf` typesets, built in one place so the +//! server and the viewer give the same bytes for the same books. +//! +//! ```text +//! GOAL: print an invoice from the books, as of a day. +//! SOURCE: plan/served.md P4b.11 (the "current" version); rules.md B-38 +//! (a document's figures are its lines that stand). +//! INVARIANTS: +//! 1. Its figures are the invoice's derived state as of the day (as of +//! its own date when the day is earlier): total, what was paid, +//! discounted and written off, and the balance due. +//! 2. Its lines are the invoice's, in order; projects, accounts and ids +//! never print. +//! 3. Each payment that stands prints on its own line, with its date, +//! payment method and reference ("Payment Feb 10, 2026 · Harbor +//! checking · ref tr_8812"), so a paid invoice is the customer's +//! receipt. Discounts and write-offs print as one line each. +//! 4. Stamps: void VOID; paid in full PAID with the day the last +//! payment came in; owing and past due PAST DUE (over PARTIALLY +//! PAID); part paid PARTIALLY PAID; otherwise none. +//! 5. Until invoices keep the copy issued (P4b S4), every render says it +//! was re-created from the books, and on which day. +//! 6. The seller is the company (`company.name`, `company.address`); the +//! customer is its party's display name and address when it has +//! them, else the customer's name. +//! ``` + +use chrono::NaiveDate; +use ledger_core::settings::Company; +use ledger_core::{DocRef, DocStatus, InvoiceId, Ledger}; +use ledger_import::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp}; + +/// A date as an invoice prints it: "Feb 1, 2026". +fn printed(d: NaiveDate) -> String { + d.format("%b %-d, %Y").to_string() +} + +/// An address's lines: one per line of the text, blank ones dropped. +fn address_lines(address: Option<&str>) -> Vec { + address + .unwrap_or("") + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() +} + +/// Invoice `id` as of `as_of`, re-created on `today` (INVARIANT 4). +pub fn invoice_document( + ledger: &Ledger, + company: &Company, + id: InvoiceId, + as_of: NaiveDate, + today: NaiveDate, +) -> Result { + let inv = ledger + .get_invoice(id) + .ok_or_else(|| format!("no invoice {id}"))?; + let doc = DocRef::Invoice(id); + let st = ledger + .doc_state(doc, as_of.max(inv.date)) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("invoice {id} is not in the ledger"))?; + let customer = ledger.get_customer(inv.customer_id); + let party = ledger + .party_of_customer(inv.customer_id) + .and_then(|p| ledger.get_party(p)); + let bill_to = InvoiceParty { + name: party + .and_then(|p| p.display_name.clone()) + .or_else(|| customer.map(|c| c.name.clone())) + .unwrap_or_default(), + lines: address_lines(party.and_then(|p| p.address.as_deref())), + }; + let seller = InvoiceParty { + name: company.name.clone(), + lines: address_lines( + company + .profile + .iter() + .find(|(k, _)| k == "address") + .map(|(_, v)| v.as_str()), + ), + }; + // Each payment that stands, oldest first (INVARIANT 3): a payment + // reversed later, and its reversal, print nothing. + let mut settled = Vec::new(); + let mut last_paid = None; + for l in ledger.doc_lines(doc) { + if l.movement != ledger_core::Movement::Payment + || l.reversal + || l.date > as_of + || !ledger.stands_on(l.entry, as_of) + { + continue; + } + let mut label = format!("Payment {}", printed(l.date)); + if let Some(i) = ledger + .entry_instrument(l.entry) + .and_then(|i| ledger.get_instrument(i)) + { + label.push_str(&format!(" · {}", i.label)); + } + if let Some(r) = ledger + .get_entry(l.entry) + .and_then(|e| e.description().rsplit_once(" (ref ")) + .and_then(|(_, r)| r.strip_suffix(')')) + { + label.push_str(&format!(" · ref {r}")); + } + settled.push((label, -l.amount)); + last_paid = Some(l.date); + } + for (label, amount) in [ + ("Discounts", st.discounted), + ("Written off", st.written_off), + ] { + if !amount.is_zero() { + settled.push((label.to_string(), amount)); + } + } + let (stamp, stamp_line) = match st.status { + DocStatus::Void => (Some(InvoiceStamp::Void), None), + DocStatus::Paid => (Some(InvoiceStamp::Paid), last_paid.map(printed)), + _ if st.overdue_days > 0 => (Some(InvoiceStamp::PastDue), None), + DocStatus::Partial => (Some(InvoiceStamp::PartiallyPaid), None), + _ => (None, None), + }; + Ok(InvoiceDoc { + number: format!("Invoice {id}"), + issue_date: printed(inv.date), + due_date: printed(inv.due_date), + terms: None, + seller, + bill_to, + items: inv + .lines + .iter() + .map(|l| InvoiceItem { + description: l.description.clone(), + amount: l.amount, + }) + .collect(), + total: st.total, + settled, + balance: st.outstanding, + stamp, + stamp_line, + note: Some(format!( + "Re-created on {} from the books; not a copy of the one sent.", + printed(today) + )), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ledger_core::{AccountType, Currency, DocumentLine}; + use rust_decimal::Decimal; + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + #[test] + fn an_invoice_prints_its_figures_as_of_a_day() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let c = l.add_customer("Harbor Client").unwrap(); + let inv = l + .create_invoice( + c, + d(2026, 2, 1), + d(2026, 3, 3), + vec![DocumentLine { + description: "Consulting".into(), + amount: Decimal::new(50_000, 2), + class_id: None, + }], + ) + .unwrap(); + l.receive_payment(inv, d(2026, 2, 10), Decimal::new(50_000, 2), cash) + .unwrap(); + let mut company = Company { + name: "Fernhill Labs LLC".into(), + ..Company::default() + }; + company + .profile + .push(("address".into(), "12 Orchard Lane\nSpringfield, UT".into())); + let before = invoice_document(&l, &company, inv, d(2026, 2, 5), d(2026, 3, 1)).unwrap(); + assert_eq!(before.balance, Decimal::new(50_000, 2)); + assert!(before.settled.is_empty() && before.stamp.is_none()); + let after = invoice_document(&l, &company, inv, d(2026, 2, 28), d(2026, 3, 1)).unwrap(); + assert_eq!(after.balance, Decimal::ZERO); + assert_eq!( + after.settled, + vec![("Payment Feb 10, 2026".to_string(), Decimal::new(50_000, 2))] + ); + assert_eq!(after.stamp, Some(InvoiceStamp::Paid)); + assert_eq!(after.stamp_line.as_deref(), Some("Feb 10, 2026")); + assert_eq!(after.seller.lines, ["12 Orchard Lane", "Springfield, UT"]); + assert_eq!(after.bill_to.name, "Harbor Client"); + assert_eq!(after.issue_date, "Feb 1, 2026"); + assert!(after.note.as_deref().unwrap().contains("Mar 1, 2026")); + assert!(ledger_import::invoice_pdf(&after).is_ok()); + } + + /// The sample invoice, through the books, in three states (part paid + /// and past due, paid in full, void): placeholder company and customer + /// with full addresses, several lines, payments with their method and + /// reference. SHOT_DIR set: each PDF is saved there, to look at. + #[test] + fn the_sample_invoice_in_each_state() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let bank = l + .add_instrument( + ledger_core::InstrumentKind::Bank, + "ACH", + Some("4821"), + None, + cash, + ) + .unwrap(); + let c = l.add_customer("Harbor Coffee Roasters").unwrap(); + let party = l.party_of_customer(c).unwrap(); + l.update_party( + party, + ledger_core::PartyField::DisplayName(Some("Harbor Coffee Roasters, Inc.".into())), + ) + .unwrap(); + l.update_party( + party, + ledger_core::PartyField::Address(Some( + "Attn: Accounts Payable\n455 Wharf Street, Suite 210\nPortland, OR 97209".into(), + )), + ) + .unwrap(); + let line = |d: &str, cents: i64| DocumentLine { + description: d.into(), + amount: Decimal::new(cents, 2), + class_id: None, + }; + let inv = l + .create_invoice( + c, + d(2026, 9, 1), + d(2026, 10, 1), + vec![ + line( + "iOS app: ordering flow, design and build (40 hours at $125)", + 500_000, + ), + line( + "Android app: port of the ordering flow (24 hours at $125)", + 300_000, + ), + line( + "App Store and Google Play submission and review support", + 45_000, + ), + line("Hosting and push notifications, September", 8_900), + ], + ) + .unwrap(); + let pay = |l: &mut Ledger, day: u32, cents: i64, r: &str| { + l.receive_payment_with( + inv, + d(2026, 10, day), + Decimal::new(cents, 2), + cash, + &ledger_core::PaymentDetails { + instrument: Some(bank), + reference: Some(r.into()), + }, + ) + .unwrap() + }; + pay(&mut l, 6, 400_000, "tr_8812"); + let mut company = Company { + name: "Fernhill Labs LLC".into(), + ..Company::default() + }; + company.profile.push(( + "address".into(), + "12 Orchard Lane, Suite 4\nSpringfield, UT 84000\nhello@fernhill.example".into(), + )); + let save = |name: &str, doc: &ledger_import::InvoiceDoc| { + let pdf = ledger_import::invoice_pdf(doc).unwrap(); + if let Ok(dir) = std::env::var("SHOT_DIR") { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(std::path::Path::new(&dir).join(name), pdf).unwrap(); + } + }; + let today = d(2026, 10, 24); + let part = invoice_document(&l, &company, inv, today, today).unwrap(); + assert_eq!(part.stamp, Some(InvoiceStamp::PastDue)); + assert_eq!(part.balance, Decimal::new(453_900, 2)); + assert_eq!(part.settled[0].0, "Payment Oct 6, 2026 · ACH · ref tr_8812"); + save("sample-part-paid.pdf", &part); + pay(&mut l, 20, 453_900, "tr_9310"); + let paid = invoice_document(&l, &company, inv, today, today).unwrap(); + assert_eq!(paid.stamp, Some(InvoiceStamp::Paid)); + assert_eq!(paid.stamp_line.as_deref(), Some("Oct 20, 2026")); + assert_eq!(paid.balance, Decimal::ZERO); + save("sample-paid.pdf", &paid); + let spare = l + .create_invoice( + c, + d(2026, 10, 2), + d(2026, 11, 1), + vec![line("Entered twice", 8_900)], + ) + .unwrap(); + l.void_document(DocRef::Invoice(spare), d(2026, 10, 2), "entered twice") + .unwrap(); + let void = invoice_document(&l, &company, spare, today, today).unwrap(); + assert_eq!(void.stamp, Some(InvoiceStamp::Void)); + save("sample-void.pdf", &void); + } +} diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index 0185d07..a6819cf 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -25,6 +25,7 @@ mod books; mod iif; +mod invoice; mod k1_tax; mod package; mod reports; @@ -34,6 +35,7 @@ mod vendor_1099; pub use books::PackageBooks; pub use iif::export_iif; +pub use invoice::invoice_document; pub use k1_tax::{open_text, tax_basis_csv}; pub use package::{Package, PackageRequest, build_package, package_file_name}; pub use reports::{ diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 53ee38a..5465423 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2027,13 +2027,17 @@ accounting only: the caller supplies every figure), tax filing. anything still owed), `overdue`, `paid`, `void`, `written_off`, `overpaid` or `all`; `party` (a customer's or vendor's name or id) and `due_by` narrow the list (P4b S2). -- `books.document { ref, as_of? }` → `{ ref, party, date, due_date, +- `books.document { ref, as_of?, pdf? }` → `{ ref, party, date, due_date, entry_id, lines: [{ description, amount, project? }], total, paid, discounted, written_off, outstanding, status, overdue_days, timeline: [{ date, entry_id, kind, amount, reversal, stands }] }`. `ref` is `invoice:N` or `bill:N`; the timeline is every ledger line tagged with the document in posting order, each saying whether its entry still - stands as of the day (P4b S2). + stands as of the day (P4b S2). With `pdf: true` an invoice's PDF comes + back as a handed file (`pdf`: run its download): re-created from the + books as of the day, each standing payment on its own line with its + date, method and reference, stamped PAID (with the day), PARTIALLY + PAID, PAST DUE or VOID (P4b S3); refused for a bill. - `books.control_check { as_of? }` → `{ as_of, agrees, accounts: [{ role, code, name, balance, documents_total, unattached: [{ entry_id, line, date, amount }], difference, attention: [{ kind, id, status, total, diff --git a/plan/STATUS.md b/plan/STATUS.md index 50e4331..0ca5425 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -235,7 +235,15 @@ tie-out in `books.verify` and `control_problems` in `books.digest`; the over-the-wire test `documents_void_and_tie_out_over_the_wire`. Still for later slices: the processor fee on a payment (S6, with multi-document payments and discounts), Needs attention in the viewer for the control -check, and S2 to S7. +check, and S2 to S7. S2 (2026-10-24): `books.documents`, +`books.document`, and the viewer's Invoices & Bills (Money in, Money out, +a document's page, overview tiles). S3 (2026-10-24): the invoice PDF +(`ledger-import` `invoice_pdf`, `templates/invoice.typ`; `ledger-package` +`invoice_document`): re-created from the books, each payment on its own +line with its date, method and reference, stamped PAID with the day, +PARTIALLY PAID, PAST DUE or VOID (DRAFT, gray, for S4's preview); by +download from `books.document pdf`, and Save invoice PDF in the viewer. +The copy issued is kept from S4 on and never changes. **Still open after P3 (each for the phase named, or the lead):** - Invoices and bills take a project now (above), behind the new journal feature `document_line_projects`: the first company's books declared