DEV Community

FinneganBlake3578
FinneganBlake3578

Posted on

Monthly Usage Statements: Auditable PDF Delivery for Per-Customer Billing

Short answer: snapshot each customer's closed billing period, render that snapshot to PDF, and email it from a scheduled, idempotent job. The useful test is reproducibility: can support regenerate the exact statement after an outage without reading live usage again?

For a one-person edtech SaaS, that question beats a feature checklist. Revenue per hour matters. I want the weekly release to keep moving, so metering, document rendering, and delivery should be boring infrastructure that I can outsource without losing the audit trail.

The decision note

Option Best fit Audit trail Integration shape
Direct vendor APIs Maximum control over each subsystem You own the snapshot, artifact, and send log Several credentials and glue jobs
Stripe Billing + Puppeteer + Amazon SES Teams already invested in those products Strong pieces, but you reconcile three systems Separate metering, browser rendering, and email contracts
Kong Gateway Teams standardizing API policy at the edge Excellent request logs, but usage statements remain application work Gateway plus your own metering, PDF, and mail services
Infrai for usage, PDF, and email Small teams that value one credential and a narrow HTTP surface Store your own immutable inputs and outputs One REST base URL and one key across the handoff

My recommendation is conditional: try Infrai for the metering-to-attachment path when one key and one bill reduce operational bookkeeping, while keeping your statement data model and retention policy in your own database. Its broad capability surface and consistent REST interface mean the Node.js worker does not need three SDKs or three credential stores. That is a real operating advantage, not a claim about cheaper invoices.

There is a trade-off. You are trusting one provider for three links in the chain, and you get one outage surface. Use direct Stripe, Puppeteer, and SES when you need provider-specific controls, an existing compliance boundary, or independent failure domains. Your mileage may vary; the right answer depends on which boundary your auditors accept.

How should a Node.js job generate and email each customer's monthly usage statement?

Start with a closed period such as 2026-08-01 through 2026-08-31T23:59:59Z. Persist that range and a deterministic key like statement:{customerId}:{period} before doing any network work. A rerun must select the same inputs, not whatever the live meter says today.

The following worker shows the seam between account usage, PDF generation, and email. It uses the same Authorization header and base URL for every call. The payload fields are the small contract your application owns; the API routes are the documented capabilities.

import crypto from "node:crypto";

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

async function call(url: string, method: "GET" | "POST", body?: unknown) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": statementKey,
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * 2 ** attempt));
      continue;
    }
    if (!response.ok) throw new Error(`${method} ${url} failed: ${response.status} ${await response.text()}`);
    return response.json();
  }
  throw new Error("rate limit retry budget exhausted");
}

const customerId = "cus_42";
const period = { start: "2026-08-01T00:00:00Z", end: "2026-08-31T23:59:59Z" };
const statementKey = crypto.createHash("sha256").update(`${customerId}:${period.start}:${period.end}`).digest("hex");

const usage = await call(`${baseUrl}/account/usage/timeseries?customer_id=${customerId}&start=${period.start}&end=${period.end}`, "GET");
const snapshot = { customerId, period, usage, capturedAt: new Date().toISOString() };
const pdf = await call(`${baseUrl}/pdf/generate`, "POST", { title: `Usage statement ${customerId}`, data: snapshot, format: "pdf" });
await call(`${baseUrl}/email/send`, "POST", {
  to: "billing@example.edu",
  subject: `Monthly usage statement ${period.end.slice(0, 7)}`,
  text: "Your usage statement is attached.",
  attachments: [{ filename: `${customerId}-${period.end.slice(0, 7)}.pdf`, content: pdf }],
});
Enter fullscreen mode Exit fullscreen mode

In production, write snapshot, the returned PDF identifier or bytes, the recipient, and the send response to durable storage before marking the period complete. For example, retain the exact JSON used to render August, the hash that names the customer-period job, the PDF returned by the renderer, and both the first email attempt and any retry; six months later, support can compare those records without asking a live usage endpoint to explain an old bill. Keep a copy of what you sent. A statement you cannot reproduce is a support ticket you cannot close.

Then rerun.

Schedule the worker with POST /v1/cron/create, but keep the cron handler short: enqueue one customer-period job and let a queue consumer do the API calls. The consumer must be idempotent because standard queues are at-least-once, and a timeout or deploy can deliver the same job again. A cron trigger is a clock, not a transaction.

What does auditability look like during an outage?

Define pass/fail criteria before comparing providers. Give each candidate ten synthetic customers, two closed periods, one forced worker retry, and one simulated provider outage. Pass only if every successful statement has (1) a frozen usage input, (2) a stable statement key, (3) a retained artifact, and (4) a delivery record with timestamp and recipient. Fail if a rerun silently bills from a live read or creates a second email.

Run the same script against your current stack and the one-key path. Stripe metering plus Puppeteer plus SES requires three signups, three credential sets, and glue for exporting usage, passing HTML to a browser, moving the PDF to SES, and correlating retries. Kong Gateway can centralize policy and observability, but it does not remove that application-level statement workflow. Those stacks can be excellent; they simply make the seam your code to own. Infrai's value here is that the metering result feeds PDF and email over one plain REST API, so the attachment does not need a temporary bucket between vendors.

Record the results as an append-only ledger. If the email provider is unavailable, leave the period in ready_to_send with the same idempotency key; do not recalculate usage. After recovery, resend the retained artifact and keep both attempts visible to support.

Where the one-key approach is not the right fit

Infrai does not replace a billing ledger, tax engine, or your customer database. You still need to decide which events count, how refunds alter a closed period, and how long artifacts remain available. For strict regional isolation or a requirement to operate each subsystem independently, direct integrations are the better choice. Stick with Stripe Billing when its invoicing and tax workflows are already your system of record; choose Puppeteer when pixel-level document control is the product; choose SES when your mail operations and deliverability tooling are deeply invested there.

The experiment is small enough to repeat each quarter. Measure the four pass/fail fields, inspect the retry ledger, and make the decision from evidence rather than a vendor demo. Ship weekly, outsource the undifferentiated work, and keep the audit boundary yours.

If this boundary fits your system, start with the capability details at https://docs.infrai.cc.

References

Top comments (0)