diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index 8f79d16..dec0b2b 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -7,6 +7,7 @@ import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx'; import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js'; +import { xirr } from '../utils/xirr.js'; const emptyForm = { investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '', @@ -72,33 +73,6 @@ const STATUT_META = { cloture: { label: 'Clôturé', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }, }; -/** - * XIRR — taux de rendement interne annualisé sur flux datés. - * cashflows : [{ amount: number, date: string 'YYYY-MM-DD' }] - * le premier flux doit être négatif (investissement initial). - * Retourne le taux annualisé (ex: 0.112 = 11.2%) ou null si non convergent. - */ -function xirr(cashflows) { - if (!cashflows || cashflows.length < 2) return null; - const t0 = new Date(cashflows[0].date).getTime(); - const years = cashflows.map(cf => (new Date(cf.date).getTime() - t0) / (365.25 * 864e5)); - const amounts = cashflows.map(cf => cf.amount); - - const npv = r => amounts.reduce((s, a, i) => s + a / Math.pow(1 + r, years[i]), 0); - const dnpv = r => amounts.reduce((s, a, i) => s - years[i] * a / Math.pow(1 + r, years[i] + 1), 0); - - let rate = 0.1; - for (let i = 0; i < 200; i++) { - const f = npv(rate); - const df = dnpv(rate); - if (Math.abs(df) < 1e-12) break; - const next = rate - f / df; - if (Math.abs(next - rate) < 1e-8) return isFinite(next) ? next : null; - rate = next; - if (rate < -0.999) rate = -0.999; // garde-fou - } - return null; -} export default function InvestissementDetail() { const { id } = useParams(); diff --git a/frontend/src/pages/Plateformes.jsx b/frontend/src/pages/Plateformes.jsx index 24dbf54..efff4ec 100644 --- a/frontend/src/pages/Plateformes.jsx +++ b/frontend/src/pages/Plateformes.jsx @@ -7,7 +7,8 @@ import PageIcon from '../components/PageIcon.jsx'; import EmptyState from '../components/EmptyState.jsx'; import InvChart from '../components/InvChart.jsx'; import InvMensuelTable from '../components/InvMensuelTable.jsx'; -import { fmtEUR, fmtDate, fmtStatut, memberLabel } from '../utils/format.js'; +import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'; +import { xirr } from '../utils/xirr.js'; import { usePagination } from '../hooks/usePagination.js'; import Pagination from '../components/Pagination.jsx'; @@ -597,6 +598,49 @@ export default function Plateformes() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedYear, filteredRows, allRembs, allReinvests, lastRembDateMap]); + /* ── XIRR agrégé (flux datés : investis + réinvestissements + remboursements + valorisation + du capital restant dû pour les prêts non soldés, à la date de coupure) ── */ + const plateformeXirr = useMemo(() => { + const cutoff = selectedYear ? `${selectedYear}-12-31` : today(); + const ids = new Set(chartRows.map(r => r.id)); + const flowsBrut = []; + const flowsNet = []; + for (const r of chartRows) { + if (!r.date_souscription) continue; + flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription }); + flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription }); + } + for (const rv of allReinvests) { + if (!ids.has(rv.investissement_id)) continue; + const d = rv.date_reinvestissement?.slice(0, 10); + if (!d || d > cutoff) continue; + flowsBrut.push({ amount: -(rv.montant || 0), date: d }); + flowsNet.push({ amount: -(rv.montant || 0), date: d }); + } + for (const rb of allRembs) { + if (!ids.has(rb.investissement_id)) continue; + const d = rb.date_remb?.slice(0, 10); + if (!d || d > cutoff) continue; + flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d }); + flowsNet.push({ amount: rb.net_recu || 0, date: d }); + } + let estime = false; + for (const r of chartRows) { + const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); + const capRemb = capRembParInv[r.id] || 0; + const capRestant = Math.max(0, capInv - capRemb); + if (capRestant > 0.01) { + estime = true; + flowsBrut.push({ amount: capRestant, date: cutoff }); + flowsNet.push({ amount: capRestant, date: cutoff }); + } + } + const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0); + flowsBrut.sort(byDate); + flowsNet.sort(byDate); + return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff }; + }, [chartRows, allRembs, allReinvests, selectedYear, reinvestCumulParInv, capRembParInv]); + const netMode = displayMode === 'net'; const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1; @@ -668,7 +712,7 @@ export default function Plateformes() { } {/* ── KPIs ── */} - {!listFocused &&
+ {!listFocused &&
{/* 1 — Capital investi */}
@@ -736,6 +780,50 @@ export default function Plateformes() { {fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
+ + {/* 6 — XIRR */} + {(() => { + const rendement = netMode ? plateformeXirr.net : plateformeXirr.brut; + const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui"; + const xirrExplication = + `XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` + + `des prêts de cette sélection (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` + + `Chaque flux est pondéré par sa date exacte.\n\n` + + (plateformeXirr.estime + ? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé au ${cutoffLabel} — c'est donc une estimation, pas un taux définitif.` + : 'Tous les prêts de cette sélection sont soldés : taux définitif.'); + return ( +
+
+ + XIRR — {netMode ? 'Net' : 'Brut'} + {plateformeXirr.estime && rendement !== null ? (estimé) : ''} + + + + + + +
+
+ = 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }} + > + {rendement !== null + ? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %` + : 'Aucun remboursement'} + +
+
+ Valorisé au {cutoffLabel} +
+
+ ); + })()}
} {/* ── Onglets ── */} diff --git a/frontend/src/utils/xirr.js b/frontend/src/utils/xirr.js new file mode 100644 index 0000000..9ccf4bd --- /dev/null +++ b/frontend/src/utils/xirr.js @@ -0,0 +1,27 @@ +/** + * XIRR — taux de rendement interne annualisé sur flux datés. + * cashflows : [{ amount: number, date: string 'YYYY-MM-DD' }] + * le premier flux doit être négatif (investissement initial). + * Retourne le taux annualisé (ex: 0.112 = 11.2%) ou null si non convergent. + */ +export function xirr(cashflows) { + if (!cashflows || cashflows.length < 2) return null; + const t0 = new Date(cashflows[0].date).getTime(); + const years = cashflows.map(cf => (new Date(cf.date).getTime() - t0) / (365.25 * 864e5)); + const amounts = cashflows.map(cf => cf.amount); + + const npv = r => amounts.reduce((s, a, i) => s + a / Math.pow(1 + r, years[i]), 0); + const dnpv = r => amounts.reduce((s, a, i) => s - years[i] * a / Math.pow(1 + r, years[i] + 1), 0); + + let rate = 0.1; + for (let i = 0; i < 200; i++) { + const f = npv(rate); + const df = dnpv(rate); + if (Math.abs(df) < 1e-12) break; + const next = rate - f / df; + if (Math.abs(next - rate) < 1e-8) return isFinite(next) ? next : null; + rate = next; + if (rate < -0.999) rate = -0.999; // garde-fou + } + return null; +}