227 lines
11 KiB
JavaScript
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());
|
|
}
|