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>
This commit is contained in:
ordinarthur 2026-05-14 02:16:45 +02:00
parent e0b47ddfdc
commit ab07cd4a3b
10 changed files with 1488 additions and 55 deletions

View File

@ -353,12 +353,17 @@ export default class InvoicesController {
}
/**
* GET /invoices/:id/pdf stream le PDF/image originel de la facture.
* GET /invoices/:id/pdf stream le PDF (généré ou uploadé) de la facture.
*
* Source : `pdfStorageKey` propagé depuis le draft d'import lors de la
* validation. 404 si la facture n'a pas de fichier (saisie manuelle).
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob
* puis affiche dans un <iframe>/<img> via objectURL.
* Cas couverts :
* - Facture OCR/manuelle (`pdfStorageKey` propagé du draft) stream tel quel.
* - Facture native déjà rendue (`pdfStorageKey` non-null) stream depuis MinIO.
* - Facture native avec génération échouée (`isNative=true` + `pdfStorageKey=null`)
* lazy regenerate à la volée, persiste, puis stream.
* - Facture sans fichier (saisie manuelle pré-feature, jamais native) 404.
*
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob puis
* affiche dans un <iframe>/<img> via objectURL.
*/
async pdf({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
@ -370,6 +375,29 @@ export default class InvoicesController {
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
// Lazy regenerate : facture native dont la génération a échoué → on retente.
if (!invoice.pdfStorageKey && invoice.isNative) {
try {
const org = await Organization.findOrFail(organizationId)
const resolvedSettings = resolveInvoiceSettings(org)
const generated = await generateInvoicePdf({
invoice,
resolvedSettings,
organization: org,
})
invoice.pdfStorageKey = generated.storageKey
invoice.pdfGeneratedAt = DateTime.utc()
await invoice.save()
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'lazy invoice pdf regeneration failed')
throw new Exception('Impossible de générer le PDF de la facture', {
status: 500,
code: 'pdf_generation_failed',
})
}
}
if (!invoice.pdfStorageKey) {
throw new Exception('Aucun PDF stocké pour cette facture', {
status: 404,
@ -569,20 +597,20 @@ export default class InvoicesController {
await invoice.load('client')
await invoice.load('plan')
// Génération du PDF en post-commit (stub Phase 1 → null, vraie impl Phase 2).
// Génération du PDF en post-commit. Échec = on log et on continue, la
// facture est créée et le PDF sera régénérable plus tard (idempotent).
try {
const resolvedSettings = resolveInvoiceSettings(
(await Organization.find(organizationId))!
)
const generated = await generateInvoicePdf({ invoice, resolvedSettings })
if (generated) {
invoice.pdfStorageKey = generated.storageKey
invoice.pdfGeneratedAt = DateTime.utc()
await invoice.save()
}
const org = await Organization.findOrFail(organizationId)
const resolvedSettings = resolveInvoiceSettings(org)
const generated = await generateInvoicePdf({
invoice,
resolvedSettings,
organization: org,
})
invoice.pdfStorageKey = generated.storageKey
invoice.pdfGeneratedAt = DateTime.utc()
await invoice.save()
} catch (err) {
// PDF generation échouée n'invalide pas la facture : elle est créée,
// le PDF sera regénérable plus tard. Log + continue.
logger.warn({ err, invoiceId: invoice.id }, 'native invoice pdf generation failed')
}
@ -603,10 +631,8 @@ export default class InvoicesController {
*
* Mêmes champs que `storeNative` (sauf `draft`) le serveur recalcule
* les totaux et stream le PDF (`application/pdf`). Utilisé par l'éditeur
* pour afficher le rendu dans un `<iframe>` ou déclencher un download
* "voir le PDF avant émission".
*
* Phase 1 stub 501. Phase 2 active la vraie génération.
* pour afficher le rendu dans un `<iframe>` (debounced 500ms côté UI
* pour éviter le spam de requêtes pendant la saisie).
*/
async previewPdf({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
@ -626,10 +652,29 @@ export default class InvoicesController {
const totals = computeInvoiceTotals(payload.lines)
// Construit un Invoice "virtuel" non-persisté pour le rendu.
const org = await Organization.findOrFail(organizationId)
const resolvedSettings = resolveInvoiceSettings(org)
// Synthétise un clientSnapshot à partir du client live (pas encore figé
// puisque la facture n'est pas émise).
const clientSnapshotForPreview = {
name: client.name,
email: client.email,
contactFirstName: client.contactFirstName,
contactLastName: client.contactLastName,
phone: client.phone,
siret: client.siret,
siren: (client as unknown as { siren: string | null }).siren ?? null,
tvaIntra: (client as unknown as { tvaIntra: string | null }).tvaIntra ?? null,
addressLine1: (client as unknown as { addressLine1: string | null }).addressLine1 ?? null,
addressLine2: (client as unknown as { addressLine2: string | null }).addressLine2 ?? null,
addressZip: (client as unknown as { addressZip: string | null }).addressZip ?? null,
addressCity: (client as unknown as { addressCity: string | null }).addressCity ?? null,
addressCountry:
(client as unknown as { addressCountry: string | null }).addressCountry ?? null,
}
// Invoice "virtuel" non-persisté pour passer dans le pipeline de rendu.
const virtualInvoice = new Invoice()
virtualInvoice.organizationId = organizationId
virtualInvoice.clientId = client.id
@ -647,8 +692,14 @@ export default class InvoicesController {
virtualInvoice.themeAccentColor = payload.accentColor
virtualInvoice.footerNotes = payload.footerNotes ?? null
virtualInvoice.isNative = true
virtualInvoice.clientSnapshot = clientSnapshotForPreview
virtualInvoice.issuerSnapshot = resolvedSettings.issuer
const pdf = await previewInvoicePdf({ invoice: virtualInvoice, resolvedSettings })
const pdf = await previewInvoicePdf({
invoice: virtualInvoice,
resolvedSettings,
organization: org,
})
response.header('Content-Type', 'application/pdf')
response.header('Cache-Control', 'no-store')

View File

@ -0,0 +1,289 @@
/**
* Template "Classique" sobre, sérieux, header texte centré.
*
* Cible : cabinets, professions réglementées, structures traditionnelles.
* Pas de bandeau coloré, pas d'ornementation. L'accent color est utilisé
* uniquement pour les filets de séparation et le numéro de facture.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
daysBetween,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
paddingTop: 56,
paddingBottom: 56,
paddingHorizontal: 56,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Helvetica',
lineHeight: 1.5,
},
// Header
headerCenter: { textAlign: 'center', marginBottom: 24 },
logo: { width: 80, height: 32, objectFit: 'contain', marginBottom: 8, alignSelf: 'center' },
companyName: { fontSize: 16, fontWeight: 'bold', marginBottom: 4 },
companyMeta: { fontSize: 9, color: PALETTE.ink2 },
divider: { borderBottomWidth: 1, marginVertical: 16 },
// Titre facture
invoiceBlock: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
invoiceTitle: { fontSize: 22, fontWeight: 'bold' },
invoiceMeta: { fontSize: 10 },
invoiceMetaRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
invoiceMetaLabel: { color: PALETTE.ink2 },
// Client
clientBlock: {
borderWidth: 1,
borderColor: PALETTE.line,
padding: 12,
marginBottom: 20,
width: '50%',
alignSelf: 'flex-end',
},
clientLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 4 },
clientName: { fontSize: 11, fontWeight: 'bold' },
// Table
table: { marginBottom: 16 },
tableHeader: {
flexDirection: 'row',
borderBottomWidth: 1,
paddingBottom: 6,
marginBottom: 4,
},
tableRow: {
flexDirection: 'row',
paddingVertical: 6,
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
},
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, fontWeight: 'bold' },
// Totals
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
marginTop: 4,
borderTopWidth: 1,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 12, fontWeight: 'bold' },
grandAmount: { fontSize: 12, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 16, width: '60%' },
tvaHeader: { flexDirection: 'row', borderBottomWidth: 0.5, borderBottomColor: PALETTE.line, paddingBottom: 3 },
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
// Footer
paymentBlock: { marginTop: 12, marginBottom: 12 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: PALETTE.line,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.4,
},
})
export function ClassiqueTemplate(props: InvoiceTemplateProps) {
const accent = { color: props.accentColor }
const dividerAccent = { borderBottomColor: props.accentColor }
const grandAccent = { borderTopColor: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header centré : logo + identité émetteur */}
<View style={styles.headerCenter}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.map((line, i) => (
<Text key={`addr-${i}`} style={styles.companyMeta}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.companyMeta}>SIRET {props.issuer.siret}</Text>
) : null}
{props.issuer.tvaIntra ? (
<Text style={styles.companyMeta}>TVA {props.issuer.tvaIntra}</Text>
) : null}
</View>
<View style={[styles.divider, dividerAccent]} />
{/* Bloc facture (gauche) + meta (droite) */}
<View style={styles.invoiceBlock}>
<View>
<Text style={[styles.invoiceTitle, accent]}>FACTURE</Text>
<Text style={styles.invoiceMeta}>N° {props.numero}</Text>
</View>
<View style={{ minWidth: 180 }}>
<View style={styles.invoiceMetaRow}>
<Text style={styles.invoiceMetaLabel}>Date d'émission</Text>
<Text>{formatDate(props.issueDate)}</Text>
</View>
<View style={styles.invoiceMetaRow}>
<Text style={styles.invoiceMetaLabel}>Date d'échéance</Text>
<Text>{formatDate(props.dueDate)}</Text>
</View>
<View style={styles.invoiceMetaRow}>
<Text style={styles.invoiceMetaLabel}>Délai</Text>
<Text>
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''}
</Text>
</View>
</View>
</View>
{/* Client */}
<View style={styles.clientBlock}>
<Text style={styles.clientLabel}>Adressée à</Text>
<Text style={styles.clientName}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`cli-${i}`} style={styles.companyMeta}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.companyMeta}>SIRET {props.client.siret}</Text>
) : null}
{props.client.tvaIntra ? (
<Text style={styles.companyMeta}>TVA {props.client.tvaIntra}</Text>
) : null}
</View>
{/* Table des lignes */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cellDesc, styles.cellHead]}>Désignation</Text>
<Text style={[styles.cellQty, styles.cellHead]}>Qté</Text>
<Text style={[styles.cellPu, styles.cellHead]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.cellHead]}>TVA</Text>
<Text style={[styles.cellTotal, styles.cellHead]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{/* Ventilation TVA — affichée seulement si plusieurs taux */}
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
<View style={styles.tvaHeader}>
<Text style={[styles.tvaCellLeft, styles.cellHead]}>Taux</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Base HT</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Montant TVA</Text>
</View>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>{formatTvaRate(b.rate)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={[styles.totalRowGrand, grandAccent]}>
<Text style={[styles.grandLabel, accent]}>Total TTC</Text>
<Text style={[styles.grandAmount, accent]}>
{formatCents(props.amountTtcCents)}
</Text>
</View>
</View>
{/* Paiement / RIB */}
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Coordonnées de paiement</Text>
{props.rib.bankName ? <Text>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text>IBAN : {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text>BIC : {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Notes</Text>
<Text>{props.footerNotes}</Text>
</View>
) : null}
{/* Pied légal */}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
<Text>
Échéance : {formatDate(props.dueDate)} (
{daysBetween(props.issueDate, props.dueDate)} jours)
</Text>
</View>
</Page>
</Document>
)
}

View File

@ -0,0 +1,148 @@
/**
* 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 }

View File

@ -0,0 +1,286 @@
/**
* Template "Élégant" filets fins, mise en page éditoriale.
*
* Cible : boutiques premium, artisanat haut de gamme, hôtellerie-restauration
* qualitative. Filets de séparation horizontaux discrets, deux colonnes
* pour l'identité, accent color sur les filets et le mot "Facture" centré.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
paddingTop: 56,
paddingBottom: 56,
paddingHorizontal: 64,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Times-Roman',
lineHeight: 1.55,
},
// Header deux colonnes
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
headerLeft: { flex: 1 },
headerRight: { flex: 1, alignItems: 'flex-end' },
logo: { width: 64, height: 26, objectFit: 'contain', marginBottom: 10 },
companyName: { fontSize: 12, fontWeight: 'bold', marginBottom: 2 },
small: { fontSize: 9, color: PALETTE.ink2 },
// Titre centré
titleBlock: { alignItems: 'center', marginVertical: 16 },
titleAccent: { fontSize: 9, textTransform: 'uppercase', letterSpacing: 4, marginBottom: 6 },
titleText: { fontSize: 24, fontWeight: 'bold', fontFamily: 'Times-Bold' },
titleNumero: { fontSize: 11, marginTop: 4, color: PALETTE.ink2, fontStyle: 'italic' },
hairline: { borderBottomWidth: 0.5, marginVertical: 12 },
// Méta
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24 },
metaBlock: { flex: 1 },
metaLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 4 },
metaValue: { fontSize: 11 },
clientName: { fontSize: 12, fontWeight: 'bold' },
// Table
table: { marginBottom: 16 },
tableHeader: {
flexDirection: 'row',
borderBottomWidth: 1,
paddingBottom: 6,
marginBottom: 4,
},
tableRow: {
flexDirection: 'row',
paddingVertical: 6,
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
},
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 1 },
// Totaux
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingTop: 10,
paddingBottom: 4,
marginTop: 6,
borderTopWidth: 1,
borderBottomWidth: 0.5,
paddingHorizontal: 4,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 13, fontWeight: 'bold' },
grandAmount: { fontSize: 13, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 16, width: '60%' },
tvaHeader: {
flexDirection: 'row',
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
paddingBottom: 3,
},
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
// Footer
paymentBlock: { marginTop: 12, marginBottom: 12 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: PALETTE.line,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.5,
fontStyle: 'italic',
},
})
export function ElegantTemplate(props: InvoiceTemplateProps) {
const accent = { color: props.accentColor }
const hairlineAccent = { borderBottomColor: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header : émetteur à gauche, dates à droite */}
<View style={styles.header}>
<View style={styles.headerLeft}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.map((line, i) => (
<Text key={`a-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.small}>SIRET {props.issuer.siret}</Text>
) : null}
{props.issuer.tvaIntra ? (
<Text style={styles.small}>TVA {props.issuer.tvaIntra}</Text>
) : null}
</View>
<View style={styles.headerRight}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={styles.metaValue}>{formatDate(props.issueDate)}</Text>
<Text style={[styles.metaLabel, { marginTop: 6 }]}>Échéance</Text>
<Text style={styles.metaValue}>{formatDate(props.dueDate)}</Text>
</View>
</View>
{/* Titre centré */}
<View style={[styles.hairline, hairlineAccent]} />
<View style={styles.titleBlock}>
<Text style={[styles.titleAccent, accent]}>Facture</Text>
<Text style={styles.titleText}>N° {props.numero}</Text>
<Text style={styles.titleNumero}>
Établie le {formatDate(props.issueDate)}
</Text>
</View>
<View style={[styles.hairline, hairlineAccent]} />
{/* Méta client */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Adressée à</Text>
<Text style={styles.clientName}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`cli-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.small}>SIRET {props.client.siret}</Text>
) : null}
{props.client.tvaIntra ? (
<Text style={styles.small}>TVA {props.client.tvaIntra}</Text>
) : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Délai de paiement</Text>
<Text style={styles.metaValue}>
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''}
</Text>
</View>
</View>
{/* Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cellDesc, styles.cellHead]}>Désignation</Text>
<Text style={[styles.cellQty, styles.cellHead]}>Qté</Text>
<Text style={[styles.cellPu, styles.cellHead]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.cellHead]}>TVA</Text>
<Text style={[styles.cellTotal, styles.cellHead]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
<View style={styles.tvaHeader}>
<Text style={[styles.tvaCellLeft, styles.cellHead]}>Taux</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Base HT</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Montant TVA</Text>
</View>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>{formatTvaRate(b.rate)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={[styles.totalRowGrand, hairlineAccent, { borderTopColor: props.accentColor }]}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={[styles.grandAmount, accent]}>
{formatCents(props.amountTtcCents)}
</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Coordonnées de paiement</Text>
{props.rib.bankName ? <Text>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text>IBAN : {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text>BIC : {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Notes</Text>
<Text>{props.footerNotes}</Text>
</View>
) : null}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
</View>
</Page>
</Document>
)
}

View File

@ -0,0 +1,51 @@
/**
* pdf-templates dispatcher : sélectionne le bon thème et rend en Buffer.
*
* Le code applicatif (`#services/invoice_pdf`) appelle `renderInvoiceToBuffer`
* qui :
* 1. Choisit le composant React selon `themeSlug`
* 2. Le rend en PDF via `@react-pdf/renderer.renderToBuffer`
* 3. Retourne le Buffer prêt à être uploadé sur MinIO ou streamé
*
* Les 4 thèmes consomment tous le même `InvoiceTemplateProps` (cf. common.tsx),
* ce qui permet d'ajouter ou de remplacer un thème sans toucher au dispatcher.
*/
import React from 'react'
import { renderToBuffer, type DocumentProps } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
type InvoiceThemeSlug,
} from '#pdf-templates/common'
import { ClassiqueTemplate } from '#pdf-templates/classique'
import { ModerneTemplate } from '#pdf-templates/moderne'
import { MinimalTemplate } from '#pdf-templates/minimal'
import { ElegantTemplate } from '#pdf-templates/elegant'
/** Mapping slug → composant. Source de vérité du dispatcher. */
const THEMES: Record<
InvoiceThemeSlug,
(props: InvoiceTemplateProps) => React.ReactElement<DocumentProps>
> = {
classique: ClassiqueTemplate,
moderne: ModerneTemplate,
minimal: MinimalTemplate,
elegant: ElegantTemplate,
}
/**
* Rend la facture en PDF (Buffer).
*
* @param themeSlug Slug du thème fallback "classique" si inconnu (defensive).
* @param props Données passées au template (cf. InvoiceTemplateProps).
*/
export async function renderInvoiceToBuffer(
themeSlug: InvoiceThemeSlug,
props: InvoiceTemplateProps
): Promise<Buffer> {
const Template = THEMES[themeSlug] ?? THEMES.classique
const element = Template(props)
return await renderToBuffer(element)
}
export type { InvoiceTemplateProps, InvoiceThemeSlug }

View File

@ -0,0 +1,230 @@
/**
* Template "Minimal" noir et blanc, aéré, aucun ornement.
*
* Cible : indépendants, designers, profils qui veulent un rendu Apple-clean.
* L'accent color n'est utilisé que pour le numéro de facture (sobre).
* Pas de filets, beaucoup d'espace blanc, typographie en valeur.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
paddingTop: 72,
paddingBottom: 60,
paddingHorizontal: 72,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Helvetica',
lineHeight: 1.6,
},
// Header
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 56 },
headerLeft: { flex: 1 },
headerRight: { flex: 1, alignItems: 'flex-end' },
logo: { width: 48, height: 18, objectFit: 'contain', marginBottom: 12 },
invoiceLabel: { fontSize: 9, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 2 },
invoiceNumero: { fontSize: 18, marginTop: 4 },
companyName: { fontSize: 11, fontWeight: 'bold' },
small: { fontSize: 9, color: PALETTE.ink2 },
// Méta
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 48 },
metaBlock: { flex: 1 },
metaLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 6 },
metaValue: { fontSize: 11 },
// Table
table: { marginBottom: 24 },
tableHeader: { flexDirection: 'row', paddingBottom: 8 },
tableRow: { flexDirection: 'row', paddingVertical: 8 },
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 1 },
// Totaux
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 32 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 12,
marginTop: 8,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 14, fontWeight: 'bold' },
grandAmount: { fontSize: 14, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 24, width: '60%' },
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
// Footer
paymentBlock: { marginTop: 16, marginBottom: 16 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 24,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.5,
},
})
export function MinimalTemplate(props: InvoiceTemplateProps) {
const accent = { color: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header sobre : logo + numéro grand */}
<View style={styles.header}>
<View style={styles.headerLeft}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.map((line, i) => (
<Text key={`a-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.small}>SIRET {props.issuer.siret}</Text>
) : null}
</View>
<View style={styles.headerRight}>
<Text style={styles.invoiceLabel}>Facture</Text>
<Text style={[styles.invoiceNumero, accent]}>{props.numero}</Text>
</View>
</View>
{/* Méta : client + dates */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Client</Text>
<Text style={styles.metaValue}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`c-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.small}>SIRET {props.client.siret}</Text>
) : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={styles.metaValue}>{formatDate(props.issueDate)}</Text>
<Text style={[styles.metaLabel, { marginTop: 8 }]}>Échéance</Text>
<Text style={styles.metaValue}>{formatDate(props.dueDate)}</Text>
</View>
</View>
{/* Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cellDesc, styles.cellHead]}>Désignation</Text>
<Text style={[styles.cellQty, styles.cellHead]}>Qté</Text>
<Text style={[styles.cellPu, styles.cellHead]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.cellHead]}>TVA</Text>
<Text style={[styles.cellTotal, styles.cellHead]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>TVA {formatTvaRate(b.rate)} sur</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={styles.totalRowGrand}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={styles.grandAmount}>{formatCents(props.amountTtcCents)}</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Paiement</Text>
{props.rib.bankName ? <Text style={styles.small}>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text style={styles.small}>IBAN {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text style={styles.small}>BIC {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.small}>{props.footerNotes}</Text>
</View>
) : null}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
</View>
</Page>
</Document>
)
}

View File

@ -0,0 +1,269 @@
/**
* Template "Moderne" bandeau coloré en header, mise en page contemporaine.
*
* Cible : agences, studios, freelances créatifs. L'accent color est dominant :
* bandeau header, ligne de séparation, total TTC. Le logo se pose sur le
* bandeau.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Helvetica',
lineHeight: 1.5,
},
// Bandeau coloré pleine largeur en haut
banner: {
paddingTop: 36,
paddingBottom: 28,
paddingHorizontal: 56,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
bannerLeft: { flex: 2 },
bannerRight: { flex: 1, alignItems: 'flex-end' },
invoiceTitle: { fontSize: 26, fontWeight: 'bold', color: PALETTE.paper, letterSpacing: 1 },
invoiceNumero: { fontSize: 11, color: PALETTE.paper, opacity: 0.85, marginTop: 4 },
bannerCompany: { fontSize: 13, color: PALETTE.paper, fontWeight: 'bold' },
bannerMeta: { fontSize: 8, color: PALETTE.paper, opacity: 0.85 },
logo: { width: 60, height: 24, objectFit: 'contain', marginBottom: 6 },
// Corps
body: { paddingTop: 32, paddingHorizontal: 56, paddingBottom: 36, flexGrow: 1 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24 },
metaBlock: { flex: 1 },
metaLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
clientName: { fontSize: 12, fontWeight: 'bold' },
// Table
table: { marginBottom: 16 },
tableHeader: {
flexDirection: 'row',
paddingVertical: 8,
paddingHorizontal: 6,
},
tableHeaderText: { fontSize: 8, textTransform: 'uppercase', fontWeight: 'bold', color: PALETTE.paper },
tableRow: {
flexDirection: 'row',
paddingVertical: 8,
paddingHorizontal: 6,
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
},
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
// Totaux
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 12,
marginTop: 6,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 12, fontWeight: 'bold', color: PALETTE.paper },
grandAmount: { fontSize: 14, fontWeight: 'bold', color: PALETTE.paper },
// TVA breakdown
tvaBlock: { marginBottom: 16, width: '60%' },
tvaHeader: {
flexDirection: 'row',
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
paddingBottom: 3,
},
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, fontWeight: 'bold' },
// Footer
paymentBlock: { marginTop: 12, marginBottom: 12 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: PALETTE.line,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.4,
},
})
export function ModerneTemplate(props: InvoiceTemplateProps) {
const accentBg = { backgroundColor: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Bandeau coloré */}
<View style={[styles.banner, accentBg]}>
<View style={styles.bannerLeft}>
<Text style={styles.invoiceTitle}>FACTURE</Text>
<Text style={styles.invoiceNumero}>N° {props.numero}</Text>
</View>
<View style={styles.bannerRight}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.bannerCompany}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.slice(0, 2).map((line, i) => (
<Text key={`bma-${i}`} style={styles.bannerMeta}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.bannerMeta}>SIRET {props.issuer.siret}</Text>
) : null}
</View>
</View>
<View style={styles.body}>
{/* Méta : client + dates */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Adressée à</Text>
<Text style={styles.clientName}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`cli-${i}`}>{line}</Text>
))}
{props.client.siret ? <Text>SIRET {props.client.siret}</Text> : null}
{props.client.tvaIntra ? <Text>TVA {props.client.tvaIntra}</Text> : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={{ marginBottom: 6 }}>{formatDate(props.issueDate)}</Text>
<Text style={styles.metaLabel}>Échéance</Text>
<Text>{formatDate(props.dueDate)}</Text>
<Text style={{ fontSize: 9, color: PALETTE.ink3, marginTop: 2 }}>
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''} de
délai
</Text>
</View>
</View>
{/* Table */}
<View style={styles.table}>
<View style={[styles.tableHeader, accentBg]}>
<Text style={[styles.cellDesc, styles.tableHeaderText]}>Désignation</Text>
<Text style={[styles.cellQty, styles.tableHeaderText]}>Qté</Text>
<Text style={[styles.cellPu, styles.tableHeaderText]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.tableHeaderText]}>TVA</Text>
<Text style={[styles.cellTotal, styles.tableHeaderText]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
<View style={styles.tvaHeader}>
<Text style={[styles.tvaCellLeft, styles.cellHead]}>Taux</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Base HT</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Montant TVA</Text>
</View>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>{formatTvaRate(b.rate)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={[styles.totalRowGrand, accentBg]}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={styles.grandAmount}>{formatCents(props.amountTtcCents)}</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Coordonnées de paiement</Text>
{props.rib.bankName ? <Text>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text>IBAN : {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text>BIC : {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Notes</Text>
<Text>{props.footerNotes}</Text>
</View>
) : null}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
</View>
</View>
</Page>
</Document>
)
}

View File

@ -1,28 +1,33 @@
/**
* invoice_pdf génération de PDF pour les factures natives.
*
* **Phase 1 stub.** L'implémentation réelle (templates @react-pdf/renderer
* + upload MinIO) arrive en Phase 2 avec packages/ui/invoice-templates/.
* Pour l'instant, `generateInvoicePdf` renvoie `null` (= pas de PDF stocké)
* et `previewInvoicePdf` throw `not_implemented` (501).
* Phase 2 : implémentation réelle via @react-pdf/renderer + upload MinIO.
*
* Le contrat de l'interface est figé pour que la Phase 2 soit un drop-in
* remplacement sans toucher au controller : on remplace le corps de ces
* fonctions par l'appel à `@react-pdf/renderer.renderToBuffer(...)` puis
* `media_storage.uploadBuffer(...)`.
* 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 { Exception } from '@adonisjs/core/exceptions'
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 {
/** L'invoice complet, snapshots compris. */
invoice: Invoice
/** Settings résolus (themeSlug, accentColor, issuer, rib…). */
// Le type complet est dans #services/invoice_settings → ResolvedInvoiceSettings,
// mais comme c'est un stub on garde unknown pour ne pas créer de couplage
// que Phase 2 devra de toute façon retravailler.
resolvedSettings: unknown
resolvedSettings: ResolvedInvoiceSettings
/** Org de l'invoice — utilisée pour le logo (brand_settings.logoUrl). */
organization: Organization
}
export interface GeneratedPdf {
@ -33,26 +38,73 @@ export interface GeneratedPdf {
}
/**
* Génère le PDF de la facture et l'upload sur MinIO. Stub Phase 1.
* Construit les props passées au template à partir d'une facture (potentiellement
* non-persistée, pour la preview) et des settings résolus.
*
* Phase 2 : renderToBuffer(<Theme {...props} />) uploadBuffer storageKey.
* 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é).
*/
export async function generateInvoicePdf(_ctx: InvoiceRenderContext): Promise<GeneratedPdf | null> {
// Phase 1 : pas de génération. Le controller persiste l'invoice avec
// pdfStorageKey=null et l'UI affichera "PDF en cours de génération"
// (ou un placeholder). La Phase 2 active la vraie génération.
return null
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,
}
}
/**
* Renvoie un buffer PDF pour preview (sans persister). Stub Phase 1.
*
* Phase 2 : même rendu que generateInvoicePdf, mais retourne le buffer
* directement au lieu d'uploader.
* Génère le PDF et l'upload sur MinIO. Retourne la storageKey à persister
* dans `invoices.pdf_storage_key`.
*/
export async function previewInvoicePdf(_ctx: InvoiceRenderContext): Promise<Buffer> {
throw new Exception('PDF preview not yet implemented (Phase 2)', {
status: 501,
code: 'not_implemented',
})
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)
}

