jeremy 4 maanden geleden
bovenliggende
commit
1aff2bd9a0
7 gewijzigde bestanden met toevoegingen van 206 en 12 verwijderingen
  1. 4 0
      .env.example
  2. 3 0
      backend/src/db.js
  3. 55 0
      backend/src/routes/irl.js
  4. 12 8
      backend/src/routes/leases.js
  5. 1 0
      backend/src/server.js
  6. 2 0
      docker-compose.yml
  7. 129 4
      frontend/src/pages/LeaseDetail.jsx

+ 4 - 0
.env.example

@@ -16,3 +16,7 @@ OIDC_REDIRECT_URI=http://localhost:3001/api/auth/oidc/callback
 OIDC_SCOPE=openid email profile
 OIDC_BUTTON_LABEL=Se connecter via SSO
 FRONTEND_URL=http://localhost:3000
+
+# INSEE API (optionnel — pour récupérer l'IRL automatiquement)
+# Obtenir un token sur https://api.insee.fr/catalogue/
+INSEE_API_TOKEN=

+ 3 - 0
backend/src/db.js

@@ -145,6 +145,9 @@ async function initDb() {
   await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT UNIQUE`);
   await pool.query(`ALTER TABLE users ALTER COLUMN password DROP NOT NULL`);
   await pool.query(`ALTER TABLE leases ADD COLUMN IF NOT EXISTS owner_id INTEGER REFERENCES owners(id)`);
+  await pool.query(`ALTER TABLE leases ADD COLUMN IF NOT EXISTS irl_reference_value REAL`);
+  await pool.query(`ALTER TABLE leases ADD COLUMN IF NOT EXISTS irl_reference_quarter INTEGER`);
+  await pool.query(`ALTER TABLE leases ADD COLUMN IF NOT EXISTS irl_reference_year INTEGER`);
   await pool.query(`ALTER TABLE charges ADD COLUMN IF NOT EXISTS recoverable INTEGER NOT NULL DEFAULT 1`);
   await pool.query(`ALTER TABLE charges ADD COLUMN IF NOT EXISTS recoverable_type TEXT DEFAULT 'recoverable'`);
   // Backfill recoverable_type from legacy integer column

+ 55 - 0
backend/src/routes/irl.js

@@ -0,0 +1,55 @@
+const express = require('express');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+
+// IRL France entière — série INSEE 001515333
+const IRL_SERIES = '001515333';
+
+async function fetchIrlFromInsee(year, quarter) {
+  const token = process.env.INSEE_API_TOKEN;
+  if (!token) return null;
+
+  const period = `${year}-Q${quarter}`;
+  const url = `https://api.insee.fr/series/BDM/V1/data/SERIES_BDM/${IRL_SERIES}?startPeriod=${period}&endPeriod=${period}`;
+
+  const resp = await fetch(url, {
+    headers: {
+      'Authorization': `Bearer ${token}`,
+      'Accept': 'application/json',
+    },
+  });
+
+  if (!resp.ok) throw new Error(`INSEE API ${resp.status}`);
+
+  const data = await resp.json();
+
+  // SDMX-JSON parsing
+  const seriesKey = Object.keys(data.dataSets[0].series)[0];
+  const observations = data.dataSets[0].series[seriesKey].observations;
+  const obsKey = Object.keys(observations)[0];
+  const value = observations[obsKey][0];
+
+  if (value === undefined || value === null) throw new Error('Valeur IRL introuvable pour cette période');
+  return parseFloat(value);
+}
+
+// GET /api/irl?year=2024&quarter=2
+router.get('/', authMiddleware, async (req, res) => {
+  const { year, quarter } = req.query;
+  if (!year || !quarter) return res.status(400).json({ error: 'year et quarter requis' });
+
+  const configured = !!process.env.INSEE_API_TOKEN;
+
+  if (!configured) return res.json({ value: null, configured: false });
+
+  try {
+    const value = await fetchIrlFromInsee(parseInt(year), parseInt(quarter));
+    res.json({ value, configured: true, year: parseInt(year), quarter: parseInt(quarter) });
+  } catch (e) {
+    console.error('IRL fetch error:', e.message);
+    res.json({ value: null, configured: true, error: e.message });
+  }
+});
+
+module.exports = router;

+ 12 - 8
backend/src/routes/leases.js

