From 981ddf583062d86dbbf0b3e05fdb44c60a569246 Mon Sep 17 00:00:00 2001 From: Olivier Date: Wed, 2 Sep 2026 08:28:15 +0200 Subject: [PATCH] Correctif --- backend/src/routes/icons.js | 5 +- backend/src/routes/plateformes.js | 5 +- backend/src/routes/v1/dashboard.js | 33 +++++++---- backend/src/routes/v1/fraisOperations.js | 6 +- frontend/src/pages/Aide.jsx | 18 ++++-- frontend/src/pages/Dashboard.jsx | 74 +++++++++++++++++++----- mcp-server/tools.js | 16 ++--- 7 files changed, 115 insertions(+), 42 deletions(-) diff --git a/backend/src/routes/icons.js b/backend/src/routes/icons.js index 7a4a3f6..b24c039 100644 --- a/backend/src/routes/icons.js +++ b/backend/src/routes/icons.js @@ -8,7 +8,10 @@ import { requireAdmin } from '../middleware/auth.js'; import sharp from 'sharp'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const iconsDir = path.resolve(__dirname, '../../../data/icons'); +const dataDir = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../../data'); // fallback dev : 3 niveaux au-dessus de routes/ +const iconsDir = path.join(dataDir, 'icons'); const historyDir = path.resolve(iconsDir, 'history'); fs.mkdirSync(iconsDir, { recursive: true }); fs.mkdirSync(historyDir, { recursive: true }); diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index 99b97c1..de9fb98 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -10,7 +10,10 @@ import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const logosDir = path.resolve(__dirname, '../../../data/logos'); +const dataDir = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../../data'); // fallback dev : 3 niveaux au-dessus de routes/ +const logosDir = path.join(dataDir, 'logos'); fs.mkdirSync(logosDir, { recursive: true }); /** Sanitise un nom de plateforme pour en faire un nom de fichier safe */ diff --git a/backend/src/routes/v1/dashboard.js b/backend/src/routes/v1/dashboard.js index 3d16c97..21d4852 100644 --- a/backend/src/routes/v1/dashboard.js +++ b/backend/src/routes/v1/dashboard.js @@ -17,12 +17,13 @@ const round2 = v => Math.round((v ?? 0) * 100) / 100; * n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés * par année via `?annee=`. `gain_net_depuis_debut` est TOUJOURS un cumul * total (indépendant de `?annee=`) : intérêts nets + cashback/bonus de - * parrainage ou de plateforme + corrections de solde − frais réglés hors - * remboursement (porte-monnaie ou compte courant du détenteur, cf. - * /frais-operations — le détail est dans `gain_net_depuis_debut.frais_hors_remboursement`), - * sur tout l'historique du portefeuille — reste valable même après un retrait - * complet des plateformes, contrairement à une approche basée sur le - * solde courant. + * parrainage ou de plateforme + corrections de solde − tous les frais réellement payés en + * dehors du passage brut → net d'un remboursement (à la souscription, porte-monnaie ou + * compte courant du détenteur — cf. /frais-operations ; seul le mode "remboursement" est + * exclu, déjà déduit des intérêts nets ci-dessus). Détail dans + * `gain_net_depuis_debut.frais_hors_remboursement`. Sur tout l'historique du portefeuille — + * reste valable même après un retrait complet des plateformes, contrairement à une approche + * basée sur le solde courant. * tags: [Dashboard] * security: [{ ApiKeyAuth: [] }] * parameters: @@ -123,16 +124,24 @@ router.get('/', (req, res) => { WHERE ${invCond('investisseur_id')} `).get(invParam); - // ── Frais hors remboursement (Étape 5, 01/09/26 — cf. project_gain_net_depuis_debut.md) : - // les frais réglés en mode "portefeuille" ou "compte_courant" sont une sortie d'argent réelle - // et séparée des remboursements (contrairement au mode "remboursement", déjà déduit de - // interets_nets ci-dessus) — ils doivent donc être soustraits du gain net, en miroir exact du - // correctif appliqué au KPI "Gain net depuis le début" de l'app interne (Dashboard.jsx). + // ── Frais hors remboursement (Étape 5, 02/09/26 — cf. project_gain_net_depuis_debut.md) : + // tout frais réel payé par l'investisseur SAUF ceux retenus directement sur un remboursement + // (mode "remboursement") est soustrait ici — ceux-là sont déjà déduits de interets_nets + // ci-dessus au moment du passage brut → net par la plateforme (ex. Fundora), les soustraire une + // 2ᵉ fois créerait une double déduction qui ne correspond à aucune sortie d'argent réelle + // supplémentaire. Les modes "source" (déduit du versement à la souscription — déjà netté dans + // montant_investi, une donnée de capital que ce total n'utilise jamais), "portefeuille" et + // "compte_courant" sont chacun une vraie sortie d'argent jamais reflétée ailleurs dans cette + // somme (qui ne comporte que des flux de revenu), donc tous les trois doivent être déduits — en + // miroir exact du correctif appliqué au KPI "Gain net depuis le début" de l'app interne + // (Dashboard.jsx). Ne pas réutiliser le filtre ('portefeuille','compte_courant') du XIRR net : + // celui-ci exclut "source" pour une raison propre au XIRR (déjà représenté par un flux de + // capital investi plus petit à t0), qui ne s'applique pas ici. const fraisHorsRembRow = db.prepare(` SELECT COALESCE(SUM(f.montant), 0) AS total FROM frais_operations f JOIN investissements i ON i.id = f.investissement_id - WHERE f.mode_reglement IN ('portefeuille','compte_courant') AND ${invCond('i.investisseur_id')} + WHERE f.mode_reglement != 'remboursement' AND ${invCond('i.investisseur_id')} `).get(invParam); const gainNetTotal = round2( diff --git a/backend/src/routes/v1/fraisOperations.js b/backend/src/routes/v1/fraisOperations.js index 8e2042e..46e5a97 100644 --- a/backend/src/routes/v1/fraisOperations.js +++ b/backend/src/routes/v1/fraisOperations.js @@ -18,9 +18,9 @@ const router = Router(); * détenteur (`compte_nom`), sans impact sur le porte-monnaie ; "remboursement" : retenu * directement sur le remboursement lié (`remboursement_id`), déjà pris en compte dans les * champs `interets_nets`/`net_recu` de ce remboursement — ne pas le compter une seconde - * fois. Seuls les modes "portefeuille" et "compte_courant" représentent une sortie - * d'argent réelle et séparée (c'est ce sous-ensemble qui est déduit de - * `gain_net_depuis_debut` sur /dashboard). + * fois. Tous les autres modes ("source", "portefeuille", "compte_courant") + * représentent chacun une sortie d'argent réelle (c'est cet ensemble — tout sauf + * "remboursement" — qui est déduit de `gain_net_depuis_debut` sur /dashboard). * tags: [Frais] * security: [{ ApiKeyAuth: [] }] * parameters: diff --git a/frontend/src/pages/Aide.jsx b/frontend/src/pages/Aide.jsx index 8fca2f0..7475b9b 100644 --- a/frontend/src/pages/Aide.jsx +++ b/frontend/src/pages/Aide.jsx @@ -344,12 +344,20 @@ export default function Aide() { séparée à suivre par ailleurs. +

