Skip to content

Receipts

PDF receipts are RBO's most physically-constrained output. Stefan strings a racket, prints the PDF, folds it twice into a quarter, and clips it onto the racket. The customer then sees just the top-left quarter of the page when they pick it up.

That single physical-world UX constraint shapes everything below.

Confidence: High on the layout constraint and tooling; Medium on the language-selection rule (depends on closing the open question in i18n).

See ADR-0002 for the locked decision.

Constraints (locked)

# Constraint Source
R1 A4 page, single page per receipt Topic 3
R2 Total price MUST land in the top-left quadrant of the page (so it survives the fold-into-quarters) Topic 3 (Theo's question, Stefan's answer)
R3 Clean, modern visual style — no V1-byte-faithful constraint Topic 3
R4 Two language variants per template: EN and DE Topic 5
R5 Receipt is finalized at Strung date but always re-generatable from order data Topic 3
R6 One receipt per order — never batched across multiple rackets Topic 3
R7 Delivered both printed AND emailed Topic 3

Tooling: WeasyPrint

WeasyPrint (HTML+CSS → PDF) is the chosen renderer.

Option Verdict Why
WeasyPrint A4 native, CSS Paged Media support, designers iterate the receipt as a normal HTML page. Pixel control we need for R2. 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 (~50 receipts/year per stringer).
LaTeX Overkill, awkward for someone iterating layout in a browser preview.

WeasyPrint is bundled into the RBO container image. No external rendering service.

Page layout sketch

┌──────────────────────────────────────────────────────────┐
│ ┌──────────────────────────┐ ┌─────────────────────────┐ │
│ │  TOP-LEFT QUADRANT       │ │  top-right quadrant     │ │
│ │                          │ │                         │ │
│ │  [Stringer logo / name]  │ │  Client: Last, First    │ │
│ │                          │ │  Receipt #N             │ │
│ │  TOTAL: CHF XX.XX        │ │  Date: YYYY-MM-DD       │ │
│ │  ─────────────           │ │                         │ │
│ │  Racket: Pure Aero 98    │ │                         │ │
│ │                          │ │                         │ │
│ └──────────────────────────┘ └─────────────────────────┘ │
│ ┌──────────────────────────┐ ┌─────────────────────────┐ │
│ │ bottom-left quadrant     │ │ bottom-right quadrant   │ │
│ │                          │ │                         │ │
│ │ Main string detail       │ │ Cross string detail     │ │
│ │ Tension, BYO, price...   │ │ Tension, BYO, price...  │ │
│ │                          │ │                         │ │
│ │ Comments / notes         │ │ Stringer signature line │ │
│ │                          │ │ + thank-you message     │ │
│ └──────────────────────────┘ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
                  ↑                     ↑
              fold here             fold here
              (vertical)            (horizontal — into quarters)

The fold lines are the page midlines. After folding twice, the customer sees only the top-left quadrant. That quadrant carries: stringer ID, total in CHF, racket model, and client name (small). Everything else (string spec, comments, signature) is on the inside, available when the receipt is unfolded.

This is a deliberate inversion of the conventional invoice layout where the total goes bottom-right. The conventional layout was designed for a flat sheet on a desk; ours is designed for a folded card clipped to a racket handle.

Template structure

templates/receipts/
├── base.html         # shared CSS Paged Media + frame layout
├── receipt_en.html   # extends base, English copy
└── receipt_de.html   # extends base, German copy

The base template defines the four-quadrant grid via CSS; the language-specific templates fill in the strings (Total, Main string, Thank you, etc.). One CSS source of truth means a layout tweak needs no per-language porting.

CSS Paged Media skeleton (illustrative)

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

.receipt-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows:    1fr 1fr;
  height: calc(297mm - 24mm);  /* A4 height minus margins */
}

