Nouvelles features: revision des conditions des prêts
This commit is contained in:
@@ -429,6 +429,41 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Migration : table des révisions de conditions de prêt (taux/date) ───
|
||||
// Distincte de investissement_historique (qui est une piste d'audit générique
|
||||
// auto-détectée sur tout changement de champ). Ici, on trace un événement métier
|
||||
// explicite (retard projet, renégociation…) avec un motif obligatoire, et qui
|
||||
// déclenche la régénération de l'échéancier (cf. routes/investissements.js).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS investissement_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
|
||||
date_effet TEXT NOT NULL,
|
||||
ancien_taux REAL,
|
||||
nouveau_taux REAL,
|
||||
ancienne_date_cible TEXT,
|
||||
nouvelle_date_cible TEXT,
|
||||
motif TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_revisions_inv ON investissement_revisions(investissement_id)');
|
||||
|
||||
// ── Migration : traçage de la durée lors d'une révision de date cible ────
|
||||
// nouvelle_date_cible ne suffisait pas à elle seule à modifier l'échéancier généré
|
||||
// (generateSimul se base sur duree_mois, pas sur date_cible). Quand une révision change
|
||||
// la date cible, duree_mois doit être recalculé en conséquence — on trace l'ancienne et
|
||||
// la nouvelle valeur ici pour permettre un rollback fidèle (DELETE /revisions/:rid).
|
||||
{
|
||||
const revCols = db.prepare('PRAGMA table_info(investissement_revisions)').all().map(c => c.name);
|
||||
if (!revCols.includes('ancien_duree_mois')) {
|
||||
db.exec('ALTER TABLE investissement_revisions ADD COLUMN ancien_duree_mois INTEGER');
|
||||
}
|
||||
if (!revCols.includes('nouveau_duree_mois')) {
|
||||
db.exec('ALTER TABLE investissement_revisions ADD COLUMN nouveau_duree_mois INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : rôle utilisateur ─────────────────────────────────────────
|
||||
{
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 } from '../utils/schedule.js';
|
||||
import { generateSimul, generateSimulWithReinvestissements, monthsDiff } from '../utils/schedule.js';
|
||||
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||
|
||||
const router = Router();
|
||||
@@ -49,6 +49,34 @@ export function detectTypeEvenement(changements) {
|
||||
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().positive().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(),
|
||||
@@ -394,6 +422,9 @@ router.get('/:id', (req, res, next) => {
|
||||
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);
|
||||
@@ -414,7 +445,7 @@ router.get('/:id', (req, res, next) => {
|
||||
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 });
|
||||
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, reinvestissements, categories_inv, secteurs_inv });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -571,6 +602,109 @@ router.delete('/:id/historique/:hid', (req, res, next) => {
|
||||
} 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 {
|
||||
|
||||
@@ -106,7 +106,7 @@ export function buildSchedule({ montant, taux, duree, type, freq, startDate, fin
|
||||
}
|
||||
|
||||
/** Nombre de mois entiers entre deux dates ISO */
|
||||
function monthsDiff(isoA, isoB) {
|
||||
export function monthsDiff(isoA, isoB) {
|
||||
const a = new Date(isoA);
|
||||
const b = new Date(isoB);
|
||||
return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
||||
|
||||
Reference in New Issue
Block a user