DEV Community

HumphreyFox1243
HumphreyFox1243

Posted on

Node.js Healthtech Statements: Validating Metered Usage Before PDF Flattening

Short answer: validate and total the metered time series before touching the PDF, write approved values into named form fields, flatten only the recipient copy, and verify both the data and rendered artifact. For monthly healthtech statements, this ordering keeps billing logic testable while making the fidelity-versus-render-cost choice explicit.

The before/after mental model is compact. Before, one function fetches readings, calculates money, edits a PDF, and hopes the output looks right. After, the pipeline has four observable stages: normalize input, calculate an immutable statement model, fill the form, then flatten and inspect the result. The PDF is a presentation artifact, not the ledger.

How should Node.js turn metered usage into a PDF statement?

A PDF form can faithfully display bad data. That is the dangerous failure mode. A polished statement with a duplicated interval or reversed timestamp looks authoritative even though its total is wrong. Rendering checks cannot prove that a usage timeseries is complete, ordered, or billed under the intended rate. A Node.js worker should therefore build a usage-based statement model before it opens the PDF template; teams that label runtimes as nodejs in deployment metadata should apply the same boundary there.

Math first.

Define the contract first. Each interval needs a stable ID, UTC boundaries, a nonnegative quantity, and one unit. Express the statement period as a half-open range: start is included and end is excluded. Adjacent months then meet without sharing an instant. Reject intervals outside the period, duplicate IDs, invalid timestamps, non-finite quantities, mixed units, and end times that do not follow start times. Decide separately whether overlaps are legal for the meter type.

Money deserves its own rule. Do not accumulate currency with binary floating-point arithmetic. This example accepts integer seconds and an integer rate in microcents per second, then rounds once to cents with integer arithmetic. Store the rate-plan version and calculation inputs beside the model so a later rerender cannot silently reprice old usage.

type UsagePoint = Readonly<{
  id: string;
  startedAt: string;
  endedAt: string;
  quantitySeconds: number;
  unit: "second";
}>;

type StatementInput = Readonly<{
  accountId: string;
  periodStart: string;
  periodEnd: string;
  rateMicrocentsPerSecond: bigint;
  usage: readonly UsagePoint[];
}>;

type StatementModel = Readonly<{
  accountId: string;
  periodLabel: string;
  usageSeconds: bigint;
  amountCents: bigint;
  sourceIds: readonly string[];
}>;

function instant(value: string, field: string): number {
  const milliseconds = Date.parse(value);
  if (!Number.isFinite(milliseconds)) throw new Error(`${field} is invalid`);
  return milliseconds;
}

function buildStatement(input: StatementInput): StatementModel {
  const start = instant(input.periodStart, "periodStart");
  const end = instant(input.periodEnd, "periodEnd");
  if (end <= start) throw new Error("Invalid statement period");
  if (input.rateMicrocentsPerSecond < 0n) throw new Error("Negative rate");

  const seen = new Set<string>();
  let usageSeconds = 0n;
  for (const point of input.usage) {
    if (seen.has(point.id)) throw new Error(`Duplicate usage ID: ${point.id}`);
    seen.add(point.id);
    const pointStart = instant(point.startedAt, `${point.id} start`);
    const pointEnd = instant(point.endedAt, `${point.id} end`);
    if (pointStart < start || pointEnd > end || pointEnd <= pointStart) {
      throw new Error(`Usage ${point.id} is outside the period`);
    }
    if (!Number.isSafeInteger(point.quantitySeconds) || point.quantitySeconds < 0) {
      throw new Error(`Usage ${point.id} has an invalid quantity`);
    }
    usageSeconds += BigInt(point.quantitySeconds);
  }

  const microcents = usageSeconds * input.rateMicrocentsPerSecond;
  return Object.freeze({
    accountId: input.accountId,
    periodLabel: `${input.periodStart} / ${input.periodEnd}`,
    usageSeconds,
    amountCents: (microcents + 500_000n) / 1_000_000n,
    sourceIds: Object.freeze([...seen].sort())
  });
}
Enter fullscreen mode Exit fullscreen mode

This function does not infer samples, convert local time, fetch a rate, or format currency. Those are policy decisions. Keeping them outside prevents a renderer upgrade from changing statement math.

Fill first, flatten last

A fillable PDF uses interactive form structures, commonly called AcroForm fields. A field name links a semantic value such as statement_total to page widgets. Filling updates that model. Flattening converts the visible field appearance into regular page content and removes or disables interactive behavior, depending on the implementation. ISO 32000-2 is the governing PDF specification; check library documentation for its exact flattening semantics.

Keep the adapter tiny.

type PdfFields = Readonly<Record<string, string>>;

