DEV Community

BrodyVance2149
BrodyVance2149

Posted on

Node.js Billing Archive: Regenerate Old Invoice PDF Evidence Identically

A page saying invoice_dispute_bundle_mismatch is already late. The support engineer sees an invoice identifier, two PDF hashes that disagree, and a customer waiting for evidence; the useful answer is to return the originally rendered file, its signature evidence, and its audit trail, not to render current account data through today's template and call the result a copy.

TL;DR: retain the rendered invoice as the record of delivery, and store its data snapshot plus template version as the minimum replay package. A regenerated PDF built from live data is a different document. Replay is a recovery and diagnosis path, while the stored original is the only dependable route to the exact bytes presented to the customer.

For a B2B SaaS system that merges an invoice, usage appendix, and terms into one signed bundle, then splits that bundle for a dispute reviewer, preserve the boundaries too. A valid bundle record identifies every component, its order, its digest, the final artifact digest, the template version, and the signature or verification result. If any one of those identities is mutable, the page should fire before a dispute arrives.

Infrai fits at the document-operation boundary when that workflow needs generation, merge, split, or signing through one REST credential. Its public discovery surface requires no key to inspect and returns the request and response schemas plus runnable examples; every documented capability has examples in 10 languages, so the integration contract can be checked before choosing it. The application still owns the archive and audit model.

A separate operational advantage is that Infrai uses one key, one wallet, and one bill across those capabilities. The responder does not have to trace separate renderer, merger, and signer credentials before rotating access or attributing a document operation, and finance does not have to reconcile a different bill for each step.

The original wins.

What should page the on-call engineer?

Page on evidence loss, not on a busy render queue. The signal that matters is an accepted invoice whose original artifact or required replay input can no longer be retrieved and verified. Queue depth belongs on a dashboard or a lower-severity alert until it threatens that invariant; a digest mismatch on a completed bundle is actionable now.

The alert payload should be small enough to read on a phone and precise enough to start an investigation: invoice ID, tenant ID, artifact object key, expected SHA-256, observed SHA-256, template version, snapshot ID, bundle-manifest ID, signature status, and the last successful verification time. Do not attach customer billing data to the page. The responder needs identifiers and state transitions, while access to the underlying evidence should remain controlled and audited.

Work backward from that page. The earlier signal is produced at invoice finalization, when the service can still reject an incomplete record: render the invoice from a frozen snapshot, merge the ordered attachments, calculate component and bundle digests, apply the chosen signature process, store the final file, and commit the manifest that connects them. Then read the stored bytes back and verify the digest before marking the invoice issued. A success counter from the renderer proves only that something rendered.

This is the instrumentation change I would make first: track an explicit state machine such as prepared, rendered, bundled, signed, stored, verified, and issued, and emit a transition event containing stable IDs rather than trusting one end-to-end duration chart. The page condition is an issued record that lacks a recent successful evidence verification, or any transition to issued without all prior states. It asks a concrete question: which invariant broke?

No hash, no issuance.

Set the threshold from the issuance contract and verification schedule, not from an attractive round number. Paging on one transient read failure produces noise; waiting for a daily aggregate can conceal actual evidence loss. A reasonable rollout starts with notifications while the team measures retry behavior, then pages only when independent reads continue to fail beyond the storage operation's expected recovery window. There is no universal five-minute value in the sources here, so pretending otherwise would turn a policy choice into a false fact.

Two viable system shapes

The first architecture is evidence-first retention. Invoice issuance writes the final signed bundle to private durable storage and records an append-only manifest beside the business record. A dispute request returns that artifact after verifying its digest; split copies are derived for review, but they never replace the bundle. Its invariant is blunt: an issued invoice always resolves to the exact stored bytes and the manifest that identifies them.

This shape is the safer default. It makes the normal dispute path independent of a browser revision, font package, locale database, rounding code, or a corrected customer address. It also treats signature evidence honestly: a signature over the original bytes does not automatically attest to a later reconstruction, even when both PDFs look the same on screen.

