Skip to content

Usage Billing Part 1: Duplicate Ingestion, Resumable Closing, and the Ledger

A dark 3D workbench where usage events pass through a pricing timeline and resumable billing close into a ledger and sealed invoice archive
The hard part of usage billing is not multiplication. It is duplicate delivery, time, restartability, and correction after finalization.

Multiplying API calls by a unit price appears sufficient to calculate a monthly charge. The formula is simple; production behavior is not.

The same usage event may arrive more than once, prices may change during the month, and a close worker may stop halfway through its run. Usage that arrives after finalization must not disappear either. Continually updating one aggregate table makes it difficult to answer which price was applied to each event and whether a restart duplicated money.

This article uses the bluetape4k-workshop usage metering and billing ledger example to connect duplicate-safe ingestion, occurrence-time pricing, resumable closing, an immutable ledger, and adjustments.

Define the billing correctness boundary first

Section titled “Define the billing correctness boundary first”

The representative scenario is a multi-tenant SaaS that bills API calls monthly. An ingestion endpoint accepts ten calls of usage, a price timeline changes the unit price at a known instant, and the monthly close records eligible usage in a ledger before finalizing an invoice.

The example covers four boundaries:

  • It deduplicates the same HTTP command and the same producer event by different identities.
  • It selects the price effective when usage occurred.
  • It resumes interrupted closing from a fixed cutoff and keyset checkpoint.
  • It corrects finalized results with new ledger entries instead of rewriting them.

It does not implement taxes, payment collection, foreign exchange, combined discounts, or tiered pricing. Nor is it a general-purpose billing platform. Its purpose is to make the first set of correctness boundaries executable through code and PostgreSQL constraints.

Three times answer three different questions

Section titled “Three times answer three different questions”

One timestamp is not enough for usage billing.

TimeAuthorityQuestion answered
occurredAtReported by the producer and bounded by service validationWhen did usage occur, and which price version applies?
receivedAtSet by the ingestion service’s ClockHad the server received this usage before the close cutoff?
Posting periodSelected by the ledgerIn which accounting period should the amount be posted?

Usage may occur on June 30 and arrive on July 2. Pricing still uses its June occurredAt, but an event received after the June cutoff does not reopen the finalized June ledger. It becomes an adjustment in the currently open posting period. Each time has one responsibility and must not silently substitute for another.

HTTP retries and producer duplicates are different problems

Section titled “HTTP retries and producer duplicates are different problems”

A client retrying because it missed a response and an upstream system delivering the same usage event through another request have different identities. The example layers two boundaries:

receipt = commandReceipts.acquire(tenant, operation, keyDigest, fingerprint)
when (receipt) {
is Replay -> return receipt.savedResponse
is Conflict -> throw IdempotencyConflict()
is InProgress -> throw RetryLater(receipt.retryAfter)
is Acquired -> {
usage = usageEvents.appendIfAbsent(sourceSystem, sourceEventId, payloadDigest)
commandReceipts.complete(receipt.ownerToken, responseFor(usage))
return responseFor(usage)
}
}
BoundaryIdentityProblem preventedMeaning of conflict
HTTP command receiptTenant, operation, and Idempotency-Key digestRe-execution of the same commandA different request fingerprint uses the same key
Producer event unique keyTenant, source system, and source event IDDuplicate storage through another HTTP requestA different payload digest uses the same source event ID

The command receipt replays a terminal response and permits takeover with a new owner token after an abandoned lease expires. The producer event is stored once through a database unique constraint. Together they preserve transport behavior and business event identity without pretending they are one problem.

Select a price from the occurrence-time half-open interval

Section titled “Select a price from the occurrence-time half-open interval”

Price versions use half-open intervals [effectiveFrom, effectiveTo).

v1: [2026-01-01T00:00Z, 2026-03-01T00:00Z) USD 0.10
v2: [2026-03-01T00:00Z, ∞) USD 0.12

Usage occurring exactly at 2026-03-01T00:00Z selects v2, with no overlap between the end of v1 and the start of v2. Price selection uses occurredAt; close eligibility uses receivedAt <= cutoffReceivedAt. Selecting the current price at close time would make a historical amount depend on when the close is rerun.

The flow from usage ingestion and occurrence-time pricing through resumable close, ledger, invoice, and adjustment
Ingestion, pricing, close, ledger, and invoice each protect a different invariant inside one PostgreSQL authority.

Explore the ledger workflow interactively

Closing is a resumable process, not a one-shot job

Section titled “Closing is a resumable process, not a one-shot job”

Starting a close moves the billing period from OPEN to CLOSING and fixes cutoffReceivedAt once. The worker then reads a default batch of 200 rows after the (occurredAt, usageEventId) keyset checkpoint.