View File

@ -34,7 +34,7 @@ import path from 'node:path'
import drive from '@adonisjs/drive/services/main'
import type { MultipartFile } from '@adonisjs/core/bodyparser'
export type MediaScope = 'blog' | 'brand-logo'
export type MediaScope = 'blog' | 'brand-logo' | 'invoice-pdf'
interface ScopeConfig {
/** Préfixe de stockage MinIO (clé S3). */
@ -62,6 +62,16 @@ const SCOPES: Record<MediaScope, ScopeConfig> = {
allowedExts: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
maxBytes: 1 * 1024 * 1024, // 1 MB — un logo n'a aucune raison d'être plus gros
},
'invoice-pdf': {
// Factures natives générées par Rubis (vs. uploads OCR qui restent dans
// un chemin distinct historique géré par l'ImportBatchesController).
// Stockées sous `invoices/<orgId>/<uuid>.pdf` — scope par org pour
// faciliter purge/migration future.
storagePrefix: 'invoices',
urlSegment: 'invoices',
allowedExts: ['pdf'],
maxBytes: 4 * 1024 * 1024, // 4 MB — une facture PDF dépasse rarement 200KB.
},
}
export interface UploadResult {
@ -157,6 +167,50 @@ export async function deleteMedia(storageKey: string): Promise<void> {
}
}
/**
* Upload un Buffer (généré en mémoire, ex. PDF rendu par @react-pdf) sur MinIO.
*
* Différence avec `uploadMedia(MultipartFile)` : pas de tmpPath à `moveFromFs`,
* on écrit directement le buffer via `drive.put`. Utilisé par la génération
* de factures natives (pas de upload depuis le client).
*
* Le caller fournit le `scope` (pour récupérer les contraintes) ET un
* sous-chemin optionnel (`subPath`) pour organiser le stockage. Pour les
* factures, on passe `subPath = orgId` `invoices/<orgId>/<uuid>.pdf`.
*/
export async function uploadBuffer(
buffer: Buffer,
scope: MediaScope,
subPath?: string
): Promise<UploadResult> {
const cfg = SCOPES[scope]
if (!cfg) throw new Error(`unknown_scope: ${scope}`)
if (cfg.allowedExts.length === 0) throw new Error(`scope ${scope} has no allowed extensions`)
// On force la première extension du scope — c'est cohérent avec l'usage
// (un scope = un format). Pour `invoice-pdf` c'est `pdf`.
const ext = cfg.allowedExts[0]
if (buffer.length > cfg.maxBytes) {
throw new Error(`file_too_large: ${buffer.length}B (max ${cfg.maxBytes}B)`)
}
const filename = `${randomUUID()}.${ext}`
const segments = subPath
? [cfg.storagePrefix, subPath, filename]
: [cfg.storagePrefix, filename]
const storageKey = segments.join('/')
await drive.use().put(storageKey, buffer)
const apiHost = (process.env.APP_URL || 'http://localhost:3333').replace(/\/$/, '')
return {
publicPath: `${apiHost}/api/v1/uploads/${cfg.urlSegment}/${filename}`,
storageKey,
contentType: extToContentType(ext),
sizeBytes: buffer.length,
}
}
function extToContentType(ext: string): string {
switch (ext) {
case 'jpg':
@ -168,6 +222,8 @@ function extToContentType(ext: string): string {
return 'image/webp'
case 'svg':
return 'image/svg+xml'
case 'pdf':
return 'application/pdf'
default:
return 'application/octet-stream'
}

View File

@ -22,6 +22,7 @@
"#exceptions/*": "./app/exceptions/*.js",
"#models/*": "./app/models/*.js",
"#mails/*": "./app/mails/*.js",
"#pdf-templates/*": "./app/pdf-templates/*.js",
"#services/*": "./app/services/*.js",
"#jobs/*": "./app/jobs/*.js",
"#listeners/*": "./app/listeners/*.js",