/** * 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 { 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)[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)[k] = v } } next.rib = merged continue } ;(next as Record)[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): 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() }