import { Router } from 'express'; import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { requireInvestisseur } from '../middleware/investisseurScope.js'; import { generateSimul } from '../utils/schedule.js'; const router = Router(); const TRACKED_FIELDS = [ { key: 'type_remb', label: 'Type de prêt' }, { key: 'taux_interet', label: 'Taux annuel (%)' }, { key: 'duree_mois', label: 'Durée (mois)' }, { key: 'montant_investi', label: 'Montant investi (€)' }, { key: 'statut', label: 'Statut' }, { key: 'freq_interets', label: 'Fréquence des intérêts' }, { key: 'date_premiere_echeance', label: 'Date 1ère échéance' }, { key: 'date_cible', label: 'Date cible' }, { key: 'date_debut_simul', label: 'Date de restructuration' }, { key: 'plateforme_id', label: 'Plateforme' }, ]; function recordHistory(investissementId, { type_evenement, changements, notes }) { if (!changements || changements.length === 0) return; db.prepare(` INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes) VALUES (?, ?, ?, ?) `).run(investissementId, type_evenement, JSON.stringify(changements), notes || null); } function detectChangements(ancien, nouveau) { const diffs = []; for (const { key, label } of TRACKED_FIELDS) { const av = ancien[key] ?? null; const nv = nouveau[key] ?? null; const avNorm = av === '' ? null : av; const nvNorm = nv === '' ? null : nv; if (String(avNorm) !== String(nvNorm)) { diffs.push({ champ: key, label, ancienne_valeur: avNorm, nouvelle_valeur: nvNorm }); } } return diffs; } function detectTypeEvenement(changements) { const champsRestructuration = ['type_remb', 'date_debut_simul']; if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration'; return 'modification'; } const Schema = z.object({ investisseur_id: z.number().int().positive().optional(), plateforme_id: z.number().int().positive(), nom_projet: z.string().min(1), emetteur: z.string().optional(), date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), date_premiere_echeance: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), date_cible: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), date_debut_simul: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), montant_investi: z.number().positive(), taux_interet: z.number().optional(), duree_mois: z.number().int().optional(), type_remb: z.enum(['in_fine','amortissable','differe']).optional().or(z.literal('')), freq_interets: z.enum(['mensuel','trimestriel','in_fine']).default('mensuel'), statut: z.enum(['en_cours','rembourse','en_retard','procedure','cloture']).default('en_cours'), reference: z.string().optional(), notes: z.string().optional(), categorie_id: z.number().int().positive().nullable().optional(), echeance_fin_de_mois: z.number().int().min(0).max(1).optional().default(0), methode_remboursement: z.enum(['portefeuille','compte_courant']).nullable().optional(), nom_compte_courant: z.string().nullable().optional(), compte_id: z.number().int().positive().nullable().optional(), pays_exposition: z.string().length(2).optional().default('FR'), }); router.use(requireInvestisseur); function resolveInvestisseurId(req, bodyInvestisseurId) { if (!bodyInvestisseurId) return req.investisseur.id; const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') .get(bodyInvestisseurId, req.user.id); if (!row) throw new HttpError(403, 'Investisseur non autorisé'); return bodyInvestisseurId; } router.get('/', (req, res) => { const scopeAll = req.query.scope === 'all'; const { statut, plateforme_id } = req.query; const conds = scopeAll ? ['i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'] : ['i.investisseur_id = ?']; const args = scopeAll ? [req.user.id] : [req.investisseur.id]; if (statut) { conds.push('i.statut = ?'); args.push(statut); } if (plateforme_id){ conds.push('i.plateforme_id = ?'); args.push(Number(plateforme_id)); } const rows = db.prepare(` SELECT i.*, p.nom AS plateforme_nom, inv.nom AS investisseur_nom, cp.nom AS categorie_nom, plat_inv.nom AS plateforme_detenteur_nom, c.id AS compte_id, c.nom AS compte_nom, c.type AS compte_type, (SELECT COALESCE(SUM(r.capital),0) FROM remboursements r WHERE r.investissement_id = i.id) AS capital_rembourse, (SELECT COALESCE(SUM(r.interets_bruts),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_percus, (SELECT COALESCE(SUM(r.interets_nets),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_nets_total, (SELECT COALESCE(SUM(r.net_recu),0) FROM remboursements r WHERE r.investissement_id = i.id) AS net_recu_total, (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS reinvestissements_total, i.montant_investi + (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS capital_total FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id JOIN investisseurs inv ON inv.id = i.investisseur_id LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id LEFT JOIN comptes c ON c.id = i.compte_id WHERE ${conds.join(' AND ')} ORDER BY i.date_souscription DESC, i.id DESC `).all(...args); // Attacher les associations catégories/secteurs d'investissement if (rows.length > 0) { const ids = rows.map(r => r.id); const placeholders = ids.map(() => '?').join(','); const cats = db.prepare(` SELECT ic.investissement_id, c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global FROM investissement_categories_inv ic JOIN categories_inv c ON c.id = ic.categorie_id WHERE ic.investissement_id IN (${placeholders}) ORDER BY is_global DESC, c.nom `).all(...ids); const sects = db.prepare(` SELECT is2.investissement_id, s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global FROM investissement_secteurs_inv is2 JOIN secteurs_inv s ON s.id = is2.secteur_id WHERE is2.investissement_id IN (${placeholders}) ORDER BY is_global DESC, s.nom `).all(...ids); const catMap = {}; const sectMap = {}; for (const c of cats) { if (!catMap[c.investissement_id]) catMap[c.investissement_id] = []; catMap[c.investissement_id].push({ id: c.id, nom: c.nom, is_global: c.is_global }); } for (const s of sects) { if (!sectMap[s.investissement_id]) sectMap[s.investissement_id] = []; sectMap[s.investissement_id].push({ id: s.id, nom: s.nom, is_global: s.is_global }); } for (const r of rows) { r.categories_inv = catMap[r.id] || []; r.secteurs_inv = sectMap[r.id] || []; } } res.json(rows); }); // Retourne les comptes bancaires d'un investisseur donné (pour le select dans le formulaire) router.get('/comptes-par-investisseur/:investisseur_id', (req, res, next) => { try { const invId = Number(req.params.investisseur_id); // Vérifie que l'investisseur appartient à l'utilisateur const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') .get(invId, req.user.id); if (!inv) throw new HttpError(403, 'Investisseur non autorisé'); const rows = db.prepare( 'SELECT id, nom, type, banque FROM comptes WHERE investisseur_id = ? AND user_id = ? ORDER BY type, nom' ).all(invId, req.user.id); res.json(rows); } catch (e) { next(e); } }); router.get('/comptes-courants', (req, res) => { const rows = db.prepare(` SELECT DISTINCT i.investisseur_id, i.nom_compte_courant FROM investissements i JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? WHERE i.nom_compte_courant IS NOT NULL AND i.nom_compte_courant != '' ORDER BY i.nom_compte_courant `).all(req.user.id); res.json(rows); }); // POST /api/investissements/fix-differe-dates // Corrige date_premiere_echeance et date_cible des prêts différés dont les dates // s'écartent de plus de 2 ans par rapport à date_souscription + duree_mois. router.post('/fix-differe-dates', (req, res, next) => { try { const rows = db.prepare(` SELECT i.id, i.nom_projet, i.date_souscription, i.duree_mois, i.date_premiere_echeance, i.date_cible FROM investissements i JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? WHERE i.type_remb = 'differe' AND i.date_souscription IS NOT NULL AND i.duree_mois IS NOT NULL `).all(req.user.id); const SEUIL_JOURS = 730; // 2 ans function addMonths(dateStr, months) { const d = new Date(dateStr + 'T00:00:00Z'); d.setUTCMonth(d.getUTCMonth() + months); return d.toISOString().slice(0, 10); } function diffJours(a, b) { return Math.abs((new Date(a + 'T00:00:00Z') - new Date(b + 'T00:00:00Z')) / 86400000); } const corriges = []; const stmt = db.prepare(` UPDATE investissements SET date_premiere_echeance = ?, date_cible = ?, updated_at = datetime('now') WHERE id = ? `); for (const inv of rows) { const dateCalculee = addMonths(inv.date_souscription, inv.duree_mois); const ecartEcheance = inv.date_premiere_echeance ? diffJours(inv.date_premiere_echeance, dateCalculee) : null; const ecartCible = inv.date_cible ? diffJours(inv.date_cible, dateCalculee) : null; const incoherent = (ecartEcheance !== null && ecartEcheance > SEUIL_JOURS) || (ecartCible !== null && ecartCible > SEUIL_JOURS) || (inv.date_premiere_echeance === null) || (inv.date_cible === null); if (incoherent) { stmt.run(dateCalculee, dateCalculee, inv.id); corriges.push({ id: inv.id, nom_projet: inv.nom_projet, date_souscription: inv.date_souscription, duree_mois: inv.duree_mois, ancienne_date_premiere_echeance: inv.date_premiere_echeance, ancienne_date_cible: inv.date_cible, nouvelle_date: dateCalculee, }); } } res.json({ updated: corriges.length, detail: corriges }); } catch (err) { next(err); } }); router.get('/:id', (req, res, next) => { try { const inv = db.prepare(` SELECT i.*, p.nom AS plateforme_nom, p.fiscalite AS plateforme_fiscalite, p.logo_filename AS plateforme_logo, cp.nom AS categorie_nom, c.id AS compte_id, c.nom AS compte_nom, c.type AS compte_type FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id LEFT JOIN comptes c ON c.id = i.compte_id WHERE i.id = ? `).get(req.user.id, req.params.id); if (!inv) throw new HttpError(404, 'Not found'); const remboursements = db.prepare( 'SELECT * FROM remboursements WHERE investissement_id = ? ORDER BY date_remb' ).all(req.params.id); const simul = db.prepare( 'SELECT * FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance' ).all(req.params.id); const historique = db.prepare( 'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC' ).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) })); const reinvestissements = db.prepare( 'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' ).all(req.params.id); const reinvestissements_total = reinvestissements.reduce((s, r) => s + r.montant, 0); const capital_total = inv.montant_investi + reinvestissements_total; // Associations catégories/secteurs const categories_inv = db.prepare(` SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global FROM investissement_categories_inv ic JOIN categories_inv c ON c.id = ic.categorie_id WHERE ic.investissement_id = ? ORDER BY is_global DESC, c.nom `).all(req.params.id); const secteurs_inv = db.prepare(` SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global FROM investissement_secteurs_inv is2 JOIN secteurs_inv s ON s.id = is2.secteur_id WHERE is2.investissement_id = ? ORDER BY is_global DESC, s.nom `).all(req.params.id); res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, reinvestissements, categories_inv, secteurs_inv }); } catch (e) { next(e); } }); router.post('/', (req, res, next) => { try { const body = Schema.parse(req.body); const investisseurId = resolveInvestisseurId(req, body.investisseur_id); 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, categorie_id, echeance_fin_de_mois, methode_remboursement, nom_compte_courant, compte_id, pays_exposition) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?,?,?,?,?,?,?) `).run( investisseurId, body.plateforme_id, body.nom_projet, body.emetteur || null, body.date_souscription, body.date_premiere_echeance || null, body.date_cible || null, body.date_debut_simul || null, body.montant_investi, body.taux_interet ?? null, body.duree_mois ?? null, body.type_remb || null, body.freq_interets, body.statut, body.reference || null, body.notes || null, body.categorie_id ?? null, body.echeance_fin_de_mois ?? 0, body.methode_remboursement ?? null, body.nom_compte_courant || null, body.compte_id ?? null, body.pays_exposition ?? 'FR', ); const newId = r.lastInsertRowid; recordHistory(newId, { type_evenement: 'creation', changements: [{ champ: 'creation', label: 'Création', ancienne_valeur: null, nouvelle_valeur: body.nom_projet }], }); generateSimul(db, { id: newId, montant_investi: body.montant_investi, taux_interet: body.taux_interet ?? null, duree_mois: body.duree_mois ?? null, type_remb: body.type_remb || 'in_fine', freq_interets: body.freq_interets || 'mensuel', date_premiere_echeance: body.date_premiere_echeance || null, date_debut_simul: body.date_debut_simul || null, date_souscription: body.date_souscription, echeance_fin_de_mois: body.echeance_fin_de_mois ?? 0, }); res.status(201).json({ id: newId, ...body }); } catch (e) { next(e); } }); router.put('/:id', (req, res, next) => { try { const body = Schema.parse(req.body); const investisseurId = resolveInvestisseurId(req, body.investisseur_id); const ancien = db.prepare(` SELECT inv_data.* FROM investissements inv_data JOIN investisseurs inv ON inv.id = inv_data.investisseur_id AND inv.user_id = ? WHERE inv_data.id = ? `).get(req.user.id, req.params.id); if (!ancien) throw new HttpError(404, 'Not found'); const r = db.prepare(` UPDATE investissements SET 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=?, notes=?, categorie_id=?, echeance_fin_de_mois=?, methode_remboursement=?, nom_compte_courant=?, compte_id=?, pays_exposition=?, updated_at=datetime('now') WHERE id=? `).run( investisseurId, body.plateforme_id, body.nom_projet, body.emetteur || null, body.date_souscription, body.date_premiere_echeance || null, body.date_cible || null, body.date_debut_simul || null, body.montant_investi, body.taux_interet ?? null, body.duree_mois ?? null, body.type_remb || null, body.freq_interets, body.statut, body.reference || null, body.notes || null, body.categorie_id ?? null, body.echeance_fin_de_mois ?? 0, body.methode_remboursement ?? null, body.nom_compte_courant || null, body.compte_id ?? null, body.pays_exposition ?? 'FR', req.params.id, ); if (r.changes === 0) throw new HttpError(404, 'Not found'); // Cascade compte_id vers les remboursements existants de cet investissement if (body.compte_id !== undefined) { const newCompteId = body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null; db.prepare( "UPDATE remboursements SET compte_id=? WHERE investissement_id=? AND methode_remboursement='compte_courant' AND compte_id IS NOT NULL" ).run(newCompteId, req.params.id); } const changements = detectChangements(ancien, { ...body, date_debut_simul: body.date_debut_simul || null, date_premiere_echeance: body.date_premiere_echeance || null, date_cible: body.date_cible || null, }); if (changements.length > 0) { recordHistory(Number(req.params.id), { type_evenement: detectTypeEvenement(changements), changements, }); } generateSimul(db, { id: Number(req.params.id), montant_investi: body.montant_investi, taux_interet: body.taux_interet ?? null, duree_mois: body.duree_mois ?? null, type_remb: body.type_remb || 'in_fine', freq_interets: body.freq_interets || 'mensuel', date_premiere_echeance: body.date_premiere_echeance || null, date_debut_simul: body.date_debut_simul || null, date_souscription: body.date_souscription, echeance_fin_de_mois: body.echeance_fin_de_mois ?? 0, }); res.json({ id: Number(req.params.id), ...body }); } catch (e) { next(e); } }); // PUT /api/investissements/:id/fiscalite-override { override: 'exonere' | null } router.put('/:id/fiscalite-override', (req, res, next) => { try { const inv = db.prepare( 'SELECT id FROM investissements WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' ).get(req.params.id, req.user.id); if (!inv) throw new HttpError(404, 'Investissement introuvable'); const override = req.body.override === 'exonere' ? 'exonere' : null; db.prepare('UPDATE investissements SET fiscalite_override = ? WHERE id = ?') .run(override, req.params.id); res.json({ fiscalite_override: override }); } catch (e) { next(e); } }); router.delete('/:id', (req, res, next) => { try { const r = db.prepare(` DELETE FROM investissements WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) `).run(req.params.id, req.user.id); if (r.changes === 0) throw new HttpError(404, 'Not found'); res.status(204).end(); } catch (e) { next(e); } }); router.delete('/:id/historique/:hid', (req, res, next) => { try { const r = db.prepare(` DELETE FROM investissement_historique WHERE id = ? AND investissement_id = ? AND EXISTS ( SELECT 1 FROM investissements WHERE id = ? AND investisseur_id = ? ) `).run(req.params.hid, req.params.id, req.params.id, req.investisseur.id); if (r.changes === 0) throw new HttpError(404, 'Not found'); res.status(204).end(); } catch (e) { next(e); } }); // PUT /api/investissements/:id/auto-reinvest { active: true|false } router.put('/:id/auto-reinvest', (req, res, next) => { try { const inv = db.prepare( 'SELECT id FROM investissements WHERE id = ? AND investisseur_id = ?' ).get(req.params.id, req.investisseur.id); if (!inv) throw new HttpError(404, 'Investissement introuvable'); const active = req.body.active ? 1 : 0; db.prepare('UPDATE investissements SET auto_reinvest = ? WHERE id = ?') .run(active, req.params.id); res.json({ auto_reinvest: !!active }); } catch (e) { next(e); } }); export default router;