diff --git a/server/src/billingDocuments.js b/server/src/billingDocuments.js index 567cdfe..b545c3b 100644 --- a/server/src/billingDocuments.js +++ b/server/src/billingDocuments.js @@ -8,7 +8,7 @@ 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 DEFAULT_PAYMENT_TERMS = '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)), @@ -27,6 +27,19 @@ const dateFr = (value) => { const typeLabel = (value) => value === 'sluc_partner' ? 'Partenaire SLUC' : 'Non partenaire SLUC'; const paymentLabel = (value) => ({ carte: 'Carte bleue', virement: 'Virement', cheque: 'Chèque' }[value] || ''); +export function sanitizeLegalMentions(value) { + return text(value) + .split(/\r?\n/) + .map((rawLine) => rawLine.trim()) + .filter(Boolean) + .filter((line) => !/^facture en euros[.!]?$/i.test(line)) + .filter((line) => !/^tva non applicable\b/i.test(line)) + .filter((line) => !/^r[èe]glement\s*:/i.test(line)) + .map((line) => line.replace(/^paiement\s+[àa]\s+r[ée]ception\s+de\s+facture\.?\s*/i, '').trim()) + .filter(Boolean) + .join('\n'); +} + function collectPdf(draw, options = {}) { return new Promise((resolve, reject) => { const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, ...options }); @@ -65,10 +78,10 @@ export function buildInvoicePdf(invoice) { 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' }); + drawMultiline(doc, issuer.association_address, 300, 63, 247, { align: 'right', height: 37, ellipsis: true }); + if (issuer.association_email) doc.text(issuer.association_email, 300, 102, { width: 247, align: 'right' }); + if (issuer.association_phone) doc.text(issuer.association_phone, 300, 115, { width: 247, align: 'right' }); + if (issuer.association_siret) doc.text(`SIRET : ${issuer.association_siret}`, 300, 128, { width: 247, align: 'right' }); doc.moveTo(48, 145).lineTo(547, 145).lineWidth(2).strokeColor(RED).stroke(); doc.font('Helvetica-Bold').fontSize(30).fillColor(INK).text('FACTURE', 48, 168, { width: 220 }); @@ -118,33 +131,43 @@ export function buildInvoicePdf(invoice) { ? '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 }); + const ribValues = [issuer.iban, issuer.bic, issuer.rib_account_holder, issuer.rib_bank_name, + issuer.rib_bank_code, issuer.rib_branch_code, issuer.rib_account_number, issuer.rib_key]; + if (ribValues.some(Boolean)) { + const ribY = 674; + doc.roundedRect(48, ribY, 499, 74, 4).fill(LIGHT); + doc.font('Helvetica-Bold').fontSize(7.5).fillColor(INK).text('COORDONNÉES BANCAIRES', 60, ribY + 9, { width: 130 }); + const bankIdentity = [issuer.rib_account_holder, issuer.rib_bank_name].filter(Boolean).join(' - '); + doc.font('Helvetica').fontSize(7).fillColor(GRAY).text(bankIdentity, 195, ribY + 9, { width: 338, align: 'right', ellipsis: true }); + + const ribColumns = [ + ['Code banque', issuer.rib_bank_code, 78], + ['Code guichet', issuer.rib_branch_code, 78], + ['N° de compte', issuer.rib_account_number, 128], + ['Clé RIB', issuer.rib_key, 58], + ['BIC / SWIFT', issuer.bic, 109], + ]; + let ribX = 60; + ribColumns.forEach(([label, value, width]) => { + doc.font('Helvetica').fontSize(6).fillColor(GRAY).text(label.toUpperCase(), ribX, ribY + 27, { width }); + doc.font('Helvetica-Bold').fontSize(7.5).fillColor(INK).text(text(value), ribX, ribY + 38, { width, ellipsis: true }); + ribX += width; + }); + doc.font('Helvetica').fontSize(6).fillColor(GRAY).text('IBAN', 60, ribY + 55, { width: 32 }); + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(INK).text(text(issuer.iban), 92, ribY + 53, { width: 441, characterSpacing: 0.5, ellipsis: true }); } - 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' }); + const legalMentions = sanitizeLegalMentions(issuer.legal_mentions) || DEFAULT_PAYMENT_TERMS; + doc.font('Helvetica').fontSize(7); + const legalContinued = doc.heightOfString(legalMentions, { width: 499, lineGap: 1.2 }) > 28; + doc.moveTo(48, 759).lineTo(547, 759).lineWidth(1).strokeColor('#D9D3CB').stroke(); + doc.font('Helvetica-Bold').fontSize(7.5).fillColor(GRAY).text('CONDITIONS DE RÈGLEMENT', 48, 769); 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, ' '), + legalContinued ? 'Les conditions complémentaires figurent en page suivante.' : legalMentions, 48, - 795, - { width: 499, align: 'center', ellipsis: true } + 782, + { width: 499, height: 28, lineGap: 1.2 } ); - doc.page.margins.bottom = invoiceBottomMargin; if (legalContinued) { doc.addPage(); diff --git a/server/src/routes/billing.js b/server/src/routes/billing.js index e46a17d..ebdd7c6 100644 --- a/server/src/routes/billing.js +++ b/server/src/routes/billing.js @@ -11,7 +11,7 @@ import { idParam, } from '../schemas.js'; import { associationSettings } from '../siteSettings.js'; -import { buildInvoicePdf, buildPaidMembersPdf, buildPaidMembersWorkbook } from '../billingDocuments.js'; +import { buildInvoicePdf, buildPaidMembersPdf, buildPaidMembersWorkbook, sanitizeLegalMentions } from '../billingDocuments.js'; export const billingRouter = Router(); billingRouter.use(requireAuth(['admin', 'treasurer'])); @@ -21,6 +21,13 @@ const DEFAULT_SETTINGS = { non_partner_amount_ht: 0, payment_due_days: 30, iban: '', + bic: '', + rib_account_holder: '', + rib_bank_name: '', + rib_bank_code: '', + rib_branch_code: '', + rib_account_number: '', + rib_key: '', legal_mentions: '', }; @@ -39,7 +46,10 @@ function numberRow(row) { async function loadSettings(season, client = { query }) { const result = await client.query('SELECT * FROM billing_season_settings WHERE season = $1', [season]); - return result.rows[0] ? { ...numberRow(result.rows[0]), configured: true } : { season, ...DEFAULT_SETTINGS, configured: false }; + if (!result.rows[0]) return { season, ...DEFAULT_SETTINGS, configured: false }; + const settings = numberRow(result.rows[0]); + settings.legal_mentions = sanitizeLegalMentions(settings.legal_mentions); + return { ...settings, configured: true }; } async function loadPaidRows(season) { @@ -112,21 +122,38 @@ billingRouter.get('/seasons/:season', validate(billingSeasonParam, 'params'), as billingRouter.put('/seasons/:season/settings', validate(billingSeasonParam, 'params'), validate(billingSettingsSchema), async (req, res, next) => { try { const d = req.data; - const iban = d.iban.replace(/\s/g, '').toUpperCase(); + const compact = (value) => value.replace(/\s/g, '').toUpperCase(); + const iban = compact(d.iban); + const bic = compact(d.bic); + const bankCode = compact(d.rib_bank_code); + const branchCode = compact(d.rib_branch_code); + const accountNumber = compact(d.rib_account_number); + const ribKey = compact(d.rib_key); + const legalMentions = sanitizeLegalMentions(d.legal_mentions); 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, 0, $4, $5, $6) + (season, sluc_partner_amount_ht, non_partner_amount_ht, vat_rate, payment_due_days, + iban, bic, rib_account_holder, rib_bank_name, rib_bank_code, rib_branch_code, + rib_account_number, rib_key, legal_mentions) + VALUES ($1, $2, $3, 0, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) 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=0, payment_due_days=EXCLUDED.payment_due_days, iban=EXCLUDED.iban, + bic=EXCLUDED.bic, + rib_account_holder=EXCLUDED.rib_account_holder, + rib_bank_name=EXCLUDED.rib_bank_name, + rib_bank_code=EXCLUDED.rib_bank_code, + rib_branch_code=EXCLUDED.rib_branch_code, + rib_account_number=EXCLUDED.rib_account_number, + rib_key=EXCLUDED.rib_key, legal_mentions=EXCLUDED.legal_mentions, updated_at=now()`, [req.params.season, d.sluc_partner_amount_ht, d.non_partner_amount_ht, - d.payment_due_days, iban, d.legal_mentions] + d.payment_due_days, iban, bic, d.rib_account_holder, d.rib_bank_name, + bankCode, branchCode, accountNumber, ribKey, legalMentions] ); res.json({ settings: await loadSettings(req.params.season) }); } catch (err) { @@ -176,7 +203,14 @@ billingRouter.post('/seasons/:season/invoices', validate(billingSeasonParam, 'pa const issuerSnapshot = { ...issuer, iban: settings.iban, - legal_mentions: settings.legal_mentions, + bic: settings.bic, + rib_account_holder: settings.rib_account_holder, + rib_bank_name: settings.rib_bank_name, + rib_bank_code: settings.rib_bank_code, + rib_branch_code: settings.rib_branch_code, + rib_account_number: settings.rib_account_number, + rib_key: settings.rib_key, + legal_mentions: sanitizeLegalMentions(settings.legal_mentions), }; let created = 0; for (const member of members.rows) { diff --git a/server/src/schemas.js b/server/src/schemas.js index 16675e3..81436ac 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -130,12 +130,23 @@ const iban = z.string().trim().max(42).refine( (value) => !value || /^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(value.replace(/\s/g, '').toUpperCase()), 'IBAN invalide' ); +const bankingCode = (regex, max, message) => z.string().trim().max(max).refine( + (value) => !value || regex.test(value.replace(/\s/g, '').toUpperCase()), + message +).optional().default(''); 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), payment_due_days: z.coerce.number().int().min(0).max(365), iban, + bic: bankingCode(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 14, 'BIC invalide'), + rib_account_holder: trimmed(160).optional().default(''), + rib_bank_name: trimmed(160).optional().default(''), + rib_bank_code: bankingCode(/^\d{5}$/, 7, 'le code banque doit contenir 5 chiffres'), + rib_branch_code: bankingCode(/^\d{5}$/, 7, 'le code guichet doit contenir 5 chiffres'), + rib_account_number: bankingCode(/^[A-Z0-9]{11}$/, 14, 'le numéro de compte doit contenir 11 caractères'), + rib_key: bankingCode(/^\d{2}$/, 3, 'la clé RIB doit contenir 2 chiffres'), legal_mentions: trimmed(3000).optional().default(''), }); diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index d5df584..5f9c294 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -196,11 +196,25 @@ CREATE TABLE IF NOT EXISTS billing_season_settings ( 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), + bic TEXT NOT NULL DEFAULT '' CHECK (char_length(bic) <= 14), + rib_account_holder TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_account_holder) <= 160), + rib_bank_name TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_bank_name) <= 160), + rib_bank_code TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_bank_code) <= 7), + rib_branch_code TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_branch_code) <= 7), + rib_account_number TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_account_number) <= 14), + rib_key TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_key) <= 3), 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; +ALTER TABLE billing_season_settings ADD COLUMN IF NOT EXISTS bic TEXT NOT NULL DEFAULT '' CHECK (char_length(bic) <= 14); +ALTER TABLE billing_season_settings ADD COLUMN IF NOT EXISTS rib_account_holder TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_account_holder) <= 160); +ALTER TABLE billing_season_settings ADD COLUMN IF NOT EXISTS rib_bank_name TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_bank_name) <= 160); +ALTER TABLE billing_season_settings ADD COLUMN IF NOT EXISTS rib_bank_code TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_bank_code) <= 7); +ALTER TABLE billing_season_settings ADD COLUMN IF NOT EXISTS rib_branch_code TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_branch_code) <= 7); +ALTER TABLE billing_season_settings ADD COLUMN IF NOT EXISTS rib_account_number TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_account_number) <= 14); +ALTER TABLE billing_season_settings ADD COLUMN IF NOT EXISTS rib_key TEXT NOT NULL DEFAULT '' CHECK (char_length(rib_key) <= 3); 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); diff --git a/web/src/components/admin/AdminBilling.jsx b/web/src/components/admin/AdminBilling.jsx index 51df875..43af097 100644 --- a/web/src/components/admin/AdminBilling.jsx +++ b/web/src/components/admin/AdminBilling.jsx @@ -30,6 +30,13 @@ function SettingsPanel({ season, settings, onSaved, onClose }) { non_partner_amount_ht: settings.non_partner_amount_ht ?? 0, payment_due_days: settings.payment_due_days ?? 30, iban: settings.iban || '', + bic: settings.bic || '', + rib_account_holder: settings.rib_account_holder || '', + rib_bank_name: settings.rib_bank_name || '', + rib_bank_code: settings.rib_bank_code || '', + rib_branch_code: settings.rib_branch_code || '', + rib_account_number: settings.rib_account_number || '', + rib_key: settings.rib_key || '', legal_mentions: settings.legal_mentions || '', }); const [busy, setBusy] = useState(false); @@ -74,11 +81,38 @@ function SettingsPanel({ season, settings, onSaved, onClose }) { - IBAN à afficher sur les factures - - - Mentions légales des factures - + Coordonnées bancaires du RIB + + Titulaire du compte + + + Banque / domiciliation + + + IBAN + + + BIC / SWIFT + + + + + Code banque + + + Code guichet + + + N° de compte + + + Clé RIB + + + + Conditions complémentaires + + La TVA, l’échéance et les modes de règlement sont déjà ajoutés automatiquement. {error && {error}}
{error}