From 96effbe15a9b9354957a3564fca3d4a6d8dc0849 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 12 Jul 2026 19:14:27 +0200 Subject: [PATCH] =?UTF-8?q?Nouvelles=20features:=20revision=20des=20condit?= =?UTF-8?q?ions=20des=20pr=C3=AAts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MEMORY.md | 34 +- backend/src/db/index.js | 35 ++ backend/src/routes/investissements.js | 138 +++++++- backend/src/utils/schedule.js | 2 +- frontend/src/pages/InvestissementDetail.jsx | 350 +++++++++++++++++++- 5 files changed, 552 insertions(+), 7 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index d9f0050..b50b927 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,5 +1,5 @@ # MEMORY.md — Crowdlending Tracker -*Dernière mise à jour: 2026-07-03 (session 8)* +*Dernière mise à jour: 2026-07-12 (session 10)* --- @@ -508,3 +508,35 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id); ### Piège outillage — mount bash périmé après édition Edit/Write - Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side. - **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`). + +--- + +## Session 10 — Bug racine dates prêts différés + audit trail + statuts auto (2026-07-12) + +### 🔴 Bug racine trouvé et corrigé — migration `db/index.js` recalculait `date_cible` à CHAQUE démarrage +- **Symptôme initial** : des dizaines de prêts `differe` avec `date_cible` aberrante (années 2100 à 2650), sans aucune trace dans `investissement_historique`, y compris des cas de "ping-pong" de statut (`en_cours` ↔ `en_retard`) sur les mêmes prêts toutes les quelques minutes. +- **Cause réelle** (migration "renommage `date_debut`→`date_premiere_echeance`, `date_echeance`→`date_cible`", `backend/src/db/index.js` ~ligne 229) : le bloc de "correction de formule historique" (`SET date_cible = date(date_premiere_echeance, '+(duree_mois-1)' months)`) n'avait **aucune garde** (`WHERE date_cible IS NULL`) — il tournait donc à **chaque redémarrage serveur**, pour **tous** les investissements. Combiné à une 2e migration juste après (`date_premiere_echeance = date_cible` pour les prêts différés, censée maintenir l'égalité des deux dates), cela formait une **boucle infinie** : à chaque redémarrage, `date_cible` dérivait de +(duree_mois-1) mois supplémentaires — jamais tracé, car du code de migration, pas une action utilisateur ni une route API. +- **Pourquoi ça n'a été détecté que maintenant** : le serveur redémarre très souvent en dev (`node --watch`), donc chaque édition de code déclenchait un cycle de dérive supplémentaire sur les prêts différés déjà touchés. +- **Fix** : le bloc de correction ne s'exécute désormais que si `migrationEnCours` est vrai (colonnes `date_debut`/`date_echeance` encore présentes, càd le jour réel du renommage) — plus jamais au démarrage normal. La migration `date_premiere_echeance = date_cible` (prêts différés) enregistre maintenant un historique précis (`correction_auto_echeancier`) quand elle modifie quelque chose. +- **Point d'attention pour le futur** : toute migration de données (pas juste `ALTER TABLE ADD COLUMN`) dans `db/index.js` doit être strictement idempotente/one-shot — soit via clause `WHERE champ IS NULL`, soit gardée derrière la détection de l'événement historique qui la justifie. Ne jamais laisser un `UPDATE` sans garde tourner à chaque boot. + +### Autres endroits déjà audités/corrigés pour la même classe de bug (dates modifiées sans trace) +- `POST /api/imports/dossier` (`imports.js`) : la branche "SCÉNARIO UPDATE" (upsert par nom_projet+date_souscription) écrasait `date_cible`/`date_premiere_echeance`/`montant_investi`/`taux_interet`/`duree_mois`/`type_remb`/`statut` avec seulement un historique générique ("Mise à jour dossier"), sans le détail des champs modifiés. Fix : réutilise `detectChangements`/`recordHistory`/`detectTypeEvenement` (désormais **exportés** depuis `investissements.js`) pour logger un diff précis champ par champ, comme l'édition manuelle. +- `backend/fix_dates_cible.mjs` : script autonome (`node fix_dates_cible.mjs`), jamais appelé automatiquement, corrige les `date_cible > 2100-01-01` — toujours présent mais pas la source du bug de cette session. + +### Nouveautés Nettoyage de données (`Settings.jsx` → section `imports`, composant `DataCleanupSection.jsx`) +1. **"Corriger les dates des prêts différés"** (`POST /investissements/fix-differe-dates`) : + - Seuil d'écart désormais **configurable** via un select dans la modale : 3 / 6 / 12 / 18 / 24 mois (défaut 24, avant en dur "2 ans"). Body `{ seuilMois }`, validé côté backend contre `[3,6,12,18,24]`. + - Régénère maintenant `simul_remboursements` via `generateSimul()` après correction (avant : échéancier laissé désynchronisé). + - Déclenche immédiatement `checkStatutsRetard()` après correction (sinon un prêt dont la date recalculée tombe dans le passé restait affiché "en_cours" jusqu'au prochain minuit). +2. **"Vérifier la cohérence de l'échéancier des prêts différés"** (nouveau, `POST /investissements/check-echeancier-differe`) — inséré juste après le précédent. Vérifie que `simul_remboursements` contient exactement 1 échéance à la date `date_premiere_echeance`/`date_cible` du prêt ; régénère sinon (`generateSimulWithReinvestissements` si réinvestissements présents, sinon `generateSimul`), log `correction_auto_echeancier`. + +### Job `autoStatut.js` — statuts automatiques enrichis +- `checkStatutsRetard()` fait maintenant **les deux sens** : `en_cours→en_retard` (comme avant, log `passage_auto_retard`) ET `en_retard→en_cours` (nouveau, log `retour_auto_en_cours`) — mais uniquement si (a) `date_cible` est entre aujourd'hui et +30 ans (garde-fou anti date-encore-aberrante-mais-"future") ET (b) le dernier événement d'historique touchant le statut était bien `passage_auto_retard` (jamais d'annulation automatique d'un passage en retard décidé manuellement). +- Chaque transition génère une **notification utilisateur** (table `notifications`) : type `warning` pour le passage en retard, type `success` pour le retour en cours, avec lien direct `/investissements/:id`. +- `TRACKED_FIELDS`, `recordHistory`, `detectChangements`, `detectTypeEvenement` sont maintenant `export` depuis `investissements.js` (réutilisés par `imports.js`). + +### Piège outillage — lecture de la DB SQLite en direct depuis le sandbox bash (WAL + serveur actif) +- Le fichier réel `backend/data/crowdlending.db` est en mode WAL et activement écrit par le serveur Node de l'utilisateur pendant la session. Une copie manuelle (`cp` séparé du `.db`/`.db-wal`/`.db-shm`) pendant que le serveur écrit produit des **lectures tronquées ("database disk image is malformed") ou incohérentes entre deux requêtes successives** — ce n'est PAS la preuve que les données changent réellement à chaque lecture. +- `better-sqlite3` du repo est un binaire natif **Windows** (`node_modules/better-sqlite3/build/Release/better_sqlite3.node`) → `invalid ELF header` dans le sandbox Linux. Utiliser `python3` + module `sqlite3` standard à la place, sur une copie locale dans `/tmp`. +- Pour une lecture fiable d'un état ponctuel : privilégier les preuves de haut niveau déjà présentes dans l'app (page Historique du prêt, page Notifications avec horodatage) plutôt que des requêtes SQL répétées sur une DB en cours d'écriture concurrente. diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 4b48834..59ba84a 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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); diff --git a/backend/src/routes/investissements.js b/backend/src/routes/investissements.js index f09d41e..aa3d4f3 100644 --- a/backend/src/routes/investissements.js +++ b/backend/src/routes/investissements.js @@ -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 { diff --git a/backend/src/utils/schedule.js b/backend/src/utils/schedule.js index 1164b33..60bf5a2 100644 --- a/backend/src/utils/schedule.js +++ b/backend/src/utils/schedule.js @@ -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()); diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index b0b1b68..1669635 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -160,6 +160,17 @@ export default function InvestissementDetail() { const [confirmingRembDelete, setConfirmingRembDelete] = useState(false); const [confirmingInvDelete, setConfirmingInvDelete] = useState(false); const [confirmingHistDelete, setConfirmingHistDelete] = useState(null); // id de l'entrée à supprimer + // Modal révision des conditions de prêt (taux/date suite à un événement) + const [revisionModalOpen, setRevisionModalOpen] = useState(false); + const [revisionForm, setRevisionForm] = useState({ + date_effet: today(), + changeTaux: false, nouveau_taux: '', + changeDate: false, nouvelle_date_cible: '', + motif: '', + }); + const [revisionErr, setRevisionErr] = useState(null); + const [revisionSaving, setRevisionSaving] = useState(false); + const [confirmingRevisionDelete, setConfirmingRevisionDelete] = useState(null); // id de la révision à supprimer const [loading, setLoading] = useState(true); const [recalculating, setRecalculating] = useState(false); // Modal réinvestissement @@ -611,6 +622,56 @@ export default function InvestissementDetail() { } catch (e) { /* silencieux */ } }; + const openRevisionModal = () => { + setRevisionForm({ + date_effet: today(), + changeTaux: false, nouveau_taux: '', + changeDate: false, nouvelle_date_cible: '', + motif: '', + }); + setRevisionErr(null); + setRevisionModalOpen(true); + }; + const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionErr(null); }; + + const submitRevision = async (e) => { + e?.preventDefault?.(); + setRevisionErr(null); + if (!revisionForm.date_effet) { setRevisionErr("La date d'effet est requise."); return; } + if (!revisionForm.motif.trim()) { setRevisionErr('Le motif est obligatoire.'); return; } + if (!revisionForm.changeTaux && !revisionForm.changeDate) { + setRevisionErr('Cochez au moins « Modifier le taux » ou « Modifier la date cible ».'); + return; + } + if (revisionForm.changeTaux && revisionForm.nouveau_taux === '') { + setRevisionErr('Saisissez le nouveau taux annuel.'); + return; + } + if (revisionForm.changeDate && !revisionForm.nouvelle_date_cible) { + setRevisionErr('Saisissez la nouvelle date cible.'); + return; + } + setRevisionSaving(true); + try { + await api.post(`/investissements/${id}/revisions`, { + date_effet: revisionForm.date_effet, + nouveau_taux: revisionForm.changeTaux ? Number(revisionForm.nouveau_taux) : undefined, + nouvelle_date_cible: revisionForm.changeDate ? revisionForm.nouvelle_date_cible : undefined, + motif: revisionForm.motif.trim(), + }); + setRevisionModalOpen(false); + await load(); + } catch (e) { setRevisionErr(e.message); } finally { setRevisionSaving(false); } + }; + + const deleteRevision = async (rid) => { + try { + await api.del(`/investissements/${id}/revisions/${rid}`); + setConfirmingRevisionDelete(null); + await load(); + } catch (e) { setConfirmingRevisionDelete(null); } + }; + const openReinvModal = (tab = 'manuel') => { setReinvForm({ montant: '', date_reinvestissement: today(), note: '' }); setReinvErr(null); @@ -658,6 +719,11 @@ export default function InvestissementDetail() { const simul = inv.simul || []; const todayStr = today(); const historique = inv.historique || []; + const revisions = inv.revisions || []; + // Dernière révision ayant effectivement changé le taux / la date cible — pour l'affichage + // "ancienne valeur barrée → nouvelle valeur" dans Informations du projet. + const lastTauxRevision = [...revisions].reverse().find(r => String(r.ancien_taux) !== String(r.nouveau_taux)); + const lastDateCibleRevision = [...revisions].reverse().find(r => String(r.ancienne_date_cible) !== String(r.nouvelle_date_cible)); const reinvs = inv.reinvestissements || []; // Capital total = montant initial + réinvestissements const capitalTotal = inv.capital_total ?? inv.montant_investi; @@ -670,6 +736,32 @@ export default function InvestissementDetail() { const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0); const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0); + // Taux annuel implicite calculé à partir des intérêts réellement versés à chaque échéance — + // utile pour repérer une revalorisation de taux non annoncée par la plateforme. + // Base = capital restant dû juste avant le remboursement (montant initial + réinvestissements + // survenus avant ou à cette date, moins le capital déjà remboursé aux échéances précédentes). + // Non pertinent pour un prêt différé (un seul versement, pas de série à comparer). + const tauxImpliciteApplicable = inv.freq_interets === 'mensuel' || inv.freq_interets === 'trimestriel'; + const tauxImpliciteByRembId = new Map(); + if (tauxImpliciteApplicable) { + const rembTries = [...remb].sort((a, b) => a.date_remb.localeCompare(b.date_remb)); + const reinvTries = [...reinvs].sort((a, b) => a.date_reinvestissement.localeCompare(b.date_reinvestissement)); + const freqStep = inv.freq_interets === 'trimestriel' ? 3 : 1; + let reinvIdx = 0; + let capitalCourant = inv.montant_investi; + for (const r of rembTries) { + while (reinvIdx < reinvTries.length && reinvTries[reinvIdx].date_reinvestissement <= r.date_remb) { + capitalCourant += reinvTries[reinvIdx].montant; + reinvIdx++; + } + const base = capitalCourant; + const tauxBrut = base > 0 ? (r.interets_bruts || 0) / base * (12 / freqStep) * 100 : null; + const tauxNet = base > 0 ? (r.interets_nets || 0) / base * (12 / freqStep) * 100 : null; + tauxImpliciteByRembId.set(r.id, { base, tauxBrut, tauxNet }); + capitalCourant -= (r.capital || 0); + } + } + // Rapprochement projections ↔ remboursements réels // Priorité : date exacte, sinon même mois (YYYY-MM) // Si tout le capital est remboursé (capRestant = 0), fallback sur le dernier remboursement @@ -757,6 +849,15 @@ export default function InvestissementDetail() { notes: h.notes, created_at: h.created_at, })), + revisions: (inv.revisions || []).map(r => ({ + date_effet: r.date_effet, + ancien_taux: r.ancien_taux, + nouveau_taux: r.nouveau_taux, + ancienne_date_cible: r.ancienne_date_cible, + nouvelle_date_cible: r.nouvelle_date_cible, + motif: r.motif, + created_at: r.created_at, + })), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -935,7 +1036,21 @@ export default function InvestissementDetail() { )}
Taux annuel
-
{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}
+ {lastTauxRevision ? ( +
+ {fmtPct(lastTauxRevision.ancien_taux)} + + {inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'} + + Révisé le {fmtDate(lastTauxRevision.date_effet)} + +
+ ) : ( +
{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}
+ )}
Durée
@@ -951,7 +1066,21 @@ export default function InvestissementDetail() {
Date cible contractuelle
-
{fmtDate(inv.date_cible) || '—'}
+ {lastDateCibleRevision ? ( +
+ {fmtDate(lastDateCibleRevision.ancienne_date_cible) || '—'} + + {fmtDate(inv.date_cible) || '—'} + + Révisé le {fmtDate(lastDateCibleRevision.date_effet)} + +
+ ) : ( +
{fmtDate(inv.date_cible) || '—'}
+ )}
{inv.notes && (
@@ -969,6 +1098,107 @@ export default function InvestissementDetail() {
+ {/* ── Révisions des conditions de prêt ──────────────────────── */} + {revisions.length > 0 && ( +
+

Révisions du prêt

+
+
+ {[...revisions].reverse().map((rev, idx) => { + const isLast = idx === 0; // dernière révision (liste inversée) = seule supprimable + const isConfirmingDelete = confirmingRevisionDelete === rev.id; + const tauxChange = String(rev.ancien_taux) !== String(rev.nouveau_taux); + const dateChange = String(rev.ancienne_date_cible) !== String(rev.nouvelle_date_cible); + return ( +
+
+
+
+ ⟳ Révision des conditions + Effet au {fmtDate(rev.date_effet)} + {isLast && !isConfirmingDelete && ( + + )} +
+ + {isConfirmingDelete && ( +
+ ⚠️ + + Annuler cette révision ?
+ + Les conditions reviendront à l'état précédent et l'échéancier sera régénéré. + +
+ + +
+ )} + + {tauxChange && ( +
+ Taux annuel + + {rev.ancien_taux != null ? fmtPct(rev.ancien_taux) : '—'} + + + + {rev.nouveau_taux != null ? fmtPct(rev.nouveau_taux) : '—'} + +
+ )} + {dateChange && ( +
+ Date cible + + {fmtDate(rev.ancienne_date_cible) || '—'} + + + + {fmtDate(rev.nouvelle_date_cible) || '—'} + +
+ )} +
+ Motif : {rev.motif} +
+
+
+ ); + })} +
+
+ )} + {/* ── Remboursements enregistrés ────────────────────────────── */}
@@ -1036,7 +1266,17 @@ export default function InvestissementDetail() { {plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && ( {r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'} )} - {fmtEUR(netMode ? r.interets_nets : r.interets_bruts)} + { + if (!tauxImpliciteApplicable) return undefined; + const ti = tauxImpliciteByRembId.get(r.id); + if (!ti || !(ti.base > 0)) return 'Taux implicite non calculable (capital restant nul ou inconnu)'; + return `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`; + })()} + > + {fmtEUR(netMode ? r.interets_nets : r.interets_bruts)} + {fmtEUR(r.cashback)} @@ -1468,6 +1708,97 @@ export default function InvestissementDetail() {
)} + {/* ── Modal révision des conditions de prêt ─────────────────── */} + + + + + } + > +

+ À utiliser lorsqu'un événement (retard projet, renégociation…) modifie le taux et/ou la date cible + du prêt. L'échéancier de projection sera régénéré automatiquement à partir de la date d'effet. +

+
+ + setRevisionForm(f => ({ ...f, date_effet: e.target.value }))} + required + /> + + {revisionForm.changeTaux && ( + setRevisionForm(f => ({ ...f, nouveau_taux: e.target.value }))} + /> + )} + + {revisionForm.changeDate && ( + <> + setRevisionForm(f => ({ ...f, nouvelle_date_cible: e.target.value }))} + /> +
+ La durée du prêt sera recalculée automatiquement pour que l'échéancier atteigne cette date. +
+ + )} + +