Short answer: keep invoice templates in your repository, render PDFs in a background worker, and attach only a validated immutable artifact to the confirmation message. This makes changes reviewable and resends deterministic.
The decision record starts with a few invariants.
The invoice comes from an immutable order snapshot, not mutable catalog rows. A template revision has a content hash. Rendering and delivery have separate retry boundaries, and a malformed PDF is rejected before SMTP. Personal data gets a retention deadline.
| Option | Template owner | Failure boundary | Best fit |
|---|---|---|---|
| Repository template plus worker | Engineering and reviewers | Render, then delivery | Regulated receipts |
| Hosted visual editor | Operations or design | Provider render and delivery | Low customization |
| Browser rendering in request | Application team | Checkout and browser | Internal previews |
I reject synchronous browser rendering for paid orders. A font fetch or renderer stall must not consume checkout's availability budget. A hosted editor can be valid when operations publishes copy, but export, audit, and rollback behavior must be tested first.
How can a Node.js order confirmation attach a receipt PDF?
Commit the order and an outbox record together. A dispatcher creates a render job. The renderer writes a temporary file, validates it, moves it to content-addressed storage, and emits an attachment-ready event. The mail worker uses the same idempotency key on every retry.
from hashlib import sha256
import json
import tempfile
from pathlib import Path
def render_invoice(snapshot, template, renderer):
payload = json.dumps(snapshot, sort_keys=True, separators=(",", ":"))
order_hash = sha256(payload.encode()).hexdigest()
template_hash = sha256(template.encode()).hexdigest()
key = f"invoice:{snapshot['order_id']}:{template_hash}"
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "invoice.pdf"
renderer.render(template, snapshot, output)
data = output.read_bytes()
if not data.startswith(b"%PDF-") or len(data) < 1024:
raise ValueError("invalid PDF")
return {"idempotency_key": key, "order_hash": order_hash,
"template_hash": template_hash,
"artifact_hash": sha256(data).hexdigest(), "bytes": data}
The header check is only a gate. Parse the cross-reference structure with the downstream attachment scanner too. ISO 32000-2 defines the PDF format; magic bytes alone do not prove conformance.
Which failure belongs to which retry?
A render timeout is safe to retry with the same key. For a storage timeout, check whether the content hash already exists before writing again. An SMTP 4xx is a delivery retry; reuse stored bytes. A 5xx should be classified before retrying because an invalid recipient will not become valid through repetition.
def deliver(event, store, mailer):
key = event["idempotency_key"]
if store.sent(key):
return "already-sent"
pdf = store.get_by_hash(event["artifact_hash"])
if pdf is None:
raise RuntimeError("artifact missing")
result = mailer.send(to=event["recipient"],
subject=f"Receipt for {event['order_id']}",
attachment_name=f"invoice-{event['order_id']}.pdf",
attachment_bytes=pdf)
if result.category == "permanent":
store.mark_failed(key, result.code)
return "dead-lettered"
if result.category == "temporary":
raise RuntimeError(result.code)
store.mark_sent(key)
return "sent"
Keep hashes and references in queue messages, not PDF bytes. Content-addressed storage turns a worker crash after upload into a lookup instead of a duplicate render. A support resend can reference the exact artifact originally sent.
This design is a poor fit for a tiny internal tool that has no asynchronous workload; a synchronous preview is easier to operate there.
Limitation: a repository-owned template needs an engineering review queue, so it is slower than an editor for same-day copy changes.
Keep it boring.
How should template ownership be tested?
Keep templates beside tests and require review for totals, tax labels, payment terms, and identity fields. Snapshot tests catch layout drift; semantic tests inspect extracted text and page count. Include long names, right-to-left addresses, refunds, fractional quantities, zero-tax orders, and narrow currency symbols.
I once assumed a font fallback was harmless because a first-page screenshot looked fine. A long street name moved the totals block onto a third page. Packaging fonts with the renderer and asserting that totals stay on the final page caught the real failure.
DocuSign centers signing workflows, Adobe PDF Services centers managed conversion APIs, and Google Docs API centers collaborative document models. Those are different ownership boundaries, not interchangeable invoice contracts.
Measure queue age, render duration, validation failures, artifact misses, and delivery outcomes separately. Pin renderer and fonts in a reproducible build. Record template and build hashes without logging the order snapshot, and make deletion of encrypted artifacts observable.
Own the template and its tests, snapshot the data, render asynchronously, validate before delivery, and make every retry idempotent. The result is a receipt the team can explain months later.
Top comments (0)