Mise en place de la feature de gestion des Frais

This commit is contained in:
ocroguennec committed 2026-09-01 22:16:18 +02:00
1 parent 9ea078302d
commit 0334a63139
27 files changed
+2401 -117

No files matched your search

+61 -11
View File
@@ -183,7 +183,7 @@ function KpiCard({ title, value, badge, refValue, note, onClick, hint }) {
}
/* ── KPI filtrés par année ── */
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements, allRembs, allReinvests, allSimulRembs }) {
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements, allRembs, allReinvests, allSimulRembs, allFraisOps }) {
// Montant total déjà déclaré en perte définitive (tous statuts confondus,
// seuls les dossiers passés en perte_definitive ont un capital_perdu > 0) —
// affiché en complément du KPI "Capital en risque", qui les exclut déjà
@@ -360,8 +360,19 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
interets_nets: acc.interets_nets + (r.interets_nets || 0),
cashback: acc.cashback + (r.cashback || 0),
}), { interets_nets: 0, cashback: 0 });
return totals.interets_nets + totals.cashback + (correctionsTotal || 0);
}, [rawDataGlobal, correctionsTotal]);
// 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).
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;
return sum + (fo.montant || 0);
}, 0);
return totals.interets_nets + totals.cashback + (correctionsTotal || 0) - fraisHorsRembTotal;
}, [rawDataGlobal, correctionsTotal, investissements, allFraisOps]);
// 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,
@@ -369,9 +380,22 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
// n'ont pas de granularité mensuelle côté backend ; elles restent donc dans le
// cumul "avant ce mois", ce qui est sans incidence pratique (montants marginaux).
const gainNetPrevMonthEnd = useMemo(() => {
const deltaCeMois = (thisMonthRow?.interets_nets || 0) + (thisMonthRow?.cashback || 0);
// Même granularité mensuelle que interets_nets/cashback pour les frais hors
// remboursement (portefeuille/compte_courant) : contrairement aux corrections de
// solde (jamais datées finement), frais_operations porte une vraie date_operation —
// on peut donc isoler la part du mois en cours plutôt que de la laisser polluer tout
// le delta M vs M-1 (cf. gainNetTotal ci-dessus).
const thisMonthStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}`;
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.date_operation?.startsWith(thisMonthStr)) return sum;
return sum + (fo.montant || 0);
}, 0);
const deltaCeMois = (thisMonthRow?.interets_nets || 0) + (thisMonthRow?.cashback || 0) - fraisCeMois;
return gainNetTotal - deltaCeMois;
}, [gainNetTotal, thisMonthRow]);
}, [gainNetTotal, thisMonthRow, currentYear, currentMonth, investissements, allFraisOps]);
// ── XIRR Global (réel + attendu) — depuis le début, indépendant du sélecteur
// d'année (même logique que "Gain net depuis le début" ci-dessus). Reprend
@@ -430,6 +454,16 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
flowsNet.push({ amount: rb.net_recu || 0, date: d });
}
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
for (const fo of (allFraisOps || [])) {
if (!ids.has(fo.investissement_id)) continue;
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
const d = fo.date_operation?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsNet.push({ amount: -(fo.montant || 0), date: d });
}
let estime = false;
for (const r of invs) {
const capInv = r.montant_investi + (reinvestCumulParInvGlobal[r.id] || 0);
@@ -445,7 +479,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
flowsBrut.sort(byDate);
flowsNet.sort(byDate);
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
}, [investissements, allRembs, allReinvests, reinvestCumulParInvGlobal, capRembParInvGlobal]);
}, [investissements, allRembs, allReinvests, allFraisOps, reinvestCumulParInvGlobal, capRembParInvGlobal]);
const xirrGlobalAttendu = useMemo(() => {
const cutoff = today();
@@ -472,6 +506,16 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
flowsNet.push({ amount: rb.net_recu || 0, date: d });
}
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
for (const fo of (allFraisOps || [])) {
if (!ids.has(fo.investissement_id)) continue;
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
const d = fo.date_operation?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsNet.push({ amount: -(fo.montant || 0), date: d });
}
const futureSimulParInv = {};
for (const sr of (allSimulRembs || [])) {
if (!ids.has(sr.investissement_id)) continue;
@@ -504,7 +548,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
flowsNet.sort(byDate);
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investissements, allRembs, allReinvests, allSimulRembs, reinvestCumulParInvGlobal, capRembParInvGlobal, pfuRates]);
}, [investissements, allRembs, allReinvests, allFraisOps, allSimulRembs, reinvestCumulParInvGlobal, capRembParInvGlobal, pfuRates]);
// ── Valeur affichée ──
const getValue = (field) => {
@@ -640,7 +684,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, 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)."
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)."
value={fmtEUR(gainNetTotal)}
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
@@ -648,10 +692,11 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
{(() => {
const rendement = netMode ? xirrGlobal.net : xirrGlobal.brut;
const rendementAttendu = netMode ? xirrGlobalAttendu.net : xirrGlobalAttendu.brut;
const fraisExplication = ` Les frais réglés hors remboursement (via le porte-monnaie ou un compte courant) sont eux aussi comptés comme une sortie d'argent dans ce calcul net — en plus des frais déjà déduits à la source ou d'un remboursement, toujours pris en compte.`;
const xirrExplication =
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
`des prêts du portefeuille (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}), ` +
`depuis le début. Chaque flux est pondéré par sa date exacte.\n\n` +
`depuis le début. Chaque flux est pondéré par sa date exacte.` + (netMode ? fraisExplication : '') + `\n\n` +
(xirrGlobal.estime
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé à aujourd'hui — c'est donc une estimation, pas un taux définitif.`
: 'Tous les prêts du portefeuille sont soldés : taux définitif.');
@@ -660,7 +705,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
`que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` +
`date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` +
`"comme racheté aujourd'hui", sans compter les intérêts futurs.\n\n` +
`"comme racheté aujourd'hui", sans compter les intérêts futurs.` + (netMode ? fraisExplication : '') + `\n\n` +
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` +
`(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` +
`valorisé de la même façon que dans le XIRR réel.`;
@@ -736,6 +781,7 @@ export default function Dashboard() {
const [allRembs, setAllRembs] = useState([]);
const [allReinvests, setAllReinvests] = useState([]);
const [allSimulRembs, setAllSimulRembs] = useState([]);
const [allFraisOps, setAllFraisOps] = useState([]);
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
@@ -792,6 +838,10 @@ export default function Dashboard() {
api.get('/remboursements', scopeParams).then(setAllRembs).catch(() => setAllRembs([])),
api.get('/reinvestissements', { scope: 'all' }).then(setAllReinvests).catch(() => setAllReinvests([])),
api.get('/simul/all', scopeParams).then(setAllSimulRembs).catch(() => setAllSimulRembs([])),
// Nécessaire au XIRR net (frais réglés par portefeuille/compte courant, cf. Étape 5) —
// sans investissement_id, ne connaît que l'utilisateur (tous investisseurs), filtré
// ensuite via `ids` au moment du calcul, même pattern que allFraisOps dans Plateformes.jsx.
api.get('/frais-operations').then(setAllFraisOps).catch(() => setAllFraisOps([])),
]);
};
@@ -849,7 +899,7 @@ export default function Dashboard() {
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
allRembs={allRembs} allReinvests={allReinvests} allSimulRembs={allSimulRembs}
allRembs={allRembs} allReinvests={allReinvests} allSimulRembs={allSimulRembs} allFraisOps={allFraisOps}
/>
<YearSelectorKpi />
</div>