feat(pdf): the issued invoice carries what the customer pays from (P4b S4.5)

The page a customer pays from must show what the books issued, and only
that. InvoiceDoc gains each line's quantity and unit price ("3 x $33.33"
beside the line's own amount, never recomputed), the customer's PO, memo,
footer, the ways to pay (a QR code through labels::qr_svg for a method
with a link that asks for one), the seller's email and website, and a
Ship-to block. InvoiceVersion separates the issued copy (no stamp, no
settled rows, no "re-created" note) from the current copy, whose
"Re-created on" note prints the as_of it was given, never the wall clock,
so the bytes stay deterministic (B-40 item 3). Every amount reaches the
page through report::amount_parts; every string is data.

ledger_package builds all three copies (issued, DRAFT, current) from one
function over the invoice's own snapshots (A-13, D-55: number, terms,
bill-to contact block, shipping address), falling back to the party record
only for a document made before snapshots, so the three cannot disagree.
invoice_document drops its now-unused `today` argument.

rules.md gains B-39 (terms) and B-40 (numbers and the issued copy) from
the 8.3.13 spec blocks, CONVENTION until their sources are vendored.
No golden moved: the render test never held a hash, it compares two
renders for equal bytes, and still does.

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 ce69d9d7bc
commit 400d0cdcab
8 files changed
+706 -169

No files matched your search

