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 guaranteesstrung_atis 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:
- Renders the PDF (above).
- Attaches it to an email via the SMTP wrapper (see integrations).
- Sends to
order.client_profile.person.emailif 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. - 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 viaINSERT ... ON CONFLICT DO UPDATEon areceipt_counterstable inside the same transaction asstrung_atset. PgBouncer-safe. - Print driver behavior. The browser handles printing; RBO just serves the PDF.