import PDFDocument from "pdfkit"; import fs from "node:fs"; import path from "node:path"; import type { SallePricingSummary } from "@shared/sallePricing"; const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); function formatCurrency(cents: number) { return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100); } function formatDateFr(date: string | Date | null | undefined) { if (!date) return "-"; try { return new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", }); } catch { return String(date); } } function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { if (!fs.existsSync(CCDS_LOGO_PATH)) return; try { doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); } catch (error) { console.warn("[SalleInvoicePdf] Impossible de charger le logo CCDS:", error); } } export async function generateSalleInvoicePdf(input: { requestId: number; requestTitle: string; associationName: string; datesLabel: string; pricing: SallePricingSummary; }) { const pdfBuffer = await new Promise((resolve, reject) => { try { const doc = new PDFDocument({ size: "A4", margins: { top: 36, bottom: 42, left: 34, right: 34 }, }); const chunks: Buffer[] = []; doc.on("data", (chunk: Buffer) => chunks.push(chunk)); doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject); const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; let y = doc.page.margins.top; drawLogo(doc, doc.page.margins.left, y, 180, 70); doc.font("Helvetica-Bold").fontSize(18).fillColor("#244f7a").text( `Facture N° ${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`, doc.page.margins.left, y + 34, { width, align: "right" } ); y += 82; const infoRows: Array<[string, string]> = [ ["Date", formatDateFr(new Date())], ["Référence dossier", `Réservation salle #${input.requestId}`], ["Émis par", "Maison de la Jeunesse des Savanes - CCDS"], ["Association", input.associationName], ["Période", input.datesLabel], ]; const labelWidth = 160; infoRows.forEach(([label, value]) => { doc.rect(doc.page.margins.left, y, labelWidth, 36).stroke("#d9d9d9"); doc.rect(doc.page.margins.left + labelWidth, y, width - labelWidth, 36).stroke("#d9d9d9"); doc.font("Helvetica-Bold").fontSize(10).fillColor("#404040").text(label, doc.page.margins.left + 10, y + 12, { width: labelWidth - 20 }); doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(value, doc.page.margins.left + labelWidth + 10, y + 12, { width: width - labelWidth - 20, }); y += 36; }); y += 26; const col1 = width * 0.52; const col2 = width * 0.13; const col3 = width * 0.17; const col4 = width - col1 - col2 - col3; doc.rect(doc.page.margins.left, y, width, 40).fillAndStroke("#244f7a", "#244f7a"); doc.font("Helvetica-Bold").fontSize(10).fillColor("#ffffff") .text("DESCRIPTION", doc.page.margins.left + 14, y + 14, { width: col1 - 20 }) .text("QTÉ", doc.page.margins.left + col1 + 10, y + 14, { width: col2 - 20, align: "center" }) .text("PRIX UNITAIRE", doc.page.margins.left + col1 + col2 + 10, y + 14, { width: col3 - 20, align: "center" }) .text("TOTAL", doc.page.margins.left + col1 + col2 + col3 + 10, y + 14, { width: col4 - 20, align: "center" }); y += 40; input.pricing.lineItems.forEach((line) => { const rowHeight = 88; doc.rect(doc.page.margins.left, y, col1, rowHeight).stroke("#d9d9d9"); doc.rect(doc.page.margins.left + col1, y, col2, rowHeight).stroke("#d9d9d9"); doc.rect(doc.page.margins.left + col1 + col2, y, col3, rowHeight).stroke("#d9d9d9"); doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, rowHeight).stroke("#d9d9d9"); doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text( `Location ${line.salleNom}`, doc.page.margins.left + 14, y + 12, { width: col1 - 24 } ); doc.font("Helvetica").fontSize(10).fillColor("#334155").text( `${line.categoryLabel}\n${input.datesLabel}\n${input.requestTitle}`, doc.page.margins.left + 14, y + 32, { width: col1 - 24 } ); doc.font("Helvetica-Bold").fontSize(12).fillColor("#1f2937") .text(String(line.quantity), doc.page.margins.left + col1, y + 34, { width: col2, align: "center" }) .text(formatCurrency(line.unitAmountCents), doc.page.margins.left + col1 + col2, y + 34, { width: col3, align: "center" }) .text(formatCurrency(line.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 34, { width: col4, align: "center" }); y += rowHeight; }); doc.rect(doc.page.margins.left, y, col1 + col2 + col3, 44).stroke("#244f7a"); doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, 44).stroke("#244f7a"); doc.font("Helvetica-Bold").fontSize(12).fillColor("#244f7a") .text("TOTAL À RÉGLER", doc.page.margins.left, y + 14, { width: col1 + col2 + col3 - 16, align: "right" }) .fillColor("#c2410c") .text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 14, { width: col4, align: "center" }); y += 60; doc.roundedRect(doc.page.margins.left, y, width, 110, 8).fillAndStroke("#f8fafc", "#cbd5e1"); doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text("Récapitulatif pour l’envoi final", doc.page.margins.left + 16, y + 14); doc.font("Helvetica").fontSize(10).fillColor("#475569") .text("Cette facture accompagne la validation finale de la réservation. Le règlement est attendu selon les modalités précisées par la Maison de la Jeunesse des Savanes.", doc.page.margins.left + 16, y + 36, { width: width - 32, }) .text("RIB MJS : à joindre ou communiquer depuis l’accueil si nécessaire.", doc.page.margins.left + 16, y + 72, { width: width - 32, }); doc.end(); } catch (error) { reject(error); } }); return { buffer: pdfBuffer, fileName: `facture-salle-ccds-${input.requestId}.pdf`, }; }