Invoice numbers that cannot skip or repeat
Two invoices carrying the same number, or a year whose numbering jumps from 0041 straight to 0043, are not cosmetic defects. Someone reading the books finds them years later, long after everyone has forgotten which release caused it. VAT rules decide what appears on the lines of an invoice. The number decides whether the document is defensible at all. It is also the part of a billing system that gets written carelessly most often, because allocating an integer looks like the easy half of the problem.
We have built invoicing and payment services long enough to have seen both failure modes in production systems we inherited. This is how we separate the two concerns, and why the numbering half ends up in the database rather than in the application. The code below is from euinvoice, our own package for EU invoice documents: https://github.com/shipmindlabs/euinvoice
Two problems that only look like one#
VAT is a decision over facts. The regime follows from the seller country, the buyer country, and whether the buyer's VAT identifier has been validated. Nothing about it is stateful, so the same facts produce the same answer today and in five years:
from euinvoice import decide_vat_regime
decision = decide_vat_regime(seller, buyer)
print(decision.regime) # VatRegime.DOMESTIC
print(decision.vat_category) # VatCategory.STANDARD
print(decision.invoice_note) # text for the invoice footer, or None
print(decision.explain()) # facts and legal basis, line by lineA decision carries the facts it was made from and the article it rests on, so the document can still be explained when the people who issued it have moved on. A VatCategory.REVERSE_CHARGE line shifts the tax to the buyer, carries a zero rate, requires the buyer's VAT identifier, and cannot be mixed with taxable lines on the same invoice. That is all rule evaluation. You can test it with no database in sight.
Numbering works the other way round. It is the one place in an invoicing system where two concurrent requests must not be allowed to reach the same answer, and where the correct behaviour is defined by what happened before, not by what is true now. If you treat the two as one module, with a create_invoice function that computes tax and grabs a number in the same breath, numbering becomes untestable and tax logic becomes slow to test.
Why MAX+1 and a native sequence are both wrong#
The naive implementation reads the highest number issued so far and adds one. Under read-committed isolation, two transactions read the same maximum and issue the same number. This is not a rare interleaving, it is the ordinary result of two people clicking a button at the same time, and it survives in codebases for years because staging traffic never produces it.
The usual correction is a native database sequence, which will not hand out the same value twice. But a sequence is deliberately non-transactional: it advances outside your transaction so that concurrent writers do not block each other, and a rolled back transaction does not give the value back. That is the wrong trade for invoice numbers. You trade duplicates for gaps, and a gap is the thing an audit asks about. "The nightly job crashed after allocating 0042" is not an answer anyone wants to write down.
So the counter has to be an ordinary row, updated inside the same transaction that writes the invoice. In euinvoice the store is a port and the caller owns the transaction:
class PostgresCounterStore:
def __init__(self, connection):
self._connection = connection
def next_count(self, key):
with self._connection.cursor() as cursor:
cursor.execute(
"INSERT INTO invoice_counters (series, period, count) "
"VALUES (%s, %s, 1) "
"ON CONFLICT (series, period) DO UPDATE "
"SET count = invoice_counters.count + 1 "
"RETURNING count",
(key.series, key.period),
)
return cursor.fetchone()[0]One statement, no read-then-write. The row lock taken by the update is held until the transaction commits, which serializes concurrent issuers on that one key, and only that one key. If the surrounding transaction rolls back, the increment rolls back with it and the number goes back rather than sitting there as a hole. That property is the whole reason the store is a port instead of a connection the library opens for itself. The library cannot know where your transaction begins, and it should not be the thing that decides.
InMemoryCounterStore ships with the package for tests and single-process use. It is not a fallback for production, and we do not pretend otherwise.
The library does not trust its own store#
An adapter is where the bug will be. Someone replaces the upsert with a read-then-write during a refactor, or a second service gets pointed at a table that was copied without its data, and the sequence quietly breaks in a way that produces valid-looking numbers. SequentialNumbering therefore checks every value the store returns against the one before it, and refuses anything that is not the next integer:
class FixedStore:
"""A store that hands out prepared values, however wrong."""
def __init__(self, values) -> None:
self._values = list(values)
def next_count(self, key):
return self._values.pop(0)
def test_a_skipped_counter_is_rejected():
numbers = numbering(store=FixedStore([1, 3]))
numbers.next_number(date(2026, 3, 1))
with pytest.raises(NumberingError):
numbers.next_number(date(2026, 3, 1))Repeats, skips, a counter that starts at zero and a non-integer counter all raise NumberingError. This is a contract test that runs in production. It does not replace the store's guarantee, and no in-process check can, since the other writer may be in another process entirely. What it does is turn a broken adapter into a failure at the first bad number instead of a discovery at the next audit. We would rather refuse to issue an invoice than issue one that has to be voided by hand.
Periods are a key, not a scheduled job#
The other common bug is a January rollover implemented as a task that resets the counter. It resets too early, too late, or twice, and it does so once a year, which is probably the worst possible cadence for finding out. There is no reset in euinvoice. The period is part of the counter's identity:
numbering = SequentialNumbering(
NumberSeries("INV", Period.YEARLY), InMemoryCounterStore()
)
issued = numbering.next_number(date(2026, 3, 1))
print(issued.number) # INV-2026-0001
print(issued.counter) # 1
print(issued.key) # CounterKey(series='INV', period='2026')An issue date in 2027 resolves to a different CounterKey, and a key that has never been used starts at one. Nothing has to run at midnight. Period.MONTHLY and Period.QUARTERLY work the same way, and Period.CONTINUOUS produces a key with no period token at all, for series that must never restart. Series do not share a counter either: sales invoices under INV and credit notes under CN count independently against the same store, because they are separate sequences in the books, and modelling them as one is a change you cannot undo later.
Note what is not in the key: the VAT regime. A domestic invoice and a reverse-charge invoice come from the same sequence. The regime decides the lines, the note in the footer and the rate. It has no business deciding the number.
What it costs to run#
One row per series per period, and a lock on it for the duration of the issuing transaction. That is real contention, and you cannot avoid it, since a gapless sequence is a serialization point by definition. The practical consequence is that the transaction which issues a number has to be short: allocate the number and write the invoice, then do PDF rendering, delivery and anything that talks to a third party afterwards, outside it. InvoiceDocument.from_invoice and render_html are pure functions over a finished invoice for exactly this reason.
The second consequence is a product decision rather than a technical one: drafts do not get numbers. A number is drawn when the document becomes an invoice, not when someone opens a form, because a draft abandoned after allocation is a gap that nobody can explain later.
Neither of those is expensive. Both are much cheaper than the alternative, which is a spreadsheet reconciling issued numbers against the ledger, maintained by someone in finance who has learned not to trust the system.