Adapte les factures avec le logo et sans TVA

This commit is contained in:
Michael committed 2026-07-16 22:39:26 +02:00
1 parent 942bc70865
commit a9e01de7a8
5 files changed
+138 -120

No files matched your search

+107 -86
View File
@@ -1,10 +1,20 @@
import PDFDocument from 'pdfkit';
import ExcelJS from 'exceljs';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const RED = '#C1272D';
const INK = '#1B1B1B';
const GRAY = '#6E675F';
const LIGHT = '#F4F1EC';
const VAT_EXEMPTION = 'TVA non applicable, article 293 B du CGI';
const DEFAULT_PAYMENT_TERMS = 'Paiement à réception de facture. Aucun escompte pour règlement anticipé. En cas de retard, une indemnité forfaitaire de 40 EUR pour frais de recouvrement est due.';
const logoCandidates = [
fileURLToPath(new URL('../public/assets/logo.jpg', import.meta.url)),
fileURLToPath(new URL('../../web/public/assets/logo.jpg', import.meta.url)),
];
const logoPath = logoCandidates.find(existsSync);
const text = (value) => String(value ?? '');
const money = (value) => `${Number(value || 0).toFixed(2).replace('.', ',')} EUR`;
@@ -49,83 +59,98 @@ function drawMultiline(doc, value, x, y, width, 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();
if (logoPath) doc.image(logoPath, 48, 38, { fit: [92, 92], align: 'center', valign: 'center' });
else doc.font('Helvetica-Bold').fontSize(17).fillColor(INK).text(text(issuer.association_name || 'Association'), 48, 56, { width: 210 });
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(12).fillColor(INK)
.text(text(issuer.association_name || 'Association'), 300, 44, { width: 247, align: 'right' });
doc.font('Helvetica').fontSize(8.5).fillColor(GRAY);
drawMultiline(doc, issuer.association_address, 300, 63, 247, { align: 'right', height: 31, ellipsis: true });
if (issuer.association_email) doc.text(issuer.association_email, 300, 98, { width: 247, align: 'right' });
if (issuer.association_phone) doc.text(issuer.association_phone, 300, 111, { width: 247, align: 'right' });
if (issuer.association_siret) doc.text(`SIRET : ${issuer.association_siret}`, 300, 124, { width: 247, align: 'right' });
doc.moveTo(48, 145).lineTo(547, 145).lineWidth(2).strokeColor(RED).stroke();
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.font('Helvetica-Bold').fontSize(30).fillColor(INK).text('FACTURE', 48, 168, { width: 220 });
doc.roundedRect(310, 161, 237, 70, 4).fill(LIGHT);
doc.font('Helvetica').fontSize(8).fillColor(GRAY).text('NUMÉRO', 325, 174, { width: 95 });
doc.text('DATE D’ÉMISSION', 430, 174, { width: 102, align: 'right' });
doc.font('Helvetica-Bold').fontSize(10.5).fillColor(INK).text(text(invoice.invoice_number), 325, 192, { width: 110 });
doc.text(dateFr(invoice.issued_at), 430, 192, { width: 102, align: 'right' });
doc.font('Helvetica').fontSize(8).fillColor(GRAY).text(`Échéance : ${dateFr(invoice.due_date)}`, 325, 212, { width: 207 });
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 });
});
doc.font('Helvetica-Bold').fontSize(8).fillColor(RED).text('FACTURÉ À', 310, 252, { width: 237 });
doc.font('Helvetica-Bold').fontSize(12).fillColor(INK).text(text(invoice.member_name), 310, 270, { width: 237 });
doc.font('Helvetica').fontSize(9).fillColor(GRAY);
drawMultiline(doc, invoice.member_address, 310, 290, 237, { height: 31, ellipsis: true });
if (invoice.member_email) doc.text(invoice.member_email, 310, 326, { width: 237 });
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' });
doc.font('Helvetica-Bold').fontSize(11).fillColor(INK)
.text(`Cotisation annuelle - Saison ${invoice.season}`, 48, 356, { width: 499 });
doc.font('Helvetica').fontSize(8.5).fillColor(GRAY)
.text(`${text(issuer.association_name)} - ${text(invoice.member_name)}`, 48, 374, { width: 499 });
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' });
});
const tableY = 403;
doc.rect(48, tableY, 499, 29).fill(INK);
doc.font('Helvetica-Bold').fontSize(8).fillColor('#FFFFFF');
doc.text('QTÉ', 60, tableY + 10, { width: 38 });
doc.text('DÉSIGNATION', 112, tableY + 10, { width: 260 });
doc.text('PRIX UNITAIRE', 382, tableY + 10, { width: 72, align: 'right' });
doc.text('MONTANT', 466, tableY + 10, { width: 67, align: 'right' });
doc.rect(48, tableY + 29, 499, 64).fill('#FAF8F5');
doc.font('Helvetica').fontSize(10).fillColor(INK).text('1', 60, tableY + 50, { width: 38 });
doc.font('Helvetica-Bold').text('Adhésion à l’association', 112, tableY + 44, { width: 250 });
doc.font('Helvetica').fontSize(8.5).fillColor(GRAY).text(typeLabel(invoice.billing_type), 112, tableY + 61, { width: 250 });
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(money(invoice.amount_ht), 382, tableY + 50, { width: 72, align: 'right' });
doc.text(money(invoice.amount_ht), 466, tableY + 50, { width: 67, 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;
doc.roundedRect(337, 513, 210, 42, 4).fill(RED);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#FFFFFF').text('NET À PAYER', 351, 529, { width: 100 });
doc.fontSize(13).text(money(invoice.amount_ht), 445, 527, { width: 88, align: 'right' });
doc.roundedRect(48, 576, 499, 38, 4).fill('#FBEDEC');
doc.font('Helvetica-Bold').fontSize(9.5).fillColor(RED).text(VAT_EXEMPTION, 62, 590, { width: 471 });
doc.font('Helvetica-Bold').fontSize(9).fillColor(INK).text('RÈGLEMENT', 48, 638);
const paymentStatus = invoice.status === 'payee'
? `Facture réglée le ${dateFr(invoice.paid_at)} par ${paymentLabel(invoice.payment_method).toLowerCase()}.`
: invoice.status === 'annulee'
? 'Facture annulée.'
: `Paiement attendu avant le ${dateFr(invoice.due_date)} par virement, chèque ou carte bleue.`;
doc.font('Helvetica').fontSize(8.5).fillColor(GRAY).text(paymentStatus, 48, 655, { width: 499 });
if (issuer.iban) {
doc.font('Helvetica-Bold').fontSize(8).fillColor(GRAY).text('IBAN', 48, 676);
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(text(issuer.iban), 80, 674, { width: 467, characterSpacing: 0.6 });
}
if (issuer.legal_mentions) {
if (y > 690) {
const legalMentions = text(issuer.legal_mentions || DEFAULT_PAYMENT_TERMS);
doc.font('Helvetica').fontSize(7.5);
const legalContinued = doc.heightOfString(legalMentions, { width: 499, lineGap: 1.5 }) > 38;
doc.moveTo(48, 705).lineTo(547, 705).lineWidth(1).strokeColor('#D9D3CB').stroke();
doc.font('Helvetica-Bold').fontSize(7.5).fillColor(GRAY).text('CONDITIONS DE RÈGLEMENT', 48, 716);
doc.font('Helvetica').fontSize(7.5).fillColor(GRAY).text(
legalContinued ? 'Les conditions détaillées figurent en page suivante.' : legalMentions,
48,
729,
{ width: 499, height: 38, lineGap: 1.5 }
);
const invoiceBottomMargin = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
doc.font('Helvetica-Bold').fontSize(7.5).fillColor(INK).text(text(issuer.association_name), 48, 783, { width: 499, align: 'center' });
doc.font('Helvetica').fontSize(7).fillColor(GRAY).text(
[issuer.association_address, issuer.association_email, issuer.association_siret ? `SIRET ${issuer.association_siret}` : ''].filter(Boolean).join(' - ').replace(/\n/g, ' '),
48,
795,
{ width: 499, align: 'center', ellipsis: true }
);
doc.page.margins.bottom = invoiceBottomMargin;
if (legalContinued) {
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 });
doc.font('Helvetica-Bold').fontSize(18).fillColor(INK).text('Conditions de règlement', 48, 54, { width: 499 });
doc.moveTo(48, 84).lineTo(547, 84).lineWidth(2).strokeColor(RED).stroke();
doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(legalMentions, 48, 108, { width: 499, lineGap: 3 });
}
}, { info: { Title: `Facture ${invoice.invoice_number}`, Author: text(invoice.issuer_snapshot?.association_name) } });
}
@@ -134,15 +159,15 @@ 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 widths = [105, 210, 105, 90, 100, 159];
const headers = ['Facture', 'Entreprise', 'Type', 'Date', 'Règlement', 'Montant'];
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);
doc.font('Helvetica-Bold').fontSize(20).fillColor(INK).text(`Adhésions réglées - ${season}`, left, 36, { lineBreak: false });
doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(`${rows.length} membre${rows.length > 1 ? 's' : ''} à jour`, left, 64, { lineBreak: false });
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' });
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;
@@ -161,17 +186,15 @@ export function buildPaidMembersPdf({ season, rows }) {
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 });
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);
const total = rows.reduce((sum, row) => sum + Number(row.amount_ht), 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}` } });
}
@@ -181,15 +204,15 @@ export async function buildPaidMembersWorkbook({ season, rows }) {
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.mergeCells('A1:F1');
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('D2').value = 'Total encaissé';
sheet.getCell('E2').value = { formula: rows.length ? `SUM(F6:F${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).values = ['N° facture', 'Entreprise', 'Type', 'Date de règlement', 'Mode de règlement', 'Montant'];
sheet.getRow(5).height = 24;
sheet.getRow(5).eachCell((cell) => {
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
@@ -204,22 +227,20 @@ export async function buildPaidMembersWorkbook({ season, rows }) {
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}` };
sheet.autoFilter = { from: 'A5', to: `F${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]'; });
sheet.getCell(`F${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];
const widths = [20, 34, 24, 20, 22, 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());
+12 -16
View File
@@ -19,7 +19,6 @@ billingRouter.use(requireAuth(['admin', 'treasurer']));
const DEFAULT_SETTINGS = {
sluc_partner_amount_ht: 0,
non_partner_amount_ht: 0,
vat_rate: 20,
payment_due_days: 30,
iban: '',
legal_mentions: '',
@@ -32,7 +31,7 @@ function currentSeason(date = new Date()) {
function numberRow(row) {
if (!row) return row;
for (const key of ['sluc_partner_amount_ht', 'non_partner_amount_ht', 'vat_rate', 'amount_ht', 'vat_amount', 'amount_ttc']) {
for (const key of ['sluc_partner_amount_ht', 'non_partner_amount_ht', 'amount_ht', 'amount_ttc']) {
if (row[key] != null) row[key] = Number(row[key]);
}
return row;
@@ -45,8 +44,7 @@ async function loadSettings(season, client = { query }) {
async function loadPaidRows(season) {
const result = await query(
`SELECT invoice_number, member_name, billing_type, paid_at, payment_method,
amount_ht, vat_amount, amount_ttc
`SELECT invoice_number, member_name, billing_type, paid_at, payment_method, amount_ht
FROM membership_invoices
WHERE season = $1 AND status = 'payee'
ORDER BY paid_at, member_name`,
@@ -76,7 +74,7 @@ billingRouter.get('/seasons/:season', validate(billingSeasonParam, 'params'), as
query(
`SELECT m.id, m.nom, m.dirigeant, m.email, m.adresse, m.billing_type, m.valide,
i.id AS invoice_id, i.invoice_number, i.issued_at, i.due_date,
i.amount_ht, i.vat_rate, i.vat_amount, i.amount_ttc,
i.amount_ht,
i.status, i.payment_method, i.paid_at
FROM members m
LEFT JOIN LATERAL (
@@ -93,10 +91,9 @@ billingRouter.get('/seasons/:season', validate(billingSeasonParam, 'params'), as
COUNT(*) FILTER (WHERE status <> 'annulee')::int AS invoiced_count,
COUNT(*) FILTER (WHERE status = 'payee')::int AS paid_count,
COUNT(*) FILTER (WHERE status = 'emise')::int AS unpaid_count,
COALESCE(SUM(amount_ttc) FILTER (WHERE status <> 'annulee'), 0)::float8 AS total_invoiced,
COALESCE(SUM(amount_ttc) FILTER (WHERE status = 'payee'), 0)::float8 AS total_paid,
COALESCE(SUM(amount_ttc) FILTER (WHERE status = 'emise'), 0)::float8 AS total_outstanding,
COALESCE(SUM(vat_amount) FILTER (WHERE status = 'payee'), 0)::float8 AS vat_collected
COALESCE(SUM(amount_ht) FILTER (WHERE status <> 'annulee'), 0)::float8 AS total_invoiced,
COALESCE(SUM(amount_ht) FILTER (WHERE status = 'payee'), 0)::float8 AS total_paid,
COALESCE(SUM(amount_ht) FILTER (WHERE status = 'emise'), 0)::float8 AS total_outstanding
FROM membership_invoices WHERE season = $1`,
[season]
),
@@ -119,16 +116,16 @@ billingRouter.put('/seasons/:season/settings', validate(billingSeasonParam, 'par
await query(
`INSERT INTO billing_season_settings
(season, sluc_partner_amount_ht, non_partner_amount_ht, vat_rate, payment_due_days, iban, legal_mentions)
VALUES ($1, $2, $3, $4, $5, $6, $7)
VALUES ($1, $2, $3, 0, $4, $5, $6)
ON CONFLICT (season) DO UPDATE SET
sluc_partner_amount_ht=EXCLUDED.sluc_partner_amount_ht,
non_partner_amount_ht=EXCLUDED.non_partner_amount_ht,
vat_rate=EXCLUDED.vat_rate,
vat_rate=0,
payment_due_days=EXCLUDED.payment_due_days,
iban=EXCLUDED.iban,
legal_mentions=EXCLUDED.legal_mentions,
updated_at=now()`,
[req.params.season, d.sluc_partner_amount_ht, d.non_partner_amount_ht, d.vat_rate,
[req.params.season, d.sluc_partner_amount_ht, d.non_partner_amount_ht,
d.payment_due_days, iban, d.legal_mentions]
);
res.json({ settings: await loadSettings(req.params.season) });
@@ -187,8 +184,7 @@ billingRouter.post('/seasons/:season/invoices', validate(billingSeasonParam, 'pa
const amountHt = member.billing_type === 'sluc_partner'
? settings.sluc_partner_amount_ht
: settings.non_partner_amount_ht;
const vatAmount = Math.round(amountHt * settings.vat_rate) / 100;
const amountTtc = Math.round((amountHt + vatAmount) * 100) / 100;
const amountNet = Math.round(amountHt * 100) / 100;
const sequence = await client.query(`SELECT nextval('billing_invoice_number_seq') AS n`);
const invoiceNumber = `FAC-${req.params.season.slice(0, 4)}-${String(sequence.rows[0].n).padStart(5, '0')}`;
await client.query(
@@ -197,8 +193,8 @@ billingRouter.post('/seasons/:season/invoices', validate(billingSeasonParam, 'pa
billing_type, amount_ht, vat_rate, vat_amount, amount_ttc, issuer_snapshot)
VALUES ($1, $2, $3, CURRENT_DATE + $4::int, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`,
[req.params.season, member.id, invoiceNumber, settings.payment_due_days, member.nom,
member.adresse || '', member.email || '', member.billing_type, amountHt, settings.vat_rate,
vatAmount, amountTtc, JSON.stringify(issuerSnapshot)]
member.adresse || '', member.email || '', member.billing_type, amountNet, 0,
0, amountNet, JSON.stringify(issuerSnapshot)]
);
created += 1;
}
-1
View File
@@ -109,7 +109,6 @@ const iban = z.string().trim().max(42).refine(
export const billingSettingsSchema = z.object({
sluc_partner_amount_ht: z.coerce.number().min(0).max(1000000),
non_partner_amount_ht: z.coerce.number().min(0).max(1000000),
vat_rate: z.coerce.number().min(0).max(100),
payment_due_days: z.coerce.number().int().min(0).max(365),
iban,
legal_mentions: trimmed(3000).optional().default(''),
+10 -1
View File
@@ -168,13 +168,17 @@ CREATE TABLE IF NOT EXISTS billing_season_settings (
season TEXT PRIMARY KEY CHECK (season ~ '^[0-9]{4}-[0-9]{4}$'),
sluc_partner_amount_ht NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (sluc_partner_amount_ht >= 0),
non_partner_amount_ht NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (non_partner_amount_ht >= 0),
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20 CHECK (vat_rate BETWEEN 0 AND 100),
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 0 CHECK (vat_rate = 0),
payment_due_days INTEGER NOT NULL DEFAULT 30 CHECK (payment_due_days BETWEEN 0 AND 365),
iban TEXT NOT NULL DEFAULT '' CHECK (char_length(iban) <= 42),
legal_mentions TEXT NOT NULL DEFAULT '' CHECK (char_length(legal_mentions) <= 3000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE billing_season_settings ALTER COLUMN vat_rate SET DEFAULT 0;
UPDATE billing_season_settings SET vat_rate = 0 WHERE vat_rate <> 0;
ALTER TABLE billing_season_settings DROP CONSTRAINT IF EXISTS billing_season_settings_vat_rate_check;
ALTER TABLE billing_season_settings ADD CONSTRAINT billing_season_settings_vat_rate_check CHECK (vat_rate = 0);
CREATE SEQUENCE IF NOT EXISTS billing_invoice_number_seq START WITH 1;
@@ -205,3 +209,8 @@ CREATE INDEX IF NOT EXISTS idx_membership_invoices_season ON membership_invoices
CREATE INDEX IF NOT EXISTS idx_membership_invoices_member ON membership_invoices(member_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_membership_invoices_active_member_season
ON membership_invoices(member_id, season) WHERE status <> 'annulee';
-- Correct unpaid invoices created before the association's VAT exemption was
-- reflected in the billing module. Paid invoices remain immutable history.
UPDATE membership_invoices
SET vat_rate = 0, vat_amount = 0, amount_ttc = amount_ht, updated_at = now()
WHERE status = 'emise' AND (vat_rate <> 0 OR vat_amount <> 0 OR amount_ttc <> amount_ht);
+9 -16
View File
@@ -28,7 +28,6 @@ function SettingsPanel({ season, settings, onSaved, onClose }) {
const [form, setForm] = useState({
sluc_partner_amount_ht: settings.sluc_partner_amount_ht ?? 0,
non_partner_amount_ht: settings.non_partner_amount_ht ?? 0,
vat_rate: settings.vat_rate ?? 20,
payment_due_days: settings.payment_due_days ?? 30,
iban: settings.iban || '',
legal_mentions: settings.legal_mentions || '',
@@ -56,24 +55,18 @@ function SettingsPanel({ season, settings, onSaved, onClose }) {
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 20, marginBottom: 22 }}>
<div>
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Paramètres de la saison {seasonLabel(season)}</h3>
<p style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 5 }}>Les montants sont saisis hors taxes. La TVA et le TTC sont calculés à la génération.</p>
<p style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 5 }}>L'association n'est pas soumise à la TVA. La mention de l'article 293 B du CGI est ajoutée automatiquement aux factures.</p>
</div>
{settings.configured && <button type="button" className="btn-link-gray" onClick={onClose}>Fermer</button>}
</div>
<form onSubmit={submit}>
<div className="grid-4" style={{ gap: 12 }}>
<label className="field">Partenaire SLUC - HT
<div className="grid-3" style={{ gap: 12 }}>
<label className="field">Montant partenaire SLUC
<input name="sluc_partner_amount_ht" type="number" min="0" max="1000000" step="0.01" value={form.sluc_partner_amount_ht} onChange={change} required />
</label>
<label className="field">Non partenaire - HT
<label className="field">Montant non partenaire
<input name="non_partner_amount_ht" type="number" min="0" max="1000000" step="0.01" value={form.non_partner_amount_ht} onChange={change} required />
</label>
<label className="field">TVA
<div style={{ position: 'relative' }}>
<input name="vat_rate" type="number" min="0" max="100" step="0.01" value={form.vat_rate} onChange={change} required style={{ paddingRight: 32 }} />
<span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--gray-light)' }}>%</span>
</div>
</label>
<label className="field">Échéance
<div style={{ position: 'relative' }}>
<input name="payment_due_days" type="number" min="0" max="365" step="1" value={form.payment_due_days} onChange={change} required style={{ paddingRight: 52 }} />
@@ -132,7 +125,7 @@ function PaymentModal({ member, onClose, onSaved }) {
};
return (
<Modal onClose={onClose} maxWidth={480} header={{ kicker: 'Règlement', title: member.nom, meta: `${member.invoice_number} · ${euro(member.amount_ttc)}` }}>
<Modal onClose={onClose} maxWidth={480} header={{ kicker: 'Règlement', title: member.nom, meta: `${member.invoice_number} · ${euro(member.amount_ht)}` }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label className="field">Mode de règlement
@@ -282,16 +275,16 @@ export function BillingTab() {
{!data.settings.configured && (
<div style={{ padding: '14px 18px', background: '#FBF0E6', border: '1px solid #F0D6BC', borderRadius: 6, color: '#8A5A22', marginBottom: 22, fontSize: 13.5 }}>
Configurez les tarifs, la TVA et l'IBAN avant de générer les factures de cette saison.
Configurez les tarifs et l'IBAN avant de générer les factures de cette saison.
</div>
)}
<div className="grid-4" style={{ marginBottom: 22 }}>
{[
['Facturé TTC', euro(stats.total_invoiced), `${stats.invoiced_count} facture${stats.invoiced_count > 1 ? 's' : ''}`],
['Facturé', euro(stats.total_invoiced), `${stats.invoiced_count} facture${stats.invoiced_count > 1 ? 's' : ''}`],
['Encaissé', euro(stats.total_paid), `${stats.paid_count} adhésion${stats.paid_count > 1 ? 's' : ''} à jour`],
['À encaisser', euro(stats.total_outstanding), `${stats.unpaid_count} règlement${stats.unpaid_count > 1 ? 's' : ''} attendu${stats.unpaid_count > 1 ? 's' : ''}`],
['Taux de règlement', `${recovery} %`, `TVA encaissée ${euro(stats.vat_collected)}`],
['Taux de règlement', `${recovery} %`, `${stats.paid_count} facture${stats.paid_count > 1 ? 's' : ''} réglée${stats.paid_count > 1 ? 's' : ''}`],
].map(([label, value, detail]) => (
<div key={label} className="card" style={{ borderRadius: 6, padding: 22 }}>
<div style={{ fontSize: 12, color: 'var(--gray-light)', marginBottom: 10 }}>{label}</div>
@@ -330,7 +323,7 @@ export function BillingTab() {
<option value="non_partner">Non partenaire SLUC</option>
</select>
</td>
<td>{member.invoice_id ? euro(member.amount_ttc) : <><strong>{euro(expectedHt)}</strong><small style={{ display: 'block', color: 'var(--gray-light)', marginTop: 3 }}>HT avant TVA</small></>}</td>
<td>{member.invoice_id ? euro(member.amount_ht) : <><strong>{euro(expectedHt)}</strong><small style={{ display: 'block', color: 'var(--gray-light)', marginTop: 3 }}>TVA non applicable</small></>}</td>
<td>
{member.invoice_id ? <>
<div style={{ fontSize: 12.5, fontWeight: 600 }}>{member.invoice_number}</div>