Correctif

This commit is contained in:
ocroguennec committed 2026-09-02 08:28:15 +02:00
1 parent 0334a63139
commit 981ddf5830
7 files changed
+115 -42

No files matched your search

+4 -1
View File
@@ -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 });
+4 -1
View File
@@ -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 */
+21 -12
View File
@@ -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(
+3 -3
View File
@@ -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:
+13 -5
View File
@@ -344,12 +344,20 @@ export default function Aide() {
séparée à suivre par ailleurs.</li>
</ul>
<p>
Le KPI <strong style={{ color: 'var(--text)' }}>Gain net depuis le début</strong> du tableau de bord déduit
tous les frais réellement payés, <strong style={{ color: 'var(--text)' }}>sauf</strong> 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.
</p>
<p style={{ marginBottom: 0 }}>
Les frais réglés en <strong style={{ color: 'var(--text)' }}>porte-monnaie</strong> ou
<strong style={{ color: 'var(--text)' }}> compte courant</strong> représentent une vraie sortie d'argent,
distincte des remboursements : ils sont donc déduits du KPI <strong style={{ color: 'var(--text)' }}>Gain net
depuis le début</strong> 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 <strong style={{ color: 'var(--text)' }}>TRI (XIRR) net</strong> 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.
</p>
</FaqItem>
+61 -13
View File
@@ -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 (
<div
className="kpi"
title={hint}
title={infoTooltip ? undefined : hint}
onClick={onClick}
style={onClick ? { cursor: 'pointer', transition: 'box-shadow 0.15s, opacity 0.15s' } : undefined}
onMouseEnter={onClick ? (e) => { 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}
>
<div className="label">{title}</div>
<div className="label" style={infoTooltip ? { display: 'flex', alignItems: 'center', gap: 4 } : undefined}>
<span>{title}</span>
{infoTooltip && (
<span
className="cell-tooltip tooltip-down"
data-tooltip={infoTooltip}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
onClick={(e) => e.stopPropagation()}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{value}</span>
{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
/>
<KpiCard
title="Gain net depuis le début"
hint="Intérêts nets + cashback + bonus parrainage/plateforme + corrections de solde, moins les frais réglés hors remboursement (porte-monnaie ou compte courant du détenteur), cumulés depuis le début — indépendant du capital encore investi ou du solde actuel sur les plateformes (reste juste même en cas de retrait complet)."
infoTooltip={gainNetInfoTooltip}
value={fmtEUR(gainNetTotal)}
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
+9 -7
View File
@@ -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)'),