charges.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. const express = require('express');
  2. const path = require('path');
  3. const fs = require('fs');
  4. const multer = require('multer');
  5. const PDFDocument = require('pdfkit');
  6. const { PDFDocument: LibPDFDocument, rgb, StandardFonts } = require('pdf-lib');
  7. const pool = require('../db');
  8. const { authMiddleware } = require('../auth');
  9. const router = express.Router();
  10. router.use(authMiddleware);
  11. // Configure multer for invoice uploads
  12. const uploadDir = path.join(__dirname, '../../uploads');
  13. if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
  14. const storage = multer.diskStorage({
  15. destination: (req, file, cb) => cb(null, uploadDir),
  16. filename: (req, file, cb) => {
  17. const ext = path.extname(file.originalname);
  18. cb(null, `charge_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`);
  19. }
  20. });
  21. const upload = multer({
  22. storage,
  23. limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
  24. fileFilter: (req, file, cb) => {
  25. const allowed = ['.pdf', '.jpg', '.jpeg', '.png', '.webp'];
  26. if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true);
  27. else cb(new Error('Fichier non autorisé (PDF, JPG, PNG)'));
  28. }
  29. });
  30. // List charges (filter by property_id, year)
  31. router.get('/', async (req, res) => {
  32. const { property_id, year } = req.query;
  33. const params = [req.userId];
  34. let query = `
  35. SELECT c.*, p.name as property_name
  36. FROM charges c
  37. JOIN properties p ON c.property_id = p.id
  38. WHERE c.user_id = $1
  39. `;
  40. if (property_id) { query += ` AND c.property_id = $${params.push(property_id)}`; }
  41. if (year) { query += ` AND c.year = $${params.push(year)}`; }
  42. query += ' ORDER BY c.date DESC';
  43. res.json((await pool.query(query, params)).rows);
  44. });
  45. // Create charge (with optional invoice file)
  46. router.post('/', upload.single('invoice'), async (req, res) => {
  47. const { property_id, category, label, amount, date, year, notes, recoverable_type } = req.body;
  48. if (!property_id || !category || !label || !amount || !date || !year)
  49. return res.status(400).json({ error: 'Champs requis manquants' });
  50. const prop = (await pool.query('SELECT id FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
  51. if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
  52. const invoice_path = req.file ? req.file.filename : null;
  53. const invoice_name = req.file ? req.file.originalname : null;
  54. const rtype = ['recoverable', 'deductible', 'none'].includes(recoverable_type) ? recoverable_type : 'recoverable';
  55. const result = await pool.query(`
  56. INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes, recoverable, recoverable_type)
  57. VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id
  58. `, [req.userId, property_id, category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, rtype === 'recoverable' ? 1 : 0, rtype]);
  59. res.status(201).json({ id: result.rows[0].id });
  60. });
  61. // Update charge (with optional new invoice)
  62. router.put('/:id', upload.single('invoice'), async (req, res) => {
  63. const charge = (await pool.query('SELECT * FROM charges WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
  64. if (!charge) return res.status(404).json({ error: 'Charge non trouvée' });
  65. const { category, label, amount, date, year, notes, recoverable_type } = req.body;
  66. let invoice_path = charge.invoice_path;
  67. let invoice_name = charge.invoice_name;
  68. if (req.file) {
  69. // Delete old file
  70. if (charge.invoice_path) {
  71. const oldPath = path.join(uploadDir, charge.invoice_path);
  72. if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
  73. }
  74. invoice_path = req.file.filename;
  75. invoice_name = req.file.originalname;
  76. }
  77. const rtype = ['recoverable', 'deductible', 'none'].includes(recoverable_type) ? recoverable_type : 'recoverable';
  78. await pool.query(`
  79. UPDATE charges SET category=$1, label=$2, amount=$3, date=$4, year=$5, invoice_path=$6, invoice_name=$7, notes=$8, recoverable=$9, recoverable_type=$10
  80. WHERE id=$11
  81. `, [category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, rtype === 'recoverable' ? 1 : 0, rtype, req.params.id]);
  82. res.json({ success: true });
  83. });
  84. // Delete charge
  85. router.delete('/:id', async (req, res) => {
  86. const charge = (await pool.query('SELECT * FROM charges WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
  87. if (!charge) return res.status(404).json({ error: 'Charge non trouvée' });
  88. if (charge.invoice_path) {
  89. const filePath = path.join(uploadDir, charge.invoice_path);
  90. if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
  91. }
  92. await pool.query('DELETE FROM charges WHERE id = $1', [req.params.id]);
  93. res.json({ success: true });
  94. });
  95. // Download invoice
  96. router.get('/:id/invoice', async (req, res) => {
  97. const charge = (await pool.query('SELECT * FROM charges WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
  98. if (!charge || !charge.invoice_path) return res.status(404).json({ error: 'Facture non trouvée' });
  99. const filePath = path.join(uploadDir, charge.invoice_path);
  100. if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Fichier introuvable' });
  101. res.setHeader('Content-Disposition', `attachment; filename="${charge.invoice_name}"`);
  102. res.sendFile(filePath);
  103. });
  104. // Annual bilan for a property
  105. router.get('/bilan/:property_id/:year', async (req, res) => {
  106. const { property_id, year } = req.params;
  107. // Verify property ownership
  108. const prop = (await pool.query('SELECT * FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
  109. if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
  110. // Total recoverable charges for this property/year (for tenant billing)
  111. const totalChargesRow = (await pool.query(`
  112. SELECT SUM(amount) as total, COUNT(*) as count
  113. FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
  114. `, [property_id, year, req.userId])).rows[0];
  115. // Total deductible (landlord fiscal charges, for info)
  116. const totalDeductibleRow = (await pool.query(`
  117. SELECT SUM(amount) as total
  118. FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'deductible'
  119. `, [property_id, year, req.userId])).rows[0];
  120. // Total non-recoverable / non-deductible (landlord's own charges, for info)
  121. const totalNonRecovRow = (await pool.query(`
  122. SELECT SUM(amount) as total
  123. FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'none'
  124. `, [property_id, year, req.userId])).rows[0];
  125. const totalChargesReelles = parseFloat(totalChargesRow?.total || 0);
  126. const totalDeductible = parseFloat(totalDeductibleRow?.total || 0);
  127. const totalNonRecoverable = parseFloat(totalNonRecovRow?.total || 0);
  128. // Charges by category (recoverable only)
  129. const chargesByCategory = (await pool.query(`
  130. SELECT category, SUM(amount) as total
  131. FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
  132. GROUP BY category ORDER BY total DESC
  133. `, [property_id, year, req.userId])).rows;
  134. // Non-recoverable by category (for info)
  135. const chargesNonRecovByCategory = (await pool.query(`
  136. SELECT category, SUM(amount) as total
  137. FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type != 'recoverable'
  138. GROUP BY category ORDER BY total DESC
  139. `, [property_id, year, req.userId])).rows;
  140. // All leases on this property (active or that were active during the year)
  141. const leases = (await pool.query(`
  142. SELECT l.*, t.first_name, t.last_name, t.email
  143. FROM leases l
  144. JOIN tenants t ON l.tenant_id = t.id
  145. WHERE l.property_id = $1 AND l.user_id = $2
  146. AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
  147. `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
  148. // For each lease, get provisions perçues for the year
  149. const leaseBilans = await Promise.all(leases.map(async lease => {
  150. const provisions = (await pool.query(`
  151. SELECT SUM(charges_paid) as total
  152. FROM payments
  153. WHERE lease_id = $1 AND period_year = $2
  154. `, [lease.id, year])).rows[0];
  155. return {
  156. lease_id: lease.id,
  157. tenant_name: `${lease.first_name} ${lease.last_name}`,
  158. tenant_email: lease.email,
  159. monthly_provision: lease.charges_amount,
  160. provisions_percues: provisions?.total || 0
  161. };
  162. }));
  163. // Calculate total provisions across all tenants for proportional split
  164. const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
  165. // Compute each tenant's share
  166. const bilans = leaseBilans.map(l => {
  167. const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
  168. const quote_part_charges = totalChargesReelles * ratio;
  169. const trop_percu = l.provisions_percues - quote_part_charges;
  170. return {
  171. ...l,
  172. ratio_percent: Math.round(ratio * 100 * 100) / 100,
  173. quote_part_charges: Math.round(quote_part_charges * 100) / 100,
  174. trop_percu: Math.round(trop_percu * 100) / 100
  175. };
  176. });
  177. res.json({
  178. property: prop,
  179. year: parseInt(year),
  180. total_charges_reelles: Math.round(totalChargesReelles * 100) / 100,
  181. total_deductible: Math.round(totalDeductible * 100) / 100,
  182. total_non_recoverable: Math.round(totalNonRecoverable * 100) / 100,
  183. charges_count: totalChargesRow?.count || 0,
  184. charges_by_category: chargesByCategory,
  185. charges_non_recov_by_category: chargesNonRecovByCategory,
  186. total_provisions_percues: Math.round(totalProvisions * 100) / 100,
  187. bilans
  188. });
  189. });
  190. // Export PDF bilan + invoices
  191. router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
  192. const { property_id, year } = req.params;
  193. const prop = (await pool.query('SELECT * FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
  194. if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
  195. // Chercher le propriétaire lié au bail actif sur ce bien, sinon fallback profil gestionnaire
  196. const ownerRow = (await pool.query(`
  197. SELECT o.type as o_type, o.first_name, o.last_name, o.company_name, o.siret,
  198. o.address, o.zip_code, o.city, o.email, o.phone
  199. FROM leases l
  200. JOIN owners o ON l.owner_id = o.id
  201. WHERE l.property_id = $1 AND l.user_id = $2 AND o.id IS NOT NULL
  202. ORDER BY l.active DESC, l.start_date DESC
  203. LIMIT 1
  204. `, [property_id, req.userId])).rows[0];
  205. let ownerFullName, ownerAddress, ownerEmail, ownerPhone, ownerSiret;
  206. if (ownerRow) {
  207. ownerFullName = ownerRow.o_type === 'morale'
  208. ? ownerRow.company_name
  209. : `${ownerRow.first_name || ''} ${ownerRow.last_name || ''}`.trim();
  210. ownerSiret = ownerRow.siret;
  211. ownerAddress = [ownerRow.address, ownerRow.zip_code && ownerRow.city ? `${ownerRow.zip_code} ${ownerRow.city}` : (ownerRow.zip_code || ownerRow.city)].filter(Boolean).join(', ');
  212. ownerEmail = ownerRow.email;
  213. ownerPhone = ownerRow.phone;
  214. } else {
  215. const u = (await pool.query('SELECT name, first_name, last_name, email, address, phone FROM users WHERE id = $1', [req.userId])).rows[0];
  216. ownerFullName = (u.first_name && u.last_name) ? `${u.first_name} ${u.last_name}` : u.name;
  217. ownerSiret = null;
  218. ownerAddress = u.address;
  219. ownerEmail = u.email;
  220. ownerPhone = u.phone;
  221. }
  222. const totalChargesRow = (await pool.query(`
  223. SELECT SUM(amount) as total, COUNT(*) as count FROM charges
  224. WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
  225. `, [property_id, year, req.userId])).rows[0];
  226. const totalChargesReelles = parseFloat(totalChargesRow?.total || 0);
  227. const chargesList = (await pool.query(`
  228. SELECT * FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3
  229. ORDER BY date ASC
  230. `, [property_id, year, req.userId])).rows;
  231. const chargesByCategory = (await pool.query(`
  232. SELECT category, SUM(amount) as total FROM charges
  233. WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
  234. GROUP BY category ORDER BY total DESC
  235. `, [property_id, year, req.userId])).rows;
  236. const leases = (await pool.query(`
  237. SELECT l.*, t.first_name, t.last_name, t.email FROM leases l
  238. JOIN tenants t ON l.tenant_id = t.id
  239. WHERE l.property_id = $1 AND l.user_id = $2
  240. AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
  241. `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
  242. const leaseBilans = await Promise.all(leases.map(async lease => {
  243. const provisions = (await pool.query(`SELECT SUM(charges_paid) as total FROM payments WHERE lease_id = $1 AND period_year = $2`, [lease.id, year])).rows[0];
  244. return {
  245. lease_id: lease.id,
  246. tenant_name: `${lease.first_name} ${lease.last_name}`,
  247. tenant_email: lease.email,
  248. provisions_percues: provisions?.total || 0
  249. };
  250. }));
  251. const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
  252. const bilans = leaseBilans.map(l => {
  253. const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
  254. const quote_part = totalChargesReelles * ratio;
  255. const trop_percu = l.provisions_percues - quote_part;
  256. return { ...l, ratio_percent: Math.round(ratio * 100 * 100) / 100, quote_part_charges: Math.round(quote_part * 100) / 100, trop_percu: Math.round(trop_percu * 100) / 100 };
  257. });
  258. const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien / Réparations', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance', autre: 'Autre' };
  259. const fmtEur = v => `${Number(v).toFixed(2)} EUR`;
  260. const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—';
  261. const solde = totalProvisions - totalChargesReelles;
  262. try {
  263. // --- Step 1: Generate bilan PDF with pdfkit ---
  264. const bilanBuf = await new Promise((resolve, reject) => {
  265. const doc = new PDFDocument({ margin: 50, size: 'A4' });
  266. const chunks = [];
  267. doc.on('data', c => chunks.push(c));
  268. doc.on('end', () => resolve(Buffer.concat(chunks)));
  269. doc.on('error', reject);
  270. const W = doc.page.width - 100;
  271. const blue = '#2563eb'; const red = '#dc2626'; const green = '#15803d'; const orange = '#c2410c'; const gray = '#6b7280';
  272. // Header
  273. doc.fontSize(20).fillColor('#111827').text(`Bilan annuel de charges ${year}`, { align: 'left' });
  274. doc.fontSize(11).fillColor(gray).text(`${prop.name} — ${prop.address || ''}`, { align: 'left' });
  275. doc.moveDown(0.5);
  276. doc.fontSize(10).fillColor(gray).text(`Document généré le ${new Date().toLocaleDateString('fr-FR')}`, { align: 'right' });
  277. doc.moveDown(0.8);
  278. // Bailleur info
  279. doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
  280. doc.moveDown(0.5);
  281. doc.fontSize(9).fillColor(gray).text('BAILLEUR', { continued: false });
  282. doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(ownerFullName);
  283. doc.font('Helvetica');
  284. if (ownerSiret) doc.fontSize(10).fillColor('#374151').text(`SIRET : ${ownerSiret}`);
  285. if (ownerAddress) doc.fontSize(10).fillColor('#374151').text(ownerAddress);
  286. if (ownerEmail) doc.fontSize(10).fillColor('#374151').text(ownerEmail);
  287. if (ownerPhone) doc.fontSize(10).fillColor('#374151').text(ownerPhone);
  288. doc.moveDown(0.5);
  289. doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
  290. doc.moveDown(1);
  291. // Summary boxes
  292. doc.fontSize(11).fillColor('#111827').text('Synthèse', { underline: true });
  293. doc.moveDown(0.5);
  294. const bx = 50; const bw = (W - 20) / 3;
  295. [[`Charges réelles`, fmtEur(totalChargesReelles), `${totalChargesRow?.count || 0} facture(s)`, red],
  296. [`Provisions perçues`, fmtEur(totalProvisions), 'de tous les locataires', blue],
  297. [solde >= 0 ? 'Trop-perçu global' : 'Solde insuffisant', `${solde >= 0 ? '+' : '-'}${fmtEur(Math.abs(solde))}`, solde >= 0 ? 'à restituer' : 'à appeler', solde >= 0 ? green : orange]
  298. ].forEach(([label, val, note, color], i) => {
  299. const x = bx + i * (bw + 10); const y = doc.y;
  300. doc.rect(x, y, bw, 60).stroke('#e5e7eb');
  301. doc.fontSize(9).fillColor(gray).text(label, x + 8, y + 8, { width: bw - 16 });
  302. doc.fontSize(14).fillColor(color).text(val, x + 8, y + 22, { width: bw - 16 });
  303. doc.fontSize(8).fillColor(gray).text(note, x + 8, y + 42, { width: bw - 16 });
  304. });
  305. doc.moveDown(4.5);
  306. // Charges by category
  307. if (chargesByCategory.length > 0) {
  308. doc.fontSize(11).fillColor('#111827').text('Répartition par catégorie', { underline: true });
  309. doc.moveDown(0.5);
  310. const colWidths = [220, 100, 80];
  311. const headers = ['Catégorie', 'Montant', '%'];
  312. let tx = 50; let ty = doc.y;
  313. doc.fontSize(9).fillColor(gray);
  314. headers.forEach((h, i) => { doc.text(h, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
  315. doc.moveDown(0.3);
  316. doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').stroke();
  317. doc.moveDown(0.3);
  318. chargesByCategory.forEach(c => {
  319. const pct = totalChargesReelles > 0 ? (c.total / totalChargesReelles * 100).toFixed(1) : '0.0';
  320. tx = 50; ty = doc.y;
  321. doc.fontSize(9).fillColor('#111827');
  322. [catLabels[c.category] || c.category, fmtEur(c.total), `${pct}%`].forEach((v, i) => { doc.text(v, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
  323. doc.moveDown(0.4);
  324. });
  325. tx = 50; ty = doc.y;
  326. doc.fontSize(9).fillColor('#111827').font('Helvetica-Bold');
  327. ['Total', fmtEur(totalChargesReelles), ''].forEach((v, i) => { doc.text(v, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
  328. doc.font('Helvetica');
  329. doc.moveDown(1.5);
  330. }
  331. // Per tenant bilan
  332. if (bilans.length > 0) {
  333. doc.fontSize(11).fillColor('#111827').text('Décompte par locataire', { underline: true });
  334. doc.moveDown(0.5);
  335. bilans.forEach(b => {
  336. const isPos = b.trop_percu >= 0;
  337. const color = isPos ? green : orange;
  338. const bY = doc.y;
  339. doc.rect(50, bY, W, 90).stroke('#e5e7eb');
  340. doc.fontSize(11).fillColor('#111827').font('Helvetica-Bold').text(b.tenant_name, 62, bY + 10, { width: W - 24 });
  341. doc.font('Helvetica');
  342. if (b.tenant_email) doc.fontSize(9).fillColor(gray).text(b.tenant_email, 62, bY + 24);
  343. const badge = isPos ? 'Remboursement' : 'Appel de fonds';
  344. doc.fontSize(9).fillColor(color).text(badge, 62, bY + 38);
  345. const cols = [['Provisions perçues', fmtEur(b.provisions_percues)], [`Quote-part (${b.ratio_percent}%)`, fmtEur(b.quote_part_charges)], [isPos ? 'Trop-perçu' : 'Solde dû', fmtEur(Math.abs(b.trop_percu))]];
  346. const cw = (W - 24) / 3;
  347. cols.forEach(([lbl, val], i) => {
  348. const cx = 62 + i * (cw + 8);
  349. doc.fontSize(8).fillColor(gray).text(lbl, cx, bY + 54, { width: cw });
  350. doc.fontSize(10).fillColor(i === 2 ? color : '#111827').font('Helvetica-Bold').text(val, cx, bY + 66, { width: cw });
  351. doc.font('Helvetica');
  352. });
  353. doc.y = bY + 100;
  354. doc.moveDown(0.3);
  355. });
  356. }
  357. // Charges list
  358. if (chargesList.length > 0) {
  359. doc.addPage();
  360. doc.fontSize(13).fillColor('#111827').text(`Détail des charges ${year}`, { underline: true });
  361. doc.moveDown(0.5);
  362. const hdrs = ['Date', 'Catégorie', 'Libellé', 'Montant', 'Facture'];
  363. const cws2 = [70, 100, 180, 80, 100];
  364. let hx = 50; const hy = doc.y;
  365. doc.fontSize(9).fillColor(gray);
  366. hdrs.forEach((h, i) => { doc.text(h, hx, hy, { width: cws2[i] }); hx += cws2[i]; });
  367. doc.moveDown(0.3);
  368. doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').stroke();
  369. doc.moveDown(0.3);
  370. chargesList.forEach(c => {
  371. if (doc.y > doc.page.height - 80) { doc.addPage(); }
  372. hx = 50; const row = doc.y;
  373. doc.fontSize(9).fillColor('#111827');
  374. [fmtDate(c.date), catLabels[c.category] || c.category, c.label, fmtEur(c.amount), c.invoice_name ? `📎 Voir annexe` : '—'].forEach((v, i) => {
  375. doc.text(v, hx, row, { width: cws2[i] }); hx += cws2[i];
  376. });
  377. doc.moveDown(0.5);
  378. });
  379. }
  380. doc.end();
  381. });
  382. // --- Step 2: Merge bilan + invoice files with pdf-lib ---
  383. const mergedPdf = await LibPDFDocument.create();
  384. // Copy bilan pages
  385. const bilanPdf = await LibPDFDocument.load(bilanBuf);
  386. const bilanPages = await mergedPdf.copyPages(bilanPdf, bilanPdf.getPageIndices());
  387. bilanPages.forEach(p => mergedPdf.addPage(p));
  388. // Append each invoice
  389. for (const charge of chargesList) {
  390. if (!charge.invoice_path) continue;
  391. const filePath = path.join(uploadDir, charge.invoice_path);
  392. if (!fs.existsSync(filePath)) continue;
  393. const ext = path.extname(charge.invoice_path).toLowerCase();
  394. const fileBytes = fs.readFileSync(filePath);
  395. if (ext === '.pdf') {
  396. try {
  397. const invPdf = await LibPDFDocument.load(fileBytes, { ignoreEncryption: true });
  398. const invPages = await mergedPdf.copyPages(invPdf, invPdf.getPageIndices());
  399. invPages.forEach(p => mergedPdf.addPage(p));
  400. } catch { /* skip unreadable PDFs */ }
  401. } else if (['.jpg', '.jpeg'].includes(ext)) {
  402. try {
  403. const img = await mergedPdf.embedJpg(fileBytes);
  404. const page = mergedPdf.addPage([img.width, img.height]);
  405. page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
  406. } catch { /* skip unreadable images */ }
  407. } else if (ext === '.png') {
  408. try {
  409. const img = await mergedPdf.embedPng(fileBytes);
  410. const page = mergedPdf.addPage([img.width, img.height]);
  411. page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
  412. } catch { /* skip unreadable images */ }
  413. }
  414. // webp not supported by pdf-lib natively — skipped
  415. }
  416. const pdfBytes = await mergedPdf.save();
  417. const filename = `bilan_charges_${year}_${prop.name.replace(/[^a-z0-9]/gi, '_')}.pdf`;
  418. res.setHeader('Content-Type', 'application/pdf');
  419. res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
  420. res.send(Buffer.from(pdfBytes));
  421. } catch (err) {
  422. console.error('PDF generation error:', err);
  423. res.status(500).json({ error: 'Erreur génération PDF' });
  424. }
  425. });
  426. // Close annual exercise — create charge_regularization for each tenant
  427. router.post('/bilan/:property_id/:year/close', async (req, res) => {
  428. const { property_id, year } = req.params;
  429. const prop = (await pool.query('SELECT * FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
  430. if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
  431. // Check not already closed for this property/year
  432. const alreadyClosed = (await pool.query(
  433. 'SELECT id FROM charge_regularizations WHERE user_id = $1 AND property_id = $2 AND year = $3',
  434. [req.userId, property_id, parseInt(year)]
  435. )).rows[0];
  436. if (alreadyClosed) return res.status(409).json({ error: `L'exercice ${year} a déjà été clôturé pour ce bien.` });
  437. // Recompute bilan (recoverable only)
  438. const totalRow = (await pool.query(
  439. `SELECT SUM(amount) as total FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'`,
  440. [property_id, year, req.userId]
  441. )).rows[0];
  442. const totalCharges = totalRow?.total || 0;
  443. const leases = (await pool.query(`
  444. SELECT l.*, t.first_name, t.last_name FROM leases l
  445. JOIN tenants t ON l.tenant_id = t.id
  446. WHERE l.property_id = $1 AND l.user_id = $2
  447. AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
  448. `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
  449. if (leases.length === 0) return res.status(400).json({ error: 'Aucun locataire sur cette période.' });
  450. const leaseBilans = await Promise.all(leases.map(async lease => {
  451. const prov = (await pool.query('SELECT SUM(charges_paid) as total FROM payments WHERE lease_id = $1 AND period_year = $2', [lease.id, year])).rows[0];
  452. return { lease_id: lease.id, tenant: `${lease.first_name} ${lease.last_name}`, provisions: prov?.total || 0 };
  453. }));
  454. const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions, 0);
  455. const client = await pool.connect();
  456. try {
  457. await client.query('BEGIN');
  458. const results = [];
  459. for (const l of leaseBilans) {
  460. const ratio = totalProvisions > 0 ? l.provisions / totalProvisions : 1 / leaseBilans.length;
  461. const quote_part = totalCharges * ratio;
  462. // Positive = trop-perçu (credit tenant), Negative = complément dû (debit tenant)
  463. const trop_percu = Math.round((l.provisions - quote_part) * 100) / 100;
  464. const label = trop_percu >= 0
  465. ? `Régularisation charges ${year} : trop-perçu de ${trop_percu.toFixed(2)} € à déduire`
  466. : `Régularisation charges ${year} : complément de ${Math.abs(trop_percu).toFixed(2)} € à appeler`;
  467. await client.query(
  468. 'INSERT INTO charge_regularizations (lease_id, user_id, property_id, year, amount, notes) VALUES ($1, $2, $3, $4, $5, $6)',
  469. [l.lease_id, req.userId, parseInt(property_id), parseInt(year), trop_percu, label]
  470. );
  471. results.push({ lease_id: l.lease_id, tenant: l.tenant, trop_percu });
  472. }
  473. await client.query('COMMIT');
  474. res.json({ success: true, year: parseInt(year), property: prop.name, regularizations: results });
  475. } catch (e) {
  476. await client.query('ROLLBACK');
  477. throw e;
  478. } finally {
  479. client.release();
  480. }
  481. });
  482. module.exports = router;