interface PdfFormAdapter {
  listFieldNames(template: Uint8Array): Promise<readonly string[]>;
  fill(template: Uint8Array, fields: PdfFields): Promise<Uint8Array>;
  flatten(filledPdf: Uint8Array): Promise<Uint8Array>;
}

async function renderStatement(
  adapter: PdfFormAdapter,
  template: Uint8Array,
  model: StatementModel
): Promise<Uint8Array> {
  const fields = {
    account_id: model.accountId,
    statement_period: model.periodLabel,
    usage_seconds: model.usageSeconds.toString(),
    statement_total_cents: model.amountCents.toString()
  };
  const available = new Set(await adapter.listFieldNames(template));
  const missing = Object.keys(fields).filter((name) => !available.has(name));
  if (missing.length) throw new Error(`Missing fields: ${missing.join(", ")}`);
  return adapter.flatten(await adapter.fill(template, fields));
}
Enter fullscreen mode Exit fullscreen mode

The diagram in words is: usage store to validator, validator to statement model, model to form filler, filler to flattener, flattener to artifact checks, then object storage. Metadata connects the statement ID to the template checksum, source IDs, calculation version, artifact checksum, and render status. Keep protected health information out of logs and metric labels; use opaque references under the organization's access policy.

Template field names form an API. Treat a renamed field as a breaking schema change. Pin a template checksum, test its expected field set in continuous integration, and deploy it with the renderer version that understands it. This catches the wonderfully mundane failure where a designer changes usage_seconds to usage_duration and every output becomes blank in one spot.

What does flattening actually buy you?

Flattening improves consistency for a recipient copy because viewers no longer need to regenerate interactive field appearances. It also prevents routine editing of those controls. It is not encryption, access control, a digital signature, or proof that values are correct. Do not call it tamper-proof.

There is a cost. Form-only validation can inspect field names and values without rasterizing each page. Visual validation requires a renderer, fonts, CPU time, and reference images. That is the main decision: fidelity versus render cost. This approach also has real limitations: it is a poor fit for statements whose layout is generated from highly variable clinical narratives, and flattening removes the convenience of recipient-editable fields. In those cases, generate a fixed-layout document from structured content or retain a separately controlled interactive copy. The extra artifact and access policy cost may be justified, but it must be intentional.

Use tiers. Every statement should pass schema validation, field-set validation, page-count expectations, byte-size bounds, parseability, and checksum recording. Render representative samples from every template version and compare them with an approved baseline. For consequential documents, render every statement and inspect targeted regions where totals, dates, and overflow-prone text appear.

Text extraction may catch a missing total, but it cannot prove that text is visible or uses the right glyphs. Pixel comparison catches clipping and font substitution, yet can be noisy across renderer versions. Pin the rendering environment and set tolerances. Combining semantic assertions with a few visual regions gives a clearer signal than one whole-page binary verdict.

How should the pipeline fail and retry?

Make each statement idempotent with a key derived from account, period, calculation version, and template version. A retry with identical inputs addresses the same logical job. Write the finished PDF under a content-addressed or versioned object key, then update metadata only after validation succeeds. A partial render must never replace the last approved artifact.

Classify failures. Invalid usage and a missing template field are permanent until data or configuration changes; repeated retries waste capacity. A transient storage timeout can retry with bounded exponential backoff. A missing font or unreadable template is a deployment fault and should alert the owning team after a small retry budget.

Track render latency by template version, failures by stage and reason, plus output size and page-count distributions. Add queue age. Alert on sustained failure ratios and old work, not one slow render. Useful logs carry a job ID, versions, stage, duration, and outcome. They do not carry patient names, addresses, raw form values, or an entire PDF.

Keep stages separately timed. If validation stays flat while flattening latency jumps, investigate fonts, template complexity, and the PDF engine before blaming the usage store. Crisp boundaries produce crisp alerts.

Measure the boundary.

Two objections worth settling

Why retain an unflattened version? Often, you should not retain a recipient-specific editable artifact. Keep the source template, immutable model, calculation inputs, versions, and final flattened output according to the retention policy. That package can reproduce the document while reducing editable files containing sensitive values. If records policy requires the filled form, classify it separately with explicit access and retention controls.

Can one library fill and validate the PDF? It can make structural checks, but that does not collapse the concerns. An engine may parse its own appearance stream while another viewer renders it differently. Structural parsing, text assertions, and an independent render check answer different questions. Independence costs compute, so sample visual checks when risk allows and validate every render when fidelity requirements justify it.

The rule is simple: calculate once, record inputs, fill by stable field name, flatten at the distribution boundary, and spend rendering capacity in proportion to the consequence of a visual defect. The monthly statement becomes explainable before it becomes beautiful. Both matter.

Further reading

Top comments (0)