Ajoute la gestion des adhesions et de la facturation
This commit is contained in:
1 parent
bdaf6cb156
commit
942b5a1ad2
13 files changed
+2029
-32
No files matched your search
Generated
+1013
File diff suppressed because it is too large.
Load diff
@@ -11,11 +11,13 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.12.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
const RED = '#C1272D';
|
||||
const INK = '#1B1B1B';
|
||||
const GRAY = '#6E675F';
|
||||
const LIGHT = '#F4F1EC';
|
||||
|
||||
const text = (value) => String(value ?? '');
|
||||
const money = (value) => `${Number(value || 0).toFixed(2).replace('.', ',')} EUR`;
|
||||
const dateFr = (value) => {
|
||||
if (!value) return '';
|
||||
const raw = value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10);
|
||||
const [year, month, day] = raw.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
};
|
||||
const typeLabel = (value) => value === 'sluc_partner' ? 'Partenaire SLUC' : 'Non partenaire SLUC';
|
||||
const paymentLabel = (value) => ({ carte: 'Carte bleue', virement: 'Virement', cheque: 'Chèque' }[value] || '');
|
||||
|
||||
function collectPdf(draw, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, ...options });
|
||||
const chunks = [];
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
draw(doc);
|
||||
const range = doc.bufferedPageRange();
|
||||
for (let page = range.start; page < range.start + range.count; page += 1) {
|
||||
doc.switchToPage(page);
|
||||
const bottomMargin = doc.page.margins.bottom;
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.fontSize(8).fillColor('#8A8279').text(
|
||||
`Page ${page - range.start + 1} / ${range.count}`,
|
||||
48,
|
||||
doc.page.height - 24,
|
||||
{ width: doc.page.width - 96, align: 'right', lineBreak: false }
|
||||
);
|
||||
doc.page.margins.bottom = bottomMargin;
|
||||
}
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
function drawMultiline(doc, value, x, y, width, options = {}) {
|
||||
doc.text(text(value), x, y, { width, lineGap: 2, ...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();
|
||||
|
||||
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(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.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 });
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
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' });
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}, { info: { Title: `Facture ${invoice.invoice_number}`, Author: text(invoice.issuer_snapshot?.association_name) } });
|
||||
}
|
||||
|
||||
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 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);
|
||||
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' });
|
||||
x += widths[index];
|
||||
});
|
||||
return 114;
|
||||
};
|
||||
let y = drawHeader();
|
||||
rows.forEach((row, rowIndex) => {
|
||||
if (y > 535) {
|
||||
doc.addPage();
|
||||
y = drawHeader();
|
||||
}
|
||||
if (rowIndex % 2 === 0) doc.rect(left, y, pageWidth - left * 2, 29).fill('#FAF8F5');
|
||||
const values = [
|
||||
row.invoice_number,
|
||||
row.member_name,
|
||||
typeLabel(row.billing_type),
|
||||
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 });
|
||||
x += widths[index];
|
||||
});
|
||||
y += 29;
|
||||
});
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.amount_ttc), 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}` } });
|
||||
}
|
||||
|
||||
export async function buildPaidMembersWorkbook({ season, rows }) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
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.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('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).height = 24;
|
||||
sheet.getRow(5).eachCell((cell) => {
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1B1B1B' } };
|
||||
cell.alignment = { vertical: 'middle' };
|
||||
});
|
||||
rows.forEach((row) => {
|
||||
sheet.addRow([
|
||||
row.invoice_number,
|
||||
row.member_name,
|
||||
typeLabel(row.billing_type),
|
||||
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}` };
|
||||
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]'; });
|
||||
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];
|
||||
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());
|
||||
}
|
||||
@@ -22,13 +22,15 @@ import { createStaffUser, resetStaffAccess } from '../userAccess.js';
|
||||
import { AccessError } from '../errors.js';
|
||||
import { buildInvitationEmail } from '../emailTemplate.js';
|
||||
import { buildCustomEmail } from '../customEmailTemplate.js';
|
||||
import { billingRouter } from './billing.js';
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
// Every route below requires at least an authenticated staff session
|
||||
// (admin or moderator); individual routes further restrict to admin-only
|
||||
// where noted.
|
||||
adminRouter.use(requireAuth(['admin', 'moderator']));
|
||||
adminRouter.use(requireAuth(['admin', 'moderator', 'treasurer']));
|
||||
adminRouter.use('/billing', billingRouter);
|
||||
const adminOnly = requireAuth('admin');
|
||||
|
||||
const MEMBER_SQL = `
|
||||
@@ -680,7 +682,7 @@ adminRouter.get('/users', adminOnly, async (req, res, next) => {
|
||||
SELECT id, email, full_name, role, must_change_password,
|
||||
CASE WHEN must_change_password THEN temp_password ELSE NULL END AS temp_password,
|
||||
created_at
|
||||
FROM users WHERE role IN ('admin', 'moderator')
|
||||
FROM users WHERE role IN ('admin', 'moderator', 'treasurer')
|
||||
ORDER BY role, full_name, email`);
|
||||
res.json({ users: result.rows.map((u) => ({ ...u, is_self: u.id === req.user.sub })) });
|
||||
} catch (err) {
|
||||
@@ -714,7 +716,7 @@ adminRouter.put('/users/:id/role', adminOnly, validate(idParam, 'params'), valid
|
||||
if (req.params.id === req.user.sub) {
|
||||
return res.status(400).json({ error: 'Vous ne pouvez pas modifier votre propre rôle.' });
|
||||
}
|
||||
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
|
||||
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]);
|
||||
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
|
||||
if (target.rows[0].role === 'admin' && req.data.role !== 'admin') {
|
||||
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
|
||||
@@ -734,7 +736,7 @@ adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async (
|
||||
if (req.params.id === req.user.sub) {
|
||||
return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte.' });
|
||||
}
|
||||
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
|
||||
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]);
|
||||
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
|
||||
if (target.rows[0].role === 'admin') {
|
||||
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
|
||||
@@ -742,7 +744,7 @@ adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async (
|
||||
return res.status(400).json({ error: 'Impossible de supprimer le dernier compte administrateur.' });
|
||||
}
|
||||
}
|
||||
await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
|
||||
await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { Router } from 'express';
|
||||
import { pool, query } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import {
|
||||
billingSeasonParam,
|
||||
billingSettingsSchema,
|
||||
billingTypeSchema,
|
||||
invoiceGenerationSchema,
|
||||
paymentSchema,
|
||||
idParam,
|
||||
} from '../schemas.js';
|
||||
import { associationSettings } from '../siteSettings.js';
|
||||
import { buildInvoicePdf, buildPaidMembersPdf, buildPaidMembersWorkbook } from '../billingDocuments.js';
|
||||
|
||||
export const billingRouter = Router();
|
||||
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: '',
|
||||
};
|
||||
|
||||
function currentSeason(date = new Date()) {
|
||||
const start = date.getMonth() >= 8 ? date.getFullYear() : date.getFullYear() - 1;
|
||||
return `${start}-${start + 1}`;
|
||||
}
|
||||
|
||||
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']) {
|
||||
if (row[key] != null) row[key] = Number(row[key]);
|
||||
}
|
||||
return 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 };
|
||||
}
|
||||
|
||||
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
|
||||
FROM membership_invoices
|
||||
WHERE season = $1 AND status = 'payee'
|
||||
ORDER BY paid_at, member_name`,
|
||||
[season]
|
||||
);
|
||||
return result.rows.map(numberRow);
|
||||
}
|
||||
|
||||
billingRouter.get('/seasons', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT season FROM billing_season_settings
|
||||
UNION SELECT season FROM membership_invoices
|
||||
ORDER BY season DESC`);
|
||||
const seasons = [...new Set([currentSeason(), ...result.rows.map((row) => row.season)])].sort().reverse();
|
||||
res.json({ seasons, current: currentSeason() });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.get('/seasons/:season', validate(billingSeasonParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { season } = req.params;
|
||||
const [settings, members, stats] = await Promise.all([
|
||||
loadSettings(season),
|
||||
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.status, i.payment_method, i.paid_at
|
||||
FROM members m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM membership_invoices mi
|
||||
WHERE mi.member_id = m.id AND mi.season = $1 AND mi.status <> 'annulee'
|
||||
ORDER BY mi.id DESC LIMIT 1
|
||||
) i ON true
|
||||
ORDER BY m.nom`,
|
||||
[season]
|
||||
),
|
||||
query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::int FROM members) AS total_members,
|
||||
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
|
||||
FROM membership_invoices WHERE season = $1`,
|
||||
[season]
|
||||
),
|
||||
]);
|
||||
res.json({
|
||||
season,
|
||||
settings,
|
||||
members: members.rows.map(numberRow),
|
||||
stats: stats.rows[0],
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
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)
|
||||
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,
|
||||
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,
|
||||
d.payment_due_days, iban, d.legal_mentions]
|
||||
);
|
||||
res.json({ settings: await loadSettings(req.params.season) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.put('/members/:id/type', validate(idParam, 'params'), validate(billingTypeSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'UPDATE members SET billing_type=$1, updated_at=now() WHERE id=$2 RETURNING id, billing_type',
|
||||
[req.data.billing_type, req.params.id]
|
||||
);
|
||||
if (!result.rowCount) return res.status(404).json({ error: 'Membre introuvable' });
|
||||
res.json({ member: result.rows[0] });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.post('/seasons/:season/invoices', validate(billingSeasonParam, 'params'), validate(invoiceGenerationSchema), async (req, res, next) => {
|
||||
let client;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
await client.query('BEGIN');
|
||||
const settingsResult = await client.query('SELECT * FROM billing_season_settings WHERE season=$1 FOR UPDATE', [req.params.season]);
|
||||
if (!settingsResult.rowCount) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'Configurez les tarifs de cette saison avant de générer les factures.' });
|
||||
}
|
||||
const settings = numberRow(settingsResult.rows[0]);
|
||||
const memberIds = req.data.member_ids;
|
||||
const members = await client.query(
|
||||
`SELECT id, nom, adresse, email, billing_type FROM members
|
||||
${memberIds ? 'WHERE id = ANY($1::int[])' : ''}
|
||||
ORDER BY nom`,
|
||||
memberIds ? [memberIds] : []
|
||||
);
|
||||
const existing = await client.query(
|
||||
`SELECT member_id FROM membership_invoices
|
||||
WHERE season=$1 AND status <> 'annulee' AND member_id = ANY($2::int[])`,
|
||||
[req.params.season, members.rows.map((member) => member.id)]
|
||||
);
|
||||
const existingIds = new Set(existing.rows.map((row) => row.member_id));
|
||||
const content = await client.query(`SELECT key, value FROM site_content WHERE key LIKE 'association_%'`);
|
||||
const issuer = associationSettings(Object.fromEntries(content.rows.map((row) => [row.key, row.value])));
|
||||
const issuerSnapshot = {
|
||||
...issuer,
|
||||
iban: settings.iban,
|
||||
legal_mentions: settings.legal_mentions,
|
||||
};
|
||||
let created = 0;
|
||||
for (const member of members.rows) {
|
||||
if (existingIds.has(member.id)) continue;
|
||||
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 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(
|
||||
`INSERT INTO membership_invoices
|
||||
(season, member_id, invoice_number, due_date, member_name, member_address, member_email,
|
||||
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)]
|
||||
);
|
||||
created += 1;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ created });
|
||||
} catch (err) {
|
||||
if (client) await client.query('ROLLBACK').catch(() => {});
|
||||
next(err);
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.put('/invoices/:id/payment', validate(idParam, 'params'), validate(paymentSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`UPDATE membership_invoices SET status='payee', payment_method=$1, paid_at=$2, updated_at=now()
|
||||
WHERE id=$3 AND status <> 'annulee' RETURNING id`,
|
||||
[req.data.payment_method, req.data.paid_at, req.params.id]
|
||||
);
|
||||
if (!result.rowCount) return res.status(404).json({ error: 'Facture introuvable ou annulée' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.delete('/invoices/:id/payment', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`UPDATE membership_invoices SET status='emise', payment_method=NULL, paid_at=NULL, updated_at=now()
|
||||
WHERE id=$1 AND status='payee' RETURNING id`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!result.rowCount) return res.status(404).json({ error: 'Règlement introuvable' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.post('/invoices/:id/cancel', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`UPDATE membership_invoices SET status='annulee', payment_method=NULL, paid_at=NULL, updated_at=now()
|
||||
WHERE id=$1 AND status='emise' RETURNING id`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!result.rowCount) return res.status(409).json({ error: 'Seule une facture non réglée peut être annulée.' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.get('/invoices/:id/pdf', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query('SELECT * FROM membership_invoices WHERE id=$1', [req.params.id]);
|
||||
if (!result.rowCount) return res.status(404).json({ error: 'Facture introuvable' });
|
||||
const invoice = numberRow(result.rows[0]);
|
||||
const pdf = await buildInvoicePdf(invoice);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoice_number}.pdf"`);
|
||||
res.send(pdf);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.get('/seasons/:season/paid.xlsx', validate(billingSeasonParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const rows = await loadPaidRows(req.params.season);
|
||||
const workbook = await buildPaidMembersWorkbook({ season: req.params.season, rows });
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="adhesions-payees-${req.params.season}.xlsx"`);
|
||||
res.send(workbook);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
billingRouter.get('/seasons/:season/paid.pdf', validate(billingSeasonParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const rows = await loadPaidRows(req.params.season);
|
||||
const pdf = await buildPaidMembersPdf({ season: req.params.season, rows });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="adhesions-payees-${req.params.season}.pdf"`);
|
||||
res.send(pdf);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
+32
-1
@@ -87,7 +87,7 @@ export const pastPhotoParam = z.object({
|
||||
|
||||
export const categorySchema = z.object({ name: trimmed(80, 1) });
|
||||
|
||||
export const staffRoleEnum = z.enum(['admin', 'moderator']);
|
||||
export const staffRoleEnum = z.enum(['admin', 'moderator', 'treasurer']);
|
||||
|
||||
export const staffUserSchema = z.object({
|
||||
fullName: trimmed(120, 1),
|
||||
@@ -97,6 +97,37 @@ export const staffUserSchema = z.object({
|
||||
|
||||
export const roleChangeSchema = z.object({ role: staffRoleEnum });
|
||||
|
||||
export const billingSeasonParam = z.object({
|
||||
season: z.string().regex(/^\d{4}-\d{4}$/, 'format AAAA-AAAA'),
|
||||
});
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
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(''),
|
||||
});
|
||||
|
||||
export const billingTypeSchema = z.object({
|
||||
billing_type: z.enum(['sluc_partner', 'non_partner']),
|
||||
});
|
||||
|
||||
export const invoiceGenerationSchema = z.object({
|
||||
member_ids: z.array(z.coerce.number().int().positive()).min(1).max(10000).optional(),
|
||||
});
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
payment_method: z.enum(['carte', 'virement', 'cheque']),
|
||||
paid_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'),
|
||||
});
|
||||
|
||||
export const contentSchema = z.object({
|
||||
hero_quote_text: trimmed(300).optional(),
|
||||
hero_quote_author: trimmed(120).optional(),
|
||||
|
||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS members (
|
||||
tel TEXT CHECK (char_length(tel) <= 30),
|
||||
site TEXT CHECK (char_length(site) <= 200),
|
||||
adresse TEXT CHECK (char_length(adresse) <= 300),
|
||||
billing_type TEXT NOT NULL DEFAULT 'non_partner' CHECK (billing_type IN ('sluc_partner', 'non_partner')),
|
||||
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
|
||||
valide BOOLEAN NOT NULL DEFAULT false,
|
||||
logo_path TEXT,
|
||||
@@ -25,14 +26,17 @@ CREATE TABLE IF NOT EXISTS members (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS adresse TEXT CHECK (char_length(adresse) <= 300);
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS billing_type TEXT NOT NULL DEFAULT 'non_partner';
|
||||
ALTER TABLE members DROP CONSTRAINT IF EXISTS members_billing_type_check;
|
||||
ALTER TABLE members ADD CONSTRAINT members_billing_type_check CHECK (billing_type IN ('sluc_partner', 'non_partner'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('member', 'admin', 'moderator')),
|
||||
role TEXT NOT NULL CHECK (role IN ('member', 'admin', 'moderator', 'treasurer')),
|
||||
member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE,
|
||||
-- Display name for admin/moderator accounts (member accounts show their
|
||||
-- Display name for staff accounts (member accounts show their
|
||||
-- name via the linked members row instead, this stays NULL for them).
|
||||
full_name TEXT,
|
||||
-- Temporary password shown to the admin (create / reset access), kept
|
||||
@@ -48,7 +52,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS temp_password TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name TEXT;
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('member', 'admin', 'moderator'));
|
||||
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('member', 'admin', 'moderator', 'treasurer'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rencontres (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -158,3 +162,46 @@ CREATE TABLE IF NOT EXISTS site_content (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Seasonal membership billing settings and immutable invoice snapshots.
|
||||
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),
|
||||
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()
|
||||
);
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS billing_invoice_number_seq START WITH 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS membership_invoices (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
season TEXT NOT NULL REFERENCES billing_season_settings(season) ON DELETE RESTRICT,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE RESTRICT,
|
||||
invoice_number TEXT NOT NULL UNIQUE,
|
||||
issued_at DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
due_date DATE NOT NULL,
|
||||
member_name TEXT NOT NULL,
|
||||
member_address TEXT NOT NULL DEFAULT '',
|
||||
member_email TEXT NOT NULL DEFAULT '',
|
||||
billing_type TEXT NOT NULL CHECK (billing_type IN ('sluc_partner', 'non_partner')),
|
||||
amount_ht NUMERIC(12,2) NOT NULL CHECK (amount_ht >= 0),
|
||||
vat_rate NUMERIC(5,2) NOT NULL CHECK (vat_rate BETWEEN 0 AND 100),
|
||||
vat_amount NUMERIC(12,2) NOT NULL CHECK (vat_amount >= 0),
|
||||
amount_ttc NUMERIC(12,2) NOT NULL CHECK (amount_ttc >= 0),
|
||||
issuer_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'emise' CHECK (status IN ('emise', 'payee', 'annulee')),
|
||||
payment_method TEXT CHECK (payment_method IN ('carte', 'virement', 'cheque')),
|
||||
paid_at DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK ((status = 'payee' AND payment_method IS NOT NULL AND paid_at IS NOT NULL) OR status <> 'payee')
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_membership_invoices_season ON membership_invoices(season);
|
||||
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';
|
||||
@@ -3,9 +3,9 @@ import { query } from './db.js';
|
||||
import { generateTempPassword } from './passwords.js';
|
||||
import { AccessError } from './errors.js';
|
||||
|
||||
const STAFF_ROLES = ['admin', 'moderator'];
|
||||
const STAFF_ROLES = ['admin', 'moderator', 'treasurer'];
|
||||
|
||||
// Creates an admin or moderator account with a temporary password — same
|
||||
// Creates a staff account with a temporary password — same
|
||||
// readable-until-changed mechanic as member accounts (see memberAccess.js).
|
||||
export async function createStaffUser({ fullName, email, role }) {
|
||||
if (!STAFF_ROLES.includes(role)) throw new AccessError(400, 'Rôle invalide.');
|
||||
@@ -24,14 +24,14 @@ export async function createStaffUser({ fullName, email, role }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerates the temporary password for an existing admin/moderator
|
||||
// Regenerates the temporary password for an existing staff
|
||||
// account (e.g. they lost it).
|
||||
export async function resetStaffAccess(userId) {
|
||||
const tempPassword = generateTempPassword();
|
||||
const hash = await bcrypt.hash(tempPassword, 12);
|
||||
const result = await query(
|
||||
`UPDATE users SET password_hash=$1, temp_password=$2, must_change_password=true
|
||||
WHERE id=$3 AND role IN ('admin', 'moderator') RETURNING id`,
|
||||
WHERE id=$3 AND role IN ('admin', 'moderator', 'treasurer') RETURNING id`,
|
||||
[hash, tempPassword, userId]
|
||||
);
|
||||
if (result.rowCount === 0) throw new AccessError(404, 'Compte introuvable.');
|
||||
|
||||
Reference in new issue
Block a user