rubis/apps/api/app/services/invoice_numbering.ts
ordinarthur e0b47ddfdc feat(invoices): éditeur de factures natif — data model + API (Phase 1)
Pose les fondations pour permettre aux utilisateurs de créer leurs
factures directement dans Rubis (en complément de l'upload OCR existant),
avec snapshots immuables, numérotation strict séquentielle (art. 242
nonies A CGI) et 4 thèmes pré-faits paramétrables.

Data model
- organizations.invoice_settings (JSONB) : thème par défaut, accent color,
  préfixe et compteur de numérotation, mentions légales (pénalités,
  escompte), identité émetteur (SIREN/SIRET/TVA intra/RCS/capital), RIB.
- clients enrichi : SIREN, TVA intra, adresse structurée (lines/zip/city
  /country). Le champ address legacy reste pour les clients pré-feature.
- invoices enrichi : lines (JSONB), client_snapshot + issuer_snapshot
  figés à l'émission, amount_ht/tva, tva_breakdown, payment_terms_days,
  theme_slug + theme_accent_color, is_native, sequence_number (unique
  per org), pdf_generated_at.

API
- GET/PATCH /organizations/me/invoice-settings (resolveInvoiceSettings)
- GET /invoice-themes (4 thèmes : classique, moderne, minimal, élégant)
- POST /invoices/native (séquence strict allouée en transaction,
  totaux recalculés serveur, snapshots immuables)
- POST /invoices/preview-pdf (stream PDF sans persister, stub Phase 1)

Le rendu PDF lui-même (@react-pdf/renderer + templates) arrive en
Phase 2 ; le storeNative crée bien la facture mais pdf_storage_key
reste null jusqu'à Phase 2. Conformité Factur-X visée pour V1.5
(Q3-Q4 2026, avant l'échéance d'émission TPE-PME au 1er sept 2027).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:07:45 +02:00

99 lines
3.9 KiB
TypeScript

/**
* 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 `<prefix><seq padé sur N>` (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<AllocatedInvoiceNumber> {
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 }
}