Files
wanderbooks/crates/ledger-import/templates/invoice.typ
T
swaitsandClaude Opus 5.5 c1f9001cfe
check / check (push) Canceled after 0s
feat: the invoice PDF, with stamps and each payment (P4b S3)
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
2026-10-24 18:20:55 -06:00

129 lines
3.8 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] }, bill_to: { name, lines: [str] },
// lines: [{ description, amount }], total,
// settled: [{ label, amount }], balance_label, balance,
// 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)
],
[
#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)
],
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 { () },
),
)
#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, 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)
}