import fs from "node:fs"; import path from "node:path"; import PDFDocument from "pdfkit"; const OUTPUT_DIR = path.resolve(process.cwd(), "exports/factures"); const OUTPUT_BASENAME = "facture-honnete-ccds-portail-ovh-2026-06-11"; const OUTPUT_PDF = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.pdf`); const OUTPUT_JSON = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.json`); const LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); const invoice = { status: "FACTURE A COMPLETER AVANT ENVOI", invoiceNumber: "FA-CCDS-2026-06-11-HONNETE", issueDate: "11/06/2026", dueDate: "A completer", issuer: { name: "Nom / raison sociale a completer", addressLines: ["Adresse a completer", "Code postal et ville a completer"], siret: "SIRET a completer", email: "Email a completer", phone: "Telephone a completer", vat: "Regime TVA a completer", }, recipient: { name: "Communaute de Communes des Savanes", addressLines: ["Quartier Cabalou", "1 rue Raymond Cresson", "97310 Kourou"], siret: "200 027 548 00029", }, project: { title: "Portail des associations CCDS", url: "https://www.portail-association973.com", }, lineItems: [ { label: "Realisation et finalisation du portail des associations CCDS", details: [ "Conception, developpement, integration, corrections et mise en coherence generale", "Projet concerne : https://www.portail-association973.com", ], quantity: "1", unitPrice: "6 000,00 EUR", total: "6 000,00 EUR", }, { label: "Location / hebergement des serveurs OVH", details: [ "Infrastructure OVH utilisee pour le projet", "Periode de facturation a completer", ], quantity: "1", unitPrice: "300,00 EUR", total: "300,00 EUR", }, ], totals: { subtotalHt: "6 300,00 EUR", vatAmount: "A verifier selon regime", totalTtc: "6 300,00 EUR si TVA non applicable", }, notes: [ "Facture volontairement sobre et centree sur le travail reellement fourni pour le portail et l'hebergement OVH.", "RIB / IBAN, regime TVA, echeance et periode exacte OVH a completer avant envoi.", ], }; function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } function drawLogo(doc, x, y, width, height) { if (!fs.existsSync(LOGO_PATH)) return; try { doc.image(LOGO_PATH, x, y, { fit: [width, height], align: "left", valign: "center" }); } catch {} } function drawBox(doc, x, y, width, height, fill = "#ffffff", stroke = "#cbd5e1") { doc.save(); doc.roundedRect(x, y, width, height, 8).fillAndStroke(fill, stroke); doc.restore(); } function label(doc, text, x, y, width) { doc.font("Helvetica-Bold").fontSize(9).fillColor("#64748b").text(text.toUpperCase(), x, y, { width }); } function value(doc, text, x, y, width, options = {}) { doc.font("Helvetica").fontSize(10.5).fillColor("#0f172a").text(text, x, y, { width, ...options }); } function heightOf(doc, text, width, fontName, fontSize) { doc.font(fontName).fontSize(fontSize); return doc.heightOfString(text, { width }); } async function render() { ensureDir(OUTPUT_DIR); fs.writeFileSync(OUTPUT_JSON, JSON.stringify(invoice, null, 2)); const doc = new PDFDocument({ size: "A4", margins: { top: 38, bottom: 42, left: 38, right: 38 }, info: { Title: "Facture honnête CCDS - portail et OVH", Author: "Codex", Subject: "Facture a completer pour la CCDS", }, }); const chunks = []; doc.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); const done = new Promise((resolve, reject) => { doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject); }); const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const left = doc.page.margins.left; let y = doc.page.margins.top; doc.save(); doc.rect(0, 0, doc.page.width, 26).fill("#fff7ed"); doc.restore(); doc.font("Helvetica-Bold").fontSize(10).fillColor("#c2410c").text(invoice.status, left, 8, { width: pageWidth, align: "center" }); drawLogo(doc, left, y + 8, 80, 80); doc.font("Helvetica-Bold").fontSize(24).fillColor("#0f172a").text("FACTURE", left + 96, y + 8, { width: 180 }); doc.font("Helvetica").fontSize(11).fillColor("#475569").text(invoice.project.title, left + 96, y + 40, { width: 240 }); doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text(invoice.project.url, left + 96, y + 58, { width: 280 }); const rightX = left + pageWidth - 220; drawBox(doc, rightX, y + 4, 220, 92, "#f8fafc"); label(doc, "Numero", rightX + 14, y + 16, 80); value(doc, invoice.invoiceNumber, rightX + 14, y + 30, 190); label(doc, "Date", rightX + 14, y + 52, 80); value(doc, invoice.issueDate, rightX + 14, y + 66, 70); label(doc, "Echeance", rightX + 112, y + 52, 80); value(doc, invoice.dueDate, rightX + 112, y + 66, 94); y += 116; const gap = 16; const half = (pageWidth - gap) / 2; drawBox(doc, left, y, half, 112); drawBox(doc, left + half + gap, y, half, 112, "#f8fafc"); label(doc, "Emetteur / prestataire", left + 14, y + 14, half - 28); value(doc, invoice.issuer.name, left + 14, y + 32, half - 28); value(doc, invoice.issuer.addressLines.join("\n"), left + 14, y + 48, half - 28); value(doc, `SIRET : ${invoice.issuer.siret}`, left + 14, y + 78, half - 28); value(doc, `${invoice.issuer.email} • ${invoice.issuer.phone}`, left + 14, y + 92, half - 28); const dx = left + half + gap + 14; label(doc, "Destinataire", dx, y + 14, half - 28); doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text(invoice.recipient.name, dx, y + 32, { width: half - 28 }); value(doc, invoice.recipient.addressLines.join("\n"), dx, y + 50, half - 28); value(doc, `SIRET : ${invoice.recipient.siret}`, dx, y + 92, half - 28); y += 134; drawBox(doc, left, y, pageWidth, 64, "#eff6ff", "#bfdbfe"); label(doc, "Objet", left + 14, y + 12, 120); doc.font("Helvetica-Bold").fontSize(12).fillColor("#1e3a8a").text( "Facturation du portail des associations CCDS et de la location des serveurs OVH", left + 14, y + 28, { width: pageWidth - 28 } ); y += 88; doc.font("Helvetica-Bold").fontSize(12).fillColor("#0f172a").text("Lignes de facturation", left, y); y += 18; const cols = { desc: pageWidth * 0.64, qty: pageWidth * 0.08, unit: pageWidth * 0.13, total: pageWidth * 0.15 }; doc.save(); doc.rect(left, y, pageWidth, 32).fill("#0f172a"); doc.restore(); doc.font("Helvetica-Bold").fontSize(9.5).fillColor("#ffffff"); doc.text("DESCRIPTION", left + 10, y + 11, { width: cols.desc - 20 }); doc.text("QTE", left + cols.desc, y + 11, { width: cols.qty, align: "center" }); doc.text("PRIX", left + cols.desc + cols.qty, y + 11, { width: cols.unit, align: "center" }); doc.text("TOTAL", left + cols.desc + cols.qty + cols.unit, y + 11, { width: cols.total, align: "center" }); y += 32; for (const item of invoice.lineItems) { const details = item.details.map((detail) => `- ${detail}`).join("\n"); const width = cols.desc - 20; const titleH = heightOf(doc, item.label, width, "Helvetica-Bold", 10.5); const detailsH = heightOf(doc, details, width, "Helvetica", 9.5); const detailsY = y + 12 + titleH + 6; const rowH = Math.max(78, 12 + titleH + 6 + detailsH + 14); [0, cols.desc, cols.desc + cols.qty, cols.desc + cols.qty + cols.unit].forEach((offset, index) => { const w = index === 0 ? cols.desc : index === 1 ? cols.qty : index === 2 ? cols.unit : cols.total; doc.rect(left + offset, y, w, rowH).stroke("#cbd5e1"); }); doc.font("Helvetica-Bold").fontSize(10.5).fillColor("#0f172a").text(item.label, left + 10, y + 10, { width }); doc.font("Helvetica").fontSize(9.5).fillColor("#475569").text(details, left + 10, detailsY, { width }); doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a") .text(item.quantity, left + cols.desc, y + rowH / 2 - 7, { width: cols.qty, align: "center" }) .text(item.unitPrice, left + cols.desc + cols.qty + 4, y + rowH / 2 - 7, { width: cols.unit - 8, align: "center" }) .text(item.total, left + cols.desc + cols.qty + cols.unit + 4, y + rowH / 2 - 7, { width: cols.total - 8, align: "center" }); y += rowH; } y += 16; const totalsW = 220; const totalsX = left + pageWidth - totalsW; drawBox(doc, totalsX, y, totalsW, 92, "#f8fafc"); [ ["Sous-total HT", invoice.totals.subtotalHt], ["TVA", invoice.totals.vatAmount], ["Total TTC", invoice.totals.totalTtc], ].forEach(([l, v], i) => { const ty = y + 16 + i * 24; doc.font(i === 2 ? "Helvetica-Bold" : "Helvetica").fontSize(i === 2 ? 11.5 : 10).fillColor("#0f172a"); doc.text(l, totalsX + 14, ty, { width: 100 }); doc.text(v, totalsX + 110, ty, { width: 96, align: "right" }); }); y += 112; drawBox(doc, left, y, pageWidth, 88, "#fff7ed", "#fdba74"); label(doc, "Notes", left + 14, y + 14, 100); doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text(invoice.notes.join("\n\n"), left + 14, y + 32, { width: pageWidth - 28 }); y += 110; doc.font("Helvetica").fontSize(9).fillColor("#64748b").text( "Version preparee pour la CCDS. Verifier avant envoi les informations emetteur, la TVA, l'echeance et le RIB.", left, y, { width: pageWidth, align: "center" } ); doc.end(); const buffer = await done; fs.writeFileSync(OUTPUT_PDF, buffer); process.stdout.write(JSON.stringify({ ok: true, pdf: OUTPUT_PDF, json: OUTPUT_JSON }, null, 2)); } render().catch((error) => { process.stderr.write(String(error?.stack || error)); process.exit(1); });