724 lines
32 KiB
JavaScript
724 lines
32 KiB
JavaScript
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, generateSimulWithReinvestissements, monthsDiff } from '../utils/schedule.js';
|
|
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
|
|
|
const router = Router();
|
|
|
|
export 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' },
|
|
];
|
|
|
|
export 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);
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export function detectTypeEvenement(changements) {
|
|
const champsRestructuration = ['type_remb', 'date_debut_simul'];
|
|
if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration';
|
|
return 'modification';
|
|
}
|
|
|
|
// Révision des conditions de prêt (taux et/ou date cible), suite à un événement
|
|
// (retard projet, renégociation…). Distinct de l'historique générique : motif obligatoire,
|
|
// table dédiée, déclenche la régénération automatique de l'échéancier futur.
|
|
const RevisionSchema = z.object({
|
|
date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
nouveau_taux: z.number().nonnegative().optional(),
|
|
nouvelle_date_cible: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
motif: z.string().trim().min(1, 'Le motif est obligatoire'),
|
|
}).refine(d => d.nouveau_taux !== undefined || d.nouvelle_date_cible !== undefined, {
|
|
message: 'Au moins un changement (nouveau taux ou nouvelle date cible) doit être renseigné',
|
|
});
|
|
|
|
function regenererEcheancier(investissementId) {
|
|
const hasReinvest = db.prepare(
|
|
'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1'
|
|
).get(investissementId);
|
|
if (hasReinvest) {
|
|
generateSimulWithReinvestissements(db, investissementId);
|
|
} else {
|
|
const inv = db.prepare(`
|
|
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
|
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
|
|
FROM investissements WHERE id = ?
|
|
`).get(investissementId);
|
|
generateSimul(db, inv);
|
|
}
|
|
}
|
|
|
|
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 { seuilMois?: 3|6|12|18|24 }
|
|
// Corrige date_premiere_echeance et date_cible des prêts différés dont les dates
|
|
// s'écartent de plus de `seuilMois` mois par rapport à date_souscription + duree_mois.
|
|
const SEUILS_MOIS_VALIDES = [3, 6, 12, 18, 24];
|
|
const SEUIL_MOIS_DEFAUT = 24;
|
|
|
|
router.post('/fix-differe-dates', (req, res, next) => {
|
|
try {
|
|
const seuilMois = SEUILS_MOIS_VALIDES.includes(Number(req.body?.seuilMois))
|
|
? Number(req.body.seuilMois)
|
|
: SEUIL_MOIS_DEFAUT;
|
|
|
|
const rows = db.prepare(`
|
|
SELECT i.id, i.nom_projet, i.date_souscription, i.duree_mois,
|
|
i.date_premiere_echeance, i.date_cible,
|
|
i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets,
|
|
i.date_debut_simul, i.echeance_fin_de_mois
|
|
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);
|
|
|
|
// Approximation jours/mois (moyenne julienne) — cohérent avec l'ancien seuil fixe
|
|
// de 730 jours pour 24 mois (2 ans).
|
|
const SEUIL_JOURS = Math.round(seuilMois * 30.44);
|
|
|
|
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);
|
|
|
|
const changements = [];
|
|
if (String(inv.date_premiere_echeance ?? null) !== String(dateCalculee)) {
|
|
changements.push({
|
|
champ: 'date_premiere_echeance',
|
|
label: 'Date 1ère échéance',
|
|
ancienne_valeur: inv.date_premiere_echeance,
|
|
nouvelle_valeur: dateCalculee,
|
|
});
|
|
}
|
|
if (String(inv.date_cible ?? null) !== String(dateCalculee)) {
|
|
changements.push({
|
|
champ: 'date_cible',
|
|
label: 'Date cible',
|
|
ancienne_valeur: inv.date_cible,
|
|
nouvelle_valeur: dateCalculee,
|
|
});
|
|
}
|
|
recordHistory(inv.id, {
|
|
type_evenement: 'correction_auto_dates',
|
|
changements,
|
|
notes: `Correction automatique (Nettoyage > Corriger les dates des prêts différés) : ` +
|
|
`écart > ${seuilMois} mois avec date_souscription + duree_mois`,
|
|
});
|
|
|
|
// Régénère l'échéancier de projection avec la date corrigée — sans ça,
|
|
// simul_remboursements reste calé sur l'ancienne date aberrante.
|
|
generateSimul(db, {
|
|
id: inv.id,
|
|
montant_investi: inv.montant_investi,
|
|
taux_interet: inv.taux_interet,
|
|
duree_mois: inv.duree_mois,
|
|
type_remb: inv.type_remb,
|
|
freq_interets: inv.freq_interets,
|
|
date_premiere_echeance: dateCalculee,
|
|
date_debut_simul: inv.date_debut_simul,
|
|
date_souscription: inv.date_souscription,
|
|
echeance_fin_de_mois: inv.echeance_fin_de_mois ?? 0,
|
|
});
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Une date corrigée peut désormais être passée (prêt qui bascule en retard) ou au
|
|
// contraire future (prêt qui en sort) — on relance immédiatement la vérification de
|
|
// statut plutôt que d'attendre le prochain passage du job (minuit / redémarrage serveur),
|
|
// sinon le prêt affiche un statut incohérent avec sa date jusqu'au lendemain.
|
|
const statutsMisAJour = corriges.length > 0 ? checkStatutsRetard() : 0;
|
|
|
|
res.json({ updated: corriges.length, detail: corriges, statutsMisAJour, seuilMois });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/investissements/check-echeancier-differe
|
|
// Vérifie, pour chaque prêt différé, que l'échéancier de projection (simul_remboursements)
|
|
// contient bien une unique échéance dont la date correspond à date_premiere_echeance
|
|
// (== date_cible pour un prêt différé). Régénère l'échéancier sinon — utile après un
|
|
// import, une restauration, ou tout chemin ayant modifié les dates sans régénérer la
|
|
// simulation associée (cf. bouton "Corriger les dates des prêts différés" ci-dessus,
|
|
// qui ne couvre que l'écart date_souscription + durée > 2 ans).
|
|
router.post('/check-echeancier-differe', (req, res, next) => {
|
|
try {
|
|
const rows = db.prepare(`
|
|
SELECT i.id, i.nom_projet, i.date_premiere_echeance, i.date_cible,
|
|
i.montant_investi, i.taux_interet, i.duree_mois, i.type_remb, i.freq_interets,
|
|
i.date_debut_simul, i.date_souscription, i.echeance_fin_de_mois
|
|
FROM investissements i
|
|
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
|
WHERE i.type_remb = 'differe'
|
|
AND i.date_premiere_echeance IS NOT NULL
|
|
AND i.taux_interet IS NOT NULL
|
|
AND i.duree_mois IS NOT NULL
|
|
`).all(req.user.id);
|
|
|
|
const getSimul = db.prepare(`
|
|
SELECT id, numero_echeance, date_prevue
|
|
FROM simul_remboursements
|
|
WHERE investissement_id = ?
|
|
ORDER BY numero_echeance
|
|
`);
|
|
const hasReinvest = db.prepare('SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1');
|
|
|
|
const corriges = [];
|
|
|
|
for (const inv of rows) {
|
|
const simulRows = getSimul.all(inv.id);
|
|
const incoherent =
|
|
simulRows.length !== 1 ||
|
|
simulRows[0].date_prevue !== inv.date_premiere_echeance;
|
|
|
|
if (!incoherent) continue;
|
|
|
|
const ancienEcheancier = simulRows.length === 0
|
|
? 'aucune échéance'
|
|
: simulRows.map(s => `${s.date_prevue} (n°${s.numero_echeance})`).join(', ');
|
|
|
|
if (hasReinvest.get(inv.id)) {
|
|
generateSimulWithReinvestissements(db, inv.id);
|
|
} else {
|
|
generateSimul(db, inv);
|
|
}
|
|
|
|
recordHistory(inv.id, {
|
|
type_evenement: 'correction_auto_echeancier',
|
|
changements: [{
|
|
champ: 'echeancier',
|
|
label: 'Échéancier de projection',
|
|
ancienne_valeur: ancienEcheancier,
|
|
nouvelle_valeur: inv.date_premiere_echeance,
|
|
}],
|
|
notes: "Correction automatique (Nettoyage > Vérifier la cohérence de l'échéancier des prêts différés)",
|
|
});
|
|
|
|
corriges.push({
|
|
id: inv.id,
|
|
nom_projet: inv.nom_projet,
|
|
ancien_echeancier: ancienEcheancier,
|
|
nouvelle_date: inv.date_premiere_echeance,
|
|
});
|
|
}
|
|
|
|
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 revisions = db.prepare(
|
|
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
|
|
).all(req.params.id);
|
|
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, revisions, 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); }
|
|
});
|
|
|
|
// POST /api/investissements/:id/revisions
|
|
// Enregistre une révision des conditions de prêt (nouveau taux et/ou nouvelle date cible),
|
|
// avec motif obligatoire. Écrase taux_interet/date_cible de l'investissement, positionne
|
|
// date_debut_simul sur la date d'effet, et régénère automatiquement l'échéancier futur.
|
|
//
|
|
// Important : generateSimul() calcule l'échéancier à partir de duree_mois (pas de date_cible,
|
|
// qui n'est qu'un champ d'affichage/contractuel). Donc si nouvelle_date_cible est fournie et
|
|
// diffère de la date actuelle, duree_mois doit être recalculé en conséquence, sinon la
|
|
// nouvelle date cible ne serait que cosmétique et l'échéancier généré ne la refléterait pas.
|
|
router.post('/:id/revisions', (req, res, next) => {
|
|
try {
|
|
const body = RevisionSchema.parse(req.body);
|
|
const invId = Number(req.params.id);
|
|
const inv = db.prepare(`
|
|
SELECT i.* FROM investissements i
|
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
|
WHERE i.id = ? AND inv.user_id = ?
|
|
`).get(invId, req.user.id);
|
|
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
|
|
|
const ancien_taux = inv.taux_interet ?? null;
|
|
const ancienne_date_cible = inv.date_cible ?? null;
|
|
const ancien_duree_mois = inv.duree_mois ?? null;
|
|
const nouveau_taux = body.nouveau_taux ?? ancien_taux;
|
|
const nouvelle_date_cible = body.nouvelle_date_cible ?? ancienne_date_cible;
|
|
|
|
// Recalcule duree_mois si la date cible change réellement, pour que l'échéancier régénéré
|
|
// atteigne effectivement cette nouvelle date (cf. convention date_cible = date_premiere_echeance
|
|
// + (duree_mois - 1) mois, utilisée côté frontend pour tous les types de prêt).
|
|
let nouveau_duree_mois = ancien_duree_mois;
|
|
if (nouvelle_date_cible !== ancienne_date_cible && inv.date_premiere_echeance) {
|
|
nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1;
|
|
}
|
|
|
|
let revisionId;
|
|
const tx = db.transaction(() => {
|
|
const r = db.prepare(`
|
|
INSERT INTO investissement_revisions
|
|
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
|
|
ancien_duree_mois, nouveau_duree_mois, motif)
|
|
VALUES (?,?,?,?,?,?,?,?,?)
|
|
`).run(invId, body.date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
|
|
ancien_duree_mois, nouveau_duree_mois, body.motif);
|
|
revisionId = r.lastInsertRowid;
|
|
|
|
db.prepare(`
|
|
UPDATE investissements
|
|
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, invId);
|
|
|
|
regenererEcheancier(invId);
|
|
});
|
|
tx();
|
|
|
|
const revision = db.prepare('SELECT * FROM investissement_revisions WHERE id = ?').get(revisionId);
|
|
res.status(201).json(revision);
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// DELETE /api/investissements/:id/revisions/:rid
|
|
// Seule la révision la plus récente peut être supprimée (rollback vers l'état précédent,
|
|
// puis régénération de l'échéancier). Évite les états incohérents en cas de suppression
|
|
// d'une révision intermédiaire.
|
|
router.delete('/:id/revisions/:rid', (req, res, next) => {
|
|
try {
|
|
const invId = Number(req.params.id);
|
|
const inv = db.prepare(`
|
|
SELECT i.id FROM investissements i
|
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
|
WHERE i.id = ? AND inv.user_id = ?
|
|
`).get(invId, req.user.id);
|
|
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
|
|
|
const derniere = db.prepare(
|
|
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id DESC LIMIT 1'
|
|
).get(invId);
|
|
if (!derniere || derniere.id !== Number(req.params.rid)) {
|
|
throw new HttpError(400, 'Seule la révision la plus récente peut être supprimée');
|
|
}
|
|
|
|
const tx = db.transaction(() => {
|
|
db.prepare(`
|
|
UPDATE investissements
|
|
SET taux_interet = ?, date_cible = ?, duree_mois = ?,
|
|
date_debut_simul = (
|
|
SELECT date_effet FROM investissement_revisions
|
|
WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1
|
|
),
|
|
updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, invId, derniere.id, invId);
|
|
|
|
db.prepare('DELETE FROM investissement_revisions WHERE id = ?').run(derniere.id);
|
|
|
|
regenererEcheancier(invId);
|
|
});
|
|
tx();
|
|
|
|
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;
|