Generating the next invoice number is easy until something fails after the number is visible.
Klevar Docs cannot treat document numbers as display counters. Invoice numbers, credit-note numbers, receipt numbers, and resolution numbers are part of the audit record. They must be scoped by entity, document type, and year. They must survive concurrency. They must handle abandoned work without silently burning numbers.
The allocator uses a two-phase model:
- Reserve a number.
- Finalize it when the document is durable.
- Abort and reclaim it if the operation fails before finalization.
- Let a reaper recover expired reservations.
The reservation path uses a PostgreSQL advisory lock keyed by entity, type, and year. That keeps concurrent requests from allocating the same sequence value while allowing unrelated entities and document types to proceed independently.
The reaper path is just as important as allocation:
const expired = await db
.select()
.from(pendingAllocations)
.where(
and(
eq(pendingAllocations.status, "pending"),
lt(pendingAllocations.expires_at, now),
),
);
for (const allocation of expired) {
await abortAndReclaim({
db,
allocationId: allocation.id,
reason: "expired",
});
}
The reaper prevents a crashed request from becoming a permanent numbering scar. If a pending allocation expires, the system moves it into the gap pool. Later reservations can reclaim gaps in order.
The test suite attacks this with race cases: multiple concurrent reservations, abort and reclaim, TTL reaping, and entity/type isolation. Those tests are not glamour tests. They protect the part of the system that accountants and auditors will notice first when it goes wrong.
The design also keeps numbering separate from rendering. A PDF can fail after a number is reserved. XML validation can fail. PDF/A can fail. Signing can fail. Storage can fail. Each failure has to return the number to a state the system can reason about.
That is why a single next_number counter is not enough. It answers only the happy path.
The more honest question is: what happens if the request dies halfway through?
For Klevar Docs, the answer is stored in tables: pending allocations, sequence gaps, finalized document numbers, and the audit trail around their transitions. The number is not a string. It is a lifecycle.