Files
SBC/server/src/billingDocuments.js
T

227 lines
11 KiB
JavaScript

import PDFDocument from 'pdfkit';
import ExcelJS from 'exceljs';
const RED = '#C1272D';
const INK = '#1B1B1B';
const GRAY = '#6E675F';
const LIGHT = '#F4F1EC';
const text = (value) => String(value ?? '');
const money = (value) => `${Number(value || 0).toFixed(2).replace('.', ',')} EUR`;
const dateFr = (value) => {
if (!value) return '';
const raw = value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10);
const [year, month, day] = raw.split('-');
return `${day}/${month}/${year}`;
};
const typeLabel = (value) => value === 'sluc_partner' ? 'Partenaire SLUC' : 'Non partenaire SLUC';
const paymentLabel = (value) => ({ carte: 'Carte bleue', virement: 'Virement', cheque: 'Chèque' }[value] || '');
function collectPdf(draw, options = {}) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, ...options });
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
draw(doc);
const range = doc.bufferedPageRange();
for (let page = range.start; page < range.start + range.count; page += 1) {
doc.switchToPage(page);
const bottomMargin = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
doc.fontSize(8).fillColor('#8A8279').text(
`Page ${page - range.start + 1} / ${range.count}`,
48,
doc.page.height - 24,
{ width: doc.page.width - 96, align: 'right', lineBreak: false }
);
doc.page.margins.bottom = bottomMargin;
}
doc.end();
});
}
function drawMultiline(doc, value, x, y, width, options = {}) {
doc.text(text(value), x, y, { width, lineGap: 2, ...options });
}
export function buildInvoicePdf(invoice) {
const issuer = invoice.issuer_snapshot || {};
return collectPdf((doc) => {
doc.fillColor(INK).font('Helvetica-Bold').fontSize(21).text(text(issuer.association_name || 'Association'), 48, 48, { width: 310 });
doc.fillColor(RED).fontSize(28).text('FACTURE', 365, 45, { width: 180, align: 'right' });
doc.moveTo(48, 84).lineTo(547, 84).lineWidth(2).strokeColor(RED).stroke();
doc.font('Helvetica-Bold').fontSize(10).fillColor(GRAY).text('ÉMETTEUR', 48, 108);
doc.font('Helvetica').fontSize(10).fillColor(INK);
drawMultiline(doc, issuer.association_name, 48, 126, 215);
drawMultiline(doc, issuer.association_address, 48, 143, 215);
if (issuer.association_email) drawMultiline(doc, issuer.association_email, 48, 178, 215);
if (issuer.association_phone) drawMultiline(doc, issuer.association_phone, 48, 194, 215);
if (issuer.association_siret) doc.text(`SIRET : ${issuer.association_siret}`, 48, 210, { width: 215 });
doc.font('Helvetica-Bold').fontSize(10).fillColor(GRAY).text('DESTINATAIRE', 310, 108);
doc.font('Helvetica-Bold').fontSize(11).fillColor(INK).text(text(invoice.member_name), 310, 126, { width: 237 });
doc.font('Helvetica').fontSize(10);
drawMultiline(doc, invoice.member_address, 310, 145, 237);
drawMultiline(doc, invoice.member_email, 310, 185, 237);
doc.roundedRect(48, 240, 499, 58, 4).fill(LIGHT);
const meta = [
['N° de facture', invoice.invoice_number],
['Date d’émission', dateFr(invoice.issued_at)],
['Date d’échéance', dateFr(invoice.due_date)],
];
meta.forEach(([label, value], index) => {
const x = 62 + index * 164;
doc.font('Helvetica').fontSize(8).fillColor(GRAY).text(label.toUpperCase(), x, 253, { width: 145 });
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(text(value), x, 270, { width: 145 });
});
const tableY = 332;
doc.rect(48, tableY, 499, 30).fill(INK);
doc.font('Helvetica-Bold').fontSize(9).fillColor('#FFFFFF');
doc.text('DÉSIGNATION', 60, tableY + 10, { width: 275 });
doc.text('HT', 350, tableY + 10, { width: 80, align: 'right' });
doc.text('TVA', 445, tableY + 10, { width: 88, align: 'right' });
doc.rect(48, tableY + 30, 499, 58).fill('#FAF8F5');
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(`Adhésion ${invoice.season}`, 60, tableY + 45, { width: 270 });
doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(typeLabel(invoice.billing_type), 60, tableY + 62, { width: 270 });
doc.font('Helvetica').fontSize(10).fillColor(INK).text(money(invoice.amount_ht), 350, tableY + 50, { width: 80, align: 'right' });
doc.text(`${Number(invoice.vat_rate).toFixed(2).replace('.', ',')} %`, 445, tableY + 50, { width: 88, align: 'right' });
const totalsY = 430;
const totals = [
['Total HT', invoice.amount_ht],
['TVA', invoice.vat_amount],
['Total TTC', invoice.amount_ttc],
];
totals.forEach(([label, value], index) => {
const y = totalsY + index * 27;
if (index === 2) doc.roundedRect(337, y - 7, 210, 28, 3).fill(RED);
doc.font('Helvetica-Bold').fontSize(index === 2 ? 11 : 10).fillColor(index === 2 ? '#FFFFFF' : INK)
.text(label, 350, y, { width: 90 })
.text(money(value), 440, y, { width: 94, align: 'right' });
});
let y = 540;
if (invoice.status === 'payee') {
doc.roundedRect(48, y, 499, 46, 4).fill('#E8F3EC');
doc.font('Helvetica-Bold').fontSize(11).fillColor('#22623E').text('FACTURE RÉGLÉE', 62, y + 10);
doc.font('Helvetica').fontSize(9).text(`${paymentLabel(invoice.payment_method)} - ${dateFr(invoice.paid_at)}`, 62, y + 27);
y += 65;
} else {
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text('RÈGLEMENT', 48, y);
doc.font('Helvetica').fontSize(9.5).fillColor(GRAY).text(`À régler avant le ${dateFr(invoice.due_date)}.`, 48, y + 18);
if (issuer.iban) doc.text(`IBAN : ${issuer.iban}`, 48, y + 36, { width: 499 });
y += issuer.iban ? 72 : 54;
}
if (issuer.legal_mentions) {
if (y > 690) {
doc.addPage();
y = 60;
}
doc.moveTo(48, y).lineTo(547, y).lineWidth(1).strokeColor('#D9D3CB').stroke();
doc.font('Helvetica-Bold').fontSize(8).fillColor(GRAY).text('MENTIONS LÉGALES', 48, y + 14);
doc.font('Helvetica').fontSize(8).fillColor(GRAY).text(text(issuer.legal_mentions), 48, y + 29, { width: 499, lineGap: 2 });
}
}, { info: { Title: `Facture ${invoice.invoice_number}`, Author: text(invoice.issuer_snapshot?.association_name) } });
}
export function buildPaidMembersPdf({ season, rows }) {
return collectPdf((doc) => {
const pageWidth = doc.page.width;
const left = 36;
const widths = [90, 150, 90, 70, 75, 70, 70, 75];
const headers = ['Facture', 'Entreprise', 'Type', 'Date', 'Règlement', 'HT', 'TVA', 'TTC'];
const drawHeader = () => {
doc.font('Helvetica-Bold').fontSize(20).fillColor(INK).text(`Adhésions réglées - ${season}`, left, 36);
doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(`${rows.length} membre${rows.length > 1 ? 's' : ''} à jour`, left, 64);
let x = left;
doc.rect(left, 88, pageWidth - left * 2, 26).fill(INK);
headers.forEach((header, index) => {
doc.font('Helvetica-Bold').fontSize(7.5).fillColor('#FFFFFF').text(header.toUpperCase(), x + 4, 97, { width: widths[index] - 8, align: index >= 5 ? 'right' : 'left' });
x += widths[index];
});
return 114;
};
let y = drawHeader();
rows.forEach((row, rowIndex) => {
if (y > 535) {
doc.addPage();
y = drawHeader();
}
if (rowIndex % 2 === 0) doc.rect(left, y, pageWidth - left * 2, 29).fill('#FAF8F5');
const values = [
row.invoice_number,
row.member_name,
typeLabel(row.billing_type),
dateFr(row.paid_at),
paymentLabel(row.payment_method),
money(row.amount_ht),
money(row.vat_amount),
money(row.amount_ttc),
];
let x = left;
values.forEach((value, index) => {
doc.font('Helvetica').fontSize(7.5).fillColor(INK).text(text(value), x + 4, y + 10, { width: widths[index] - 8, align: index >= 5 ? 'right' : 'left', ellipsis: true });
x += widths[index];
});
y += 29;
});
const total = rows.reduce((sum, row) => sum + Number(row.amount_ttc), 0);
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(`Total encaissé : ${money(total)}`, left, y + 18, { width: pageWidth - left * 2, align: 'right' });
}, { size: 'A4', layout: 'landscape', margin: 36, info: { Title: `Adhésions réglées ${season}` } });
}
export async function buildPaidMembersWorkbook({ season, rows }) {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'SBC - Module facturation';
workbook.created = new Date();
const sheet = workbook.addWorksheet('Adhésions payées', { views: [{ state: 'frozen', ySplit: 5 }] });
sheet.mergeCells('A1:H1');
sheet.getCell('A1').value = `Adhésions réglées - Saison ${season}`;
sheet.getCell('A1').font = { bold: true, size: 18, color: { argb: 'FF1B1B1B' } };
sheet.getCell('A2').value = 'Membres à jour';
sheet.getCell('B2').value = rows.length;
sheet.getCell('D2').value = 'Total encaissé TTC';
sheet.getCell('E2').value = { formula: rows.length ? `SUM(H6:H${5 + rows.length})` : '0' };
sheet.getCell('E2').numFmt = '#,##0.00 [$EUR]';
sheet.getRow(5).values = ['N° facture', 'Entreprise', 'Type', 'Date de règlement', 'Mode de règlement', 'Montant HT', 'TVA', 'Montant TTC'];
sheet.getRow(5).height = 24;
sheet.getRow(5).eachCell((cell) => {
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1B1B1B' } };
cell.alignment = { vertical: 'middle' };
});
rows.forEach((row) => {
sheet.addRow([
row.invoice_number,
row.member_name,
typeLabel(row.billing_type),
row.paid_at ? new Date(`${String(row.paid_at).slice(0, 10)}T00:00:00Z`) : null,
paymentLabel(row.payment_method),
Number(row.amount_ht),
Number(row.vat_amount),
Number(row.amount_ttc),
]);
});
const lastDataRow = 5 + rows.length;
if (rows.length) {
sheet.autoFilter = { from: 'A5', to: `H${lastDataRow}` };
for (let row = 6; row <= lastDataRow; row += 1) {
sheet.getCell(`D${row}`).numFmt = 'dd/mm/yyyy';
['F', 'G', 'H'].forEach((column) => { sheet.getCell(`${column}${row}`).numFmt = '#,##0.00 [$EUR]'; });
if (row % 2 === 0) {
sheet.getRow(row).eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFAF8F5' } }; });
}
}
}
const widths = [20, 34, 24, 20, 22, 16, 16, 18];
widths.forEach((width, index) => { sheet.getColumn(index + 1).width = width; });
sheet.getColumn(2).alignment = { wrapText: true, vertical: 'top' };
return Buffer.from(await workbook.xlsx.writeBuffer());
}