@@ -44,7 +44,7 @@ router.get('/:id', async (req, res) => {
 });
 
 router.post('/', async (req, res) => {
-  const { property_id, tenant_id, owner_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day } = req.body;
+  const { property_id, tenant_id, owner_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day, irl_reference_value, irl_reference_quarter, irl_reference_year } = req.body;
   if (!property_id || !tenant_id || !start_date || !rent_amount)
     return res.status(400).json({ error: 'Champs requis manquants' });
 
@@ -52,25 +52,29 @@ router.post('/', async (req, res) => {
   if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
 
   const result = await pool.query(`
-    INSERT INTO leases (user_id, property_id, tenant_id, owner_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day)
-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id
-  `, [req.userId, property_id, tenant_id, owner_id || null, start_date, end_date || null, rent_amount, charges_amount || 0, deposit_amount || 0, payment_day || 1]);
+    INSERT INTO leases (user_id, property_id, tenant_id, owner_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day, irl_reference_value, irl_reference_quarter, irl_reference_year)
+    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id
+  `, [req.userId, property_id, tenant_id, owner_id || null, start_date, end_date || null, rent_amount, charges_amount || 0, deposit_amount || 0, payment_day || 1,
+      irl_reference_value || null, irl_reference_quarter || null, irl_reference_year || null]);
   res.status(201).json({ id: result.rows[0].id });
 });
 
 router.put('/:id', async (req, res) => {
   const lease = (await pool.query('SELECT id FROM leases WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
   if (!lease) return res.status(404).json({ error: 'Bail non trouvé' });
-  const { property_id, tenant_id, owner_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day, active } = req.body;
+  const { property_id, tenant_id, owner_id, start_date, end_date, rent_amount, charges_amount, deposit_amount, payment_day, active, irl_reference_value, irl_reference_quarter, irl_reference_year } = req.body;
   await pool.query(`
     UPDATE leases SET property_id=COALESCE($1,property_id), tenant_id=COALESCE($2,tenant_id),
       owner_id=$3, start_date=COALESCE($4,start_date), end_date=$5,
-      rent_amount=$6, charges_amount=$7, deposit_amount=$8, payment_day=$9, active=$10
-    WHERE id=$11
+      rent_amount=$6, charges_amount=$7, deposit_amount=$8, payment_day=$9, active=$10,
+      irl_reference_value=$11, irl_reference_quarter=$12, irl_reference_year=$13
+    WHERE id=$14
   `, [property_id || null, tenant_id || null, owner_id || null,
     start_date || null, end_date || null,
     rent_amount, charges_amount || 0, deposit_amount || 0, payment_day || 1,
-    active !== undefined ? active : 1, req.params.id]);
+    active !== undefined ? active : 1,
+    irl_reference_value || null, irl_reference_quarter || null, irl_reference_year || null,
+    req.params.id]);
   res.json({ success: true });
 });
 

+ 1 - 0
backend/src/server.js

@@ -22,6 +22,7 @@ app.use('/api/leases', require('./routes/leases'));
 app.use('/api/payments', require('./routes/payments'));
 app.use('/api/receipts', require('./routes/receipts'));
 app.use('/api/charges', require('./routes/charges'));
+app.use('/api/irl', require('./routes/irl'));
 
 app.get('/api/health', (_, res) => res.json({ ok: true }));
 

+ 2 - 0
docker-compose.yml

@@ -37,6 +37,8 @@ services:
       - OIDC_SCOPE=${OIDC_SCOPE:-openid email profile}
       - OIDC_BUTTON_LABEL=${OIDC_BUTTON_LABEL:-Se connecter via SSO}
       - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
+      # INSEE API (optionnel — pour récupérer l'IRL automatiquement)
+      - INSEE_API_TOKEN=${INSEE_API_TOKEN:-}
     depends_on:
       - postgres
     restart: unless-stopped

+ 129 - 4
frontend/src/pages/LeaseDetail.jsx

@@ -67,6 +67,38 @@ export default function LeaseDetail() {
     charges_amount: '',
     notes: ''
   })
+  const [irlCurrent, setIrlCurrent] = useState({ value: null, loading: false, quarter: null, year: null })
+
+  // Retourne le trimestre (1-4) d'une date ISO
+  const getQuarter = dateStr => Math.ceil((new Date(dateStr).getMonth() + 1) / 3)
+
+  // Ouvre le modal révision et tente de récupérer l'IRL du trimestre de référence pour l'année en cours
+  const openRevisionModal = async () => {
+    setRevForm({ effective_date: now.toISOString().slice(0, 10), rent_amount: String(lease.rent_amount), charges_amount: String(lease.charges_amount), notes: '' })
+    setIrlCurrent({ value: null, loading: false, quarter: null, year: null })
+
+    if (lease.irl_reference_quarter) {
+      const q = lease.irl_reference_quarter
+      const y = now.getFullYear()
+      setIrlCurrent(s => ({ ...s, loading: true, quarter: q, year: y }))
+      try {
+        const { data } = await api.get(`/irl?year=${y}&quarter=${q}`)
+        setIrlCurrent({ value: data.value, loading: false, quarter: q, year: y, configured: data.configured })
+        // Pré-calculer le loyer si IRL de référence connu
+        if (data.value && lease.irl_reference_value) {
+          const ratio = data.value / lease.irl_reference_value
+          setRevForm(f => ({
+            ...f,
+            rent_amount: (lease.rent_amount * ratio).toFixed(2),
+            notes: `Révision IRL T${q} ${y} — indice ${data.value} / ref ${lease.irl_reference_value}`
+          }))
+        }
+      } catch {
+        setIrlCurrent(s => ({ ...s, loading: false }))
+      }
+    }
+    setShowRevisionModal(true)
+  }
 
   const load = async () => {
     const [l, p, r, reg, props, ten, own] = await Promise.all([
@@ -163,6 +195,9 @@ export default function LeaseDetail() {
       charges_amount: String(lease.charges_amount),
       deposit_amount: String(lease.deposit_amount),
       payment_day: String(lease.payment_day),
+      irl_reference_value: lease.irl_reference_value ? String(lease.irl_reference_value) : '',
+      irl_reference_quarter: lease.irl_reference_quarter ? String(lease.irl_reference_quarter) : '',
+      irl_reference_year: lease.irl_reference_year ? String(lease.irl_reference_year) : '',
     })
     setShowEditLeaseModal(true)
   }
@@ -180,6 +215,9 @@ export default function LeaseDetail() {
         deposit_amount: parseFloat(leaseForm.deposit_amount || 0),
         payment_day: parseInt(leaseForm.payment_day || 1),
         active: lease.active,
+        irl_reference_value: leaseForm.irl_reference_value ? parseFloat(leaseForm.irl_reference_value) : null,
+        irl_reference_quarter: leaseForm.irl_reference_quarter ? parseInt(leaseForm.irl_reference_quarter) : null,
+        irl_reference_year: leaseForm.irl_reference_year ? parseInt(leaseForm.irl_reference_year) : null,
       })
       toast.success('Bail mis à jour !')
       setShowEditLeaseModal(false)
@@ -247,10 +285,7 @@ export default function LeaseDetail() {
           </div>
           <div className="flex flex-wrap items-center gap-2">
             {lease.active && (
-              <button onClick={() => {
-                setRevForm({ effective_date: now.toISOString().slice(0, 10), rent_amount: String(lease.rent_amount), charges_amount: String(lease.charges_amount), notes: '' })
-                setShowRevisionModal(true)
-              }} className="btn-secondary text-sm">📈 Réviser</button>
+              <button onClick={openRevisionModal} className="btn-secondary text-sm">📈 Réviser</button>
             )}
             <button onClick={openEditLease} className="btn-secondary text-sm">✏️ Modifier</button>
             <span className={lease.active ? 'badge-green' : 'badge-red'}>{lease.active ? 'Actif' : 'Clôturé'}</span>
@@ -286,6 +321,15 @@ export default function LeaseDetail() {
             <p className="text-sm text-gray-500">Échéance</p>
             <p className="font-semibold text-gray-900">Le {lease.payment_day} du mois</p>
           </div>
+          {lease.irl_reference_value && (
+            <div>
+              <p className="text-sm text-gray-500">IRL de référence</p>
+              <p className="font-semibold text-gray-900">
+                {lease.irl_reference_value}
+                <span className="text-xs text-gray-400 font-normal ml-1">(T{lease.irl_reference_quarter} {lease.irl_reference_year})</span>
+              </p>
+            </div>
+          )}
         </div>
 
         {/* Propriétaire bailleur */}
@@ -738,6 +782,41 @@ export default function LeaseDetail() {
                 Si la date est aujourd'hui ou passée, le loyer en vigueur sera mis à jour immédiatement.
               </p>
             </div>
+
+            {/* Bloc IRL */}
+            {lease.irl_reference_quarter ? (
+              <div className="bg-indigo-50 border border-indigo-100 rounded-xl p-4 space-y-2">
+                <p className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">Indice IRL</p>
+                <div className="grid grid-cols-2 gap-3 text-sm">
+                  <div>
+                    <p className="text-gray-500 text-xs">IRL de référence (T{lease.irl_reference_quarter} {lease.irl_reference_year})</p>
+                    <p className="font-bold text-gray-800">{lease.irl_reference_value ?? '—'}</p>
+                  </div>
+                  <div>
+                    <p className="text-gray-500 text-xs">IRL T{irlCurrent.quarter} {irlCurrent.year} (en cours)</p>
+                    {irlCurrent.loading
+                      ? <p className="text-indigo-500 text-xs">⏳ Récupération…</p>
+                      : irlCurrent.value !== null
+                        ? <p className="font-bold text-indigo-700">{irlCurrent.value}</p>
+                        : <p className="text-gray-400 text-xs">{irlCurrent.configured === false ? 'INSEE non configuré' : 'Indice non disponible'}</p>}
+                  </div>
+                </div>
+                {irlCurrent.value && lease.irl_reference_value && (
+                  <p className="text-xs text-indigo-600 pt-1 border-t border-indigo-100">
+                    Variation : <strong>+{((irlCurrent.value / lease.irl_reference_value - 1) * 100).toFixed(2)} %</strong>
+                    {' '}— loyer calculé : <strong>{fmt(lease.rent_amount * irlCurrent.value / lease.irl_reference_value)}</strong>
+                  </p>
+                )}
+                {!irlCurrent.configured && (
+                  <p className="text-xs text-gray-400">Configurez <code>INSEE_API_TOKEN</code> pour la récupération automatique.</p>
+                )}
+              </div>
+            ) : (
+              <div className="bg-gray-50 border border-gray-200 rounded-xl p-3 text-xs text-gray-400">
+                💡 Renseignez l'IRL de référence dans les informations du bail pour bénéficier du pré-calcul automatique.
+              </div>
+            )}
+
             <div className="grid grid-cols-2 gap-4">
               <div>
                 <label className="label">Nouveau loyer HC (€) *</label>
@@ -846,6 +925,52 @@ export default function LeaseDetail() {
               </div>
             </div>
 
+            {/* IRL de référence */}
+            <div className="border-t pt-4">
+              <p className="text-sm font-semibold text-gray-700 mb-3">📈 IRL de référence (pour révision annuelle)</p>
+              <div className="grid grid-cols-3 gap-3">
+                <div>
+                  <label className="label text-xs">Valeur IRL</label>
+                  <div className="flex gap-1">
+                    <input className="input" type="number" step="0.01" placeholder="Ex: 142.06"
+                      value={leaseForm.irl_reference_value}
+                      onChange={e => setLeaseForm(f => ({ ...f, irl_reference_value: e.target.value }))} />
+                    {leaseForm.irl_reference_quarter && leaseForm.irl_reference_year && (
+                      <button type="button" title="Récupérer depuis INSEE"
+                        onClick={async () => {
+                          try {
+                            const { data } = await api.get(`/irl?year=${leaseForm.irl_reference_year}&quarter=${leaseForm.irl_reference_quarter}`)
+                            if (data.value) setLeaseForm(f => ({ ...f, irl_reference_value: String(data.value) }))
+                            else toast.error(data.configured === false ? 'INSEE non configuré (INSEE_API_TOKEN)' : 'IRL indisponible pour cette période')
+                          } catch { toast.error('Erreur récupération IRL') }
+                        }}
+                        className="px-2 py-1 bg-indigo-100 hover:bg-indigo-200 text-indigo-700 rounded-lg text-sm transition-colors whitespace-nowrap">
+                        🔄
+                      </button>
+                    )}
+                  </div>
+                </div>
+                <div>
+                  <label className="label text-xs">Trimestre (1-4)</label>
+                  <select className="input" value={leaseForm.irl_reference_quarter}
+                    onChange={e => setLeaseForm(f => ({ ...f, irl_reference_quarter: e.target.value }))}>
+                    <option value="">—</option>
+                    <option value="1">T1 (jan-mars)</option>
+                    <option value="2">T2 (avr-juin)</option>
+                    <option value="3">T3 (juil-sep)</option>
+                    <option value="4">T4 (oct-déc)</option>
+                  </select>
+                </div>
+                <div>
+                  <label className="label text-xs">Année</label>
+                  <input className="input" type="number" min="2000" max="2100" placeholder={now.getFullYear()}
+                    value={leaseForm.irl_reference_year}
+                    onChange={e => setLeaseForm(f => ({ ...f, irl_reference_year: e.target.value }))} />
+                </div>
+              </div>
+              <p className="text-xs text-gray-400 mt-1">L'IRL du même trimestre de l'année en cours sera utilisé lors de la révision pour pré-calculer le nouveau loyer.</p>
+            </div>
+
             <div className="flex gap-3 pt-2">
               <button type="button" onClick={() => setShowEditLeaseModal(false)} className="btn-secondary flex-1">Annuler</button>
               <button type="submit" disabled={leaseEditLoading} className="btn-primary flex-1">