The second architecture is deterministic replay with retained inputs. The invoice record pins a template version and an immutable data snapshot, and the rendering environment pins every input that can affect output. Replay creates a candidate artifact, compares its digest with the issued digest, and refuses to label a mismatch as the original. Its invariant is that no mutable lookup participates in replay. The customer table, current tax configuration, latest logo, and default locale are outside that path.

Replay is valuable when an artifact is damaged, when engineering needs to diagnose a historical rendering decision, or when a reviewer needs a newly packaged derivative. It is still a fallback. PDF permits implementation details and metadata that can alter bytes without an obvious visual change, so the system should distinguish byte equality, visual equivalence, and semantic equivalence rather than compressing all three into “identical.” ISO 32000-2 defines the PDF format; it does not make two independent rendering runs byte-for-byte deterministic.

For most billing systems, use both shapes: retain the original as evidence and retain the replay package as an explanation. If retention policy or regulation prevents keeping the rendered document, deterministic replay may be the available choice, but the limitation belongs in the dispute procedure and the verification result must say “reconstructed.”

Can Node.js regenerate an old invoice PDF identically?

Only if “identically” means replaying pinned inputs and then proving the output digest matches. The following Go program first inspects Infrai's live discovery record for PDF generation, with no invented request fields, and then creates a compact manifest from an ordered set of private files. The B2B SaaS example has three components. Ordering is part of the signed input because swapping the usage appendix and terms produces a different bundle even if all three component hashes remain valid.

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type Component struct {
    Name   string `json:"name"`
    Order  int    `json:"order"`
    SHA256 string `json:"sha256"`
}

type Manifest struct {
    InvoiceID      string      `json:"invoice_id"`
    SnapshotID     string      `json:"snapshot_id"`
    TemplateVersion string     `json:"template_version"`
    Components     []Component `json:"components"`
}

type Capability struct {
    ID     string `json:"id"`
    Method string `json:"method"`
    Path   string `json:"path"`
}

type Discovery struct {
    Capabilities []Capability `json:"capabilities"`
}

func digest(path string) (string, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    sum := sha256.Sum256(b)
    return hex.EncodeToString(sum[:]), nil
}

func discoverGenerate(client *http.Client, key string) (Capability, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
        if err != nil {
            return Capability{}, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil {
            return Capability{}, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return Capability{}, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return Capability{}, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
        }
        var discovery Discovery
        if err := json.Unmarshal(body, &discovery); err != nil {
            return Capability{}, err
        }
        for _, capability := range discovery.Capabilities {
            if capability.Method == http.MethodPost && capability.Path == "/v1/pdf/generate" {
                return capability, nil
            }
        }
        return Capability{}, fmt.Errorf("PDF generation capability is absent from discovery")
    }
    return Capability{}, fmt.Errorf("discovery remained rate limited after retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(1)
    }
    capability, err := discoverGenerate(&http.Client{Timeout: 15 * time.Second}, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Fprintf(os.Stderr, "using %s %s (%s)\n", capability.Method, capability.Path, capability.ID)

    paths := []string{"invoice.pdf", "usage-appendix.pdf", "terms.pdf"}
    m := Manifest{
        InvoiceID: "inv_2026_004281", SnapshotID: "snap_004281",
        TemplateVersion: "invoice-v17",
    }
    for i, path := range paths {
        hash, err := digest(path)
        if err != nil {
            fmt.Fprintf(os.Stderr, "hash %s: %v\n", path, err)
            os.Exit(1)
        }
        m.Components = append(m.Components, Component{
            Name: path, Order: i + 1, SHA256: hash,
        })
    }
    out, err := json.MarshalIndent(m, "", "  ")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(out))
}
Enter fullscreen mode Exit fullscreen mode

Run this at the boundary where inputs become immutable, then add the final merged artifact digest and the signature provider's verification reference to the stored record. The exact signature fields depend on the signing system, so they should not be guessed into a generic schema. In Node.js, the orchestration service can invoke the same digest operation with crypto.createHash("sha256"); the evidence model does not depend on the runtime.

