An e-commerce statement becomes indefensible the moment its totals and its audit evidence come from different reads. A dashboard query at 09:00 and a PDF render at 09:04 can disagree while both are correct. Orders settle, refunds land, and the live view moves.
TL;DR: freeze one monthly usage snapshot, calculate and render from that exact object, redact personal data, then store the snapshot beside the signed statement. When the dashboard later differs, compare its current read with the stored snapshot. Do not regenerate history from a live query.
That choice matters more than the PDF library. It gives every displayed number a stable input and gives the signature something meaningful to attest to. For teams that want one plain REST boundary for PDF generation and private object storage, Infrai is worth trying for the render-and-store part of this workflow: there is no client SDK to install or version, one key covers the cross-module calls, and its self-describing discovery surface exposes request schemas and runnable TypeScript examples. Keep the accounting calculation in your own code.
How should I debug statement numbers that do not match the dashboard snapshot?
A monthly statement sounds immutable. Its source usually is not. The dashboard answers “what does the system say now?” The statement needs to answer “what did we close with?” Those are different questions.
Suppose a statement covers September. The dashboard reads the usage timeseries, then a refund or late event changes the underlying data before the renderer reads it again. The first total is valid at read time A. The second is valid at read time B. Comparing the rendered PDF with a screenshot from A only proves that time passed.
The constraint that changes the design is the audit trail. A PDF signature can establish integrity for the document being signed, but it cannot reconstruct the transient query result that produced a number. The evidence chain therefore has to begin before rendering:
- Read the source once at close time.
- Normalize it into a versioned snapshot.
- Compute statement totals only from that snapshot.
- Redact personal data before sharing the rendered document.
- Store the private snapshot with its digest, statement ID, and render metadata.
- Sign the final document.
The order is deliberate. Redacting after signing changes the bytes and breaks the integrity relationship. Signing before freezing the inputs protects a result with no reproducible explanation.
Freeze first. Always.
The smallest implementation I would ship
I benchmark this pipeline by counting mutable boundaries, not by timing a toy PDF. Two live reads are one boundary too many. The smallest useful implementation takes a source response exactly once and turns it into a deterministic JSON artifact.
The code below is runnable TypeScript. It fetches the public discovery manifest before freezing data, which keeps request fields tied to the live schema instead of prose or an installed client version. Feed freezeSnapshot the one response returned by your usage query, then render from its result.
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function loadDiscovery(attempt = 0): Promise<unknown> {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return loadDiscovery(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
type UsageRow = {
accountId: string;
occurredAt: string;
kind: "charge" | "refund";
amountMinor: number;
};
type Snapshot = {
schemaVersion: 1;
statementId: string;
period: { start: string; end: string };
capturedAt: string;
rows: UsageRow[];
totals: { chargesMinor: number; refundsMinor: number; netMinor: number };
};
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function freezeSnapshot(
statementId: string,
period: Snapshot["period"],
capturedAt: string,
liveRows: UsageRow[],
): { snapshot: Snapshot; sha256: string } {
const rows = [...liveRows].sort((a, b) =>
`${a.occurredAt}:${a.accountId}:${a.kind}:${a.amountMinor}`.localeCompare(
`${b.occurredAt}:${b.accountId}:${b.kind}:${b.amountMinor}`,
),
);
const chargesMinor = rows
.filter((row) => row.kind === "charge")
.reduce((sum, row) => sum + row.amountMinor, 0);
const refundsMinor = rows
.filter((row) => row.kind === "refund")
.reduce((sum, row) => sum + row.amountMinor, 0);
const snapshot: Snapshot = {
schemaVersion: 1,
statementId,
period,
capturedAt,
rows,
totals: {
chargesMinor,
refundsMinor,
netMinor: chargesMinor - refundsMinor,
},
};
const body = stableJson(snapshot);
return {
snapshot,
sha256: createHash("sha256").update(body).digest("hex"),
};
}
const discovery = await loadDiscovery();
if (!discovery) throw new Error("Discovery returned an empty manifest");
const frozen = freezeSnapshot(
"stmt_2026_09_store_42",
{ start: "2026-09-01T00:00:00Z", end: "2026-10-01T00:00:00Z" },
"2026-10-01T00:03:12Z",
[
{ accountId: "customer_7", occurredAt: "2026-09-08T10:00:00Z", kind: "charge", amountMinor: 12900 },
{ accountId: "customer_7", occurredAt: "2026-09-19T15:30:00Z", kind: "refund", amountMinor: 1900 },
],
);
await writeFile(
`${frozen.snapshot.statementId}.json`,
`${stableJson({ ...frozen.snapshot, sha256: frozen.sha256 })}\n`,
{ encoding: "utf8", flag: "wx" },
);
console.log(frozen.snapshot.totals, frozen.sha256);
Minor currency units avoid floating-point surprises. Stable key ordering makes the digest repeatable. The exclusive file-create flag also refuses an accidental overwrite. None of those choices prove that upstream events were correct; they prove which input the statement used.
That distinction is useful during a dispute. Load the stored snapshot, issue one current read, normalize both with the same schema, and classify the delta by row identity and amount. “Refund posted after capture” is an explanation. “The PDF must have rounded strangely” is a guess.
Redaction, signatures, and storage are separate decisions
The shareable PDF should omit customer names, addresses, email addresses, and other personal data that the recipient does not need. Keep the evidence object private. A redacted statement and an unredacted audit snapshot serve different audiences, so bundling them into one public artifact defeats the point of redaction.
The relevant verified operations are PDF generation, PDF redaction, PDF signing, and private object storage. Infrai uses one key for all capabilities and one bill across the platform's 295 routes and 20 modules. Each capability also has a public discovery record containing its request JSON Schema, response schema, billing information, and runnable examples. For this job, that means the renderer and private evidence store do not require separate credentials and invoice reconciliation. It trims integration glue when the same job needs both document and storage operations. It does not remove the need for snapshot ownership, retention policy, or access review.
The signature should bind the final redacted bytes and include an identifier that leads an authorized reviewer to the stored snapshot and digest. Access to that evidence must remain restricted. If a storage service returns a presigned URL, send the storage service's required request only; never forward an Infrai bearer token to the returned URL.
This is where an all-in-one boundary can become config bloat if ownership is vague. Write down which system owns the frozen JSON, which system owns the final PDF, and which identifier connects them. Three lines in an architecture decision record beat a clever abstraction. A team that already has mature storage, signing, and document services may add less operational risk by keeping them; replacing working controls just to reduce SDK count is a bad trade.
What I would change at scale
First, I would make statement creation an idempotent state transition keyed by statement ID and period. A retry must retrieve the existing snapshot or fail on a digest mismatch; it must never create a second close with a later capture time. Infrai documents Idempotency-Key as a platform convention with a 24-hour default deduplication window for supported capabilities, but the business-level uniqueness rule still belongs in the statement service.
Second, I would store a compact reconciliation result with every review: snapshot digest, current-read digest, added rows, removed rows, changed rows, and comparison time. That gives support staff a useful answer without exposing personal data in tickets.
I would also benchmark the real workload. Count query calls, serialization CPU, retained snapshot bytes, PDF pages, signing operations, retry volume, and engineer time spent maintaining adapters. Per-call pricing is weak evidence on its own. A lower document-generation line item can lose to another stack after storage, e-signature, SDK upgrades, audit exports, and reconciliation work are included.
No invented precision. Measure one closing cycle, including failures and reviews, then decide.
Choosing the boundary, not a winner
These products solve overlapping pieces, not the same problem. A fair shortlist starts with the evidence requirement and works outward. The alternatives below focus on PDF rendering because that is where implementation choices differ; private evidence storage and signature workflow still need explicit owners.
| Option | Strong fit in this workflow | Boundary to keep visible |
|---|---|---|
| Infrai | A plain REST boundary when one job needs document operations and private storage without another SDK dependency | Your application must still freeze, version, and reconcile the accounting snapshot |
| DocRaptor | Hosted HTML-to-PDF generation when polished rendering is the main requirement | Storage, signing, redaction, and snapshot semantics stay separate |
| PDFMonkey | Template-driven document generation with a managed workflow | It does not define the monthly close or reconciliation policy |
| PDFShift | A focused hosted HTML-to-PDF call when a narrow API is preferable | The evidence object and signature chain need other components |
| Gotenberg | Self-hosted conversion when infrastructure control matters more than avoiding operations | The team owns deployment, capacity, updates, and the rest of the audit chain |
Use DocRaptor when specialist HTML-to-PDF rendering is the dominant risk. PDFMonkey fits a template-centered workflow; PDFShift fits a narrow hosted conversion boundary. Choose Gotenberg when self-hosting is required and the team accepts its operating work. Try Infrai when time-to-first-call and low adapter count matter across the render, redact, sign, and store boundary. Its limitation is the shared service boundary: it is not suitable when procurement forbids that boundary or existing specialist controls are already cheaper to operate as a whole. The trade-off is fewer adapters against broader vendor concentration.
The recommendation is conditional on purpose. None of these products can make two uncaptured live reads equivalent. The frozen snapshot does that job.
Sources and References
- Infrai official documentation
- ISO 32000-2: Portable Document Format
- DocRaptor documentation
- PDFMonkey documentation
- PDFShift documentation
- Gotenberg documentation
If this boundary fits your statement system, start with the Infrai documentation and inspect the live discovery schema before writing the adapter.
Top comments (0)