Skip to content

ADR-0002: Receipt PDF design — A4 with top-left total

  • Status: accepted
  • Date: 2026-04-27
  • Decider(s): Stefan (constraint), Theo (SA)

Update note (2026-05-02 — see ADR-0004). The "Language selection rule" decision below references Player.preferred_locale. The locale field has moved to Person.default_locale per the Person/ClientProfile split. The current language-selection rule is order.client_profile.person.default_locale ?? Stringer.default_locale — see docs/architecture/i18n.md and docs/architecture/receipts.md. The rule's shape (per-recipient locale wins, falling back to per-stringer default) is unchanged.

Receipt numbering is now locked separately in ADR-0008 (in-flight): per-stringer-per-year YYYY-NNNN minted via INSERT ... ON CONFLICT DO UPDATE on a receipt_counters table inside the strung_at-set transaction.

Context

PDF receipts are RBO V2's most physically constrained output. Stefan's stringing-day workflow:

  1. Strings the racket.
  2. Prints the receipt on plain A4 paper.
  3. Folds it twice into a quarter (page midline twice).
  4. Clips the folded card to the racket handle.
  5. Customer picks up the racket; what they see at handover is only the top-left quadrant of the page.

The total price must be visible at handover — that's the customer's first signal of "what do I owe?". On a conventional invoice layout, the total goes bottom-right; that's exactly what disappears when you fold an A4 into quarters with the printed side facing in.

Topic 3 confirmed three additional constraints:

  • One receipt per order (never batched).
  • Finalized at Strung date but always re-generatable from order data (FADP basics, not legal-archival).
  • Both printed and emailed.
  • EN + DE template variants (Topic 5).

The design decision is therefore not "which renderer" alone — it's the page layout plus the renderer choice plus the language-template structure, treated as a single locked design.

Options

Renderer

  • WeasyPrint (HTML+CSS → PDF). A4 native, CSS Paged Media support, designers iterate the receipt as a normal HTML page. Pure Python, fits the FastAPI container.
  • ReportLab. More code per layout change; harder to iterate the design as Stefan tweaks it.
  • Headless Chromium / Puppeteer. Heavyweight container dep for a tiny output volume.
  • LaTeX. Overkill, awkward for browser-based iteration.

Layout

  • Conventional invoice layout (logo top, line items middle, total bottom-right). Fails the fold-into-quarters constraint — total ends up on the inside of the folded card.
  • Top-left-quadrant-carries-the-total layout. Total visible at handover. All other quadrants populated with secondary info (string spec, comments, signature) that's available when the receipt is unfolded.

Language variants

  • One template + i18n string substitution. Cleaner for a SPA; in WeasyPrint, the gain is small and the per-language override of layout micro-tweaks is harder.
  • One template per language. Two files, shared base CSS, simpler mental model, easier for a non-programmer to tweak DE strings without touching EN.

Decision

  • Renderer: WeasyPrint.
  • Page: A4, single page per receipt.
  • Layout: total price in the top-left quadrant. Specifically: a four-quadrant CSS Grid where quad-top-left holds {stringer logo/name, total in CHF at 28pt, racket model, player name}. Other quadrants carry string spec, comments, and signature line.
  • Templates: one per language, sharing a base.html with the layout CSS. Files: templates/receipts/receipt_en.html, receipt_de.html.
  • Language selection rule: Player.preferred_locale ?? Stringer.default_locale. Per-order override deferred to V3 if asked.
  • Persistence: none. Receipts are re-rendered on every request; no PDFs cached on disk. Order's denormalized price snapshots (labor_chf, strings_chf, total_chf) protect historical receipts from drifting if a catalogue price is later updated.

Consequences

Good

  • The fold-into-quarters constraint is structurally enforced by the layout — not a fragile afterthought.
  • Re-generation from data is the literal default behavior; no stored-vs-current PDF drift.
  • WeasyPrint lets Stefan iterate the design as an HTML preview in a browser, then commit. No round-trip through a heavyweight design tool.
  • Adding a language later is one new template file plus a translation pass; no schema change.
  • Player.preferred_locale is the right granularity — clients of a German-speaking stringer can still receive English receipts.

Costs we accept

  • Conventional invoice mental model is broken — anyone landing on the receipt for the first time sees the total in an unusual place. We mitigate by making it the largest typographic element on the page.
  • No pre-rendered PDFs means a tiny CPU cost on every email + every print. WeasyPrint renders this single-page receipt in well under 1 s; at our volume this never matters.
  • Two language templates means a layout tweak takes two file edits — kept manageable by sharing the base CSS, but it's a small ongoing cost as the layout evolves.
  • No legal-archival immutability. Acceptable per Topic 1 (hobby, not business; no audit obligation).

Cross-references