Ajoute le RIB complet et dedoublonne les factures
This commit is contained in:
1 parent
b34e361cbe
commit
dd1053491b
5 files changed
+155
-39
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(''),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user