Files
wanderbooks/crates/ledger-import/src/invoice_pdf.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

268 lines
9.2 KiB
Rust

//! 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<String>,
}
/// 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<String>,
/// The company.
pub seller: InvoiceParty,
/// The customer.
pub bill_to: InvoiceParty,
/// Its lines.
pub items: Vec<InvoiceItem>,
/// 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<InvoiceStamp>,
/// A line under the stamp's word: for PAID, the day it was paid in
/// full.
pub stamp_line: Option<String>,
/// A note under it (for a re-created copy, saying so).
pub note: Option<String>,
}
static TEMPLATE: &str = include_str!("../templates/invoice.typ");
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = 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<serde_json::Value, PdfError> {
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::<Vec<_>>(),
"total": dollars(doc.total),
"settled": doc.settled.iter().map(|(label, a)| serde_json::json!({
"label": label,
"amount": dollars(-*a),
})).collect::<Vec<_>>(),
"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<PagedDocument, PdfError> {
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<Vec<u8>, 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());
}
}