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>
99 lines
3.9 KiB
TypeScript
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 }
|
|
}
|