import { Router } from 'express'; import multer from 'multer'; import xlsx from 'xlsx'; import path from 'node:path'; import fs from 'node:fs'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { requireInvestisseur } from '../middleware/investisseurScope.js'; import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js'; const router = Router(); const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve('./uploads'); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); const upload = multer({ dest: UPLOAD_DIR, limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB }); /** * Step 1: POST /api/imports/preview * multipart/form-data with `file` (xlsx/csv) * -> returns: { headers: [...], sampleRows: [...], allRowCount, tempId } * * Step 2: POST /api/imports/apply * body: { tempId, module, mapping, defaults } * -> applies the mapping and inserts rows in the chosen module */ // Modules scoped to a specific investisseur (require X-Investisseur-Id) const INVESTISSEUR_SCOPED = ['depots_retraits', 'investissements', 'remboursements']; const MODULES = { depots_retraits: { requiredTargets: ['plateforme_id', 'date_operation', 'type', 'montant'], optionalTargets: ['libelle', 'reference', 'notes'], }, investissements: { requiredTargets: ['plateforme_id', 'nom_projet', 'date_souscription', 'montant_investi'], optionalTargets: ['emetteur','date_premiere_echeance','date_cible','taux_interet','duree_mois','type_remb','freq_interets','statut','reference','notes'], }, remboursements: { requiredTargets: ['investissement_id', 'date_remb'], optionalTargets: ['capital','cashback','interets_bruts','prelev_sociaux','prelev_forfaitaire','statut','notes'], }, plateformes: { requiredTargets: ['nom'], optionalTargets: ['url', 'notes'], }, taux_pfu: { requiredTargets: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'], optionalTargets: [], }, }; /** Parse un fichier uploadé selon son extension → tableau d'objets */ function parseFile(filePath, originalName) { const ext = path.extname(originalName).toLowerCase(); if (ext === '.json') { const content = fs.readFileSync(filePath, 'utf8'); let parsed; try { parsed = JSON.parse(content); } catch { throw new HttpError(400, 'Fichier JSON invalide — vérifiez la syntaxe'); } if (!Array.isArray(parsed)) { throw new HttpError(400, 'Le fichier JSON doit contenir un tableau d\'objets à la racine'); } return { rows: parsed, sheetName: 'json' }; } // Excel / CSV const wb = xlsx.readFile(filePath, { cellDates: true }); const sheetName = wb.SheetNames[0]; const ws = wb.Sheets[sheetName]; return { rows: xlsx.utils.sheet_to_json(ws, { defval: null, raw: false }), sheetName }; } router.post('/preview', upload.single('file'), (req, res, next) => { try { if (!req.file) throw new HttpError(400, 'No file uploaded'); const { rows, sheetName } = parseFile(req.file.path, req.file.originalname); const headers = rows.length ? Object.keys(rows[0]) : []; res.json({ tempId: path.basename(req.file.path), filename: req.file.originalname, sheetName, headers, sampleRows: rows.slice(0, 10), allRowCount: rows.length, }); } catch (e) { next(e); } }); router.post('/apply', (req, res, next) => { try { const { tempId, module, mapping, defaults = {} } = req.body || {}; if (!tempId || !module || !mapping) { throw new HttpError(400, 'tempId, module and mapping are required'); } const def = MODULES[module]; if (!def) throw new HttpError(400, 'Unknown module'); // Require a specific investisseur for transactional modules if (INVESTISSEUR_SCOPED.includes(module)) { requireInvestisseur(req, res, () => {}); } const tempPath = path.join(UPLOAD_DIR, tempId); if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired'); for (const target of def.requiredTargets) { if (!mapping[target] && defaults[target] === undefined) { throw new HttpError(400, `Missing mapping for required field: ${target}`); } } // Déterminer la source selon l'extension du fichier original (passé en body optionnel) const origName = req.body.originalFilename || ''; const isJson = path.extname(origName).toLowerCase() === '.json'; const srcLabel = isJson ? 'import_json' : 'import_excel'; const { rows } = parseFile(tempPath, origName || 'file.xlsx'); let inserted = 0, skipped = 0; const errors = []; const tx = db.transaction(() => { for (let idx = 0; idx < rows.length; idx++) { const row = rows[idx]; try { const v = (target) => { const col = mapping[target]; if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') { return row[col]; } return defaults[target]; }; if (module === 'depots_retraits') { db.prepare(` INSERT INTO depots_retraits (investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source) VALUES (?,?,?,?,?,?,?,?) `).run( req.investisseur.id, Number(v('plateforme_id')), normaliseDate(v('date_operation')), normaliseType(v('type')), num(v('montant')), v('libelle') || null, v('reference') || null, srcLabel, ); } else if (module === 'investissements') { db.prepare(` INSERT INTO investissements (investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription, date_premiere_echeance, date_cible, montant_investi, taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( req.investisseur.id, Number(v('plateforme_id')), String(v('nom_projet')), v('emetteur') || null, normaliseDate(v('date_souscription')), v('date_premiere_echeance') ? normaliseDate(v('date_premiere_echeance')) : null, v('date_cible') ? normaliseDate(v('date_cible')) : null, num(v('montant_investi')), v('taux_interet') ? Number(String(v('taux_interet')).replace(',', '.')) : null, v('duree_mois') ? parseInt(v('duree_mois'), 10) : null, v('type_remb') || null, v('freq_interets') || 'mensuel', v('statut') || 'en_cours', v('reference') || null, srcLabel, ); } else if (module === 'remboursements') { const capital = num(v('capital')); const cashback = num(v('cashback')); const bruts = num(v('interets_bruts')); const ps = num(v('prelev_sociaux')); const pf = num(v('prelev_forfaitaire')); const interets_nets = Math.round((bruts - ps - pf) * 100) / 100; const net_recu = Math.round((capital + cashback + interets_nets) * 100) / 100; db.prepare(` INSERT INTO remboursements (investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source) VALUES (?,?,?,?,?,?,?,?,?,?,?) `).run( Number(v('investissement_id')), normaliseDate(v('date_remb')), capital, cashback, bruts, ps, pf, interets_nets, net_recu, v('statut') || 'paye', srcLabel, ); } else if (module === 'plateformes') { const nom = String(v('nom') || '').trim(); if (!nom) throw new Error('Le champ nom est vide'); const r = db.prepare(` INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes) VALUES (?, ?, ?, ?) `).run( req.user.id, nom, v('url') || null, v('notes') || null, ); // changes = 0 means the row was ignored (nom already exists) if (r.changes === 0) throw new Error(`Plateforme "${nom}" existe déjà — ignorée`); } else if (module === 'taux_pfu') { const annee = parseInt(v('annee'), 10); if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide'); db.prepare(` INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux) VALUES (?, ?, ?, ?) ON CONFLICT(annee) DO UPDATE SET pfu_total = excluded.pfu_total, impot_revenu = excluded.impot_revenu, prelev_sociaux = excluded.prelev_sociaux, updated_at = datetime('now') `).run( annee, num(v('pfu_total')), num(v('impot_revenu')), num(v('prelev_sociaux')), ); } inserted++; } catch (err) { skipped++; errors.push({ row: idx + 2, error: err.message }); } } }); tx(); db.prepare(` INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json) VALUES (?,?,?,?,?,?,?,?) `).run( req.user.id, req.investisseur?.id ?? null, module, tempId, rows.length, inserted, skipped, JSON.stringify(mapping), ); // Clean up temp file try { fs.unlinkSync(tempPath); } catch { /* */ } res.json({ inserted, skipped, total: rows.length, errors: errors.slice(0, 50) }); } catch (e) { next(e); } }); /** * POST /api/imports/dossier * Importe un dossier investissement complet (format d'export natif). * Scénario CREATE : le dossier n'existe pas → création complète. * Scénario UPDATE : le dossier existe déjà → mise à jour des champs + remboursements manquants. * Identification : (investisseur_id, nom_projet, date_souscription) — clé naturelle portable. */ router.post('/dossier', (req, res, next) => { try { requireInvestisseur(req, res, () => {}); const { dossier } = req.body || {}; if (!dossier || dossier.type !== 'dossier_investissement') { throw new HttpError(400, 'Format invalide — attendu { dossier: { type: "dossier_investissement", ... } }'); } const { investissement: inv, plateforme: platInfo, remboursements = [], reinvestissements = [], historique = [] } = dossier; if (!inv?.nom_projet || !inv?.date_souscription) { throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription'); } let action, investissementId; const tx = db.transaction(() => { /* ── 1. Résoudre / créer la plateforme ─────────────────── */ let platRow = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?') .get(req.user.id, platInfo?.nom || ''); if (!platRow && platInfo?.nom) { const r = db.prepare('INSERT INTO plateformes (user_id, nom, url) VALUES (?,?,?)') .run(req.user.id, platInfo.nom, platInfo.url || null); platRow = { id: r.lastInsertRowid }; } if (!platRow) throw new HttpError(400, 'Plateforme introuvable et nom manquant dans le dossier'); const plateformeId = platRow.id; /* ── 2. Chercher l'investissement existant (clé naturelle) */ const existing = db.prepare(` SELECT id FROM investissements WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ? LIMIT 1 `).get(req.investisseur.id, inv.nom_projet, inv.date_souscription); if (!existing) { /* ────────────── SCÉNARIO CREATE ──────────────────────── */ const r = db.prepare(` INSERT INTO investissements (investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription, date_premiere_echeance, date_cible, date_debut_simul, montant_investi, taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?) `).run( req.investisseur.id, plateformeId, inv.nom_projet, inv.emetteur || null, inv.date_souscription, inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null, Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null, inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel', inv.statut || 'en_cours', inv.reference || null, inv.notes || null, ); investissementId = Number(r.lastInsertRowid); // Remboursements for (const rb of remboursements) { db.prepare(` INSERT INTO remboursements (investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source) VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier') `).run( investissementId, rb.date_remb, rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0, rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0, rb.interets_nets || 0, rb.net_recu || 0, rb.statut || 'paye', rb.notes || null, ); } // Réinvestissements for (const rv of reinvestissements) { if (!rv.date_reinvestissement || !rv.montant) continue; db.prepare(` INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source) VALUES (?, ?, ?, ?, ?) `).run( investissementId, Number(rv.montant), rv.date_reinvestissement, rv.note || null, rv.source || 'manuel', ); } // Historique (lecture seule — on importe pour la traçabilité) for (const h of historique) { db.prepare(` INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes, created_at) VALUES (?,?,?,?,?) `).run( investissementId, h.type_evenement || 'import', typeof h.changements === 'string' ? h.changements : JSON.stringify(h.changements || []), h.notes || null, h.created_at || null, ); } // Entrée d'historique de l'import lui-même db.prepare(` INSERT INTO investissement_historique (investissement_id, type_evenement, changements) VALUES (?, 'import', ?) `).run(investissementId, JSON.stringify([{ champ: 'import', label: 'Import dossier', ancienne_valeur: null, nouvelle_valeur: dossier.exported_at || 'inconnu', }])); action = 'created'; } else { /* ────────────── SCÉNARIO UPDATE ──────────────────────── */ investissementId = existing.id; db.prepare(` UPDATE investissements SET plateforme_id = ?, emetteur = ?, date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?, montant_investi = ?, taux_interet = ?, duree_mois = ?, type_remb = ?, freq_interets = ?, statut = ?, reference = ?, notes = ? WHERE id = ? `).run( plateformeId, inv.emetteur || null, inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null, Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null, inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel', inv.statut || 'en_cours', inv.reference || null, inv.notes || null, investissementId, ); // Remboursements : ajouter les manquants (clé naturelle = date_remb + capital + interets_bruts) let rembInserted = 0; for (const rb of remboursements) { const exists = db.prepare(` SELECT id FROM remboursements WHERE investissement_id = ? AND date_remb = ? AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005 `).get(investissementId, rb.date_remb, rb.capital || 0, rb.interets_bruts || 0); if (!exists) { db.prepare(` INSERT INTO remboursements (investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source) VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier') `).run( investissementId, rb.date_remb, rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0, rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0, rb.interets_nets || 0, rb.net_recu || 0, rb.statut || 'paye', rb.notes || null, ); rembInserted++; } } // Réinvestissements : ajouter les manquants (clé = date + montant) let reinvInserted = 0; for (const rv of reinvestissements) { if (!rv.date_reinvestissement || !rv.montant) continue; const rvExists = db.prepare(` SELECT id FROM reinvestissements WHERE investissement_id = ? AND date_reinvestissement = ? AND ABS(montant - ?) < 0.005 `).get(investissementId, rv.date_reinvestissement, Number(rv.montant)); if (!rvExists) { db.prepare(` INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source) VALUES (?, ?, ?, ?, ?) `).run( investissementId, Number(rv.montant), rv.date_reinvestissement, rv.note || null, rv.source || 'manuel', ); reinvInserted++; } } // Entrée d'historique de la mise à jour db.prepare(` INSERT INTO investissement_historique (investissement_id, type_evenement, changements) VALUES (?, 'import', ?) `).run(investissementId, JSON.stringify([{ champ: 'import', label: 'Mise à jour dossier', ancienne_valeur: null, nouvelle_valeur: `${dossier.exported_at || 'inconnu'} — ${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`, }])); action = 'updated'; } }); tx(); /* ── 3. Régénérer la simulation ─────────────────────────── */ const fresh = db.prepare('SELECT * FROM investissements WHERE id = ?').get(investissementId); generateSimulWithReinvestissements(db, investissementId); /* ── 4. Log import ──────────────────────────────────────── */ db.prepare(` INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json) VALUES (?,?,?,?,?,?,?,?) `).run( req.user.id, req.investisseur.id, 'dossier_investissement', `Dossier_${inv.nom_projet}`, 1, 1, 0, JSON.stringify({ action, investissementId }), ); res.json({ action, investissementId }); } catch (e) { next(e); } }); router.get('/history', (req, res) => { const rows = db.prepare(` SELECT * FROM imports WHERE user_id=? ORDER BY id DESC LIMIT 100 `).all(req.user.id); res.json(rows); }); // helpers /** Supprime les accents/diacritiques d'une chaîne (ex. "Dépôt" → "Depot") */ function stripAccents(s) { return String(s).normalize('NFD').replace(/[̀-ͯ]/g, ''); } /** Convertit une valeur monétaire en nombre (gère "€", espaces, virgule décimale) */ function num(v) { if (v === undefined || v === null || v === '') return 0; // Supprimer tout ce qui n'est pas chiffre, virgule, point ou signe moins const clean = String(v).replace(/[^\d,.-]/g, '').replace(',', '.'); const n = Number(clean); return isNaN(n) ? 0 : n; } function normaliseType(v) { // Normalise les accents avant la comparaison : "Dépôt" → "depot" const s = stripAccents(String(v || '')).toLowerCase(); if (s.startsWith('dep') || s === 'versement' || s === 'in') return 'depot'; if (s.startsWith('ret') || s === 'withdrawal' || s === 'out') return 'retrait'; return s; // CHECK constraint will reject if invalid } function normaliseDate(v) { if (!v) return null; if (v instanceof Date) return v.toISOString().slice(0, 10); const s = String(v).trim(); // Already ISO if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); // dd/mm/yyyy const m = s.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/); if (m) { const [_, d, mo, y] = m; const yy = y.length === 2 ? '20' + y : y; return `${yy}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; } return s; } export default router;