transaction {
batch = usageEvents.findAfter(
checkpoint = run.checkpoint,
cutoff = run.cutoffReceivedAt,
limit = 200,
)
for (usage in batch) {
price = prices.findAt(usage.occurredAt) ?: markUnpriced(usage)
ledger.appendChargeOnce(usage, price)
}
closeRuns.advanceCheckpoint(run.id, batch.lastKey)
}

appendChargeOnce() and checkpoint advancement commit in the same transaction. A process exit before commit rolls both back. An exit immediately after commit may cause the next run to inspect the same range again, but the ledger’s source unique key prevents duplicate money. Work may repeat; the amount may not.

The close run reaches READY_TO_FINALIZE only when no eligible rows remain and the unpriced count is zero. A missing price interval stops the run in FAILED_VALIDATION. After an operator explicitly repairs the gap, the run starts again with the same fixed cutoff instead of hiding a partial bill.

Finalization seals both the invoice and its provenance

Section titled “Finalization seals both the invoice and its provenance”

Finalization groups posting-period ledger entries by meter, price version, and entry type. It also records which entries belong to each invoice line. The transaction checks this equality:

sum(invoice lines) == invoice total == sum(linked ledger entries)

It then finalizes the invoice, lines, provenance links, billing period, and close run together. Append-only repository contracts reject generic updates and deletes, preserving both the financial result and the evidence behind it.

Late usage does not reopen a finalized period

Section titled “Late usage does not reopen a finalized period”

Usage received after the close cutoff is still priced by its original occurredAt. It does not mutate the finalized service period, however. The service appends a positive DEBIT_ADJUSTMENT to the one open posting period containing the server’s current time.

Credits also store a positive amount and use CREDIT_ADJUSTMENT for direction, linking back through relatedOriginalEntryId. This keeps amount sign and business meaning from being interpreted differently across layers.

Recover from the durable evidence that remains

Section titled “Recover from the durable evidence that remains”

Restarting every step from the beginning is unsafe after a failure. This example retains completed receipts, accepted usage, ledger entries, the close cutoff, and the checkpoint as durable evidence, then chooses a recovery boundary from the point at which processing stopped.

Failure boundaryDetection signalAutomatic protectionOperator recoveryRecovery gate
Lost response or abandoned command ownerReceipt remains IN_PROGRESS after its lease expiresOnly a new owner token may take overRetry with the same Idempotency-Key and fingerprintThe stored response replays and exactly one business result exists
Same producer event ID with different contentPayload digest conflictUnique constraint and digest comparison reject the writeInvestigate upstream ID reuse or payload mutationThe source event ID maps to one verified payload
Missing occurrence-time price intervalClose run enters FAILED_VALIDATION; unpriced count growsPartial invoice finalization is blockedAdd the explicit price interval and resume with the same cutoffUnpriced count is zero and the run is READY_TO_FINALIZE
Worker stops around a batch commitCheckpoint stalls or the lease expiresTransaction rollback and the ledger source unique keyResume after the last durable checkpointThe checkpoint reaches the final candidate with no duplicate ledger entry
Incorrect amount found after finalizationReconciliation finding or customer disputeFinalized ledger entries and invoices reject mutationAppend a linked DEBIT_ADJUSTMENT or CREDIT_ADJUSTMENTLedger, invoice, and provenance totals match and the finding is closed

Use the following recovery order:

  1. Identify the affected tenant, period, command, or close run.
  2. Determine which receipt, usage event, ledger entry, cutoff, and checkpoint values committed.
  3. Preserve that evidence and select only takeover, resume, or append-only adjustment.
  4. Recheck deduplication constraints, price versions, checkpoints, and monetary totals.
  5. Declare recovery complete only when no reconciliation finding remains unresolved.

Do not mark a receipt complete by hand, advance a checkpoint past unprocessed work, or update or delete a finalized ledger entry. Those actions remove the evidence needed to distinguish a duplicate from a missing charge.

ConditionBenefitDecision still required
Commands and events can be delivered more than onceSeparate response replay from business-event uniquenessReceipt retention and lease policy
Prices change over timeReproduce the price effective at occurrenceDiscount, tiering, and currency rules
Closing cannot finish in one transactionResume from a fixed cutoff and checkpointOperational SLA, retries, and alerts
Finalized results require an audit trailPreserve ledger, invoice, and provenanceTax, collection, and accounting integration

This design does not solve every billing problem. It is a baseline for deciding which invariants must appear first when a simple aggregate encounters duplicate delivery, temporal pricing, process restarts, and post-finalization corrections.

Part 2 will examine what is gained—and what operational cost is added—when the ledger model moves from current state to domain events, aggregate replay, snapshots, and projection rebuilding.

Comments

Leave a note or reaction with your GitHub account.