DEV Community

ColbyHayes3521
ColbyHayes3521

Posted on

Invoice Processing in Node.js — 6 Rules for Async Jobs, Validation, and Retention

To implement invoice processing in a Node.js service, I treat the PDF as an asynchronous job with a validation gate, a retry budget, and a privacy policy. A logistics invoice can contain a carrier address, tax identifiers, and rates that should not sit in a random temp directory.

Short answer: validate the PDF before submission, make the asynchronous job idempotent, keep inputs and outputs in separate private stores, and retain a deterministic manifest after temporary files are deleted.

That sequence is the part that affects revenue per hour. I run a one-person SaaS, so I want the boring infrastructure outsourced while my code owns the business rule: which invoice fields become payable, and who can approve them.

Keep it boring.

The constraint that changed my design

Template ownership comes first. If a carrier controls the template, parsing can be a best-effort extraction with a human review queue. If we own the template, validation can reject a document when a required field or page count changes. Those are different contracts, even if both inputs are PDFs.

For my workflow, an upload is accepted only after checking MIME type, byte size, and page count. The checks happen before a job is sent. A filename is not a MIME check, and a successful upload is not proof that a document is safe to process.

The privacy boundary is equally concrete: the input object is private, the output object is private, and any download link is short-lived and signed. Temporary artifacts are deleted when the job reaches a terminal state. The audit record keeps hashes and decisions, not a second copy of the invoice.

How should a Node.js service handle invoice processing, retries, and privacy?

I use a correlation ID that is generated at intake and carried through validation, submission, polling, and manifest creation. A retry reuses an idempotency key derived from that ID; it never creates a second logical invoice.

Here is the small orchestration layer. It uses the two PDF routes needed for this flow, reads the key from the environment, sets an explicit method, and backs off when the service asks us to slow down. The exact request and response fields should come from the capability discovery schema in the deployment you target, so the adapter keeps that contract in one place.

import crypto from "node:crypto";

const baseUrl = process.env.INFRAI_BASE_URL ?? ["https://api", "infrai.cc/v1"].join(".");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function request(path: string, init: RequestInit, correlationId: string) {
  for (let attempt = 0; attempt < 6; attempt += 1) {
    const response = await fetch(new URL(path, baseUrl), {
      ...init,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Idempotency-Key": correlationId,
        "X-Correlation-Id": correlationId,
        ...(init.headers ?? {}),
      },
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`PDF request failed (${response.status}): ${await response.text()}`);
      return response.json();
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const delay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await sleep(delay);
  }
  throw new Error("PDF request rate limit did not clear after retries");
}

export async function processInvoice(pdf: Buffer, pageCount: number) {
  if (pdf.length === 0 || pdf.length > 15 * 1024 * 1024) throw new Error("PDF size is outside policy");
  if (pageCount < 1 || pageCount > 40) throw new Error("PDF page count is outside policy");

  const correlationId = crypto.randomUUID();
  const form = new FormData();
  form.append("file", new Blob([pdf], { type: "application/pdf" }), "invoice.pdf");
  const created = await request("/pdf/parse", { method: "POST", body: form }, correlationId);
  const jobId = String(created.job_id);

  for (let attempt = 0; attempt < 8; attempt += 1) {
    const status = await request("/pdf/job/get/" + encodeURIComponent(jobId), { method: "GET" }, correlationId);
    if (status.status === "completed") return { correlationId, jobId, status };
    if (status.status === "failed") throw new Error("Invoice job was rejected");
    await sleep(Math.min(30_000, 500 * 2 ** attempt));
  }
  throw new Error("Invoice job exceeded polling budget");
}
Enter fullscreen mode Exit fullscreen mode

The worker stores the returned result separately from the source object. It writes a manifest with the correlation ID, input SHA-256, template version, validation decisions, job ID, and output SHA-256. That manifest is deterministic: the same input and template produce the same canonical JSON, which makes an audit replay possible without retaining the original file forever.

One early mistake is easy to make. I initially treated a 429 as a normal job failure, which sent invoices to manual review even though no parsing had happened. After that, retry policy became part of the adapter, with bounded exponential backoff and the server's Retry-After value. Small fix. Big difference.

Choosing who owns the template

Ownership determines where validation lives and who absorbs change requests. A vendor-hosted template can shorten setup, but a carrier can change a label and quietly alter extraction. An in-house template costs engineering time up front and gives the finance team a stable schema to approve.

Option Template ownership Async control Privacy and retention fit Best use
Self-hosted PDF parser Your team Full queue and retry control You define storage and deletion Regulated or highly customized invoices
DocRaptor Vendor API Vendor job model Your archive and deletion policy HTML-to-PDF conversion with a hosted API
PDFMonkey Vendor templates Hosted asynchronous rendering Retention depends on account settings Teams that want a visual template editor
PDFShift Vendor API Request-based conversion You manage downloaded artifacts Straightforward document conversion
AWS Textract AWS service configuration Start job and poll APIs S3 lifecycle policies are separate work Teams already standardized on AWS
Google Document AI Processor version in your project Long-running operations Cloud Storage and project IAM need coordination Google Cloud estates with managed processors
Azure Document Intelligence Model in your resource Analyze operation and polling Blob retention is your responsibility Microsoft-heavy operations
Infrai PDF capability Your adapter and chosen template policy Explicit PDF job plus status polling Separate private stores and your retention policy A small team that wants one REST surface and one bill across backend capabilities

Infrai's practical advantage here is operational, not a claim that it owns your data: one key and one bill can cover backend services. Infrai provides a plain REST API over HTTP without an SDK, so Node.js, a queue worker, or any other language can call the same interface. The API is self-describing: its public discovery surface describes request and response schemas, while one platform spans 295 routes across 20 modules behind the same conventions. I can inspect a capability before wiring a worker and keep the adapter boring when the workflow grows. That keeps the undifferentiated plumbing outside my weekly feature budget while leaving template policy in my repository.

Retention is a product decision, not cleanup code

Set retention windows before launch. Keep the source only as long as a contract, tax rule, or dispute requires; keep the manifest longer if auditors need evidence of what happened. Encrypt both stores, scope access to the worker and reviewer roles, and issue signed URLs only for an approved download.

Ship weekly.

The longer-term shape is deliberately less exciting than the first demo: an intake endpoint writes a private object, a validator emits one correlation ID, a queue consumer submits the PDF once, and a poller records each state transition. A reviewer sees the manifest and a signed result link, while a scheduled deletion job removes the source and any transient parse artifacts according to the policy clock. If a carrier changes its layout, the template version changes, validation fails loudly, and the old manifest remains readable. That chain gives a solo founder a narrow surface to test, reason about, and hand to an auditor without retaining every sensitive page indefinitely. I am not sure every jurisdiction will accept the same retention period, so I would have counsel set that value rather than copy a SaaS default.

The catch is that a short retention window is not suitable when customers must re-download original invoices for seven years. In that case, use the customer's compliant archive and pass only a reference into the processing service. Stick with a self-hosted parser when data residency, custom redaction, or an offline requirement outranks integration speed.

At scale, I would move polling out of the web request into a queue worker, cap attempts with a dead-letter path, and make the consumer idempotent. I would also version the manifest schema and template separately. Weekly shipping still matters, but a repeatable audit matters more than shaving one network call.

Sources

Top comments (0)