Files
crowdlending-app/backend/src/routes/imports.js
T

915 lines
40 KiB
JavaScript

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';
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.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); }
});
/**
* POST /api/imports/check-duplicates
* Même entrée que /apply (tempId, module, mapping, defaults) mais AUCUNE écriture en base :
* rejoue la détection anti-doublon module par module et renvoie, pour chaque ligne concernée,
* les valeurs de la ligne importée en regard de l'enregistrement existant en base, afin que
* l'utilisateur puisse décider ligne par ligne (modale de doublons) avant l'import réel.
*/
router.post('/check-duplicates', (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');
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');
const origName = req.body.originalFilename || '';
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
let platIdSet = new Set(), platNameMap = new Map(), platNomById = new Map();
if (module === 'depots_retraits' || module === 'investissements') {
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
platIdSet = new Set(platRows.map(p => p.id));
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
platNomById = new Map(platRows.map(p => [p.id, p.nom]));
}
let invIdSet = new Set(), invNameMap = new Map(), invNomById = new Map();
if (module === 'remboursements') {
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
invIdSet = new Set(invRows.map(i => i.id));
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
invNomById = new Map(invRows.map(i => [i.id, i.nom_projet]));
}
const duplicates = [];
// Doublons INTERNES au fichier : /apply traite les lignes une à une dans une seule transaction,
// donc une ligne répétée plus loin dans le même fichier se retrouve comparée à la précédente
// occurrence tout juste "insérée" — pas seulement aux enregistrements déjà présents en base.
// Comme cette route ne fait aucune écriture, on simule cet effet avec une map en mémoire :
// clé de dédoublonnage → { row, incoming } de la première occurrence rencontrée.
const seenDepotsRetraits = new Map();
const seenInvestissements = new Map();
const seenRemboursements = new Map();
const seenPlateformes = new Map();
const seenTauxPfu = new Map();
for (let idx = 0; idx < rows.length; idx++) {
const row = rows[idx];
const v = (target) => {
const col = mapping[target];
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
return defaults[target];
};
const rowNum = idx + 2;
try {
if (module === 'depots_retraits') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const dateOperation = normaliseDate(v('date_operation'));
const type = normaliseType(v('type'));
const montant = num(v('montant'));
const incoming = {
plateforme: platNomById.get(plateformeId) || '', date_operation: dateOperation,
type, montant, libelle: v('libelle') || null, reference: v('reference') || null,
};
const existing = db.prepare(`
SELECT * FROM depots_retraits
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
AND type = ? AND ABS(montant - ?) < 0.005
LIMIT 1
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
const key = `${plateformeId}|${dateOperation}|${type}|${montant.toFixed(2)}`;
const seen = seenDepotsRetraits.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
plateforme: platNomById.get(existing.plateforme_id) || '', date_operation: existing.date_operation,
type: existing.type, montant: existing.montant, libelle: existing.libelle, reference: existing.reference,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenDepotsRetraits.set(key, { row: rowNum, incoming });
}
} else if (module === 'investissements') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const nomProjet = String(v('nom_projet'));
const dateSouscription = normaliseDate(v('date_souscription'));
const incoming = {
plateforme: platNomById.get(plateformeId) || '', nom_projet: nomProjet, date_souscription: dateSouscription,
montant_investi: num(v('montant_investi')), taux_interet: v('taux_interet') || null,
duree_mois: v('duree_mois') || null, statut: v('statut') || 'en_cours',
};
const existing = db.prepare(`
SELECT * FROM investissements
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
const key = `${plateformeId}|${normalizeName(nomProjet)}|${dateSouscription}`;
const seen = seenInvestissements.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
plateforme: platNomById.get(existing.plateforme_id) || '', nom_projet: existing.nom_projet,
date_souscription: existing.date_souscription, montant_investi: existing.montant_investi,
taux_interet: existing.taux_interet, duree_mois: existing.duree_mois, statut: existing.statut,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenInvestissements.set(key, { row: rowNum, incoming });
}
} else if (module === 'remboursements') {
const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
const dateRemb = normaliseDate(v('date_remb'));
const capital = num(v('capital'));
const bruts = num(v('interets_bruts'));
const incoming = {
investissement: invNomById.get(investissementId) || '', date_remb: dateRemb, capital,
cashback: num(v('cashback')), interets_bruts: bruts,
prelev_sociaux: num(v('prelev_sociaux')), prelev_forfaitaire: num(v('prelev_forfaitaire')),
statut: v('statut') || 'paye',
};
const existing = db.prepare(`
SELECT * FROM remboursements
WHERE investissement_id = ? AND date_remb = ?
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
LIMIT 1
`).get(investissementId, dateRemb, capital, bruts);
const key = `${investissementId}|${dateRemb}|${capital.toFixed(2)}|${bruts.toFixed(2)}`;
const seen = seenRemboursements.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
investissement: invNomById.get(existing.investissement_id) || '', date_remb: existing.date_remb,
capital: existing.capital, cashback: existing.cashback, interets_bruts: existing.interets_bruts,
prelev_sociaux: existing.prelev_sociaux, prelev_forfaitaire: existing.prelev_forfaitaire,
statut: existing.statut,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenRemboursements.set(key, { row: rowNum, incoming });
}
} else if (module === 'plateformes') {
const nom = String(v('nom') || '').trim();
if (!nom) continue;
const incoming = { nom, url: v('url') || null, notes: v('notes') || null };
const existing = db.prepare('SELECT * FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
const key = nom;
const seen = seenPlateformes.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: { nom: existing.nom, url: existing.url, notes: existing.notes } });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenPlateformes.set(key, { row: rowNum, incoming });
}
} else if (module === 'taux_pfu') {
const annee = parseInt(v('annee'), 10);
if (!annee) continue;
const incoming = {
annee, pfu_total: num(v('pfu_total')), impot_revenu: num(v('impot_revenu')), prelev_sociaux: num(v('prelev_sociaux')),
};
const existing = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(annee);
const key = annee;
const seen = seenTauxPfu.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
annee: existing.annee, pfu_total: existing.pfu_total, impot_revenu: existing.impot_revenu, prelev_sociaux: existing.prelev_sociaux,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenTauxPfu.set(key, { row: rowNum, incoming });
}
}
} catch {
// Une ligne dont les références ne se résolvent pas sera de toute façon signalée en erreur
// par /apply — on ne la fait pas échouer ici, la vérification des doublons est best-effort.
}
}
res.json({ total: rows.length, duplicates });
} catch (e) { next(e); }
});
router.post('/apply', (req, res, next) => {
try {
const { tempId, module, mapping, defaults = {}, duplicateDecisions = {} } = 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');
// Résolution plateforme_id / investissement_id : accepte un ID numérique OU un nom
// (ex. colonne "Plateforme" contenant "BienPrêter" plutôt qu'un ID) — cf. resolveRefId.
let platIdSet = new Set(), platNameMap = new Map();
if (module === 'depots_retraits' || module === 'investissements') {
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
platIdSet = new Set(platRows.map(p => p.id));
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
}
let invIdSet = new Set(), invNameMap = new Map();
if (module === 'remboursements') {
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
invIdSet = new Set(invRows.map(i => i.id));
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
}
let inserted = 0, skipped = 0, duplicates = 0;
const errors = [];
// Date la plus ancienne rencontrée par plateforme (dépôts/retraits + investissements)
// — sert à détecter une date antérieure à la date d'ouverture déclarée sur la plateforme.
const platMinDate = new Map();
const trackPlatDate = (plateformeId, dateStr) => {
if (!plateformeId || !dateStr) return;
const cur = platMinDate.get(plateformeId);
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
};
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];
};
// Décision prise par l'utilisateur dans la modale de doublons (voir /check-duplicates) :
// 'accept' = importer quand même malgré la correspondance ; absent/'skip' = comportement
// historique (la ligne en doublon est ignorée).
const rowDecision = duplicateDecisions[String(idx + 2)];
if (module === 'depots_retraits') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const dateOperation = normaliseDate(v('date_operation'));
const type = normaliseType(v('type'));
const montant = num(v('montant'));
trackPlatDate(plateformeId, dateOperation);
// Anti-doublon : même investisseur + plateforme + date + type + montant
const dup = db.prepare(`
SELECT id FROM depots_retraits
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
AND type = ? AND ABS(montant - ?) < 0.005
LIMIT 1
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
VALUES (?,?,?,?,?,?,?,?)
`).run(
req.investisseur.id,
plateformeId,
dateOperation,
type,
montant,
v('libelle') || null,
v('reference') || null,
srcLabel,
);
} else if (module === 'investissements') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const nomProjet = String(v('nom_projet'));
const dateSouscription = normaliseDate(v('date_souscription'));
trackPlatDate(plateformeId, dateSouscription);
// Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription
const dup = db.prepare(`
SELECT id FROM investissements
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
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,
plateformeId,
nomProjet,
v('emetteur') || null,
dateSouscription,
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 investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
const dateRemb = normaliseDate(v('date_remb'));
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;
// Anti-doublon : même investissement + date + capital + intérêts bruts
const dup = db.prepare(`
SELECT id FROM remboursements
WHERE investissement_id = ? AND date_remb = ?
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
LIMIT 1
`).get(investissementId, dateRemb, capital, bruts);
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
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(
investissementId,
dateRemb,
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 existingPlat = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
if (existingPlat) {
// Une plateforme "nom" est unique par utilisateur/détenteur : impossible de créer un
// second doublon en base. "Accepter" le doublon signifie donc ici mettre à jour la
// fiche existante avec les valeurs importées, plutôt que de créer une nouvelle ligne.
if (rowDecision !== 'accept') { duplicates++; continue; }
db.prepare('UPDATE plateformes SET url = ?, notes = ? WHERE id = ?')
.run(v('url') || null, v('notes') || null, existingPlat.id);
} else {
db.prepare(`
INSERT INTO plateformes (user_id, nom, url, notes)
VALUES (?, ?, ?, ?)
`).run(
req.user.id,
nom,
v('url') || null,
v('notes') || null,
);
}
} else if (module === 'taux_pfu') {
const annee = parseInt(v('annee'), 10);
if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide');
const existingTaux = db.prepare('SELECT annee FROM taux_pfu WHERE annee = ?').get(annee);
// Comportement historique : un taux existant est toujours écrasé par l'import (upsert).
// On ne change ce comportement que si l'utilisateur a explicitement refusé le doublon
// dans la modale de vérification — dans ce cas seulement, le taux existant est préservé.
if (existingTaux && rowDecision === 'skip') { duplicates++; continue; }
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();
// Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme
const anomalies = [];
if (platMinDate.size > 0) {
for (const [plateformeId, minDate] of platMinDate) {
const plat = db.prepare('SELECT id, nom, date_ouverture FROM plateformes WHERE id = ? AND user_id = ?')
.get(plateformeId, req.user.id);
if (plat && plat.date_ouverture && minDate < plat.date_ouverture) {
anomalies.push({
plateforme_id: plat.id,
plateforme_nom: plat.nom,
date_ouverture_actuelle: plat.date_ouverture,
date_detectee: minDate,
});
}
}
}
db.prepare(`
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json)
VALUES (?,?,?,?,?,?,?,?,?)
`).run(
req.user.id,
req.investisseur?.id ?? null,
module,
tempId,
rows.length,
inserted,
skipped,
duplicates,
JSON.stringify(mapping),
);
// Clean up temp file
try { fs.unlinkSync(tempPath); } catch { /* */ }
res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50), anomalies });
} catch (e) { next(e); }
});
/**
* POST /api/imports/template
* Génère, à partir du fichier déjà analysé (preview) et du mappage/valeurs par défaut choisis,
* un jeu de données complet : toutes les colonnes du module cible (obligatoires + optionnelles),
* y compris celles qui n'ont pas de colonne source dans le fichier (laissées vides). L'utilisateur
* peut ainsi compléter/enrichir ce fichier avant de le réimporter.
*/
router.post('/template', (req, res, next) => {
try {
const { tempId, module, mapping = {}, defaults = {} } = req.body || {};
if (!tempId || !module) throw new HttpError(400, 'tempId et module sont requis');
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
const tempPath = path.join(UPLOAD_DIR, tempId);
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
const origName = req.body.originalFilename || '';
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
const allTargets = [...def.requiredTargets, ...def.optionalTargets];
const v = (row, target) => {
const col = mapping[target];
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
return defaults[target] !== undefined ? defaults[target] : '';
};
const templateRows = rows.map(row => {
const out = {};
for (const t of allTargets) out[t] = v(row, t);
return out;
});
res.json({ headers: allTargets, rows: templateRows });
} 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 * 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;
const nouveau = {
plateforme_id: plateformeId,
date_premiere_echeance: inv.date_premiere_echeance || null,
date_cible: inv.date_cible || null,
date_debut_simul: inv.date_debut_simul || null,
montant_investi: Number(inv.montant_investi),
taux_interet: inv.taux_interet ?? null,
duree_mois: inv.duree_mois ?? null,
type_remb: inv.type_remb || 'in_fine',
freq_interets: inv.freq_interets || 'mensuel',
statut: inv.statut || 'en_cours',
};
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(
nouveau.plateforme_id, inv.emetteur || null,
nouveau.date_premiere_echeance, nouveau.date_cible, nouveau.date_debut_simul,
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
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 — diff précis champ par champ
// (même logique que l'édition manuelle, pour que la date_cible/date_premiere_echeance
// etc. écrasées par ce ré-import restent traçables dans l'historique du prêt).
const changements = detectChangements(existing, nouveau);
const notesImport = `Import dossier (${dossier.exported_at || 'date export inconnue'}) — ` +
`${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`;
if (changements.length > 0) {
recordHistory(investissementId, {
type_evenement: detectTypeEvenement(changements),
changements,
notes: notesImport,
});
} else {
recordHistory(investissementId, {
type_evenement: 'import',
changements: [{
champ: 'import', label: 'Mise à jour dossier',
ancienne_valeur: null,
nouvelle_valeur: notesImport,
}],
});
}
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, '');
}
/** Normalise un nom pour comparaison insensible à la casse/accents (ex. plateforme, projet) */
function normalizeName(s) {
return stripAccents(String(s || ''))
.toLowerCase()
.trim()
.replace(/\s+/g, ' ');
}
/**
* Résout une valeur de colonne (ID numérique OU nom texte) vers l'ID cible réel.
* - Si la valeur est un entier, elle doit correspondre à un ID existant (idSet).
* - Sinon, elle est recherchée par nom normalisé (nameMap) — insensible casse/accents.
* Lève une erreur explicite si rien ne correspond (la ligne sera ignorée par l'appelant).
*/
function resolveRefId(value, idSet, nameMap, label) {
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`${label} manquant`);
}
const raw = String(value).trim();
if (/^\d+$/.test(raw)) {
const id = Number(raw);
if (idSet.has(id)) return id;
throw new Error(`${label} #${id} introuvable`);
}
const id = nameMap.get(normalizeName(raw));
if (id == null) throw new Error(`${label} "${raw}" introuvable`);
return id;
}
/** 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;