diff --git a/backend/src/invoices/entities/invoice.entity.ts b/backend/src/invoices/entities/invoice.entity.ts index e8d9b329..98b2b1b4 100644 --- a/backend/src/invoices/entities/invoice.entity.ts +++ b/backend/src/invoices/entities/invoice.entity.ts @@ -15,6 +15,18 @@ import { InvoiceStatus } from '../enums/invoice-status.enum'; @Entity('invoices') @Index(['invoiceNumber'], { unique: true }) +/** + * BE-51 — One payment produces at most one invoice. Without this + * constraint two concurrent generations for the same payment both pass + * the "does an invoice already exist?" lookup and each insert its own + * row, silently double-billing the customer. The unique index makes + * the race visible at the database level; the provider catches the + * violation and returns the row the winner created. + */ +@Index('IDX_invoices_payment_id_unique', ['paymentId'], { + unique: true, + where: '"paymentId" IS NOT NULL', +}) @Index(['userId']) @Index(['bookingId']) export class Invoice { diff --git a/backend/src/invoices/providers/generate-invoice.provider.spec.ts b/backend/src/invoices/providers/generate-invoice.provider.spec.ts new file mode 100644 index 00000000..cb076d75 --- /dev/null +++ b/backend/src/invoices/providers/generate-invoice.provider.spec.ts @@ -0,0 +1,318 @@ +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { GenerateInvoiceProvider } from './generate-invoice.provider'; +import { InvoiceSequenceProvider } from './invoice-sequence.provider'; +import { Invoice } from '../entities/invoice.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { User } from '../../users/entities/user.entity'; +import { Workspace } from '../../workspaces/entities/workspace.entity'; +import { EmailService } from '../../email/email.service'; +import { PdfGenerationProvider } from './pdf-generation.provider'; + +const PAYMENT = { + id: 'payment-1', + userId: 'user-1', + bookingId: 'booking-1', + amount: 250000, + currency: 'NGN', + paidAt: new Date('2026-01-02T03:04:05Z'), +}; + +const USER = { + id: 'user-1', + email: 'jane@example.com', + fullName: 'Jane Doe', +}; + +/** In-memory stand-in for the unique indexes on `invoices`. */ +class FakeInvoiceTable { + rows: Partial[] = []; + + insert(row: Partial): Partial { + if ( + this.rows.some((existing) => existing.invoiceNumber === row.invoiceNumber) + ) { + throw Object.assign(new Error('duplicate key value'), { + code: '23505', + constraint: 'IDX_invoices_invoice_number_unique', + }); + } + if ( + row.paymentId && + this.rows.some((existing) => existing.paymentId === row.paymentId) + ) { + throw Object.assign(new Error('duplicate key value'), { + code: '23505', + constraint: 'IDX_invoices_payment_id_unique', + }); + } + const stored = { ...row, id: `invoice-${this.rows.length + 1}` }; + this.rows.push(stored); + return stored; + } + + findByPaymentId(paymentId: string): Partial | undefined { + return this.rows.find((row) => row.paymentId === paymentId); + } +} + +describe('GenerateInvoiceProvider (BE-51 — atomic invoice-number allocation)', () => { + let provider: GenerateInvoiceProvider; + let table: FakeInvoiceTable; + let nextNumber: number; + let sequenceProvider: { nextInvoiceNumber: jest.Mock }; + let emailService: { sendInvoiceReadyEmail: jest.Mock }; + let pdfGenerationProvider: { generate: jest.Mock }; + let existingLookup: jest.Mock; + let transactionCount: number; + + beforeEach(async () => { + table = new FakeInvoiceTable(); + transactionCount = 0; + nextNumber = 1; + + sequenceProvider = { + nextInvoiceNumber: jest.fn(async () => { + const value = nextNumber++; + return `INV-${String(value).padStart(5, '0')}`; + }), + }; + + emailService = { sendInvoiceReadyEmail: jest.fn().mockResolvedValue(true) }; + pdfGenerationProvider = { + generate: jest.fn().mockResolvedValue(Buffer.from('pdf')), + }; + + existingLookup = jest.fn( + async ({ where }: { where: { paymentId?: string } }) => + where.paymentId + ? (table.findByPaymentId(where.paymentId) ?? null) + : null, + ); + + const manager = { + create: (_entity: unknown, data: Partial) => data, + save: async (data: Partial) => table.insert(data), + }; + + const dataSource = { + transaction: async (work: (m: typeof manager) => Promise) => { + transactionCount++; + return work(manager); + }, + }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + GenerateInvoiceProvider, + { + provide: getRepositoryToken(Invoice), + useValue: { findOne: existingLookup }, + }, + { + provide: getRepositoryToken(Payment), + // Echo the requested id back so each caller invoices its own payment. + useValue: { + findOne: jest.fn(async ({ where }: { where: { id: string } }) => ({ + ...PAYMENT, + id: where.id, + })), + }, + }, + { + provide: getRepositoryToken(Booking), + useValue: { findOne: jest.fn(async () => null) }, + }, + { + provide: getRepositoryToken(User), + useValue: { findOne: jest.fn(async () => USER) }, + }, + { + provide: getRepositoryToken(Workspace), + useValue: { findOne: jest.fn(async () => null) }, + }, + { provide: EmailService, useValue: emailService }, + { provide: PdfGenerationProvider, useValue: pdfGenerationProvider }, + { provide: InvoiceSequenceProvider, useValue: sequenceProvider }, + { provide: DataSource, useValue: dataSource }, + ], + }).compile(); + + provider = moduleRef.get(GenerateInvoiceProvider); + }); + + it('allocates a number from the sequence and persists it inside a transaction', async () => { + const invoice = await provider.generateForPayment('payment-1'); + + expect(invoice.invoiceNumber).toBe('INV-00001'); + expect(sequenceProvider.nextInvoiceNumber).toHaveBeenCalledTimes(1); + expect(transactionCount).toBe(1); + expect(table.rows).toHaveLength(1); + }); + + it('gives every concurrent generation a distinct, gap-free number', async () => { + const invoices = await Promise.all([ + provider.generateForPayment('payment-1'), + provider.generateForPayment('payment-2'), + provider.generateForPayment('payment-3'), + ]); + + const numbers = invoices.map((invoice) => invoice.invoiceNumber); + expect(numbers).toEqual(['INV-00001', 'INV-00002', 'INV-00003']); + expect(new Set(numbers).size).toBe(3); + }); + + it('reuses the invoice already stored for a payment', async () => { + const first = await provider.generateForPayment('payment-1'); + const second = await provider.generateForPayment('payment-1'); + + expect(second.id).toBe(first.id); + expect(second.invoiceNumber).toBe(first.invoiceNumber); + expect(table.rows).toHaveLength(1); + }); + + it('returns the winner when two callers invoice the same payment at once', async () => { + // Both callers run their initial "does an invoice exist?" lookup + // before either has inserted, which is exactly the check-then-act + // window the paymentId unique index has to close. + existingLookup.mockImplementation( + async ({ where }: { where: { paymentId?: string } }) => + where.paymentId + ? (table.findByPaymentId(where.paymentId) ?? null) + : null, + ); + + const [a, b] = await Promise.all([ + provider.generateForPayment('payment-1'), + provider.generateForPayment('payment-1'), + ]); + + expect(a.id).toBe(b.id); + expect(table.rows).toHaveLength(1); + expect(table.rows[0].invoiceNumber).toBe('INV-00001'); + }); + + it('retries with a fresh number when the allocated one is already taken', async () => { + const insert = jest.spyOn(table, 'insert'); + insert.mockImplementationOnce(() => { + throw Object.assign(new Error('duplicate key value'), { + code: '23505', + constraint: 'IDX_invoices_invoice_number_unique', + }); + }); + + const invoice = await provider.generateForPayment('payment-1'); + + expect(invoice.invoiceNumber).toBe('INV-00002'); + expect(sequenceProvider.nextInvoiceNumber).toHaveBeenCalledTimes(2); + expect(table.rows).toHaveLength(1); + }); + + it('gives up after exhausting the collision retries', async () => { + jest.spyOn(table, 'insert').mockImplementation(() => { + throw Object.assign(new Error('duplicate key value'), { + code: '23505', + constraint: 'IDX_invoices_invoice_number_unique', + }); + }); + + await expect(provider.generateForPayment('payment-1')).rejects.toThrow( + 'duplicate key value', + ); + // 1 initial attempt + 3 retries, each with its own sequence value. + expect(sequenceProvider.nextInvoiceNumber).toHaveBeenCalledTimes(4); + expect(table.rows).toHaveLength(0); + }); + + it('propagates a non-uniqueness failure without retrying', async () => { + jest.spyOn(table, 'insert').mockImplementation(() => { + throw new Error('value too long for type character varying(20)'); + }); + + await expect(provider.generateForPayment('payment-1')).rejects.toThrow( + 'value too long for type character varying(20)', + ); + expect(sequenceProvider.nextInvoiceNumber).toHaveBeenCalledTimes(1); + expect(table.rows).toHaveLength(0); + }); + + it('leaves no visible invoice behind when generation fails', async () => { + jest.spyOn(table, 'insert').mockImplementation(() => { + throw new Error('boom'); + }); + + await expect(provider.generateForPayment('payment-1')).rejects.toThrow( + 'boom', + ); + + expect(table.rows).toEqual([]); + expect(pdfGenerationProvider.generate).not.toHaveBeenCalled(); + expect(emailService.sendInvoiceReadyEmail).not.toHaveBeenCalled(); + }); + + it('notifies the customer exactly once for the transaction that inserted', async () => { + await provider.generateForPayment('payment-1'); + // Let the fire-and-forget PDF/email chain settle. + await new Promise((resolve) => setImmediate(resolve)); + + expect(pdfGenerationProvider.generate).toHaveBeenCalledTimes(1); + expect(emailService.sendInvoiceReadyEmail).toHaveBeenCalledTimes(1); + expect(emailService.sendInvoiceReadyEmail).toHaveBeenCalledWith( + USER.email, + USER.fullName, + expect.objectContaining({ invoiceNumber: 'INV-00001' }), + expect.any(Buffer), + ); + }); + + it('does not re-notify the customer when the invoice already existed', async () => { + await provider.generateForPayment('payment-1'); + await new Promise((resolve) => setImmediate(resolve)); + jest.clearAllMocks(); + + await provider.generateForPayment('payment-1'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(pdfGenerationProvider.generate).not.toHaveBeenCalled(); + expect(emailService.sendInvoiceReadyEmail).not.toHaveBeenCalled(); + }); + + it('rejects an unknown payment before allocating a number', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + GenerateInvoiceProvider, + { + provide: getRepositoryToken(Invoice), + useValue: { findOne: jest.fn(async () => null) }, + }, + { + provide: getRepositoryToken(Payment), + useValue: { findOne: jest.fn(async () => null) }, + }, + { + provide: getRepositoryToken(Booking), + useValue: { findOne: jest.fn() }, + }, + { provide: getRepositoryToken(User), useValue: { findOne: jest.fn() } }, + { + provide: getRepositoryToken(Workspace), + useValue: { findOne: jest.fn() }, + }, + { provide: EmailService, useValue: emailService }, + { provide: PdfGenerationProvider, useValue: pdfGenerationProvider }, + { provide: InvoiceSequenceProvider, useValue: sequenceProvider }, + { provide: DataSource, useValue: { transaction: jest.fn() } }, + ], + }).compile(); + + const isolated = moduleRef.get(GenerateInvoiceProvider); + + await expect(isolated.generateForPayment('missing')).rejects.toThrow( + 'Payment "missing" not found', + ); + expect(sequenceProvider.nextInvoiceNumber).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/invoices/providers/generate-invoice.provider.ts b/backend/src/invoices/providers/generate-invoice.provider.ts index 0017e28a..32e11c27 100644 --- a/backend/src/invoices/providers/generate-invoice.provider.ts +++ b/backend/src/invoices/providers/generate-invoice.provider.ts @@ -9,6 +9,10 @@ import { Workspace } from '../../workspaces/entities/workspace.entity'; import { InvoiceStatus } from '../enums/invoice-status.enum'; import { EmailService } from '../../email/email.service'; import { PdfGenerationProvider } from './pdf-generation.provider'; +import { + InvoiceSequenceProvider, + isInvoiceNumberCollision, +} from './invoice-sequence.provider'; @Injectable() export class GenerateInvoiceProvider { @@ -28,6 +32,7 @@ export class GenerateInvoiceProvider { private readonly dataSource: DataSource, private readonly emailService: EmailService, private readonly pdfGenerationProvider: PdfGenerationProvider, + private readonly invoiceSequenceProvider: InvoiceSequenceProvider, ) {} async generateForPayment(paymentId: string): Promise { @@ -69,21 +74,27 @@ export class GenerateInvoiceProvider { }, ]; - const saved = await this.createWithUniqueInvoiceNumber({ - userId: payment.userId, - bookingId: payment.bookingId, - paymentId: payment.id, - amountKobo: payment.amount, - currency: payment.currency, - status: InvoiceStatus.PAID, - paidAt: payment.paidAt, - lineItems, - }); + const { invoice: saved, created } = + await this.createWithUniqueInvoiceNumber({ + userId: payment.userId, + bookingId: payment.bookingId, + paymentId: payment.id, + amountKobo: payment.amount, + currency: payment.currency, + status: InvoiceStatus.PAID, + paidAt: payment.paidAt, + lineItems, + }); this.logger.log( - `Invoice ${saved.invoiceNumber} generated for payment ${paymentId}`, + created + ? `Invoice ${saved.invoiceNumber} generated for payment ${paymentId}` + : `Invoice ${saved.invoiceNumber} already exists for payment ${paymentId} — returning it`, ); - if (user) { + // Only the transaction that actually inserted the row notifies the + // customer. A caller that lost the race must not send a second + // "invoice ready" email for the same invoice. + if (created && user) { this.pdfGenerationProvider .generate(saved) .then((pdfBuffer) => { @@ -109,38 +120,64 @@ export class GenerateInvoiceProvider { } /** - * B47 — Generates an invoice using a PostgreSQL sequence value while - * guaranteeing uniqueness under concurrent creation. The invoice - * insert is wrapped in a transaction so a failure does not surface a - * half-created record, and the number is only considered consumed - * once the insert commits. If a concurrent caller somehow collides - * on the same number, the unique index triggers a retry with a fresh - * sequence value. + * BE-51 — Generates an invoice using a number allocated from the + * PostgreSQL sequence while guaranteeing uniqueness under concurrent + * creation. + * + * Two independent races are handled: + * + * 1. Two callers generating invoices *at the same time*. The number + * comes from `nextval()`, which is atomic, and the insert runs in + * its own transaction so a failure rolls the row back entirely — + * a failed generation never leaves a half-written (or visible) + * invoice behind. If the unique index on `invoiceNumber` still + * rejects the insert (a database restored from a dump, where the + * sequence can lag behind the rows), the caller retries with a + * freshly allocated number. + * + * 2. Two callers generating an invoice *for the same payment*. The + * "does one already exist?" lookup at the top of + * `generateForPayment` is a check-then-act and cannot close that + * window on its own, so the partial unique index on `paymentId` + * is the real guard: the loser of the race catches the violation + * and returns the invoice the winner created instead of inserting + * a second one. */ private async createWithUniqueInvoiceNumber( data: Partial, - ): Promise { + ): Promise<{ invoice: Invoice; created: boolean }> { const MAX_RETRIES = 3; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const invoiceNumber = await this.nextInvoiceNumber(); + const invoiceNumber = + await this.invoiceSequenceProvider.nextInvoiceNumber(); try { - return await this.dataSource.transaction(async (manager) => { + const invoice = await this.dataSource.transaction(async (manager) => { const draft = manager.create(Invoice, { ...data, invoiceNumber, }); return manager.save(draft); }); + return { invoice, created: true }; } catch (err) { - const message = - err instanceof Error ? err.message : 'unknown invoice error'; - const isCollision = - typeof message === 'string' && - message.toLowerCase().includes('duplicate'); + // The insert lost the race against a concurrent generation for + // the same payment. Surface the winner's invoice rather than + // burning the whole call. + if (data.paymentId && isInvoiceNumberCollision(err)) { + const concurrent = await this.invoicesRepository.findOne({ + where: { paymentId: data.paymentId }, + }); + if (concurrent) { + this.logger.warn( + `Invoice ${invoiceNumber} discarded — payment ${data.paymentId} was already invoiced as ${concurrent.invoiceNumber}`, + ); + return { invoice: concurrent, created: false }; + } + } - if (!isCollision || attempt === MAX_RETRIES) { + if (!isInvoiceNumberCollision(err) || attempt === MAX_RETRIES) { throw err; } this.logger.warn( @@ -151,12 +188,4 @@ export class GenerateInvoiceProvider { throw new Error('Failed to allocate a unique invoice number'); } - - private async nextInvoiceNumber(): Promise { - const result = await this.dataSource.query<{ nextval: string }[]>( - `SELECT nextval('invoice_number_seq')`, - ); - const seq = parseInt(result[0].nextval, 10); - return `INV-${String(seq).padStart(5, '0')}`; - } -} \ No newline at end of file +} diff --git a/backend/src/invoices/providers/invoice-sequence.provider.spec.ts b/backend/src/invoices/providers/invoice-sequence.provider.spec.ts new file mode 100644 index 00000000..5f319fde --- /dev/null +++ b/backend/src/invoices/providers/invoice-sequence.provider.spec.ts @@ -0,0 +1,177 @@ +import { DataSource } from 'typeorm'; +import { Logger } from '@nestjs/common'; + +import { + INVOICE_NUMBER_SEQUENCE, + InvoiceSequenceProvider, + formatInvoiceNumber, + isInvoiceNumberCollision, +} from './invoice-sequence.provider'; + +/** Shape of the error `pg` raises for a unique index violation. */ +function uniqueViolation(constraint: string, message?: string) { + return Object.assign(new Error(message ?? 'duplicate key value'), { + code: '23505', + constraint, + }); +} + +describe('formatInvoiceNumber', () => { + it('zero-pads the sequence value to five digits', () => { + expect(formatInvoiceNumber(1)).toBe('INV-00001'); + expect(formatInvoiceNumber(42)).toBe('INV-00042'); + expect(formatInvoiceNumber(99999)).toBe('INV-99999'); + }); + + it('does not truncate values that exceed the padding width', () => { + expect(formatInvoiceNumber(100000)).toBe('INV-100000'); + }); + + it('accepts the string values `nextval()` returns over the wire', () => { + expect(formatInvoiceNumber('7')).toBe('INV-00007'); + }); + + it('refuses values that cannot be rendered as an invoice number', () => { + expect(() => formatInvoiceNumber(null)).toThrow(/non-usable value/); + expect(() => formatInvoiceNumber(undefined)).toThrow(/non-usable value/); + expect(() => formatInvoiceNumber(Number.NaN)).toThrow(/non-usable value/); + expect(() => formatInvoiceNumber(-1)).toThrow(/non-usable value/); + expect(() => formatInvoiceNumber(1.5)).toThrow(/non-usable value/); + }); +}); + +describe('isInvoiceNumberCollision', () => { + it('detects a unique violation from the SQLSTATE', () => { + expect(isInvoiceNumberCollision(uniqueViolation('PK_invoices'))).toBe(true); + }); + + it('detects a violation reported only through the driver message', () => { + expect( + isInvoiceNumberCollision(new Error('duplicate key value violates')), + ).toBe(true); + expect(isInvoiceNumberCollision({ message: 'Duplicate Key Value' })).toBe( + true, + ); + }); + + it('ignores unrelated database errors', () => { + expect(isInvoiceNumberCollision(new Error('connection terminated'))).toBe( + false, + ); + expect( + isInvoiceNumberCollision( + Object.assign(new Error('nope'), { code: '23503' }), + ), + ).toBe(false); + }); + + it('ignores non-object throwables', () => { + expect(isInvoiceNumberCollision('duplicate key')).toBe(false); + expect(isInvoiceNumberCollision(null)).toBe(false); + expect(isInvoiceNumberCollision(undefined)).toBe(false); + }); + + it('only matches the named constraint when one is supplied', () => { + expect( + isInvoiceNumberCollision( + uniqueViolation('IDX_invoices_payment_id_unique'), + 'PK_other', + ), + ).toBe(false); + expect( + isInvoiceNumberCollision(uniqueViolation('PK_other'), 'PK_other'), + ).toBe(true); + }); +}); + +describe('InvoiceSequenceProvider', () => { + let provider: InvoiceSequenceProvider; + let query: jest.Mock; + let nextval: number; + + beforeEach(() => { + jest.clearAllMocks(); + nextval = 1; + query = jest.fn(async (sql: string) => { + if (sql.includes('nextval')) { + return [{ nextval: String(nextval++) }]; + } + return []; + }); + + provider = new InvoiceSequenceProvider({ query } as unknown as DataSource); + }); + + it('creates the sequence and advances it past the highest existing invoice', async () => { + await provider.onApplicationBootstrap(); + + expect(query).toHaveBeenCalledTimes(2); + expect(query.mock.calls[0][0]).toContain( + `CREATE SEQUENCE IF NOT EXISTS ${INVOICE_NUMBER_SEQUENCE}`, + ); + // The alignment statement is what stops a restored database from + // re-issuing numbers that already exist. + expect(query.mock.calls[1][0]).toContain('setval'); + expect(query.mock.calls[1][0]).toContain('GREATEST'); + expect(query.mock.calls[1][0]).toContain('invoices'); + }); + + it('allocates a number from the sequence', async () => { + const invoiceNumber = await provider.nextInvoiceNumber(); + + expect(invoiceNumber).toBe('INV-00001'); + expect(query).toHaveBeenCalledWith( + `SELECT nextval('${INVOICE_NUMBER_SEQUENCE}')`, + ); + }); + + it('hands out a distinct number to every concurrent caller', async () => { + // No bootstrap, so the first call has to ensure the sequence itself. + const numbers = await Promise.all( + Array.from({ length: 25 }, () => provider.nextInvoiceNumber()), + ); + + expect(new Set(numbers).size).toBe(25); + }); + + it('ensures the sequence lazily when bootstrap has not run', async () => { + await provider.nextInvoiceNumber(); + + const sql = query.mock.calls.map((call) => String(call[0])); + expect(sql.some((s) => s.includes('CREATE SEQUENCE'))).toBe(true); + expect(sql.filter((s) => s.includes('CREATE SEQUENCE'))).toHaveLength(1); + }); + + it('does not re-create the sequence on every allocation', async () => { + await provider.nextInvoiceNumber(); + await provider.nextInvoiceNumber(); + await provider.nextInvoiceNumber(); + + const createCalls = query.mock.calls.filter((call) => + String(call[0]).includes('CREATE SEQUENCE'), + ); + expect(createCalls).toHaveLength(1); + }); + + it('surfaces a broken sequence instead of returning a bogus number', async () => { + query.mockRejectedValue(new Error('relation does not exist')); + + await expect(provider.nextInvoiceNumber()).rejects.toThrow( + 'relation does not exist', + ); + }); + + it('does not stop the app from booting when the sequence cannot be created', async () => { + const logger = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + query.mockRejectedValue(new Error('permission denied')); + + await expect(provider.onApplicationBootstrap()).resolves.toBeUndefined(); + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('permission denied'), + ); + + logger.mockRestore(); + }); +}); diff --git a/backend/src/invoices/providers/invoice-sequence.provider.ts b/backend/src/invoices/providers/invoice-sequence.provider.ts index 2e9c8ca3..57a1391b 100644 --- a/backend/src/invoices/providers/invoice-sequence.provider.ts +++ b/backend/src/invoices/providers/invoice-sequence.provider.ts @@ -1,26 +1,145 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { DataSource } from 'typeorm'; +/** Name of the PostgreSQL sequence backing `Invoice.invoiceNumber`. */ +export const INVOICE_NUMBER_SEQUENCE = 'invoice_number_seq'; + +/** Prefix used to render a sequence value as a human-readable number. */ +export const INVOICE_NUMBER_PREFIX = 'INV-'; + +/** Number of digits the rendered invoice number is zero-padded to. */ +export const INVOICE_NUMBER_PAD = 5; + +/** PostgreSQL SQLSTATE for `unique_violation`. */ +export const UNIQUE_VIOLATION = '23505'; + +/** + * BE-51 — Render a raw sequence value as an invoice number. + * Exported so the formatting contract is unit-testable in isolation. + */ +export function formatInvoiceNumber(sequenceValue: unknown): string { + if ( + sequenceValue === null || + sequenceValue === undefined || + sequenceValue === '' + ) { + throw new Error( + `invoice_number_seq returned a non-usable value: ${String(sequenceValue)}`, + ); + } + + const value = Number(sequenceValue); + if (!Number.isInteger(value) || value < 0) { + throw new Error( + `invoice_number_seq returned a non-usable value: ${String(sequenceValue)}`, + ); + } + return `${INVOICE_NUMBER_PREFIX}${String(value).padStart( + INVOICE_NUMBER_PAD, + '0', + )}`; +} + +/** + * BE-51 — Does this error mean "the invoice number I just generated is + * already taken"? Matching on the SQLSTATE (and, when present, the + * constraint name) is far more reliable than substring-matching the + * driver message, which differs between `pg` versions and is not + * guaranteed to be an `Error` instance at all. + */ +export function isInvoiceNumberCollision( + error: unknown, + constraint?: string, +): boolean { + if (typeof error !== 'object' || error === null) return false; + const err = error as { + code?: unknown; + constraint?: unknown; + message?: unknown; + }; + + if (constraint && err.constraint && err.constraint !== constraint) { + return false; + } + if (err.code === UNIQUE_VIOLATION) return true; + + // Some drivers surface the violation only as a message. + return ( + typeof err.message === 'string' && + err.message.toLowerCase().includes('duplicate key') + ); +} + /** - * Ensures the PostgreSQL sequence used for invoice numbers exists. - * Runs once on application bootstrap. + * BE-51 — Owns the PostgreSQL sequence that backs + * `Invoice.invoiceNumber` and hands out invoice numbers. + * + * `nextval()` is the only allocation primitive: it is atomic and + * non-transactional, so two concurrent transactions can never receive + * the same value and a rolled-back insert only burns a number (it never + * leaves a half-written invoice behind). Uniqueness is still enforced by + * the unique index on `invoices."invoiceNumber"`, which is what makes a + * duplicate *visible* rather than silently corrupting the ledger. */ @Injectable() export class InvoiceSequenceProvider implements OnApplicationBootstrap { private readonly logger = new Logger(InvoiceSequenceProvider.name); + private ensured = false; constructor(private readonly dataSource: DataSource) {} async onApplicationBootstrap(): Promise { + await this.ensureSequence(); + } + + /** + * Create the sequence if it is missing and push it past any invoice + * number that already exists. A database restored from a dump — or one + * where the sequence was created after rows had been written — would + * otherwise start handing out numbers that are already taken. + * + * `setval` only ever moves the sequence forward and is a single + * statement, so concurrent boots converge on the same value. + */ + async ensureSequence(): Promise { try { await this.dataSource.query( - `CREATE SEQUENCE IF NOT EXISTS invoice_number_seq START 1`, + `CREATE SEQUENCE IF NOT EXISTS ${INVOICE_NUMBER_SEQUENCE} START 1`, ); - this.logger.log('invoice_number_seq sequence ensured'); + await this.dataSource.query( + `SELECT setval( + '${INVOICE_NUMBER_SEQUENCE}', + GREATEST( + (SELECT last_value FROM ${INVOICE_NUMBER_SEQUENCE}), + (SELECT COALESCE(MAX(NULLIF(substring("invoiceNumber" from '[0-9]+$'), '')::bigint), 0) FROM invoices) + ), + true + )`, + ); + this.ensured = true; + this.logger.log(`${INVOICE_NUMBER_SEQUENCE} sequence ensured`); } catch (err) { + // Bootstrap is best-effort — a read-only replica or a database + // without DDL rights must not stop the app from booting. this.logger.error( - `Failed to create invoice_number_seq: ${(err as Error).message}`, + `Failed to ensure ${INVOICE_NUMBER_SEQUENCE}: ${(err as Error).message}`, ); } } + + /** + * Allocate the next invoice number. Each call consumes exactly one + * sequence value, so the result is never reused by another caller. + */ + async nextInvoiceNumber(): Promise { + if (!this.ensured) { + await this.ensureSequence(); + } + + const result = await this.dataSource.query<{ nextval: string }[]>( + `SELECT nextval('${INVOICE_NUMBER_SEQUENCE}')`, + ); + const row = Array.isArray(result) ? result[0] : result; + return formatInvoiceNumber(row?.nextval); + } }