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

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.
| Time | Authority | Question answered |
|---|---|---|
occurredAt | Reported by the producer and bounded by service validation | When did usage occur, and which price version applies? |
receivedAt | Set by the ingestion service’s Clock | Had the server received this usage before the close cutoff? |
| Posting period | Selected by the ledger | In 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) }}| Boundary | Identity | Problem prevented | Meaning of conflict |
|---|---|---|---|
| HTTP command receipt | Tenant, operation, and Idempotency-Key digest | Re-execution of the same command | A different request fingerprint uses the same key |
| Producer event unique key | Tenant, source system, and source event ID | Duplicate storage through another HTTP request | A 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.10v2: [2026-03-01T00:00Z, ∞) USD 0.12Usage 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.

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 boundary | Detection signal | Automatic protection | Operator recovery | Recovery gate |
|---|---|---|---|---|
| Lost response or abandoned command owner | Receipt remains IN_PROGRESS after its lease expires | Only a new owner token may take over | Retry with the same Idempotency-Key and fingerprint | The stored response replays and exactly one business result exists |
| Same producer event ID with different content | Payload digest conflict | Unique constraint and digest comparison reject the write | Investigate upstream ID reuse or payload mutation | The source event ID maps to one verified payload |
| Missing occurrence-time price interval | Close run enters FAILED_VALIDATION; unpriced count grows | Partial invoice finalization is blocked | Add the explicit price interval and resume with the same cutoff | Unpriced count is zero and the run is READY_TO_FINALIZE |
| Worker stops around a batch commit | Checkpoint stalls or the lease expires | Transaction rollback and the ledger source unique key | Resume after the last durable checkpoint | The checkpoint reaches the final candidate with no duplicate ledger entry |
| Incorrect amount found after finalization | Reconciliation finding or customer dispute | Finalized ledger entries and invoices reject mutation | Append a linked DEBIT_ADJUSTMENT or CREDIT_ADJUSTMENT | Ledger, invoice, and provenance totals match and the finding is closed |
Use the following recovery order:
- Identify the affected tenant, period, command, or close run.
- Determine which receipt, usage event, ledger entry, cutoff, and checkpoint values committed.
- Preserve that evidence and select only takeover, resume, or append-only adjustment.
- Recheck deduplication constraints, price versions, checkpoints, and monetary totals.
- 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.
When this structure fits
Section titled “When this structure fits”| Condition | Benefit | Decision still required |
|---|---|---|
| Commands and events can be delivered more than once | Separate response replay from business-event uniqueness | Receipt retention and lease policy |
| Prices change over time | Reproduce the price effective at occurrence | Discount, tiering, and currency rules |
| Closing cannot finish in one transaction | Resume from a fixed cutoff and checkpoint | Operational SLA, retries, and alerts |
| Finalized results require an audit trail | Preserve ledger, invoice, and provenance | Tax, 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.
Where the series goes next
Section titled “Where the series goes next”- Usage Billing Part 1: Duplicate Ingestion, Resumable Closing, and the Ledger
- Usage Billing Part 2: Event Sourcing, Replay, and Projection Operations
- Usage Billing Part 3: Microservices, Outbox, and Inbox
- Usage Billing Part 4: Detection, Isolation, Reprocessing, and Reconciliation
- Integrated interactive visualization
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.
Implementation and related resources
Section titled “Implementation and related resources”- Usage metering and billing ledger README: The complete state model plus execution and validation instructions.
BillingCloseService.kt: Fixed cutoff, keyset batching, ledger append, and checkpoint advancement.InvoiceService.kt: Invoice lines, provenance links, and amount invariants.MeteringEndToEndIntegrationTest.kt: Duplicate ingestion, close, invoice finalization, late usage, and explicit price-gap repair in one lifecycle.- Interactive usage-billing evolution: Compare the ledger, Event Sourcing, and microservice architectures through the same scenario.
Comments
Leave a note or reaction with your GitHub account.