/** * pdf-templates/common — types, formatters, et helpers partagés entre * les 4 thèmes de factures Rubis. * * Les thèmes consomment tous le même `InvoiceTemplateProps`. Le dispatcher * (`#pdf-templates/index`) sélectionne le bon composant selon le slug. * * Convention : * - Tous les montants en *centimes* (int). On formate au moment du render * via `formatCents(cents)` (sépare millier + ", " + " €"). * - Toutes les dates en `DateTime` Luxon — on formate via `formatDate(d)` * en français long ("15 mai 2026"). */ import { DateTime } from 'luxon' import type { InvoiceIssuer, InvoiceThemeSlug, } from '#services/invoice_settings' import type { ComputedInvoiceLine, TvaBreakdownItem, } from '#services/invoice_totals' import type { ClientSnapshot } from '#models/invoice' /** * Props passées à chaque template. C'est le contrat figé que respectent * tous les thèmes — ajouter un champ = mettre à jour les 4 composants. */ export interface InvoiceTemplateProps { /** Métadonnées en-tête : numéro, dates, paiement. */ numero: string issueDate: DateTime dueDate: DateTime paymentTermsDays: number /** Émetteur (snapshot figé à l'émission). */ issuer: InvoiceIssuer /** Client destinataire (snapshot figé à l'émission). */ client: ClientSnapshot /** Lignes calculées (HT par ligne déjà arrondi). */ lines: ComputedInvoiceLine[] /** Ventilation TVA — affichée seulement si plusieurs taux. */ tvaBreakdown: TvaBreakdownItem[] /** Totaux agrégés (en centimes). */ amountHtCents: number amountTvaCents: number amountTtcCents: number /** Mentions légales (snapshot des settings au moment de l'émission). */ penaltyRateText: string escompteText: string footerLegalText: string /** Notes libres en pied de page (custom par facture). */ footerNotes: string | null /** RIB pour le pied de page paiement. */ rib: { iban: string | null bic: string | null bankName: string | null } /** Couleur d'accent appliquée (hex #RRGGBB). */ accentColor: string /** URL absolue du logo (null = pas de logo). */ logoUrl: string | null } // ============================================================================ // Formatters (format français) // ============================================================================ /** * Formate des centimes en string "X 123,45 €". Pas de décimales sans virgule * (toujours 2 chiffres après la virgule, exigence comptable). */ export function formatCents(cents: number): string { const euros = cents / 100 return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(euros) } /** Format français long : "15 mai 2026". */ export function formatDate(d: DateTime): string { return d.setLocale('fr').toFormat('d MMMM yyyy') } /** Format quantité : 2 décimales si non-entier, sinon int. */ export function formatQuantity(q: number): string { return Number.isInteger(q) ? String(q) : new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(q) } /** Format taux TVA : "20 %" ou "5,5 %". */ export function formatTvaRate(rate: number): string { return Number.isInteger(rate) ? `${rate} %` : `${rate.toString().replace('.', ',')} %` } /** * Formate l'adresse postale en multi-lignes utilisable dans le PDF. * Retourne un tableau de strings non-vides (le template fait un map → Text). */ export function formatAddress(parts: { line1: string | null | undefined line2: string | null | undefined zip: string | null | undefined city: string | null | undefined country: string | null | undefined }): string[] { const out: string[] = [] if (parts.line1) out.push(parts.line1) if (parts.line2) out.push(parts.line2) if (parts.zip || parts.city) { out.push([parts.zip, parts.city].filter(Boolean).join(' ')) } // Pays affiché uniquement hors France (par convention factures domestiques). if (parts.country && parts.country !== 'FR') out.push(parts.country) return out } /** Calcule le nombre de jours entre deux dates (issue vs due) pour affichage. */ export function daysBetween(from: DateTime, to: DateTime): number { return Math.round(to.diff(from, 'days').days) } // ============================================================================ // Palette commune // ============================================================================ /** Tons de gris cohérents avec la palette Rubis (cream + ink). */ export const PALETTE = { ink: '#1A1410', ink2: '#4F4640', ink3: '#8A7F76', line: '#E8E0D6', cream: '#FAF7F2', paper: '#FFFFFF', } as const // ============================================================================ // Dispatcher type — réexporté pour le sélecteur côté `index.tsx` // ============================================================================ export type { InvoiceThemeSlug }