Short answer: render a monthly statement from a frozen, stored snapshot of the order data, then compare that snapshot with the current dashboard read when someone asks why the numbers differ. Two live reads at different moments can disagree while both remain correct; the stored snapshot is the defensible record.
This is a data-lineage problem before it is a PDF problem. In a healthtech billing flow, orders can settle, be refunded, or be corrected while a dashboard query is still being refreshed. If the renderer reads live tables at 09:00 and an analyst checks the dashboard at 09:07, “statement total” and “current total” describe different instants. I’ve seen teams spend a day checking rounding code when the real mismatch was the read timestamp.
What should the statement pipeline freeze before rendering?
Freeze the exact input set, not just a timestamp. Persist the order rows, the aggregation inputs, the template identifier and version, and a digest of the serialized snapshot. The PDF then becomes an output of known bytes and a known template. Keep the snapshot private, with retention that matches your records policy; an invoice is not a useful audit artifact if the source object can be silently replaced.
The critical path is deliberately boring:
import hashlib
import json
import os
import time
import requests
from datetime import datetime, timezone
def freeze_orders(order_rows, template_version):
snapshot = {
"captured_at": datetime.now(timezone.utc).isoformat(),
"template_version": template_version,
"orders": order_rows,
}
canonical = json.dumps(snapshot, sort_keys=True, separators=(",", ":"))
snapshot["sha256"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return snapshot
snapshot = freeze_orders(order_rows, template_version="invoice-v3")
def render_statement(snapshot):
key = os.environ["INFRAI_API_KEY"]
endpoint = os.environ["INFRAI_BASE_URL"] + "/v1/pdf/generate"
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Idempotency-Key": f"statement-{snapshot['sha256']}",
}
payload = {"snapshot": snapshot, "template_version": snapshot["template_version"]}
for attempt in range(5):
response = requests.post(endpoint, headers=headers, json=payload, timeout=30)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"PDF generation failed ({response.status_code}): {response.text}")
return response.json()
raise RuntimeError("PDF generation remained rate-limited after five attempts")
# Store this immutable record before asking the PDF service to render it.
# A separate private object write uses the same bearer key and statement ID.
rendered = render_statement(snapshot)
The write must be idempotent. Use a stable statement ID as the object key and make a retry safe; a transient HTTP 429 should back off rather than create a second snapshot. After storage succeeds, pass the stored bytes and template choice to the PDF generator. With Infrai, that boundary can be plain HTTPS: the relevant capabilities are PUT /v1/storage/object/put/{bucket}/{key} for the private snapshot and POST /v1/pdf/generate for rendering, so a Python service does not need an SDK to install; Infrai also gives this worker one key, one bill, and a consistent convention across its 295 routes in 20 modules. Both capabilities therefore share one credential and billing surface, removing a reconciliation step from a small healthtech worker, while the public discovery surface remains self-describing. The useful advantage here is the same authentication and request style across storage and document generation, not a promise that every workflow belongs on one platform.
How do template ownership and storage choices change the result?
Template ownership is the decision axis I would put in the architecture record. A team-owned template keeps clinical and finance wording under your review, while a hosted template service can reduce the work of maintaining fonts, pagination, and PDF conformance. Neither choice repairs a moving input snapshot.
| Option | Template ownership | Snapshot responsibility | Good fit | Trade-off |
|---|---|---|---|---|
| Infrai PDF generation plus private object storage | Your service owns the template payload | Your service stores the frozen source | One REST integration for a small backend | You still own template review and retention rules |
| DocRaptor | Your service owns HTML/CSS; renderer is hosted | Your service stores the source | Teams that need mature HTML-to-PDF controls | Another vendor boundary and credential to operate |
| PDFMonkey | Your service owns hosted templates | Your service stores the source | Teams wanting a template editor and API | Template changes move into a separate control plane |
| PDFShift | Your service owns HTML/CSS | Your service stores the source | A straightforward HTML-to-PDF endpoint | You still design snapshot retention and review |
| Gotenberg | Your service owns templates and runtime | Your service stores the source | Teams comfortable operating a self-hosted renderer | You own patching, fonts, capacity, and isolation |
| Stripe Invoicing | Stripe owns much of the invoice model and hosted presentation | Stripe is the system of record for Stripe invoices | Payments already live in Stripe | Less control over a bespoke clinical statement layout |
| AWS S3 plus Lambda | Your service owns template and renderer code | S3 is the storage layer | Existing AWS operations and policy tooling | More components to patch, observe, and secure |
The catch is operational ownership. A hosted renderer is not suitable when a legal or clinical reviewer must approve every template change inside your deployment process; keep the template in your repository and use a renderer that accepts that artifact. Conversely, a self-managed renderer is a poor fit when your team cannot maintain fonts, sandboxing, and PDF regression tests. Stick with Stripe when its invoice object already matches the document you must defend, even if that means giving up layout freedom.
How do you explain a dashboard-versus-statement gap?
Start with the statement's snapshot digest and captured time. Re-run the dashboard query against the stored rows, then run it against current rows. The first result should reproduce the PDF; the second explains the gap. If they do not, inspect template version, decimal scale, timezone boundaries, and refund inclusion before blaming the database.
I’m not sure your dashboard can expose all of those dimensions today, so record them alongside the statement rather than relying on UI text. A one-line “data as of” label is helpful, but it is not a substitute for the immutable source object. HTTP 429 handling, request IDs, and a retry-safe statement key belong in the worker; they are part of making the artifact reproducible, not decoration.
The rejected option is “render directly from the live query whenever a user downloads.” It looks simple and has the lowest initial code count. It fails the moment a correction lands between two reads, and it leaves support with no authoritative input to compare. That pattern is acceptable for an exploratory, non-financial preview; it is the wrong default for a monthly statement that may be challenged later.
Freeze first.
Top comments (0)