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

+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))
}