Ajoute le RIB complet et dedoublonne les factures

This commit is contained in:
Michael committed 2026-07-16 23:50:48 +02:00
1 parent b34e361cbe
commit dd1053491b
5 files changed
+155 -39

No files matched your search

+50 -27
View File
@@ -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();
+41 -7
View File
@@ -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) {
+11
View File
@@ -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(''),
});
+14
View File
@@ -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);