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:
parent
e0b47ddfdc
commit
ab07cd4a3b
@ -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')
|
||||
|
||||
289
apps/api/app/pdf-templates/classique.tsx
Normal file
289
apps/api/app/pdf-templates/classique.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
apps/api/app/pdf-templates/common.tsx
Normal file
148
apps/api/app/pdf-templates/common.tsx
Normal 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 }
|
||||
286
apps/api/app/pdf-templates/elegant.tsx
Normal file
286
apps/api/app/pdf-templates/elegant.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
apps/api/app/pdf-templates/index.tsx
Normal file
51
apps/api/app/pdf-templates/index.tsx
Normal 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 }
|
||||
230
apps/api/app/pdf-templates/minimal.tsx
Normal file
230
apps/api/app/pdf-templates/minimal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
apps/api/app/pdf-templates/moderne.tsx
Normal file
269
apps/api/app/pdf-templates/moderne.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user