jeremy 4 meses atrás
pai
commit
91e4a864f3

+ 20 - 0
backend/src/db.js

@@ -116,4 +116,24 @@ db.exec(`
 // Add recoverable column to charges if missing
 try { db.exec('ALTER TABLE charges ADD COLUMN recoverable INTEGER NOT NULL DEFAULT 1'); } catch {}
 
+// Add charge_regularization column to payments if missing
+try { db.exec('ALTER TABLE payments ADD COLUMN charge_regularization REAL DEFAULT 0'); } catch {}
+
+// Regularizations from annual exercise closure
+db.exec(`
+  CREATE TABLE IF NOT EXISTS charge_regularizations (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    lease_id INTEGER NOT NULL,
+    user_id INTEGER NOT NULL,
+    property_id INTEGER NOT NULL,
+    year INTEGER NOT NULL,
+    amount REAL NOT NULL,
+    notes TEXT,
+    applied_payment_id INTEGER,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (lease_id) REFERENCES leases(id),
+    FOREIGN KEY (user_id) REFERENCES users(id)
+  )
+`);
+
 module.exports = db;

+ 56 - 0
backend/src/routes/charges.js

@@ -446,4 +446,60 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
   }
 });
 
+// Close annual exercise — create charge_regularization for each tenant
+router.post('/bilan/:property_id/:year/close', (req, res) => {
+  const { property_id, year } = req.params;
+
+  const prop = db.prepare('SELECT * FROM properties WHERE id = ? AND user_id = ?').get(property_id, req.userId);
+  if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
+
+  // Check not already closed for this property/year
+  const alreadyClosed = db.prepare(
+    'SELECT id FROM charge_regularizations WHERE user_id = ? AND property_id = ? AND year = ?'
+  ).get(req.userId, property_id, parseInt(year));
+  if (alreadyClosed) return res.status(409).json({ error: `L'exercice ${year} a déjà été clôturé pour ce bien.` });
+
+  // Recompute bilan (recoverable only)
+  const totalRow = db.prepare(
+    'SELECT SUM(amount) as total FROM charges WHERE property_id = ? AND year = ? AND user_id = ? AND recoverable = 1'
+  ).get(property_id, year, req.userId);
+  const totalCharges = totalRow?.total || 0;
+
+  const leases = db.prepare(`
+    SELECT l.*, t.first_name, t.last_name FROM leases l
+    JOIN tenants t ON l.tenant_id = t.id
+    WHERE l.property_id = ? AND l.user_id = ?
+      AND (l.start_date <= ? AND (l.end_date IS NULL OR l.end_date >= ?))
+  `).all(property_id, req.userId, `${year}-12-31`, `${year}-01-01`);
+
+  if (leases.length === 0) return res.status(400).json({ error: 'Aucun locataire sur cette période.' });
+
+  const leaseBilans = leases.map(lease => {
+    const prov = db.prepare('SELECT SUM(charges_paid) as total FROM payments WHERE lease_id = ? AND period_year = ?').get(lease.id, year);
+    return { lease_id: lease.id, tenant: `${lease.first_name} ${lease.last_name}`, provisions: prov?.total || 0 };
+  });
+  const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions, 0);
+
+  const insert = db.prepare(
+    'INSERT INTO charge_regularizations (lease_id, user_id, property_id, year, amount, notes) VALUES (?, ?, ?, ?, ?, ?)'
+  );
+
+  const closeAll = db.transaction(() => {
+    return leaseBilans.map(l => {
+      const ratio = totalProvisions > 0 ? l.provisions / totalProvisions : 1 / leaseBilans.length;
+      const quote_part = totalCharges * ratio;
+      // Positive = trop-perçu (credit tenant), Negative = complément dû (debit tenant)
+      const trop_percu = Math.round((l.provisions - quote_part) * 100) / 100;
+      const label = trop_percu >= 0
+        ? `Régularisation charges ${year} : trop-perçu de ${trop_percu.toFixed(2)} € à déduire`
+        : `Régularisation charges ${year} : complément de ${Math.abs(trop_percu).toFixed(2)} € à appeler`;
+      insert.run(l.lease_id, req.userId, parseInt(property_id), parseInt(year), trop_percu, label);
+      return { lease_id: l.lease_id, tenant: l.tenant, trop_percu };
+    });
+  });
+
+  const results = closeAll();
+  res.json({ success: true, year: parseInt(year), property: prop.name, regularizations: results });
+});
+
 module.exports = router;

