Files
wanderbooks/crates/ledger-import/templates/invoice.typ
T
swaitsandClaude Fable 5.1 400d0cdcab 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
2026-10-29 12:55:10 -06:00

185 lines
5.4 KiB
Typst

// 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] }, 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 }
#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)
#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) \
#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)
#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),
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 { () },
..if d.customer_ref != none { (text(fill: quiet, "Your reference"), d.customer_ref) } 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
if l.detail != none {
linebreak()
text(8.5pt, fill: quiet, l.detail)
}
},
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)
}
#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))
}