DEV Community

MortimerNilsson7694
MortimerNilsson7694

Posted on

Reproducing a Sealed Invoice PDF for a Dispute — Template Ownership Comes First

Two requirements collide the moment a parent files a dispute over an old tuition invoice — one your school-billing platform issued back in 2023. Finance needs the exact PDF that went out. Legal needs a copy with the student's name, date of birth and district ID removed before it reaches an outside mediator. You will not get both from a fresh render, so use the archived bytes as the evidence and treat the redacted copy as a separate, derived artifact with its own digest.

Everything after this is about making that split survive three years of template edits.

The choice matrix for template ownership

Where the template lives Who can change the layout Reproducible three years later Redaction path
A hosted designer in some billing SaaS The vendor, on their release schedule No — the layout drifts with their engine Re-render and pray the diff is cosmetic
A file pinned in your repo You, through a reviewed commit Only if fonts, locale and renderer are pinned with it Re-render, then diff against the seal
Nowhere; you archived the rendered output Nobody Yes, by construction Derive straight from the archived bytes

Row three is the recommendation. Archive the rendered artifact at issue time, hash it, and make that hash the thing your dispute workflow trusts. Template ownership still matters — you want the pinned repo copy for the day you have to explain why line four says "Lab Materials Fee" — but ownership buys you an explanation, not a reproduction.

The ordering is easy to get backwards. A team pins the template, pins the container, writes a clean render-from-source path — and then a font package update shifts the kerning of one column. The numbers come out identical. The bytes don't, and the bytes are what the other side asked for.

Can you regenerate an old invoice PDF identically in Node.js once a dispute starts?

Only if you controlled every input, and almost nobody did. A PDF is a serialization, not a picture, so byte equality depends on things that have nothing to do with your invoice data: the exact font files and their subsetting, the object numbering your writer library chose, stream compression level, the /CreationDate and /ModDate entries, and the two-element /ID array that ISO 32000-2 describes as a file identifier — most writers seed it from a timestamp or a random source. Layout engines add more. A headless browser resolves CSS paged-media rules against its own build; a print engine two minor versions later may hyphenate differently or round a column width by a fraction of a point. Then there is the locale: currency symbols, thousands separators and date order come from ICU data that ships with your runtime, and Node.js updates ICU on its own cadence. None of that is a defect in any of those tools. It is just what happens when you ask a layout pipeline to be a hash function. If you want to regenerate an old invoice PDF identically, you are really asking to freeze a dozen upstream release trains, and you can't do that retroactively.

So stop asking. Seal instead.

Sealing is three fields and a write: the invoice ID, the SHA-256 of the bytes, and enough provenance to explain the render — the template content hash, not its filename, plus the renderer identity. Filenames lie. invoice-v2.hbs tends to be several different documents over its life, which is exactly why the seal should carry a content hash of the template rather than a mutable path. Write the seal in the same transaction that stores the object, or you will eventually archive a PDF nobody can vouch for. If your retention policy runs seven years, budget for it honestly — invoices land under 200 KB each, and object storage at that volume is cheaper than one afternoon of a lawyer's time reconstructing a render environment.

Redaction is a second artifact, not an edit

The failure mode here is famous and still common: someone draws a black rectangle over the student's name, exports, and ships it. The glyphs are still in the content stream. Any extractor reads them straight back out. pdf-lib can draw that rectangle happily; it doesn't remove the underlying text objects, and neither does a screenshot-and-reflatten pass if the source had a text layer. Real redaction removes the content, then removes the copies you forgot about — XMP metadata, document info entries, annotation appearance streams, embedded file attachments, and any bookmark that spells out the name you just deleted.

In edtech this is not a stylistic concern. Student education records sit under FERPA, and a tuition invoice with a district student ID on it is squarely inside that definition, so the redacted derivative is what leaves the building — never the sealed original.

Give the derivative its own manifest: source digest, output digest, the field classes removed, the redaction tool identity. Now a dispute produces a chain rather than a file. Someone can verify the original is untouched and that the shared copy came from it, without ever seeing the student's data.

Wiring it into the Node.js pipeline

The release step is small, which is the point. It verifies, derives, and records — no rendering anywhere in it.

import { createHash, timingSafeEqual } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

