Skip to content

Receipt PDF — Visual Design

The customer-facing artifact. A4, single page, folded twice into quarters; the customer sees only the top-left quadrant at handover. Owned by Mira. Cross-cuts: receipt-content (field contract), ADR-0002 (layout constraint), ADR-0004 (Rule #1 redaction), ADR-0008 (numbering), receipts.md (pipeline), i18n.md (locale rule), design-tokens.

Source requirement

  • Field contract: every required field in receipt-content § Quadrant-by-quadrant field list is rendered.
  • Layout constraint (ADR-0002): A4 single page; total in the top-left quadrant; one base layout shared between EN + DE templates.
  • Numbering (ADR-0008): YYYY-NNNN, per (stringer, year), assigned at first emit, persisted, reused on re-emit.
  • Redaction (ADR-0004 + OQ-R-1): two visibility modes — default (originating stringer + client) renders comments; Rule #1 grantee renders without comments.
  • Locale (i18n.md): order.client_profile.person.default_locale ?? Stringer.default_locale picks receipt_en.html or receipt_de.html. Same base.html + CSS.
  • Mandatory disclosure: "Not VAT-registered" / "Nicht mehrwertsteuerpflichtig." footer band, every receipt.

Goal

Make a printed-and-folded receipt that:

  1. Answers "how much do I owe?" at handover with one glance at the visible quadrant.
  2. Reads as a professional artifact when the client unfolds it later — not a hobbyist's text dump.
  3. Renders the same way in EN and DE without per-locale layout drift.
  4. Survives a black-and-white office printer and a typical sub-£100 inkjet equally well.
  5. Re-renders consistently for two redaction modes (default and Rule #1 grantee) without layout reflow that would betray which mode produced it.

Page geometry (locked from ADR-0002)

A4 portrait, 210 × 297 mm. WeasyPrint @page { size: A4; margin: 12mm; }. Inside the 12 mm margins, the printable area is 186 × 273 mm; the four quadrants are each ~93 × 136.5 mm.

┌──────────────────────────────────────────────────────────────────┐ ← 12 mm top margin
│                                                                  │
│ ┌────────────────────────────┐  ┌────────────────────────────┐   │
│ │  TOP-LEFT (visible at      │  │  TOP-RIGHT                 │   │
│ │  handover after fold)      │  │  (inside the fold)         │   │
│ │                            │  │                            │   │
│ │  Logo or stringer name     │  │  Receipt #YYYY-NNNN        │   │
│ │  Stringer business name    │  │  Strung   2026-04-30       │   │
│ │                            │  │  Ordered  2026-04-28       │   │
│ │  ──────────────            │  │                            │   │
│ │                            │  │  For                       │   │
│ │  CHF 48.00                 │  │  Lukas Müller              │   │
│ │  (28pt display)            │  │  (instance ID, if set)     │   │
│ │                            │  │                            │   │
│ │  Babolat Pure Aero 98      │  │                            │   │
│ │  Lukas M.                  │  │                            │   │
│ └────────────────────────────┘  └────────────────────────────┘   │
│                                                                  │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ horizontal fold ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│                                                                  │
│ ┌────────────────────────────┐  ┌────────────────────────────┐   │
│ │  BOTTOM-LEFT               │  │  BOTTOM-RIGHT              │   │
│ │                            │  │                            │   │
│ │  MAIN STRING               │  │  CROSS STRING              │   │
│ │  Luxilon ALU Power 1.25    │  │  Same as main              │   │
│ │  24 kg          CHF 18.00  │  │  24 kg          CHF 18.00  │   │
│ │  [BYO]                     │  │                            │   │
│ │  Color: black              │  │                            │   │
│ │                            │  │  Labor          CHF 25.00  │   │
│ │                            │  │  Strings        CHF 18.00  │   │
│ │                            │  │  ─────────────             │   │
│ │                            │  │  TOTAL          CHF 48.00  │   │
│ │                            │  │                            │   │
│ │                            │  │  Thank you for your        │   │
│ │                            │  │  business.                 │   │
│ └────────────────────────────┘  └────────────────────────────┘   │
│                                                                  │
│ ┌──── COMMENTS BAND (conditional, default mode only) ────────┐   │
│ │  Notes: lighter cross tension as discussed                 │   │
│ └────────────────────────────────────────────────────────────┘   │
│                                                                  │
│ ┌──── FOOTER BAND ───────────────────────────────────────────┐   │
│ │  Not VAT-registered.                                       │   │
│ │  Stefan Wagen · Bahnhofstrasse 1, 8000 Zürich              │   │
│ │  +41 79 000 00 00 · stefan@example.ch                      │   │
│ └────────────────────────────────────────────────────────────┘   │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘ ← 12 mm bottom margin
                       ↑                       ↑
                  vertical fold           vertical fold

The two fold lines are the horizontal and vertical page midlines. After folding twice, the receipt is a quarter-A4 card (~105 × 148 mm) clipped to the racket; only the top-left quadrant is visible.

Quadrant-by-quadrant content

The field IDs (TL-1, TR-1, etc.) are the requirements-page contract from receipt-content § Quadrant-by-quadrant field list.

Top-left (visible at handover)

Slot Field Source Render notes
Top of quadrant TL-1 logo (if set) else TL-2 display name as wordmark Stringer.logo or Stringer.display_name Logo: max 120 × 36 mm internal, contained, never stretched; PNG/SVG/JPG accepted by template (asset-handling is Pax's call). When no logo, the display name renders as text-h2 (20/28 px) in slate-900. The two-line stringer block is ~6–8 mm tall total.
Below TL-3 business name Stringer.business_name Optional. text-small (14/20 px), slate-600. Suppressed when NULL.
1.5 × space-6 below TL-4 TOTAL Order.total The 28pt display anchor. font-weight: 700, slate-900, tabular-nums, baseline aligned to a horizontal rule above. Format CHF 48.00 (Babel format_currency('CHF', locale); in V2 both locales render CHF X.XX per receipt-content § Locale handling). The "CHF" prefix is text-h2 (20pt) so the number reads bigger; "48.00" is 28pt.
Below TL-6 racket model order.racket.make + model + version text-body (16/24 px), slate-900. Concise: "Babolat Pure Aero 98".
Below TL-5 player name order.client_profile.person.display_first_name + display_last_name text-small (14/20 px), slate-600. Format: "First Last". On self-jobs (is_self_for_stringer = TRUE), suppressed entirely.

Visual hierarchy: the eye lands on the total, then the racket model (the "is this the right racket?" check), then the player name. The stringer block at the top is identity at a glance.

No-logo case. Per OQ-R-5 default, the logo is uploaded in account-settings; new stringers do not have one until they upload. The template gracefully falls back: Stringer.display_name renders as a wordmark (text-h2, 700 weight, slate-900, letter-spacing: -0.01em) in the same vertical slot. The total's vertical position does not shift between logo / no-logo modes — the slot is fixed (~12 mm tall); a missing logo leaves whitespace above the wordmark, the wordmark itself is bottom-aligned to the slot. This keeps the visible quadrant's geometry stable across stringers.

Top-right (inside fold)

Slot Field Source Render notes
Top of quadrant TR-1 receipt number Order.receipt_number Format YYYY-NNNN per ADR-0008. Typographic prominence: text-h2 (20/28 px), slate-900, tabular-nums, prefix #. Right-aligned within the quadrant. Five-digit overflow guard: the slot reserves width for #YYYY-NNNNN (one extra digit). At V2 volume (≤ 50 receipts/year/stringer per v1-baseline.md) overflow is a 200-year problem; the reservation is essentially free and prevents a layout regression if a high-volume stringer ever joins.
Below TR-2 strung date Order.strung_at Required. text-body, slate-900, tabular-nums. Format ISO YYYY-MM-DD per OQ-R-2 default. Label "Strung" (EN) / "Bespannt" (DE).
Below TR-3 ordered date Order.ordered_at Optional. Same shape as TR-2; suppressed entirely (label + value) when NULL — the row collapses, no empty placeholder. Label "Ordered" (EN) / "Bestellt" (DE).
space-6 below TL-5 (recap) "For" + player name order.client_profile.person.display_first_name + display_last_name The unfolded receipt benefits from a clearer "this is for X" line on the inside. text-h3 weight 600, slate-900. Suppressed on self-jobs.
Below TR-4 racket instance ID order.racket.serial_or_instance_id Optional. text-small, slate-600. Format: parenthesised, e.g. "(PA98_2023_25)". Disambiguates when the player owns multiple of the same model.

Per OQ-R-4: returned_at and paid_at are NEVER on the receipt. The TR quadrant has only ordered_at + strung_at.

Bottom-left (inside fold) — Main string

Slot Field Source Render notes
BL-1 header "MAIN STRING" / "HAUPTSAITE" i18n constant text-h3 (18/24 px), 600, slate-900, letter-spacing: 0.04em, all-caps. Top of quadrant.
BL-2 string id order.main_string.{manufacturer, model, gauge} or order.main_string_text Order field text-body, slate-900. Concise: "Luxilon ALU Power 1.25". When main_string_text is set, render verbatim.
BL-3 + BL-4 row tension + price Order.main_tension, Order.main_price Two-column row: tension left ("24 kg", text-body, tabular-nums); price right ("CHF 18.00", text-body, tabular-nums, slate-900). Per BL-4, price is always shown (visible even when BYO); excluded from the strings subtotal when BYO.
BL-5 BYO badge Order.main_byo = TRUE Conditional Renders as a badge below the tension+price row: "BYO" in text-tiny (12/16 px), 500, slate-700 text on slate-100 background, rounded-sm, padding space-1 × space-2. Localized per locale (EN: "BYO"; DE: "BYO" — kept short / international). Adjacent annotation text-small, slate-600: "(not charged)" / "(nicht berechnet)".
BL-6 color Order.main_color Optional text-small, slate-600, label "Color: " / "Farbe: ", value rendered verbatim. Suppressed when NULL.
Slot Field Source Render notes
BR-1 header "CROSS STRING" / "QUERSAITE" i18n constant Same shape as BL-1.
BR-2 string id order.cross_string.{...} or order.cross_string_text Order field Same shape as BL-2. Cross-equals-main short form: when (main_string_id, main_tension, main_byo, main_color) == (cross_*), render the body of BR-2 + BR-3 + BR-4 as the single line "Same as main." / "Wie Hauptsaite." in text-body, slate-600, italic. Saves space and reads truer to UC-2's 89% case. The header (BR-1) is still rendered.
BR-3 + BR-4 row tension + price Same as BL-3 + BL-4 Same shape; suppressed under cross-equals-main short form.
BR-5 BYO badge Order.cross_byo = TRUE Conditional Same shape as BL-5.
BR-6 color Order.cross_color Optional Same shape as BL-6.
space-6 separator (horizontal border-slate-200 1px line) Below the string detail; above the totals block.
BR-7 labor Order.labor Required Two-column row: label "Labor" / "Arbeitslohn" left, value right. text-body, tabular-nums, slate-900.
BR-8 strings subtotal Order.strings_subtotal Required Same shape: label "Strings" / "Saiten", value right. Computed server-side from non-BYO sides.
BR-9 total recap Order.total Required Above-the-row hairline (border-slate-300 1.5px). Label "TOTAL" all-caps, text-h3 600. Value text-h2 700, slate-900. Same number as TL-4 but in the conventional invoice position; gives the unfolded receipt a familiar reading.
space-6 below BR-10 thank-you Stringer.receipt_thankyou_text (verbatim) else i18n constant receipt.thankyou.default Per OQ-R-3 closure. When receipt_thankyou_text is set, render verbatim with line breaks preserved (locale-neutral; the stringer wrote it in their preferred language). When NULL, fall back to the i18n string for the resolved receipt locale (EN: "Thank you for your business." / DE: "Vielen Dank für Ihren Auftrag."). text-small, slate-600, italic, max-width keeps it readable (no full-quadrant text run).
┌────────────────────────────────────────────────────────────┐
│  Notes / Anmerkungen                                       │
│  lighter cross tension as discussed                        │
└────────────────────────────────────────────────────────────┘
  • Renders only when Order.comments is non-empty AND the receipt is in the default visibility mode (originating stringer or client viewer). See Redaction modes.
  • Spans both bottom quadrants in width.
  • Header "Notes" (EN) / "Anmerkungen" (DE), text-small 500, slate-600, all-caps with letter-spacing: 0.04em.
  • Body: Order.comments rendered verbatim, line breaks preserved (white-space: pre-wrap), text-small, slate-900.
  • Background: slate-50 (#f8fafc), border-slate-200 1px, rounded (8px), padding space-4.
  • Vertical position: above the footer band, below the bottom quadrants. When suppressed (empty comments OR Rule #1 mode), the footer band rises into the freed space — the page-break behaviour stays single-page in both cases.
┌────────────────────────────────────────────────────────────┐
│  Not VAT-registered.                                       │
│  Stefan Wagen · Bahnhofstrasse 1, 8000 Zürich              │
│  +41 79 000 00 00 · stefan@example.ch                      │
└────────────────────────────────────────────────────────────┘
Slot Field Source Render notes
F-1 "Not VAT-registered." / "Nicht mehrwertsteuerpflichtig." i18n constant Required, every receipt. text-tiny (12/16 px), 500, slate-600. Bold-italic to signal regulatory disclosure without shouting. EN exact: "Not VAT-registered." DE exact: "Nicht mehrwertsteuerpflichtig."
F-2 + F-3 + F-4 line address · phone · email Stringer.business_address, Stringer.phone, Stringer.email Single-line interpolation with · (mid-dot) separators. Address optional (suppressed when NULL); phone optional (suppressed when NULL); email always rendered (F-4 required). text-tiny (12/16 px), slate-600.

The footer band is border-top: 1px slate-200, padding-top: space-3, margin-top: space-4 from the comments band (or directly from the bottom quadrants when comments are absent).

Redaction modes

Two render modes per OQ-R-1 closure + ADR-0004 § Visibility / redaction:

Mode A — Default (originating stringer + client viewer)

Every field above renders. Comments band visible when Order.comments is non-empty.

This is the mode used for:

  • The print path (always — Stefan prints for handover).
  • The client's emailed PDF (always — they are the order's client_profile.person).
  • The originating stringer's on-demand view from the order page.

Mode B — Rule #1 grantee view

Comments band suppressed entirely (header + body). All other fields render identically.

Important: the redaction is at render time (per ADR-0004 § Visibility / redaction) — the same template runs in both modes; only comments is omitted from the context. This means:

  • Layout reflows minimally (the footer rises into the freed space; the bottom quadrants do not move).
  • A Rule #1 grantee viewing the receipt cannot tell from the rendered output whether the originating stringer left comments empty or whether they were redacted. (This is intentional privacy hygiene — the absence of a comments band carries no signal.)

Per ADR-0004 § Visibility / redaction matrix Rule #1 also redacts client PII and pricing. Round 2 design caveat: the Rule #1 view of an Order is rendered through a different surface (the Shared-with-me inbox detail page), NOT through the printable receipt PDF. Rule #1 grantees do not get a downloadable receipt PDF — they get an HTML detail view with the redaction matrix applied. This page therefore specifies only the originating-stringer + client receipt PDF; the shared-with-me inbox covers the Rule #1 grantee's read surface.

Why split this way: a printable PDF that carries the racket's technical setup (the only part Rule #1 grantees need) without the customer-facing receipt framing (total, client PII, etc.) is simpler than two parallel PDF templates. The HTML detail view gives the grantee what they need (a stringing spec) without trying to dual-purpose the receipt artifact.

WeasyPrint's CSS Paged Media support is the primary surface; the template should fall back gracefully when opened in a browser preview.

Page setup

@page {
  size: A4;
  margin: 12mm;
}

html, body {
  font-family: 'Inter', system-ui, sans-serif;
  font-feature-settings: 'tnum';  /* tabular-nums for prices, tensions, dates */
  color: #0f172a;                  /* slate-900 */
  font-size: 16px;
  line-height: 1.5;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;       /* preserves slate-50 comment band background */
}

.receipt-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  height: calc(297mm - 24mm);
  gap: 0;                          /* fold lines are conceptual; no inter-quadrant gap */
}

.quad {
  padding: space-6 space-4;        /* ~24px × 16px interior padding */
  display: flex;
  flex-direction: column;
}

.total {
  font-size: 28pt;
  font-weight: 700;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

Page-break behaviour

  • Single-page contract. Every V2 receipt fits in one A4 page. The CSS uses height: calc(297mm - 24mm) to fix the grid to the printable area.
  • Comments band overflow guard: if Order.comments is unusually long, the template caps it visually with max-height: 30mm + overflow: hidden and a fade-out gradient at the bottom. Stefan-side admin convention is "comments stay short"; the cap prevents a malformed receipt from a typo-pasted novel. (Stefan flagged: a soft cap, not a hard char-count rule.)
  • No page-break-before / page-break-after directives. The single-page contract makes them unnecessary; their absence ensures WeasyPrint never accidentally splits a quadrant across pages.
  • break-inside: avoid on every .quad — defensive: if a future content change spills, no quadrant gets sliced.

Margin choices

  • 12 mm page margin matches ADR-0002. Tighter would lose to printer-driver minimum margins (most consumer printers floor at 5–8 mm; 12 mm gives a safe buffer and visual breathing room).
  • Interior quadrant padding space-6 (24 px) vertical, space-4 (16 px) horizontal — gives the quadrants a card-like feel without wasting real estate on a 93 × 136 mm canvas.
  • Footer band offset: padding-top: space-3 from the bottom-quadrant content; margin-bottom: 0 (the page margin handles the rest).

Font fallbacks for WeasyPrint

WeasyPrint can embed fonts from @font-face. The receipt embeds Inter (Open Font License, free for commercial / personal use):

@font-face {
  font-family: 'Inter';
  src: url('../static/fonts/Inter-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Inter';
  src: url('../static/fonts/Inter-Bold.woff2') format('woff2');
  font-weight: 700;
}

Fallback chain: 'Inter', 'Helvetica Neue', 'Arial', system-ui, sans-serif. This keeps the receipt readable if Inter ever fails to embed (e.g. font file missing in a future build) — the receipt degrades to a clean sans-serif, never to a fallback that would re-flow the layout (e.g. avoid Times New Roman as a fallback — its metrics differ enough to push the total off the 28pt line).

Color choices for print

  • Body text: slate-900 (#0f172a) prints near-black on grayscale printers; on color printers it's a slightly cool-black, which reads as professional rather than bureaucratic.
  • Muted text (slate-600 #475569): for secondary metadata. Prints as ~50% gray, still legible at 14 px.
  • Comments-band background (slate-50 #f8fafc): prints as ~5% gray, just enough to visually distinguish the band without becoming a dark muddy box on a low-quality printer. The print-color-adjust: exact directive ensures it actually prints (browsers default to "save toner" otherwise).
  • No brand color on print. Indigo-700 is reserved for the web UI; the receipt is intentionally monochrome (so it prints identically on color and B&W). The only allowed accent is the BYO badge background (slate-100).

Typography on the receipt

Role Size Weight Use
Total (TL-4) 28 pt 700 Per ADR-0002 + design-tokens text-display. Reserved for this slot.
Receipt number (TR-1), TOTAL recap (BR-9 value) 20 pt 700 text-h2.
Section headers (MAIN STRING / CROSS STRING / TOTAL label) 18 pt 600 text-h3 + 0.04em letter-spacing all-caps.
Body fields 16 pt 400 text-body.
Secondary metadata, dates labels, business info 14 pt 400 text-small.
BYO badge, "Not VAT-registered" disclosure, footer 12 pt 500 text-tiny. Floor of the scale.

Inter's tabular-numerals variant (font-feature-settings: 'tnum') is the body default — keeps CHF 48.00, 24 kg, 2026-04-30 aligned in their respective columns.

Locale templates

Per ADR-0002 + receipts.md:

templates/receipts/
├── base.html         # shared layout + CSS + Jinja2 blocks
├── receipt_en.html   # extends base; only fills {% block lang_strings %}
└── receipt_de.html   # extends base; only fills {% block lang_strings %}

base.html carries every layout decision in this page — quadrant grid, paddings, font stack, conditional comments band, footer band placement. The two language templates carry only the i18n strings (header labels, "Strung", "Labor", footer disclosure, fallback thank-you). This keeps Stefan's iteration cost low: edit base.html once, both locales pick it up.

EN strings — Mira's draft (Iris's DE pass follows)

Slot EN string i18n key
TL "for" recap label "For" receipt.for
TR-1 prefix "Receipt" receipt.number_label
TR-2 label "Strung" receipt.strung_label
TR-3 label "Ordered" receipt.ordered_label
BL-1 header "MAIN STRING" receipt.main_string_header
BR-1 header "CROSS STRING" receipt.cross_string_header
BR-2 short form "Same as main" receipt.same_as_main
BL-3 / BR-3 unit "kg" receipt.tension_unit
BL-5 / BR-5 badge "BYO" receipt.byo_badge
BL-5 / BR-5 annotation "(not charged)" receipt.byo_not_charged
BL-6 / BR-6 label "Color" receipt.color_label
BR-7 label "Labor" receipt.labor_label
BR-8 label "Strings" receipt.strings_subtotal_label
BR-9 label "TOTAL" receipt.total_label
BR-10 fallback "Thank you for your business." receipt.thankyou.default
Comments band header "Notes" receipt.comments_header
F-1 disclosure "Not VAT-registered." receipt.vat_disclosure

DE width budget (designer note for Iris)

Several DE strings are noticeably longer than EN — the layout tolerates them all (the bottom-row labels are right-aligned, the value column is fixed-width, so a longer label column just compresses the gap):

  • "TOTAL" → "GESAMT" (similar).
  • "Labor" → "Arbeitslohn" (~1.6× longer) — within budget.
  • "Strings" → "Saiten" (similar).
  • "Not VAT-registered." → "Nicht mehrwertsteuerpflichtig." (~1.5× longer) — within text-tiny line capacity at the footer's two-line wrap allowance.
  • "Same as main" → "Wie Hauptsaite" (similar).
  • "Thank you for your business." → "Vielen Dank für Ihren Auftrag." (similar).

No DE-specific layout overrides needed.

Logo handling

Per OQ-R-5 default: logo is uploaded in account-settings as a follow-up; new stringers do not have one until they upload.

State Render
Stringer.logo is set <img> tag, max-height: 36mm, max-width: 60mm, object-fit: contain. Bottom-aligned to a fixed ~12mm slot at the top of the top-left quadrant.
Stringer.logo is NULL Stringer.display_name rendered as text-h2 (20pt) 700 weight wordmark in the same slot, bottom-aligned.

The slot's vertical extent is fixed (~12 mm); whether logo or wordmark, the total below sits at the same Y coordinate. This stabilises the visible-quadrant geometry across stringers — Stefan's receipt and a colleague's receipt look like the same family of artifact, even if one has a logo and the other doesn't.

Asset constraints (informative for Pax — implementation lane):

  • Allowed formats: PNG, SVG, JPG. SVG preferred (sharp at any zoom).
  • Max upload size: 1 MB (Pax sets the cap).
  • Aspect ratio: any; the template object-fit: contain adapts.
  • Background: prefer transparent; the receipt's white surface shows through.

Accessibility

PDFs have weaker a11y guarantees than HTML, but WeasyPrint inherits HTML semantics:

  • Heading hierarchy: every section header (MAIN STRING, CROSS STRING, TOTAL, etc.) is a real <h2> / <h3> in the source HTML, not a styled <div>. PDF readers that follow PDF/UA tagging surface them.
  • Tables for line items: the labor/strings/total block is a <table> with <th> for labels and <td> for values. Screen readers read the structured data; sighted users see the layout.
  • Logo alt: Stringer.display_name (so a screen reader still gets identity even when the logo is an image).
  • Color contrast: every text/background pair on the receipt meets WCAG 2.1 AA. slate-900 on white = 16.1:1; slate-600 on white = 7.55:1; slate-700 on slate-100 (BYO badge) = 9.6:1. Footer's text-tiny slate-600 on white = 7.55:1, well above the 4.5:1 floor.
  • Tabular numerals ensure column alignment is reliable for low-vision users zooming in.
  • Email PDF: the email body itself (specified in receipt-email; transport per integrations.md) recapitulates the receipt number + racket + total in plain text, so screen-reader users get the content even if their PDF reader struggles.

i18n affordance

Every string above is {% trans %}-driven via the per-locale template. Three categories:

  1. Catalogue-driven — section headers, labels, BYO badge, comments header, footer disclosure, fallback thank-you. Keys listed in the EN strings table above.
  2. Data — stringer name, business name, address, phone, email, racket make/model, string make/model/gauge, color, comments. Stored as-is, displayed as-is. Per i18n § Catalogue / data localization.
  3. Format — receipt number (#YYYY-NNNN pattern), dates (ISO YYYY-MM-DD per OQ-R-2 default), prices (Babel.format_currency('CHF', locale) — both locales render CHF X.XX), tensions (Babel.format_unit('kg', locale)).

Stringer-customizable thank-you (BR-10)

Per OQ-R-3 closure, Stringer.receipt_thankyou_text is locale-neutral free text. The stringer writes it in their preferred language; if they want one EN and one DE version, they leave the field NULL and the platform-default i18n string handles it. The template logic:

{% if stringer.receipt_thankyou_text %}
  <p class="thankyou">{{ stringer.receipt_thankyou_text }}</p>
{% else %}
  <p class="thankyou">{% trans %}receipt.thankyou.default{% endtrans %}</p>
{% endif %}

The custom text is rendered with white-space: pre-wrap so a stringer can put a two-line message in (their own line break preserved).

Cross-reference back to data shape

Per the data model:

  • Order.total, Order.strings_subtotal, Order.labor, Order.main_price, Order.cross_price — all denormalized snapshots at order-save time, not joined from the catalogue at receipt-render time. Per receipts.md § Generation pipeline. This is what protects historical receipts from changing if a catalogue price is later updated.
  • Order.receipt_number — assigned at first emit (T2 transition) per ADR-0008; reused on re-emit; never re-assigned. The template just reads this field.
  • Order.main_byo, Order.cross_byo — drive the conditional badge + "(not charged)" annotation + the strings-subtotal exclusion.
  • Order.comments — drives the conditional comments band; redacted under Rule #1 view.

Every receipt-side field has a single source. Pax's receipt-rendering handler is therefore a thin Jinja context-builder + WeasyPrint call; no business logic.

Open questions for Stefan (with proposed defaults)

  1. Logo aspect-ratio handling when the upload is wildly wrong (e.g. a tall portrait logo). Proposed default: object-fit: contain with max-height: 36mm and max-width: 60mm. A weird aspect just centers in the slot with whitespace; we don't auto-crop. Alternative: hard-clip with object-fit: cover (potentially mangling the brand). The contain default is safer; Stefan can re-upload if it looks off.
  2. Comments-band overflow cap (currently max-height: 30mm with fade-out). Proposed default: keep the soft cap; flag a Round-2.x feature for "comments truncated — full text in account view" if Stefan ever sees it triggered. Practical: Stefan's V1 comments are typically one short line; the cap is defensive, not expected to fire.
  3. Currency format CHF X.XX in both locales. Per receipt-content § Locale handling. Proposed default: keep. Babel's DE-CH format would render CHF 48.00 identically to EN-CH for the CHF currency — no divergence to surface. If a future EUR currency is added, locale-specific formatting (€48.00 vs 48,00 €) would matter; flagged then, not now.
  4. "Not VAT-registered" line — placement above or below the contact line? Proposed default: above. The disclosure is the regulatory commitment; the contact info is the discoverable detail. Reading top-to-bottom, the disclosure lands first, which feels right ("here's what I am, here's how to reach me"). Easy flip if Stefan reads it the other way.
  5. Cross-equals-main short form ("Same as main") vs full repeat in BR-2/3/4. Proposed default: short form. Reads truer to the 89% UC-2 case, saves the bottom-right quadrant some real estate, and matches the dashboard's analogous collapse. Alternative: always-repeat (visually symmetric quadrants). Symmetry is nice but adds visual noise; the short form is opinionated in favour of readability.
  6. Receipt-number prefix character — current default #YYYY-NNNN. Proposed default: keep #. International, recognisable as "an identifier." Alternatives: Nr. (DE-natural; would force a per-locale variant), Receipt spelled out (verbose, eats column width). The # prefix is locale-neutral.
  7. Logo file format precedence — if Stefan uploads multiple formats over time, latest wins. Proposed default: latest-upload wins, single-slot replacement. No history of past logos. (Stefan can always re-upload; account-settings nice-to-have is a "preview before save" affordance, flagged for the onboarding-wizard round.)
  8. Print-only fold-line guides — should the receipt render hairline guides at the page midlines to help Stefan fold accurately? Proposed default: no. Stefan strings 50/year; muscle memory + page-edge alignment is faster than a printed cue, and a printed cue would be visible inside the unfolded card (which is suboptimal aesthetically). If Stefan ever asks for fold guides, a prefers-print-fold-guides style toggle can add hairline border-bottom / border-right on the top-left quadrant — additive, easy.

Cross-references