Files
crowdlending-app/backend/src/utils/schedule.js
T
2026-07-12 19:38:41 +02:00

618 lines
25 KiB
JavaScript

/**
* 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();
}