type Seal = {
  invoiceId: string;
  issuedAt: string;      // ISO 8601, stamped when the original was written
  sha256: string;        // digest of the sealed PDF bytes
  renderer: string;      // image digest or package version that produced it
  templateRef: string;   // content hash of the template, never a filename
};

const digest = (buf: Buffer) => createHash("sha256").update(buf).digest("hex");

function assertSealIntact(actual: string, expected: string): void {
  const a = Buffer.from(actual, "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error(`seal mismatch: archive ${actual}, seal ${expected}`);
  }
}

export interface Redactor {
  // Must delete the text objects and every copy of them (XMP, /Info,
  // annotation appearances). Painting a rectangle over a name is not redaction.
  redact(pdf: Buffer, targets: string[]): Promise<Buffer>;
}

export async function releaseForDispute(
  seal: Seal,
  archivePath: string,
  redactor: Redactor,
  targets: string[],
) {
  const original = await readFile(archivePath);
  assertSealIntact(digest(original), seal.sha256);

  const redacted = await redactor.redact(original, targets);
  const manifest = {
    kind: "redacted-derivative",
    invoiceId: seal.invoiceId,
    sourceSha256: seal.sha256,
    sha256: digest(redacted),
    // never log the target strings themselves; they are the PII
    redactedFields: targets.map((t) => `${t.slice(0, 1)}***`),
    renderer: seal.renderer,
    templateRef: seal.templateRef,
  };

  await writeFile(`${seal.invoiceId}-redacted.pdf`, redacted);
  await writeFile(`${seal.invoiceId}-redacted.json`, JSON.stringify(manifest, null, 2));
  return manifest;
}
Enter fullscreen mode Exit fullscreen mode

Redactor stays an interface on purpose. Whatever removes the content — an in-process library, a container behind an internal HTTP call, a batch job — the verification and the manifest do not change, and swapping the implementation costs one class. Gotenberg, for instance, ships a container around a headless browser, so its output is pinned to whatever Chromium build and font set that image carries; that pinning is a property of the image, not of your code, which is precisely why it belongs behind an interface with a recorded version string.

Then make the guarantee testable. A dispute release is not something you want to debug at 4pm on the day a mediator asked for it:

sha256sum archive/INV-2023-0417.pdf | cut -d' ' -f1 > /tmp/actual
jq -r .sha256 seals/INV-2023-0417.json > /tmp/expected
diff /tmp/actual /tmp/expected && pdftotext -q dist/INV-2023-0417-redacted.pdf - | grep -c "Rivera" 
Enter fullscreen mode Exit fullscreen mode

That second half is the check people skip. Extract text from the redacted output and assert the target string count is zero. Run it on a fixture in CI, not by eye — a visual review passes a black box drawn over live text every single time. Instrument the release path too: count seal mismatches separately from redaction failures, because they mean different things. A mismatch says your archive or your seal store is wrong and someone needs to stop touching things. A redaction failure is a bug in a tool you can swap.

Where re-rendering from a pinned template still wins

Sealing has a real limitation: it only gives you documents you actually produced. If the disputed invoice predates your archive, or if the original was generated by a system you've since migrated off, there is nothing to verify against and a reconstruction from the pinned template is the honest best effort — labelled as a reconstruction, with the template commit and renderer version printed on it. That's not a reproduction, and you should never call it one.

Re-rendering also wins when the output must change. A corrected invoice, a translated one, an accessible tagged variant for a parent using a screen reader, a PDF/A conversion for long-term archival — all of those are new documents, and they need a live render path with an owned template. Stick with a pinned-template pipeline as your primary when your regulator wants regeneration from source data rather than artifact retention; some archival regimes do, and a hash of a blob doesn't satisfy them.

And if your volume is genuinely tiny — a few hundred invoices a year — the sealing infrastructure may cost more attention than it saves. Archive to object storage with versioning on, keep the digest in your invoice row, and skip the manifest machinery until a second document type shows up. Where exactly that threshold sits isn't obvious, and it probably has more to do with how often your templates change than with document count.

The rule worth writing down somewhere a new engineer will read it: if a human might one day have to swear the document is unchanged, the bytes are the record and everything else is a derivative. Render once. Hash immediately. Redact into a new file, never over the old one.

References

Top comments (0)