portail-associations/scripts/generate_ccds_invoice_simple.mjs

242 lines
9.5 KiB
JavaScript

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);
});