Ver código fonte

scan factures

jeremy 4 meses atrás
pai
commit
a1ca892417

+ 2 - 0
backend/package.json

@@ -14,7 +14,9 @@
     "express": "^4.18.2",
     "jsonwebtoken": "^9.0.2",
     "multer": "^1.4.5-lts.1",
+    "node-cron": "^3.0.3",
     "pdf-lib": "^1.17.1",
+    "pdf-parse": "^1.1.1",
     "pdfkit": "^0.15.0"
   },
   "devDependencies": {

+ 23 - 0
backend/src/db.js

@@ -137,7 +137,30 @@ async function initDb() {
     );
   `);
 
+  // Invoice scanning tables
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS scanned_invoices (
+      id SERIAL PRIMARY KEY,
+      user_id INTEGER NOT NULL REFERENCES users(id),
+      property_id INTEGER NOT NULL REFERENCES properties(id),
+      file_path TEXT NOT NULL,
+      file_hash TEXT NOT NULL,
+      year INTEGER,
+      detected_supplier TEXT,
+      detected_category TEXT,
+      detected_amount REAL,
+      detected_label TEXT,
+      status TEXT NOT NULL DEFAULT 'pending',
+      approved_at TIMESTAMP,
+      charge_id INTEGER REFERENCES charges(id),
+      notes TEXT,
+      created_at TIMESTAMP DEFAULT NOW()
+    );
+  `);
+
   // Idempotent migrations for columns added after initial schema
