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
+457
View File
@@ -0,0 +1,457 @@
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;