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>
275 lines
9.8 KiB
TypeScript
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()
|
|
}
|