From a9e01de7a8d79b5109930232cd8b6e61faa52577 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 16 Jul 2026 22:39:14 +0200 Subject: [PATCH] Adapte les factures avec le logo et sans TVA --- server/src/billingDocuments.js | 195 ++++++++++++---------- server/src/routes/billing.js | 28 ++-- server/src/schemas.js | 1 - server/src/sql/schema.sql | 11 +- web/src/components/admin/AdminBilling.jsx | 25 +-- 5 files changed, 139 insertions(+), 121 deletions(-) diff --git a/server/src/billingDocuments.js b/server/src/billingDocuments.js index 9fdea38..567cdfe 100644 --- a/server/src/billingDocuments.js +++ b/server/src/billingDocuments.js @@ -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) { - 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 }); + 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(); + 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()); diff --git a/server/src/routes/billing.js b/server/src/routes/billing.js index 607af27..e46a17d 100644 --- a/server/src/routes/billing.js +++ b/server/src/routes/billing.js @@ -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; } diff --git a/server/src/schemas.js b/server/src/schemas.js index 469f8d6..6812873 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -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(''), diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index 877ca21..5b917df 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -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); diff --git a/web/src/components/admin/AdminBilling.jsx b/web/src/components/admin/AdminBilling.jsx index 6e3168f..51df875 100644 --- a/web/src/components/admin/AdminBilling.jsx +++ b/web/src/components/admin/AdminBilling.jsx @@ -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 }) {

Paramètres de la saison {seasonLabel(season)}

-

Les montants sont saisis hors taxes. La TVA et le TTC sont calculés à la génération.

+

L'association n'est pas soumise à la TVA. La mention de l'article 293 B du CGI est ajoutée automatiquement aux factures.

{settings.configured && }
-
-