From 7ace2c8f2eadc46f0cff02a904292dcd2a0c15ae Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 13 Jul 2026 07:11:42 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20du=20calcul=20XIRR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MEMORY.md | 34 ++++++++++++++++++++- frontend/src/pages/InvestissementDetail.jsx | 30 ++++++++++++------ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index b50b927..281cade 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,5 +1,5 @@ # MEMORY.md — Crowdlending Tracker -*Dernière mise à jour: 2026-07-12 (session 10)* +*Dernière mise à jour: 2026-07-13 (session 11)* --- @@ -540,3 +540,35 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id); - 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. + +--- + +## Session 11 — Révision des conditions de prêt (2026-07-13) + +### Nouvelle fonctionnalité : révision des conditions (taux / date cible) +Distincte de `investissement_historique` (audit générique auto-détecté sur tout changement de champ) : trace un **événement métier explicite** (retard projet, renégociation…) avec **motif obligatoire**, dans sa propre table. + +- Table `investissement_revisions` (migration dans `db/index.js`) : `date_effet`, `ancien_taux`/`nouveau_taux`, `ancienne_date_cible`/`nouvelle_date_cible`, `ancien_duree_mois`/`nouveau_duree_mois`, `motif TEXT NOT NULL`. +- Routes dans `investissements.js` (pas de nouveau fichier) : `POST /:id/revisions`, `DELETE /:id/revisions/:rid` (**seule la dernière révision est supprimable**, rollback vers l'état précédent + régénération de l'échéancier). `GET /:id` retourne `revisions` au même niveau que `historique`/`simul`. +- **Point technique clé** : `generateSimul()` se base sur `duree_mois`, **pas** sur `date_cible` (champ d'affichage/contractuel). Donc si `nouvelle_date_cible` est fournie, `duree_mois` est recalculé (`monthsDiff(date_premiere_echeance, nouvelle_date_cible) + 1`) sinon la nouvelle date cible ne serait que cosmétique. Ancien/nouveau `duree_mois` tracés en base pour un rollback fidèle. +- Helper `regenererEcheancier(investissementId)` dans `investissements.js` : appelle `generateSimulWithReinvestissements` si l'investissement a des réinvestissements, sinon `generateSimul`. +- Frontend `InvestissementDetail.jsx` : modal "Réviser les conditions" avec **2 checkboxes explicites** ("Modifier le taux" / "Modifier la date cible") — pas de convention implicite "champ vide = inchangé" (ambiguë, corrigée suite à retour utilisateur). Carte "Révisions du prêt" placée **juste après "Informations du projet"**, avant "Remboursements enregistrés" (positionnement demandé explicitement par l'utilisateur). Badges "Révisé le" avec ancien barré → nouveau sur les champs Taux annuel / Date cible contractuelle. Avertissement non bloquant (texte violet `#a78bfa`) dans le formulaire "Modifier" standard si le champ a déjà été révisé — n'empêche pas l'édition directe (choix utilisateur : le formulaire standard reste une simple correction de fiche, pas un événement de révision). + +### 🔴 Bug trouvé et corrigé — `generateSimulWithReinvestissements` ne préservait pas les échéances déjà payées +- **Symptôme** : après une révision sur un prêt avec réinvestissement actif, la table "Projections de remboursements" perdait plusieurs mois d'échéances déjà payées (renumérotées à partir de 1 depuis la date d'effet de la révision), alors que les remboursements réels restaient intacts. +- **Cause** : `generateSimul()` a une logique de "mode restructuration" (si `date_debut_simul` posé : conserve les échéances antérieures qui correspondent à un remboursement réel, supprime le reste, renumérote à partir des mois réellement écoulés) — mais `generateSimulWithReinvestissements()` ne l'avait **jamais eue** : elle supprimait tout l'échéancier et le régénérait en repartant à `numero_echeance = 1`. Ce chemin n'avait jamais été exercé avant (aucune fonctionnalité ne posait `date_debut_simul` sur un prêt ayant des réinvestissements). +- **Fix** : dupliqué exactement la logique restructuration de `generateSimul()` dans `generateSimulWithReinvestissements()` (`backend/src/utils/schedule.js`). +- **Remède pour les investissements déjà impactés** : menu ⋮ de "Projections de remboursements" → "Régénérer l'échéancier" (route `/simul/recalculate` → `adjustSimulForActuals` → `generateSimulWithReinvestissements`, maintenant corrigée). + +### 🔴 Bug trouvé et corrigé — `taux_interet = 0` traité comme "absent" +- **Cas réel** : plateforme de cloud mining à l'arrêt → révision du taux à 0 %. Bloqué côté validation (`z.number().positive()` rejette 0) ET aurait été silencieusement ignoré par les gardes `!taux_interet` (falsy pour `0`, `null` et `undefined` en JS) qui auraient empêché toute régénération d'échéancier même une fois la validation corrigée. +- **Fix** : `RevisionSchema.nouveau_taux` → `.nonnegative()`. Gardes `!inv.taux_interet` → `inv.taux_interet == null` dans `schedule.js` (`generateSimul`, `generateSimulWithReinvestissements`, `adjustSimulForActuals`), `simul.js` (`POST /generate`), et 3 endroits d'affichage frontend (`InvestissementDetail.jsx` bouton "Régénérer l'échéancier", `SimulRemboursements.jsx`, `Remboursements.jsx` — message "taux manquant"). +- **Règle à retenir** : ne jamais tester un champ numérique métier potentiellement à 0 avec `!champ` — toujours `champ == null` (ou `??` pour les fallbacks, déjà en usage ailleurs dans le code pour `taux_interet`). + +### Tooltip "taux implicite" — détection de revalorisation non annoncée +- Colonne "Intérêts" du tableau "Remboursements enregistrés" (`InvestissementDetail.jsx`) : tooltip (`cell-tooltip`, même pattern que la colonne Imposition) calculant le taux annuel implicite brut/net à partir de l'intérêt réellement versé à cette échéance et du capital restant dû juste avant (montant investi + réinvestissements antérieurs − capital déjà remboursé aux échéances précédentes). +- But : aider à repérer visuellement une revalorisation de taux non annoncée par la plateforme (le taux implicite change d'une ligne à l'autre sans qu'aucune révision n'ait été saisie). +- Non calculé pour les prêts `differe` (un seul versement, pas de série à comparer) — guardé sur `freq_interets === 'mensuel' || 'trimestriel'`. + +### Piège outillage — confirmé à nouveau (cf. session 9) +Le mount bash de `crowdlending-app` était figé sur un instantané ancien (dates de fichiers plusieurs semaines avant la session), sans lien avec les éditions faites via l'outil `Edit`/`Write` dans cette session — `wc -l`/`node --check` sur le mount bash donnaient un fichier tronqué non représentatif. Solution utilisée : reconstruire le fichier édité dans le dossier `outputs` (accessible en écriture réelle depuis bash) via lecture complète par l'outil `Read`, puis `node --check` / `esbuild --loader=jsx` dessus pour une vérification syntaxique fiable — en complément (pas remplacement) de la relecture visuelle des zones éditées via `Read`. diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index 152a8f4..a363e40 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -872,24 +872,34 @@ export default function InvestissementDetail() { URL.revokeObjectURL(url); }; - // Rendement réel annualisé (XIRR) — calculé uniquement si l'opération est remboursée + // Rendement annualisé (XIRR) — calcul exact une fois le prêt intégralement remboursé. + // Sinon (en cours / en retard / procédure), estimation basée sur les remboursements déjà + // versés + une valorisation du capital restant dû à la date du jour (comme si ce capital + // était "racheté" aujourd'hui) — donne un taux annualisé qui tient compte du rythme réel + // des versements, contrairement à un simple ratio intérêts perçus / capital / durée. let rendementReelBrut = null; let rendementReel = null; - if (inv.statut === 'rembourse' && remb.length > 0 && inv.date_souscription) { + const rendementEstime = inv.statut !== 'rembourse'; + if (remb.length > 0 && inv.date_souscription) { // Flux sortants supplémentaires (réinvestissements = nouveaux décaissements) const reinvOutflows = reinvs.map(r => ({ amount: -r.montant, date: r.date_reinvestissement })); + // Valorisation à date du capital pas encore remboursé (uniquement pour l'estimation en cours) + const valorisationAuj = rendementEstime && capRestant > 0 ? [{ amount: capRestant, date: todayStr }] : []; // Brut : capital + cashback + intérêts bruts (avant fiscalité) const cashflowsBrut = [ { amount: -inv.montant_investi, date: inv.date_souscription }, ...reinvOutflows, ...remb.map(r => ({ amount: (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0), date: r.date_remb })), + ...valorisationAuj, ]; rendementReelBrut = xirr(cashflowsBrut); - // Net : net_recu (après prélèvements sociaux + impôt sur le revenu) + // Net : net_recu (après prélèvements sociaux + impôt sur le revenu) — le capital restant + // valorisé n'est, lui, pas soumis à fiscalité (ce n'est pas un gain, juste le principal) const cashflows = [ { amount: -inv.montant_investi, date: inv.date_souscription }, ...reinvOutflows, ...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })), + ...valorisationAuj, ]; rendementReel = xirr(cashflows); } @@ -961,18 +971,20 @@ export default function InvestissementDetail() { {(() => { const rendement = netMode ? rendementReel : rendementReelBrut; + const baseTip = netMode ? 'XIRR sur flux nets réels (après fiscalité)' : 'XIRR sur flux bruts (avant fiscalité)'; const titleTip = rendement !== null - ? (netMode ? 'XIRR sur flux nets réels (après fiscalité)' : 'XIRR sur flux bruts (avant fiscalité)') - : inv.statut !== 'rembourse' - ? 'Disponible uniquement sur les investissements remboursés' - : 'Données insuffisantes'; + ? baseTip + (rendementEstime ? ' — estimation : capital restant dû valorisé à la date du jour' : '') + : 'Nécessite au moins un remboursement enregistré'; return (
-
Rendement annualisé — {netMode ? 'Net' : 'Brut'}
+
+ Rendement annualisé — {netMode ? 'Net' : 'Brut'} + {rendementEstime && rendement !== null ? (estimé) : ''} +
= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}> {rendement !== null ? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %` - : inv.statut === 'rembourse' ? '—' : 'En cours…'} + : remb.length === 0 ? 'Aucun remboursement' : '—'}
);