Mise en place de la notion de perte définitive, ainsi que la révision avec statut Polongation

This commit is contained in:
ocroguennec committed 2026-08-22 16:27:41 +02:00
1 parent 4ad6ea79e8
commit 8e1b82d161
21 files changed
+1069 -184

No files matched your search

+185 -78
View File
@@ -158,7 +158,78 @@ export function adjustSimulForActuals(db, investissementId) {
'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0'
).get(investissementId);
// Capital effectivement investi jusqu'à la date du dernier remboursement
applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital);
// Ajustement de la première période partielle (mois incomplet)
adjustFirstPartialPeriod(db, investissementId);
}
/**
* Rejoue chronologiquement l'intégralité de l'historique des remboursements de capital
* anticipés pour reconstruire l'ajustement progressif de l'échéancier.
*
* Nécessaire après une régénération COMPLÈTE (generateSimul()/generateSimulWithReinvestissements()
* en mode standard, qui réécrit TOUT l'échéancier à neuf à partir du montant_investi d'origine,
* sans connaître les remboursements anticipés déjà effectués) — contrairement à un simple appel
* à adjustSimulForActuals(), qui ne recalcule que les échéances postérieures au DERNIER
* remboursement connu et ne peut donc pas reconstituer une valeur intermédiaire déjà "gelée"
* par un remboursement plus récent (ex. la dernière échéance d'un prêt in fine, soldée
* partiellement : son capital_prevu ajusté a été fixé par le remboursement anticipé PRÉCÉDENT,
* pas par celui qui la solde, donc un unique appel après régénération ne la retoucherait
* jamais — il faut rejouer les remboursements dans l'ordre pour reconstruire cette valeur).
*
* Utilisé par regenererEcheancier() (routes/investissements.js) après une régénération
* complète (ex. annulation d'une révision) — jamais après une restructuration partielle, où
* les échéances déjà honorées sont préservées telles quelles par generateSimul() lui-même.
*
* Bug découvert le 22/08/26 sur le dossier "L'Olympique" : l'annulation d'une révision a
* effacé tout l'historique d'ajustement de capital anticipé accumulé sur 18 échéances,
* l'échéancier retombant sur les valeurs naïves d'origine (intérêts et capital final
* recalculés sur le montant investi total, comme si aucun remboursement anticipé n'avait
* jamais eu lieu).
*/
export function replayAdjustSimulForActuals(db, investissementId) {
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);
if (!inv || inv.taux_interet == null || !inv.duree_mois) return;
const reinvests = db.prepare(
'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(investissementId);
// Chaque date distincte où du capital a été remboursé, dans l'ordre chronologique — ce sont
// les points où adjustSimulForActuals() aurait été appelé en conditions réelles.
const dates = db.prepare(`
SELECT DISTINCT date_remb FROM remboursements
WHERE investissement_id = ? AND capital > 0
ORDER BY date_remb ASC
`).all(investissementId).map(r => r.date_remb);
if (!dates.length) return; // aucun remboursement de capital : l'échéancier naïf est déjà correct
for (const d of dates) {
const { total_capital } = db.prepare(
'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ? AND date_remb <= ?'
).get(investissementId, d);
applyCapitalAdjustment(db, inv, reinvests, d, total_capital);
}
adjustFirstPartialPeriod(db, investissementId);
}
/**
* Cœur du recalcul des échéances futures pour un capital remboursé et une date de référence
* donnés — factorisé entre adjustSimulForActuals() (un seul point dans le temps : le dernier
* remboursement connu) et replayAdjustSimulForActuals() (rejoue ce même calcul à CHAQUE point
* historique, dans l'ordre, pour reconstruire les valeurs intermédiaires "gelées" par des
* remboursements plus récents).
*/
function applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital) {
// Capital effectivement investi jusqu'à la date de référence
// (initial + réinvestissements survenus avant ou à cette date)
const capitalAtLastDate = round2(
inv.montant_investi +
@@ -167,25 +238,25 @@ export function adjustSimulForActuals(db, investissementId) {
const remainingCapital = round2(capitalAtLastDate - total_capital);
// Entrées de simulation à recalculer (strictement après la date du dernier remb capital)
// Entrées de simulation à recalculer (strictement après la date de référence)
const futureEntries = db.prepare(`
SELECT * FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue > ?
ORDER BY numero_echeance
`).all(investissementId, last_date);
`).all(inv.id, last_date);
if (futureEntries.length === 0) return;
// Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante
if (remainingCapital <= 0) {
// Entrée simul qui couvre la période du dernier remboursement capital
// Entrée simul qui couvre la période de la date de référence
const lastPaidEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu
FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue <= ?
ORDER BY date_prevue DESC
LIMIT 1
`).get(investissementId, last_date);
`).get(inv.id, last_date);
db.transaction(() => {
// Supprimer les échéances futures devenues caduques
@@ -213,7 +284,7 @@ export function adjustSimulForActuals(db, investissementId) {
const nFuture = futureEntries.length;
const type = inv.type_remb || 'in_fine';
// Réinvestissements encore à venir (après la date du dernier remboursement)
// Réinvestissements encore à venir (après la date de référence)
const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date);
const updates = [];
@@ -278,9 +349,6 @@ export function adjustSimulForActuals(db, investissementId) {
`);
for (const u of updates) stmt.run(u.capital, u.interets, u.total, u.id);
})();
// Ajustement de la première période partielle (mois incomplet)
adjustFirstPartialPeriod(db, investissementId);
}
/**
@@ -384,20 +452,49 @@ export function generateSimulWithReinvestissements(db, investissementId) {
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
if (inv.taux_interet == null || !inv.duree_mois) return;
const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription;
if (!startDate) return;
const finDeMois = !!inv.echeance_fin_de_mois;
const type = inv.type_remb || 'in_fine';
const freq = inv.freq_interets || 'mensuel';
const step = freq === 'trimestriel' ? 3 : 1;
const rPer = (inv.taux_interet / 100 / 12) * step;
const isRestructuration = !!inv.date_debut_simul;
// Durée effective (tient compte d'une éventuelle restructuration)
let effectiveDuree = inv.duree_mois;
if (inv.date_debut_simul && inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) {
const elapsed = monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul);
effectiveDuree = Math.max(1, inv.duree_mois - elapsed);
// En mode restructuration, il faut connaître les échéances déjà honorées AVANT de
// calculer la date de départ de la suite de l'échéancier (cf. generateSimul() pour le
// détail du raisonnement) — simple lecture, peut se faire hors transaction.
let keptEntries = [];
if (isRestructuration) {
keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(investissementId, inv.date_debut_simul);
}
let startDate, effectiveDuree = inv.duree_mois, elapsedMonths = 0;
if (isRestructuration) {
// Même logique que generateSimul() : numérotation absolue anti-collision (jamais en
// dessous du plus haut numero_echeance déjà conservé) et date de départ qui continue
// la cadence mensuelle d'origine plutôt que de repartir littéralement de date_debut_simul.
const dateBasedElapsed = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance)
? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul)
: keptEntries.length;
const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0);
elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero);
effectiveDuree = Math.max(1, inv.duree_mois - elapsedMonths);
const cadenceBase = inv.date_premiere_echeance || inv.date_debut_simul;
startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths);
} else {
startDate = inv.date_premiere_echeance || inv.date_souscription;
if (!startDate) return;
}
// Calendrier de base pour obtenir les dates de chaque échéance
@@ -463,24 +560,10 @@ export function generateSimulWithReinvestissements(db, investissementId) {
}
db.transaction(() => {
if (inv.date_debut_simul) {
if (isRestructuration) {
// ── Mode restructuration ──────────────────────────────────────────────
// Même logique que generateSimul() : conserver les échéances déjà payées avant
// la date de restructuration, supprimer le reste, et renuméroter à partir du
// nombre de mois réellement écoulés (pas de repartir à 1, qui ferait disparaître
// les échéances passées — déjà payées — de la table des projections).
const keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(investissementId, inv.date_debut_simul);
// keptEntries/elapsedMonths déjà calculés plus haut (nécessaire pour la date de
// départ du calendrier de base) : on les réutilise tels quels ici.
if (keptEntries.length > 0) {
db.prepare(
`DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})`
@@ -489,10 +572,6 @@ export function generateSimulWithReinvestissements(db, investissementId) {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
}
const elapsedMonths = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance)
? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul)
: keptEntries.length;
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
@@ -520,9 +599,13 @@ export function generateSimulWithReinvestissements(db, investissementId) {
* Génère (ou régénère) le tableau d'amortissement d'un investissement dans la DB.
* Ne fait rien si taux_interet ou duree_mois est absent.
*
* Si date_debut_simul est renseigné (restructuration de prêt), la simulation
* démarre à cette date et la durée effective est réduite du nombre de mois déjà
* écoulés depuis date_premiere_echeance, afin de ne pas allonger artificiellement le prêt.
* Si date_debut_simul est renseigné (restructuration de prêt suite à une révision des
* conditions), la durée effective est réduite du nombre de mois déjà écoulés depuis
* date_premiere_echeance, afin de ne pas allonger artificiellement le prêt — et la suite
* de l'échéancier continue la cadence mensuelle d'origine (même jour du mois que
* date_premiere_echeance) à partir de la première échéance pas encore écoulée. date_debut_simul
* n'est PAS utilisé comme date de la première nouvelle échéance : ce n'est que la date
* d'effet administrative de la révision, qui peut tomber n'importe quel jour du mois.
*/
export function generateSimul(db, inv) {
const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
@@ -531,16 +614,66 @@ export function generateSimul(db, inv) {
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
if (taux_interet == null || !duree_mois) return;
// date_debut_simul remplace le point de départ quand le prêt a été restructuré
const startDate = date_debut_simul || date_premiere_echeance || date_souscription;
if (!startDate) return;
const finDeMois = !!echeance_fin_de_mois;
const isRestructuration = !!date_debut_simul;
// Durée effective : si restructuration, on soustrait les mois déjà écoulés
// pour que la simulation se termine bien à la date cible contractuelle d'origine.
let effectiveDuree = duree_mois;
if (date_debut_simul && date_premiere_echeance && date_debut_simul > date_premiere_echeance) {
const elapsed = monthsDiff(date_premiere_echeance, date_debut_simul);
effectiveDuree = Math.max(1, duree_mois - elapsed);
// En mode restructuration, on a besoin de connaître les échéances déjà honorées
// AVANT de calculer la date de départ de la suite de l'échéancier (cf. plus bas) —
// simple lecture, sans effet de bord, peut se faire hors transaction.
let keptEntries = [];
if (isRestructuration) {
// Conserver uniquement les échéances déjà payées avant la date de restructuration
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
// Les échéances de la période creuse (non payées entre fin de la phase initiale
// et date_debut_simul) seront supprimées avec tout ce qui suit.
keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(id, date_debut_simul);
}
let startDate, effectiveDuree = duree_mois, elapsedMonths = 0;
if (isRestructuration) {
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
const dateBasedElapsed = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
? monthsDiff(date_premiere_echeance, date_debut_simul)
: keptEntries.length; // fallback : nombre de lignes conservées
// Garde-fou anti-collision : le calcul calendaire ci-dessus peut sous-estimer le nombre
// d'échéances déjà écoulées (ex. date_debut_simul tombe le même mois que la dernière
// échéance déjà honorée, avant que le mois suivant ne soit atteint), auquel cas la
// numérotation reprendrait à un numero_echeance déjà utilisé par une échéance conservée
// ci-dessus, provoquant une violation de la contrainte UNIQUE(investissement_id,
// numero_echeance) à l'INSERT. On ne redescend donc jamais en dessous du plus haut
// numero_echeance déjà conservé.
const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0);
elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero);
effectiveDuree = Math.max(1, duree_mois - elapsedMonths);
// Date de départ de la suite de l'échéancier : on continue la cadence mensuelle
// d'origine (même jour du mois que date_premiere_echeance) à partir de la première
// échéance pas encore écoulée — on ne repart PAS littéralement de date_debut_simul,
// qui n'est que la date d'effet administrative de la révision (ex. le jour où
// l'utilisateur l'a saisie) et peut tomber le même mois qu'une échéance déjà honorée,
// ce qui créerait deux échéances dans le même mois (cf. bug signalé par Olivier le
// 22/08/26 sur le dossier "L'Olympique" : échéances 19 et 20 incohérentes).
const cadenceBase = date_premiere_echeance || date_debut_simul;
startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths);
} else {
startDate = date_premiere_echeance || date_souscription;
if (!startDate) return;
}
const echeances = buildSchedule({
@@ -550,28 +683,11 @@ export function generateSimul(db, inv) {
type: type_remb || 'in_fine',
freq: freq_interets || 'mensuel',
startDate,
finDeMois: !!echeance_fin_de_mois,
finDeMois,
});
const tx = db.transaction(() => {
if (date_debut_simul) {
// ── Mode restructuration ──────────────────────────────────────────────
// Conserver uniquement les échéances déjà payées avant la date de restructuration
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
// Les échéances de la période creuse (non payées entre fin de la phase initiale
// et date_debut_simul) sont supprimées avec tout ce qui suit.
const keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(id, date_debut_simul);
if (isRestructuration) {
if (keptEntries.length > 0) {
// Supprime tout sauf les entrées payées conservées
db.prepare(
@@ -582,15 +698,6 @@ export function generateSimul(db, inv) {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
}
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
const elapsedMonths = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
? monthsDiff(date_premiere_echeance, date_debut_simul)
: keptEntries.length; // fallback : nombre de lignes conservées
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)