+ 28 - 4
backend/src/routes/payments.js

@@ -24,7 +24,7 @@ router.get('/', (req, res) => {
 });
 
 router.post('/', (req, res) => {
-  const { lease_id, period_month, period_year, rent_paid, charges_paid, payment_date, payment_method, notes } = req.body;
+  const { lease_id, period_month, period_year, rent_paid, charges_paid, payment_date, payment_method, notes, charge_regularization } = req.body;
   if (!lease_id || !period_month || !period_year || rent_paid === undefined || !payment_date)
     return res.status(400).json({ error: 'Champs requis manquants' });
 
@@ -36,10 +36,23 @@ router.post('/', (req, res) => {
   ).get(lease_id, period_month, period_year);
   if (existing) return res.status(409).json({ error: 'Un paiement existe déjà pour cette période' });
 
+  const reg = parseFloat(charge_regularization || 0);
+
   const result = db.prepare(`
-    INSERT INTO payments (lease_id, user_id, period_month, period_year, rent_paid, charges_paid, payment_date, payment_method, notes)
-    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
-  `).run(lease_id, req.userId, period_month, period_year, rent_paid, charges_paid || 0, payment_date, payment_method || 'virement', notes || null);
+    INSERT INTO payments (lease_id, user_id, period_month, period_year, rent_paid, charges_paid, payment_date, payment_method, notes, charge_regularization)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+  `).run(lease_id, req.userId, period_month, period_year, rent_paid, charges_paid || 0,
+    payment_date, payment_method || 'virement', notes || null, reg);
+
+  // Mark regularization as applied
+  if (reg !== 0) {
+    db.prepare(`
+      UPDATE charge_regularizations SET applied_payment_id = ?
+      WHERE lease_id = ? AND applied_payment_id IS NULL
+      ORDER BY created_at ASC LIMIT 1
+    `).run(result.lastInsertRowid, lease_id);
+  }
+
   res.status(201).json({ id: result.lastInsertRowid });
 });
 
@@ -50,6 +63,17 @@ router.delete('/:id', (req, res) => {
   res.json({ success: true });
 });
 
+// Pending regularization for a lease
+router.get('/regularization/:lease_id', (req, res) => {
+  const lease = db.prepare('SELECT id FROM leases WHERE id = ? AND user_id = ?').get(req.params.lease_id, req.userId);
+  if (!lease) return res.status(404).json({ error: 'Bail non trouvé' });
+  const rows = db.prepare(
+    'SELECT * FROM charge_regularizations WHERE lease_id = ? AND applied_payment_id IS NULL ORDER BY year DESC'
+  ).all(req.params.lease_id);
+  const total = rows.reduce((s, r) => s + r.amount, 0);
+  res.json({ pending: rows, total: Math.round(total * 100) / 100 });
+});
+
 // Dashboard stats
 router.get('/stats', (req, res) => {
   const stats = db.prepare(`

+ 19 - 7
backend/src/routes/receipts.js

@@ -80,22 +80,34 @@ router.get('/:paymentId', (req, res) => {
   doc.fontSize(12).font('Helvetica-Bold').text('DÉTAIL DU RÈGLEMENT');
   doc.moveDown(0.5);
 
-  const tableTop = doc.y;
   const col1 = 50, col2 = 400;
 
-  const drawRow = (label, value, bold = false) => {
+  const drawRow = (label, value, bold = false, color = '#000000') => {
     const y = doc.y;
-    doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(11);
+    doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(11).fillColor(color);
     doc.text(label, col1, y);
-    doc.text(`${value.toFixed(2)} €`, col2, y, { width: 100, align: 'right' });
+    doc.text(`${Number(value).toFixed(2)} €`, col2, y, { width: 100, align: 'right' });
+    doc.fillColor('#000000');
     doc.moveDown(0.6);
   };
 
   drawRow('Loyer hors charges', payment.rent_paid);
-  drawRow('Charges', payment.charges_paid);
+  drawRow('Charges locatives', payment.charges_paid);
+
+  const reg = payment.charge_regularization || 0;
+  if (reg !== 0) {
+    // positive reg = trop-perçu → déduit du loyer (bonne nouvelle pour locataire)
+    // negative reg = complément → ajouté au loyer
+    const regLabel = reg > 0
+      ? `Régularisation de charges — trop-perçu (déduit)`
+      : `Régularisation de charges — complément appelé`;
+    drawRow(regLabel, -reg, false, reg > 0 ? '#15803d' : '#c2410c');
+  }
+
   doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
   doc.moveDown(0.3);
-  drawRow('TOTAL', total, true);
+  const netTotal = payment.rent_paid + payment.charges_paid - reg;
+  drawRow('TOTAL NET À PAYER', netTotal, true);
   doc.moveDown(0.5);
 
   // Mode et date de paiement
@@ -116,7 +128,7 @@ router.get('/:paymentId', (req, res) => {
   doc.font('Helvetica').fontSize(10).fillColor('#6b7280')
     .text(
       `Je soussigné(e) ${ownerFullName}, bailleur, donne quittance à ${payment.tenant_first_name} ${payment.tenant_last_name} ` +
-      `pour la somme de ${total.toFixed(2)} € correspondant au paiement du loyer et des charges du logement situé ` +
+      `pour la somme de ${netTotal.toFixed(2)} € correspondant au paiement du loyer et des charges du logement situé ` +
       `${payment.property_address}, ${payment.property_zip} ${payment.property_city}, ` +
       `pour la période de ${monthName} ${payment.period_year}.`,
       { align: 'justify' }

+ 30 - 3
frontend/src/pages/Charges.jsx

@@ -47,6 +47,7 @@ export default function Charges() {
   const [bilan, setBilan] = useState(null)
   const [bilanLoading, setBilanLoading] = useState(false)
   const [pdfLoading, setPdfLoading] = useState(false)
+  const [closeLoading, setCloseLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [editCharge, setEditCharge] = useState(null)
 
@@ -312,6 +313,28 @@ export default function Charges() {
     finally { setPdfLoading(false) }
   }
 
+  const closeExercise = async () => {
+    if (!filterProperty || !filterYear) return
+    if (!confirm(
+      `Clôturer l'exercice ${filterYear} pour ce bien ?\n\n` +
+      `Cela calculera la régularisation de charges (plus-value ou moins-value) pour chaque locataire ` +
+      `et la reportera sur le prochain loyer.\n\nCette opération est définitive.`
+    )) return
+    setCloseLoading(true)
+    try {
+      const { data } = await api.post(`/charges/bilan/${filterProperty}/${filterYear}/close`)
+      const lines = data.regularizations.map(r =>
+        `• ${r.tenant} : ${r.trop_percu >= 0 ? '-' : '+'}${Math.abs(r.trop_percu).toFixed(2)} € sur le prochain loyer`
+      ).join('\n')
+      toast.success(`Exercice ${filterYear} clôturé !\n${lines}`, { duration: 6000 })
+      setShowBilan(false)
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur lors de la clôture')
+    } finally {
+      setCloseLoading(false)
+    }
+  }
+
   const fmt = v => `${Number(v).toFixed(2)} €`
   const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—'
   const totalAmount = charges.reduce((s, c) => s + c.amount, 0)
@@ -512,14 +535,18 @@ export default function Charges() {
       {showBilan && bilan && (
         <Modal title={`📊 Bilan annuel ${bilan.year} — ${bilan.property?.name}`} onClose={() => setShowBilan(false)}>
           <div className="space-y-6">
-            <div className="flex justify-end gap-2">
+            <div className="flex justify-end gap-2 flex-wrap">
               <button onClick={printBilan}
                 className="btn-secondary flex items-center gap-2 text-sm">
                 🖨️ Aperçu / Imprimer
               </button>
               <button onClick={downloadPdf} disabled={pdfLoading}
-                className="btn-primary flex items-center gap-2 text-sm">
-                {pdfLoading ? '⏳ Génération…' : '📥 Télécharger PDF complet (avec factures)'}
+                className="btn-secondary flex items-center gap-2 text-sm">
+                {pdfLoading ? '⏳ Génération…' : '📥 PDF complet'}
+              </button>
+              <button onClick={closeExercise} disabled={closeLoading}
+                className="flex items-center gap-2 text-sm px-4 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white font-semibold transition-colors">
+                {closeLoading ? '⏳…' : '✅ Clôturer l\'exercice'}
               </button>
             </div>
             {/* Summary cards */}

+ 80 - 20
frontend/src/pages/LeaseDetail.jsx

@@ -30,6 +30,7 @@ export default function LeaseDetail() {
   const [lease, setLease] = useState(null)
   const [payments, setPayments] = useState([])
   const [revisions, setRevisions] = useState([])
+  const [pendingReg, setPendingReg] = useState({ pending: [], total: 0 })
   const [showModal, setShowModal] = useState(false)
   const [showRevisionModal, setShowRevisionModal] = useState(false)
   const [loading, setLoading] = useState(false)
@@ -43,7 +44,8 @@ export default function LeaseDetail() {
     charges_paid: '',
     payment_date: now.toISOString().slice(0, 10),
     payment_method: 'virement',
-    notes: ''
+    notes: '',
+    apply_regularization: false,
   })
 
   const [revForm, setRevForm] = useState({
@@ -54,14 +56,16 @@ export default function LeaseDetail() {
   })
 
   const load = async () => {
-    const [l, p, r] = await Promise.all([
+    const [l, p, r, reg] = await Promise.all([
       api.get(`/leases/${id}`),
       api.get(`/payments?lease_id=${id}`),
-      api.get(`/leases/${id}/revisions`)
+      api.get(`/leases/${id}/revisions`),
+      api.get(`/payments/regularization/${id}`),
     ])
     setLease(l.data)
     setPayments(p.data)
     setRevisions(r.data)
+    setPendingReg(reg.data)
     setForm(f => ({ ...f, rent_paid: l.data.rent_amount, charges_paid: l.data.charges_amount }))
   }
 
@@ -70,7 +74,12 @@ export default function LeaseDetail() {
   const handlePayment = async e => {
     e.preventDefault(); setLoading(true)
     try {
-      await api.post('/payments', { ...form, lease_id: Number(id) })
+      const payload = {
+        ...form,
+        lease_id: Number(id),
+        charge_regularization: form.apply_regularization ? pendingReg.total : 0,
+      }
+      await api.post('/payments', payload)
       toast.success('Paiement enregistré !')
       setShowModal(false); load()
     } catch (err) { toast.error(err.response?.data?.error || 'Erreur') }
@@ -229,6 +238,24 @@ export default function LeaseDetail() {
         )}
       </div>
 
+      {/* Bannière régularisation en attente */}
+      {pendingReg.pending.length > 0 && (
+        <div className={`mb-4 rounded-xl border p-4 flex items-start gap-3 ${pendingReg.total >= 0 ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
+          <span className="text-2xl">{pendingReg.total >= 0 ? '✅' : '⚠️'}</span>
+          <div>
+            <p className={`font-semibold text-sm ${pendingReg.total >= 0 ? 'text-green-800' : 'text-orange-800'}`}>
+              {pendingReg.total >= 0
+                ? `Régularisation de charges en attente : ${fmt(pendingReg.total)} à déduire du prochain loyer`
+                : `Régularisation de charges en attente : ${fmt(Math.abs(pendingReg.total))} à appeler sur le prochain loyer`}
+            </p>
+            <p className="text-xs text-gray-500 mt-0.5">
+              {pendingReg.pending.map(r => r.notes).join(' · ')}
+            </p>
+            <p className="text-xs text-gray-400 mt-1">Sera appliquée automatiquement lors du prochain paiement enregistré.</p>
+          </div>
+        </div>
+      )}
+
       {payments.length === 0 ? (
         <div className="card text-center py-10">
           <p className="text-gray-400">Aucun paiement enregistré pour ce bail.</p>
@@ -243,25 +270,38 @@ export default function LeaseDetail() {
                 <th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Mode</th>
                 <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Loyer</th>
                 <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Charges</th>
-                <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Total</th>
+                <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Régul.</th>
+                <th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Net</th>
                 <th className="px-6 py-3"></th>
               </tr>
             </thead>
             <tbody className="divide-y divide-gray-50">
-              {payments.map(p => (
-                <tr key={p.id} className="hover:bg-gray-50">
-                  <td className="px-6 py-4 font-medium text-gray-900">{MONTHS[p.period_month-1]} {p.period_year}</td>
-                  <td className="px-6 py-4 text-gray-600">{fmtDate(p.payment_date)}</td>
-                  <td className="px-6 py-4 text-gray-600 capitalize">{p.payment_method}</td>
-                  <td className="px-6 py-4 text-right text-gray-900">{fmt(p.rent_paid)}</td>
-                  <td className="px-6 py-4 text-right text-gray-600">{fmt(p.charges_paid)}</td>
-                  <td className="px-6 py-4 text-right font-semibold text-green-700">{fmt(p.rent_paid + p.charges_paid)}</td>
-                  <td className="px-6 py-4 text-right space-x-2">
-                    <button onClick={() => downloadReceipt(p.id)} className="text-blue-600 hover:text-blue-800 text-sm font-medium" title="Télécharger la quittance PDF">📄 Quittance</button>
-                    <button onClick={() => handleDeletePayment(p.id)} className="text-gray-400 hover:text-red-600 ml-2">🗑️</button>
-                  </td>
-                </tr>
-              ))}
+              {payments.map(p => {
+                const reg = p.charge_regularization || 0
+                const net = p.rent_paid + p.charges_paid - reg
+                return (
+                  <tr key={p.id} className="hover:bg-gray-50">
+                    <td className="px-6 py-4 font-medium text-gray-900">{MONTHS[p.period_month-1]} {p.period_year}</td>
+                    <td className="px-6 py-4 text-gray-600">{fmtDate(p.payment_date)}</td>
+                    <td className="px-6 py-4 text-gray-600 capitalize">{p.payment_method}</td>
+                    <td className="px-6 py-4 text-right text-gray-900">{fmt(p.rent_paid)}</td>
+                    <td className="px-6 py-4 text-right text-gray-600">{fmt(p.charges_paid)}</td>
+                    <td className="px-6 py-4 text-right text-sm">
+                      {reg !== 0
+                        ? <span className={`font-semibold ${reg > 0 ? 'text-green-600' : 'text-orange-600'}`}
+                            title={reg > 0 ? 'Trop-perçu déduit' : 'Complément appelé'}>
+                            {reg > 0 ? `−${fmt(reg)}` : `+${fmt(Math.abs(reg))}`}
+                          </span>
+                        : <span className="text-gray-300">—</span>}
+                    </td>
+                    <td className="px-6 py-4 text-right font-semibold text-green-700">{fmt(net)}</td>
+                    <td className="px-6 py-4 text-right space-x-2">
+                      <button onClick={() => downloadReceipt(p.id)} className="text-blue-600 hover:text-blue-800 text-sm font-medium" title="Télécharger la quittance PDF">📄 Quittance</button>
+                      <button onClick={() => handleDeletePayment(p.id)} className="text-gray-400 hover:text-red-600 ml-2">🗑️</button>
+                    </td>
+                  </tr>
+                )
+              })}
             </tbody>
           </table>
         </div>
@@ -309,8 +349,28 @@ export default function LeaseDetail() {
                 <input className="input" value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="..." />
               </div>
             </div>
+
+            {pendingReg.pending.length > 0 && (
+              <label className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer ${form.apply_regularization ? (pendingReg.total >= 0 ? 'bg-green-50 border-green-300' : 'bg-orange-50 border-orange-300') : 'bg-gray-50 border-gray-200'}`}>
+                <input type="checkbox" className="mt-0.5" checked={form.apply_regularization}
+                  onChange={e => setForm(f => ({ ...f, apply_regularization: e.target.checked }))} />
+                <div>
+                  <p className="text-sm font-semibold text-gray-800">
+                    {pendingReg.total >= 0
+                      ? `Appliquer la régularisation : −${fmt(pendingReg.total)} (trop-perçu à déduire)`
+                      : `Appliquer la régularisation : +${fmt(Math.abs(pendingReg.total))} (complément à appeler)`}
+                  </p>
+                  <p className="text-xs text-gray-500 mt-0.5">Suite à la clôture des charges de l'exercice</p>
+                </div>
+              </label>
+            )}
+
             <div className="bg-blue-50 rounded-lg p-3 text-sm text-blue-700">
-              <strong>Total à encaisser :</strong> {fmt(Number(form.rent_paid || 0) + Number(form.charges_paid || 0))}
+              <strong>Total à encaisser :</strong>{' '}
+              {fmt(
+                Number(form.rent_paid || 0) + Number(form.charges_paid || 0) -
+                (form.apply_regularization ? (pendingReg.total || 0) : 0)
+              )}
             </div>
             <div className="flex gap-3 pt-2">
               <button type="button" onClick={() => setShowModal(false)} className="btn-secondary flex-1">Annuler</button>