rubis/apps/api/app/services/invoice_settings.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

275 lines
9.8 KiB
TypeScript

/**
* invoice_settings — résolution des paramètres de facturation d'une org
* pour l'éditeur de factures natif.
*
* Stockage : JSONB `organizations.invoice_settings` (cf. migration
* 1778800000000). Tous les champs sont optionnels, on resolve avec des
* defaults au moment de générer un PDF.
*
* Convention `null` :
* - PATCH avec une clé à `null` explicite → reset au default sur ce champ
* - PATCH avec une clé absente → laisse intact
* - Cohérent avec brand.ts pour réduire la charge cognitive côté SPA.
*
* Pas de plan gating : toute org peut paramétrer sa facturation. Le gating
* porte sur la création de facture elle-même (`canCreateInvoices`).
*
* Pattern : les types sont déclarés localement (pas d'import depuis
* @rubis/shared) — cohérent avec brand.ts et les autres services. Les
* types côté SPA (packages/shared) sont structurellement équivalents.
*/
import type Organization from '#models/organization'
import { resolveBrandTokens } from '#services/brand'
const HEX_RE = /^#[0-9a-fA-F]{6}$/u
const ISO_COUNTRY_RE = /^[A-Z]{2}$/u
const SIREN_RE = /^\d{9}$/u
const SIRET_RE = /^\d{14}$/u
const TVA_INTRA_RE = /^[A-Z]{2}[A-Z0-9]{2,18}$/u
const NAF_RE = /^\d{4}[A-Z]$/u
const IBAN_RE = /^[A-Z0-9 ]{15,40}$/u
const BIC_RE = /^[A-Z0-9]{8}([A-Z0-9]{3})?$/u
export const INVOICE_THEME_SLUGS = ['classique', 'moderne', 'minimal', 'elegant'] as const
export type InvoiceThemeSlug = (typeof INVOICE_THEME_SLUGS)[number]
export interface InvoiceIssuer {
companyName?: string | null
addressLine1?: string | null
addressLine2?: string | null
addressZip?: string | null
addressCity?: string | null
addressCountry?: string | null
siren?: string | null
siret?: string | null
tvaIntra?: string | null
rcs?: string | null
capital?: string | null
formeJuridique?: string | null
naf?: string | null
contactEmail?: string | null
contactPhone?: string | null
}
export interface InvoiceRib {
iban?: string | null
bic?: string | null
bankName?: string | null
}
/** Shape brute du JSONB `organizations.invoice_settings`. */
export interface InvoiceSettings {
themeSlug?: InvoiceThemeSlug
accentColor?: string | null
numeroPrefix?: string | null
numeroNextSeq?: number | null
numeroPadding?: number | null
paymentTermsDays?: number | null
penaltyRateText?: string | null
escompteText?: string | null
footerLegalText?: string | null
issuer?: InvoiceIssuer | null
rib?: InvoiceRib | null
}
/** Settings résolus — ce que les templates PDF consomment. */
export interface ResolvedInvoiceSettings {
themeSlug: InvoiceThemeSlug
accentColor: string
numeroPrefix: string
numeroNextSeq: number
numeroPadding: number
paymentTermsDays: number
penaltyRateText: string
escompteText: string
footerLegalText: string
issuer: Required<{ [K in keyof InvoiceIssuer]: string | null }>
rib: Required<{ [K in keyof InvoiceRib]: string | null }>
}
/** Defaults publics — texte légal aligné sur les exigences du Code de commerce. */
export const DEFAULT_PENALTY_RATE_TEXT =
"En cas de retard de paiement, des pénalités de retard sont exigibles au taux annuel équivalent à trois fois le taux d'intérêt légal. Une indemnité forfaitaire pour frais de recouvrement de 40 € s'applique également (art. D441-5 du Code de commerce)."
export const DEFAULT_ESCOMPTE_TEXT = "Pas d'escompte consenti pour paiement anticipé."
export const DEFAULT_PAYMENT_TERMS_DAYS = 30
export const DEFAULT_NUMERO_PADDING = 4
export const DEFAULT_THEME_SLUG: InvoiceThemeSlug = 'classique'
/**
* Résout les settings effectifs d'une org pour générer un PDF.
*
* - `accentColor` : settings → brand.primaryColor → rubis #9F1239
* - `issuer.companyName` : settings → org.name
* - `issuer.siret` : settings → org.siret
* - autres : defaults applicatifs
*/
export function resolveInvoiceSettings(org: Organization): ResolvedInvoiceSettings {
const settings = (org.invoiceSettings ?? null) as InvoiceSettings | null
const brand = resolveBrandTokens(org)
return {
themeSlug: settings?.themeSlug ?? DEFAULT_THEME_SLUG,
accentColor: settings?.accentColor ?? brand.primary,
numeroPrefix: settings?.numeroPrefix ?? '',
numeroNextSeq: settings?.numeroNextSeq ?? 1,
numeroPadding: settings?.numeroPadding ?? DEFAULT_NUMERO_PADDING,
paymentTermsDays: settings?.paymentTermsDays ?? DEFAULT_PAYMENT_TERMS_DAYS,
penaltyRateText: settings?.penaltyRateText ?? DEFAULT_PENALTY_RATE_TEXT,
escompteText: settings?.escompteText ?? DEFAULT_ESCOMPTE_TEXT,
footerLegalText: settings?.footerLegalText ?? '',
issuer: {
companyName: settings?.issuer?.companyName ?? org.name ?? null,
addressLine1: settings?.issuer?.addressLine1 ?? null,
addressLine2: settings?.issuer?.addressLine2 ?? null,
addressZip: settings?.issuer?.addressZip ?? null,
addressCity: settings?.issuer?.addressCity ?? null,
addressCountry: settings?.issuer?.addressCountry ?? 'FR',
siren: settings?.issuer?.siren ?? null,
siret: settings?.issuer?.siret ?? org.siret ?? null,
tvaIntra: settings?.issuer?.tvaIntra ?? null,
rcs: settings?.issuer?.rcs ?? null,
capital: settings?.issuer?.capital ?? null,
formeJuridique: settings?.issuer?.formeJuridique ?? null,
naf: settings?.issuer?.naf ?? null,
contactEmail: settings?.issuer?.contactEmail ?? null,
contactPhone: settings?.issuer?.contactPhone ?? null,
},
rib: {
iban: settings?.rib?.iban ?? null,
bic: settings?.rib?.bic ?? null,
bankName: settings?.rib?.bankName ?? null,
},
}
}
/**
* Merge un patch dans les settings existants — pattern identique à
* `mergeBrandSettings` : `null` explicite supprime le champ, `undefined`
* laisse intact. `issuer` et `rib` sont mergés en deep partial.
*/
export function mergeInvoiceSettings(
existing: InvoiceSettings | null,
patch: Partial<InvoiceSettings>
): InvoiceSettings {
const next: InvoiceSettings = { ...(existing ?? {}) }
for (const [key, value] of Object.entries(patch) as [keyof InvoiceSettings, unknown][]) {
if (value === null) {
delete next[key]
continue
}
if (value === undefined) continue
if (key === 'issuer') {
const existingIssuer = (existing?.issuer ?? {}) as InvoiceIssuer
const patchIssuer = value as InvoiceIssuer
const merged: InvoiceIssuer = { ...existingIssuer }
for (const [k, v] of Object.entries(patchIssuer) as [keyof InvoiceIssuer, unknown][]) {
if (v === null) {
delete merged[k]
} else if (v !== undefined) {
;(merged as Record<string, unknown>)[k] = v
}
}
next.issuer = merged
continue
}
if (key === 'rib') {
const existingRib = (existing?.rib ?? {}) as InvoiceRib
const patchRib = value as InvoiceRib
const merged: InvoiceRib = { ...existingRib }
for (const [k, v] of Object.entries(patchRib) as [keyof InvoiceRib, unknown][]) {
if (v === null) {
delete merged[k]
} else if (v !== undefined) {
;(merged as Record<string, unknown>)[k] = v
}
}
next.rib = merged
continue
}
;(next as Record<string, unknown>)[key] = value
}
return next
}
/**
* Valide un patch InvoiceSettings — retourne le premier message d'erreur,
* ou null si tout est OK. Vérifications minimales en complément de Vine
* côté validator (les regex sont dupliquées pour blinder le service en
* cas d'appel direct hors HTTP).
*/
export function validateInvoiceSettings(patch: Partial<InvoiceSettings>): string | null {
if (patch.themeSlug !== undefined && patch.themeSlug !== null) {
if (!INVOICE_THEME_SLUGS.includes(patch.themeSlug)) {
return `invalid_theme: doit être l'un de ${INVOICE_THEME_SLUGS.join(', ')}`
}
}
if (patch.accentColor !== undefined && patch.accentColor !== null) {
if (!HEX_RE.test(patch.accentColor)) {
return 'invalid_accent_color: format #RRGGBB attendu'
}
}
if (patch.numeroPadding !== undefined && patch.numeroPadding !== null) {
if (!Number.isInteger(patch.numeroPadding) || patch.numeroPadding < 1 || patch.numeroPadding > 10) {
return 'invalid_numero_padding: entier entre 1 et 10'
}
}
if (patch.numeroNextSeq !== undefined && patch.numeroNextSeq !== null) {
if (!Number.isInteger(patch.numeroNextSeq) || patch.numeroNextSeq < 1) {
return 'invalid_numero_next_seq: entier ≥ 1'
}
}
if (patch.paymentTermsDays !== undefined && patch.paymentTermsDays !== null) {
if (
!Number.isInteger(patch.paymentTermsDays) ||
patch.paymentTermsDays < 0 ||
patch.paymentTermsDays > 365
) {
return 'invalid_payment_terms_days: entier entre 0 et 365'
}
}
if (patch.issuer) {
const { issuer } = patch
if (issuer.addressCountry && !ISO_COUNTRY_RE.test(issuer.addressCountry)) {
return 'invalid_issuer.address_country: code ISO 2 lettres'
}
if (issuer.siren && !SIREN_RE.test(issuer.siren)) {
return 'invalid_issuer.siren: 9 chiffres requis'
}
if (issuer.siret && !SIRET_RE.test(issuer.siret)) {
return 'invalid_issuer.siret: 14 chiffres requis'
}
if (issuer.tvaIntra && !TVA_INTRA_RE.test(issuer.tvaIntra)) {
return 'invalid_issuer.tva_intra: format UE invalide (ex. FR12345678901)'
}
if (issuer.naf && !NAF_RE.test(issuer.naf)) {
return 'invalid_issuer.naf: format NAF/APE invalide (ex. 6201Z)'
}
}
if (patch.rib) {
const { rib } = patch
if (rib.iban && !IBAN_RE.test(rib.iban)) {
return 'invalid_rib.iban: IBAN invalide'
}
if (rib.bic && !BIC_RE.test(rib.bic)) {
return 'invalid_rib.bic: BIC/SWIFT invalide (8 ou 11 caractères)'
}
}
return null
}
/** Normalise un IBAN : majuscules + suppression des espaces. */
export function normalizeIban(iban: string): string {
return iban.replace(/\s+/g, '').toUpperCase()
}