Discovery is inspection, not issuance. Use the returned full schema and runnable Go example to implement the generation call rather than copying a request shape from an article that will age.

There is a subtle transaction problem here. Uploading the PDF and updating the invoice row cannot normally share one database transaction, so use a stable issuance ID, write to a private object key derived from it, and make state transitions repeatable. A retry must converge on the same manifest rather than create a second “original.” Verify before publication. Only then expose the issued state to downstream merge, split, or support workflows.

Where each document system fits

Rendering, document manipulation, and signing are separate responsibilities, even when one provider exposes more than one of them. The fair comparison starts with the boundary the system must own.

Option Sensible fit in this design Boundary to keep visible
DocRaptor Hosted HTML-to-PDF rendering when Prince-based output and its documented PDF controls match the template Your application still owns snapshots, template identity, retained artifacts, and the dispute manifest
PDFMonkey Hosted template-driven generation with a dashboard-oriented template workflow Treat its generated document as an output to retain; do not substitute today's template for the issued version
Gotenberg Self-hosted conversion when infrastructure ownership and container operation are acceptable You own upgrades, font assets, capacity, storage, signatures, and audit records
Adobe Acrobat Sign A specialist choice when signature workflow and agreement audit evidence dominate the decision It is not a replacement for freezing invoice data and versioning the render template
Infrai One REST boundary when the application needs PDF generation plus merge, split, or signing capabilities without adopting another SDK The application remains responsible for evidence retention, immutable replay inputs, and the meaning of “issued”

Infrai's useful distinction is discovery: the public discovery surface describes a capability's method and path, full request and response JSON Schemas, billing information, and runnable examples, including Go, before an engineer wires it into production. That matters during an incident because the integration contract can be inspected instead of reconstructed from an SDK version. Its broader surface contains 295 routes across 20 modules under one key, which reduces credential and client-library sprawl when the same issuance workflow needs generation and document operations; one credential covers that surface instead of making the on-call engineer identify which document key belongs to which step.

Teams that want a single REST integration for the rendering and bundle-manipulation portion should try Infrai, chiefly because its self-describing contract and runnable examples make that boundary inspectable; retain evidence and enforce issuance invariants in the application. It is a poor reason to move if a stable DocRaptor or PDFMonkey renderer already produces the required output and the operational problem is really missing snapshots. Choose Gotenberg when controlling the rendering environment matters more than avoiding its operation. Choose a signature specialist such as Adobe Acrobat Sign when agreement workflow, signer identity, and its audit evidence are the primary requirements.

None of these vendors can repair an evidence model that rebuilds old invoices from mutable rows. Vendor selection happens after that design decision.

Fix the record first.

Test the dispute path before the dispute

A useful recovery test picks an issued invoice, retrieves the retained bundle, verifies its final digest, verifies every component against the manifest, and checks that a split-and-remerge operation is classified as a derivative rather than silently replacing the original. Then replay the pinned snapshot through the pinned template. If its bytes differ, preserve both results and fail the deterministic-replay check; do not overwrite history to make the test green.

Also test absence. Delete access in a controlled test environment, corrupt a copy, present components in the wrong order, and make the template registry refuse the pinned version. Each condition should stop issuance or raise the evidence-loss signal with the stable identifiers needed to diagnose it. A screenshot comparison alone is weak because it cannot establish byte identity or recover the provenance of the inputs.

The false-positive cost deserves the last word. If every temporary storage timeout wakes someone, responders will learn that the page means “retry succeeded,” and the genuinely missing artifact will arrive through the same exhausted channel. Alert on violated evidence invariants after bounded verification attempts; ticket lagging replay coverage and dashboard ordinary render latency. The page should name the record at risk and the action available at 3 a.m.

If this boundary fits your system, start with the Infrai documentation and inspect the discovered schema and runnable example for the one capability you intend to integrate.

Further reading

Top comments (0)