/** * invoice_numbering — allocation atomique du prochain numéro de facture. * * Stratégie : numérotation strict séquentielle par organisation (exigence * art. 242 nonies A du CGI : chronologie continue, sans rupture). Le * compteur d'org `invoice_settings.numeroNextSeq` est lu+incrémenté+saved * dans une transaction avec verrou explicite, garantissant qu'aucun n° * n'est attribué deux fois même sous forte concurrence (deux requêtes * simultanées du même user via deux onglets, par exemple). * * Le format affiché est `` (ex. "FAC-2026-0042"). * Le préfixe et la séquence sont aussi stockés séparément sur la facture * (`numero` = chaîne formatée, `sequence_number` = entier brut) pour * permettre le tri SQL natif et la détection des gaps. * * Mode brouillon : si `draft = true`, on ne consomme pas la séquence. Le * `numero` retourné est éphémère ("BROUILLON" + UUID) et `sequenceNumber` * vaut null. Émettre plus tard = appel sans draft → allocation propre. */ import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import Organization from '#models/organization' import { resolveInvoiceSettings, mergeInvoiceSettings, type InvoiceSettings, } from '#services/invoice_settings' import { Exception } from '@adonisjs/core/exceptions' import { randomUUID } from 'node:crypto' export interface AllocatedInvoiceNumber { /** Chaîne formatée affichée et stockée dans `invoices.numero`. */ numero: string /** Entier brut stocké dans `invoices.sequence_number`. Null si draft. */ sequenceNumber: number | null } /** * Alloue le prochain numéro de facture pour l'org. * * @param organizationId UUID de l'org * @param trx Transaction Lucid — DOIT être passée pour que le verrou tienne * jusqu'au save de la facture. Le caller ouvre `db.transaction(...)` * et fait `allocateNextInvoiceNumber(orgId, trx)` puis crée la * facture dans la même transaction. * @param options.draft Si true, ne consomme pas la séquence (brouillon). */ export async function allocateNextInvoiceNumber( organizationId: string, trx: TransactionClientContract, options: { draft?: boolean } = {} ): Promise { if (options.draft) { return { numero: `BROUILLON-${randomUUID().slice(0, 8).toUpperCase()}`, sequenceNumber: null, } } // Verrou row-level sur la ligne organization pour sérialiser les // appels concurrents. Postgres : FOR UPDATE bloque les autres SELECT // FOR UPDATE et UPDATE jusqu'au commit/rollback. const orgRow = await trx .from('organizations') .where('id', organizationId) .forUpdate() .select('invoice_settings') .first() if (!orgRow) { throw new Exception('Organisation introuvable', { status: 404, code: 'not_found' }) } const settings = (orgRow.invoice_settings ?? null) as InvoiceSettings | null // Charge l'org en mémoire pour brand fallback (mais on n'a pas besoin de la // verrouiller à nouveau — invoice_settings est déjà locked). const org = await Organization.find(organizationId, { client: trx }) if (!org) { throw new Exception('Organisation introuvable', { status: 404, code: 'not_found' }) } const resolved = resolveInvoiceSettings(org) const seq = settings?.numeroNextSeq ?? resolved.numeroNextSeq const padding = settings?.numeroPadding ?? resolved.numeroPadding const prefix = settings?.numeroPrefix ?? resolved.numeroPrefix const numero = `${prefix}${String(seq).padStart(padding, '0')}` // Incrémente le compteur dans le JSONB. mergeInvoiceSettings respecte // les autres champs, donc on n'écrase rien d'autre. const nextSettings = mergeInvoiceSettings(settings, { numeroNextSeq: seq + 1 }) await trx .from('organizations') .where('id', organizationId) .update({ invoice_settings: JSON.stringify(nextSettings) }) return { numero, sequenceNumber: seq } }