Repository navigation
fix: BE-51 — allocate invoice numbers safely under concurrency (#640) - #824
Merged
Qoder-Voidd merged 3 commits intoOct 20, 2026
Conversation
fix(contact): improve rate-limit key normalization for IPv4 and IPv4-mapped IPv6
…jan#640) Invoice generation had two concurrency holes. **Numbers were only "unique until the database noticed".** The number came straight from `nextval()` in `GenerateInvoiceProvider`, and a collision was detected by substring-matching the driver's error message for `"duplicate"` — brittle across `pg` versions. Worse, the sequence was only ever `CREATE`d: on a database restored from a dump, `nextval()` happily re-issued numbers that already existed and every attempt collided. **One payment could produce two invoices.** `generateForPayment` opens with a "does an invoice already exist for this payment?" lookup and then inserts. That is a check-then-act, so two concurrent calls for the same payment both see nothing and both insert — double-billing the customer with two perfectly valid, non-duplicate invoice numbers. - `InvoiceSequenceProvider` now owns allocation end to end: it ensures the sequence, advances it past the highest `invoiceNumber` already stored (`setval` in a single statement that only ever moves forward, so concurrent boots converge), hands out numbers through `formatInvoiceNumber`, and classifies failures with `isInvoiceNumberCollision`, which matches the `23505` SQLSTATE (and optionally the constraint name) instead of the driver message. - `GenerateInvoiceProvider` injects the sequence provider, retries a number collision with a freshly allocated number, and — the actual fix for the race — catches the unique violation on `paymentId` and returns the invoice the winning transaction created. - `Invoice` gains a partial unique index on `paymentId` (`WHERE "paymentId" IS NOT NULL`), which is what makes the second concurrent insert fail at the database level instead of silently duplicating the invoice. - Only the transaction that actually inserted the row now emails the customer, so the caller that lost the race does not send a second "invoice ready" notification for the same invoice. The insert stays inside its own transaction, so a failed generation rolls back completely and never surfaces a half-written invoice; a burned sequence value is a gap, not a visible invoice. Adds `invoice-sequence.provider.spec.ts` and `generate-invoice.provider.spec.ts` covering the allocation, the collision retry, the same-payment race, the failure path and the single-notification guarantee.
|
@VeronicDev Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #640
Problem
Invoice generation had two concurrency holes.
1. Number allocation was not actually guaranteed unique. The number came straight from
nextval()inGenerateInvoiceProvider, and a collision was detected by substring-matching the driver error message for"duplicate"— brittle acrosspgversions. Worse, the sequence was only everCREATEd, never advanced: on a database restored from a dump,nextval()re-issued numbers that already existed and every attempt collided.2. One payment could produce two invoices.
generateForPaymentopens with a "does an invoice already exist for this payment?" lookup and then inserts. That is a check-then-act, so two concurrent calls for the same payment both see nothing and both insert — double-billing the customer with two perfectly valid, non-duplicate invoice numbers.Fix
InvoiceSequenceProvidernow owns allocation end to end. It ensures the sequence, advances it past the highestinvoiceNumberalready stored, hands out numbers throughformatInvoiceNumber, and classifies failures withisInvoiceNumberCollision.setval(..., GREATEST(last_value, MAX(existing invoice number)))statement. It only ever moves forward, so concurrent app boots converge on the same value and a healthy database is untouched.isInvoiceNumberCollisionmatches the23505SQLSTATE (and optionally the constraint name) instead of the driver message.GenerateInvoiceProviderinjects the sequence provider, retries a number collision with a freshly allocated number, and catches the unique violation onpaymentIdto return the invoice the winning transaction created.Invoicegains a partial unique index onpaymentId(WHERE "paymentId" IS NOT NULL). This is what makes the second concurrent insert fail at the database level instead of silently duplicating the invoice.The insert stays inside its own transaction, so a failed generation rolls back completely and never surfaces a half-written invoice; a burned sequence value is a gap, not a visible invoice.
Tests
invoice-sequence.provider.spec.ts— number formatting, collision classification, bootstrap alignment, and that 25 concurrent callers each receive a distinct number.generate-invoice.provider.spec.ts— the allocation transaction, distinct numbers under concurrency, the same-payment race returning a single row, the collision retry and its give-up path, propagation of unrelated errors without retrying, no visible invoice on failure, and the single-notification guarantee.Notes
The issue listed a 3-file scope; this touches those three plus
generate-invoice.provider.ts(which is where the race actually lives and had to call the new provider) and the two additive spec files the acceptance criteria require.