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>
111 lines
4.2 KiB
TypeScript
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)
|
|
}
|