ordinarthur ab07cd4a3b feat(invoices): génération PDF native via @react-pdf/renderer (Phase 2)
Implémente les 4 thèmes de factures (Classique, Moderne, Minimal,
Élégant) en composants React PDF et remplace les stubs Phase 1 par la
vraie génération + upload MinIO.

Templates (app/pdf-templates/)
- common.tsx : props partagées, formatters fr-FR (cents → euros,
  dates longues, taux TVA), palette neutre.
- classique : sobre, header texte centré, filets fins. Pour les
  cabinets et professions réglementées.
- moderne : bandeau coloré pleine largeur, logo dans le bandeau.
  Pour les agences et studios.
- minimal : noir et blanc, aéré, accent uniquement sur le numéro.
  Pour les indépendants et les designers.
- elegant : Times Roman, filets fins, titre centré encadré, italique
  sur le pied légal. Pour les boutiques premium.
- index.tsx : dispatcher slug → composant + renderInvoiceToBuffer.

Génération
- media_storage : nouveau scope `invoice-pdf` (`invoices/<orgId>/<uuid>.pdf`)
  et fonction `uploadBuffer(buffer, scope, subPath?)` pour stocker les
  buffers générés en mémoire (vs. uploads multipart existants).
- invoice_pdf : `generateInvoicePdf` rend + upload, `previewInvoicePdf`
  rend en Buffer pour stream HTTP direct.
- InvoicesController.pdf : lazy regenerate si pdf_storage_key est null
  sur une facture native (cas où la génération initiale a échoué).
- InvoicesController.previewPdf : synthétise un clientSnapshot depuis
  les données live, passe dans le pipeline standard.
- InvoicesController.storeNative : appelle la vraie génération en
  post-commit, log + continue si échec.

Conformité Factur-X (V1.5) : la structure de génération est un
point d'extension Buffer → Buffer ; l'injection d'un XML CII en
pièce jointe PDF sera ajoutée sans toucher aux templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:16:45 +02:00

149 lines
4.9 KiB
TypeScript

/**
* 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 }