Receipt Delivery — Triggers, Recipients, Channels, Failure Semantics¶
This page is the delivery-semantics rules layer for the receipt: when it is emitted, to whom, on which channel, and what happens on failure or re-emit. It is the companion page to receipt-content.md (which pins the field list) and the order-lifecycle counterpart order-lifecycle.md (which owns the re-emit trigger list). This page does not redefine those — it picks up where they leave off and locks the parts the implementation needs that neither page covers in full: recipient resolution, channel selection, SMTP failure handling, the precise meaning of "one receipt per racket-order," and the explicit V2-out-of-scope list. Tracked in racket-book#116.
Architectural counterparts: ADR-0007 (state machine — trigger source), ADR-0008 (numbering — same-number-across-re-emits invariant), ADR-0009 (SMTP env contract). UX counterpart: Mira's receipt-email design (email-body visual: subject, salutation, summary block, signature footer, EN/DE strings). Implementation counterpart: M14 (racket-book#67), C2 (racket-book#44 — silent + log on failure).
Scope of this page¶
- Trigger: the moment(s) at which RBO emits a receipt (initial + re-emit).
- Recipient resolution: which address (and entity) gets the email; what happens when there is no email.
- Channel selection: email vs. print-only in V2; future channels in V3.
- Failure semantics: what happens when the SMTP send fails; what happens for bounces.
- Idempotency / re-emit: what "one receipt per racket-order" actually means in the presence of edits; what changes per emit.
- What is NOT in V2: an explicit list to prevent scope creep.
- Open questions (with proposed defaults) that Stefan should confirm before Pax-A's M14 implementation lands.
Out of scope:
- The receipt's content (field list, layout, locale handling) — owned by
receipt-content.md. - The state machine that drives the trigger — owned by
order-lifecycle.mdand ADR-0007. - The receipt-numbering scheme — owned by ADR-0008.
- The email body (subject line, salutation, paragraph copy, EN/DE strings) — owned by Mira's receipt-email design.
- The PDF rendering pipeline (WeasyPrint, templates, CSS) — owned by ADR-0002 +
docs/architecture/receipts.md. - The SMTP wrapper / provider — owned by
docs/architecture/integrations.md(Resend SMTP) + ADR-0009 (env contract). - The admin re-send endpoint (UI / route shape) — flagged as a follow-up implementation requirement; out of scope for this requirements page.
1. Trigger¶
The receipt is emitted on two classes of event: the initial emit at the Strung transition, and re-emits triggered by post-Strung edits to receipt-affecting fields.
1.1 Initial emit¶
| Aspect | Decision |
|---|---|
| Triggering event | The Ordered → Strung transition (T2 in the order-lifecycle transition table) — i.e. the stringer fills Order.strung_at. |
| Pipeline order | Per ADR-0008 §Mint timing (T-1) the receipt number is minted in the same transaction as strung_at is set. The PDF render + email send happen after that transaction commits — so the receipt number is durable on the Order row before any email goes out. |
| Self-job specialization | Per order-lifecycle § Self-job vs. client-job differences: on a self-job (is_stringer_self = TRUE), the PDF is rendered and available for download but is not auto-emailed even if the self-Person has an email. The stringer can manually emit if they want a personal record. |
| Synchronous vs. async | Inline (synchronous within the request), per the M14 acceptance criteria. The state transition does not wait on email-send success (per §4 below — fail-soft); it does wait on render success because PDF-render failure is a user-visible bug, not a transient network event. |
| Order state at emit | Strung or later. The receipt is never emitted while the Order is in Draft or Ordered. |
1.2 Re-emit¶
A re-emit is a fresh PDF render + a fresh email send for an already-Strung Order. The full list of fields whose post-Strung edit triggers a re-emit is owned by order-lifecycle § Receipt re-emit triggers; this page does not duplicate that list. The delivery-semantics consequences:
| Aspect | Decision |
|---|---|
| Trigger source | Edits to receipt-affecting fields (per OQ-L-1 (a) — closed 2026-05-04) on Strung+ orders. The full list lives on the order-lifecycle page. |
| Email behaviour | The client receives a fresh email on each re-emit (proposal — see open question OQ-D-1). The email is identical in shape to the initial emit; the subject line indicates "Updated receipt" or equivalent (Mira's call). |
| Receipt number | Unchanged — re-emit reuses the original receipt number (per ADR-0008 § Order.receipt_number lifecycle). The "one Order ↔ one number forever" invariant holds across all re-emits. |
| Manual re-emit | Always available via a "re-send receipt" button on the Strung+ order page (per docs/architecture/receipts.md § Email delivery and order-lifecycle § Manual re-emit). One click; no audit reason required (non-mutating). |
| Un-Strung → Re-Strung | Clearing strung_at (T2-r) does not auto-email an "ignore the previous receipt" notice. Re-Strung (T2 again) emits a fresh receipt with the same receipt number. Per order-lifecycle § Un-Strung (T2-r) re-emit. |
1.3 "One receipt per racket-order" — precise meaning¶
Per the M14 issue (#67) and ADR-0008's invariants:
- One Order = one receipt number. Minted at first Strung; preserved across all re-emits, state regressions, and corrections. A re-string (a different physical stringing job for the same racket) is a new Order with its own number — not a new emit on the old one.
- Re-emits are multiple emails carrying the same receipt number with potentially different content snapshots (price, string spec, etc.). The email-body subject and visible "version" cue is Mira's design call; the receipt number on the PDF face is unchanged.
- The latest emit is authoritative. If the email body or the PDF face shows a number that differs from a prior version, the latest version is the truth. Old versions are kept in
Order.receipt_emit_logsnapshots for audit (per OQ-L-1 (a) — see order-lifecycle § Old receipt version retention) but are not re-rendered for customers. - No batching across rackets / orders. Each Order ships its own PDF and its own email, even if a client has three rackets strung on the same day. (This is restated from ADR-0002 R6 / receipt-content § Quadrant-by-quadrant field list — the receipt is per-Order, not per-day.)
2. Recipient resolution¶
| Aspect | Decision |
|---|---|
| Default recipient | order.client_profile.person.email if non-null. The Person-level email is the canonical address; ClientProfile does not carry a separate email field (per ADR-0004). |
Fallback when Person.email IS NULL |
Print-only path — the PDF is still rendered (it is needed for the printed copy clipped to the racket); no email is attempted. The Order is still marked Strung; nothing fails. The audit trail records that no email was sent. See §4 Failure semantics for the log-row shape. |
| Manual postal copy | Out of scope for this page (the PDF is downloadable; postal mailing is a stringer-side workflow, not a platform feature). Flagged for future consideration if Stefan ever wants the platform to support a "mail-to-this-address" affordance. |
| Stringer-as-CC | Not in V2. Rationale: the stringer already has the order in their dashboard; they do not need a copy of every receipt in their inbox. Flagged as a future option (V3 nice-to-have if a multi-stringer admin ever wants a CC for accounting purposes). See §6 What is NOT in V2. |
| Multi-recipient (CC, BCC, multiple To-addresses) | Not in V2. A racket has one client; the client has one Person; the Person has zero-or-one email. No multi-recipient surface in the data model, none in V2 delivery. |
| Address snapshot at emit | The recipient address is snapshotted on each emit into receipt_emit_log.email_to (per order-lifecycle § Re-emit log). This means a later edit to Person.email does not retroactively change the audit trail of where past receipts were sent. |
| Email-changed-since-Strung behaviour | If the client's Person.email is updated after a receipt was emitted, the next re-emit (auto or manual) goes to the new address. Old emits are not re-sent to the new address. |
2.1 Audit trail when no email is sent¶
Per §4.2 Log row shape below, the receipt_emit_log row for a Person-without-email Order carries:
kind = initial(orauto_re_emit/manual_re_emit)email_to = NULL(no recipient address)delivery_status = "skipped_no_email"— a distinct status fromfailedso a stringer / admin can grep audit history for "Persons that have never received their receipt because we don't have their email" and follow up out-of-band.
This skipped_no_email status is the audit signal a stringer can use to identify clients to ask for an email address (and then manual-re-emit once collected).
3. Channel selection¶
| Aspect | Decision |
|---|---|
| V2 channels | Email when Person.email is set; print-only otherwise. The PDF is always rendered (for print + download); email is conditional on having an address. |
| V3 channels | SMS / WhatsApp / Telegram are parking-lot per the existing V3 issues (D1–E5 family). Out of scope for V2. No channel-selection field exists on Person or ClientProfile in V2 (the data model is "have email or don't"). |
| Per-Person channel preference | Not a V2 entity. V2 has no Person.preferred_channel field. When V3 lands additional channels, the field will be added then; until then, channel selection is implicit (email-if-available). |
| Per-Order channel override | Not in V2. The stringer cannot mark an individual Order as "send via SMS instead." Flagged as V3 if multi-channel support lands. |
The architectural surface for "additional channels" lands in docs/architecture/integrations.md when V3 schedules them; the requirements page will be updated then.
4. Failure semantics¶
The locked-by-Stefan position (per #44 (C2) and NFR-2): silent + log. The state transition does not block on email-send success; the customer-visible UI shows nothing on failure; the failure is logged with full context and surfaces only via Stefan's log triage path.
This page restates the C2 decision in the requirements layer + extends it for the re-emit case + bounce case + the skipped_no_email case.
4.1 Failure modes covered¶
| Mode | Behaviour | Order state outcome |
|---|---|---|
| PDF render failure (template error, missing required field, WeasyPrint exception) | The transaction that set strung_at is rolled back. Per ADR-0008 § Failure modes, the receipt-number mint is rolled back too (gap on aborted transaction is acceptable per the Stefan-default). The stringer sees the standard "save failed" UI error and can correct + retry. |
Order remains in its prior state (Ordered, typically). |
| SMTP transient failure (network glitch, Resend 5xx, timeout) | The send attempt is logged with delivery_status = "failed"; the Order's strung_at set is not rolled back (the state transition stands; only the email failed). No UI banner; no automatic retry; no notification to the stringer. The stringer can re-trigger via the manual "re-send receipt" button. |
Order is Strung; receipt number is minted; PDF is downloadable; no email was delivered. |
| SMTP authentication failure (credentials wrong, Resend account suspended) | Same as transient failure — log + skip + carry on. This is the symptom of a platform-level outage that Stefan / Atlas needs to triage; surfacing it in the stringer UI does not help the stringer (they cannot fix it). The log line is the surface. | Same as transient failure — Strung + logged + no email. |
| SMTP rejection (recipient address malformed, Resend's pre-send validation fails) | Same as transient failure — log + carry on. The email_to snapshot in the log makes the bad address visible to triage. |
Same. |
| Email bounce (delivered to Resend, bounced by recipient's MTA later) | V2 does not consume the Resend bounce webhook. The send is recorded as delivery_status = "sent" (RBO's view); a real bounce is invisible to RBO until V3 adds bounce-webhook ingestion. The Resend dashboard is the only surface today. Out of scope for V2. |
Order is Strung; RBO believes the email was delivered; the bounce lives only in Resend. |
Person.email IS NULL at emit |
Not a failure — see §2 Recipient resolution. Logged with delivery_status = "skipped_no_email". |
Order is Strung; no email attempt. |
| Re-emit on a Strung Order, SMTP fails | Same as initial-emit failure. The Order's state is unchanged (already Strung). The new emit row is logged with kind = auto_re_emit (or manual_re_emit) and delivery_status = "failed". The previous successful emit's log row is unchanged. |
Order remains Strung; the latest emit is logged as failed. |
4.2 Log row shape¶
Per order-lifecycle § Re-emit log, every emit attempt (success, failure, skipped, initial, re-emit) writes a row to Order.receipt_emit_log. The schema-side shape is Theo's call (ADR-0007 amendment); the requirements view of the columns:
| Column | Required | Notes |
|---|---|---|
emit_at |
Required | Timestamp of the attempt. |
kind |
Required | Enum: initial | auto_re_emit | manual_re_emit | admin_re_emit. |
triggered_by_stringer_id |
Required | The actor (admin tracked separately via actor_kind). |
email_to |
Conditional | Snapshot of Person.email at emit time. NULL when delivery_status = "skipped_no_email". |
delivery_status |
Required | Enum: sent | failed | skipped_no_email. (V2 does not distinguish bounced — that's V3 when bounce webhook lands.) |
error_class |
Conditional | Populated when delivery_status = "failed"; the exception class name. |
error_message |
Conditional | Populated on failure; truncated to a reasonable length (Pax's call). |
| Receipt-content snapshot | Required | Snapshot of receipt-affecting fields (pricing, string-spec, racket FK, ClientProfile FK, business-identity fields, locale used) at emit time. Per OQ-L-1 (a) and the order-lifecycle requirements. |
The structured log line (per ADR-0009 § 1. Logging format and field set) carries the same context fields plus standard request-correlation (request_id, current_stringer_id, current_person_id of the Order's client). Per ADR-0009 forbidden-fields rule, the log line does not carry the SMTP password or the raw email body — only metadata.
4.3 Retry policy¶
| Aspect | Decision (proposal — see open question OQ-D-3) |
|---|---|
| In-process retry on transient failure | None in V2. RBO marks the emit as failed and stops. Rationale: Resend's relay layer already provides per-message retry on the transport side; an in-process retry loop in RBO is a complexity surface (exponential backoff, jitter, timeout budget, concurrent-emit ordering) that buys little at our scale. Stringers can manually re-trigger if they care. |
| Admin re-send endpoint | Out of scope for this requirements doc. Flagged as a follow-up implementation requirement: an admin route that takes an Order ID and re-runs the emit pipeline, logged as kind = admin_re_emit. The route is the same code path as the manual "re-send receipt" button; just exposed without the stringer-owns-this-Order gate. |
| Stringer manual re-send | Already specified by the order-lifecycle "Re-send receipt" button. One click; new emit-log row; no audit reason required. |
| Bulk re-send (e.g. after Resend outage) | Not in V2. No queue, no batch endpoint, no scheduler. If a transport outage takes out a day's receipts, the stringer hand-clicks the manual re-send on each affected Order. At our scale (~1–5 receipts/day per stringer) this is acceptable. Re-evaluate if multi-stringer scale ever makes manual re-send painful. |
5. Idempotency and re-emit¶
| Aspect | Decision |
|---|---|
| Each emit writes a fresh log row | Yes — initial, every auto re-emit, every manual re-emit, every admin re-emit. The log is append-only; no "supersedes" / "obsoletes" semantics in V2. |
| Old PDFs persisted on disk? | No. Per ADR-0002 R5 and docs/architecture/receipts.md, the PDF is always re-renderable from the Order row (and the per-emit snapshot for historical reconstruction). No PDF binary is stored. The audit answer to "what did the May email say?" comes from re-rendering against the snapshot, not from a file on disk. |
| Email re-sent on re-emit? | Yes (proposal — see open question OQ-D-1). Symmetric with the initial emit: when the receipt content changes (price edit, string-spec edit, etc.) the client gets a fresh email. The user-facing implication: a client may receive multiple receipt emails for the same racket as the stringer corrects details over time. This is documented as expected V2 behaviour; V3 may consolidate (e.g. "send only on the next state-change after edits batched in a 1-hour window"). |
| Receipt number across re-emits | Same number forever (per ADR-0008). The PDF face shows the same YYYY-NNNN; only the body changes. |
| Snapshot-vs-live divergence | The current PDF (rendered now) always matches current Order data. The snapshot (stored at emit time) lets us answer "what did the recipient see when this version was emailed?" without keeping the binary. If a stringer wants to show "version 1 vs. version 2" diff, that's V3 (see §6 What is NOT in V2). |
| Idempotency under concurrent edits | If two edits land back-to-back within the same second on the same Order, two emit rows are written and two emails are sent. No deduplication / coalescing in V2. At our scale this is essentially impossible (single stringer, one Order at a time); not worth the complexity to coalesce. Re-evaluate at multi-stringer scale. |
| Idempotency on retry of the same emit | The "manual re-send" button writes a new row each click. There is no client-side idempotency key. If a stringer triple-clicks the button, three emits and three emails are sent. Mira's UI design should debounce the button (loading state on first click); that is the entire de-duplication strategy in V2. |
6. What is NOT in V2¶
An explicit list to anchor scope:
| Item | Status | Rationale |
|---|---|---|
| Bounce-handling webhook (consume Resend's bounce events into RBO) | V3 | At V2 scale, the Resend dashboard is the bounce surface; consuming the webhook adds a public ingress route + signature verification + a delivery_status = "bounced" enum value + a stringer-facing "this client's email is bouncing" surface. None of those earn enough at <50 emails/year per stringer. Re-evaluate when V3 multi-stringer scale lands. |
| Multi-recipient (CC / BCC / additional To-addresses on a single Order) | V3+ | No data-model surface for a second recipient in V2. The single Person.email per ClientProfile is the contract. |
| Stringer-as-CC on every outgoing receipt | V3 nice-to-have | Useful for accounting (stringer wants a copy in their inbox). At V2 single-stringer scale, the dashboard is the primary record; the inbox copy is redundant. Revisit if multi-stringer admin requests it. |
| Send-on-Returned or Send-on-Paid (a fresh receipt at later lifecycle transitions) | Out of V2 (and likely never) | Per OQ-R-4 (closed 2026-05-04 on receipt-content), returned_at and paid_at are never on the receipt face. There is therefore no content-change to re-emit. The receipt is finalized at Strung; later state transitions do not re-trigger emission. |
| In-app receipt-preview-before-send | V3 | Stringers in V2 click Strung and the receipt goes. A "preview, edit, then send" flow is a V3-portal-style affordance; not a V2 stringer-app surface. |
| Receipt versioning UI ("show me version 1 vs. version 2 of this Order's receipt history") | V3 | The snapshots exist in receipt_emit_log (per OQ-L-1 (a)); the UI to surface them is V3 polish. V2 surfaces a flat "emitted N times" log per order-lifecycle § Re-emit log. |
| In-process retry loop on SMTP transient failure | V2 — explicitly not | See §4.3 Retry policy. Resend handles transport-level retry; RBO logs and stops. |
| Bulk re-send after a transport outage | V2 — explicitly not | See §4.3 Retry policy. Manual re-send per Order is the V2 surface. |
| Per-Person channel preference (email vs. SMS vs. WhatsApp) | V3 (depends on additional channels landing) | V2 has no channel selector. |
| Per-Order channel override | V3+ | See above. |
| Postal-mailing affordance (RBO mails the printed copy) | Never (or far-V3) | Stringer hand-clips the printed receipt to the racket; no platform-side postal workflow envisioned. |
| Public receipt-view link in the email body (alternative to PDF attachment) | V3 (depends on V3 client portal) | See open question OQ-D-2. |
| Server-side coalescing of rapid edits ("hold for 1 hour, then emit") | V3 | See §5 idempotency notes. |
| Per-Order "do not email this customer" suppression flag | Not in V2 | If the customer asks for no further emails, the stringer can NULL Person.email (the recipient resolution falls back to print-only) or simply not Strung-and-re-emit while pricing churns. No first-class suppression flag. |
7. Acceptance criteria (delivery-rules layer)¶
For Pax-A's M14 implementation (and any future audit of "did we cover the contract?"):
- A1. Setting
strung_aton an Order in any state where the lifecycle transition is allowed triggers: (1) PDF render, (2) email send via the SMTP wrapper ifPerson.emailis non-null, (3) write of areceipt_emit_logrow with the documented fields (§4.2), (4) state advance to Strung. - A2. PDF render failure rolls back the transaction (the state transition does not advance); SMTP send failure does not.
- A3. When
Person.email IS NULL, no email is attempted; the emit log row is written withdelivery_status = "skipped_no_email"; the PDF is downloadable; the state advance proceeds. - A4. Re-emit (auto or manual) reuses the original receipt number; never mints a new one.
- A5. Re-emit always sends a fresh email if
Person.emailis non-null (subject to OQ-D-1 confirmation). - A6. The manual "re-send receipt" button is available on every Strung+ Order and writes a
kind = manual_re_emitlog row. - A7. SMTP failure logs the structured fields per ADR-0009 § 1; does not surface in the stringer UI; does not notify the stringer.
- A8. The Resend bounce webhook is not consumed in V2 (no V2 ingress route for it;
delivery_status = "bounced"is not a V2 enum value). - A9. Multi-recipient sends (CC / BCC / additional To) are not produced by V2.
- A10. No in-process retry loop on transient SMTP failure; admin re-trigger is the only retry path (out of scope for this requirements doc as an endpoint, but the path exists via the manual re-send button).
- A11. Self-jobs do not auto-email even when
Person.emailis set; the PDF is still rendered for download (per order-lifecycle § Self-job vs. client-job differences).
8. Open questions¶
- OQ-D-1 (Stefan to confirm): when an edit triggers a re-emit, does the client receive a fresh email for each re-emit, or only the regenerated PDF (no email; client sees updates only on next portal-style access)?
- Default applied: re-send the email. Symmetric with the initial emit; the client knows their receipt was updated. Risk: chatty inbox if the stringer churns prices over many edits — flagged in §5 Idempotency as expected V2 behaviour, with V3 consolidation as a future option.
-
One-line flip if Stefan prefers PDF-regenerate-without-email-on-re-emit.
-
OQ-D-2 (Stefan to confirm): does the email body include a public link to view the receipt online (V3-style portal preview), or attach the PDF only?
- Default applied: PDF attachment only in V2. No V3 client portal exists yet; a "view online" link would 404. The PDF attachment is the artifact and the record. V3 portal landing is the moment to add the link.
-
Mira's email-body design reflects this — PDF attachment only, no online-view link.
-
OQ-D-3 (Stefan to confirm): SMTP retry on transient failure — RBO-side in-process retry loop, or rely on Resend's transport-level retry?
- Default applied: rely on Resend. RBO marks
failedand stops; admin re-trigger is the only retry path. Rationale in §4.3 Retry policy. Adds zero complexity to RBO for marginal value at our scale. - One-line flip if Stefan wants a single in-process retry-on-transient-only (not exponential backoff, just one retry on a connection-class exception).
9. Cross-references¶
- Sibling requirements:
receipt-content.md(field list),order-lifecycle.md(state machine + re-emit triggers),client-identity-and-sharing.md(Person.email and ClientProfile model). - Architecture: ADR-0002 (PDF design + R5/R6/R7), ADR-0007 (state machine), ADR-0008 (numbering invariants — same number across re-emits), ADR-0009 (SMTP env contract + structured-log field set),
docs/architecture/receipts.md(rendering pipeline + email delivery section),docs/architecture/integrations.md(Resend SMTP wrapper +EmailSendershape). - UX: Mira's receipt-email design — email-body visual: subject line, salutation, paragraph copy, EN/DE strings. This page is locale-agnostic on the email body; Mira owns the copy.
- Implementation: #67 (M14) (receipt delivery — Pax-A, in flight), #44 (C2) (silent + log failure behaviour — Pax-A), #45 (C3) (admin_audit_log).
- Issue tracking: racket-book#116 (this page).