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

290 lines
11 KiB
TypeScript

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