.quad-top-left    { grid-area: 1 / 1; }
.quad-top-right   { grid-area: 1 / 2; }
.quad-bottom-left { grid-area: 2 / 1; }
.quad-bottom-right{ grid-area: 2 / 2; }

.total {
  font-size: 28pt;
  font-weight: bold;
  /* lives inside .quad-top-left */
}

The 28pt total is intentional — visible from a meter away on a hung racket bag.

Language selection rule

Decision target (per i18n, pending close on the open question): receipt language = order.client_profile.person.default_locale if non-null, else Stringer.default_locale. Per-order override is not supported in V2 (could be added in V3 if Stefan asks).

The locale field lives on Person (the platform-level identity record per ADR-0004) — a Person carries one default_locale across every stringer they're a client of. Stringer-private overrides (if Stefan ever wants them) would live on ClientProfile, not on Person.

Selection is a single line: templates/receipts/receipt_{locale}.html. Adding a third language later is one new file plus a translation pass.

Receipt date scope (locked 2026-05-04)

The receipt only shows two dates:

  • Ordered date (Order.ordered_at) — when set; suppressed when NULL (typical on self-jobs).
  • Strung date (Order.strung_at) — always present at receipt-emit time (it is the moment the receipt is finalized; per ADR-0002 R5 / ADR-0007 the state machine guarantees strung_at is set when a receipt exists).

Order.returned_at and Order.paid_at are never rendered on the receipt, even when set, even on a post-Strung re-emit. They are post-job admin-tracking dates relevant to the stringer's internal record (the order page, reporting), not to the customer-facing receipt. A "PAID" affordance on the receipt was considered and rejected — the receipt is a stringing record, not a payment confirmation.

This rule cross-references Iris's docs/requirements/receipt-content.md — the receipt-content requirements doc carries the parallel field-level decision (the TR-4 "Returned date" and TR-5 "Paid date" rows are dropped from its quadrant tables).

If paid_at ever needs to surface to the customer in a future iteration, that's a V3 concern — likely as a separate "payment confirmation" artifact with its own PDF template, not by adding a date to the receipt.

Generation pipeline

HTTP POST /orders/{id}/receipt
Order ─────► resolve locale ─────► Jinja2 render templates/receipts/receipt_{locale}.html
        │                                │
        │                                ▼
        │                         HTML string
        │                                │
        │                                ▼
        │                         WeasyPrint(HTML).write_pdf()
        │                                │
        │                                ▼
        ▼                         PDF bytes
   email path                            │
        │                                ├──► HTTP response (download / browser-print)
        ▼                                │
SMTP attach + send                       └──► (also attached to email path)

Receipts are not persisted on disk. The pipeline reads the order, renders, ships. Every request that asks for a receipt re-runs the pipeline. This makes R5 (always re-generatable) the literal default behavior — there is no "old PDF" to drift away from current order data.

The order's price fields (labor_chf, strings_chf, total_chf) are denormalized snapshots at order-save time, not joined from the catalogue at receipt-render time. This is what protects historical receipts from changing if a catalogue price is later updated. Re-generation is data-driven, but the data it draws from is the order's own row, not the live catalogue.

Email delivery

When the stringer sets strung_at on an order, RBO:

  1. Renders the PDF (above).
  2. Attaches it to an email via the SMTP wrapper (see integrations).
  3. Sends to order.client_profile.person.email if non-null. If null (the Person is unclaimed — see ADR-0004 §Identity matching), the email step is skipped silently — the PDF is still rendered for the print path.
  4. Logs the send (success/failure) on the Order row (receipt_emailed_at).

A manual "resend receipt" button on the order page re-runs the same path on demand.

What this chapter does NOT cover

  • Logo/branding asset management — implementation detail; an uploads/ mount on the container or a base64-inlined SVG, both fine.
  • Receipt numbering scheme — 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 same transaction as strung_at set. PgBouncer-safe.
  • Print driver behavior. The browser handles printing; RBO just serves the PDF.