portail-associations/scripts/generate_ccds_invoice_draft.mjs

301 lines
12 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-projet-ccds-portail-associations-6300-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: "PROJET DE FACTURE A VERIFIER AVANT ENVOI",
invoiceNumber: "FA-CCDS-2026-06-11-6300",
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",
reference: "Facture adressee a la CCDS",
},
project: {
title: "Portail des associations CCDS",
url: "https://www.portail-association973.com",
},
lineItems: [
{
label: "Conception, developpement, integration et finalisation du portail web",
details: [
"Projet realise pour le portail des associations de la CCDS",
"Travail incluant structuration fonctionnelle, developpements, corrections, ajustements metier et mise en coherence generale du site",
],
quantity: "1",
unitPrice: "4 500,00 EUR",
total: "4 500,00 EUR",
},
{
label: "Deploiement, exploitation technique, optimisation continue et accompagnement a la mise en production",
details: [
"Mises a jour successives, stabilisation, finalisation des workflows, mise en place de la preproduction et du tour operationnel",
],
quantity: "1",
unitPrice: "1 500,00 EUR",
total: "1 500,00 EUR",
},
{
label: "Location / hebergement des serveurs OVH",
details: [
"Portail heberge sur infrastructure OVH",
"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: [
"Projet initie avec Manus IA puis repris et approfondi sous Codex jusqu'a un niveau de quasi finalisation.",
"La montee en competence technique a ete facilitee par William, ingenieur informatique, qui a accompagne l'usage de Manus IA et l'appropriation de termes techniques pour accelerer l'execution.",
"Par honnetete de facturation, cet accompagnement n'est pas facture ici comme ligne separee sauf accord explicite contraire.",
"Chiffrage retenu pour ce projet : 4 500,00 EUR pour la conception / developpement du portail, 1 500,00 EUR pour le deploiement / finalisation / accompagnement technique, 300,00 EUR pour l'hebergement OVH.",
"RIB / IBAN, regime TVA et modalites de reglement a joindre dans la version finale.",
],
};
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 {
// Keep the invoice usable even if the logo cannot be drawn.
}
}
function drawMutedLabel(doc, text, x, y, width) {
doc.font("Helvetica-Bold").fontSize(9).fillColor("#64748b").text(text.toUpperCase(), x, y, { width });
}
function drawValue(doc, text, x, y, width, options = {}) {
doc.font("Helvetica").fontSize(10.5).fillColor("#0f172a").text(text, x, y, { width, ...options });
}
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 getTextHeight(doc, text, width, fontName, fontSize) {
doc.font(fontName).fontSize(fontSize);
return doc.heightOfString(text, { width });
}
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 brouillon CCDS - Portail des associations",
Author: "Codex",
Subject: "Facture brouillon 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 pageLeft = 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, pageLeft, 8, {
width: pageWidth,
align: "center",
});
drawLogo(doc, pageLeft, y + 6, 88, 88);
doc.font("Helvetica-Bold").fontSize(24).fillColor("#0f172a").text("FACTURE", pageLeft + 100, y + 8, { width: 220 });
doc.font("Helvetica").fontSize(11).fillColor("#475569").text("Projet Portail des associations CCDS", pageLeft + 100, y + 40, { width: 260 });
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text(invoice.project.url, pageLeft + 100, y + 58, { width: 280 });
const headerBoxX = pageLeft + pageWidth - 220;
drawBox(doc, headerBoxX, y + 4, 220, 92, "#f8fafc");
drawMutedLabel(doc, "Numero", headerBoxX + 14, y + 16, 80);
drawValue(doc, invoice.invoiceNumber, headerBoxX + 14, y + 30, 192);
drawMutedLabel(doc, "Date", headerBoxX + 14, y + 52, 80);
drawValue(doc, invoice.issueDate, headerBoxX + 14, y + 66, 80);
drawMutedLabel(doc, "Echeance", headerBoxX + 112, y + 52, 80);
drawValue(doc, invoice.dueDate, headerBoxX + 112, y + 66, 94);
y += 116;
const halfGap = 16;
const halfWidth = (pageWidth - halfGap) / 2;
const leftBoxHeight = 112;
const rightBoxHeight = 112;
drawBox(doc, pageLeft, y, halfWidth, leftBoxHeight, "#ffffff");
drawBox(doc, pageLeft + halfWidth + halfGap, y, halfWidth, rightBoxHeight, "#f8fafc");
drawMutedLabel(doc, "Emetteur / prestataire", pageLeft + 14, y + 14, halfWidth - 28);
drawValue(doc, invoice.issuer.name, pageLeft + 14, y + 32, halfWidth - 28);
drawValue(doc, invoice.issuer.addressLines.join("\n"), pageLeft + 14, y + 48, halfWidth - 28);
drawValue(doc, `SIRET : ${invoice.issuer.siret}`, pageLeft + 14, y + 78, halfWidth - 28);
drawValue(doc, `${invoice.issuer.email}${invoice.issuer.phone}`, pageLeft + 14, y + 92, halfWidth - 28);
const recipientX = pageLeft + halfWidth + halfGap + 14;
drawMutedLabel(doc, "Destinataire", recipientX, y + 14, halfWidth - 28);
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text(invoice.recipient.name, recipientX, y + 32, {
width: halfWidth - 28,
});
drawValue(doc, invoice.recipient.addressLines.join("\n"), recipientX, y + 50, halfWidth - 28);
drawValue(doc, `SIRET : ${invoice.recipient.siret}`, recipientX, y + 92, halfWidth - 28);
y += 134;
drawBox(doc, pageLeft, y, pageWidth, 70, "#eff6ff", "#bfdbfe");
drawMutedLabel(doc, "Objet", pageLeft + 14, y + 12, 120);
doc.font("Helvetica-Bold").fontSize(12).fillColor("#1e3a8a").text(
"Facturation du travail realise sur le projet portail-association973.com et des frais d'hebergement OVH",
pageLeft + 14,
y + 28,
{ width: pageWidth - 28 }
);
y += 92;
doc.font("Helvetica-Bold").fontSize(12).fillColor("#0f172a").text("Lignes de facturation", pageLeft, y);
y += 18;
const columns = {
desc: pageWidth * 0.62,
qty: pageWidth * 0.08,
unit: pageWidth * 0.14,
total: pageWidth * 0.16,
};
const headerHeight = 32;
doc.save();
doc.rect(pageLeft, y, pageWidth, headerHeight).fill("#0f172a");
doc.restore();
doc.font("Helvetica-Bold").fontSize(9.5).fillColor("#ffffff");
doc.text("DESCRIPTION", pageLeft + 10, y + 11, { width: columns.desc - 20 });
doc.text("QTE", pageLeft + columns.desc, y + 11, { width: columns.qty, align: "center" });
doc.text("PRIX", pageLeft + columns.desc + columns.qty, y + 11, { width: columns.unit, align: "center" });
doc.text("TOTAL", pageLeft + columns.desc + columns.qty + columns.unit, y + 11, { width: columns.total, align: "center" });
y += headerHeight;
for (const item of invoice.lineItems) {
const detailsText = item.details.map((detail) => `- ${detail}`).join("\n");
const contentWidth = columns.desc - 20;
const labelHeight = getTextHeight(doc, item.label, contentWidth, "Helvetica-Bold", 10.5);
const detailsHeight = getTextHeight(doc, detailsText, contentWidth, "Helvetica", 9.5);
const detailsY = y + 12 + labelHeight + 6;
const descHeight = 12 + labelHeight + 6 + detailsHeight + 14;
const rowHeight = Math.max(78, descHeight);
[0, columns.desc, columns.desc + columns.qty, columns.desc + columns.qty + columns.unit].forEach((offset, index) => {
const width = index === 0
? columns.desc
: index === 1
? columns.qty
: index === 2
? columns.unit
: columns.total;
doc.rect(pageLeft + offset, y, width, rowHeight).stroke("#cbd5e1");
});
doc.font("Helvetica-Bold").fontSize(10.5).fillColor("#0f172a").text(item.label, pageLeft + 10, y + 10, {
width: contentWidth,
});
doc.font("Helvetica").fontSize(9.5).fillColor("#475569").text(detailsText, pageLeft + 10, detailsY, {
width: contentWidth,
});
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a")
.text(item.quantity, pageLeft + columns.desc, y + rowHeight / 2 - 7, { width: columns.qty, align: "center" })
.text(item.unitPrice, pageLeft + columns.desc + columns.qty + 4, y + rowHeight / 2 - 7, { width: columns.unit - 8, align: "center" })
.text(item.total, pageLeft + columns.desc + columns.qty + columns.unit + 4, y + rowHeight / 2 - 7, { width: columns.total - 8, align: "center" });
y += rowHeight;
}
y += 16;
const totalsBoxWidth = 220;
const totalsBoxX = pageLeft + pageWidth - totalsBoxWidth;
drawBox(doc, totalsBoxX, y, totalsBoxWidth, 92, "#f8fafc");
const totalRows = [
["Sous-total HT", invoice.totals.subtotalHt],
["TVA", invoice.totals.vatAmount],
["Total TTC", invoice.totals.totalTtc],
];
let totalY = y + 16;
totalRows.forEach(([label, value], index) => {
doc.font(index === 2 ? "Helvetica-Bold" : "Helvetica").fontSize(index === 2 ? 11.5 : 10).fillColor("#0f172a");
doc.text(label, totalsBoxX + 14, totalY, { width: 100 });
doc.text(value, totalsBoxX + 110, totalY, { width: 96, align: "right" });
totalY += 24;
});
y += 112;
drawBox(doc, pageLeft, y, pageWidth, 138, "#fff7ed", "#fdba74");
drawMutedLabel(doc, "Note de contexte et d'honnetete", pageLeft + 14, y + 14, pageWidth - 28);
doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text(
invoice.notes.join("\n\n"),
pageLeft + 14,
y + 32,
{ width: pageWidth - 28 }
);
y += 156;
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text(
"Document de travail genere pour preparation d'une facture finale. Les montants, coordonnees de l'emetteur, regime TVA et modalites de reglement doivent etre verifies avant envoi.",
pageLeft,
y,
{ width: pageWidth, align: "center" }
);
doc.end();
return done.then((buffer) => {
fs.writeFileSync(OUTPUT_PDF, buffer);
return { pdf: OUTPUT_PDF, json: OUTPUT_JSON };
});
}
render()
.then(({ pdf, json }) => {
process.stdout.write(JSON.stringify({ ok: true, pdf, json }, null, 2));
})
.catch((error) => {
process.stderr.write(String(error?.stack || error));
process.exitCode = 1;
});