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 (