+ Le KPI Gain net depuis le début du tableau de bord déduit + tous les frais réellement payés, sauf ceux du mode « Déduit + du remboursement » : ceux-là sont déjà comptés en creux dans les intérêts nets de ce remboursement (le + retirer une 2ᵉ fois compterait la même dépense deux fois). Les frais « Déduit du versement lors de la + souscription », « Porte-monnaie » et « Débité du compte courant » sont chacun une vraie sortie d'argent qui + n'apparaît nulle part ailleurs dans ce total, donc ils sont bien soustraits. +

- Les frais réglés en porte-monnaie ou - compte courant représentent une vraie sortie d'argent, - distincte des remboursements : ils sont donc déduits du KPI Gain net - depuis le début du tableau de bord (et pris en compte dans le TRI net des investissements - concernés), en plus de leur effet sur le solde du porte-monnaie détaillé ci-dessus. + Le TRI (XIRR) net d'un investissement suit une règle + légèrement différente : seuls les frais « Porte-monnaie » et « Compte courant » y sont ajoutés comme sortie + de trésorerie. Un frais « Déduit du versement lors de la souscription » n'y est pas rajouté séparément car + il est déjà reflété dans le montant investi (net du frais dès la souscription) qui sert de point de départ + au calcul — l'ajouter une 2ᵉ fois fausserait le TRI.

diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index ea717e1..6046438 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -157,17 +157,31 @@ function TrendBadge({ current, prev, invert = false }) { } /* ── Carte KPI individuelle ── */ -function KpiCard({ title, value, badge, refValue, note, onClick, hint }) { +function KpiCard({ title, value, badge, refValue, note, onClick, hint, infoTooltip }) { return (
{ e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'; e.currentTarget.style.opacity = '0.88'; } : undefined} onMouseLeave={onClick ? (e) => { e.currentTarget.style.boxShadow = ''; e.currentTarget.style.opacity = ''; } : undefined} > -
{title}
+
+ {title} + {infoTooltip && ( + e.stopPropagation()} + > + + + + + )} +
{value} {badge} @@ -355,24 +369,58 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ // indépendant du capital encore investi et du solde porte-monnaie courant : // reste juste même si tout l'argent est retiré des plateformes. // Voir mémoire project_gain_net_depuis_debut pour la dérivation complète. - const gainNetTotal = useMemo(() => { + // gainNetDetail : mêmes totaux que gainNetTotal ci-dessous, mais conservés en détail + // (pas seulement le résultat final) pour alimenter le popup ⓘ de la carte KPI — demande + // Olivier du 02/09/26, juste après le correctif de la règle de déduction des frais. + const gainNetDetail = useMemo(() => { const totals = (rawDataGlobal.rembourses ?? []).reduce((acc, r) => ({ interets_nets: acc.interets_nets + (r.interets_nets || 0), cashback: acc.cashback + (r.cashback || 0), }), { interets_nets: 0, cashback: 0 }); - // Frais réglés en dehors des montants déjà nettés (frais 'source' à la souscription, - // et frais liés à un remboursement déjà déduits de interets_nets/net_recu) : mode - // 'portefeuille' ou 'compte_courant' — vraie sortie d'argent pour l'investisseur, - // jusque-là absente de ce total (même trou que celui corrigé sur le XIRR net à - // l'Étape 5, cf. xirrGlobal plus bas — signalé par Olivier le 01/09/26). + // Tous les frais réels réglés par l'investisseur SAUF ceux retenus directement sur un + // remboursement (mode_reglement='remboursement') — ceux-là sont déjà déduits de + // interets_nets/net_recu au moment du passage brut → net par la plateforme (ex. + // Fundora) : les soustraire ici referait une 2ᵉ diminution du gain, ce qui ne + // correspond à aucune sortie d'argent réelle supplémentaire (piège signalé par + // Olivier le 02/09/26). En revanche mode 'source' (déduit du versement à la + // souscription — déjà netté dans montant_investi, une donnée de CAPITAL que ce + // total n'utilise jamais) et 'portefeuille'/'compte_courant' (Baltis, réglés à part) + // sont chacun une vraie sortie d'argent jamais reflétée ailleurs dans cette somme + // (qui ne fait intervenir que des flux de revenu : intérêts, cashback, corrections) + // — les trois doivent donc être déduits. Ne PAS réutiliser tel quel le filtre + // ('portefeuille','compte_courant') de xirrGlobal (Étape 5) : le XIRR exclut + // 'source' pour une raison différente et spécifique au XIRR (déjà représenté par un + // flux de capital investi plus petit à t0), qui ne s'applique pas ici puisque ce + // total ne comporte aucun flux de capital. const idsGainNet = new Set((investissements || []).map(r => r.id)); const fraisHorsRembTotal = (allFraisOps || []).reduce((sum, fo) => { if (!idsGainNet.has(fo.investissement_id)) return sum; - if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') return sum; + if (fo.mode_reglement === 'remboursement') return sum; return sum + (fo.montant || 0); }, 0); - return totals.interets_nets + totals.cashback + (correctionsTotal || 0) - fraisHorsRembTotal; + const corrections = correctionsTotal || 0; + const avantFrais = totals.interets_nets + totals.cashback + corrections; + return { + interetsNets: totals.interets_nets, + cashback: totals.cashback, + corrections, + avantFrais, + frais: fraisHorsRembTotal, + total: avantFrais - fraisHorsRembTotal, + }; }, [rawDataGlobal, correctionsTotal, investissements, allFraisOps]); + const gainNetTotal = gainNetDetail.total; + const gainNetInfoTooltip = + `Intérêts nets + cashback + bonus parrainage/plateforme + corrections de solde, moins ` + + `tous les frais réellement payés hors du passage brut → net d'un remboursement — cumulé ` + + `depuis le début, indépendant du capital encore investi ou du solde actuel.\n\n` + + `Détail du calcul :\n\n` + + `Intérêts nets perçus : ${fmtEUR(gainNetDetail.interetsNets)}\n` + + `+ Cashback et bonus (parrainage/plateforme) : ${fmtEUR(gainNetDetail.cashback)}\n` + + `+ Corrections de solde : ${fmtEUR(gainNetDetail.corrections)}\n` + + `= Gain avant frais : ${fmtEUR(gainNetDetail.avantFrais)}\n\n` + + `− Frais pris en compte (hors frais déjà déduits d'un remboursement) : ${fmtEUR(gainNetDetail.frais)}\n\n` + + `= Gain net depuis le début : ${fmtEUR(gainNetDetail.total)}`; // Valeur "Gain net" telle qu'elle aurait été affichée fin du mois précédent — // on retire du cumul total la part perçue ce mois-ci (intérêts nets + cashback, @@ -389,7 +437,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ const idsGainNet = new Set((investissements || []).map(r => r.id)); const fraisCeMois = (allFraisOps || []).reduce((sum, fo) => { if (!idsGainNet.has(fo.investissement_id)) return sum; - if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') return sum; + if (fo.mode_reglement === 'remboursement') return sum; if (!fo.date_operation?.startsWith(thisMonthStr)) return sum; return sum + (fo.montant || 0); }, 0); @@ -684,7 +732,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ /> } refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel} diff --git a/mcp-server/tools.js b/mcp-server/tools.js index f3dd295..3e442a8 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -76,9 +76,11 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo "retard/procédure), montant remboursé, intérêts bruts/nets perçus, " + "capital reçu, total dépôts/retraits, et gain_net_depuis_debut (intérêts " + "nets + cashback/bonus de parrainage ou de plateforme + corrections de " + - "solde − frais réglés hors remboursement (porte-monnaie ou compte " + - "courant du détenteur ; détail dans gain_net_depuis_debut." + - "frais_hors_remboursement — voir aussi crowdlending_list_frais_operations), " + + "solde − tous les frais réellement payés en dehors du passage brut → net " + + "d'un remboursement (à la souscription, porte-monnaie ou compte courant " + + "du détenteur ; seul le mode \"remboursement\" est exclu, déjà déduit des " + + "intérêts nets ; détail dans gain_net_depuis_debut.frais_hors_remboursement " + + "— voir aussi crowdlending_list_frais_operations), " + "cumulés sur tout l'historique — équivalent au KPI \"Gain net " + "depuis le début\" de l'app, toujours un total, jamais filtré par année, " + "reste valable même après un retrait complet des plateformes). Les " + @@ -188,10 +190,10 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo "le remboursement lié (`remboursement_id`), déjà pris en compte dans " + "les champs interets_nets/net_recu de ce remboursement (voir " + "crowdlending_list_remboursements) — ne pas le compter une seconde " + - "fois. Seuls les modes \"portefeuille\" et \"compte_courant\" " + - "représentent une sortie d'argent réelle et séparée : c'est ce " + - "sous-ensemble qui est déduit de gain_net_depuis_debut sur " + - "crowdlending_get_dashboard. Filtrable par période."), + "fois. Tous les autres modes (\"source\", \"portefeuille\", " + + "\"compte_courant\") représentent chacun une sortie d'argent réelle : " + + "c'est cet ensemble (tout sauf \"remboursement\") qui est déduit de " + + "gain_net_depuis_debut sur crowdlending_get_dashboard. Filtrable par période."), inputSchema: { date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() .describe('Date de début au format YYYY-MM-DD (incluse)'),