+  await pool.query(`ALTER TABLE properties ADD COLUMN IF NOT EXISTS scan_directory TEXT`);
+  await pool.query(`ALTER TABLE properties ADD COLUMN IF NOT EXISTS scan_cron_enabled INTEGER DEFAULT 0`);
   await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT`);
   await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT`);
   await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS address TEXT`);

+ 6 - 6
backend/src/routes/properties.js

@@ -11,23 +11,23 @@ router.get('/', async (req, res) => {
 });
 
 router.post('/', async (req, res) => {
-  const { name, address, city, zip_code, type, rooms, area } = req.body;
+  const { name, address, city, zip_code, type, rooms, area, scan_directory, scan_cron_enabled } = req.body;
   if (!name || !address || !city || !zip_code || !type)
     return res.status(400).json({ error: 'Champs requis manquants' });
   const result = await pool.query(
-    'INSERT INTO properties (user_id, name, address, city, zip_code, type, rooms, area) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id',
-    [req.userId, name, address, city, zip_code, type, rooms || null, area || null]
+    'INSERT INTO properties (user_id, name, address, city, zip_code, type, rooms, area, scan_directory, scan_cron_enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id',
+    [req.userId, name, address, city, zip_code, type, rooms || null, area || null, scan_directory || null, scan_cron_enabled ? 1 : 0]
   );
   res.status(201).json({ id: result.rows[0].id });
 });
 
 router.put('/:id', async (req, res) => {
-  const { name, address, city, zip_code, type, rooms, area } = req.body;
+  const { name, address, city, zip_code, type, rooms, area, scan_directory, scan_cron_enabled } = req.body;
   const prop = (await pool.query('SELECT id FROM properties WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
   if (!prop) return res.status(404).json({ error: 'Bien non trouvé' });
   await pool.query(
-    'UPDATE properties SET name=$1, address=$2, city=$3, zip_code=$4, type=$5, rooms=$6, area=$7 WHERE id=$8',
-    [name, address, city, zip_code, type, rooms || null, area || null, req.params.id]
+    'UPDATE properties SET name=$1, address=$2, city=$3, zip_code=$4, type=$5, rooms=$6, area=$7, scan_directory=$8, scan_cron_enabled=$9 WHERE id=$10',
+    [name, address, city, zip_code, type, rooms || null, area || null, scan_directory || null, scan_cron_enabled ? 1 : 0, req.params.id]
   );
   res.json({ success: true });
 });

+ 180 - 0
backend/src/routes/scan.js

@@ -0,0 +1,180 @@
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const pool = require('../db');
+const { authMiddleware } = require('../auth');
+const { scanProperty } = require('../services/scanner');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+const uploadsDir = path.join(__dirname, '../../uploads');
+
+// POST /api/scan/:propertyId - Trigger scan for a property
+router.post('/:propertyId', async (req, res) => {
+  try {
+    const result = await scanProperty(parseInt(req.params.propertyId), req.userId);
+    res.json(result);
+  } catch (err) {
+    res.status(400).json({ error: err.message });
+  }
+});
+
+// GET /api/scan/invoices - List scanned invoices (filterable)
+router.get('/invoices', async (req, res) => {
+  const { property_id, status, year } = req.query;
+  let query = `
+    SELECT si.*, p.name as property_name
+    FROM scanned_invoices si
+    JOIN properties p ON si.property_id = p.id
+    WHERE si.user_id = $1
+  `;
+  const params = [req.userId];
+  let idx = 2;
+
+  if (property_id) {
+    query += ` AND si.property_id = $${idx++}`;
+    params.push(parseInt(property_id));
+  }
+  if (status) {
+    query += ` AND si.status = $${idx++}`;
+    params.push(status);
+  }
+  if (year) {
+    query += ` AND si.year = $${idx++}`;
+    params.push(parseInt(year));
+  }
+
+  query += ' ORDER BY si.created_at DESC';
+  const rows = (await pool.query(query, params)).rows;
+  res.json(rows);
+});
+
+// GET /api/scan/invoices/:id/preview - Serve the scanned file for preview
+router.get('/invoices/:id/preview', async (req, res) => {
+  const inv = (await pool.query(
+    'SELECT * FROM scanned_invoices WHERE id = $1 AND user_id = $2',
+    [req.params.id, req.userId]
+  )).rows[0];
+  if (!inv) return res.status(404).json({ error: 'Facture non trouvée' });
+
+  if (!fs.existsSync(inv.file_path)) {
+    return res.status(404).json({ error: 'Fichier introuvable sur le disque' });
+  }
+
+  const ext = path.extname(inv.file_path).toLowerCase();
+  const mimeTypes = { '.pdf': 'application/pdf', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' };
+  res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
+  res.setHeader('Content-Disposition', `inline; filename="${path.basename(inv.file_path)}"`);
+  fs.createReadStream(inv.file_path).pipe(res);
+});
+
+// PUT /api/scan/invoices/:id - Update detected fields before approval
+router.put('/invoices/:id', async (req, res) => {
+  const { detected_supplier, detected_category, detected_amount, detected_label, year, notes } = req.body;
+  const inv = (await pool.query(
+    'SELECT id FROM scanned_invoices WHERE id = $1 AND user_id = $2 AND status = $3',
+    [req.params.id, req.userId, 'pending']
+  )).rows[0];
+  if (!inv) return res.status(404).json({ error: 'Facture non trouvée ou déjà traitée' });
+
+  await pool.query(
+    `UPDATE scanned_invoices SET detected_supplier=$1, detected_category=$2, detected_amount=$3, detected_label=$4, year=$5, notes=$6 WHERE id=$7`,
+    [detected_supplier, detected_category, detected_amount, detected_label, year, notes || null, req.params.id]
+  );
+  res.json({ success: true });
+});
+
+// POST /api/scan/invoices/:id/approve - Approve and create charge
+router.post('/invoices/:id/approve', async (req, res) => {
+  const inv = (await pool.query(
+    'SELECT * FROM scanned_invoices WHERE id = $1 AND user_id = $2 AND status = $3',
+    [req.params.id, req.userId, 'pending']
+  )).rows[0];
+  if (!inv) return res.status(404).json({ error: 'Facture non trouvée ou déjà traitée' });
+
+  if (!inv.detected_category || !inv.detected_amount || !inv.detected_label) {
+    return res.status(400).json({ error: 'Veuillez renseigner la catégorie, le montant et le libellé avant d\'approuver' });
+  }
+
+  // Copy file to uploads directory
+  const ext = path.extname(inv.file_path).toLowerCase();
+  const newFilename = `charge_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
+  const destPath = path.join(uploadsDir, newFilename);
+
+  try {
+    fs.copyFileSync(inv.file_path, destPath);
+  } catch (err) {
+    return res.status(500).json({ error: 'Erreur lors de la copie du fichier' });
+  }
+
+  // Determine recoverable_type from request or default
+  const recoverableType = req.body.recoverable_type || 'recoverable';
+
+  // Create charge entry
+  const chargeDate = inv.year ? `${inv.year}-01-01` : new Date().toISOString().slice(0, 10);
+  const chargeResult = await pool.query(
+    `INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes, recoverable, recoverable_type)
+     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
+    [
+      req.userId, inv.property_id, inv.detected_category, inv.detected_label,
+      inv.detected_amount, chargeDate, inv.year || new Date().getFullYear(),
+      newFilename, path.basename(inv.file_path),
+      inv.notes || `Import automatique - ${inv.detected_supplier || 'Fournisseur inconnu'}`,
+      recoverableType === 'recoverable' ? 1 : 0,
+      recoverableType,
+    ]
+  );
+
+  // Update scanned invoice status
+  await pool.query(
+    'UPDATE scanned_invoices SET status = $1, approved_at = NOW(), charge_id = $2 WHERE id = $3',
+    ['approved', chargeResult.rows[0].id, inv.id]
+  );
+
+  res.json({ success: true, charge_id: chargeResult.rows[0].id });
+});
+
+// POST /api/scan/invoices/:id/reject - Reject a scanned invoice
+router.post('/invoices/:id/reject', async (req, res) => {
+  const inv = (await pool.query(
+    'SELECT id FROM scanned_invoices WHERE id = $1 AND user_id = $2 AND status = $3',
+    [req.params.id, req.userId, 'pending']
+  )).rows[0];
+  if (!inv) return res.status(404).json({ error: 'Facture non trouvée ou déjà traitée' });
+
+  await pool.query(
+    'UPDATE scanned_invoices SET status = $1, notes = COALESCE($2, notes) WHERE id = $3',
+    ['rejected', req.body.notes || null, req.params.id]
+  );
+  res.json({ success: true });
+});
+
+// DELETE /api/scan/invoices/:id - Delete a scanned invoice entry
+router.delete('/invoices/:id', async (req, res) => {
+  const inv = (await pool.query(
+    'SELECT id, status FROM scanned_invoices WHERE id = $1 AND user_id = $2',
+    [req.params.id, req.userId]
+  )).rows[0];
+  if (!inv) return res.status(404).json({ error: 'Facture non trouvée' });
+  if (inv.status === 'approved') return res.status(400).json({ error: 'Impossible de supprimer une facture déjà approuvée' });
+
+  await pool.query('DELETE FROM scanned_invoices WHERE id = $1', [req.params.id]);
+  res.json({ success: true });
+});
+
+// GET /api/scan/stats - Summary stats for dashboard
+router.get('/stats', async (req, res) => {
+  const result = await pool.query(
+    `SELECT
+       COUNT(*) FILTER (WHERE status = 'pending') as pending,
+       COUNT(*) FILTER (WHERE status = 'approved') as approved,
+       COUNT(*) FILTER (WHERE status = 'rejected') as rejected,
+       COUNT(*) as total
+     FROM scanned_invoices WHERE user_id = $1`,
+    [req.userId]
+  );
+  res.json(result.rows[0]);
+});
+
+module.exports = router;

+ 14 - 0
backend/src/server.js

@@ -23,8 +23,22 @@ 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.use('/api/scan', require('./routes/scan'));
 
 app.get('/api/health', (_, res) => res.json({ ok: true }));
 
+// Optional cron for automatic invoice scanning (daily at 2am)
+const cron = require('node-cron');
+const { scanAllProperties } = require('./services/scanner');
+cron.schedule('0 2 * * *', async () => {
+  console.log('[CRON] Starting automatic invoice scan...');
+  try {
+    const results = await scanAllProperties();
+    console.log('[CRON] Scan complete:', JSON.stringify(results));
+  } catch (err) {
+    console.error('[CRON] Scan error:', err.message);
+  }
+});
+
 const PORT = process.env.PORT || 3001;
 app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));

+ 162 - 0
backend/src/services/invoiceDetector.js

@@ -0,0 +1,162 @@
+const pdfParse = require('pdf-parse');
+const fs = require('fs');
+
+// Known suppliers with their aliases and associated charge categories
+const SUPPLIERS = [
+  { name: 'EDF', patterns: [/\bedf\b/i, /\bélectricité de france\b/i], category: 'electricite' },
+  { name: 'Engie', patterns: [/\bengie\b/i, /\bgdf\s*suez\b/i], category: 'gaz' },
+  { name: 'TotalEnergies', patterns: [/\btotal\s*energies?\b/i, /\btotal\s*direct\s*energie\b/i], category: 'electricite' },
+  { name: 'Eni', patterns: [/\beni\s/i, /\beni\.com\b/i], category: 'gaz' },
+  { name: 'Veolia', patterns: [/\bveolia\b/i], category: 'eau' },
+  { name: 'Suez', patterns: [/\bsuez\b/i, /\blyonnaise des eaux\b/i], category: 'eau' },
+  { name: 'Saur', patterns: [/\bsaur\b/i], category: 'eau' },
+  { name: 'Eau de Paris', patterns: [/\beau de paris\b/i], category: 'eau' },
+  { name: 'GRDF', patterns: [/\bgrdf\b/i], category: 'gaz' },
+  { name: 'Enedis', patterns: [/\benedis\b/i, /\berdf\b/i], category: 'electricite' },
+  { name: 'Orange', patterns: [/\borange\b/i, /\bfrance\s*t[eé]l[eé]com\b/i], category: 'autre' },
+  { name: 'SFR', patterns: [/\bsfr\b/i], category: 'autre' },
+  { name: 'Free', patterns: [/\bfree\s*mobile\b/i, /\bfree\.fr\b/i, /\biliad\b/i], category: 'autre' },
+  { name: 'Bouygues Telecom', patterns: [/\bbouygues\s*tel/i], category: 'autre' },
+  { name: 'Axa', patterns: [/\baxa\b/i], category: 'assurance' },
+  { name: 'Maif', patterns: [/\bmaif\b/i], category: 'assurance' },
+  { name: 'MACIF', patterns: [/\bmacif\b/i], category: 'assurance' },
+  { name: 'Groupama', patterns: [/\bgroupama\b/i], category: 'assurance' },
+  { name: 'Allianz', patterns: [/\ballianz\b/i], category: 'assurance' },
+  { name: 'MMA', patterns: [/\bmma\b/i], category: 'assurance' },
+  { name: 'MAAF', patterns: [/\bmaaf\b/i], category: 'assurance' },
+  { name: 'Generali', patterns: [/\bgenerali\b/i], category: 'assurance' },
+  { name: 'Matmut', patterns: [/\bmatmut\b/i], category: 'assurance' },
+  { name: 'Otis', patterns: [/\botis\b/i], category: 'ascenseur' },
+  { name: 'Schindler', patterns: [/\bschindler\b/i], category: 'ascenseur' },
+  { name: 'Kone', patterns: [/\bkone\b/i], category: 'ascenseur' },
+  { name: 'ThyssenKrupp', patterns: [/\bthyssen\s*krupp\b/i], category: 'ascenseur' },
+];
+
+// Category keywords for fallback detection
+const CATEGORY_KEYWORDS = {
+  eau: [/\beau\b/i, /\bassainissement\b/i, /\bconsommation\s*d'eau\b/i],
+  gaz: [/\bgaz\b/i, /\bgaz\s*naturel\b/i],
+  electricite: [/\b[eé]lectricit[eé]\b/i, /\bconsommation\s*[eé]lectrique\b/i, /\bkwh\b/i],
+  entretien: [/\bentretien\b/i, /\bmaintenance\b/i, /\bnettoyage\b/i, /\bravalement\b/i, /\br[eé]paration\b/i],
+  ascenseur: [/\bascenseur\b/i, /\b[eé]l[eé]vateur\b/i],
+  ordures: [/\bordures\b/i, /\bd[eé]chets\b/i, /\bom\b/i, /\bteom\b/i, /\btaxe.*enl[eè]vement\b/i],
+  assurance: [/\bassurance\b/i, /\bprime\b/i, /\bcotisation\b/i, /\bsinistre\b/i, /\bgarantie\b/i],
+};
+
+// Amount patterns (French format: 1 234,56 € or 1234.56)
+const AMOUNT_PATTERNS = [
+  /(?:total\s*(?:ttc|à\s*payer|net|facture|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
+  /(?:montant\s*(?:ttc|total|à\s*payer|net|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
+  /(?:net\s*à\s*payer)\s*[:\s]*(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
+  /(?:total\s*(?:ttc|à\s*payer|net|facture|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})/i,
+  /(?:montant\s*(?:ttc|total|à\s*payer|net|dû|du))\s*[:\s]*(\d[\d\s]*[.,]\d{2})/i,
+  /(?:net\s*à\s*payer)\s*[:\s]*(\d[\d\s]*[.,]\d{2})/i,
+  /(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)\s*(?:ttc)/i,
+  /(\d[\d\s]*[.,]\d{2})\s*(?:€|eur)/i,
+];
+
+function parseAmount(raw) {
+  if (!raw) return null;
+  const cleaned = raw.replace(/\s/g, '').replace(',', '.');
+  const val = parseFloat(cleaned);
+  return isNaN(val) ? null : Math.round(val * 100) / 100;
+}
+
+function detectSupplier(text) {
+  for (const supplier of SUPPLIERS) {
+    for (const pattern of supplier.patterns) {
+      if (pattern.test(text)) {
+        return { name: supplier.name, category: supplier.category };
+      }
+    }
+  }
+  return null;
+}
+
+function detectCategory(text) {
+  for (const [category, patterns] of Object.entries(CATEGORY_KEYWORDS)) {
+    for (const pattern of patterns) {
+      if (pattern.test(text)) return category;
+    }
+  }
+  return 'autre';
+}
+
+function detectAmount(text) {
+  for (const pattern of AMOUNT_PATTERNS) {
+    const match = text.match(pattern);
+    if (match) {
+      const amount = parseAmount(match[1]);
+      if (amount && amount > 0 && amount < 100000) return amount;
+    }
+  }
+  return null;
+}
+
+async function analyzeFile(filePath) {
+  const ext = filePath.toLowerCase().split('.').pop();
+
+  if (ext === 'pdf') {
+    return analyzePdf(filePath);
+  }
+
+  // For images (jpg, png, webp) - extract info from filename only
+  return analyzeFromFilename(filePath);
+}
+
+async function analyzePdf(filePath) {
+  const buffer = fs.readFileSync(filePath);
+  let text = '';
+  try {
+    const data = await pdfParse(buffer);
+    text = data.text || '';
+  } catch (err) {
+    console.warn(`Could not parse PDF ${filePath}:`, err.message);
+    return analyzeFromFilename(filePath);
+  }
+
+  const supplierInfo = detectSupplier(text);
+  const detectedAmount = detectAmount(text);
+  const detectedCategory = supplierInfo?.category || detectCategory(text);
+  const detectedSupplier = supplierInfo?.name || null;
+
+  // Try to build a label
+  let label = '';
+  if (detectedSupplier) label = `Facture ${detectedSupplier}`;
+  else if (detectedCategory !== 'autre') {
+    const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance' };
+    label = `Facture ${catLabels[detectedCategory] || detectedCategory}`;
+  } else {
+    label = 'Facture à identifier';
+  }
+
+  return {
+    detected_supplier: detectedSupplier,
+    detected_category: detectedCategory,
+    detected_amount: detectedAmount,
+    detected_label: label,
+  };
+}
+
+function analyzeFromFilename(filePath) {
+  const basename = filePath.split('/').pop().replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
+
+  const supplierInfo = detectSupplier(basename);
+  const detectedCategory = supplierInfo?.category || detectCategory(basename) || 'autre';
+  const detectedSupplier = supplierInfo?.name || null;
+
+  // Try to find amount in filename (e.g., "EDF_150.50_2024.pdf")
+  const amountMatch = basename.match(/(\d+[.,]\d{2})/);
+  const detectedAmount = amountMatch ? parseAmount(amountMatch[1]) : null;
+
+  let label = detectedSupplier ? `Facture ${detectedSupplier}` : 'Facture à identifier';
+
+  return {
+    detected_supplier: detectedSupplier,
+    detected_category: detectedCategory,
+    detected_amount: detectedAmount,
+    detected_label: label,
+  };
+}
+
+module.exports = { analyzeFile, detectSupplier, detectCategory, detectAmount, SUPPLIERS };

+ 123 - 0
backend/src/services/scanner.js

@@ -0,0 +1,123 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const pool = require('../db');
+const { analyzeFile } = require('./invoiceDetector');
+
+const SUPPORTED_EXTENSIONS = ['.pdf', '.jpg', '.jpeg', '.png', '.webp'];
+
+function computeFileHash(filePath) {
+  const buffer = fs.readFileSync(filePath);
+  return crypto.createHash('sha256').update(buffer).digest('hex');
+}
+
+function discoverFiles(baseDir) {
+  const files = [];
+  if (!fs.existsSync(baseDir)) return files;
+
+  const entries = fs.readdirSync(baseDir, { withFileTypes: true });
+  for (const entry of entries) {
+    const fullPath = path.join(baseDir, entry.name);
+    if (entry.isDirectory()) {
+      // Recurse into subdirectories (year folders, etc.)
+      files.push(...discoverFiles(fullPath));
+    } else if (entry.isFile()) {
+      const ext = path.extname(entry.name).toLowerCase();
+      if (SUPPORTED_EXTENSIONS.includes(ext)) {
+        files.push(fullPath);
+      }
+    }
+  }
+  return files;
+}
+
+function detectYearFromPath(filePath, baseDir) {
+  // Extract year from directory structure (e.g., /base/2024/facture.pdf → 2024)
+  const relativePath = path.relative(baseDir, filePath);
+  const parts = relativePath.split(path.sep);
+
+  for (const part of parts) {
+    const yearMatch = part.match(/^(20\d{2})$/);
+    if (yearMatch) return parseInt(yearMatch[1]);
+  }
+
+  // Fallback: try to find year in filename
+  const basename = path.basename(filePath);
+  const yearInName = basename.match(/(20\d{2})/);
+  if (yearInName) return parseInt(yearInName[1]);
+
+  return new Date().getFullYear();
+}
+
+async function scanProperty(propertyId, userId) {
+  const propResult = await pool.query(
+    'SELECT id, scan_directory, name FROM properties WHERE id = $1 AND user_id = $2',
+    [propertyId, userId]
+  );
+  const property = propResult.rows[0];
+  if (!property) throw new Error('Bien non trouvé');
+  if (!property.scan_directory) throw new Error('Aucun dossier de scan configuré pour ce bien');
+
+  const baseDir = property.scan_directory;
+  if (!fs.existsSync(baseDir)) throw new Error(`Le dossier "${baseDir}" n'existe pas`);
+
+  // Get existing hashes to avoid duplicates
+  const existingHashes = new Set(
+    (await pool.query('SELECT file_hash FROM scanned_invoices WHERE property_id = $1', [propertyId]))
+      .rows.map(r => r.file_hash)
+  );
+
+  const files = discoverFiles(baseDir);
+  const results = { scanned: 0, new: 0, duplicates: 0, errors: 0 };
+
+  for (const filePath of files) {
+    results.scanned++;
+    try {
+      const fileHash = computeFileHash(filePath);
+
+      if (existingHashes.has(fileHash)) {
+        results.duplicates++;
+        continue;
+      }
+
+      const year = detectYearFromPath(filePath, baseDir);
+      const analysis = await analyzeFile(filePath);
+
+      await pool.query(
+        `INSERT INTO scanned_invoices (user_id, property_id, file_path, file_hash, year, detected_supplier, detected_category, detected_amount, detected_label, status)
+         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`,
+        [userId, propertyId, filePath, fileHash, year,
+         analysis.detected_supplier, analysis.detected_category,
+         analysis.detected_amount, analysis.detected_label]
+      );
+
+      existingHashes.add(fileHash);
+      results.new++;
+    } catch (err) {
+      console.error(`Error processing ${filePath}:`, err.message);
+      results.errors++;
+    }
+  }
+
+  return results;
+}
+
+async function scanAllProperties() {
+  // Used by cron: scan all properties with scan_cron_enabled
+  const props = (await pool.query(
+    'SELECT id, user_id, name, scan_directory FROM properties WHERE scan_directory IS NOT NULL AND scan_cron_enabled = 1'
+  )).rows;
+
+  const results = [];
+  for (const prop of props) {
+    try {
+      const result = await scanProperty(prop.id, prop.user_id);
+      results.push({ property: prop.name, ...result });
+    } catch (err) {
+      results.push({ property: prop.name, error: err.message });
+    }
+  }
+  return results;
+}
+
+module.exports = { scanProperty, scanAllProperties };

