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'; import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); 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'); const historyDir = path.resolve(iconsDir, 'history');
fs.mkdirSync(iconsDir, { recursive: true }); fs.mkdirSync(iconsDir, { recursive: true });
fs.mkdirSync(historyDir, { 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'; import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.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 }); fs.mkdirSync(logosDir, { recursive: true });
/** Sanitise un nom de plateforme pour en faire un nom de fichier safe */ /** 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 * 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 * 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 * total (indépendant de `?annee=`) : intérêts nets + cashback/bonus de
* parrainage ou de plateforme + corrections de solde − frais réglés hors * parrainage ou de plateforme + corrections de solde − tous les frais réellement payés en
* remboursement (porte-monnaie ou compte courant du détenteur, cf. * dehors du passage brut → net d'un remboursement (à la souscription, porte-monnaie ou
* /frais-operations — le détail est dans `gain_net_depuis_debut.frais_hors_remboursement`), * compte courant du détenteur — cf. /frais-operations ; seul le mode "remboursement" est
* sur tout l'historique du portefeuille — reste valable même après un retrait * exclu, déjà déduit des intérêts nets ci-dessus). Détail dans
* complet des plateformes, contrairement à une approche basée sur le * `gain_net_depuis_debut.frais_hors_remboursement`. Sur tout l'historique du portefeuille —
* solde courant. * reste valable même après un retrait complet des plateformes, contrairement à une approche
* basée sur le solde courant.
* tags: [Dashboard] * tags: [Dashboard]
* security: [{ ApiKeyAuth: [] }] * security: [{ ApiKeyAuth: [] }]
* parameters: * parameters:
@@ -123,16 +124,24 @@ router.get('/', (req, res) => {
WHERE ${invCond('investisseur_id')} WHERE ${invCond('investisseur_id')}
`).get(invParam); `).get(invParam);
// ── Frais hors remboursement (Étape 5, 01/09/26 — cf. project_gain_net_depuis_debut.md) : // ── Frais hors remboursement (Étape 5, 02/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 // tout frais réel payé par l'investisseur SAUF ceux retenus directement sur un remboursement
// et séparée des remboursements (contrairement au mode "remboursement", déjà déduit de // (mode "remboursement") est soustrait ici — ceux-là sont déjà déduits de interets_nets
// interets_nets ci-dessus) — ils doivent donc être soustraits du gain net, en miroir exact du // ci-dessus au moment du passage brut → net par la plateforme (ex. Fundora), les soustraire une
// correctif appliqué au KPI "Gain net depuis le début" de l'app interne (Dashboard.jsx). // 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(` const fraisHorsRembRow = db.prepare(`
SELECT COALESCE(SUM(f.montant), 0) AS total SELECT COALESCE(SUM(f.montant), 0) AS total
FROM frais_operations f FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id 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); `).get(invParam);
const gainNetTotal = round2( 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 * 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 * 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 * 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 * fois. Tous les autres modes ("source", "portefeuille", "compte_courant")
* d'argent réelle et séparée (c'est ce sous-ensemble qui est déduit de * représentent chacun une sortie d'argent réelle (c'est cet ensemble — tout sauf
* `gain_net_depuis_debut` sur /dashboard). * "remboursement" — qui est déduit de `gain_net_depuis_debut` sur /dashboard).
* tags: [Frais] * tags: [Frais]
* security: [{ ApiKeyAuth: [] }] * security: [{ ApiKeyAuth: [] }]
* parameters: * parameters:
+13 -5
View File
@@ -344,12 +344,20 @@ export default function Aide() {
séparée à suivre par ailleurs.</li> séparée à suivre par ailleurs.</li>
</ul> </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 }}> <p style={{ marginBottom: 0 }}>
Les frais réglés en <strong style={{ color: 'var(--text)' }}>porte-monnaie</strong> ou Le <strong style={{ color: 'var(--text)' }}>TRI (XIRR) net</strong> d'un investissement suit une règle
<strong style={{ color: 'var(--text)' }}> compte courant</strong> représentent une vraie sortie d'argent, légèrement différente : seuls les frais « Porte-monnaie » et « Compte courant » y sont ajoutés comme sortie
distincte des remboursements : ils sont donc déduits du KPI <strong style={{ color: 'var(--text)' }}>Gain net de trésorerie. Un frais « Déduit du versement lors de la souscription » n'y est pas rajouté séparément car
depuis le début</strong> du tableau de bord (et pris en compte dans le TRI net des investissements il est déjà reflété dans le montant investi (net du frais dès la souscription) qui sert de point de départ
concernés), en plus de leur effet sur le solde du porte-monnaie détaillé ci-dessus. au calcul — l'ajouter une 2ᵉ fois fausserait le TRI.
</p> </p>
</FaqItem> </FaqItem>
+61 -13
View File
@@ -157,17 +157,31 @@ function TrendBadge({ current, prev, invert = false }) {
} }
/* ── Carte KPI individuelle ── */ /* ── Carte KPI individuelle ── */
function KpiCard({ title, value, badge, refValue, note, onClick, hint }) { function KpiCard({ title, value, badge, refValue, note, onClick, hint, infoTooltip }) {
return ( return (
<div <div
className="kpi" className="kpi"
title={hint} title={infoTooltip ? undefined : hint}
onClick={onClick} onClick={onClick}
style={onClick ? { cursor: 'pointer', transition: 'box-shadow 0.15s, opacity 0.15s' } : undefined} 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} 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} 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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{value}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{value}</span>
{badge} {badge}
@@ -355,24 +369,58 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
// indépendant du capital encore investi et du solde porte-monnaie courant : // indépendant du capital encore investi et du solde porte-monnaie courant :
// reste juste même si tout l'argent est retiré des plateformes. // 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. // 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) => ({ const totals = (rawDataGlobal.rembourses ?? []).reduce((acc, r) => ({
interets_nets: acc.interets_nets + (r.interets_nets || 0), interets_nets: acc.interets_nets + (r.interets_nets || 0),
cashback: acc.cashback + (r.cashback || 0), cashback: acc.cashback + (r.cashback || 0),
}), { interets_nets: 0, cashback: 0 }); }), { interets_nets: 0, cashback: 0 });
// Frais réglés en dehors des montants déjà nettés (frais 'source' à la souscription, // Tous les frais réels réglés par l'investisseur SAUF ceux retenus directement sur un
// et frais liés à un remboursement déjà déduits de interets_nets/net_recu) : mode // remboursement (mode_reglement='remboursement') — ceux-là sont déjà déduits de
// 'portefeuille' ou 'compte_courant' — vraie sortie d'argent pour l'investisseur, // interets_nets/net_recu au moment du passage brut → net par la plateforme (ex.
// jusque-là absente de ce total (même trou que celui corrigé sur le XIRR net à // Fundora) : les soustraire ici referait une 2ᵉ diminution du gain, ce qui ne
// l'Étape 5, cf. xirrGlobal plus bas — signalé par Olivier le 01/09/26). // 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 idsGainNet = new Set((investissements || []).map(r => r.id));
const fraisHorsRembTotal = (allFraisOps || []).reduce((sum, fo) => { const fraisHorsRembTotal = (allFraisOps || []).reduce((sum, fo) => {
if (!idsGainNet.has(fo.investissement_id)) return sum; 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); return sum + (fo.montant || 0);
}, 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]); }, [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 — // 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, // 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 idsGainNet = new Set((investissements || []).map(r => r.id));
const fraisCeMois = (allFraisOps || []).reduce((sum, fo) => { const fraisCeMois = (allFraisOps || []).reduce((sum, fo) => {
if (!idsGainNet.has(fo.investissement_id)) return sum; 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; if (!fo.date_operation?.startsWith(thisMonthStr)) return sum;
return sum + (fo.montant || 0); return sum + (fo.montant || 0);
}, 0); }, 0);
@@ -684,7 +732,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
/> />
<KpiCard <KpiCard
title="Gain net depuis le début" 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)} value={fmtEUR(gainNetTotal)}
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />} badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel} 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, " + "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 " + "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 " + "nets + cashback/bonus de parrainage ou de plateforme + corrections de " +
"solde − frais réglés hors remboursement (porte-monnaie ou compte " + "solde − tous les frais réellement payés en dehors du passage brut → net " +
"courant du détenteur ; détail dans gain_net_depuis_debut." + "d'un remboursement (à la souscription, porte-monnaie ou compte courant " +
"frais_hors_remboursement — voir aussi crowdlending_list_frais_operations), " + "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 " + "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, " + "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 " + "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 " + "le remboursement lié (`remboursement_id`), déjà pris en compte dans " +
"les champs interets_nets/net_recu de ce remboursement (voir " + "les champs interets_nets/net_recu de ce remboursement (voir " +
"crowdlending_list_remboursements) — ne pas le compter une seconde " + "crowdlending_list_remboursements) — ne pas le compter une seconde " +
"fois. Seuls les modes \"portefeuille\" et \"compte_courant\" " + "fois. Tous les autres modes (\"source\", \"portefeuille\", " +
"représentent une sortie d'argent réelle et séparée : c'est ce " + "\"compte_courant\") représentent chacun une sortie d'argent réelle : " +
"sous-ensemble qui est déduit de gain_net_depuis_debut sur " + "c'est cet ensemble (tout sauf \"remboursement\") qui est déduit de " +
"crowdlending_get_dashboard. Filtrable par période."), "gain_net_depuis_debut sur crowdlending_get_dashboard. Filtrable par période."),
inputSchema: { inputSchema: {
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de début au format YYYY-MM-DD (incluse)'), .describe('Date de début au format YYYY-MM-DD (incluse)'),