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
185 lines
5.4 KiB
Typst
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))
|
|
}
|