+ 2 - 0
frontend/src/App.jsx

@@ -12,6 +12,7 @@ import Leases from './pages/Leases'
 import LeaseDetail from './pages/LeaseDetail'
 import Payments from './pages/Payments'
 import Charges from './pages/Charges'
+import InvoiceScanner from './pages/InvoiceScanner'
 import Profile from './pages/Profile'
 
 function PrivateRoute({ children }) {
@@ -43,6 +44,7 @@ export default function App() {
             <Route path="leases/:id" element={<LeaseDetail />} />
             <Route path="payments" element={<Payments />} />
             <Route path="charges" element={<Charges />} />
+            <Route path="scanner" element={<InvoiceScanner />} />
             <Route path="profile" element={<Profile />} />
           </Route>
         </Routes>

+ 1 - 0
frontend/src/components/Layout.jsx

@@ -10,6 +10,7 @@ const navItems = [
   { to: '/leases', label: '📋 Baux' },
   { to: '/payments', label: '💰 Paiements' },
   { to: '/charges', label: '🧾 Charges' },
+  { to: '/scanner', label: '📄 Scanner' },
   { to: '/profile', label: '⚙️ Mon profil' },
 ]
 

+ 366 - 0
frontend/src/pages/InvoiceScanner.jsx

@@ -0,0 +1,366 @@
+import { useEffect, useState } from 'react'
+import api from '../api'
+import toast from 'react-hot-toast'
+
+const CATEGORIES = [
+  { value: 'eau', label: 'Eau' },
+  { value: 'gaz', label: 'Gaz' },
+  { value: 'electricite', label: 'Électricité' },
+  { value: 'entretien', label: 'Entretien' },
+  { value: 'ascenseur', label: 'Ascenseur' },
+  { value: 'ordures', label: 'Ordures ménagères' },
+  { value: 'assurance', label: 'Assurance' },
+  { value: 'autre', label: 'Autre' },
+]
+
+const STATUS_LABELS = {
+  pending: { label: 'En attente', class: 'bg-yellow-100 text-yellow-800' },
+  approved: { label: 'Approuvée', class: 'bg-green-100 text-green-800' },
+  rejected: { label: 'Rejetée', class: 'bg-red-100 text-red-800' },
+}
+
+function Modal({ title, onClose, children }) {
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
+      <div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] flex flex-col">
+        <div className="flex items-center justify-between p-4 sm:p-6 border-b shrink-0">
+          <h2 className="text-lg font-semibold">{title}</h2>
+          <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
+        </div>
+        <div className="p-4 sm:p-6 overflow-y-auto">{children}</div>
+      </div>
+    </div>
+  )
+}
+
+export default function InvoiceScanner() {
+  const [properties, setProperties] = useState([])
+  const [invoices, setInvoices] = useState([])
+  const [selectedProperty, setSelectedProperty] = useState('')
+  const [filterStatus, setFilterStatus] = useState('pending')
+  const [filterYear, setFilterYear] = useState('')
+  const [scanning, setScanning] = useState(false)
+  const [scanResult, setScanResult] = useState(null)
+  const [stats, setStats] = useState(null)
+  const [editInvoice, setEditInvoice] = useState(null)
+  const [editForm, setEditForm] = useState({})
+  const [previewId, setPreviewId] = useState(null)
+
+  const loadProperties = () => api.get('/properties').then(r => setProperties(r.data))
+  const loadStats = () => api.get('/scan/stats').then(r => setStats(r.data))
+
+  const loadInvoices = () => {
+    const params = new URLSearchParams()
+    if (selectedProperty) params.set('property_id', selectedProperty)
+    if (filterStatus) params.set('status', filterStatus)
+    if (filterYear) params.set('year', filterYear)
+    api.get(`/scan/invoices?${params}`).then(r => setInvoices(r.data))
+  }
+
+  useEffect(() => { loadProperties(); loadStats() }, [])
+  useEffect(() => { loadInvoices() }, [selectedProperty, filterStatus, filterYear])
+
+  const propertiesWithScan = properties.filter(p => p.scan_directory)
+
+  const handleScan = async (propertyId) => {
+    setScanning(true)
+    setScanResult(null)
+    try {
+      const r = await api.post(`/scan/${propertyId}`)
+      setScanResult(r.data)
+      toast.success(`Scan terminé : ${r.data.new} nouvelle(s) facture(s)`)
+      loadInvoices()
+      loadStats()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur lors du scan')
+    } finally {
+      setScanning(false)
+    }
+  }
+
+  const handleScanAll = async () => {
+    setScanning(true)
+    let totalNew = 0
+    for (const p of propertiesWithScan) {
+      try {
+        const r = await api.post(`/scan/${p.id}`)
+        totalNew += r.data.new
+      } catch {}
+    }
+    toast.success(`Scan global terminé : ${totalNew} nouvelle(s) facture(s)`)
+    setScanning(false)
+    loadInvoices()
+    loadStats()
+  }
+
+  const openEdit = (inv) => {
+    setEditForm({
+      detected_supplier: inv.detected_supplier || '',
+      detected_category: inv.detected_category || 'autre',
+      detected_amount: inv.detected_amount || '',
+      detected_label: inv.detected_label || '',
+      year: inv.year || new Date().getFullYear(),
+      notes: inv.notes || '',
+      recoverable_type: 'recoverable',
+    })
+    setEditInvoice(inv)
+  }
+
+  const handleSave = async () => {
+    try {
+      await api.put(`/scan/invoices/${editInvoice.id}`, editForm)
+      toast.success('Facture mise à jour')
+      setEditInvoice(null)
+      loadInvoices()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur')
+    }
+  }
+
+  const handleApprove = async (inv) => {
+    const target = editInvoice || inv
+    const form = editInvoice ? editForm : {}
+    if (!target.detected_amount && !form.detected_amount) {
+      toast.error('Veuillez renseigner le montant avant d\'approuver')
+      return
+    }
+    try {
+      if (editInvoice) {
+        await api.put(`/scan/invoices/${target.id}`, editForm)
+      }
+      await api.post(`/scan/invoices/${target.id}/approve`, { recoverable_type: form.recoverable_type || 'recoverable' })
+      toast.success('Facture approuvée et intégrée aux charges !')
+      setEditInvoice(null)
+      loadInvoices()
+      loadStats()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur')
+    }
+  }
+
+  const handleReject = async (inv) => {
+    if (!confirm('Rejeter cette facture ?')) return
+    try {
+      await api.post(`/scan/invoices/${inv.id}/reject`)
+      toast.success('Facture rejetée')
+      loadInvoices()
+      loadStats()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur')
+    }
+  }
+
+  const handleDelete = async (inv) => {
+    if (!confirm('Supprimer cette entrée ?')) return
+    try {
+      await api.delete(`/scan/invoices/${inv.id}`)
+      toast.success('Entrée supprimée')
+      loadInvoices()
+      loadStats()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur')
+    }
+  }
+
+  const years = [...new Set(invoices.map(i => i.year).filter(Boolean))].sort((a, b) => b - a)
+  const allYears = years.length > 0 ? years : [new Date().getFullYear()]
+
+  return (
+    <div>
+      <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-8 gap-4">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">📄 Scanner de factures</h1>
+          <p className="text-gray-500 mt-1">Détection automatique et approbation des factures</p>
+        </div>
+        {propertiesWithScan.length > 0 && (
+          <button onClick={handleScanAll} disabled={scanning} className="btn-primary whitespace-nowrap">
+            {scanning ? '⏳ Scan en cours...' : '🔍 Scanner tous les biens'}
+          </button>
+        )}
+      </div>
+
+      {/* Stats */}
+      {stats && (
+        <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
+          <div className="card text-center">
+            <p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
+            <p className="text-xs text-gray-500">En attente</p>
+          </div>
+          <div className="card text-center">
+            <p className="text-2xl font-bold text-green-600">{stats.approved}</p>
+            <p className="text-xs text-gray-500">Approuvées</p>
+          </div>
+          <div className="card text-center">
+            <p className="text-2xl font-bold text-red-600">{stats.rejected}</p>
+            <p className="text-xs text-gray-500">Rejetées</p>
+          </div>
+          <div className="card text-center">
+            <p className="text-2xl font-bold text-gray-700">{stats.total}</p>
+            <p className="text-xs text-gray-500">Total</p>
+          </div>
+        </div>
+      )}
+
+      {/* Scan buttons per property */}
+      {propertiesWithScan.length > 0 && (
+        <div className="card mb-6">
+          <h2 className="font-semibold text-gray-800 mb-3">Biens avec dossier de scan configuré</h2>
+          <div className="space-y-2">
+            {propertiesWithScan.map(p => (
+              <div key={p.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2">
+                <div>
+                  <span className="font-medium text-gray-800">{p.name}</span>
+                  <span className="text-xs text-gray-400 ml-2">{p.scan_directory}</span>
+                  {p.scan_cron_enabled ? <span className="badge-blue ml-2 text-xs">Auto</span> : null}
+                </div>
+                <button onClick={() => handleScan(p.id)} disabled={scanning} className="text-sm text-blue-600 hover:text-blue-800 font-medium">
+                  🔍 Scanner
+                </button>
+              </div>
+            ))}
+          </div>
+          {scanResult && (
+            <div className="mt-3 p-3 bg-blue-50 rounded-lg text-sm">
+              Résultat : {scanResult.scanned} fichier(s) analysé(s), <strong>{scanResult.new} nouveau(x)</strong>, {scanResult.duplicates} doublon(s), {scanResult.errors} erreur(s)
+            </div>
+          )}
+        </div>
+      )}
+
+      {propertiesWithScan.length === 0 && (
+        <div className="card text-center py-16 mb-6">
+          <div className="text-5xl mb-4">📂</div>
+          <p className="text-gray-500">Aucun bien n'a de dossier de scan configuré.</p>
+          <p className="text-gray-400 text-sm mt-1">Rendez-vous dans la page Biens pour configurer un dossier de scan.</p>
+        </div>
+      )}
+
+      {/* Filters */}
+      <div className="flex flex-wrap gap-3 mb-4">
+        <select className="input w-auto" value={selectedProperty} onChange={e => setSelectedProperty(e.target.value)}>
+          <option value="">Tous les biens</option>
+          {properties.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
+        </select>
+        <select className="input w-auto" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
+          <option value="">Tous les statuts</option>
+          <option value="pending">En attente</option>
+          <option value="approved">Approuvées</option>
+          <option value="rejected">Rejetées</option>
+        </select>
+        <select className="input w-auto" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
+          <option value="">Toutes les années</option>
+          {allYears.map(y => <option key={y} value={y}>{y}</option>)}
+        </select>
+      </div>
+
+      {/* Invoice list */}
+      {invoices.length === 0 ? (
+        <div className="card text-center py-12">
+          <p className="text-gray-400">Aucune facture scannée pour les filtres sélectionnés</p>
+        </div>
+      ) : (
+        <div className="space-y-3">
+          {invoices.map(inv => (
+            <div key={inv.id} className="card hover:shadow-md transition-shadow">
+              <div className="flex flex-col sm:flex-row sm:items-center gap-3">
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-2 mb-1">
+                    <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[inv.status]?.class}`}>
+                      {STATUS_LABELS[inv.status]?.label}
+                    </span>
+                    <span className="text-sm font-medium text-gray-800 truncate">{inv.detected_label || 'Facture à identifier'}</span>
+                    {inv.year && <span className="text-xs text-gray-400">{inv.year}</span>}
+                  </div>
+                  <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
+                    <span>🏠 {inv.property_name}</span>
+                    {inv.detected_supplier && <span>🏢 {inv.detected_supplier}</span>}
+                    {inv.detected_category && <span>📁 {CATEGORIES.find(c => c.value === inv.detected_category)?.label || inv.detected_category}</span>}
+                    {inv.detected_amount != null && <span className="font-medium text-gray-700">💰 {inv.detected_amount.toFixed(2)} €</span>}
+                  </div>
+                  <p className="text-xs text-gray-400 mt-1 truncate" title={inv.file_path}>{inv.file_path.split('/').slice(-2).join('/')}</p>
+                </div>
+                <div className="flex items-center gap-2 shrink-0">
+                  <button onClick={() => setPreviewId(inv.id)} className="text-sm text-gray-500 hover:text-blue-600" title="Prévisualiser">👁️</button>
+                  {inv.status === 'pending' && (
+                    <>
+                      <button onClick={() => openEdit(inv)} className="text-sm text-blue-600 hover:text-blue-800" title="Modifier et approuver">✏️</button>
+                      <button onClick={() => handleApprove(inv)} className="text-sm text-green-600 hover:text-green-800" title="Approuver">✅</button>
+                      <button onClick={() => handleReject(inv)} className="text-sm text-red-600 hover:text-red-800" title="Rejeter">❌</button>
+                    </>
+                  )}
+                  {inv.status !== 'approved' && (
+                    <button onClick={() => handleDelete(inv)} className="text-sm text-gray-400 hover:text-red-600" title="Supprimer">🗑️</button>
+                  )}
+                </div>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* Preview modal */}
+      {previewId && (
+        <Modal title="Prévisualisation" onClose={() => setPreviewId(null)}>
+          <iframe
+            src={`/api/scan/invoices/${previewId}/preview`}
+            className="w-full h-[70vh] border rounded-lg"
+            title="Aperçu facture"
+          />
+        </Modal>
+      )}
+
+      {/* Edit/Approve modal */}
+      {editInvoice && (
+        <Modal title="Vérifier et approuver la facture" onClose={() => setEditInvoice(null)}>
+          <div className="space-y-4">
+            <div className="bg-gray-50 rounded-lg p-3 text-xs text-gray-500">
+              <p className="truncate">📄 {editInvoice.file_path.split('/').slice(-2).join('/')}</p>
+              <button onClick={() => { setPreviewId(editInvoice.id) }} className="text-blue-600 hover:underline mt-1">Prévisualiser le fichier</button>
+            </div>
+            <div className="grid grid-cols-2 gap-4">
+              <div className="col-span-2">
+                <label className="label">Libellé</label>
+                <input className="input" value={editForm.detected_label} onChange={e => setEditForm(f => ({ ...f, detected_label: e.target.value }))} />
+              </div>
+              <div>
+                <label className="label">Fournisseur</label>
+                <input className="input" value={editForm.detected_supplier} onChange={e => setEditForm(f => ({ ...f, detected_supplier: e.target.value }))} placeholder="Ex: EDF" />
+              </div>
+              <div>
+                <label className="label">Catégorie</label>
+                <select className="input" value={editForm.detected_category} onChange={e => setEditForm(f => ({ ...f, detected_category: e.target.value }))}>
+                  {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
+                </select>
+              </div>
+              <div>
+                <label className="label">Montant (€)</label>
+                <input className="input" type="number" step="0.01" min="0" value={editForm.detected_amount} onChange={e => setEditForm(f => ({ ...f, detected_amount: parseFloat(e.target.value) || '' }))} />
+              </div>
+              <div>
+                <label className="label">Année</label>
+                <input className="input" type="number" min="2000" max="2099" value={editForm.year} onChange={e => setEditForm(f => ({ ...f, year: parseInt(e.target.value) || '' }))} />
+              </div>
+              <div>
+                <label className="label">Type de charge</label>
+                <select className="input" value={editForm.recoverable_type} onChange={e => setEditForm(f => ({ ...f, recoverable_type: e.target.value }))}>
+                  <option value="recoverable">Récupérable</option>
+                  <option value="deductible">Déductible</option>
+                  <option value="none">Non récupérable</option>
+                </select>
+              </div>
+              <div>
+                <label className="label">Notes</label>
+                <input className="input" value={editForm.notes} onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))} placeholder="Optionnel" />
+              </div>
+            </div>
+            <div className="flex gap-3 pt-2">
+              <button onClick={() => setEditInvoice(null)} className="btn-secondary flex-1">Annuler</button>
+              <button onClick={handleSave} className="btn-secondary flex-1">💾 Sauvegarder</button>
+              <button onClick={() => handleApprove(editInvoice)} className="btn-primary flex-1">✅ Approuver</button>
+            </div>
+          </div>
+        </Modal>
+      )}
+    </div>
+  )
+}

+ 16 - 2
frontend/src/pages/Properties.jsx

@@ -18,7 +18,7 @@ function Modal({ title, onClose, children }) {
   )
 }
 
-const emptyForm = { name: '', address: '', city: '', zip_code: '', type: 'Appartement', rooms: '', area: '' }
+const emptyForm = { name: '', address: '', city: '', zip_code: '', type: 'Appartement', rooms: '', area: '', scan_directory: '', scan_cron_enabled: false }
 
 export default function Properties() {
   const [properties, setProperties] = useState([])
@@ -31,7 +31,7 @@ export default function Properties() {
   useEffect(() => { load() }, [])
 
   const openAdd = () => { setForm(emptyForm); setEditing(null); setShowModal(true) }
-  const openEdit = p => { setForm({ name: p.name, address: p.address, city: p.city, zip_code: p.zip_code, type: p.type, rooms: p.rooms || '', area: p.area || '' }); setEditing(p.id); setShowModal(true) }
+  const openEdit = p => { setForm({ name: p.name, address: p.address, city: p.city, zip_code: p.zip_code, type: p.type, rooms: p.rooms || '', area: p.area || '', scan_directory: p.scan_directory || '', scan_cron_enabled: !!p.scan_cron_enabled }); setEditing(p.id); setShowModal(true) }
 
   const handleSubmit = async e => {
     e.preventDefault()
@@ -88,6 +88,9 @@ export default function Properties() {
                   {p.rooms && `${p.rooms} pièce(s)`}{p.rooms && p.area && ' · '}{p.area && `${p.area} m²`}
                 </p>
               )}
+              {p.scan_directory && (
+                <p className="text-xs text-green-600 mt-2 flex items-center gap-1">📂 Scan configuré{p.scan_cron_enabled ? ' (auto)' : ''}</p>
+              )}
             </div>
           ))}
         </div>
@@ -127,6 +130,17 @@ export default function Properties() {
                 <label className="label">Surface (m²)</label>
                 <input className="input" type="number" min="1" step="0.1" value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))} placeholder="65" />
               </div>
+              <div className="col-span-2 border-t pt-4 mt-2">
+                <label className="label">📂 Dossier de scan des factures</label>
+                <input className="input" value={form.scan_directory} onChange={e => setForm(f => ({ ...f, scan_directory: e.target.value }))} placeholder="/chemin/vers/factures/bien1" />
+                <p className="text-xs text-gray-400 mt-1">Chemin local du serveur. Structure attendue : dossier/année/factures.pdf</p>
+              </div>
+              {form.scan_directory && (
+                <div className="col-span-2 flex items-center gap-2">
+                  <input type="checkbox" id="scan_cron" checked={form.scan_cron_enabled} onChange={e => setForm(f => ({ ...f, scan_cron_enabled: e.target.checked }))} className="rounded border-gray-300 text-blue-600" />
+                  <label htmlFor="scan_cron" className="text-sm text-gray-600">Activer le scan automatique quotidien</label>
+                </div>
+              )}
             </div>
             <div className="flex gap-3 pt-2">
               <button type="button" onClick={() => setShowModal(false)} className="btn-secondary flex-1">Annuler</button>