Short answer: generate each monthly usage statement from an immutable usage snapshot, render the PDF in a bounded worker, and send it only after the artifact and delivery attempt have durable records. A spend ceiling should be a deliberate refusal policy, not an accidental consequence of a mail provider quota.
The awkward part of per-customer billing is not the PDF. It is deciding what “this month” means when events arrive late, a customer changes plan mid-cycle, and a scheduler retries the same job at 00:00 UTC. A statement is a financial record, so I start with the ledger boundary and work outward to rendering and email.
What should a monthly usage statement pipeline guarantee?
Give every billing period an explicit half-open interval, such as [2026-08-01T00:00:00Z, 2026-09-01T00:00:00Z), and persist the snapshot version used for the statement. Do not query a mutable dashboard table while rendering. The query should read usage events that passed validation, group them by customer and meter, and record the event ids or a content hash that proves which rows were included.
The scheduler creates a job key from customer_id, period start, and period end. A unique constraint makes the key idempotent. The worker can then be boring: claim one job, build one snapshot, render one PDF, and move the job through states such as prepared, rendered, sent, or needs_review. A retry sees the existing state instead of silently charging twice.
That state machine is where most “email the invoice” examples are too casual. A successful SMTP response means the message was accepted by the next hop; it does not prove inbox delivery. Store the provider message id, recipient, template version, and a redacted error category. Keep the PDF in object storage with a retention policy, and put only a short-lived download URL in the message when attachments would exceed a size limit.
One hard rule: never put an API key in a generated PDF, log line, or task payload. OWASP’s secrets guidance recommends a dedicated secret-management process, rotation, and least-privilege access; the worker needs a send permission, not a database administrator token.
Keep the boundary sharp.
How do scheduled per-customer PDF emails control spend and refused traffic?
Metering and delivery are separate budgets. Let usage_cost cover the customer’s recorded consumption and delivery_cost cover PDF rendering, storage, and email attempts. Before admitting a batch, calculate the remaining monthly ceiling and reserve capacity with an atomic update. If the reservation would cross the ceiling, refuse new work with a visible reason and leave the usage statement in needs_review; do not fabricate a partial invoice.
Here is a small policy core. It is intentionally provider-neutral, because the useful decision is the refusal semantics, not an SDK call.
from dataclasses import dataclass
@dataclass(frozen=True)
class Budget:
ceiling_cents: int
reserved_cents: int
def reserve(budget: Budget, estimated_cents: int) -> Budget:
remaining = budget.ceiling_cents - budget.reserved_cents
if estimated_cents > remaining:
raise ValueError("delivery_budget_exceeded")
return Budget(
ceiling_cents=budget.ceiling_cents,
reserved_cents=budget.reserved_cents + estimated_cents,
)
The database transaction around this function must lock the budget row or use a compare-and-swap update. Otherwise two scheduler shards can both observe the same remaining amount and overspend it. Refused traffic is an operational outcome: expose a metric, alert the account owner, and provide a replay path after the ceiling is raised.
I would rather refuse a low-priority re-send than let a retry storm consume the budget. Your mileage may vary if the statement itself is a legal obligation; in that case, reserve a separate compliance budget and page an operator when it is exhausted.
Which failure modes make the PDF and email disagree?
Late events are the first trap. If the statement closes on the first day of the next month, define a correction window and a credit-note process instead of rewriting a sent PDF. Clock skew is another: normalize event timestamps to UTC at ingestion and retain the original timestamp for audit.
Consider a customer whose August traffic arrives in three waves: accepted events on August 31, a retried export on September 1, and a delayed batch on September 3. A naïve WHERE timestamp < September 1 query produces a defensible first total but no way to explain the later delta. The safer workflow stores the first snapshot, tags the late events as a new revision, and emits a correction statement that points back to the original statement id. That sounds like extra bookkeeping until a customer disputes one line item and you need to show exactly which event ids were known at close. It also keeps PDF rendering deterministic: the same snapshot hash always produces the same input, even if the dashboard has since been recomputed. I would rather expose a visible correction than silently alter a document that someone has already downloaded.
That is the failure I design around.
Rendering can fail on an unexpectedly long customer name, a missing font, or a table with ten thousand line items. Set page, time, and memory limits in the renderer. A failed render should leave the snapshot intact so a new renderer version can reproduce the same input. Do not mark the job sent merely because a PDF file exists.
Email retries have their own edge cases. Use an idempotency key at the application layer, but assume the downstream mail system may deliver a duplicate anyway. Include a stable statement identifier in the subject and body, and make the customer portal the canonical copy. Bounce events should update a contact status without mutating the financial record.
There is a less visible failure: a customer changes their email address between generation and send. Resolve the recipient from the versioned billing profile captured with the snapshot, then require an explicit profile change to affect a future period. Otherwise a retry can send an old statement to a new address or vice versa.
What trade-offs belong in the architecture decision?
| Decision | Safer default | Cost or limitation |
|---|---|---|
| Snapshot timing | Close after a documented grace window | Late usage becomes a correction workflow |
| PDF storage | Encrypted object storage with retention | Storage and key-rotation work remain yours |
| Delivery | Queue plus idempotent worker | Messages can be delayed or duplicated |
| Budget policy | Refuse after an atomic reservation | Some statements need a separate compliance path |
| Reconciliation | Daily compare ledger, jobs, and provider events | More queries and alert triage |
The choice is not suitable when you need real-time tax calculation, jurisdiction-specific filing, or a guaranteed inbox SLA; use a specialized billing or compliance system for those boundaries and keep your usage ledger as the source of truth. A simple worker is a good fit for a developer tool that needs predictable monthly statements, not a replacement for every accounting control.
How can a Node.js team roll out monthly usage statement generation safely?
Even if the application is Node.js, keep the interfaces small: load_snapshot(period, customer), render_pdf(snapshot), deliver(message), and record_result(result). Put contract tests around each boundary and run a fake clock through month-end, daylight-saving transitions, retries, and a deliberately refused budget reservation. The PDF bytes should be checked for a statement id and period, not just a nonzero file size.
Start with one internal customer and a shadow mode that renders but does not send. Compare totals against the existing usage report for two periods. Then enable a small cohort, watch render duration, queue age, bounce rate, duplicate statement ids, and reserved-versus-actual delivery cost. Keep a manual replay command that takes a statement id, never an arbitrary customer query, so an operator cannot accidentally regenerate an entire month.
I am not sure a single “sent” counter will stay useful once several delivery channels exist. Record channel-specific attempts now, even if email is the only channel, and you will be able to explain a discrepancy without scraping provider dashboards later.
The durable design is modest: immutable inputs, explicit period boundaries, a state machine, atomic budget reservations, and an audit trail. The PDF is just one projection of that record.
Top comments (0)