Short answer: for a dispute, do not “recreate” an old invoice by rerunning today’s template; preserve the original PDF bytes, verify their hash, and only render a replacement when the original artifact is genuinely unavailable.
That rule sounds conservative because it is. PDF is a file format, not a promise that two visually similar files have the same bytes. Object ordering, metadata timestamps, font subsets, compression, and producer fields can all change after a second render. A reviewer may care about those details, especially when the invoice is evidence.
What should a Node.js team preserve before regenerating an old invoice PDF?
Treat the invoice as an evidence package. Store the source PDF, the invoice's business inputs, the template revision, the font files, locale, timezone, and the rendering tool version. Keep a SHA-256 digest beside the object. The digest proves which bytes you examined; it does not prove the document's business truth, so keep the input record too.
I use a content-addressed path such as invoices/{invoiceId}/{sha256}.pdf. That makes accidental overwrites obvious and lets a dispute worker fetch the exact object selected by an auditor. Keep the original immutable. If a correction is needed, create a new artifact with a reason and a link to its predecessor.
Here is a small TypeScript check that fails closed when the requested invoice is missing or its digest differs from the dispute record:
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
type DisputeRecord = {
invoiceId: string;
sourcePath: string;
expectedSha256: string;
};
async function verifySource(record: DisputeRecord): Promise<Buffer> {
const pdf = await readFile(record.sourcePath);
const actual = createHash("sha256").update(pdf).digest("hex");
if (actual !== record.expectedSha256.toLowerCase()) {
throw new Error(`invoice ${record.invoiceId} digest mismatch`);
}
if (pdf.subarray(0, 5).toString("ascii") !== "%PDF-") {
throw new Error(`invoice ${record.invoiceId} is not a PDF stream`);
}
return pdf;
}
const source = await verifySource({
invoiceId: "INV-1042",
sourcePath: "./evidence/INV-1042.pdf",
expectedSha256: "replace-with-the-recorded-digest"
});
console.log(`verified ${source.length} bytes`);
The code deliberately does not “fix” a mismatch. A changed file is a new fact that needs review, not a formatting problem to hide.
How can Node.js regenerate an old invoice PDF identically for a dispute?
There are two paths, and they should never be confused. If the original bytes exist, reproduction is a byte-for-byte copy after verification. If they do not, regeneration means replaying a controlled rendering recipe and labeling the output as reconstructed. Identical visual output is not identical evidence.
For the first path, stream the verified object to a new location and hash the result. This is useful when a case-management system needs a local working copy while the evidence vault remains untouched:
import { createHash } from "node:crypto";
import { copyFile, readFile } from "node:fs/promises";
async function copyVerifiedPdf(input: string, output: string): Promise<string> {
const bytes = await readFile(input);
await copyFile(input, output);
const copied = await readFile(output);
const before = createHash("sha256").update(bytes).digest("hex");
const after = createHash("sha256").update(copied).digest("hex");
if (before !== after) throw new Error("evidence copy changed bytes");
return after;
}
console.log(await copyVerifiedPdf("./evidence/INV-1042.pdf", "./case/INV-1042.pdf"));
For the reconstruction path, freeze every input. Serialize monetary values as integer minor units, sort line items by their stored sequence, use an explicit IANA timezone, and embed the exact font files. Pin the runtime too; for example, record the Node.js 22 image digest used by the worker, the PDF library version, and the font package checksums. Record the renderer command and its version in a manifest. Avoid a “current date” helper: it guarantees drift. Your acceptance test should compare page count, extracted text, bounding boxes, and a rendered image checksum at a fixed DPI. A byte checksum is expected to differ when the renderer rewrites metadata, so report both results instead of pretending they are equivalent. That is a reconstruction, not a recovered original.
The failure modes are predictable. A tax rate lookup performed during replay can use a later rule. Database queries without ORDER BY can move line items. Font fallback changes glyph widths, which changes line wrapping and pagination. PDF metadata can also carry hidden state such as creation time or a producer name. One practical test is to run the same reconstruction twice in isolated containers, compare the text and page geometry, and stop the dispute export when they differ. I am not sure every PDF library exposes metadata controls consistently, so your manifest should capture the actual bytes and the library version rather than relying on a configuration name.
That's the useful boundary.
When is regeneration the wrong choice?
The catch is that a rebuilt invoice can be accurate and still be unsuitable as primary evidence. Do not regenerate when the original PDF is available, when the source inputs are incomplete, or when a legal hold requires preservation of the received file. Keep the original and attach a reconstructed copy with a clear label when a human reviewer needs a readable artifact.
Batch throughput matters in an edtech billing system, but parallel workers must not race on the same invoice. Partition by invoice ID, make writes idempotent, and put a bounded queue in front of expensive rendering. Measure queue age, render duration, byte-hash mismatches, and the count of reconstructed documents. A fast pipeline that silently changes an invoice is a failed pipeline.
Operationally, the checklist is short: immutable source storage; a recorded SHA-256; frozen inputs and timezone; pinned fonts and renderer; deterministic ordering; an idempotency key; and an audit record that says whether the output was copied or reconstructed. I’ve found that writing these fields before the worker starts is easier to audit than trying to infer them from logs after a case opens. Keep those records longer than the dispute window requires, subject to your retention policy.
Top comments (0)