Mise en place de la notion de perte définitive, ainsi que la révision avec statut Polongation
This commit is contained in:
1 parent
4ad6ea79e8
commit
8e1b82d161
21 files changed
+1069
-184
No files matched your search
+185
-78
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user