+202 -9
View File
@@ -17,6 +17,14 @@
//! 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.
//! 5. The issued copy (`InvoiceVersion::Issued`) is the page the
//! customer got: no "re-created" note and no settled rows. The
//! current copy (`InvoiceVersion::Current { as_of }`) says on which
//! day it was re-created, and that day is the `as_of` it was given,
//! never the wall clock, so the bytes stay deterministic.
//! 6. A line given as a quantity and a unit price prints "3 × $33.33"
//! under its description; the amount beside it is the line's own.
//! Nothing internal prints: no account code, project or id.
//! SCENARIOS:
//! - An open invoice: total, nothing settled, balance due = total.
//! - Part paid: a "Payments received" line, the balance due the rest.
@@ -26,7 +34,8 @@
//! ```
use crate::pdf::PdfError;
use crate::report::format_amount;
use crate::report::amount_parts;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use std::sync::OnceLock;
use typst::foundations::{Dict, IntoValue};
@@ -49,6 +58,38 @@ pub struct InvoiceItem {
pub description: String,
/// Its amount.
pub amount: Decimal,
/// How many units, when the line was given as a quantity and a unit
/// price (both or neither).
pub quantity: Option<Decimal>,
/// The price of one unit, with [`Self::quantity`].
pub unit_price: Option<Decimal>,
}
/// One way to pay, as the page prints it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PaymentMethodDoc {
/// Its name ("Bank transfer", "Card").
pub label: String,
/// The lines under it (bank details, a link).
pub lines: Vec<String>,
/// A QR code for it, as an SVG document (`labels::qr_svg`), when it
/// has one.
pub qr_svg: Option<String>,
}
/// Which copy of the invoice this is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum InvoiceVersion {
/// The copy issued to the customer (or its draft): no "re-created"
/// note, nothing settled on it.
#[default]
Issued,
/// The invoice re-created from the books as of a day; the page says
/// so, with that day.
Current {
/// The day the figures are as of, printed in the note.
as_of: NaiveDate,
},
}
/// A stamp across the invoice, as a rubber stamp would mark it.
@@ -80,7 +121,7 @@ impl InvoiceStamp {
}
/// Everything an invoice prints.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct InvoiceDoc {
/// Its number, as printed ("Invoice 7"; "INV-0007" once numbered).
pub number: String,
@@ -92,8 +133,25 @@ pub struct InvoiceDoc {
pub terms: Option<String>,
/// The company.
pub seller: InvoiceParty,
/// The seller's email, when it has one.
pub seller_email: Option<String>,
/// The seller's website, when it has one.
pub seller_website: Option<String>,
/// The customer.
pub bill_to: InvoiceParty,
/// Where goods go, when the customer said so: printed as a Ship-to
/// block.
pub ship_to: Vec<String>,
/// The customer's own reference (their purchase order), when given.
pub customer_ref: Option<String>,
/// A memo printed under the lines, when given.
pub memo: Option<String>,
/// The ways to pay.
pub payment_methods: Vec<PaymentMethodDoc>,
/// Text at the foot of the page (`invoicing.footer`).
pub footer: Option<String>,
/// Which copy this is.
pub version: InvoiceVersion,
/// Its lines.
pub items: Vec<InvoiceItem>,
/// The sum of its lines.
@@ -109,7 +167,8 @@ pub struct InvoiceDoc {
/// 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).
/// A note under it, besides the re-created note a current copy always
/// carries.
pub note: Option<String>,
}
@@ -125,12 +184,50 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
})
}
/// An amount as printed: "$1,234.56".
/// An amount as printed: "$1,234.56". Every amount on the page comes
/// through [`amount_parts`].
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}"),
let p = amount_parts(d);
if p.negative {
format!("-${}", p.digits)
} else {
format!("${}", p.digits)
}
}
/// A unit price as printed: to the cent when it is one, else exactly (a
/// price of $0.125 must not print as $0.13 beside a total it multiplies
/// to).
fn unit_price(d: Decimal) -> String {
if d.normalize().scale() <= 2 {
dollars(d)
} else {
format!("${}", d.normalize())
}
}
/// "3 × $33.33" for a line given as a quantity and a price.
fn quantity_detail(i: &InvoiceItem) -> Option<String> {
match (i.quantity, i.unit_price) {
(Some(q), Some(p)) => Some(format!("{} × {}", q.normalize(), unit_price(p))),
_ => None,
}
}
/// The note under the totals: the re-created line of a current copy, then
/// whatever note the document carries.
fn note_text(doc: &InvoiceDoc) -> Option<String> {
let recreated = match doc.version {
InvoiceVersion::Current { as_of } => Some(format!(
"Re-created on {} from the books; not a copy of the one sent.",
as_of.format("%b %-d, %Y")
)),
InvoiceVersion::Issued => None,
};
match (recreated, &doc.note) {
(Some(r), Some(n)) => Some(format!("{r} {n}")),
(Some(r), None) => Some(r),
(None, n) => n.clone(),
}
}
@@ -149,11 +246,23 @@ pub fn template_data(doc: &InvoiceDoc) -> Result<serde_json::Value, PdfError> {
"due_date": doc.due_date,
"terms": doc.terms,
"seller": party(&doc.seller),
"seller_email": doc.seller_email,
"seller_website": doc.seller_website,
"bill_to": party(&doc.bill_to),
"ship_to": doc.ship_to,
"customer_ref": doc.customer_ref,
"memo": doc.memo,
"lines": doc.items.iter().map(|i| serde_json::json!({
"description": i.description,
"detail": quantity_detail(i),
"amount": dollars(i.amount),
})).collect::<Vec<_>>(),
"payment_methods": doc.payment_methods.iter().map(|m| serde_json::json!({
"label": m.label,
"lines": m.lines,
"qr": m.qr_svg,
})).collect::<Vec<_>>(),
"footer": doc.footer,
"total": dollars(doc.total),
"settled": doc.settled.iter().map(|(label, a)| serde_json::json!({
"label": label,
@@ -164,7 +273,7 @@ pub fn template_data(doc: &InvoiceDoc) -> Result<serde_json::Value, PdfError> {
"stamp": doc.stamp.map(InvoiceStamp::text),
"stamp_draft": doc.stamp == Some(InvoiceStamp::Draft),
"stamp_line": doc.stamp_line,
"note": doc.note,
"note": note_text(doc),
}))
}
@@ -207,10 +316,14 @@ mod tests {
InvoiceItem {
description: "Consulting, January".into(),
amount: Decimal::new(120_000, 2),
quantity: None,
unit_price: None,
},
InvoiceItem {
description: "#panic(\"no\") *not bold* $x$".into(),
amount: Decimal::new(3_450, 2),
quantity: Some(Decimal::new(3, 0)),
unit_price: Some(Decimal::new(1_150, 2)),
},
],
total: Decimal::new(123_450, 2),
@@ -219,6 +332,7 @@ mod tests {
stamp: None,
stamp_line: None,
note: None,
..InvoiceDoc::default()
}
}
@@ -264,4 +378,83 @@ mod tests {
d.items.clear();
assert!(invoice_pdf(&d).is_err());
}
/// INVARIANT 2 for every string the page takes: Typst syntax in an
/// item, a party, a memo, a payment method or a footer prints as
/// written and breaks nothing.
#[test]
fn labels_are_data() {
let mut d = doc();
d.items[0].description = "#import \"x\": *".into();
d.memo = Some("#panic(\"memo\") $x$ ]".into());
d.footer = Some("#panic(\"footer\")".into());
d.customer_ref = Some("PO #{1}".into());
d.payment_methods = vec![PaymentMethodDoc {
label: "#panic(\"label\")".into(),
lines: vec!["#import \"y\"".into()],
qr_svg: None,
}];
let data = template_data(&d).unwrap();
assert_eq!(data["lines"][0]["description"], "#import \"x\": *");
assert_eq!(data["memo"], "#panic(\"memo\") $x$ ]");
assert_eq!(data["payment_methods"][0]["label"], "#panic(\"label\")");
assert!(invoice_pdf(&d).is_ok());
}
/// A line given as a quantity and a price prints both; the amount is
/// the line's own, never recomputed here.
#[test]
fn a_quantity_prints_beside_its_price() {
let data = template_data(&doc()).unwrap();
assert_eq!(data["lines"][1]["detail"], "3 × $11.50");
assert_eq!(data["lines"][1]["amount"], "$34.50");
assert!(data["lines"][0]["detail"].is_null());
let mut d = doc();
d.items[1].quantity = Some(Decimal::new(8, 0));
d.items[1].unit_price = Some(Decimal::new(125, 3));
d.items[1].amount = Decimal::new(100, 2);
let data = template_data(&d).unwrap();
assert_eq!(data["lines"][1]["detail"], "8 × $0.125");
}
/// The current copy says on which day it was re-created, from the
/// `as_of` it was given; the issued copy says nothing of the kind.
#[test]
fn only_the_current_copy_says_it_was_re_created() {
let mut d = doc();
assert!(template_data(&d).unwrap()["note"].is_null());
d.version = InvoiceVersion::Current {
as_of: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
};
let note = template_data(&d).unwrap()["note"]
.as_str()
.unwrap()
.to_string();
assert!(note.starts_with("Re-created on Mar 1, 2026"), "{note}");
d.note = Some("Voided.".into());
let note = template_data(&d).unwrap()["note"]
.as_str()
.unwrap()
.to_string();
assert!(note.ends_with(" Voided."), "{note}");
}
#[test]
fn two_hundred_lines_paginate() {
let mut d = doc();
d.items = (1..=200)
.map(|n| InvoiceItem {
description: format!("Line {n}"),
amount: Decimal::new(100, 2),
quantity: None,
unit_price: None,
})
.collect();
d.total = Decimal::new(20_000, 2);
d.settled.clear();
d.balance = d.total;
let pages = typeset_invoice(&d).unwrap();
assert!(pages.pages().len() > 1, "{} pages", pages.pages().len());
assert!(invoice_pdf(&d).unwrap().starts_with(b"%PDF"));
}
}
+4 -1
View File
@@ -35,7 +35,10 @@ 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 invoice_pdf::{
InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, InvoiceVersion, PaymentMethodDoc,
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};
+59 -3
View File
@@ -7,9 +7,14 @@
// 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,
// seller: { name, lines: [str] }, seller_email: null | str,
// seller_website: null | str,
// bill_to: { name, lines: [str] }, ship_to: [str],
// customer_ref: null | str, memo: null | str,
// lines: [{ description, detail: null | str, amount }], total,
// settled: [{ label, amount }], balance_label, balance,
// payment_methods: [{ label, lines: [str], qr: null | svg }],
// footer: null | str,
// stamp: null | str, stamp_draft: bool, stamp_line: null | str,
// note: null | str }
@@ -41,6 +46,8 @@
#text(15pt, weight: "bold", d.seller.name) \
#set text(fill: quiet)
#lines(d.seller.lines)
#if d.seller_email != none [#d.seller_email \ ]
#if d.seller_website != none [#d.seller_website \ ]
],
[
#text(22pt, weight: "bold", tracking: 0.08em, d.title) \
@@ -58,6 +65,12 @@
#v(2pt)
#text(weight: "semibold", d.bill_to.name) \
#lines(d.bill_to.lines)
#if d.ship_to.len() > 0 {
v(6pt)
text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "SHIP TO")
linebreak()
lines(d.ship_to)
}
],
grid(
columns: (auto, auto),
@@ -67,6 +80,7 @@
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 { () },
..if d.customer_ref != none { (text(fill: quiet, "Your reference"), d.customer_ref) } else { () },
),
)
@@ -82,7 +96,16 @@
text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "AMOUNT"),
),
table.hline(stroke: hair),
..d.lines.map(l => (l.description, l.amount)).flatten(),
..d.lines.map(l => (
{
l.description
if l.detail != none {
linebreak()
text(8.5pt, fill: quiet, l.detail)
}
},
l.amount,
)).flatten(),
table.hline(stroke: firm),
)
@@ -126,3 +149,36 @@
v(0.3in)
text(8.5pt, fill: quiet, d.note)
}
#if d.memo != none {
v(0.25in)
text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "NOTES")
linebreak()
d.memo
}
#if d.payment_methods.len() > 0 {
v(0.3in)
block(breakable: false, {
text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "HOW TO PAY")
v(4pt)
for m in d.payment_methods {
block(breakable: false, below: 8pt, grid(
columns: (1fr, auto),
column-gutter: 12pt,
align: (left + top, right + top),
[
#text(weight: "semibold", m.label) \
#set text(fill: quiet)
#lines(m.lines)
],
if m.qr != none { image(bytes(m.qr), format: "svg", width: 0.8in, height: 0.8in) } else { none },
))
}
})
}
#if d.footer != none {
v(0.3in)
align(center, text(8.5pt, fill: quiet, d.footer))
}
+117
View File
@@ -5205,3 +5205,120 @@ proptest! {
prop_assert_eq!(&iif.changes, &want);
}
}
// ---------------------------------------------------------------------
// The issued invoice's page (P4b S4.5): what a customer pays from
// ---------------------------------------------------------------------
fn numbered_invoice() -> ledger_import::InvoiceDoc {
use ledger_import::{InvoiceItem, InvoiceParty, PaymentMethodDoc};
ledger_import::InvoiceDoc {
number: "INV-0007".into(),
issue_date: "Oct 1, 2026".into(),
due_date: "Oct 31, 2026".into(),
terms: Some("Net 30".into()),
seller: InvoiceParty {
name: "Fernhill Labs LLC".into(),
lines: vec!["12 Orchard Lane".into(), "Springfield, UT 84000".into()],
},
seller_email: Some("billing@fernhill.example".into()),
seller_website: Some("fernhill.example".into()),
bill_to: InvoiceParty {
name: "東京珈琲 株式会社".into(),
lines: vec!["שלום עולם בע״מ".into(), "1-2-3 Chiyoda".into()],
},
ship_to: vec!["Dock 4, Harbor Road".into()],
customer_ref: Some("PO-4471".into()),
memo: Some("Thank you for the quick turnaround.".into()),
items: vec![
InvoiceItem {
description: "Design hours".into(),
amount: dec("99.99"),
quantity: Some(dec("3")),
unit_price: Some(dec("33.33")),
},
InvoiceItem {
description: "Hosting".into(),
amount: dec("1000.01"),
quantity: None,
unit_price: None,
},
],
total: dec("1100.00"),
balance: dec("1100.00"),
payment_methods: vec![
PaymentMethodDoc {
label: "Bank transfer".into(),
lines: vec!["Routing 123456789".into(), "Account 000111222".into()],
qr_svg: None,
},
PaymentMethodDoc {
label: "Card".into(),
lines: vec!["https://pay.example/fernhill".into()],
qr_svg: Some(
ledger_import::labels::qr_svg("https://pay.example/fernhill").unwrap(),
),
},
],
footer: Some("Thank you for your business.".into()),
..ledger_import::InvoiceDoc::default()
}
}
/// The issued invoice's PDF, read back with the PDF text reader, carries
/// the number, both dates, the total, the bill-to (CJK and right-to-left
/// text included), the terms, the reference, each payment method's label
/// and lines, and the quantity beside its price; and nothing internal.
#[test]
fn invoice_pdf_prints_what_it_should() {
let doc = numbered_invoice();
let pdf = ledger_import::invoice_pdf(&doc).unwrap();
// SHOT_DIR set: the page, 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-numbered.pdf"),
&pdf,
)
.unwrap();
}
let text = ledger_import::pdf_text(&pdf).unwrap();
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
for want in [
"INV-0007",
"Oct 1, 2026",
"Oct 31, 2026",
"$1,100.00",
"Net 30",
"PO-4471",
"Fernhill Labs LLC",
"billing@fernhill.example",
"fernhill.example",
"3 × $33.33",
"$99.99",
"$1,000.01",
"Bank transfer",
"Routing 123456789",
"Account 000111222",
"Card",
"https://pay.example/fernhill",
"Dock 4, Harbor Road",
"Thank you for your business.",
"Thank you for the quick turnaround.",
"東京珈琲",
"1-2-3 Chiyoda",
] {
assert!(flat.contains(want), "{want:?} missing from:\n{flat}");
}
// Right-to-left text comes back in reading or visual order depending
// on the reader; its letters are all there either way.
let has_hebrew = |s: &str| "שלוםעולם".chars().all(|c| s.contains(c));
assert!(has_hebrew(&flat), "Hebrew missing from:\n{flat}");
// The issued copy: no stamp, no "re-created", no balance of payments.
assert!(!flat.contains("Re-created"), "{flat}");
assert!(!flat.contains("DRAFT") && !flat.contains("PAID"), "{flat}");
// Nothing internal: no account code, project, or id.
for never in ["Project", "project", "class", "6999", "Account 4", "id:"] {
assert!(!flat.contains(never), "{never:?} on the page:\n{flat}");
}
}