Receipt numbers that never skip, even when two counters collect at once
- Node.js
- MongoDB
- Concurrency

Fee receipts have a requirement most software does not: the numbers must have no gaps. Not "mostly no gaps". If an auditor asks to see receipt 1043 and you cannot produce it, the explanation that it was lost to a race condition is not an explanation they accept.
This is one of those problems that looks trivial until two people collect a fee in the same second.
Why the obvious approaches fail
Counting existing rows — count() + 1 — fails immediately. Two concurrent requests both read 1042 and both write 1043.
A UUID or timestamp sidesteps collisions but abandons the actual requirement. Auditors want a sequence, not a unique string.
Auto-increment in the application just moves the race into your process. Two Node workers hold separate memory.
A transaction around read-then-write works, but only if you are prepared to serialise every receipt in the system, and only if you never retry a failed transaction without care — a retry that re-runs the read is a fresh chance to duplicate.
Push the atomicity into the database
The reliable shape is a dedicated counter document mutated atomically, returning its new value in the same round trip:
const { value } = await Counters.findOneAndUpdate(
{ _id: `receipt:${instituteId}:${financialYear}` },
{ $inc: { seq: 1 } },
{ upsert: true, returnDocument: "after" }
);
const receiptNo = value.seq;$inc is atomic at the document level, so two simultaneous callers get 1043 and 1044. Never the same number, never a skip. Scope the counter key to whatever the numbering must restart on — per institute, per financial year — because a global counter makes every tenant's receipts share a sequence, which is its own audit problem.
The gap you will still create
Atomic allocation guarantees unique ascending numbers. It does not guarantee no gaps, and this is the part most implementations miss.
If you allocate the number and then the payment write fails, 1043 is burned. The counter has moved on and no receipt exists at that number. You have a gap, which is exactly what you were trying to avoid.
So allocate late. The number should be the last thing assigned, after the payment has been recorded and everything that can fail has already failed:
Validate and record the payment.
Only once it is durable, allocate the receipt number.
Attach it and return.
If step 3 fails after step 2, you have a payment with no receipt number — recoverable, because you can allocate one later. That is strictly better than a number with no payment, which is unrecoverable and looks like a deleted record.
Reversals are records, not deletions
The other half of the requirement: a cancelled receipt must not vanish. Deleting it produces the same gap by a different route.
Record the reversal as its own row that references the original. The original stays, the sequence stays intact, and the ledger tells the whole story — issued, then reversed, by whom, when. An auditor reading it can reconstruct what happened. An auditor reading a missing row cannot.
Run the batch twice
The last thing worth building is idempotency on invoice generation. Term billing gets kicked off twice more often than anyone admits — a timeout, a nervous click, a cron that overlapped.
Key each invoice on (studentId, billingPeriod) and upsert. Running the batch twice then bills once, and the second run becomes a very boring no-op. Which is exactly what you want from money.