/** * Shared schedule builder — used by both simul.js and investissements.js */ function addMonths(isoDate, months) { const [y, m, d] = isoDate.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1 + months, d)); return dt.toISOString().slice(0, 10); } /** * Retourne le dernier jour du mois de isoDate. * Ex : '2024-01-15' → '2024-01-31', '2024-02-01' → '2024-02-29' */ function lastDayOfMonth(isoDate) { const [y, m] = isoDate.split('-').map(Number); const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant = dernier jour du mois courant return dt.toISOString().slice(0, 10); } /** * Ajoute n mois à isoDate et positionne le résultat sur le dernier jour du mois cible. * Évite les débordements de JavaScript (ex : 31 jan + 1 mois = 29 fév, pas 3 mar). * Ex : addMonthsEOM('2024-01-31', 1) → '2024-02-29' */ function addMonthsEOM(isoDate, months) { const [y, m] = isoDate.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1 + months + 1, 0)); // jour 0 = dernier du mois cible return dt.toISOString().slice(0, 10); } const round2 = n => Math.round(n * 100) / 100; /** * Builds a payment schedule. * * type: * - in_fine : intérêts périodiques selon freq, capital à l'échéance * - amortissable: mensualité/trimestrialité constante (modèle bancaire) * - differe : aucun versement intermédiaire, tout en une fois à l'échéance * * freq: * - mensuel : une échéance par mois (offset i-1) * - trimestriel : une échéance tous les 3 mois * - in_fine : forcé pour differe * * startDate = date_premiere_echeance (1ère échéance tombe sur cette date, offset 0) */ export function buildSchedule({ montant, taux, duree, type, freq, startDate, finDeMois = false }) { const step = freq === 'trimestriel' ? 3 : 1; const rPer = (taux / 100 / 12) * step; const nPer = freq === 'in_fine' ? 1 : Math.round(duree / step); const out = []; // Calcule la date d'une échéance selon le mode fin-de-mois ou non. const dateAt = (base, offsetMonths) => finDeMois ? addMonthsEOM(base, offsetMonths) : addMonths(base, offsetMonths); if (type === 'differe') { const interets = round2(montant * (taux / 100 / 12) * duree); // Pour un prêt différé, date_premiere_echeance == date_cible (versement unique). // startDate est déjà la bonne date ; en mode fin-de-mois on s'assure qu'elle // correspond bien au dernier jour (idempotent si déjà positionné par le frontend). out.push({ n: 1, date: finDeMois ? lastDayOfMonth(startDate) : startDate, capital: round2(montant), interets, total: round2(montant + interets), }); } else if (type === 'in_fine') { const interetsPer = round2(montant * rPer); for (let i = 1; i <= nPer; i++) { const isLast = i === nPer; out.push({ n: i, date: dateAt(startDate, (i - 1) * step), capital: isLast ? round2(montant) : 0, interets: interetsPer, total: round2(interetsPer + (isLast ? montant : 0)), }); } } else { // amortissable const a = rPer === 0 ? montant / nPer : montant * rPer / (1 - Math.pow(1 + rPer, -nPer)); let restant = montant; for (let i = 1; i <= nPer; i++) { const interets = round2(restant * rPer); const capital = round2(a - interets); restant = round2(restant - capital); out.push({ n: i, date: dateAt(startDate, (i - 1) * step), capital, interets, total: round2(a), }); } } return out; } /** Nombre de mois entiers entre deux dates ISO */ 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()); } /** * Recalcule les simul_remboursements FUTURS en tenant compte des remboursements * de capital déjà enregistrés (remboursement anticipé partiel) ET des réinvestissements. * * - Les entrées dont date_prevue <= dernière date de remboursement capital sont laissées intactes. * - Les entrées suivantes sont recalculées sur la base du capital restant dû. * - Si aucun capital n'a encore été remboursé → régénération complète. * - Les réinvestissements planifiés après la dernière date de remboursement sont pris en compte * pour gonfler progressivement le capital des périodes futures. */ export function adjustSimulForActuals(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); // taux_interet peut légitimement valoir 0 (ex. révision suite à un arrêt de production // n'engendrant plus d'intérêts) — ne pas confondre avec "non renseigné" (null/undefined). if (!inv || inv.taux_interet == null || !inv.duree_mois) return; // Réinvestissements triés par date (peuvent être vides) const reinvests = db.prepare( 'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' ).all(investissementId); const { total_capital } = db.prepare( 'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ?' ).get(investissementId); // Aucun capital remboursé → régénération complète (avec réinvestissements si présents) if (total_capital <= 0) { if (reinvests.length) { generateSimulWithReinvestissements(db, investissementId); } else { generateSimul(db, inv); } adjustFirstPartialPeriod(db, investissementId); return; } // Date du dernier remboursement comportant du capital const { last_date } = db.prepare( '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 // (initial + réinvestissements survenus avant ou à cette date) const capitalAtLastDate = round2( inv.montant_investi + reinvests.filter(r => r.date_reinvestissement <= last_date).reduce((s, r) => s + r.montant, 0) ); const remainingCapital = round2(capitalAtLastDate - total_capital); // Entrées de simulation à recalculer (strictement après la date du dernier remb capital) const futureEntries = db.prepare(` SELECT * FROM simul_remboursements WHERE investissement_id = ? AND date_prevue > ? ORDER BY numero_echeance `).all(investissementId, 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 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); db.transaction(() => { // Supprimer les échéances futures devenues caduques const stmtDel = db.prepare('DELETE FROM simul_remboursements WHERE id=?'); for (const e of futureEntries) stmtDel.run(e.id); // Mettre à jour l'échéance courante pour qu'elle reflète le capital soldé if (lastPaidEntry) { db.prepare(` UPDATE simul_remboursements SET capital_prevu=?, total_prevu=? WHERE id=? `).run( capitalAtLastDate, round2(capitalAtLastDate + lastPaidEntry.interets_prevus), lastPaidEntry.id, ); } })(); return; } const step = inv.freq_interets === 'trimestriel' ? 3 : 1; const rPer = (inv.taux_interet / 100 / 12) * step; const nFuture = futureEntries.length; const type = inv.type_remb || 'in_fine'; // Réinvestissements encore à venir (après la date du dernier remboursement) const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date); const updates = []; if (type === 'differe') { // Versement unique : recalcul sur capital restant + réinvestissements futurs const entry = futureEntries[0]; const extraCapital = futureReinvests .filter(r => r.date_reinvestissement <= entry.date_prevue) .reduce((s, r) => s + r.montant, 0); const totalCap = round2(remainingCapital + extraCapital); const moisRestants = monthsDiff(last_date, entry.date_prevue); const interets = round2(totalCap * (inv.taux_interet / 100 / 12) * moisRestants); updates.push({ id: entry.id, capital: totalCap, interets, total: round2(totalCap + interets) }); } else if (type === 'in_fine') { // Intérêts sur capital courant (augmente à chaque réinvestissement futur) let capital = remainingCapital; let reinvestIdx = 0; futureEntries.forEach((entry, i) => { while (reinvestIdx < futureReinvests.length && futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) { capital += futureReinvests[reinvestIdx].montant; reinvestIdx++; } const isLast = i === nFuture - 1; const interets = round2(capital * rPer); updates.push({ id: entry.id, capital: isLast ? capital : 0, interets, total: round2(interets + (isLast ? capital : 0)), }); }); } else { // Amortissable : recalcul période par période avec capital courant let restant = remainingCapital; let reinvestIdx = 0; futureEntries.forEach((entry, i) => { while (reinvestIdx < futureReinvests.length && futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) { restant += futureReinvests[reinvestIdx].montant; reinvestIdx++; } const periodsLeft = nFuture - i; const a = rPer === 0 ? restant / periodsLeft : restant * rPer / (1 - Math.pow(1 + rPer, -periodsLeft)); const interets = round2(restant * rPer); const capital = round2(a - interets); restant = round2(restant - capital); updates.push({ id: entry.id, capital, interets, total: round2(a) }); }); } db.transaction(() => { const stmt = db.prepare(` UPDATE simul_remboursements SET capital_prevu = ?, interets_prevus = ?, total_prevu = ? WHERE id = ? `); 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); } /** * Détecte et corrige la première période partielle (mois incomplet au démarrage du prêt). * * Pour les prêts in_fine et amortissables, la première échéance avec intérêts correspond * parfois à un mois incomplet (souscription en milieu de mois). Si le premier remboursement * réel avec intérêts est inférieur au montant simulé, on met à jour la ligne simul * correspondante avec la valeur réelle et on reporte la différence sur la dernière échéance. * * L'ajustement est idempotent : si les valeurs sont déjà cohérentes, rien n'est modifié. */ function adjustFirstPartialPeriod(db, investissementId) { const inv = db.prepare( 'SELECT type_remb FROM investissements WHERE id = ?' ).get(investissementId); if (!inv || inv.type_remb === 'differe') return; // Premier remboursement enregistré avec des intérêts (exclure cashback-only) const firstRemb = db.prepare(` SELECT date_remb, interets_bruts FROM remboursements WHERE investissement_id = ? AND interets_bruts > 0 ORDER BY date_remb ASC LIMIT 1 `).get(investissementId); if (!firstRemb) return; const firstRembMonth = firstRemb.date_remb.slice(0, 7); // YYYY-MM // Entrée simul du même mois YYYY-MM const simulEntry = db.prepare(` SELECT id, interets_prevus, capital_prevu, total_prevu FROM simul_remboursements WHERE investissement_id = ? AND substr(date_prevue, 1, 7) = ? `).get(investissementId, firstRembMonth); if (!simulEntry) return; const diff = round2(simulEntry.interets_prevus - firstRemb.interets_bruts); if (diff <= 0.001) return; // Pas d'écart significatif, aucune correction nécessaire // Dernière échéance simul (celle qui absorbera la différence) const lastEntry = db.prepare(` SELECT id, interets_prevus, capital_prevu, total_prevu FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance DESC LIMIT 1 `).get(investissementId); if (!lastEntry || lastEntry.id === simulEntry.id) return; const newFirstInterets = round2(firstRemb.interets_bruts); const newFirstTotal = round2(simulEntry.capital_prevu + newFirstInterets); const newLastInterets = round2(lastEntry.interets_prevus + diff); const newLastTotal = round2(lastEntry.capital_prevu + newLastInterets); db.transaction(() => { db.prepare( 'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?' ).run(newFirstInterets, newFirstTotal, simulEntry.id); db.prepare( 'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?' ).run(newLastInterets, newLastTotal, lastEntry.id); })(); } /** * Génère (ou régénère) le tableau d'amortissement en tenant compte des réinvestissements. * * Pour chaque période, le capital pris en compte est : * montant_investi + SUM(reinvestissements dont date <= date_période) * * - in_fine : intérêts recalculés sur le capital cumulé à chaque période, * capital final = capital total cumulé * - differe : versement unique recalculé sur le capital total à l'échéance * - amortissable : intérêts de base + quote-part du réinvestissement sur les périodes restantes */ export function generateSimulWithReinvestissements(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) return; const reinvests = db.prepare( 'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' ).all(investissementId); // Pas de réinvestissement → génération standard if (!reinvests.length) { generateSimul(db, inv); return; } // 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; // 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); } // Calendrier de base pour obtenir les dates de chaque échéance const baseSchedule = buildSchedule({ montant: inv.montant_investi, taux: inv.taux_interet, duree: effectiveDuree, type, freq, startDate, finDeMois, }); let capital = inv.montant_investi; let reinvestIdx = 0; const schedule = []; // Pour amortissable : on garde la structure du capital initial et ajoute l'impact // du réinvestissement sur les périodes restantes (quote-part d'intérêts supplémentaires). // Indexes des réinvestissements non encore traités dans le plan amorti. let pendingReinvests = []; for (let i = 0; i < baseSchedule.length; i++) { const entry = baseSchedule[i]; // Réinvestissements dont la date tombe avant (ou sur) cette échéance while (reinvestIdx < reinvests.length && reinvests[reinvestIdx].date_reinvestissement <= entry.date) { const r = reinvests[reinvestIdx]; capital += r.montant; // Pour amortissable : mémoriser le montant réinvesti et le nb de périodes restantes if (type === 'amortissable') { pendingReinvests.push({ montant: r.montant, remainingPeriods: baseSchedule.length - i }); } reinvestIdx++; } const isLast = i === baseSchedule.length - 1; let entryCapital, entryInterets, entryTotal; if (type === 'in_fine') { entryInterets = round2(capital * rPer); entryCapital = isLast ? capital : 0; entryTotal = round2(entryInterets + entryCapital); } else if (type === 'differe') { // Versement unique : intérêts recalculés sur le capital total depuis le début const totalMonths = inv.duree_mois; entryInterets = round2(capital * (inv.taux_interet / 100 / 12) * totalMonths); entryCapital = capital; entryTotal = round2(entryCapital + entryInterets); } else { // Amortissable : intérêts de base + quote-part des réinvestissements const baseInterets = entry.interets; const extraInterets = pendingReinvests.reduce((sum, pr) => { return sum + round2(pr.montant * rPer); }, 0); entryInterets = round2(baseInterets + extraInterets); entryCapital = entry.capital; entryTotal = round2(entryCapital + entryInterets); } schedule.push({ n: entry.n, date: entry.date, capital: entryCapital, interets: entryInterets, total: entryTotal }); } db.transaction(() => { if (inv.date_debut_simul) { // ── 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); if (keptEntries.length > 0) { db.prepare( `DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})` ).run(investissementId, ...keptEntries.map(e => e.id)); } else { 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) VALUES (?,?,?,?,?,?) `); for (const e of schedule) { stmt.run(investissementId, elapsedMonths + e.n, e.date, e.capital, e.interets, e.total); } } else { // ── Mode standard : régénération complète ──────────────────────────── db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId); const stmt = db.prepare(` INSERT INTO simul_remboursements (investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu) VALUES (?,?,?,?,?,?) `); for (const e of schedule) { stmt.run(investissementId, e.n, e.date, e.capital, e.interets, e.total); } } })(); } /** * 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. */ export function generateSimul(db, inv) { const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets, date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois } = 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; // 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); } const echeances = buildSchedule({ montant: montant_investi, taux: taux_interet, duree: effectiveDuree, type: type_remb || 'in_fine', freq: freq_interets || 'mensuel', startDate, finDeMois: !!echeance_fin_de_mois, }); 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 (keptEntries.length > 0) { // Supprime tout sauf les entrées payées conservées db.prepare( `DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})` ).run(id, ...keptEntries.map(e => e.id)); } else { // Rien à conserver → suppression totale 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) VALUES (?,?,?,?,?,?) `); for (const e of echeances) { stmt.run(id, elapsedMonths + e.n, e.date, e.capital, e.interets, e.total); } } else { // ── Mode standard : régénération complète ──────────────────────────── db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id); const stmt = db.prepare(` INSERT INTO simul_remboursements (investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu) VALUES (?,?,?,?,?,?) `); for (const e of echeances) { stmt.run(id, e.n, e.date, e.capital, e.interets, e.total); } } }); tx(); }