Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+531
View File
@@ -0,0 +1,531 @@
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;