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

111 lines
4.2 KiB
TypeScript

/**
* invoice_pdf — génération de PDF pour les factures natives.
*
* Phase 2 : implémentation réelle via @react-pdf/renderer + upload MinIO.
*
* Pipeline :
* 1. Construit les `InvoiceTemplateProps` depuis l'invoice + settings résolus
* 2. Appelle `renderInvoiceToBuffer(themeSlug, props)` (dispatcher de thèmes)
* 3. `generateInvoicePdf` upload sur MinIO et retourne la storageKey.
* `previewInvoicePdf` retourne le buffer brut pour stream HTTP.
*
* Note Factur-X (V1.5) : le buffer généré est un PDF/A-3 compatible. Pour
* passer en Factur-X, il faudra injecter un XML CII en pièce jointe dans
* le PDF (cf. roadmap dans CLAUDE.md / decisions.md ADR-022). Ce point
* d'extension est laissé en post-traitement Buffer → Buffer.
*/
import type Invoice from '#models/invoice'
import type { ResolvedInvoiceSettings } from '#services/invoice_settings'
import { resolveBrandTokens } from '#services/brand'
import { renderInvoiceToBuffer } from '#pdf-templates/index'
import { uploadBuffer } from '#services/media_storage'
import type { InvoiceTemplateProps } from '#pdf-templates/common'
import type Organization from '#models/organization'
export interface InvoiceRenderContext {
invoice: Invoice
resolvedSettings: ResolvedInvoiceSettings
/** Org de l'invoice — utilisée pour le logo (brand_settings.logoUrl). */
organization: Organization
}
export interface GeneratedPdf {
/** Clé MinIO sous laquelle le PDF est stocké. */
storageKey: string
/** Taille du PDF en bytes. */
bytes: number
}
/**
* Construit les props passées au template à partir d'une facture (potentiellement
* non-persistée, pour la preview) et des settings résolus.
*
* On lit en priorité les snapshots de la facture (`issuerSnapshot`, `clientSnapshot`)
* — c'est l'état figé à l'émission. Si les snapshots sont absents (preview avant
* persistance), on retombe sur les settings résolus pour issuer et sur les
* données fournies à la preview pour client (cf. previewPdf controller qui
* construit un Invoice virtuel avec un clientSnapshot synthétisé).
*/
function buildProps(ctx: InvoiceRenderContext): InvoiceTemplateProps {
const { invoice, resolvedSettings, organization } = ctx
const issuer = invoice.issuerSnapshot ?? resolvedSettings.issuer
if (!invoice.clientSnapshot) {
throw new Error(
'invoice_pdf: clientSnapshot manquant — impossible de rendre la facture sans destinataire'
)
}
const brand = resolveBrandTokens(organization)
const logoUrl = brand.logoUrl ?? null
return {
numero: invoice.numero,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
paymentTermsDays: invoice.paymentTermsDays ?? resolvedSettings.paymentTermsDays,
issuer,
client: invoice.clientSnapshot,
lines: invoice.lines ?? [],
tvaBreakdown: invoice.tvaBreakdown ?? [],
amountHtCents: invoice.amountHtCents ?? 0,
amountTvaCents: invoice.amountTvaCents ?? 0,
amountTtcCents: invoice.amountTtcCents,
penaltyRateText: resolvedSettings.penaltyRateText,
escompteText: resolvedSettings.escompteText,
footerLegalText: resolvedSettings.footerLegalText,
footerNotes: invoice.footerNotes,
rib: {
iban: resolvedSettings.rib.iban,
bic: resolvedSettings.rib.bic,
bankName: resolvedSettings.rib.bankName,
},
accentColor: invoice.themeAccentColor ?? resolvedSettings.accentColor,
logoUrl,
}
}
/**
* Génère le PDF et l'upload sur MinIO. Retourne la storageKey à persister
* dans `invoices.pdf_storage_key`.
*/
export async function generateInvoicePdf(
ctx: InvoiceRenderContext
): Promise<GeneratedPdf> {
const themeSlug = ctx.invoice.themeSlug ?? ctx.resolvedSettings.themeSlug
const props = buildProps(ctx)
const buffer = await renderInvoiceToBuffer(themeSlug, props)
const uploaded = await uploadBuffer(buffer, 'invoice-pdf', ctx.invoice.organizationId)
return { storageKey: uploaded.storageKey, bytes: uploaded.sizeBytes }
}
/**
* Génère le PDF sans l'uploader — utilisé pour la preview (stream HTTP direct).
*/
export async function previewInvoicePdf(ctx: InvoiceRenderContext): Promise<Buffer> {
const themeSlug = ctx.invoice.themeSlug ?? ctx.resolvedSettings.themeSlug
const props = buildProps(ctx)
return await renderInvoiceToBuffer(themeSlug, props)
}