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
This commit is contained in:
12 files changed
+843
-4
No files matched your search
@@ -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<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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in new issue
Block a user